Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .archon/commands/maintainer-standup.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Fields: `gh_handle`, `since_date`, `all_open_prs`, `review_requested`, `authored
$read-context.output
```

Fields: `direction` (markdown string), `profile` (markdown string), `prior_state` (object or null), `recent_briefs` (array of `{date, content}`).
Fields: `direction` (markdown string), `profile` (markdown string), `prior_state` (object or null), `recent_briefs` (array of `{date, content}`), `today` (`YYYY-MM-DD`), `deadline_3d` (`YYYY-MM-DD`), `reviewed_prs` (map of PR number → `{ reviewed_at, gate_verdict, run_id }` recording past maintainer-review-pr runs — see Phase 2h).

---

Expand Down Expand Up @@ -87,6 +87,18 @@ If any PR raises a "we don't have a stance on this" question that `direction.md`

Items that have been in `prior_state.carry_over` for multiple runs (check `first_seen` dates) are higher priority — surface them prominently and consider escalating their P-level.

### 2h. Review-history awareness (cross-workflow memory)

`read-context.output.reviewed_prs` is a map of PR number → `{ reviewed_at, gate_verdict, run_id }` recording past maintainer-review-pr runs. When listing PRs in any P1-P4 (or Polite-decline) section, append a marker if the PR has an entry:

- **Reviewed (review branch)**: `✓ reviewed Nd ago` — N is days between `read-context.output.today` and `reviewed_at` (`YYYY-MM-DD` slice). Use `0d` for today, `1d` for yesterday, etc.
- **Declined (decline / needs_split branch)**: `✓ declined Nd ago` — same age math, distinct verb so the brief reads correctly when a PR was politely declined rather than reviewed.
- **Unclear**: `✓ triaged Nd ago (unclear)` — for `gate_verdict: 'unclear'` runs.

**Staleness check**: compare `reviewed_at` to the PR's `updatedAt` (in `gh-data.output.all_open_prs`). If `updatedAt > reviewed_at`, append `⚠ contributor pushed since` so the maintainer knows the prior review may need re-running. Only flag when the gap is real and meaningful — same-day commits don't need a warning.

PRs not in `reviewed_prs` get no marker (their absence is itself the signal: "not yet reviewed via the workflow").

---

## Phase 3: GENERATE OUTPUT
Expand Down
180 changes: 180 additions & 0 deletions .archon/scripts/maintainer-standup-backfill-reviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env bun
/**
* One-shot: scan the maintainer's recent GitHub comments and populate
* .archon/maintainer-standup/reviewed-prs.json with `{ reviewed_at,
* gate_verdict, run_id }` entries inferred from comment-body patterns.
*
* Use case: after adopting the cross-workflow memory feature, today's
* morning brief should already mark "✓ reviewed Nd ago" for the PRs that
* were reviewed before the writer node existed. Without backfill, those
* markers only appear for runs going forward.
*
* Inference patterns (from the maintainer-review-pr output):
* - Body contains "## Review Summary" → gate_verdict: review
* - Body contains "isn't a direction we're" → gate_verdict: decline
* OR "Conflicts with `direction.md"
* - Body contains "Could you split this" → gate_verdict: needs_split
* OR "split into <N> focused PRs"
*
* Behavior:
* - Fetches the maintainer's comments authored in the last 7 days.
* - Per PR, takes the LATEST matching comment (newer comments win).
* - Existing entries (from real workflow runs) take precedence over
* backfilled ones — the writer-node record is more authoritative.
* - Idempotent: re-running adds nothing new if no new pattern-matching
* comments have been authored since.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

type GhComment = {
user?: { login?: string };
created_at?: string;
body?: string;
issue_url?: string;
};

type ReviewedEntry = {
reviewed_at: string;
gate_verdict: 'review' | 'decline' | 'needs_split' | 'unclear';
run_id?: string;
source?: 'workflow' | 'backfill';
};

const baseDir = resolve(process.cwd(), '.archon/maintainer-standup');

// ── Read gh handle from profile ──
const profilePath = resolve(baseDir, 'profile.md');
if (!existsSync(profilePath)) {
console.error('No profile.md found — run from repo root, with .archon/maintainer-standup/profile.md present.');
process.exit(1);
}
const ghHandleMatch = readFileSync(profilePath, 'utf8').match(/^gh_handle:\s*(\S+)/m);
if (!ghHandleMatch) {
console.error('No gh_handle in profile.md frontmatter');
process.exit(1);
}
const ghHandle = ghHandleMatch[1];

// ── Resolve owner/repo from the origin remote ──
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
stdio: ['ignore', 'pipe', 'pipe'],
})
.toString()
.trim();
const repoMatch = remote.match(/[:/]([^:/]+)\/([^/]+?)(?:\.git)?$/);
if (!repoMatch) {
console.error(`Could not parse owner/repo from origin remote: ${remote}`);
process.exit(1);
}
const [, owner, repo] = repoMatch;

// ── Fetch issue/PR conversation comments since 7 days ago ──
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const since = sevenDaysAgo.toISOString();

console.log(`Scanning ${ghHandle}'s comments on ${owner}/${repo} since ${since}...`);

// Default maxBuffer is 1MB which 7 days of paginated comments easily exceeds
// in an active repo (1k+ comments → multi-MB JSON). 64MB is generous and
// well below available memory; if the repo grows past that, switch to
// streaming the gh process and parsing line-by-line.
const allComments = JSON.parse(
execFileSync(
'gh',
[
'api',
`repos/${owner}/${repo}/issues/comments?since=${since}&per_page=100`,
'--paginate',
],
{ stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 },
).toString(),
) as GhComment[];

// ── Pattern-match the maintainer's own review/decline comments ──
function inferVerdict(body: string): ReviewedEntry['gate_verdict'] | null {
if (body.includes('## Review Summary')) return 'review';
if (
body.includes("isn't a direction we're") ||
body.includes('Conflicts with `direction.md') ||
body.includes('direction.md §')
)
return 'decline';
if (
body.includes('Could you split this') ||
body.includes('Could you two coordinate') ||
/split into \d+ focused PRs/.test(body)
)
return 'needs_split';
return null;
}

function extractPrNumber(issueUrl: string | undefined): string | null {
if (!issueUrl) return null;
const m = issueUrl.match(/\/(\d+)$/);
return m ? m[1] : null;
}

const inferred: Record<string, ReviewedEntry> = {};
let scanned = 0;
let mineMatching = 0;

for (const c of allComments) {
scanned++;
const author = c.user?.login;
if (!author || author.toLowerCase() !== ghHandle.toLowerCase()) continue;
const body = c.body ?? '';
const verdict = inferVerdict(body);
if (!verdict) continue;
const prNumber = extractPrNumber(c.issue_url);
if (!prNumber) continue;
const createdAt = c.created_at ?? '';
// Latest comment per PR wins (newer reviews supersede older).
if (!inferred[prNumber] || createdAt > inferred[prNumber].reviewed_at) {
inferred[prNumber] = {
reviewed_at: createdAt,
gate_verdict: verdict,
source: 'backfill',
};
}
mineMatching++;
}

console.log(
`Scanned ${scanned} comments. ${mineMatching} authored by ${ghHandle} matched a review/decline pattern. Unique PRs: ${Object.keys(inferred).length}.`,
);

// ── Merge with existing reviewed-prs.json ──
// Existing entries (especially those without source: 'backfill', i.e. written
// by the workflow's record-review node) take precedence — they're more
// authoritative than pattern-matched bodies.
if (!existsSync(baseDir)) mkdirSync(baseDir, { recursive: true });
const outPath = resolve(baseDir, 'reviewed-prs.json');
let existing: Record<string, ReviewedEntry> = {};
if (existsSync(outPath)) {
try {
existing = JSON.parse(readFileSync(outPath, 'utf8'));
} catch {
existing = {};
}
}

let added = 0;
let skipped = 0;
for (const [num, entry] of Object.entries(inferred)) {
if (existing[num]) {
skipped++;
continue;
}
existing[num] = entry;
added++;
}

writeFileSync(outPath, JSON.stringify(existing, null, 2) + '\n');

console.log(
`Backfilled ${added} new entries (skipped ${skipped} that already had workflow-recorded entries). Total tracked: ${Object.keys(existing).length}.`,
);
console.log(`Written to: ${outPath}`);
15 changes: 15 additions & 0 deletions .archon/scripts/maintainer-standup-read-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ const deadlineDate = new Date(todayDate);
deadlineDate.setDate(deadlineDate.getDate() + 3);
const deadline_3d = deadlineDate.toLocaleDateString('sv-SE');

// Cross-workflow memory: which PRs has maintainer-review-pr already triaged?
// Written by maintainer-review-pr's `record-review` node; surfaced here so
// the standup synthesizer can mark "✓ reviewed Nd ago" next to P1-P4 entries
// and flag staleness when the contributor pushes after a prior review.
const reviewedPrsPath = resolve(baseDir, 'reviewed-prs.json');
let reviewedPrs: unknown = {};
if (existsSync(reviewedPrsPath)) {
try {
reviewedPrs = JSON.parse(readFileSync(reviewedPrsPath, 'utf8'));
} catch {
reviewedPrs = {};
}
}

console.log(
JSON.stringify({
direction,
Expand All @@ -63,5 +77,6 @@ console.log(
recent_briefs: recentBriefs,
today,
deadline_3d,
reviewed_prs: reviewedPrs,
}),
);
56 changes: 53 additions & 3 deletions .archon/workflows/maintainer/maintainer-review-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,61 @@ nodes:
when: "$gate.output.verdict == 'unclear'"

# ═══════════════════════════════════════════════════════════════
# PHASE 5: FINAL REPORT (whichever branch ran)
# PHASE 5: RECORD REVIEW IN SHARED STATE
# ═══════════════════════════════════════════════════════════════

- id: report
command: maintainer-review-report
# Append this run's PR number + verdict + timestamp to
# .archon/maintainer-standup/reviewed-prs.json so the morning standup
# brief can mark "✓ reviewed Nd ago" next to PRs that have already
# been triaged. Cross-workflow memory; gitignored, per-maintainer.
#
# Runs deterministically (no AI) after whichever branch fired. Inline
# script for the same reason persist is inline in maintainer-standup:
# JSON is valid JS expression syntax so $gate.output substitutes
# directly without a String.raw template literal. Records the gate
# verdict (review / decline / needs_split / unclear), not the
# synthesis verdict — keeps the contract narrow.
- id: record-review
runtime: bun
timeout: 10000
depends_on: [post-review, post-decline, approve-unclear]
trigger_rule: one_success
script: |
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

const gate = $gate.output;

const baseDir = resolve(process.cwd(), '.archon/maintainer-standup');
if (!existsSync(baseDir)) mkdirSync(baseDir, { recursive: true });

const prPath = resolve(process.cwd(), '$ARTIFACTS_DIR/.pr-number');
const prNumber = readFileSync(prPath, 'utf8').trim();

const reviewedPath = resolve(baseDir, 'reviewed-prs.json');
let reviewed = {};
if (existsSync(reviewedPath)) {
try {
reviewed = JSON.parse(readFileSync(reviewedPath, 'utf8'));
} catch {
reviewed = {};
}
}

reviewed[prNumber] = {
reviewed_at: new Date().toISOString(),
gate_verdict: gate.verdict,
run_id: '$WORKFLOW_ID',
};

writeFileSync(reviewedPath, JSON.stringify(reviewed, null, 2) + '\n');
console.log(`Recorded review of PR #${prNumber} (gate: ${gate.verdict})`);

# ═══════════════════════════════════════════════════════════════
# PHASE 6: FINAL REPORT (whichever branch ran)
# ═══════════════════════════════════════════════════════════════

- id: report
command: maintainer-review-report
depends_on: [record-review]
context: fresh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ e2e-screenshots/
.archon/maintainer-standup/profile.md
.archon/maintainer-standup/state.json
.archon/maintainer-standup/briefs/
.archon/maintainer-standup/reviewed-prs.json

# Agent artifacts (generated, local only)
.agents/
Expand Down
Loading