@iceinvein/agent-skills 0.22.0 → 0.23.1

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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +8 -1
  4. package/skills/bug-hunter/evals/README.md +6 -1
  5. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
  6. package/skills/bug-hunter/evals/rules.json +8 -0
  7. package/skills/bug-hunter/skill.json +1 -1
  8. package/skills/index.json +6 -6
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +41 -15
  11. package/skills/magpie/bin/magpie.ts +83 -7
  12. package/skills/magpie/evals/README.md +7 -4
  13. package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
  14. package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
  15. package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
  16. package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
  17. package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
  18. package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
  19. package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
  20. package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
  21. package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
  22. package/skills/magpie/evals/quality/README.md +73 -2
  23. package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
  24. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
  25. package/skills/magpie/evals/quality/corpus.ts +4 -1
  26. package/skills/magpie/evals/quality/recall.ts +249 -0
  27. package/skills/magpie/evals/quality/replay-critic.ts +3 -0
  28. package/skills/magpie/evals/quality/replay-full.ts +168 -6
  29. package/skills/magpie/evals/rules.json +9 -1
  30. package/skills/magpie/references/checks.md +78 -0
  31. package/skills/magpie/references/peer-review.md +7 -0
  32. package/skills/magpie/references/scout.md +12 -5
  33. package/skills/magpie/references/specialists.md +10 -8
  34. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
  35. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
  36. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
  37. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
  38. package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
  39. package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
  40. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
  41. package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
  42. package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
  43. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
  44. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
  45. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
  46. package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
  47. package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
  48. package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
  49. package/skills/magpie/scripts/critic-cmd.ts +2 -1
  50. package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
  51. package/skills/magpie/scripts/handoff-cmd.ts +132 -0
  52. package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
  53. package/skills/magpie/scripts/local-diff.ts +228 -0
  54. package/skills/magpie/scripts/post-cmd.ts +1 -0
  55. package/skills/magpie/scripts/render-annotation.ts +1 -0
  56. package/skills/magpie/scripts/resolve-run.ts +17 -3
  57. package/skills/magpie/scripts/server.ts +9 -1
  58. package/skills/magpie/scripts/setup-cmd.ts +241 -49
  59. package/skills/magpie/scripts/status-cmd.ts +22 -6
  60. package/skills/magpie/scripts/target.ts +56 -0
  61. package/skills/magpie/scripts/types.ts +1 -0
  62. package/skills/magpie/skill.json +2 -2
  63. package/skills/scope-estimate/SKILL.md +1 -1
  64. package/skills/scope-estimate/skill.json +16 -5
  65. package/skills/sluice/SKILL.md +6 -2
  66. package/skills/sluice/evals/README.md +10 -2
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  72. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  73. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  79. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  80. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  81. package/skills/sluice/evals/rules.json +11 -0
  82. package/skills/sluice/references/review.md +6 -4
  83. package/skills/sluice/references/test-first.md +24 -4
  84. package/skills/sluice/scripts/content-digest.sh +1 -1
  85. package/skills/sluice/scripts/run-stats.sh +14 -0
  86. package/skills/sluice/scripts/session-start.sh +44 -2
  87. package/skills/sluice/scripts/status.sh +293 -0
  88. package/skills/sluice/scripts/stop-guard.sh +80 -6
  89. package/skills/sluice/scripts/test-changes.sh +135 -0
  90. package/skills/sluice/skill.json +2 -2
@@ -4,7 +4,8 @@
4
4
  // the labelled ones, and scores them. Spends real money: the whole review runs
5
5
  // again, capped by --max-cost-usd.
6
6
 
7
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
7
+ import { existsSync } from 'node:fs'
8
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
8
9
  import { tmpdir } from 'node:os'
9
10
  import { join } from 'node:path'
10
11
  import { detectMissingTests } from '../../scripts/tests-check.ts'
@@ -15,6 +16,14 @@ import {
15
16
  readFindings,
16
17
  resolveCorpusRoot,
17
18
  } from './corpus.ts'
19
+ import {
20
+ DEFAULT_JUDGE_MODEL,
21
+ judgeRecall,
22
+ loadMisses,
23
+ type RecallScore,
24
+ type RecallVerdict,
25
+ scoreRecall,
26
+ } from './recall.ts'
18
27
  import { claudeFailureMessage, worktreeRemoveCommand } from './replay-critic.ts'
19
28
  import {
20
29
  formatRatio,
@@ -29,6 +38,117 @@ const SKILL_DIR = new URL('../..', import.meta.url).pathname.replace(/\/$/, '')
29
38
  const MAGPIE_BIN = join(SKILL_DIR, 'bin', 'magpie.ts')
30
39
  const DEFAULT_MAX_COST_USD = 10
31
40
 
41
+ export type ReplayDiagnostics = {
42
+ log: Array<Record<string, unknown>>
43
+ shardManifest: unknown | null
44
+ findingsFiles: string[]
45
+ dedupedCount: number | null
46
+ criticDropped: unknown | null
47
+ evidenceDropped: unknown | null
48
+ thresholdDropped: unknown | null
49
+ }
50
+
51
+ async function readJsonIfPresent(path: string): Promise<unknown | null> {
52
+ if (!existsSync(path)) return null
53
+ try {
54
+ return JSON.parse(await readFile(path, 'utf8'))
55
+ } catch (err) {
56
+ throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : err}`)
57
+ }
58
+ }
59
+
60
+ /** What the scratch run dir says about the session, read before the dir is deleted:
61
+ * without it a thin result cannot be told apart as missed shards or critic drops.
62
+ * The model writes most log lines by hand, so a line that is not a JSON object is
63
+ * kept raw in its place rather than failing a replay that has already been paid for. */
64
+ export async function replayDiagnostics(runDir: string): Promise<ReplayDiagnostics> {
65
+ const log: Array<Record<string, unknown>> = []
66
+ for (const [i, line] of (await readFile(join(runDir, 'log.jsonl'), 'utf8')).split('\n').entries()) {
67
+ if (line.trim() === '') continue
68
+ let entry: unknown
69
+ try {
70
+ entry = JSON.parse(line)
71
+ } catch {
72
+ entry = null
73
+ }
74
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
75
+ log.push({ unparsed: line, line: i + 1 })
76
+ } else {
77
+ log.push(entry as Record<string, unknown>)
78
+ }
79
+ }
80
+
81
+ const dedupedPath = join(runDir, 'findings.deduped.json')
82
+ const deduped = await readJsonIfPresent(dedupedPath)
83
+ if (deduped !== null && !Array.isArray(deduped)) {
84
+ throw new Error(`${dedupedPath} is not a JSON array`)
85
+ }
86
+
87
+ return {
88
+ log,
89
+ shardManifest: await readJsonIfPresent(join(runDir, 'shards', 'manifest.json')),
90
+ findingsFiles: (await readdir(join(runDir, 'findings'))).sort(),
91
+ dedupedCount: deduped === null ? null : deduped.length,
92
+ criticDropped: await readJsonIfPresent(join(runDir, 'critic-dropped.json')),
93
+ evidenceDropped: await readJsonIfPresent(join(runDir, 'evidence-dropped.json')),
94
+ thresholdDropped: await readJsonIfPresent(join(runDir, 'threshold-dropped.json')),
95
+ }
96
+ }
97
+
98
+ /** Shards some specialist wrote a findings file for, out of the manifest's shards.
99
+ * A single-shard run takes the unsharded path, so its files are `<focus>.json`;
100
+ * `tests.json` is the deterministic setup finding and proves no specialist ran. */
101
+ export function shardCoverageCount(
102
+ manifest: unknown | null,
103
+ findingsFiles: string[],
104
+ ): { reviewed: number; total: number } {
105
+ if (manifest === null) return { reviewed: 0, total: 0 }
106
+ const shards =
107
+ typeof manifest === 'object' && 'shards' in manifest ? manifest.shards : undefined
108
+ if (!Array.isArray(shards)) throw new Error('shard manifest has no shards array')
109
+ if (shards.length <= 1) {
110
+ const reviewed = findingsFiles.some((f) => /^[^.]+\.json$/.test(f) && f !== 'tests.json')
111
+ return { reviewed: reviewed && shards.length === 1 ? 1 : 0, total: shards.length }
112
+ }
113
+ const ids = new Set(
114
+ shards.map((s, i) => {
115
+ const id = typeof s === 'object' && s !== null && 'id' in s ? s.id : undefined
116
+ if (typeof id !== 'number') throw new Error(`shard manifest shards[${i}] has no numeric id`)
117
+ return id
118
+ }),
119
+ )
120
+ const reviewed = new Set<number>()
121
+ for (const f of findingsFiles) {
122
+ const m = /^[^.]+\.shard-(\d+)\.json$/.exec(f)
123
+ if (m && ids.has(Number(m[1]))) reviewed.add(Number(m[1]))
124
+ }
125
+ return { reviewed: reviewed.size, total: shards.length }
126
+ }
127
+
128
+ export function replaySummary(input: {
129
+ runId: string
130
+ kept: number
131
+ unlabelled: number
132
+ precision: number | null
133
+ recall: number | null
134
+ costUsd: number | null
135
+ missedRecall: { caught: number; misses: number } | null
136
+ coverage: { reviewed: number; total: number }
137
+ outPath: string
138
+ }): string {
139
+ const missed =
140
+ input.missedRecall === null
141
+ ? ''
142
+ : `, missed-recall ${input.missedRecall.caught}/${input.missedRecall.misses}`
143
+ return (
144
+ `${input.runId}: kept ${input.kept} (${input.unlabelled} unlabelled), ` +
145
+ `precision ${formatRatio(input.precision)}, recall ${formatRatio(input.recall)}, ` +
146
+ `cost ${input.costUsd === null ? 'unreported' : `$${input.costUsd.toFixed(2)}`}${missed}, ` +
147
+ `shards ${input.coverage.reviewed}/${input.coverage.total}\n` +
148
+ `wrote ${input.outPath}\n`
149
+ )
150
+ }
151
+
32
152
  async function exec(
33
153
  cmd: string[],
34
154
  options: { cwd?: string; stdinText?: string; claudeLabel?: string } = {},
@@ -72,13 +192,15 @@ This is an unattended evaluation run:
72
192
  }
73
193
 
74
194
  async function main(argv: string[]): Promise<number> {
75
- const flags = parseFlags(argv, ['corpus', 'repo', 'out', 'max-cost-usd'])
195
+ const flags = parseFlags(argv, ['corpus', 'repo', 'out', 'max-cost-usd', 'judge-model'])
76
196
  if (!flags.corpus || !flags.repo) {
77
197
  throw new Error(
78
- 'usage: replay-full.ts --corpus <runId> --repo <path> [--max-cost-usd N] [--out <corpus-dir>]',
198
+ 'usage: replay-full.ts --corpus <runId> --repo <path> [--max-cost-usd N] ' +
199
+ '[--judge-model M] [--out <corpus-dir>]',
79
200
  )
80
201
  }
81
202
  const maxCostUsd = positiveNumberFlag(flags, 'max-cost-usd') ?? DEFAULT_MAX_COST_USD
203
+ const judgeModel = flags['judge-model'] ?? DEFAULT_JUDGE_MODEL
82
204
  const corpusRoot = resolveCorpusRoot(flags)
83
205
  const run = await loadCorpusRun(join(corpusRoot, flags.corpus))
84
206
  const sha = headRefOid(run)
@@ -143,6 +265,9 @@ async function main(argv: string[]): Promise<number> {
143
265
  newKept,
144
266
  })
145
267
 
268
+ const diagnostics = await replayDiagnostics(scratch)
269
+ const shardCoverage = shardCoverageCount(diagnostics.shardManifest, diagnostics.findingsFiles)
270
+
146
271
  const resultsDir = join(corpusRoot, 'results')
147
272
  await mkdir(resultsDir, { recursive: true })
148
273
  const outPath = join(resultsDir, `${resultTimestamp(new Date())}-full-${run.runId}.json`)
@@ -156,12 +281,49 @@ async function main(argv: string[]): Promise<number> {
156
281
  matches: Object.fromEntries(matches),
157
282
  newKept,
158
283
  score,
284
+ recall: null as RecallScore | null,
285
+ recallVerdicts: null as RecallVerdict[] | null,
286
+ judgeModel: null as string | null,
287
+ judgeCostUsd: null as number | null,
288
+ ...diagnostics,
289
+ shardCoverage,
159
290
  }
291
+ // Written before the judge runs so a judge failure cannot lose a paid replay.
160
292
  await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`)
293
+
294
+ try {
295
+ const misses = await loadMisses(join(corpusRoot, 'misses', `${run.runId}.json`))
296
+ if (misses !== null) {
297
+ const judged = await judgeRecall({
298
+ misses,
299
+ findings: newKept,
300
+ model: judgeModel,
301
+ cwd: scratch,
302
+ })
303
+ result.recall = scoreRecall(misses, judged.verdicts)
304
+ result.recallVerdicts = judged.verdicts
305
+ result.judgeModel = judgeModel
306
+ result.judgeCostUsd = judged.costUsd
307
+ await writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`)
308
+ }
309
+ } catch (err) {
310
+ throw new Error(
311
+ `${err instanceof Error ? err.message : err} (replay result already written to ${outPath})`,
312
+ )
313
+ }
314
+
161
315
  process.stdout.write(
162
- `${run.runId}: kept ${score.kept} (${unlabelled} unlabelled), ` +
163
- `precision ${formatRatio(score.precision)}, recall ${formatRatio(score.recall)}, ` +
164
- `cost ${costUsd === null ? 'unreported' : `$${costUsd.toFixed(2)}`}\nwrote ${outPath}\n`,
316
+ replaySummary({
317
+ runId: run.runId,
318
+ kept: score.kept,
319
+ unlabelled,
320
+ precision: score.precision,
321
+ recall: score.recall,
322
+ costUsd,
323
+ missedRecall: result.recall,
324
+ coverage: shardCoverage,
325
+ outPath,
326
+ }),
165
327
  )
166
328
  return 0
167
329
  } finally {
@@ -147,7 +147,15 @@
147
147
  "states": "SKILL.md: 10. Cleanup",
148
148
  "origin": "commit:e49534b",
149
149
  "cases": [],
150
- "unpinned": "No case runs cleanup, and the fakes have no cleanup subcommand."
150
+ "unpinned": "Only local-mode-hands-back runs cleanup, and local cleanup prints no view-later line by design; no PR case reaches stage 10, and the PR fakes have no cleanup subcommand."
151
+ },
152
+ {
153
+ "id": "local-mode-hands-back-via-handoff",
154
+ "states": "SKILL.md: Local mode",
155
+ "origin": "commit:9353023",
156
+ "cases": [
157
+ "local-mode-hands-back"
158
+ ]
151
159
  },
152
160
  {
153
161
  "id": "resumable-means-log-not-server-info",
@@ -0,0 +1,78 @@
1
+ # Checks prompt
2
+
3
+ Stage 3 of the walkthrough dispatches one subagent from this file, alongside the scout,
4
+ once per run and never per shard. Build its prompt from two parts, in this order, and
5
+ send it as the agent's entire task:
6
+
7
+ 1. The fenced `magpie-checks` block below, with `<<RUN_DIR>>`, `<<WORKTREE>>` and
8
+ `<<TARGET>>` replaced by the real values, as `references/scout.md` describes them.
9
+ 2. The `## Output Contract` section of `references/specialists.md`, verbatim, with every
10
+ `<RUN_DIR>` replaced. Its `<focus>` is `checks`, which the block tells the agent, so
11
+ the findings land in `<RUN_DIR>/findings/checks.json`.
12
+
13
+ The subagent has no shell variables from your session, so an unexpanded path means it
14
+ writes its findings where nothing will read them.
15
+
16
+ ```magpie-checks
17
+ You are a senior engineer reviewing the checks in <<TARGET>>: the tests, scenario
18
+ and golden-file harnesses, runner scripts that score or gate results, baselines and blessed
19
+ fixtures, positive and negative controls, guards in test helpers, and CI steps that the
20
+ diff adds or changes. Your focus id is `checks`.
21
+
22
+ Working directory: <<WORKTREE>>
23
+ Diff: <<RUN_DIR>>/diff.patch
24
+
25
+ The specialists reviewing this PR read the product code. You read the checks, and ask of
26
+ each one: what is it for, and could it pass without proving that?
27
+
28
+ ## How to work
29
+
30
+ For each check the diff adds or changes, and for each runner or helper the diff changes
31
+ that existing checks rely on:
32
+
33
+ 1. Work out what behaviour it exists to prove, from the code and from any test name,
34
+ comment, doc or plan in the repo that says so. Cite the file:line you learned it from.
35
+ 2. Find what it depends on that it does not control: the real clock under a frozen or
36
+ faked one, the user's real data directory or database, process-wide state shared with
37
+ tests that run in parallel, network, timing on a loaded machine, another check's output.
38
+ 3. Find how it could pass while proving nothing:
39
+ - an assertion that cannot fail;
40
+ - an expected value produced by running the code under test;
41
+ - a test double that ignores the input the check is about (a query filter, an id);
42
+ - a guard missing for the very case the check was added for;
43
+ - an empty, skipped or timed-out run that scores as a pass;
44
+ - a control that cannot tell "worked" from "did not run";
45
+ - a baseline re-blessed with no stated cause;
46
+ - a suite that CI never runs, or that skips itself silently when its environment is absent.
47
+ 4. Confirm each condition by reading the code before you report it. A condition you
48
+ could not confirm is not a finding.
49
+
50
+ Report a check only when it is weaker than its purpose in a way that would let a real
51
+ regression through, or when it can fail for a reason unrelated to its purpose (the clock,
52
+ shared state, load), which teaches people to ignore it. A check that is merely thin, or
53
+ tests a constant, is not a finding unless something depends on it catching more.
54
+
55
+ ## Findings
56
+
57
+ Each finding names the check, what it is for, and the concrete way it passes without
58
+ proving it.
59
+ - `Observation:` the condition, with the file:line that shows it.
60
+ - `Why it matters:` the regression it would let through.
61
+ - `Suggested direction:` the smallest change that makes the check able to fail.
62
+
63
+ Anchor a finding on the line of the assertion, guard, double or runner branch at fault,
64
+ with its `evidence`. Use `line: null` only when the defect is an absence, such as a
65
+ missing guard entry or a suite CI never runs, and name the file where it belongs.
66
+
67
+ Risk guide (`risk.impact`):
68
+ - high: a check the PR relies on to prove a fix or a security property cannot fail
69
+ - medium: a check that passes on a realistic broken path, or depends on uncontrolled state
70
+ - low: a weak check whose purpose is covered elsewhere
71
+
72
+ Boundary: report the check's weakness. A defect in the product code the check should have
73
+ caught belongs to the specialists; mention it only as what the weak check lets through.
74
+
75
+ Report at most 8 findings, the ones whose false pass would mislead the most.
76
+
77
+ Use the JSON schema defined in the `## Output Contract` section that follows; do not invent fields.
78
+ ```
@@ -27,6 +27,13 @@ Replace each `<<NAME>>` placeholder in the block below:
27
27
  `severity` is left out on purpose: it is derived from `risk.impact`, so a reviewer who wants to change how bad a finding is changes `risk`.
28
28
  - `<<DIFF_EXCERPT>>` — the diff hunks containing the kept findings. For small PRs, the full `diff.patch` is fine. For larger PRs, narrow to the files referenced by `findings.kept.json`.
29
29
 
30
+ **Local runs.** A local run has no `pr.json`. Take these four from `local.json` instead; the rest are unchanged:
31
+
32
+ - `<<PR_TITLE>>`: `Local branch <branch>`, with `<branch>` from `jq -r .branch < $RUN_DIR/local.json`, or `Local detached HEAD` when that is null
33
+ - `<<PR_AUTHOR>>`: `jq -r .author < $RUN_DIR/local.json`
34
+ - `<<PR_HEAD_BRANCH>>`: `jq -r '.branch // "detached HEAD"' < $RUN_DIR/local.json`
35
+ - `<<PR_BASE_BRANCH>>`: `jq -r .baseRef < $RUN_DIR/local.json`
36
+
30
37
  ````magpie-peer-review
31
38
  You are the second-opinion reviewer for a PR review. <<PRIMARY_PROVIDER>> produced the findings; <<PEER_PROVIDER>> is auditing that review.
32
39
 
@@ -2,16 +2,23 @@
2
2
 
3
3
  Stage 3 of the walkthrough dispatches one subagent from this file, before the five
4
4
  specialists. Send the fenced `magpie-scout` block below as the agent's entire task,
5
- with `<<RUN_DIR>>` and `<<PR_NUMBER>>` replaced by the real values first. The
6
- subagent has no shell variables from your session, so an unexpanded path means it
7
- writes the brief where nothing will read it.
5
+ with `<<RUN_DIR>>`, `<<WORKTREE>>` and `<<TARGET>>` replaced by the real values first:
6
+ `<<WORKTREE>>` is what `magpie target "$RUN_DIR"` prints, and `<<TARGET>>` is `PR #<n>`
7
+ or, on a local run, `local branch <branch> against <baseRef>` (`detached HEAD
8
+ against <baseRef>` when HEAD has no branch). The subagent has no shell variables from
9
+ your session, so an unexpanded path means it writes the brief where nothing will read it.
10
+
11
+ On a local run there is no `pr.json`, so drop the `PR metadata:` line from the block.
12
+ The intent evidence is `<<RUN_DIR>>/local.json` instead: its `commits[].headline` and
13
+ `commits[].body`, plus the file named by its `intentFile` when that is not null. Tell
14
+ the scout to read those in place of `title`, `body` and the linked issues.
8
15
 
9
16
  ```magpie-scout
10
17
  You are a senior engineer building the orienting brief that five specialist
11
- reviewers will read before they review PR #<<PR_NUMBER>>. You are not reviewing the
18
+ reviewers will read before they review <<TARGET>>. You are not reviewing the
12
19
  code. You are answering "what is this PR for, and what does it actually do".
13
20
 
14
- Working directory: <<RUN_DIR>>/worktree
21
+ Working directory: <<WORKTREE>>
15
22
  PR metadata: <<RUN_DIR>>/pr.json
16
23
  Diff: <<RUN_DIR>>/diff.patch
17
24
 
@@ -9,16 +9,16 @@ them is instruction to you, not text for the specialist.
9
9
  2. The run header, with the placeholders filled in. Unsharded:
10
10
 
11
11
  ```
12
- You are reviewing PR #<PR_NUMBER>.
13
- Working directory: <RUN_DIR>/worktree
12
+ You are reviewing <TARGET>.
13
+ Working directory: <WORKTREE>
14
14
  Diff: <RUN_DIR>/diff.patch
15
15
  ```
16
16
 
17
17
  When stage 4 is fanning this focus across shards, use instead:
18
18
 
19
19
  ```
20
- You are reviewing PR #<PR_NUMBER>.
21
- Working directory: <RUN_DIR>/worktree
20
+ You are reviewing <TARGET>.
21
+ Working directory: <WORKTREE>
22
22
  Shard: <n> of <N>
23
23
  Diff: <RUN_DIR>/shards/shard-<n>.patch
24
24
 
@@ -68,10 +68,12 @@ review rule is a convention the maintainers wrote down: a change that breaks one
68
68
  finding in your domain when the rule bears on it, citing the rule's source.
69
69
  ```
70
70
 
71
- Replace every `<RUN_DIR>` and `<PR_NUMBER>` with the real values before sending: the
72
- subagent has no shell variables from your session, so an unexpanded path means it
73
- writes its findings where nothing will read them. Leave `<focus>` as written; the
74
- contract tells the subagent to substitute it.
71
+ Replace every `<RUN_DIR>`, `<WORKTREE>` and `<TARGET>` with the real values before
72
+ sending. `<WORKTREE>` is what `magpie target "$RUN_DIR"` prints. `<TARGET>` is `PR #<n>`,
73
+ or on a local run `local branch <branch> against <baseRef>` (`detached HEAD
74
+ against <baseRef>` when HEAD has no branch). The subagent has no shell variables from
75
+ your session, so an unexpanded path means it writes its findings where nothing will
76
+ read them. Leave `<focus>` as written; the contract tells the subagent to substitute it.
75
77
 
76
78
  Parts 1, 2, and 3 go every time. The contract is what makes the output parseable by
77
79
  `magpie dedupe`, and the focus blocks are what keep the five reviews from collapsing
@@ -3,6 +3,9 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
3
3
  import { tmpdir } from 'node:os'
4
4
  import { basename, dirname, join } from 'node:path'
5
5
  import { killServer, runCleanup } from '../cleanup-cmd.ts'
6
+ import { runSetup } from '../setup-cmd.ts'
7
+
8
+ const FAKE_GH = new URL('../../fixtures/fake-gh.sh', import.meta.url).pathname
6
9
 
7
10
  let repo: string
8
11
  let runDir: string
@@ -13,6 +16,14 @@ async function sh(cwd: string, ...cmd: string[]): Promise<void> {
13
16
  if (exit !== 0) throw new Error(`${cmd.join(' ')} exit ${exit}`)
14
17
  }
15
18
 
19
+ async function gitOut(cwd: string, ...args: string[]): Promise<string> {
20
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
21
+ const out = await new Response(proc.stdout).text()
22
+ const exit = await proc.exited
23
+ if (exit !== 0) throw new Error(`git ${args.join(' ')} exit ${exit}`)
24
+ return out
25
+ }
26
+
16
27
  beforeEach(async () => {
17
28
  repo = await mkdtemp(join(tmpdir(), 'magpie-cleanup-repo-'))
18
29
  runDir = await mkdtemp(join(tmpdir(), 'magpie-cleanup-run-'))
@@ -26,6 +37,8 @@ beforeEach(async () => {
26
37
  await sh(repo, 'git', 'commit', '-q', '-m', 'init')
27
38
  await sh(repo, 'git', 'worktree', 'add', '--detach', join(runDir, 'worktree'), 'HEAD')
28
39
  await mkdir(join(runDir, 'state'), { recursive: true })
40
+ // setup writes log.jsonl first in every real run.
41
+ await writeFile(join(runDir, 'log.jsonl'), '')
29
42
  })
30
43
 
31
44
  afterEach(async () => {
@@ -62,6 +75,35 @@ test('cleanup prints a view-later hint line with the archived id', async () => {
62
75
  expect(text).toMatch(/magpie open \S+\.archived-\d+/)
63
76
  })
64
77
 
78
+ test('cleanup of a local run prints the archive path but no view-later hint', async () => {
79
+ await sh(repo, 'git', 'worktree', 'remove', '--force', join(runDir, 'worktree'))
80
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: repo }))
81
+ const out: string[] = []
82
+ const origWrite = process.stdout.write.bind(process.stdout)
83
+ process.stdout.write = ((s: string) => {
84
+ out.push(s)
85
+ return true
86
+ }) as typeof process.stdout.write
87
+ try {
88
+ await runCleanup({ runDir, repoPath: repo, gitBin: 'git' })
89
+ } finally {
90
+ process.stdout.write = origWrite
91
+ }
92
+ const text = out.join('')
93
+ expect(text).toContain('archived to ')
94
+ expect(text).not.toContain('view later')
95
+ })
96
+
97
+ test('cleanup with a malformed target.json rejects before archiving or writing labels', async () => {
98
+ await writeFile(join(runDir, 'log.jsonl'), '')
99
+ await writeFile(join(runDir, 'findings.final.json'), '[]')
100
+ await writeFile(join(runDir, 'target.json'), '{not json')
101
+ await expect(runCleanup({ runDir, repoPath: repo, gitBin: 'git' })).rejects.toThrow('target.json')
102
+ const entries = await readdir(dirname(runDir))
103
+ expect(entries.some((e) => e.startsWith(`${basename(runDir)}.archived-`))).toBe(false)
104
+ expect(await Bun.file(join(runDir, 'labels.json')).exists()).toBe(false)
105
+ })
106
+
65
107
  test('killServer reports no-server-info when state/server-info missing', async () => {
66
108
  const result = await killServer(runDir, 100)
67
109
  expect(result.outcome).toBe('no-server-info')
@@ -151,3 +193,55 @@ test('runCleanup logs kill outcome to log.jsonl', async () => {
151
193
  expect(log).toContain('"stage":"cleanup"')
152
194
  expect(log).toContain('"outcome":"no-server-info"')
153
195
  })
196
+
197
+ test('cleanup after a local setup archives the run and leaves the repo as it was', async () => {
198
+ // The shared fixture adds a worktree; a local run has none, so it gets its own repo and run dir.
199
+ const localRepo = await mkdtemp(join(tmpdir(), 'magpie-cleanup-local-repo-'))
200
+ const localRun = await mkdtemp(join(tmpdir(), 'magpie-cleanup-local-run-'))
201
+ try {
202
+ await sh(localRepo, 'git', 'init', '-q', '-b', 'main')
203
+ await sh(localRepo, 'git', 'config', 'user.email', 't@t.t')
204
+ await sh(localRepo, 'git', 'config', 'user.name', 't')
205
+ await sh(localRepo, 'git', 'config', 'commit.gpgsign', 'false')
206
+ await writeFile(join(localRepo, 'a.txt'), 'a\n')
207
+ await sh(localRepo, 'git', 'add', '.')
208
+ await sh(localRepo, 'git', 'commit', '-q', '-m', 'init')
209
+ await sh(localRepo, 'git', 'checkout', '-q', '-b', 'feature-x')
210
+ await writeFile(join(localRepo, 'b.txt'), 'b\n')
211
+ await sh(localRepo, 'git', 'add', 'b.txt')
212
+ await sh(localRepo, 'git', 'commit', '-q', '-m', 'add b')
213
+ await writeFile(join(localRepo, 'a.txt'), 'a\nedited\n')
214
+ await writeFile(join(localRepo, 'new.txt'), 'untracked\n')
215
+
216
+ const setupExit = await runSetup({
217
+ mode: 'local',
218
+ runDir: localRun,
219
+ repoPath: localRepo,
220
+ baseRef: 'main',
221
+ deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
222
+ })
223
+ expect(setupExit).toBe(0)
224
+
225
+ const exit = await runCleanup({ runDir: localRun, repoPath: localRepo, gitBin: 'git' })
226
+ expect(exit).toBe(0)
227
+
228
+ const parent = dirname(localRun)
229
+ const archived = (await readdir(parent)).find((e) =>
230
+ e.startsWith(`${basename(localRun)}.archived-`),
231
+ )
232
+ if (!archived) throw new Error('expected an archived run dir')
233
+ const target = JSON.parse(await readFile(join(parent, archived, 'target.json'), 'utf8'))
234
+ await rm(join(parent, archived), { recursive: true, force: true })
235
+ expect(target).toEqual({ mode: 'local', root: localRepo })
236
+
237
+ expect(await readFile(join(localRepo, 'a.txt'), 'utf8')).toBe('a\nedited\n')
238
+ expect(await readFile(join(localRepo, 'new.txt'), 'utf8')).toBe('untracked\n')
239
+ const status = await gitOut(localRepo, 'status', '--porcelain')
240
+ expect(status).toBe(' M a.txt\n?? new.txt\n')
241
+ const worktrees = await gitOut(localRepo, 'worktree', 'list')
242
+ expect(worktrees.trim().split('\n')).toHaveLength(1)
243
+ } finally {
244
+ await rm(localRepo, { recursive: true, force: true })
245
+ await rm(localRun, { recursive: true, force: true })
246
+ }
247
+ })
@@ -12,6 +12,8 @@ let stderr: string
12
12
 
13
13
  beforeEach(async () => {
14
14
  runDir = await mkdtemp(join(tmpdir(), 'magpie-critic-'))
15
+ // setup's first write; without it the dir does not read as a run.
16
+ await writeFile(join(runDir, 'log.jsonl'), '')
15
17
  stdout = ''
16
18
  stderr = ''
17
19
  spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
@@ -88,6 +90,22 @@ test('critic-prompt writes the prompt file from the magpie-critic block and prin
88
90
  expect(prompt).toContain(`Write to: ${outputPath}`)
89
91
  })
90
92
 
93
+ test('critic-prompt points <<WORKTREE>> at the review root named in target.json', async () => {
94
+ const templatePath = join(runDir, 'critic.md')
95
+ await writeFile(templatePath, TEMPLATE_DOC)
96
+ await writeDeduped([finding('a', 'bugs')])
97
+ await writeFile(
98
+ join(runDir, 'target.json'),
99
+ `${JSON.stringify({ mode: 'local', root: '/repos/caller' }, null, 2)}\n`,
100
+ )
101
+
102
+ const exit = await runCriticPrompt(runDir, { templatePath })
103
+
104
+ expect(exit).toBe(0)
105
+ const prompt = await readFile(join(runDir, 'critic-prompt.md'), 'utf8')
106
+ expect(prompt).toContain('Worktree: /repos/caller\n')
107
+ })
108
+
91
109
  test('critic-prompt writes one numbered prompt per batch', async () => {
92
110
  const templatePath = join(runDir, 'critic.md')
93
111
  await writeFile(templatePath, TEMPLATE_DOC)
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -10,6 +10,8 @@ let runDir: string
10
10
  beforeEach(async () => {
11
11
  runDir = await mkdtemp(join(tmpdir(), 'magpie-dedupe-'))
12
12
  await mkdir(join(runDir, 'findings'), { recursive: true })
13
+ // setup's first write; without it the dir does not read as a run.
14
+ await writeFile(join(runDir, 'log.jsonl'), '')
13
15
  })
14
16
 
15
17
  afterEach(async () => {
@@ -385,6 +387,36 @@ test('a malformed shards/manifest.json is treated as no manifest', async () => {
385
387
  expect(done?.coverage).toBeUndefined()
386
388
  })
387
389
 
390
+ test('a checks.json written once per run reaches the deduped findings', async () => {
391
+ await writeWorktreeSource()
392
+ await writeFile(
393
+ join(runDir, 'findings', 'checks.json'),
394
+ JSON.stringify([
395
+ {
396
+ ...f('checks-1', 'a.ts', 0, 'runner scores an empty report as a passing control', 'checks'),
397
+ line: null,
398
+ },
399
+ ]),
400
+ )
401
+ expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
402
+ const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
403
+ expect(out.map((x: ReviewFinding) => [x.id, x.domain])).toEqual([['checks-1', 'checks']])
404
+ })
405
+
406
+ test('a sharded run never expects a checks file per shard', async () => {
407
+ await writeManifest(2)
408
+ for (const focus of LLM_FOCUSES) {
409
+ for (const shard of [1, 2]) {
410
+ await writeFile(join(runDir, 'findings', `${focus}.shard-${shard}.json`), JSON.stringify([]))
411
+ }
412
+ }
413
+ await writeFile(join(runDir, 'findings', 'checks.json'), JSON.stringify([]))
414
+ const { exit } = await dedupeCapturingStdout()
415
+ expect(exit).toBe(0)
416
+ const done = await dedupeLogEntry('done')
417
+ expect(done?.coverage).toEqual({ expected: 10, missing: [] })
418
+ })
419
+
388
420
  test('runDedupe still skips files it cannot map to a focus', async () => {
389
421
  await writeFile(join(runDir, 'findings', 'nonsense.json'), JSON.stringify([]))
390
422
  await writeFile(join(runDir, 'findings', 'security.shard-x.json'), JSON.stringify([]))
@@ -398,3 +430,50 @@ test('runDedupe still skips files it cannot map to a focus', async () => {
398
430
  .filter((e) => e.status === 'skip' && e.reason === 'unknown-focus')
399
431
  expect(skips.map((s) => s.file).sort()).toEqual(['nonsense.json', 'security.shard-x.json'])
400
432
  })
433
+
434
+ describe('with target.json naming a review root outside the run dir', () => {
435
+ let root: string
436
+
437
+ beforeEach(async () => {
438
+ root = await mkdtemp(join(tmpdir(), 'magpie-dedupe-root-'))
439
+ await writeFile(
440
+ join(runDir, 'target.json'),
441
+ `${JSON.stringify({ mode: 'local', root }, null, 2)}\n`,
442
+ )
443
+ await writeFile(
444
+ join(runDir, 'findings', 'bugs.json'),
445
+ JSON.stringify([
446
+ {
447
+ ...f('bugs-1', 'a.ts', 2, 'secret read without a fallback', 'bugs'),
448
+ evidence: 'const secret = process.env.KEY',
449
+ },
450
+ ]),
451
+ )
452
+ })
453
+
454
+ afterEach(async () => {
455
+ await rm(root, { recursive: true, force: true })
456
+ })
457
+
458
+ test('keeps a finding whose file and evidence are in that root', async () => {
459
+ await writeFile(join(root, 'a.ts'), '// line 1\nconst secret = process.env.KEY\n')
460
+ expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
461
+ const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
462
+ expect(kept.map((x: ReviewFinding) => x.id)).toEqual(['bugs-1'])
463
+ })
464
+
465
+ test('fails without writing findings when a local root does not exist', async () => {
466
+ await rm(root, { recursive: true, force: true })
467
+ const exit = await runDedupe(runDir, { threshold: 0 })
468
+ expect(exit).not.toBe(0)
469
+ expect(await Bun.file(join(runDir, 'findings.deduped.json')).exists()).toBe(false)
470
+ })
471
+
472
+ test('drops the finding as hallucinated-file when that root lacks the file', async () => {
473
+ expect(await runDedupe(runDir, { threshold: 0 })).toBe(0)
474
+ const evidence = JSON.parse(await readFile(join(runDir, 'evidence-dropped.json'), 'utf8'))
475
+ expect(evidence.dropped).toEqual([
476
+ { id: 'bugs-1', reason: 'hallucinated-file', file: 'a.ts', line: 2 },
477
+ ])
478
+ })
479
+ })