@iceinvein/agent-skills 0.23.9 → 0.23.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +2 -2
  3. package/skills/magpie/SKILL.md +21 -20
  4. package/skills/magpie/bin/magpie.ts +63 -2
  5. package/skills/magpie/evals/quality/README.md +74 -1
  6. package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
  7. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
  8. package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
  9. package/skills/magpie/evals/quality/replay-full.ts +6 -2
  10. package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
  11. package/skills/magpie/fixtures/fake-gh.sh +49 -2
  12. package/skills/magpie/references/critic.md +20 -4
  13. package/skills/magpie/references/proof.md +102 -0
  14. package/skills/magpie/references/scout.md +6 -1
  15. package/skills/magpie/references/specialists.md +15 -9
  16. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
  17. package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
  18. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
  19. package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
  20. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
  22. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
  23. package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
  24. package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
  25. package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
  26. package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
  27. package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
  28. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
  29. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
  30. package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
  31. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
  32. package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
  33. package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
  34. package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
  35. package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
  36. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
  37. package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
  38. package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
  39. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
  40. package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
  41. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
  42. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
  43. package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
  44. package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
  45. package/skills/magpie/scripts/critic-cmd.ts +2 -0
  46. package/skills/magpie/scripts/critic.ts +4 -0
  47. package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
  48. package/skills/magpie/scripts/evidence-filter.ts +10 -0
  49. package/skills/magpie/scripts/git-diff.ts +50 -19
  50. package/skills/magpie/scripts/handoff-cmd.ts +42 -3
  51. package/skills/magpie/scripts/history.ts +143 -0
  52. package/skills/magpie/scripts/impact-cmd.ts +202 -0
  53. package/skills/magpie/scripts/impact-render.ts +158 -0
  54. package/skills/magpie/scripts/impact.ts +622 -0
  55. package/skills/magpie/scripts/incremental.ts +44 -5
  56. package/skills/magpie/scripts/labels-cmd.ts +7 -3
  57. package/skills/magpie/scripts/post-cmd.ts +46 -0
  58. package/skills/magpie/scripts/pr-activity.ts +243 -0
  59. package/skills/magpie/scripts/preview-cmd.ts +3 -0
  60. package/skills/magpie/scripts/previous-findings.ts +181 -0
  61. package/skills/magpie/scripts/proof-cmd.ts +226 -0
  62. package/skills/magpie/scripts/proof.ts +150 -0
  63. package/skills/magpie/scripts/refresh.ts +11 -0
  64. package/skills/magpie/scripts/render-annotation.ts +17 -0
  65. package/skills/magpie/scripts/render-cmd.ts +12 -0
  66. package/skills/magpie/scripts/render-findings.ts +37 -1
  67. package/skills/magpie/scripts/render-progress.ts +3 -0
  68. package/skills/magpie/scripts/setup-cmd.ts +142 -11
  69. package/skills/magpie/scripts/shard-coverage.ts +53 -0
  70. package/skills/magpie/scripts/status-cmd.ts +2 -0
  71. package/skills/magpie/scripts/types.ts +39 -0
  72. package/skills/magpie/skill.json +1 -1
  73. package/skills/magpie/templates/styles.css +30 -0
  74. package/skills/sluice/scripts/status.sh +32 -13
  75. package/skills/sluice/skill.json +1 -1
@@ -8,9 +8,79 @@ export type RunCleanupInput = {
8
8
  runDir: string
9
9
  repoPath: string
10
10
  gitBin: string
11
+ ghBin: string
11
12
  killGraceMs?: number
12
13
  }
13
14
 
15
+ async function run(
16
+ cmd: string[],
17
+ cwd: string,
18
+ ): Promise<{ exit: number; stdout: string; stderr: string }> {
19
+ const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
20
+ const [exit, stdout, stderr] = await Promise.all([
21
+ proc.exited,
22
+ new Response(proc.stdout).text(),
23
+ new Response(proc.stderr).text(),
24
+ ])
25
+ return { exit, stdout, stderr }
26
+ }
27
+
28
+ /**
29
+ * Setup fetches a PR head into `refs/magpie/pr-<n>` when the commit is not local,
30
+ * and the ref keeps that head reachable for the next re-review's changed-files
31
+ * list. Once the PR is closed or merged nothing will re-review it.
32
+ */
33
+ export type PruneResult = { deleted: string[]; failed: Array<{ ref: string; error: string }> }
34
+
35
+ /** The decision for one ref: true to delete. Throws when its PR state cannot be known. */
36
+ async function isClosedPr(ghBin: string, repoPath: string, n: string): Promise<boolean> {
37
+ const view = await run([ghBin, 'pr', 'view', n, '--json', 'state'], repoPath)
38
+ if (view.exit !== 0) throw new Error(`gh pr view ${n} exit ${view.exit}: ${view.stderr.trim()}`)
39
+ let state: unknown
40
+ try {
41
+ state = (JSON.parse(view.stdout) as { state?: unknown }).state
42
+ } catch (err) {
43
+ throw new Error(`gh pr view ${n}: ${err instanceof Error ? err.message : String(err)}`)
44
+ }
45
+ if (state === 'OPEN') return false
46
+ if (state === 'CLOSED' || state === 'MERGED') return true
47
+ throw new Error(`gh pr view ${n}: unexpected state ${JSON.stringify(state)}`)
48
+ }
49
+
50
+ /**
51
+ * Setup fetches a PR head into `refs/magpie/pr-<n>` when the commit is not local,
52
+ * and the ref keeps that head reachable for the next re-review's changed-files
53
+ * list. Once the PR is closed or merged nothing will re-review it. A ref whose
54
+ * PR cannot be checked is kept and reported, without holding up the others.
55
+ */
56
+ export async function pruneClosedPrRefs(input: {
57
+ gitBin: string
58
+ ghBin: string
59
+ repoPath: string
60
+ }): Promise<PruneResult> {
61
+ const list = await run(
62
+ [input.gitBin, 'for-each-ref', '--format=%(refname)', 'refs/magpie/'],
63
+ input.repoPath,
64
+ )
65
+ if (list.exit !== 0) throw new Error(`git for-each-ref exit ${list.exit}: ${list.stderr.trim()}`)
66
+ const result: PruneResult = { deleted: [], failed: [] }
67
+ for (const ref of list.stdout.split('\n').filter(Boolean)) {
68
+ const match = ref.match(/^refs\/magpie\/pr-(\d+)$/)
69
+ if (!match) continue
70
+ try {
71
+ if (!(await isClosedPr(input.ghBin, input.repoPath, match[1] as string))) continue
72
+ const del = await run([input.gitBin, 'update-ref', '-d', ref], input.repoPath)
73
+ if (del.exit !== 0) {
74
+ throw new Error(`git update-ref -d ${ref} exit ${del.exit}: ${del.stderr.trim()}`)
75
+ }
76
+ result.deleted.push(ref)
77
+ } catch (err) {
78
+ result.failed.push({ ref, error: err instanceof Error ? err.message : String(err) })
79
+ }
80
+ }
81
+ return result
82
+ }
83
+
14
84
  export type KillOutcome =
15
85
  | 'no-server-info'
16
86
  | 'no-pid'
@@ -95,6 +165,21 @@ export async function runCleanup(input: RunCleanupInput): Promise<number> {
95
165
  await writeLabels(input.runDir)
96
166
  }
97
167
 
168
+ // Pruning is housekeeping for the repo, so a failure warns rather than leaving the run unarchived.
169
+ let pruneError: string | null = null
170
+ if (mode === 'pr') {
171
+ try {
172
+ const pruned = await pruneClosedPrRefs(input)
173
+ await appendFile(
174
+ join(input.runDir, 'log.jsonl'),
175
+ `${JSON.stringify({ stage: 'cleanup', status: 'prune-refs', deleted: pruned.deleted, failed: pruned.failed.map((f) => f.ref), ts: Date.now() })}\n`,
176
+ )
177
+ if (pruned.failed.length > 0) pruneError = pruned.failed.map((f) => f.error).join('; ')
178
+ } catch (err) {
179
+ pruneError = err instanceof Error ? err.message : String(err)
180
+ }
181
+ }
182
+
98
183
  const target = `${input.runDir}.archived-${Date.now()}`
99
184
  await rename(input.runDir, target)
100
185
  process.stdout.write(`archived to ${target}\n`)
@@ -102,6 +187,9 @@ export async function runCleanup(input: RunCleanupInput): Promise<number> {
102
187
  if (archivedId && mode !== 'local') {
103
188
  process.stdout.write(`view later: magpie open ${archivedId}\n`)
104
189
  }
190
+ if (pruneError !== null) {
191
+ process.stderr.write(`magpie: warning: could not prune refs/magpie: ${pruneError}\n`)
192
+ }
105
193
  if (kill.outcome === 'failed') {
106
194
  process.stderr.write(
107
195
  `magpie: warning: server process pid=${kill.pid} survived cleanup; kill it manually\n`,
@@ -7,6 +7,7 @@ import {
7
7
  DEFAULT_BATCH_SIZE,
8
8
  parseCriticVerdicts,
9
9
  } from './critic.ts'
10
+ import { readHistory, renderAlreadyRaised } from './history.ts'
10
11
  import { reviewRoot } from './target.ts'
11
12
  import { parseBrief, parseFinding, type ReviewFinding, type ReviewRule } from './types.ts'
12
13
 
@@ -85,6 +86,7 @@ export async function runCriticPrompt(
85
86
  candidates: await readCandidates(runDir),
86
87
  mergeCandidates: await readMergeCandidates(runDir),
87
88
  reviewRules: await readReviewRules(runDir),
89
+ alreadyRaised: renderAlreadyRaised(await readHistory(runDir)),
88
90
  worktree: await reviewRoot(runDir),
89
91
  diffPath: join(runDir, 'diff.patch'),
90
92
  runDir,
@@ -19,6 +19,7 @@ export const CRITIC_PLACEHOLDERS = [
19
19
  '<<CANDIDATES>>',
20
20
  '<<MERGE_CANDIDATES>>',
21
21
  '<<REVIEW_RULES>>',
22
+ '<<ALREADY_RAISED>>',
22
23
  '<<WORKTREE>>',
23
24
  '<<DIFF_PATH>>',
24
25
  '<<OUTPUT_PATH>>',
@@ -237,6 +238,8 @@ export function buildCriticPrompts(input: {
237
238
  candidates: ReviewFinding[]
238
239
  mergeCandidates: string[][]
239
240
  reviewRules: ReviewRule[]
241
+ /** Rendered by `renderAlreadyRaised`, `(none)` when there is nothing. */
242
+ alreadyRaised: string
240
243
  worktree: string
241
244
  diffPath: string
242
245
  runDir: string
@@ -258,6 +261,7 @@ export function buildCriticPrompts(input: {
258
261
  '<<CANDIDATES>>': renderCandidates(batch),
259
262
  '<<MERGE_CANDIDATES>>': JSON.stringify(groups, null, 2),
260
263
  '<<REVIEW_RULES>>': renderRules(input.reviewRules),
264
+ '<<ALREADY_RAISED>>': input.alreadyRaised,
261
265
  '<<WORKTREE>>': input.worktree,
262
266
  '<<DIFF_PATH>>': input.diffPath,
263
267
  '<<OUTPUT_PATH>>': outputPath,
@@ -6,6 +6,7 @@ import { deduplicateFindings, findMergeCandidates } from './dedupe.ts'
6
6
  import { verifyEvidence } from './evidence-filter.ts'
7
7
  import { namespaceId, parseFindingsFilename } from './findings-files.ts'
8
8
  import { DEFAULT_THRESHOLD, scoreRisk } from './score.ts'
9
+ import { readShardCoverage } from './shard-coverage.ts'
9
10
  import { readTarget } from './target.ts'
10
11
  import { FOCUS_IDS, parseFinding, type ReviewFinding } from './types.ts'
11
12
 
@@ -53,9 +54,12 @@ async function reconcileCoverage(runDir: string, present: Set<string>): Promise<
53
54
  // A single-shard manifest means stage 4 took the unsharded path, so its
54
55
  // specialists write `<focus>.json` with no shard suffix.
55
56
  const sharded = shards.length > 1
57
+ // A shard skipped at the stage 4 gate has no findings by design, and the report says so.
58
+ const skipped = new Set((await readShardCoverage(runDir))?.skipped.map((s) => s.id))
56
59
  const expected: string[] = []
57
60
  for (const [i, shard] of shards.entries()) {
58
61
  const id = typeof shard?.id === 'number' ? shard.id : i + 1
62
+ if (skipped.has(id)) continue
59
63
  for (const focus of SPECIALIST_FOCUS_IDS) {
60
64
  expected.push(sharded ? `${focus}.shard-${id}.json` : `${focus}.json`)
61
65
  }
@@ -72,6 +72,16 @@ function locateEvidence(fileLines: string[], snippet: string[], line: number): n
72
72
  return matches.length === 1 ? (matches[0] ?? null) : null
73
73
  }
74
74
 
75
+ /** True when `evidence` appears anywhere in `fileText`, whitespace normalised as above. */
76
+ export function containsEvidence(fileText: string, evidence: string): boolean {
77
+ const lines = fileText.split('\n')
78
+ const snippet = evidence.trim().split('\n').map(normalise)
79
+ for (let start = 1; start <= lines.length; start++) {
80
+ if (matchesAt(lines, snippet, start)) return true
81
+ }
82
+ return false
83
+ }
84
+
75
85
  export async function verifyEvidence(
76
86
  findings: ReviewFinding[],
77
87
  worktreePath: string,
@@ -14,8 +14,14 @@ async function git(
14
14
  gitBin: string,
15
15
  cwd: string,
16
16
  args: string[],
17
+ env?: Record<string, string>,
17
18
  ): Promise<{ exit: number; stdout: string; stderr: string }> {
18
- const proc = Bun.spawn([gitBin, ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
19
+ const proc = Bun.spawn([gitBin, ...args], {
20
+ cwd,
21
+ stdout: 'pipe',
22
+ stderr: 'pipe',
23
+ ...(env ? { env: { ...process.env, ...env } } : {}),
24
+ })
19
25
  const stdout = await new Response(proc.stdout).text()
20
26
  const stderr = await new Response(proc.stderr).text()
21
27
  const exit = await proc.exited
@@ -49,31 +55,47 @@ function fetchDiagnostic(result: { exit: number; stderr: string }): string {
49
55
  * `gh pr diff`'s three-dot semantics: the PR's own changes, not every commit
50
56
  * the base branch gained since the branch point.
51
57
  */
52
- export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResult> {
53
- const { gitBin, repoPath, prNumber, baseRefName, headRefOid } = input
58
+ export type PrHeadInput = {
59
+ gitBin: string
60
+ repoPath: string
61
+ prNumber: number
62
+ headRefOid: string
63
+ }
64
+
65
+ /**
66
+ * The PR's head commit in the local clone, exactly `headRefOid`. The local
67
+ * branch named `headRefName` can be behind, ahead with unpushed commits, or
68
+ * absent on a fork PR, so it is never what gets reviewed.
69
+ */
70
+ export async function resolvePrHead(
71
+ input: PrHeadInput,
72
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
73
+ const { gitBin, repoPath, prNumber, headRefOid } = input
54
74
  const prRef = `refs/magpie/pr-${prNumber}`
55
75
 
76
+ // Present, the SHA is the PR head by definition, so there is nothing to fetch
77
+ // and a stale ref from an earlier run does not matter.
78
+ const local = await resolveCommit(gitBin, repoPath, headRefOid)
79
+ if (local) return { ok: true, sha: local }
80
+
56
81
  // Best effort. `pull/<n>/head` is a GitHub convention and resolves fork PRs
57
- // that `headRefName` cannot, but a repo with no `origin`, an offline run, or
58
- // a non-GitHub remote must still work when the commits are already local.
59
- // Each fetch's own result is kept (not discarded) so, if resolution below
60
- // still fails, the error can say why the fetch didn't help.
61
- const headFetch = await git(gitBin, repoPath, [
62
- 'fetch',
63
- 'origin',
64
- `pull/${prNumber}/head:${prRef}`,
65
- ])
66
- const baseFetch = await git(gitBin, repoPath, ['fetch', 'origin', baseRefName])
82
+ // that `headRefName` cannot. The fetch's own result is kept (not discarded)
83
+ // so, if resolution below still fails, the error can say why the fetch didn't
84
+ // help. The `+` lets the ref follow a force-push; without it git keeps the head
85
+ // an earlier run fetched. No terminal prompt, so missing credentials fail
86
+ // instead of waiting on a tty nobody is watching.
87
+ const headFetch = await git(
88
+ gitBin,
89
+ repoPath,
90
+ ['fetch', 'origin', `+pull/${prNumber}/head:${prRef}`],
91
+ { GIT_TERMINAL_PROMPT: '0' },
92
+ )
67
93
 
68
- let head: string | null = null
69
- for (const ref of [prRef, headRefOid]) {
70
- head = await resolveCommit(gitBin, repoPath, ref)
71
- if (head) break
72
- }
94
+ const head = await resolveCommit(gitBin, repoPath, prRef)
73
95
  if (!head) {
74
96
  return {
75
97
  ok: false,
76
- error: `cannot resolve PR head ${headRefOid} locally (tried ${prRef} and the SHA); fetch the PR branch and retry${fetchDiagnostic(headFetch)}`,
98
+ error: `cannot resolve PR head ${headRefOid} locally (tried the SHA and ${prRef}); run \`git fetch <remote> pull/${prNumber}/head\` from the remote that holds the PR, then retry${fetchDiagnostic(headFetch)}`,
77
99
  }
78
100
  }
79
101
  // Reviewing a stale local ref and reporting it as a review of the PR is worse
@@ -84,6 +106,15 @@ export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResul
84
106
  error: `local PR head ${head} does not match the PR head ${headRefOid}; the local ref is stale`,
85
107
  }
86
108
  }
109
+ return { ok: true, sha: head }
110
+ }
111
+
112
+ export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResult> {
113
+ const { gitBin, repoPath, prNumber, baseRefName, headRefOid } = input
114
+ const resolved = await resolvePrHead({ gitBin, repoPath, prNumber, headRefOid })
115
+ if (!resolved.ok) return resolved
116
+ const head = resolved.sha
117
+ const baseFetch = await git(gitBin, repoPath, ['fetch', 'origin', baseRefName])
87
118
 
88
119
  let base: string | null = null
89
120
  for (const ref of [`origin/${baseRefName}`, baseRefName]) {
@@ -3,7 +3,10 @@ import { appendFile, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { formatFindingDescriptionMarkdown } from './finding-description.ts'
5
5
  import { diffSha256, localBranchDiff } from './local-diff.ts'
6
+ import type { ProofDropped } from './proof.ts'
7
+ import { readProofDropped } from './proof-cmd.ts'
6
8
  import { rankActionable } from './render-issues-list.ts'
9
+ import { describeUnreviewed, readShardCoverage, type ShardCoverage } from './shard-coverage.ts'
7
10
  import { readTarget } from './target.ts'
8
11
  import { isSuggestion, parseFinding, type ReviewFinding, type Suggestion } from './types.ts'
9
12
 
@@ -16,6 +19,18 @@ export type FormatHandoffInput = {
16
19
  target: string
17
20
  findings: ReviewFinding[]
18
21
  stale: boolean
22
+ /** Shards skipped at the stage 4 gate; absent when every shard was reviewed. */
23
+ unreviewed?: ShardCoverage
24
+ /** Findings the proof stage removed because a passing test showed the code is right. */
25
+ disproved?: ProofDropped[]
26
+ }
27
+
28
+ function formatDisproved(disproved: ProofDropped[]): string {
29
+ const lines = disproved.map((d) => {
30
+ const anchor = d.line === null ? d.file : `${d.file}:${d.line}`
31
+ return `- ${anchor}: ${d.title} (${d.reason}; \`${d.repro.command}\`)`
32
+ })
33
+ return ['Dropped because a passing test disproved them:', ...lines].join('\n')
19
34
  }
20
35
 
21
36
  /** A fence longer than any backtick run in the body, so the body cannot close it early. */
@@ -31,22 +46,37 @@ function formatSuggestion(s: Suggestion): string {
31
46
  return `Suggested replacement for ${lines}:\n${fence}\n${s.body}\n${fence}`
32
47
  }
33
48
 
49
+ function formatProof(f: ReviewFinding): string | null {
50
+ if (f.proof?.status === 'not-proven') return `Not proven: ${f.proof.reason}`
51
+ if (f.proof?.status !== 'reproduced') return null
52
+ const { command, source } = f.proof.repro
53
+ const fence = fenceFor(source)
54
+ return `Reproduced with \`${command}\`:\n${fence}\n${source}\n${fence}`
55
+ }
56
+
34
57
  function formatItem(n: number, f: ReviewFinding): string {
35
58
  const anchor = f.line === null ? f.file : `${f.file}:${f.line}`
36
59
  const parts = [`${n}. [${f.risk.action}] ${anchor}: ${f.title}`]
37
60
  const body = formatFindingDescriptionMarkdown(f.description)
38
61
  if (body) parts.push(body)
39
62
  if (f.suggestion) parts.push(formatSuggestion(f.suggestion))
63
+ const proof = formatProof(f)
64
+ if (proof) parts.push(proof)
40
65
  return parts.join('\n\n')
41
66
  }
42
67
 
43
68
  export function formatHandoff(input: FormatHandoffInput): string {
44
- const { target, findings, stale } = input
69
+ const { target, findings, stale, unreviewed, disproved = [] } = input
45
70
  const header = [`# magpie local review: ${target}`]
46
71
  if (stale) {
47
72
  header.push('Warning: the tree changed since this review began; line anchors may be off.')
48
73
  }
49
- if (findings.length === 0) return `${header.join('\n')}\n\nNo findings.\n`
74
+ if (unreviewed) {
75
+ const files = unreviewed.skipped.flatMap((s) => s.files.map((f) => `- ${f}`))
76
+ header.push(`Warning: ${describeUnreviewed(unreviewed)}:`, ...files)
77
+ }
78
+ const tail = disproved.length > 0 ? [formatDisproved(disproved)] : []
79
+ if (findings.length === 0) return `${[header.join('\n'), 'No findings.', ...tail].join('\n\n')}\n`
50
80
 
51
81
  const mustFix = findings.filter((f) => f.risk.action === 'must-fix').length
52
82
  const shouldFix = findings.filter((f) => f.risk.action === 'should-fix').length
@@ -60,6 +90,7 @@ export function formatHandoff(input: FormatHandoffInput): string {
60
90
  `${mustFix} must-fix, ${shouldFix} should-fix, ${optional} optional`,
61
91
  ...ordered.map((f, i) => formatItem(i + 1, f)),
62
92
  'Address every must-fix and should-fix, or say why not. The rest are optional.',
93
+ ...tail,
63
94
  ]
64
95
  return `${sections.join('\n\n')}\n`
65
96
  }
@@ -124,8 +155,16 @@ export async function runHandoff(runDir: string, opts: { gitBin?: string } = {})
124
155
  const stale = recorded === null || currentSha === null || currentSha !== recorded
125
156
 
126
157
  const subject = meta.branch === null ? 'detached HEAD' : `local branch ${meta.branch}`
158
+ const unreviewed = await readShardCoverage(runDir)
159
+ const disproved = await readProofDropped(runDir)
127
160
  process.stdout.write(
128
- formatHandoff({ target: `${subject} against ${meta.baseRef}`, findings, stale }),
161
+ formatHandoff({
162
+ target: `${subject} against ${meta.baseRef}`,
163
+ findings,
164
+ stale,
165
+ ...(unreviewed ? { unreviewed } : {}),
166
+ disproved,
167
+ }),
129
168
  )
130
169
  await logLine(runDir, { stage: 'handoff', status: 'done', findings: findings.length })
131
170
  return 0
@@ -0,0 +1,143 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { PrActivity } from './pr-activity.ts'
4
+ import type { PreviousFindings } from './previous-findings.ts'
5
+
6
+ export type HistoryIncremental = {
7
+ previousSha: string
8
+ changedSincePrevious: string[] | null
9
+ }
10
+
11
+ export type History = {
12
+ activity: PrActivity | null
13
+ previous: PreviousFindings | null
14
+ incremental: HistoryIncremental | null
15
+ }
16
+
17
+ const MAX_BODY = 280
18
+
19
+ // Conclusions that mean the check found something; CANCELLED and SKIPPED say nothing about the code.
20
+ const FAILING = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
21
+
22
+ function flatten(body: string): string {
23
+ const text = body
24
+ .replace(/<!--[\s\S]*?-->/g, '')
25
+ .replace(/\s+/g, ' ')
26
+ .trim()
27
+ return text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}…` : text
28
+ }
29
+
30
+ function location(file: string, line: number | null): string {
31
+ return line === null ? file : `${file}:${line}`
32
+ }
33
+
34
+ function section(heading: string, lines: string[]): string[] {
35
+ return lines.length === 0 ? [] : [`${heading}:`, ...lines]
36
+ }
37
+
38
+ function joinSections(sections: string[][]): string {
39
+ return sections
40
+ .filter((s) => s.length > 0)
41
+ .map((s) => s.join('\n'))
42
+ .join('\n\n')
43
+ }
44
+
45
+ // The account GitHub Actions posts as: coverage tables, deploy previews and other
46
+ // CI output, which reaches the review as checks. Review bots post as their own
47
+ // apps, and what they raise still counts. gh prints the login without `[bot]`,
48
+ // the REST API with it.
49
+ const CI_AUTHORS = new Set(['github-actions', 'github-actions[bot]'])
50
+
51
+ function raisedSections(input: Pick<History, 'activity' | 'previous'>): string[][] {
52
+ const threads = (input.activity?.threads ?? [])
53
+ .filter((t) => !t.resolved)
54
+ .map((t) => {
55
+ const who = t.magpie ? 'magpie, earlier run' : `${t.author}, ${t.replies} replies`
56
+ const outdated = t.outdated ? ' [outdated]' : ''
57
+ return `- ${location(t.file, t.line)} (${who})${outdated}: ${flatten(t.body)}`
58
+ })
59
+ const comments = (input.activity?.comments ?? [])
60
+ .filter((c) => !CI_AUTHORS.has(c.author))
61
+ .map((c) => `- ${c.magpie ? 'magpie, earlier run' : c.author}: ${flatten(c.body)}`)
62
+ const dismissed = (input.previous?.findings ?? []).flatMap((f) =>
63
+ f.label === 'dismissed' ? [`- ${location(f.file, f.line)} [${f.reason}]: ${f.title}`] : [],
64
+ )
65
+ return [
66
+ section('Open review threads', threads),
67
+ section('PR comments', comments),
68
+ section('Dismissed by the reviewer in an earlier magpie run', dismissed),
69
+ ]
70
+ }
71
+
72
+ /** What the critic weighs a candidate against for the "already raised" drop. */
73
+ export function renderAlreadyRaised(input: Pick<History, 'activity' | 'previous'>): string {
74
+ return joinSections(raisedSections(input)) || '(none)'
75
+ }
76
+
77
+ /** The specialists' "Already on this PR" prompt part; empty when there is nothing to say. */
78
+ export function renderHistory(history: History): string {
79
+ const checks = (history.activity?.checks ?? [])
80
+ .filter((c) => FAILING.has(c.conclusion))
81
+ .map((c) => `- ${c.name}: ${c.conclusion}${c.url ? ` (${c.url})` : ''}`)
82
+ const changed = history.incremental?.changedSincePrevious ?? []
83
+ const sinceHeading = history.incremental
84
+ ? `Files changed since the last review at ${history.incremental.previousSha.slice(0, 7)} (the whole diff is still your scope)`
85
+ : ''
86
+ const raised = joinSections(raisedSections(history))
87
+ const leads = joinSections([
88
+ section('Failing CI checks (a lead to investigate, not a finding in itself)', checks),
89
+ section(
90
+ sinceHeading,
91
+ changed.map((f) => `- ${f}`),
92
+ ),
93
+ ])
94
+ if (raised === '' && leads === '') return ''
95
+ const parts = ['## Already on this PR']
96
+ if (raised !== '') {
97
+ parts.push(
98
+ 'The threads, comments and dismissals below were raised before this review. Do not report a finding that repeats one of them: the critic drops repeats. Report it again only when you have something material it lacks, and say what that is.',
99
+ raised,
100
+ )
101
+ }
102
+ if (leads !== '') {
103
+ parts.push('Leads, not repeats: a finding that explains one of these is wanted.', leads)
104
+ }
105
+ return parts.join('\n\n')
106
+ }
107
+
108
+ async function readSidecar<T>(runDir: string, name: string): Promise<T | null> {
109
+ const path = join(runDir, name)
110
+ let text: string
111
+ try {
112
+ text = await readFile(path, 'utf8')
113
+ } catch (err) {
114
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return null
115
+ throw err
116
+ }
117
+ try {
118
+ return JSON.parse(text) as T
119
+ } catch (err) {
120
+ throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`)
121
+ }
122
+ }
123
+
124
+ /** Each sidecar is optional: local runs, first runs and runs from older versions lack them. */
125
+ export async function readHistory(runDir: string): Promise<History> {
126
+ const incremental = await readSidecar<{
127
+ previousSha?: unknown
128
+ changedSincePrevious?: unknown
129
+ }>(runDir, 'incremental.json')
130
+ return {
131
+ activity: await readSidecar<PrActivity>(runDir, 'pr-activity.json'),
132
+ previous: await readSidecar<PreviousFindings>(runDir, 'previous-findings.json'),
133
+ incremental:
134
+ incremental && typeof incremental.previousSha === 'string'
135
+ ? {
136
+ previousSha: incremental.previousSha,
137
+ changedSincePrevious: Array.isArray(incremental.changedSincePrevious)
138
+ ? (incremental.changedSincePrevious as string[])
139
+ : null,
140
+ }
141
+ : null,
142
+ }
143
+ }