@iceinvein/agent-skills 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +5 -0
  4. package/skills/bug-hunter/evals/README.md +6 -1
  5. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
  6. package/skills/bug-hunter/evals/rules.json +8 -0
  7. package/skills/bug-hunter/skill.json +1 -1
  8. package/skills/index.json +4 -4
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +40 -15
  11. package/skills/magpie/bin/magpie.ts +83 -7
  12. package/skills/magpie/evals/README.md +7 -4
  13. package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
  14. package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
  15. package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
  16. package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
  17. package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
  18. package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
  19. package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
  20. package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
  21. package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
  22. package/skills/magpie/evals/quality/README.md +73 -2
  23. package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
  24. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
  25. package/skills/magpie/evals/quality/corpus.ts +4 -1
  26. package/skills/magpie/evals/quality/recall.ts +249 -0
  27. package/skills/magpie/evals/quality/replay-critic.ts +3 -0
  28. package/skills/magpie/evals/quality/replay-full.ts +168 -6
  29. package/skills/magpie/evals/rules.json +9 -1
  30. package/skills/magpie/references/checks.md +78 -0
  31. package/skills/magpie/references/peer-review.md +7 -0
  32. package/skills/magpie/references/scout.md +12 -5
  33. package/skills/magpie/references/specialists.md +10 -8
  34. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
  35. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
  36. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
  37. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
  38. package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
  39. package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
  40. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
  41. package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
  42. package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
  43. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
  44. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
  45. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
  46. package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
  47. package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
  48. package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
  49. package/skills/magpie/scripts/critic-cmd.ts +2 -1
  50. package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
  51. package/skills/magpie/scripts/handoff-cmd.ts +132 -0
  52. package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
  53. package/skills/magpie/scripts/local-diff.ts +228 -0
  54. package/skills/magpie/scripts/post-cmd.ts +1 -0
  55. package/skills/magpie/scripts/render-annotation.ts +1 -0
  56. package/skills/magpie/scripts/resolve-run.ts +17 -3
  57. package/skills/magpie/scripts/server.ts +9 -1
  58. package/skills/magpie/scripts/setup-cmd.ts +241 -49
  59. package/skills/magpie/scripts/status-cmd.ts +22 -6
  60. package/skills/magpie/scripts/target.ts +56 -0
  61. package/skills/magpie/scripts/types.ts +1 -0
  62. package/skills/magpie/skill.json +2 -2
  63. package/skills/sluice/SKILL.md +3 -1
  64. package/skills/sluice/evals/README.md +10 -2
  65. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  66. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  72. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  73. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  79. package/skills/sluice/evals/rules.json +11 -0
  80. package/skills/sluice/references/review.md +6 -4
  81. package/skills/sluice/references/test-first.md +24 -4
  82. package/skills/sluice/scripts/content-digest.sh +1 -1
  83. package/skills/sluice/scripts/run-stats.sh +14 -0
  84. package/skills/sluice/scripts/session-start.sh +44 -2
  85. package/skills/sluice/scripts/status.sh +293 -0
  86. package/skills/sluice/scripts/stop-guard.sh +80 -6
  87. package/skills/sluice/scripts/test-changes.sh +135 -0
  88. package/skills/sluice/skill.json +1 -1
@@ -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
- await mkdir(input.runDir, { recursive: true })
25
- await mkdir(join(input.runDir, 'findings'), { recursive: true })
26
- await mkdir(join(input.runDir, 'screen'), { recursive: true })
27
- await mkdir(join(input.runDir, 'state'), { recursive: true })
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(input.runDir, { stage: 'setup', status: 'error', missing: preResult.missing })
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(input.runDir)
72
+ await cleanup(runDir)
34
73
  return 3
35
74
  }
36
75
  if (preResult.missingOptional.length > 0) {
37
- await logLine(input.runDir, {
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(input.runDir, { stage: 'preflight', status: 'done' })
85
+ await logLine(runDir, { stage: 'preflight', status: 'done' })
47
86
  }
48
87
 
49
- const fetched = await fetchPr({
50
- ghBin: deps.gh,
51
- gitBin: deps.git,
52
- prNumber: input.prNumber,
53
- runDir: input.runDir,
54
- cwd: input.repoPath,
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(input.runDir, input.repoPath)
97
+ await applyPathFilter(runDir, repoPath)
76
98
 
77
99
  try {
78
- await runShard(input.runDir)
100
+ await runShard(runDir)
79
101
  } catch (err) {
80
102
  const message = err instanceof Error ? err.message : String(err)
81
- await logLine(input.runDir, { stage: 'shard', status: 'error', error: message })
103
+ await logLine(runDir, { stage: 'shard', status: 'error', error: message })
82
104
  process.stderr.write(`magpie: ${message}\n`)
83
- await cleanup(input.runDir)
105
+ await cleanup(runDir)
84
106
  return 6
85
107
  }
86
108
 
87
- await runTestsCheck(input.runDir)
109
+ await runTestsCheck(runDir)
88
110
 
89
- await detectIncrementalReview(input.runDir, input.prNumber)
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(input.runDir, 'pr.json')).json()) as { headRefName: string }
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: input.repoPath,
123
+ repoPath,
97
124
  branch,
98
- runDir: input.runDir,
125
+ runDir: runDir,
99
126
  })
100
127
  if (!wt.ok) {
101
- await logLine(input.runDir, { stage: 'worktree', status: 'error', error: wt.error })
128
+ await logLine(runDir, { stage: 'worktree', status: 'error', error: wt.error })
102
129
  process.stderr.write(`magpie: ${wt.error}\n`)
103
- await cleanup(input.runDir)
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(input.runDir, { stage: 'setup', status: 'done', worktree: wt.worktreePath })
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: (typeof ORDER)[number] | null
26
- next: (typeof ORDER)[number] | 'cleanup'
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
- (ORDER as readonly string[]).includes(stage)
65
+ (ladder as readonly string[]).includes(stage)
50
66
  ) {
51
- lastCompleted = stage as StatusResult['lastCompleted']
67
+ lastCompleted = stage as Stage
52
68
  }
53
69
  }
54
- const idx = lastCompleted ? ORDER.indexOf(lastCompleted) : -1
55
- const nextStage = ORDER[idx + 1]
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
+ }
@@ -4,6 +4,7 @@ export const FOCUS_IDS = [
4
4
  'performance',
5
5
  'code-smells',
6
6
  'architecture',
7
+ 'checks',
7
8
  'tests',
8
9
  ] as const
9
10
  export type FocusId = (typeof FOCUS_IDS)[number]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.13.1",
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": [
@@ -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. `references/test-first.md`
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
- Nineteen cases for `claude plugin eval`. Twelve pin the routing decision: which
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
- Eighteen cases scaffold a small Node repo and then change it, so they need the
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,4 @@
1
+ schema_version: "1.1"
2
+ name: fast-leaves-an-unrelated-red-test-alone
3
+ context:
4
+ scaffold_script: fixture.sh
@@ -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
+ ---