@iceinvein/agent-skills 0.22.0 → 0.23.0

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 (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +5 -0
  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 +4 -4
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +40 -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/sluice/SKILL.md +3 -1
  64. package/skills/sluice/evals/README.md +10 -2
  65. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  66. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  72. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  73. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  79. package/skills/sluice/evals/rules.json +11 -0
  80. package/skills/sluice/references/review.md +6 -4
  81. package/skills/sluice/references/test-first.md +24 -4
  82. package/skills/sluice/scripts/content-digest.sh +1 -1
  83. package/skills/sluice/scripts/run-stats.sh +14 -0
  84. package/skills/sluice/scripts/session-start.sh +44 -2
  85. package/skills/sluice/scripts/status.sh +293 -0
  86. package/skills/sluice/scripts/stop-guard.sh +80 -6
  87. package/skills/sluice/scripts/test-changes.sh +135 -0
  88. package/skills/sluice/skill.json +1 -1
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { ReviewFinding } from '../../../scripts/types.ts'
6
+ import { listCorpusRuns } from '../corpus.ts'
7
+ import {
8
+ loadMisses,
9
+ type Miss,
10
+ parseRecallVerdicts,
11
+ type RecallVerdict,
12
+ recallJudgeCommand,
13
+ recallPrompt,
14
+ scoreRecall,
15
+ } from '../recall.ts'
16
+
17
+ function finding(over: Partial<ReviewFinding> & { id: string }): ReviewFinding {
18
+ return {
19
+ file: 'src/orders.ts',
20
+ line: 10,
21
+ severity: 'medium',
22
+ risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
23
+ title: 'Something is off',
24
+ description: 'Details.',
25
+ domain: 'bugs',
26
+ ...over,
27
+ }
28
+ }
29
+
30
+ function miss(over: Partial<Miss> & { id: string }): Miss {
31
+ return {
32
+ file: 'src/orders.ts',
33
+ line: 42,
34
+ title: 'Refund ignores partial captures',
35
+ summary: 'A partially captured order refunds the full amount.',
36
+ ownerDomain: 'bugs',
37
+ bugClass: 'contract',
38
+ outsideDiff: false,
39
+ preExisting: false,
40
+ source: 'human review comment',
41
+ ...over,
42
+ }
43
+ }
44
+
45
+ const findings = [finding({ id: 'bugs-1' }), finding({ id: 'security-2' })]
46
+ const misses = [miss({ id: 'm1' }), miss({ id: 'm2' })]
47
+
48
+ test('a bare verdict array covering every miss is accepted', () => {
49
+ const text = JSON.stringify([
50
+ { missId: 'm1', findingId: 'bugs-1', reason: 'Same refund defect.' },
51
+ { missId: 'm2', findingId: null, reason: 'Nothing describes it.' },
52
+ ])
53
+ expect(parseRecallVerdicts(text, misses, findings)).toEqual([
54
+ { missId: 'm1', findingId: 'bugs-1', reason: 'Same refund defect.' },
55
+ { missId: 'm2', findingId: null, reason: 'Nothing describes it.' },
56
+ ])
57
+ })
58
+
59
+ test('a verdict array inside a fenced json block is accepted', () => {
60
+ const text =
61
+ 'Here are the verdicts.\n```json\n' +
62
+ '[{"missId":"m1","findingId":null,"reason":"No match."},' +
63
+ '{"missId":"m2","findingId":"security-2","reason":"Same defect."}]\n```\n'
64
+ expect(parseRecallVerdicts(text, misses, findings)).toEqual([
65
+ { missId: 'm1', findingId: null, reason: 'No match.' },
66
+ { missId: 'm2', findingId: 'security-2', reason: 'Same defect.' },
67
+ ])
68
+ })
69
+
70
+ test('a verdict array inside an untagged fence is accepted', () => {
71
+ const text =
72
+ '```\n[{"missId":"m1","findingId":"bugs-1","reason":"Same defect."},' +
73
+ '{"missId":"m2","findingId":null,"reason":"No match."}]\n```'
74
+ expect(parseRecallVerdicts(text, misses, findings)).toEqual([
75
+ { missId: 'm1', findingId: 'bugs-1', reason: 'Same defect.' },
76
+ { missId: 'm2', findingId: null, reason: 'No match.' },
77
+ ])
78
+ })
79
+
80
+ test('a verdict array wrapped in prose without a fence is accepted', () => {
81
+ const text =
82
+ 'Verdicts follow. [{"missId":"m1","findingId":null,"reason":"No match [none]."},' +
83
+ '{"missId":"m2","findingId":"security-2","reason":"Same defect."}] That is all.'
84
+ expect(parseRecallVerdicts(text, misses, findings)).toEqual([
85
+ { missId: 'm1', findingId: null, reason: 'No match [none].' },
86
+ { missId: 'm2', findingId: 'security-2', reason: 'Same defect.' },
87
+ ])
88
+ })
89
+
90
+ test('a verdict with no findingId is rejected', () => {
91
+ const text = JSON.stringify([
92
+ { missId: 'm1', reason: 'No match.' },
93
+ { missId: 'm2', findingId: null, reason: 'No match.' },
94
+ ])
95
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('[0]')
96
+ })
97
+
98
+ test('a verdict whose reason is not a string is rejected', () => {
99
+ const text = JSON.stringify([
100
+ { missId: 'm1', findingId: null, reason: 'No match.' },
101
+ { missId: 'm2', findingId: null, reason: 7 },
102
+ ])
103
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('[1]')
104
+ })
105
+
106
+ test('the recall judge runs with no MCP servers and no tools', () => {
107
+ expect(recallJudgeCommand('claude-sonnet-5-5')).toEqual([
108
+ 'claude',
109
+ '-p',
110
+ '--model',
111
+ 'claude-sonnet-5-5',
112
+ '--output-format',
113
+ 'json',
114
+ '--strict-mcp-config',
115
+ '--tools',
116
+ '',
117
+ ])
118
+ })
119
+
120
+ test('verdicts that leave a miss unjudged are rejected', () => {
121
+ const text = JSON.stringify([{ missId: 'm1', findingId: null, reason: 'No match.' }])
122
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('m2')
123
+ })
124
+
125
+ test('verdicts that judge a miss twice are rejected', () => {
126
+ const text = JSON.stringify([
127
+ { missId: 'm1', findingId: null, reason: 'No match.' },
128
+ { missId: 'm1', findingId: 'bugs-1', reason: 'Same defect.' },
129
+ { missId: 'm2', findingId: null, reason: 'No match.' },
130
+ ])
131
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('m1')
132
+ })
133
+
134
+ test('a verdict for a miss not in the set is rejected', () => {
135
+ const text = JSON.stringify([
136
+ { missId: 'm1', findingId: null, reason: 'No match.' },
137
+ { missId: 'm2', findingId: null, reason: 'No match.' },
138
+ { missId: 'm9', findingId: null, reason: 'No match.' },
139
+ ])
140
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('m9')
141
+ })
142
+
143
+ test('a verdict naming a finding that was not kept is rejected', () => {
144
+ const text = JSON.stringify([
145
+ { missId: 'm1', findingId: 'bugs-7', reason: 'Same defect.' },
146
+ { missId: 'm2', findingId: null, reason: 'No match.' },
147
+ ])
148
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('bugs-7')
149
+ })
150
+
151
+ test('a judge reply that is not JSON is rejected', () => {
152
+ expect(() => parseRecallVerdicts('I could not decide.', misses, findings)).toThrow()
153
+ })
154
+
155
+ test('a judge reply that is a JSON object rather than an array is rejected', () => {
156
+ const text = '{"missId":"m1","findingId":null,"reason":"No match."}'
157
+ expect(() => parseRecallVerdicts(text, misses, findings)).toThrow('array')
158
+ })
159
+
160
+ test('recall counts caught misses overall, by domain, by class and without pre-existing ones', () => {
161
+ const set = [
162
+ miss({ id: 'a', ownerDomain: 'bugs', bugClass: 'contract' }),
163
+ miss({ id: 'b', ownerDomain: 'bugs', bugClass: 'null-handling', preExisting: true }),
164
+ miss({ id: 'c', ownerDomain: 'security', bugClass: 'contract' }),
165
+ ]
166
+ const verdicts: RecallVerdict[] = [
167
+ { missId: 'a', findingId: 'bugs-1', reason: 'Same.' },
168
+ { missId: 'b', findingId: 'bugs-3', reason: 'Same.' },
169
+ { missId: 'c', findingId: null, reason: 'None.' },
170
+ ]
171
+ expect(scoreRecall(set, verdicts)).toEqual({
172
+ misses: 3,
173
+ caught: 2,
174
+ recall: 2 / 3,
175
+ byDomain: { bugs: { misses: 2, caught: 2 }, security: { misses: 1, caught: 0 } },
176
+ byClass: { contract: { misses: 2, caught: 1 }, 'null-handling': { misses: 1, caught: 1 } },
177
+ excludingPreExisting: { misses: 2, caught: 1, recall: 1 / 2 },
178
+ })
179
+ })
180
+
181
+ test('recall over an empty miss set is null rather than zero', () => {
182
+ expect(scoreRecall([], [])).toEqual({
183
+ misses: 0,
184
+ caught: 0,
185
+ recall: null,
186
+ byDomain: {},
187
+ byClass: {},
188
+ excludingPreExisting: { misses: 0, caught: 0, recall: null },
189
+ })
190
+ })
191
+
192
+ test('the recall prompt names every miss and every kept finding', () => {
193
+ const prompt = recallPrompt(
194
+ [miss({ id: 'miss-alpha' }), miss({ id: 'miss-beta' })],
195
+ [finding({ id: 'bugs-11' }), finding({ id: 'contracts-4' })],
196
+ )
197
+ for (const id of ['miss-alpha', 'miss-beta', 'bugs-11', 'contracts-4']) {
198
+ expect(prompt).toContain(id)
199
+ }
200
+ })
201
+
202
+ let root: string
203
+
204
+ beforeEach(async () => {
205
+ root = await mkdtemp(join(tmpdir(), 'magpie-recall-'))
206
+ })
207
+
208
+ afterEach(async () => {
209
+ await rm(root, { recursive: true, force: true })
210
+ })
211
+
212
+ test('a missing recall set loads as null', async () => {
213
+ expect(await loadMisses(join(root, 'misses', 'pr-1-1.json'))).toBeNull()
214
+ })
215
+
216
+ test('a recall set file loads its misses', async () => {
217
+ const path = join(root, 'pr-1-1.json')
218
+ await writeFile(path, JSON.stringify([miss({ id: 'm1' })]))
219
+ expect(await loadMisses(path)).toEqual([miss({ id: 'm1' })])
220
+ })
221
+
222
+ test('a malformed miss is rejected naming the file and its index', async () => {
223
+ const path = join(root, 'pr-1-1.json')
224
+ const { bugClass: _, ...noClass } = miss({ id: 'm2' })
225
+ await writeFile(path, JSON.stringify([miss({ id: 'm1' }), noClass]))
226
+ await expect(loadMisses(path)).rejects.toThrow(`${path}[1]`)
227
+ })
228
+
229
+ test('the corpus run list leaves out the results and misses directories', async () => {
230
+ await mkdir(join(root, 'results'))
231
+ await mkdir(join(root, 'misses'))
232
+ await mkdir(join(root, 'pr-46-1787748571'))
233
+ expect(await listCorpusRuns(root)).toEqual(['pr-46-1787748571'])
234
+ })
@@ -0,0 +1,164 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { replayDiagnostics, replaySummary, shardCoverageCount } from '../replay-full.ts'
6
+
7
+ const base = {
8
+ runId: 'pr-50-1789099641',
9
+ kept: 12,
10
+ unlabelled: 4,
11
+ precision: 0.5,
12
+ recall: 0.75,
13
+ costUsd: 3.456,
14
+ outPath: '/corpus/results/x-full-pr-50-1789099641.json',
15
+ coverage: { reviewed: 3, total: 7 },
16
+ }
17
+
18
+ test('a replay without a recall set prints no missed-recall figure', () => {
19
+ expect(replaySummary({ ...base, missedRecall: null })).toBe(
20
+ 'pr-50-1789099641: kept 12 (4 unlabelled), precision 0.50, recall 0.75, cost $3.46, shards 3/7\n' +
21
+ 'wrote /corpus/results/x-full-pr-50-1789099641.json\n',
22
+ )
23
+ })
24
+
25
+ test('a replay judged against a recall set prints caught over misses', () => {
26
+ expect(replaySummary({ ...base, missedRecall: { caught: 3, misses: 14 } })).toBe(
27
+ 'pr-50-1789099641: kept 12 (4 unlabelled), precision 0.50, recall 0.75, cost $3.46, ' +
28
+ 'missed-recall 3/14, shards 3/7\nwrote /corpus/results/x-full-pr-50-1789099641.json\n',
29
+ )
30
+ })
31
+
32
+ test('a replay whose cost Claude did not report says so', () => {
33
+ expect(
34
+ replaySummary({ ...base, precision: null, recall: null, costUsd: null, missedRecall: null }),
35
+ ).toBe(
36
+ 'pr-50-1789099641: kept 12 (4 unlabelled), precision -, recall -, cost unreported, shards 3/7\n' +
37
+ 'wrote /corpus/results/x-full-pr-50-1789099641.json\n',
38
+ )
39
+ })
40
+
41
+ function manifestOf(shardCount: number) {
42
+ return {
43
+ budget: 6000,
44
+ maxFiles: 80,
45
+ totalFiles: shardCount,
46
+ totalLines: 10 * shardCount,
47
+ shards: Array.from({ length: shardCount }, (_, i) => ({
48
+ id: i + 1,
49
+ path: shardCount === 1 ? 'diff.patch' : `shards/shard-${i + 1}.patch`,
50
+ files: [`src/f${i + 1}.ts`],
51
+ lines: 10,
52
+ })),
53
+ }
54
+ }
55
+
56
+ test('a sharded replay counts only the shards some specialist wrote findings for', () => {
57
+ const files = [
58
+ 'bugs.shard-4.json',
59
+ 'bugs.shard-5.json',
60
+ 'security.shard-5.json',
61
+ 'architecture.shard-6.json',
62
+ 'code-smells.shard-7.json',
63
+ 'tests.json',
64
+ ]
65
+ expect(shardCoverageCount(manifestOf(7), files)).toEqual({ reviewed: 4, total: 7 })
66
+ })
67
+
68
+ test('a single-shard replay counts as reviewed when a specialist wrote its unsharded file', () => {
69
+ expect(shardCoverageCount(manifestOf(1), ['bugs.json', 'tests.json'])).toEqual({
70
+ reviewed: 1,
71
+ total: 1,
72
+ })
73
+ })
74
+
75
+ test('a single-shard replay with only the tests finding counts as unreviewed', () => {
76
+ expect(shardCoverageCount(manifestOf(1), ['tests.json'])).toEqual({ reviewed: 0, total: 1 })
77
+ })
78
+
79
+ test('a replay with no shard manifest reports no shards', () => {
80
+ expect(shardCoverageCount(null, ['bugs.json'])).toEqual({ reviewed: 0, total: 0 })
81
+ })
82
+
83
+ test('a manifest without a shards array is rejected', () => {
84
+ expect(() => shardCoverageCount({ budget: 6000 }, [])).toThrow('shards')
85
+ })
86
+
87
+ async function scratchRunDir(): Promise<string> {
88
+ const dir = await mkdtemp(join(tmpdir(), 'magpie-replay-diag-'))
89
+ await mkdir(join(dir, 'findings'))
90
+ return dir
91
+ }
92
+
93
+ test('diagnostics carry the log, manifest, findings files, deduped count and critic drops', async () => {
94
+ const dir = await scratchRunDir()
95
+ await writeFile(
96
+ join(dir, 'log.jsonl'),
97
+ '{"stage":"setup","status":"done"}\n\n{"stage":"critic","status":"done","kept":8}\n',
98
+ )
99
+ await mkdir(join(dir, 'shards'))
100
+ await writeFile(join(dir, 'shards', 'manifest.json'), JSON.stringify(manifestOf(2)))
101
+ await writeFile(join(dir, 'findings', 'security.shard-2.json'), '[]')
102
+ await writeFile(join(dir, 'findings', 'bugs.shard-1.json'), '[]')
103
+ await writeFile(join(dir, 'findings.deduped.json'), '[{"id":"a"},{"id":"b"},{"id":"c"}]')
104
+ await writeFile(join(dir, 'critic-dropped.json'), '[{"id":"b","reason":"speculative"}]')
105
+
106
+ expect(await replayDiagnostics(dir)).toEqual({
107
+ log: [
108
+ { stage: 'setup', status: 'done' },
109
+ { stage: 'critic', status: 'done', kept: 8 },
110
+ ],
111
+ shardManifest: manifestOf(2),
112
+ findingsFiles: ['bugs.shard-1.json', 'security.shard-2.json'],
113
+ dedupedCount: 3,
114
+ criticDropped: [{ id: 'b', reason: 'speculative' }],
115
+ evidenceDropped: null,
116
+ thresholdDropped: null,
117
+ })
118
+ })
119
+
120
+ test('diagnostics report absent manifest, deduped and drop files as null', async () => {
121
+ const dir = await scratchRunDir()
122
+ await writeFile(join(dir, 'log.jsonl'), '{"stage":"setup","status":"done"}\n')
123
+
124
+ expect(await replayDiagnostics(dir)).toEqual({
125
+ log: [{ stage: 'setup', status: 'done' }],
126
+ shardManifest: null,
127
+ findingsFiles: [],
128
+ dedupedCount: null,
129
+ criticDropped: null,
130
+ evidenceDropped: null,
131
+ thresholdDropped: null,
132
+ })
133
+ })
134
+
135
+ test('a log line that is not a JSON object is kept raw in its place with its line number', async () => {
136
+ const dir = await scratchRunDir()
137
+ await writeFile(
138
+ join(dir, 'log.jsonl'),
139
+ '{"stage":"setup","status":"done"}\n{"stage":"shard"\n[1,2]\n{"stage":"critic"}\n',
140
+ )
141
+ expect((await replayDiagnostics(dir)).log).toEqual([
142
+ { stage: 'setup', status: 'done' },
143
+ { unparsed: '{"stage":"shard"', line: 2 },
144
+ { unparsed: '[1,2]', line: 3 },
145
+ { stage: 'critic' },
146
+ ])
147
+ })
148
+
149
+ test('diagnostics carry the evidence and threshold drops when the run wrote them', async () => {
150
+ const dir = await scratchRunDir()
151
+ await writeFile(join(dir, 'log.jsonl'), '{"stage":"setup","status":"done"}\n')
152
+ await writeFile(join(dir, 'evidence-dropped.json'), '[{"id":"e1","reason":"quote not in diff"}]')
153
+ await writeFile(join(dir, 'threshold-dropped.json'), '[{"id":"t1","confidence":0.2}]')
154
+ const diagnostics = await replayDiagnostics(dir)
155
+ expect(diagnostics.evidenceDropped).toEqual([{ id: 'e1', reason: 'quote not in diff' }])
156
+ expect(diagnostics.thresholdDropped).toEqual([{ id: 't1', confidence: 0.2 }])
157
+ })
158
+
159
+ test('a deduped findings file that is not an array is rejected', async () => {
160
+ const dir = await scratchRunDir()
161
+ await writeFile(join(dir, 'log.jsonl'), '{"stage":"setup","status":"done"}\n')
162
+ await writeFile(join(dir, 'findings.deduped.json'), '{"id":"a"}')
163
+ await expect(replayDiagnostics(dir)).rejects.toThrow('findings.deduped.json')
164
+ })
@@ -154,8 +154,11 @@ export async function loadCorpusRun(dir: string): Promise<CorpusRun> {
154
154
  }
155
155
  }
156
156
 
157
+ // Directories beside the runs that hold eval output and recall sets, not runs.
158
+ const NON_RUN_DIRS = ['results', 'misses']
159
+
157
160
  export async function listCorpusRuns(corpusRoot: string): Promise<string[]> {
158
- const names = (await readdir(corpusRoot)).filter((n) => n !== 'results').sort()
161
+ const names = (await readdir(corpusRoot)).filter((n) => !NON_RUN_DIRS.includes(n)).sort()
159
162
  const runs: string[] = []
160
163
  for (const name of names) {
161
164
  if ((await stat(join(corpusRoot, name))).isDirectory()) runs.push(name)
@@ -0,0 +1,249 @@
1
+ // Recall against known misses: defects a human found that magpie's kept
2
+ // findings did not raise. A model judges which kept finding, if any, describes
3
+ // each miss, because a miss is often anchored somewhere other than where a
4
+ // finding about the same defect lands.
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile } from 'node:fs/promises'
8
+ import type { ReviewFinding } from '../../scripts/types.ts'
9
+ import { claudeFailureMessage } from './replay-critic.ts'
10
+ import { parseClaudeResult } from './score.ts'
11
+
12
+ export const DEFAULT_JUDGE_MODEL = 'claude-sonnet-5-5'
13
+
14
+ export type Miss = {
15
+ id: string
16
+ file: string
17
+ line: number | null
18
+ title: string
19
+ summary: string
20
+ ownerDomain: string
21
+ bugClass: string
22
+ outsideDiff: boolean
23
+ preExisting: boolean
24
+ source: string
25
+ }
26
+
27
+ export type RecallVerdict = { missId: string; findingId: string | null; reason: string }
28
+
29
+ type Tally = { misses: number; caught: number }
30
+
31
+ export type RecallScore = {
32
+ misses: number
33
+ caught: number
34
+ recall: number | null
35
+ byDomain: Record<string, Tally>
36
+ byClass: Record<string, Tally>
37
+ excludingPreExisting: { misses: number; caught: number; recall: number | null }
38
+ }
39
+
40
+ const MISS_STRING_FIELDS = ['id', 'file', 'title', 'summary', 'ownerDomain', 'bugClass', 'source']
41
+ const MISS_BOOLEAN_FIELDS = ['outsideDiff', 'preExisting']
42
+
43
+ function parseMiss(raw: unknown): Miss {
44
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
45
+ throw new Error('is not an object')
46
+ }
47
+ const entry = raw as Record<string, unknown>
48
+ for (const field of MISS_STRING_FIELDS) {
49
+ if (typeof entry[field] !== 'string' || entry[field] === '') {
50
+ throw new Error(`${field} is not a non-empty string`)
51
+ }
52
+ }
53
+ for (const field of MISS_BOOLEAN_FIELDS) {
54
+ if (typeof entry[field] !== 'boolean') throw new Error(`${field} is not a boolean`)
55
+ }
56
+ if (entry.line !== null && !Number.isInteger(entry.line)) {
57
+ throw new Error('line is not an integer or null')
58
+ }
59
+ return entry as unknown as Miss
60
+ }
61
+
62
+ /** The recall set for one run, or null when no one has written one. */
63
+ export async function loadMisses(path: string): Promise<Miss[] | null> {
64
+ if (!existsSync(path)) return null
65
+ let raw: unknown
66
+ try {
67
+ raw = JSON.parse(await readFile(path, 'utf8'))
68
+ } catch (err) {
69
+ throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : err}`)
70
+ }
71
+ if (!Array.isArray(raw)) throw new Error(`${path} is not a JSON array`)
72
+ return raw.map((m, i) => {
73
+ try {
74
+ return parseMiss(m)
75
+ } catch (err) {
76
+ throw new Error(`${path}[${i}]: ${err instanceof Error ? err.message : err}`)
77
+ }
78
+ })
79
+ }
80
+
81
+ function anchor(file: string, line: number | null): string {
82
+ return line === null ? file : `${file}:${line}`
83
+ }
84
+
85
+ export function recallPrompt(misses: Miss[], findings: ReviewFinding[]): string {
86
+ const missBlock = misses
87
+ .map(
88
+ (m) =>
89
+ `- id: ${m.id}\n at: ${anchor(m.file, m.line)}\n title: ${m.title}\n summary: ${m.summary}`,
90
+ )
91
+ .join('\n')
92
+ const findingBlock = findings
93
+ .map(
94
+ (f) =>
95
+ `- id: ${f.id}\n at: ${anchor(f.file, f.line)}\n title: ${f.title}\n description: ${f.description}`,
96
+ )
97
+ .join('\n')
98
+ return [
99
+ 'You judge whether a code review caught known defects.',
100
+ '',
101
+ 'Below are MISSES, defects a human reviewer found in a pull request, and FINDINGS, the',
102
+ 'comments an automated review kept for the same pull request. For each miss, name the one',
103
+ 'finding that describes the same underlying defect, wherever that finding is anchored, or',
104
+ 'null when none does. Partial overlap that would not lead the author to the fix is null.',
105
+ 'Give a reason under 25 words for each verdict.',
106
+ '',
107
+ 'Reply with a JSON array only, one entry per miss, each miss exactly once:',
108
+ '[{"missId": "<miss id>", "findingId": "<finding id or null>", "reason": "<why>"}]',
109
+ '',
110
+ 'MISSES',
111
+ missBlock,
112
+ '',
113
+ 'FINDINGS',
114
+ findings.length === 0 ? '(none)' : findingBlock,
115
+ '',
116
+ ].join('\n')
117
+ }
118
+
119
+ const FENCED = /```(?:json)?\s*\n([\s\S]*?)```/
120
+
121
+ /** The verdict array from a fence if there is one, else the outermost [...] in the reply. */
122
+ function verdictText(text: string): string {
123
+ const fenced = FENCED.exec(text)?.[1]
124
+ if (fenced !== undefined) return fenced.trim()
125
+ const start = text.indexOf('[')
126
+ const end = text.lastIndexOf(']')
127
+ return start === -1 || end < start ? text.trim() : text.slice(start, end + 1)
128
+ }
129
+
130
+ export function parseRecallVerdicts(
131
+ text: string,
132
+ misses: Miss[],
133
+ findings: ReviewFinding[],
134
+ ): RecallVerdict[] {
135
+ const body = verdictText(text)
136
+ let raw: unknown
137
+ try {
138
+ raw = JSON.parse(body)
139
+ } catch {
140
+ throw new Error(`recall judge printed no JSON: ${text.slice(0, 200)}`)
141
+ }
142
+ if (!Array.isArray(raw)) throw new Error('recall judge reply is not a JSON array')
143
+
144
+ const missIds = new Set(misses.map((m) => m.id))
145
+ const findingIds = new Set(findings.map((f) => f.id))
146
+ const judged = new Set<string>()
147
+ const verdicts = raw.map((v, i): RecallVerdict => {
148
+ const entry = v as Record<string, unknown> | null
149
+ if (
150
+ typeof entry?.missId !== 'string' ||
151
+ !(typeof entry.findingId === 'string' || entry.findingId === null) ||
152
+ typeof entry.reason !== 'string'
153
+ ) {
154
+ throw new Error(`recall verdict [${i}] is not a {missId, findingId, reason} object`)
155
+ }
156
+ if (!missIds.has(entry.missId)) throw new Error(`recall verdict for unknown miss ${entry.missId}`)
157
+ if (judged.has(entry.missId)) throw new Error(`miss ${entry.missId} is judged twice`)
158
+ judged.add(entry.missId)
159
+ if (entry.findingId !== null && !findingIds.has(entry.findingId)) {
160
+ throw new Error(`miss ${entry.missId} names ${entry.findingId}, which is not a kept finding`)
161
+ }
162
+ return { missId: entry.missId, findingId: entry.findingId, reason: entry.reason }
163
+ })
164
+ const unjudged = misses.filter((m) => !judged.has(m.id)).map((m) => m.id)
165
+ if (unjudged.length > 0) throw new Error(`recall judge left unjudged: ${unjudged.join(', ')}`)
166
+ return verdicts
167
+ }
168
+
169
+ // `--strict-mcp-config` with no config loads no MCP servers, whose tools
170
+ // `--tools ""` would not remove.
171
+ export function recallJudgeCommand(model: string): string[] {
172
+ return [
173
+ 'claude',
174
+ '-p',
175
+ '--model',
176
+ model,
177
+ '--output-format',
178
+ 'json',
179
+ '--strict-mcp-config',
180
+ '--tools',
181
+ '',
182
+ ]
183
+ }
184
+
185
+ /** One `claude -p` with no tools or MCP servers: the judge reads only what the prompt gives it. */
186
+ export async function judgeRecall(input: {
187
+ misses: Miss[]
188
+ findings: ReviewFinding[]
189
+ model: string
190
+ cwd: string
191
+ }): Promise<{ verdicts: RecallVerdict[]; costUsd: number | null }> {
192
+ const label = 'recall judge'
193
+ const proc = Bun.spawn(recallJudgeCommand(input.model), {
194
+ cwd: input.cwd,
195
+ stdin: new Blob([recallPrompt(input.misses, input.findings)]),
196
+ stdout: 'pipe',
197
+ stderr: 'pipe',
198
+ })
199
+ const [stdout, stderr, exit] = await Promise.all([
200
+ new Response(proc.stdout).text(),
201
+ new Response(proc.stderr).text(),
202
+ proc.exited,
203
+ ])
204
+ if (exit !== 0) throw new Error(claudeFailureMessage(label, exit, stdout, stderr))
205
+ const { costUsd } = parseClaudeResult(stdout, label)
206
+ const result = (JSON.parse(stdout) as Record<string, unknown>).result
207
+ if (typeof result !== 'string') throw new Error(`${label}: claude result is not a string`)
208
+ return { verdicts: parseRecallVerdicts(result, input.misses, input.findings), costUsd }
209
+ }
210
+
211
+ function ratio(caught: number, misses: number): number | null {
212
+ return misses === 0 ? null : caught / misses
213
+ }
214
+
215
+ export function scoreRecall(misses: Miss[], verdicts: RecallVerdict[]): RecallScore {
216
+ const caughtIds = new Set(verdicts.filter((v) => v.findingId !== null).map((v) => v.missId))
217
+ const byDomain: Record<string, Tally> = {}
218
+ const byClass: Record<string, Tally> = {}
219
+ let caught = 0
220
+ let newMisses = 0
221
+ let newCaught = 0
222
+ for (const m of misses) {
223
+ const isCaught = caughtIds.has(m.id)
224
+ const domain = byDomain[m.ownerDomain] ?? { misses: 0, caught: 0 }
225
+ byDomain[m.ownerDomain] = domain
226
+ const bugClass = byClass[m.bugClass] ?? { misses: 0, caught: 0 }
227
+ byClass[m.bugClass] = bugClass
228
+ domain.misses++
229
+ bugClass.misses++
230
+ if (!m.preExisting) newMisses++
231
+ if (!isCaught) continue
232
+ caught++
233
+ domain.caught++
234
+ bugClass.caught++
235
+ if (!m.preExisting) newCaught++
236
+ }
237
+ return {
238
+ misses: misses.length,
239
+ caught,
240
+ recall: ratio(caught, misses.length),
241
+ byDomain,
242
+ byClass,
243
+ excludingPreExisting: {
244
+ misses: newMisses,
245
+ caught: newCaught,
246
+ recall: ratio(newCaught, newMisses),
247
+ },
248
+ }
249
+ }
@@ -7,6 +7,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { join } from 'node:path'
9
9
  import type { Subprocess } from 'bun'
10
+ import { writeTarget } from '../../scripts/target.ts'
10
11
  import { originalSelection } from './baseline.ts'
11
12
  import {
12
13
  copyCorpusFiles,
@@ -127,6 +128,8 @@ async function main(argv: string[]): Promise<number> {
127
128
  'merge-candidates.json',
128
129
  'brief.json',
129
130
  ])
131
+ // magpie refuses a dir with neither target.json nor log.jsonl as not a run.
132
+ await writeTarget(scratch, { mode: 'pr', root: worktree })
130
133
 
131
134
  const batches = (await exec(['bun', MAGPIE_BIN, 'critic-prompt', scratch]))
132
135
  .split('\n')