@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/bug-hunter/SKILL.md +5 -0
- package/skills/bug-hunter/evals/README.md +6 -1
- package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
- package/skills/bug-hunter/evals/rules.json +8 -0
- package/skills/bug-hunter/skill.json +1 -1
- package/skills/index.json +4 -4
- package/skills/magpie/README.md +5 -3
- package/skills/magpie/SKILL.md +40 -15
- package/skills/magpie/bin/magpie.ts +83 -7
- package/skills/magpie/evals/README.md +7 -4
- package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
- package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
- package/skills/magpie/evals/quality/README.md +73 -2
- package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
- package/skills/magpie/evals/quality/corpus.ts +4 -1
- package/skills/magpie/evals/quality/recall.ts +249 -0
- package/skills/magpie/evals/quality/replay-critic.ts +3 -0
- package/skills/magpie/evals/quality/replay-full.ts +168 -6
- package/skills/magpie/evals/rules.json +9 -1
- package/skills/magpie/references/checks.md +78 -0
- package/skills/magpie/references/peer-review.md +7 -0
- package/skills/magpie/references/scout.md +12 -5
- package/skills/magpie/references/specialists.md +10 -8
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
- package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
- package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
- package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
- package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
- package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
- package/skills/magpie/scripts/critic-cmd.ts +2 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
- package/skills/magpie/scripts/handoff-cmd.ts +132 -0
- package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
- package/skills/magpie/scripts/local-diff.ts +228 -0
- package/skills/magpie/scripts/post-cmd.ts +1 -0
- package/skills/magpie/scripts/render-annotation.ts +1 -0
- package/skills/magpie/scripts/resolve-run.ts +17 -3
- package/skills/magpie/scripts/server.ts +9 -1
- package/skills/magpie/scripts/setup-cmd.ts +241 -49
- package/skills/magpie/scripts/status-cmd.ts +22 -6
- package/skills/magpie/scripts/target.ts +56 -0
- package/skills/magpie/scripts/types.ts +1 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/sluice/SKILL.md +3 -1
- package/skills/sluice/evals/README.md +10 -2
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
- package/skills/sluice/evals/rules.json +11 -0
- package/skills/sluice/references/review.md +6 -4
- package/skills/sluice/references/test-first.md +24 -4
- package/skills/sluice/scripts/content-digest.sh +1 -1
- package/skills/sluice/scripts/run-stats.sh +14 -0
- package/skills/sluice/scripts/session-start.sh +44 -2
- package/skills/sluice/scripts/status.sh +293 -0
- package/skills/sluice/scripts/stop-guard.sh +80 -6
- package/skills/sluice/scripts/test-changes.sh +135 -0
- package/skills/sluice/skill.json +1 -1
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import { appendFile, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
3
|
import { fetchPr } from './gh.ts'
|
|
4
4
|
import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
|
|
5
|
+
import { diffSha256, localBranchDiff, localBranchMeta } from './local-diff.ts'
|
|
5
6
|
import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
|
|
6
7
|
import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
|
|
7
8
|
import { shardDiff } from './shard.ts'
|
|
9
|
+
import { writeTarget } from './target.ts'
|
|
8
10
|
import { detectMissingTests } from './tests-check.ts'
|
|
9
|
-
import { createWorktree } from './worktree.ts'
|
|
11
|
+
import { createWorktree, removeWorktree } from './worktree.ts'
|
|
10
12
|
|
|
11
13
|
export type RunSetupInput = {
|
|
12
14
|
runDir: string
|
|
13
|
-
prNumber: number
|
|
14
15
|
repoPath: string
|
|
15
16
|
deps?: Deps
|
|
16
|
-
}
|
|
17
|
+
} & ({ mode: 'pr'; prNumber: number } | { mode: 'local'; baseRef?: string; intentFile?: string })
|
|
17
18
|
|
|
18
19
|
async function logLine(runDir: string, entry: Record<string, unknown>): Promise<void> {
|
|
19
20
|
await appendFile(join(runDir, 'log.jsonl'), `${JSON.stringify({ ...entry, ts: Date.now() })}\n`)
|
|
@@ -21,20 +22,58 @@ async function logLine(runDir: string, entry: Record<string, unknown>): Promise<
|
|
|
21
22
|
|
|
22
23
|
export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
23
24
|
const deps = input.deps ?? defaultDeps()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Absolute so target.json and every git call agree on the repo whatever the cwd.
|
|
26
|
+
const repoPath = resolve(input.repoPath)
|
|
27
|
+
// Resolved once so the inside-repo guard checks the same path mkdir creates:
|
|
28
|
+
// left raw, the OS would follow `..` through a symlink the guard resolved textually.
|
|
29
|
+
const runDir = resolve(input.runDir)
|
|
30
|
+
if (input.mode === 'local') {
|
|
31
|
+
const repoStat = await stat(repoPath).catch((err: NodeJS.ErrnoException) => {
|
|
32
|
+
if (err.code === 'ENOENT') return null
|
|
33
|
+
throw err
|
|
34
|
+
})
|
|
35
|
+
if (repoStat === null) {
|
|
36
|
+
process.stderr.write(`magpie: --repo ${repoPath} does not exist\n`)
|
|
37
|
+
return 2
|
|
38
|
+
}
|
|
39
|
+
if (!repoStat.isDirectory()) {
|
|
40
|
+
process.stderr.write(`magpie: --repo ${repoPath} is not a directory\n`)
|
|
41
|
+
return 2
|
|
42
|
+
}
|
|
43
|
+
// Checked before any mkdir: a --repo below the root would otherwise let a
|
|
44
|
+
// run dir elsewhere in the repo pass the guard below and be created there.
|
|
45
|
+
const top = await gitToplevel(deps.git, repoPath)
|
|
46
|
+
if (!top.ok) {
|
|
47
|
+
process.stderr.write(`magpie: ${top.error}\n`)
|
|
48
|
+
return 2
|
|
49
|
+
}
|
|
50
|
+
if ((await realpath(repoPath)) !== (await realpath(top.root))) {
|
|
51
|
+
process.stderr.write(`magpie: --repo ${repoPath} is not the repository root ${top.root}\n`)
|
|
52
|
+
return 2
|
|
53
|
+
}
|
|
54
|
+
// Local mode reviews the repo in place, so a run dir inside it would be
|
|
55
|
+
// written to and its rollback would delete the caller's files.
|
|
56
|
+
if (await isWithin(runDir, top.root)) {
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
`magpie: run dir ${runDir} is inside the repository under review ${top.root}\n`,
|
|
59
|
+
)
|
|
60
|
+
return 2
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await mkdir(runDir, { recursive: true })
|
|
64
|
+
await mkdir(join(runDir, 'findings'), { recursive: true })
|
|
65
|
+
await mkdir(join(runDir, 'screen'), { recursive: true })
|
|
66
|
+
await mkdir(join(runDir, 'state'), { recursive: true })
|
|
28
67
|
|
|
29
68
|
const preResult = await preflight(deps)
|
|
30
69
|
if (!preResult.ok) {
|
|
31
|
-
await logLine(
|
|
70
|
+
await logLine(runDir, { stage: 'setup', status: 'error', missing: preResult.missing })
|
|
32
71
|
process.stderr.write(`magpie: missing dependencies:\n${renderInstallHint(preResult.missing)}\n`)
|
|
33
|
-
await cleanup(
|
|
72
|
+
await cleanup(runDir)
|
|
34
73
|
return 3
|
|
35
74
|
}
|
|
36
75
|
if (preResult.missingOptional.length > 0) {
|
|
37
|
-
await logLine(
|
|
76
|
+
await logLine(runDir, {
|
|
38
77
|
stage: 'preflight',
|
|
39
78
|
status: 'done',
|
|
40
79
|
missingOptional: preResult.missingOptional,
|
|
@@ -43,77 +82,230 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
43
82
|
`magpie: optional dependency unavailable, some stages will be skipped:\n${renderInstallHint(preResult.missingOptional)}\n`,
|
|
44
83
|
)
|
|
45
84
|
} else {
|
|
46
|
-
await logLine(
|
|
85
|
+
await logLine(runDir, { stage: 'preflight', status: 'done' })
|
|
47
86
|
}
|
|
48
87
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!fetched.ok) {
|
|
57
|
-
await logLine(input.runDir, { stage: 'fetch-pr', status: 'error', error: fetched.error })
|
|
58
|
-
process.stderr.write(`magpie: ${fetched.error}\n`)
|
|
59
|
-
await cleanup(input.runDir)
|
|
60
|
-
return 4
|
|
88
|
+
const diffExit =
|
|
89
|
+
input.mode === 'local'
|
|
90
|
+
? await writeLocalDiff(runDir, deps.git, repoPath, input.baseRef, input.intentFile)
|
|
91
|
+
: await writePrDiff(runDir, deps, repoPath, input.prNumber)
|
|
92
|
+
if (diffExit !== 0) {
|
|
93
|
+
await cleanup(runDir)
|
|
94
|
+
return diffExit
|
|
61
95
|
}
|
|
62
|
-
await logLine(input.runDir, {
|
|
63
|
-
stage: 'fetch-pr',
|
|
64
|
-
status: 'done',
|
|
65
|
-
source: fetched.source,
|
|
66
|
-
...(fetched.mergeBase ? { mergeBase: fetched.mergeBase } : {}),
|
|
67
|
-
})
|
|
68
|
-
// Sidecar so the report can say where the diff came from without re-parsing
|
|
69
|
-
// log.jsonl, the same way incremental.json carries the incremental context.
|
|
70
|
-
await writeFile(
|
|
71
|
-
join(input.runDir, 'diff-source.json'),
|
|
72
|
-
`${JSON.stringify({ source: fetched.source, mergeBase: fetched.mergeBase ?? null }, null, 2)}\n`,
|
|
73
|
-
)
|
|
74
96
|
|
|
75
|
-
await applyPathFilter(
|
|
97
|
+
await applyPathFilter(runDir, repoPath)
|
|
76
98
|
|
|
77
99
|
try {
|
|
78
|
-
await runShard(
|
|
100
|
+
await runShard(runDir)
|
|
79
101
|
} catch (err) {
|
|
80
102
|
const message = err instanceof Error ? err.message : String(err)
|
|
81
|
-
await logLine(
|
|
103
|
+
await logLine(runDir, { stage: 'shard', status: 'error', error: message })
|
|
82
104
|
process.stderr.write(`magpie: ${message}\n`)
|
|
83
|
-
await cleanup(
|
|
105
|
+
await cleanup(runDir)
|
|
84
106
|
return 6
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
await runTestsCheck(
|
|
109
|
+
await runTestsCheck(runDir)
|
|
88
110
|
|
|
89
|
-
|
|
111
|
+
if (input.mode === 'local') {
|
|
112
|
+
await logLine(runDir, { stage: 'setup', status: 'done', root: repoPath })
|
|
113
|
+
return 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await detectIncrementalReview(runDir, input.prNumber)
|
|
90
117
|
|
|
91
|
-
const prJson = (await Bun.file(join(
|
|
118
|
+
const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as { headRefName: string }
|
|
92
119
|
const branch = prJson.headRefName
|
|
93
120
|
|
|
94
121
|
const wt = await createWorktree({
|
|
95
122
|
gitBin: deps.git,
|
|
96
|
-
repoPath
|
|
123
|
+
repoPath,
|
|
97
124
|
branch,
|
|
98
|
-
runDir:
|
|
125
|
+
runDir: runDir,
|
|
99
126
|
})
|
|
100
127
|
if (!wt.ok) {
|
|
101
|
-
await logLine(
|
|
128
|
+
await logLine(runDir, { stage: 'worktree', status: 'error', error: wt.error })
|
|
102
129
|
process.stderr.write(`magpie: ${wt.error}\n`)
|
|
103
|
-
await cleanup(
|
|
130
|
+
await cleanup(runDir)
|
|
131
|
+
return 5
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await writeTarget(runDir, { mode: 'pr', root: resolve(runDir, 'worktree') })
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
137
|
+
await logLine(runDir, { stage: 'worktree', status: 'error', error: message })
|
|
138
|
+
process.stderr.write(`magpie: ${message}\n`)
|
|
139
|
+
// The worktree is registered in the caller's repo; deleting the dir alone would leave it listed.
|
|
140
|
+
const removed = await removeWorktree({
|
|
141
|
+
gitBin: deps.git,
|
|
142
|
+
repoPath,
|
|
143
|
+
worktreePath: wt.worktreePath,
|
|
144
|
+
})
|
|
145
|
+
if (!removed.ok) process.stderr.write(`magpie: ${removed.error}\n`)
|
|
146
|
+
await cleanup(runDir)
|
|
104
147
|
return 5
|
|
105
148
|
}
|
|
106
|
-
await logLine(
|
|
149
|
+
await logLine(runDir, { stage: 'setup', status: 'done', worktree: wt.worktreePath })
|
|
150
|
+
return 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** realpath of the nearest existing ancestor, with the not-yet-created tail re-appended. */
|
|
154
|
+
async function realpathAllowingMissing(path: string): Promise<string> {
|
|
155
|
+
const absolute = resolve(path)
|
|
156
|
+
try {
|
|
157
|
+
return await realpath(absolute)
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err
|
|
160
|
+
const parent = dirname(absolute)
|
|
161
|
+
if (parent === absolute) throw err
|
|
162
|
+
return join(await realpathAllowingMissing(parent), basename(absolute))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function gitToplevel(
|
|
167
|
+
gitBin: string,
|
|
168
|
+
repoPath: string,
|
|
169
|
+
): Promise<{ ok: true; root: string } | { ok: false; error: string }> {
|
|
170
|
+
const proc = Bun.spawn([gitBin, 'rev-parse', '--show-toplevel'], {
|
|
171
|
+
cwd: repoPath,
|
|
172
|
+
stdout: 'pipe',
|
|
173
|
+
stderr: 'pipe',
|
|
174
|
+
})
|
|
175
|
+
const [stdout, stderr] = await Promise.all([
|
|
176
|
+
new Response(proc.stdout).text(),
|
|
177
|
+
new Response(proc.stderr).text(),
|
|
178
|
+
])
|
|
179
|
+
if ((await proc.exited) !== 0) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: `--repo ${repoPath} is not inside a git work tree: ${stderr.trim()}`,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, root: stdout.trim() }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function isWithin(path: string, root: string): Promise<boolean> {
|
|
189
|
+
const rel = relative(await realpathAllowingMissing(root), await realpathAllowingMissing(path))
|
|
190
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function writePrDiff(
|
|
194
|
+
runDir: string,
|
|
195
|
+
deps: Deps,
|
|
196
|
+
repoPath: string,
|
|
197
|
+
prNumber: number,
|
|
198
|
+
): Promise<number> {
|
|
199
|
+
const fetched = await fetchPr({
|
|
200
|
+
ghBin: deps.gh,
|
|
201
|
+
gitBin: deps.git,
|
|
202
|
+
prNumber,
|
|
203
|
+
runDir,
|
|
204
|
+
cwd: repoPath,
|
|
205
|
+
})
|
|
206
|
+
if (!fetched.ok) {
|
|
207
|
+
await logLine(runDir, { stage: 'fetch-pr', status: 'error', error: fetched.error })
|
|
208
|
+
process.stderr.write(`magpie: ${fetched.error}\n`)
|
|
209
|
+
return 4
|
|
210
|
+
}
|
|
211
|
+
await logLine(runDir, {
|
|
212
|
+
stage: 'fetch-pr',
|
|
213
|
+
status: 'done',
|
|
214
|
+
source: fetched.source,
|
|
215
|
+
...(fetched.mergeBase ? { mergeBase: fetched.mergeBase } : {}),
|
|
216
|
+
})
|
|
217
|
+
// Sidecar so the report can say where the diff came from without re-parsing
|
|
218
|
+
// log.jsonl, the same way incremental.json carries the incremental context.
|
|
219
|
+
await writeFile(
|
|
220
|
+
join(runDir, 'diff-source.json'),
|
|
221
|
+
`${JSON.stringify({ source: fetched.source, mergeBase: fetched.mergeBase ?? null }, null, 2)}\n`,
|
|
222
|
+
)
|
|
223
|
+
return 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function writeLocalDiff(
|
|
227
|
+
runDir: string,
|
|
228
|
+
gitBin: string,
|
|
229
|
+
repoPath: string,
|
|
230
|
+
baseRef: string | undefined,
|
|
231
|
+
intentFile: string | undefined,
|
|
232
|
+
): Promise<number> {
|
|
233
|
+
const intentPath = intentFile === undefined ? null : resolve(intentFile)
|
|
234
|
+
if (intentPath !== null) {
|
|
235
|
+
const intentStat = await stat(intentPath).catch((err: NodeJS.ErrnoException) => {
|
|
236
|
+
if (err.code === 'ENOENT') return null
|
|
237
|
+
throw err
|
|
238
|
+
})
|
|
239
|
+
let error: string | null = null
|
|
240
|
+
if (intentStat === null) error = `intent file ${intentPath} does not exist`
|
|
241
|
+
else if (!intentStat.isFile()) error = `intent file ${intentPath} is not a file`
|
|
242
|
+
if (error !== null) {
|
|
243
|
+
await logLine(runDir, { stage: 'local-diff', status: 'error', error })
|
|
244
|
+
process.stderr.write(`magpie: ${error}\n`)
|
|
245
|
+
return 4
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const diff = await localBranchDiff({
|
|
249
|
+
gitBin,
|
|
250
|
+
repoPath,
|
|
251
|
+
...(baseRef !== undefined ? { baseRef } : {}),
|
|
252
|
+
})
|
|
253
|
+
if (!diff.ok) {
|
|
254
|
+
await logLine(runDir, { stage: 'local-diff', status: 'error', error: diff.error })
|
|
255
|
+
process.stderr.write(`magpie: ${diff.error}\n`)
|
|
256
|
+
return 4
|
|
257
|
+
}
|
|
258
|
+
await writeFile(join(runDir, 'diff.patch'), diff.diff)
|
|
259
|
+
const meta = await localBranchMeta({
|
|
260
|
+
gitBin,
|
|
261
|
+
repoPath,
|
|
262
|
+
baseRef: diff.baseRef,
|
|
263
|
+
mergeBase: diff.mergeBase,
|
|
264
|
+
head: diff.head,
|
|
265
|
+
intentFile: intentPath,
|
|
266
|
+
})
|
|
267
|
+
if (!meta.ok) {
|
|
268
|
+
await logLine(runDir, { stage: 'local-diff', status: 'error', error: meta.error })
|
|
269
|
+
process.stderr.write(`magpie: ${meta.error}\n`)
|
|
270
|
+
return 4
|
|
271
|
+
}
|
|
272
|
+
// Hash the raw diff: applyPathFilter rewrites diff.patch next, and handoff
|
|
273
|
+
// compares against a fresh raw diff, so a filtered hash would always read as stale.
|
|
274
|
+
await writeFile(
|
|
275
|
+
join(runDir, 'diff-source.json'),
|
|
276
|
+
`${JSON.stringify(
|
|
277
|
+
{
|
|
278
|
+
source: 'local',
|
|
279
|
+
mergeBase: diff.mergeBase,
|
|
280
|
+
baseRef: diff.baseRef,
|
|
281
|
+
head: diff.head,
|
|
282
|
+
diffSha256: diffSha256(diff.diff),
|
|
283
|
+
},
|
|
284
|
+
null,
|
|
285
|
+
2,
|
|
286
|
+
)}\n`,
|
|
287
|
+
)
|
|
288
|
+
await writeFile(join(runDir, 'local.json'), `${JSON.stringify(meta.meta, null, 2)}\n`)
|
|
289
|
+
await writeTarget(runDir, { mode: 'local', root: repoPath })
|
|
290
|
+
await logLine(runDir, {
|
|
291
|
+
stage: 'local-diff',
|
|
292
|
+
status: 'done',
|
|
293
|
+
mergeBase: diff.mergeBase,
|
|
294
|
+
baseRef: diff.baseRef,
|
|
295
|
+
})
|
|
107
296
|
return 0
|
|
108
297
|
}
|
|
109
298
|
|
|
110
299
|
async function cleanup(runDir: string): Promise<void> {
|
|
300
|
+
// Names inside the run dir only: a local run's review root is the caller's repo.
|
|
111
301
|
for (const name of [
|
|
112
302
|
'worktree',
|
|
113
303
|
'pr.json',
|
|
114
304
|
'diff.patch',
|
|
115
305
|
'diff.full.patch',
|
|
116
306
|
'diff-source.json',
|
|
307
|
+
'local.json',
|
|
308
|
+
'target.json',
|
|
117
309
|
'excluded-files.json',
|
|
118
310
|
'shards',
|
|
119
311
|
'incremental.json',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { readTarget } from './target.ts'
|
|
3
4
|
|
|
4
5
|
/** The resume ladder. Exported so `skill-lint` can assert that the diagnostic
|
|
5
6
|
* stage names SKILL.md logs (`filter`, `tests-check`, `shard-coverage`, ...) stay
|
|
@@ -15,6 +16,19 @@ export const ORDER = [
|
|
|
15
16
|
'post',
|
|
16
17
|
] as const
|
|
17
18
|
|
|
19
|
+
/** Local mode has no PR to report on or post to; it hands findings back instead. */
|
|
20
|
+
export const LOCAL_ORDER = [
|
|
21
|
+
'setup',
|
|
22
|
+
'context',
|
|
23
|
+
'specialists',
|
|
24
|
+
'dedupe',
|
|
25
|
+
'critic',
|
|
26
|
+
'peer-review',
|
|
27
|
+
'handoff',
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
type Stage = (typeof ORDER)[number] | (typeof LOCAL_ORDER)[number]
|
|
31
|
+
|
|
18
32
|
export type StatusResult = {
|
|
19
33
|
/**
|
|
20
34
|
* Highest stage the log says is behind us. A stage logged `skipped` counts:
|
|
@@ -22,12 +36,14 @@ export type StatusResult = {
|
|
|
22
36
|
* has nothing to go back for, so treating it as unfinished would send a resume
|
|
23
37
|
* to a stage with no remaining work.
|
|
24
38
|
*/
|
|
25
|
-
lastCompleted:
|
|
26
|
-
next:
|
|
39
|
+
lastCompleted: Stage | null
|
|
40
|
+
next: Stage | 'cleanup'
|
|
27
41
|
error: string | null
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export async function runStatus(runDir: string): Promise<StatusResult> {
|
|
45
|
+
const { mode } = await readTarget(runDir)
|
|
46
|
+
const ladder: readonly Stage[] = mode === 'local' ? LOCAL_ORDER : ORDER
|
|
31
47
|
const text = await readFile(join(runDir, 'log.jsonl'), 'utf8').catch(() => '')
|
|
32
48
|
let lastCompleted: StatusResult['lastCompleted'] = null
|
|
33
49
|
let error: string | null = null
|
|
@@ -46,13 +62,13 @@ export async function runStatus(runDir: string): Promise<StatusResult> {
|
|
|
46
62
|
}
|
|
47
63
|
if (
|
|
48
64
|
(status === 'done' || status === 'skipped') &&
|
|
49
|
-
(
|
|
65
|
+
(ladder as readonly string[]).includes(stage)
|
|
50
66
|
) {
|
|
51
|
-
lastCompleted = stage as
|
|
67
|
+
lastCompleted = stage as Stage
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
|
-
const idx = lastCompleted ?
|
|
55
|
-
const nextStage =
|
|
70
|
+
const idx = lastCompleted ? ladder.indexOf(lastCompleted) : -1
|
|
71
|
+
const nextStage = ladder[idx + 1]
|
|
56
72
|
const next: StatusResult['next'] = nextStage ?? 'cleanup'
|
|
57
73
|
return { lastCompleted, next, error }
|
|
58
74
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** What a run reviews: a PR checked out in the run's worktree, or a local branch in place. */
|
|
6
|
+
export type ReviewTarget = { mode: 'pr' | 'local'; root: string }
|
|
7
|
+
|
|
8
|
+
function isMissingFile(err: unknown): boolean {
|
|
9
|
+
return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs from before local mode wrote no target.json; those are all PR runs.
|
|
14
|
+
* setup writes log.jsonl first, so a dir without it is a mistyped path, not an old run.
|
|
15
|
+
*/
|
|
16
|
+
export async function readTarget(runDir: string): Promise<ReviewTarget> {
|
|
17
|
+
const path = join(runDir, 'target.json')
|
|
18
|
+
let text: string
|
|
19
|
+
try {
|
|
20
|
+
text = await readFile(path, 'utf8')
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (!isMissingFile(err)) throw err
|
|
23
|
+
if (!existsSync(join(runDir, 'log.jsonl'))) {
|
|
24
|
+
throw new Error(`${runDir}: not a magpie run (no target.json or log.jsonl)`)
|
|
25
|
+
}
|
|
26
|
+
return { mode: 'pr', root: join(runDir, 'worktree') }
|
|
27
|
+
}
|
|
28
|
+
let raw: unknown
|
|
29
|
+
try {
|
|
30
|
+
raw = JSON.parse(text)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(`${path}: not valid JSON (${err instanceof Error ? err.message : String(err)})`)
|
|
33
|
+
}
|
|
34
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
35
|
+
throw new Error(`${path}: expected an object`)
|
|
36
|
+
}
|
|
37
|
+
const { mode, root } = raw as Record<string, unknown>
|
|
38
|
+
if (mode !== 'pr' && mode !== 'local') {
|
|
39
|
+
throw new Error(`${path}: mode must be "pr" or "local", got ${JSON.stringify(mode)}`)
|
|
40
|
+
}
|
|
41
|
+
if (typeof root !== 'string') {
|
|
42
|
+
throw new Error(`${path}: root must be a string, got ${JSON.stringify(root)}`)
|
|
43
|
+
}
|
|
44
|
+
// A relative root would resolve against whichever cwd a command runs in.
|
|
45
|
+
if (!isAbsolute(root)) throw new Error(`${path}: root must be an absolute path`)
|
|
46
|
+
return { mode, root }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeTarget(runDir: string, target: ReviewTarget): Promise<void> {
|
|
50
|
+
await writeFile(join(runDir, 'target.json'), `${JSON.stringify(target, null, 2)}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The directory findings are anchored against. */
|
|
54
|
+
export async function reviewRoot(runDir: string): Promise<string> {
|
|
55
|
+
return (await readTarget(runDir)).root
|
|
56
|
+
}
|
package/skills/magpie/skill.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), drops findings whose quoted evidence is not in the code, dedupes, runs critic subagents that re-check each finding against the worktree and set its risk label, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting or dismissing findings to post via gh, recording the outcome per finding as labels. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request. Splits oversized diffs into budgeted shards and rebuilds the diff from the local clone when gh pr diff refuses it.",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture) plus a once-per-PR checks pass that finds tests and controls able to pass without proving their purpose, drops findings whose quoted evidence is not in the code, dedupes, runs critic subagents that re-check each finding against the worktree and set its risk label, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting or dismissing findings to post via gh, recording the outcome per finding as labels. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request, or to self-review the current branch diff before a PR exists, typically requested by an implementing or orchestrating agent, with findings handed back in the terminal. Splits oversized diffs into budgeted shards and rebuilds the diff from the local clone when gh pr diff refuses it.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|
|
7
7
|
"tools": [
|
package/skills/sluice/SKILL.md
CHANGED
|
@@ -77,7 +77,9 @@ yourself wanting to skip the rule, or arguing that this one is the exception.
|
|
|
77
77
|
- **Test first.** The test comes before the code; run it while it should
|
|
78
78
|
still be failing, then write the least code that turns it green. Skip that
|
|
79
79
|
watching step and a green result is only an unchecked guess. A test no
|
|
80
|
-
plausible change could turn red is not one you owe.
|
|
80
|
+
plausible change could turn red is not one you owe. A changed existing test
|
|
81
|
+
is recorded with `status.sh tests-changed` before the handback.
|
|
82
|
+
`references/test-first.md`
|
|
81
83
|
- **Root cause** before fix. A fix that only hides the symptom has not fixed
|
|
82
84
|
anything. Three failed fixes point at the design, not your guesswork.
|
|
83
85
|
`references/root-cause.md`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sluice evals
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Twenty-one cases for `claude plugin eval`. Fourteen pin the routing decision: which
|
|
4
4
|
channel the run names, and whether the behaviour that channel owes actually
|
|
5
5
|
happened. Seven pin what a deep run does after pre-flight, where the question is
|
|
6
6
|
no longer which channel but whether the run keeps going.
|
|
@@ -17,6 +17,8 @@ no longer which channel but whether the run keeps going.
|
|
|
17
17
|
| `announcement-reaches-the-ledger` | A flag that a surface test turns into a new export | `status.sh route main` before any write under `src/` and before any fast or deep route |
|
|
18
18
|
| `fast-reads-the-unmentioned-convention` | Flags live in a table the prompt never names | The flag lands in the registry table and the generated `--help` |
|
|
19
19
|
| `fast-verifies-before-handback` | A fast flag, then the handback | A passing `status.sh verify` after the last edit, before handing back |
|
|
20
|
+
| `fast-updates-the-test-the-change-replaces` | A new summary line that an existing test pins the old form of | The test updated without asking; `status.sh tests-changed -- <suite>` after the last edit |
|
|
21
|
+
| `fast-leaves-an-unrelated-red-test-alone` | The obvious edit to a shared helper turns the status test red | Status output kept by changing code, or a question; `tests/status.test.js` untouched |
|
|
20
22
|
| `main-hunts-a-checkable-claim` | Main work whose intent states a checkable claim | bug-hunter on the diff once green; the `lastPage` remainder bug it proves, fixed test-first |
|
|
21
23
|
| `main-skips-the-hunt-without-a-claim` | A new interface with nothing to check | No bug-hunter; one line saying the intent stated no checkable claim |
|
|
22
24
|
|
|
@@ -34,7 +36,7 @@ Execution, where the run is already past both stops:
|
|
|
34
36
|
|
|
35
37
|
## Running
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
Twenty cases scaffold a small Node repo and then change it, so they need the
|
|
38
40
|
scaffold flag and a tool grant. Pin the model, because the eval child does not
|
|
39
41
|
inherit it, and pin the judge, because `--model` does not reach it and the
|
|
40
42
|
`llm` graders otherwise run on Haiku. From the repo root:
|
|
@@ -244,6 +246,12 @@ commit is the agent working around the harness, so a deep score on this
|
|
|
244
246
|
machine still depends on it; a Linux runner, where `git` is an ordinary binary,
|
|
245
247
|
gives the clean read.
|
|
246
248
|
|
|
249
|
+
`status.sh tests-changed` runs `git worktree add` under `$TMPDIR`, so in a
|
|
250
|
+
macOS sandbox the proof itself may fail while
|
|
251
|
+
`fast-updates-the-test-the-change-replaces`'s proof grader, which sees the call
|
|
252
|
+
and not its exit, still passes. That is why both test-change cases are
|
|
253
|
+
`sandboxSensitive`.
|
|
254
|
+
|
|
247
255
|
**Fixed between the rerun and pass 2.**
|
|
248
256
|
|
|
249
257
|
- `deep-run-blocks-on-a-real-decision` carried a second contract break: Task
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The deploy repo with its summary line moved into a helper the status command
|
|
3
|
+
# shares, so the obvious edit to summary() turns the status test red on
|
|
4
|
+
# behaviour the request never mentions.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
mkdir -p src/cli tests
|
|
8
|
+
|
|
9
|
+
cat > package.json <<'JSON'
|
|
10
|
+
{
|
|
11
|
+
"name": "shipit",
|
|
12
|
+
"version": "1.2.0",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test \"tests/*.test.js\""
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
JSON
|
|
19
|
+
|
|
20
|
+
cat > src/format.js <<'JS'
|
|
21
|
+
export function summary(verb, items) {
|
|
22
|
+
return `${verb}: ${items.length} steps`;
|
|
23
|
+
}
|
|
24
|
+
JS
|
|
25
|
+
|
|
26
|
+
cat > src/cli/deploy.js <<'JS'
|
|
27
|
+
import { summary } from "../format.js";
|
|
28
|
+
|
|
29
|
+
const STEPS = ["build", "upload", "activate"];
|
|
30
|
+
|
|
31
|
+
export function deploy(args, log = console.log) {
|
|
32
|
+
const dryRun = args.includes("--dry-run");
|
|
33
|
+
|
|
34
|
+
for (const step of STEPS) {
|
|
35
|
+
log(`-> ${step}`);
|
|
36
|
+
if (!dryRun) {
|
|
37
|
+
run(step);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log(dryRun ? "dry run: 3 steps skipped" : summary("deployed", STEPS));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function run(step) {
|
|
45
|
+
if (!STEPS.includes(step)) {
|
|
46
|
+
throw new Error(`unknown step: ${step}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
JS
|
|
50
|
+
|
|
51
|
+
cat > src/cli/status.js <<'JS'
|
|
52
|
+
import { summary } from "../format.js";
|
|
53
|
+
|
|
54
|
+
const CHECKS = ["config", "credentials", "target"];
|
|
55
|
+
|
|
56
|
+
export function status(log = console.log) {
|
|
57
|
+
for (const check of CHECKS) {
|
|
58
|
+
log(`ok ${check}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log(summary("checked", CHECKS));
|
|
62
|
+
}
|
|
63
|
+
JS
|
|
64
|
+
|
|
65
|
+
cat > tests/deploy.test.js <<'JS'
|
|
66
|
+
import assert from "node:assert/strict";
|
|
67
|
+
import { test } from "node:test";
|
|
68
|
+
import { deploy } from "../src/cli/deploy.js";
|
|
69
|
+
|
|
70
|
+
function capture() {
|
|
71
|
+
const lines = [];
|
|
72
|
+
return { lines, log: (line) => lines.push(line) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("dry run skips the steps and says so", () => {
|
|
76
|
+
const { lines, log } = capture();
|
|
77
|
+
deploy(["--dry-run"], log);
|
|
78
|
+
assert.equal(lines.at(-1), "dry run: 3 steps skipped");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a full deploy reports every step", () => {
|
|
82
|
+
const { lines, log } = capture();
|
|
83
|
+
deploy([], log);
|
|
84
|
+
assert.deepEqual(lines, ["-> build", "-> upload", "-> activate", "deployed: 3 steps"]);
|
|
85
|
+
});
|
|
86
|
+
JS
|
|
87
|
+
|
|
88
|
+
cat > tests/status.test.js <<'JS'
|
|
89
|
+
import assert from "node:assert/strict";
|
|
90
|
+
import { test } from "node:test";
|
|
91
|
+
import { status } from "../src/cli/status.js";
|
|
92
|
+
|
|
93
|
+
test("status ends on a count of the checks", () => {
|
|
94
|
+
const lines = [];
|
|
95
|
+
status((line) => lines.push(line));
|
|
96
|
+
assert.equal(lines.at(-1), "checked: 3 steps");
|
|
97
|
+
});
|
|
98
|
+
JS
|
|
99
|
+
|
|
100
|
+
cat > README.md <<'MD'
|
|
101
|
+
# shipit
|
|
102
|
+
|
|
103
|
+
Deploy CLI. `npm test` runs the suite.
|
|
104
|
+
MD
|
|
105
|
+
|
|
106
|
+
git init --quiet
|
|
107
|
+
git add -A
|
|
108
|
+
git -c user.email=fixture@example.com -c user.name=fixture commit --quiet -m "shipit 1.2.0"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: regex
|
|
3
|
+
# The deploy test is the one place the new line must appear as a literal: the
|
|
4
|
+
# source can build it with a join, but the existing test pins `deployed: 3
|
|
5
|
+
# steps`, so a green suite needs this test updated to the new line. Without
|
|
6
|
+
# this grader a run that changes nothing scores on status-test-unchanged.
|
|
7
|
+
pattern: 'deployed: build, upload, activate'
|
|
8
|
+
target: { source: file, path: tests/deploy.test.js }
|
|
9
|
+
---
|