diff --git a/.archon/commands/maintainer-standup.md b/.archon/commands/maintainer-standup.md index 2e549fb9a1..72567726e9 100644 --- a/.archon/commands/maintainer-standup.md +++ b/.archon/commands/maintainer-standup.md @@ -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). --- @@ -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 diff --git a/.archon/scripts/maintainer-standup-backfill-reviews.ts b/.archon/scripts/maintainer-standup-backfill-reviews.ts new file mode 100644 index 0000000000..bd4fb25685 --- /dev/null +++ b/.archon/scripts/maintainer-standup-backfill-reviews.ts @@ -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 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 = {}; +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 = {}; +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}`); diff --git a/.archon/scripts/maintainer-standup-read-context.ts b/.archon/scripts/maintainer-standup-read-context.ts index 0c4614f053..1d2173ecee 100644 --- a/.archon/scripts/maintainer-standup-read-context.ts +++ b/.archon/scripts/maintainer-standup-read-context.ts @@ -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, @@ -63,5 +77,6 @@ console.log( recent_briefs: recentBriefs, today, deadline_3d, + reviewed_prs: reviewedPrs, }), ); diff --git a/.archon/workflows/maintainer/maintainer-review-pr.yaml b/.archon/workflows/maintainer/maintainer-review-pr.yaml index d436118f95..7cacad57b6 100644 --- a/.archon/workflows/maintainer/maintainer-review-pr.yaml +++ b/.archon/workflows/maintainer/maintainer-review-pr.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 1f8415a4f8..133ca539b7 100644 --- a/.gitignore +++ b/.gitignore @@ -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/