@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
@@ -0,0 +1,672 @@
1
+ #!/usr/bin/env bun
2
+ // Runs one specialist on one shard of a corpus run, with or without the impact
3
+ // map in its prompt, and judges its findings against chosen misses. A narrower
4
+ // and cheaper question than a full replay: does this focus, on this shard, find
5
+ // these defects. Spends real money unless --dry-run: one `claude -p` per repeat,
6
+ // capped by --max-cost-usd, plus one judge call.
7
+
8
+ import { constants, existsSync } from 'node:fs'
9
+ import {
10
+ access,
11
+ copyFile,
12
+ mkdir,
13
+ mkdtemp,
14
+ readFile,
15
+ realpath,
16
+ rm,
17
+ writeFile,
18
+ } from 'node:fs/promises'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import type { ShardEntry } from '../../scripts/shard.ts'
22
+ import { type PrBrief, parseBrief, parseFinding, type ReviewFinding } from '../../scripts/types.ts'
23
+ import {
24
+ copyCorpusFiles,
25
+ type CorpusRun,
26
+ headRefOid,
27
+ loadCorpusRun,
28
+ resolveCorpusRoot,
29
+ } from './corpus.ts'
30
+ import {
31
+ DEFAULT_JUDGE_MODEL,
32
+ judgeRecall,
33
+ loadMisses,
34
+ type Miss,
35
+ type RecallVerdict,
36
+ } from './recall.ts'
37
+ import { worktreeRemoveCommand } from './replay-critic.ts'
38
+ import { exec } from './replay-full.ts'
39
+ import { parseFlags, positiveNumberFlag, resultTimestamp } from './score.ts'
40
+
41
+ const SKILL_DIR = new URL('../..', import.meta.url).pathname.replace(/\/$/, '')
42
+ const MAGPIE_BIN = join(SKILL_DIR, 'bin', 'magpie.ts')
43
+ const SPECIALISTS_MD = join(SKILL_DIR, 'references', 'specialists.md')
44
+ const DEFAULT_MAX_COST_USD = 3
45
+ const ARMS = ['base', 'impact'] as const
46
+ type Arm = (typeof ARMS)[number]
47
+
48
+ // Parts 2, 4 and the close of part 5 are fenced blocks the orchestrator copies
49
+ // by position, with no tag to find them by, so they are held here; a test checks
50
+ // each is still in references/specialists.md word for word.
51
+ export const RUN_HEADER_UNSHARDED = `You are reviewing <TARGET>.
52
+ Working directory: <WORKTREE>
53
+ Diff: <RUN_DIR>/diff.patch`
54
+
55
+ export const RUN_HEADER_SHARDED = `You are reviewing <TARGET>.
56
+ Working directory: <WORKTREE>
57
+ Shard: <n> of <N>
58
+ Diff: <RUN_DIR>/shards/shard-<n>.patch
59
+
60
+ This shard is your review scope. The other shards belong to other agents working
61
+ in parallel: do not review files outside your shard, and do not report findings
62
+ anchored to them. Reading any file in the worktree for context is expected and
63
+ encouraged.`
64
+
65
+ export const EXCLUDED_FILES_BLOCK = `Files excluded by the path filter are absent from your diff but present in the
66
+ worktree, with their full patches in <RUN_DIR>/diff.full.patch and the exclusion
67
+ list in <RUN_DIR>/excluded-files.json. When a change in your scope implies a change
68
+ in an excluded file (a migration implies a model snapshot, a schema change implies
69
+ generated types), open it and cross-check rather than treating it as out of scope.`
70
+
71
+ export const BRIEF_CLOSING = `This brief is the author's claim as understood by a reader who has not reviewed the
72
+ code. It is not ground truth. Where the diff contradicts it, that is a finding in
73
+ your domain, not a correction to the brief. A watch item is a pointer, not a verdict:
74
+ escalate it into a finding with your own risk fields, or leave it alone. A repository
75
+ review rule is a convention the maintainers wrote down: a change that breaks one is a
76
+ finding in your domain when the rule bears on it, citing the rule's source.`
77
+
78
+ function taggedBlock(md: string, tag: string): string {
79
+ const open = `\`\`\`${tag}\n`
80
+ const start = md.indexOf(open)
81
+ if (start === -1) throw new Error(`specialists.md has no ${tag} block`)
82
+ const end = md.indexOf('\n```', start + open.length)
83
+ if (end === -1) throw new Error(`specialists.md ${tag} block is not closed`)
84
+ return md.slice(start + open.length, end)
85
+ }
86
+
87
+ // The section runs to the next `## ` heading outside a fence; its own fenced
88
+ // schema must not end it.
89
+ function outputContract(md: string): string {
90
+ const lines = md.split('\n')
91
+ const start = lines.indexOf('## Output Contract')
92
+ if (start === -1) throw new Error('specialists.md has no ## Output Contract section')
93
+ let inFence = false
94
+ let end = lines.length
95
+ for (let i = start + 1; i < lines.length; i++) {
96
+ const line = lines[i] as string
97
+ if (line.startsWith('```')) inFence = !inFence
98
+ else if (!inFence && line.startsWith('## ')) {
99
+ end = i
100
+ break
101
+ }
102
+ }
103
+ return lines.slice(start, end).join('\n').trimEnd()
104
+ }
105
+
106
+ function fillRunPlaceholders(
107
+ text: string,
108
+ values: { runDir: string; worktree: string; target: string; focus: string },
109
+ ): string {
110
+ return text
111
+ .replaceAll('<RUN_DIR>', values.runDir)
112
+ .replaceAll('<WORKTREE>', values.worktree)
113
+ .replaceAll('<TARGET>', values.target)
114
+ .replaceAll('<focus>', values.focus)
115
+ }
116
+
117
+ /** The specialist prompt as references/specialists.md tells the orchestrator to build it. */
118
+ export function assembleSpecialistPrompt(input: {
119
+ specialistsMd: string
120
+ focus: string
121
+ runDir: string
122
+ worktree: string
123
+ target: string
124
+ shard: { n: number; total: number } | null
125
+ brief: string | null
126
+ excludedFiles: boolean
127
+ impact: { path: string; inline: string } | null
128
+ }): string {
129
+ const focusBlock = taggedBlock(input.specialistsMd, `magpie-specialist-${input.focus}`)
130
+ const impactPart =
131
+ input.impact === null
132
+ ? null
133
+ : // Replacer functions, because the entries are source code and `$&` or `$'`
134
+ // in a string replacement would splice in other text.
135
+ taggedBlock(input.specialistsMd, 'magpie-impact-part')
136
+ .replace('<top entries>', () => input.impact?.inline ?? '')
137
+ .replace('<the path above>', () => input.impact?.path ?? '')
138
+
139
+ const headerTemplate =
140
+ input.shard === null
141
+ ? RUN_HEADER_UNSHARDED
142
+ : RUN_HEADER_SHARDED.replaceAll('<n>', String(input.shard.n)).replace(
143
+ '<N>',
144
+ String(input.shard.total),
145
+ )
146
+ let header = fillRunPlaceholders(headerTemplate, input)
147
+ const impactPath = input.impact?.path
148
+ if (input.impact !== null) {
149
+ header = header.replace(
150
+ /^Diff: .*$/m,
151
+ (diffLine) => `${diffLine}\nOutside the diff: ${impactPath}`,
152
+ )
153
+ }
154
+
155
+ const parts = [
156
+ focusBlock,
157
+ header,
158
+ fillRunPlaceholders(outputContract(input.specialistsMd), input),
159
+ ]
160
+ if (input.excludedFiles) parts.push(fillRunPlaceholders(EXCLUDED_FILES_BLOCK, input))
161
+ if (input.brief !== null) parts.push(input.brief)
162
+ if (impactPart !== null) parts.push(impactPart)
163
+ return parts.join('\n\n')
164
+ }
165
+
166
+ function bulletSection(heading: string, items: string[]): string {
167
+ return [heading, ...items.map((item) => `- ${item}`)].join('\n')
168
+ }
169
+
170
+ /** Part 5 of the specialist prompt, rendered from brief.json. */
171
+ export function renderBriefPart(brief: PrBrief): string {
172
+ const sections = [
173
+ `## What this PR is for\n\n${brief.purpose}`,
174
+ bulletSection('What it does:', brief.changes),
175
+ ]
176
+ if (brief.watchItems.length > 0) sections.push(bulletSection('Watch items:', brief.watchItems))
177
+ if (brief.unclear.length > 0) {
178
+ sections.push(bulletSection('Open questions the scout could not resolve:', brief.unclear))
179
+ }
180
+ if (brief.reviewRules.length > 0) {
181
+ sections.push(
182
+ bulletSection(
183
+ 'Repository review rules:',
184
+ brief.reviewRules.map((r) => `${r.rule} (${r.source})`),
185
+ ),
186
+ )
187
+ }
188
+ sections.push(BRIEF_CLOSING)
189
+ return sections.join('\n\n')
190
+ }
191
+
192
+ // A killed session leaves its last line cut off. The lines before it still say
193
+ // what the session did, so a line that does not parse is counted, not fatal.
194
+ function streamEvents(streamJson: string): {
195
+ events: Array<Record<string, unknown>>
196
+ badLines: number
197
+ } {
198
+ const events: Array<Record<string, unknown>> = []
199
+ let badLines = 0
200
+ for (const line of streamJson.split('\n')) {
201
+ if (line.trim() === '') continue
202
+ let event: unknown
203
+ try {
204
+ event = JSON.parse(line)
205
+ } catch {
206
+ badLines++
207
+ continue
208
+ }
209
+ if (event !== null && typeof event === 'object' && !Array.isArray(event)) {
210
+ events.push(event as Record<string, unknown>)
211
+ }
212
+ }
213
+ return { events, badLines }
214
+ }
215
+
216
+ /** Transcript lines that are not JSON, most often one cut off when the session was killed. */
217
+ export function transcriptBadLines(streamJson: string): number {
218
+ return streamEvents(streamJson).badLines
219
+ }
220
+
221
+ /** Whether the session opened `path` with the Read tool. A Bash `cat` is not counted. */
222
+ export function transcriptReadPath(streamJson: string, path: string): boolean {
223
+ return streamEvents(streamJson).events.some((event) => {
224
+ if (event.type !== 'assistant') return false
225
+ const message = event.message as { content?: unknown } | undefined
226
+ if (!Array.isArray(message?.content)) return false
227
+ return message.content.some((block: unknown) => {
228
+ const b = block as { type?: unknown; name?: unknown; input?: { file_path?: unknown } }
229
+ return b.type === 'tool_use' && b.name === 'Read' && b.input?.file_path === path
230
+ })
231
+ })
232
+ }
233
+
234
+ /** What a finished session cost and, if it failed, why. Never throws: the
235
+ * session is paid for whatever happened, so its result file must still be written. */
236
+ export function sessionOutcome(
237
+ streamJson: string,
238
+ exit: number,
239
+ stderr: string,
240
+ ): { costUsd: number | null; error: string | null } {
241
+ const errors: string[] = []
242
+ let costUsd: number | null = null
243
+ const result = streamEvents(streamJson).events.find((event) => event.type === 'result')
244
+ if (result === undefined) {
245
+ errors.push('stream-json has no result event')
246
+ } else {
247
+ if (typeof result.total_cost_usd === 'number') costUsd = result.total_cost_usd
248
+ if (result.is_error === true) {
249
+ errors.push(`session ended in error (${String(result.subtype)}): ${String(result.result)}`)
250
+ }
251
+ }
252
+ if (exit !== 0) errors.push(`exit ${exit}: ${stderr.trim()}`)
253
+ return { costUsd, error: errors.length === 0 ? null : errors.join('; ') }
254
+ }
255
+
256
+ const CITATION = /([^\s`'"()[\],:]+):(\d+)/g
257
+
258
+ function citations(text: string): string[] {
259
+ return [...text.matchAll(CITATION)].map((m) => `${m[1]}:${m[2]}`)
260
+ }
261
+
262
+ /** Findings that cite, anywhere in their description, a file:line the impact map lists
263
+ * in a file outside the shard: the locations the map exists to point at. */
264
+ export function countOutsideCitations(
265
+ findings: ReviewFinding[],
266
+ impactMarkdown: string,
267
+ shardFiles: string[],
268
+ ): number {
269
+ const inShard = new Set(shardFiles)
270
+ const outside = new Set(
271
+ citations(impactMarkdown).filter((c) => !inShard.has(c.slice(0, c.lastIndexOf(':')))),
272
+ )
273
+ return findings.filter((f) => citations(f.description).some((c) => outside.has(c))).length
274
+ }
275
+
276
+ /** A probe judges findings straight from the specialist, before dedupe has
277
+ * given every one an id, so a missing id is filled from its position. */
278
+ export function numberProbeFindings(
279
+ raw: unknown,
280
+ focus: string,
281
+ shard: number,
282
+ path: string,
283
+ ): ReviewFinding[] {
284
+ if (!Array.isArray(raw)) throw new Error(`${path} is not a JSON array`)
285
+ return raw.map((entry, i) => {
286
+ const withId =
287
+ entry !== null && typeof entry === 'object' && !('id' in entry)
288
+ ? { ...entry, id: `${focus}-s${shard}-${i + 1}` }
289
+ : entry
290
+ try {
291
+ return parseFinding(withId)
292
+ } catch (err) {
293
+ throw new Error(`${path}[${i}]: ${err instanceof Error ? err.message : err}`)
294
+ }
295
+ })
296
+ }
297
+
298
+ /** A failure after the session was paid for, with its result file already written.
299
+ * It belongs to one repeat; any other failure is systematic and stops them all. */
300
+ export class PostSessionError extends Error {}
301
+
302
+ /** Runs repeats in order, returning how many failed after their session. */
303
+ export async function runRepeats(
304
+ count: number,
305
+ runOne: (repeat: number) => Promise<void>,
306
+ warn: (line: string) => void,
307
+ ): Promise<number> {
308
+ let failed = 0
309
+ for (let repeat = 1; repeat <= count; repeat++) {
310
+ try {
311
+ await runOne(repeat)
312
+ } catch (err) {
313
+ if (!(err instanceof PostSessionError)) throw err
314
+ warn(`repeat ${repeat} failed after its session: ${err.message}\n`)
315
+ failed++
316
+ }
317
+ }
318
+ return failed
319
+ }
320
+
321
+ type ProbeOptions = {
322
+ run: CorpusRun
323
+ repo: string
324
+ sha: string
325
+ target: string
326
+ focus: string
327
+ file: string
328
+ misses: Miss[]
329
+ arm: Arm
330
+ repeat: number
331
+ maxCostUsd: number
332
+ judgeModel: string
333
+ corpusRoot: string
334
+ dryRun: boolean
335
+ }
336
+
337
+ async function readShards(runDir: string): Promise<ShardEntry[]> {
338
+ const manifest = (await Bun.file(join(runDir, 'shards', 'manifest.json')).json()) as {
339
+ shards?: unknown
340
+ }
341
+ if (!Array.isArray(manifest.shards)) throw new Error('shard manifest has no shards array')
342
+ return manifest.shards as ShardEntry[]
343
+ }
344
+
345
+ async function readOptionalText(path: string): Promise<string | null> {
346
+ return existsSync(path) ? readFile(path, 'utf8') : null
347
+ }
348
+
349
+ async function readBriefPart(runDir: string): Promise<string | null> {
350
+ const path = join(runDir, 'brief.json')
351
+ if (!existsSync(path)) return null
352
+ const brief = parseBrief(JSON.parse(await readFile(path, 'utf8')))
353
+ if (brief === null) throw new Error(`${path} has no purpose, so it cannot be rendered as part 5`)
354
+ return renderBriefPart(brief)
355
+ }
356
+
357
+ // The shard's own impact file when sharded, the run's when not: never the whole
358
+ // run's map for one shard.
359
+ function impactPrefix(scratch: string, shard: { n: number; total: number } | null): string {
360
+ return join(scratch, shard === null ? 'impact' : `impact.shard-${shard.n}`)
361
+ }
362
+
363
+ /** The impact arm's part 6. A shard with no impact file has nothing to measure,
364
+ * so it is refused before any money is spent on it. */
365
+ export async function readImpactPart(
366
+ runDir: string,
367
+ shard: { n: number; total: number } | null,
368
+ ): Promise<{ path: string; inline: string }> {
369
+ const prefix = impactPrefix(runDir, shard)
370
+ const path = `${prefix}.md`
371
+ if (!existsSync(path)) {
372
+ const scope = shard === null ? 'the run' : `shard ${shard.n}`
373
+ throw new Error(
374
+ `magpie impact wrote no ${path} for ${scope}; the impact arm would measure nothing`,
375
+ )
376
+ }
377
+ const inline = await readOptionalText(`${prefix}.brief.md`)
378
+ if (inline === null) throw new Error(`${path} exists without ${prefix}.brief.md`)
379
+ return { path, inline: inline.trimEnd() }
380
+ }
381
+
382
+ async function runImpactStage(scratch: string): Promise<void> {
383
+ await exec(['bun', MAGPIE_BIN, 'impact', scratch])
384
+ // `magpie impact` exits 0 when it skips; an impact arm with no map is a base arm.
385
+ const lines = (await readFile(join(scratch, 'log.jsonl'), 'utf8')).trim().split('\n')
386
+ const last = JSON.parse(lines[lines.length - 1] as string) as Record<string, unknown>
387
+ if (last.stage !== 'impact' || last.status !== 'done') {
388
+ throw new Error(`magpie impact did not finish: ${JSON.stringify(last)}`)
389
+ }
390
+ }
391
+
392
+ // Not `exec`: a failed session still has a transcript and a cost to record.
393
+ async function runSession(
394
+ prompt: string,
395
+ worktree: string,
396
+ scratch: string,
397
+ maxCostUsd: number,
398
+ ): Promise<{ stdout: string; stderr: string; exit: number }> {
399
+ const proc = Bun.spawn(
400
+ [
401
+ 'claude',
402
+ '-p',
403
+ '--output-format',
404
+ 'stream-json',
405
+ '--verbose',
406
+ '--allowedTools',
407
+ 'Bash,Read,Grep,Glob,Write',
408
+ '--max-budget-usd',
409
+ String(maxCostUsd),
410
+ '--add-dir',
411
+ scratch,
412
+ ],
413
+ { cwd: worktree, stdin: new Blob([prompt]), stdout: 'pipe', stderr: 'pipe' },
414
+ )
415
+ const [stdout, stderr, exit] = await Promise.all([
416
+ new Response(proc.stdout).text(),
417
+ new Response(proc.stderr).text(),
418
+ proc.exited,
419
+ ])
420
+ return { stdout, stderr, exit }
421
+ }
422
+
423
+ async function probeOnce(opts: ProbeOptions): Promise<void> {
424
+ const { run, focus } = opts
425
+ // Resolved so the path in the prompt is the one the session's Read calls carry.
426
+ const scratch = await realpath(await mkdtemp(join(tmpdir(), 'magpie-probe-')))
427
+ const worktree = join(scratch, 'worktree')
428
+ let worktreeCreated = false
429
+ try {
430
+ await exec(['git', '-C', opts.repo, 'worktree', 'add', '--detach', worktree, opts.sha])
431
+ worktreeCreated = true
432
+ await copyCorpusFiles(run, scratch, ['pr.json', 'diff.patch', 'brief.json'])
433
+ if (!existsSync(join(scratch, 'diff.patch'))) throw new Error(`${run.dir} has no diff.patch`)
434
+ // Not corpus files today, but a run that has them gets part 4 as setup would.
435
+ const excludedFiles = existsSync(join(run.dir, 'excluded-files.json'))
436
+ for (const name of ['excluded-files.json', 'diff.full.patch']) {
437
+ if (existsSync(join(run.dir, name))) await copyFile(join(run.dir, name), join(scratch, name))
438
+ }
439
+ await mkdir(join(scratch, 'findings'))
440
+ await writeFile(
441
+ join(scratch, 'log.jsonl'),
442
+ `${JSON.stringify({ stage: 'setup', status: 'done', worktree, ts: Date.now() })}\n`,
443
+ )
444
+ await exec(['bun', MAGPIE_BIN, 'shard', scratch])
445
+
446
+ const shards = await readShards(scratch)
447
+ const chosen = shards.find((s) => s.files.includes(opts.file))
448
+ if (chosen === undefined) throw new Error(`no shard of ${run.runId} contains ${opts.file}`)
449
+ const shard = shards.length > 1 ? { n: chosen.id, total: shards.length } : null
450
+
451
+ if (opts.arm === 'impact') await runImpactStage(scratch)
452
+ const impact = opts.arm === 'impact' ? await readImpactPart(scratch, shard) : null
453
+ const prompt = assembleSpecialistPrompt({
454
+ specialistsMd: await readFile(SPECIALISTS_MD, 'utf8'),
455
+ focus,
456
+ runDir: scratch,
457
+ worktree,
458
+ target: opts.target,
459
+ shard,
460
+ brief: await readBriefPart(scratch),
461
+ excludedFiles,
462
+ impact,
463
+ })
464
+
465
+ if (opts.dryRun) {
466
+ process.stdout.write(
467
+ `repeat ${opts.repeat}: shard ${chosen.id} of ${shards.length} (${chosen.files.length} files), ` +
468
+ `impact ${impact === null ? 'none' : impact.path}, prompt ${prompt.length} chars\n` +
469
+ `${prompt}\n`,
470
+ )
471
+ return
472
+ }
473
+
474
+ // Checked before the session so a corpus root that cannot take the result fails
475
+ // before anything is spent.
476
+ const resultsDir = join(opts.corpusRoot, 'results')
477
+ await mkdir(resultsDir, { recursive: true })
478
+ await access(resultsDir, constants.W_OK)
479
+ const outPath = join(
480
+ resultsDir,
481
+ `${resultTimestamp(new Date())}-probe-${run.runId}-${focus}-s${chosen.id}-${opts.arm}-r${opts.repeat}.json`,
482
+ )
483
+
484
+ const session = await runSession(prompt, worktree, scratch, opts.maxCostUsd)
485
+ const outcome = sessionOutcome(session.stdout, session.exit, session.stderr)
486
+ const findingsName = shard === null ? `${focus}.json` : `${focus}.shard-${shard.n}.json`
487
+ const findingsPath = join(scratch, 'findings', findingsName)
488
+ // Null fields are steps not yet reached; a probe that stops early leaves them null
489
+ // and says why in `error`.
490
+ const result = {
491
+ tier: 'probe',
492
+ runId: run.runId,
493
+ focus,
494
+ shard: chosen.id,
495
+ arm: opts.arm,
496
+ repeat: opts.repeat,
497
+ missIds: opts.misses.map((m) => m.id),
498
+ caught: [] as string[],
499
+ verdicts: [] as RecallVerdict[],
500
+ judged: false,
501
+ notJudged: null as string | null,
502
+ findings: null as number | null,
503
+ missingFindings: null as string | null,
504
+ /** Kept only when the session wrote no findings file, the case no one could explain. */
505
+ transcript: null as string | null,
506
+ readImpact: null as boolean | null,
507
+ citesOutside: null as number | null,
508
+ costUsd: outcome.costUsd,
509
+ judgeCostUsd: null as number | null,
510
+ sessionError: outcome.error,
511
+ transcriptBadLines: transcriptBadLines(session.stdout),
512
+ error: null as string | null,
513
+ }
514
+ const save = () => writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`)
515
+ // Saved before anything else can throw, so a paid session's cost is never lost.
516
+ await save()
517
+
518
+ try {
519
+ const findings = existsSync(findingsPath)
520
+ ? numberProbeFindings(
521
+ JSON.parse(await readFile(findingsPath, 'utf8')),
522
+ focus,
523
+ chosen.id,
524
+ findingsPath,
525
+ )
526
+ : []
527
+ result.findings = findings.length
528
+ result.missingFindings = existsSync(findingsPath) ? null : `session wrote no ${findingsName}`
529
+ if (result.missingFindings !== null) {
530
+ const transcript = outPath.replace(/\.json$/, '.transcript.jsonl')
531
+ await writeFile(transcript, session.stdout)
532
+ result.transcript = transcript
533
+ }
534
+ result.readImpact = impact === null ? null : transcriptReadPath(session.stdout, impact.path)
535
+ await save()
536
+
537
+ // A base-arm session has ended before this map exists, so it cannot have read
538
+ // it; computing it here lets both arms count outside citations the same way.
539
+ if (opts.arm === 'base') await runImpactStage(scratch)
540
+ const impactMarkdown = (await readOptionalText(`${impactPrefix(scratch, shard)}.md`)) ?? ''
541
+ result.citesOutside = countOutsideCitations(findings, impactMarkdown, chosen.files)
542
+ await save()
543
+
544
+ // With no findings every verdict is null, so the judge is not paid to say so.
545
+ if (findings.length === 0) {
546
+ result.notJudged = result.missingFindings ?? 'no findings to judge'
547
+ } else {
548
+ const judged = await judgeRecall({
549
+ misses: opts.misses,
550
+ findings,
551
+ model: opts.judgeModel,
552
+ cwd: scratch,
553
+ })
554
+ result.verdicts = judged.verdicts
555
+ result.caught = judged.verdicts.filter((v) => v.findingId !== null).map((v) => v.missId)
556
+ result.judgeCostUsd = judged.costUsd
557
+ result.judged = true
558
+ }
559
+ await save()
560
+ } catch (err) {
561
+ result.error = err instanceof Error ? err.message : String(err)
562
+ await save()
563
+ throw new PostSessionError(`${result.error} (probe result written to ${outPath})`)
564
+ }
565
+
566
+ if (outcome.error !== null) process.exitCode = 1
567
+ process.stdout.write(
568
+ `${run.runId} ${focus} s${chosen.id} ${opts.arm} r${opts.repeat}: ` +
569
+ `caught ${result.caught.length}/${opts.misses.length}${result.judged ? '' : ' (not judged)'}, ` +
570
+ `findings ${result.findings}, ` +
571
+ `read impact ${result.readImpact === null ? '-' : result.readImpact}, ` +
572
+ `cites outside ${result.citesOutside}, ` +
573
+ `cost ${outcome.costUsd === null ? 'unreported' : `$${outcome.costUsd.toFixed(2)}`}` +
574
+ `${outcome.error === null ? '' : `, session error: ${outcome.error}`}\nwrote ${outPath}\n`,
575
+ )
576
+ } finally {
577
+ // A failed removal must not mask the error that got us here, but it must be seen.
578
+ const removeCommand = worktreeRemoveCommand(opts.repo, worktree, worktreeCreated)
579
+ if (removeCommand) {
580
+ await exec(removeCommand).catch((err) => {
581
+ process.stderr.write(`probe-specialist: worktree removal failed: ${err.message}\n`)
582
+ process.exitCode = 1
583
+ })
584
+ }
585
+ await rm(scratch, { recursive: true, force: true })
586
+ }
587
+ }
588
+
589
+ const USAGE =
590
+ 'usage: probe-specialist.ts --corpus <runId> --repo <path> --focus <focus> --file <diff path> ' +
591
+ '--misses <id,id> --arm base|impact --repeats <n> [--max-cost-usd N] [--judge-model M] ' +
592
+ '[--out <corpus-dir>] [--dry-run]'
593
+
594
+ async function main(argv: string[]): Promise<number> {
595
+ const dryRun = argv.includes('--dry-run')
596
+ const flags = parseFlags(
597
+ argv.filter((a) => a !== '--dry-run'),
598
+ [
599
+ 'corpus',
600
+ 'repo',
601
+ 'focus',
602
+ 'file',
603
+ 'misses',
604
+ 'arm',
605
+ 'repeats',
606
+ 'max-cost-usd',
607
+ 'judge-model',
608
+ 'out',
609
+ ],
610
+ )
611
+ const { corpus, repo, focus, file, arm, repeats } = flags
612
+ if (!corpus || !repo || !focus || !file || !flags.misses || !arm || !repeats)
613
+ throw new Error(USAGE)
614
+ if (!ARMS.includes(arm as Arm)) throw new Error(`--arm ${arm}: want base or impact`)
615
+ if (!/^[1-9]\d*$/.test(repeats)) throw new Error(`--repeats ${repeats}: want an integer >= 1`)
616
+ const maxCostUsd = positiveNumberFlag(flags, 'max-cost-usd') ?? DEFAULT_MAX_COST_USD
617
+ const judgeModel = flags['judge-model'] ?? DEFAULT_JUDGE_MODEL
618
+
619
+ const corpusRoot = resolveCorpusRoot(flags)
620
+ const run = await loadCorpusRun(join(corpusRoot, corpus))
621
+ const sha = headRefOid(run)
622
+ const prNumber = run.pr?.number
623
+ if (typeof prNumber !== 'number') throw new Error(`${join(run.dir, 'pr.json')} has no number`)
624
+ const missesPath = join(corpusRoot, 'misses', `${run.runId}.json`)
625
+ const allMisses = await loadMisses(missesPath)
626
+ if (allMisses === null) throw new Error(`${missesPath} does not exist`)
627
+ const misses = flags.misses.split(',').map((id) => {
628
+ const miss = allMisses.find((m) => m.id === id)
629
+ if (miss === undefined) throw new Error(`${missesPath} has no miss ${id}`)
630
+ return miss
631
+ })
632
+ await exec(['git', '-C', repo, 'cat-file', '-e', `${sha}^{commit}`]).catch((err) => {
633
+ throw new Error(
634
+ `commit ${sha} is not in ${repo}; fetch the PR head there first (${err.message})`,
635
+ )
636
+ })
637
+
638
+ const failed = await runRepeats(
639
+ Number(repeats),
640
+ (repeat) =>
641
+ probeOnce({
642
+ run,
643
+ repo,
644
+ sha,
645
+ target: `PR #${prNumber}`,
646
+ focus,
647
+ file,
648
+ misses,
649
+ arm: arm as Arm,
650
+ repeat,
651
+ maxCostUsd,
652
+ judgeModel,
653
+ corpusRoot,
654
+ dryRun,
655
+ }),
656
+ (line) => process.stderr.write(line),
657
+ )
658
+ if (failed > 0) process.exitCode = 1
659
+ return 0
660
+ }
661
+
662
+ if (import.meta.main) {
663
+ main(process.argv.slice(2)).then(
664
+ (code) => process.exit(process.exitCode || code),
665
+ (err) => {
666
+ process.stderr.write(
667
+ `probe-specialist: ${err instanceof Error ? err.message : String(err)}\n`,
668
+ )
669
+ process.exit(1)
670
+ },
671
+ )
672
+ }