@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.
- package/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- 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
|
+
}
|