@iceinvein/agent-skills 0.22.0 → 0.23.1

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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +8 -1
  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 +6 -6
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +41 -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/scope-estimate/SKILL.md +1 -1
  64. package/skills/scope-estimate/skill.json +16 -5
  65. package/skills/sluice/SKILL.md +6 -2
  66. package/skills/sluice/evals/README.md +10 -2
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  72. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  73. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  79. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  80. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  81. package/skills/sluice/evals/rules.json +11 -0
  82. package/skills/sluice/references/review.md +6 -4
  83. package/skills/sluice/references/test-first.md +24 -4
  84. package/skills/sluice/scripts/content-digest.sh +1 -1
  85. package/skills/sluice/scripts/run-stats.sh +14 -0
  86. package/skills/sluice/scripts/session-start.sh +44 -2
  87. package/skills/sluice/scripts/status.sh +293 -0
  88. package/skills/sluice/scripts/stop-guard.sh +80 -6
  89. package/skills/sluice/scripts/test-changes.sh +135 -0
  90. package/skills/sluice/skill.json +2 -2
@@ -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.1",
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. Local mode (setup --local) runs the same pipeline on the current branch's diff from its merge base, uncommitted and untracked work included, against the caller's live tree, with no server or posting, and hands the ranked findings back in the terminal for an implementing or orchestrating agent to address. 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": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: scope-estimate
3
- description: Use when asked to scope, size or estimate a rebuild or respec of an existing system for a proposal or engagement, to map what a codebase does into a capability register, to turn capabilities into functional requirements and build slices, or to work out how long an agent-driven team (engineers x worktrees) takes to build it. Lighter than migrate: no store, no CLI gates, presentable docs plus a scheduler script so the numbers add up.
3
+ description: "Use when asked to scope, size or estimate a rebuild or respec of an existing system for a proposal or engagement, to map what a codebase does into a capability register, to turn capabilities into functional requirements and build slices, or to work out how long an agent-driven team (engineers x worktrees) takes to build it. Lighter than migrate: no store, no CLI gates, presentable docs plus a scheduler script so the numbers add up."
4
4
  ---
5
5
 
6
6
  # Scope and estimate
@@ -1,14 +1,25 @@
1
1
  {
2
2
  "name": "scope-estimate",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scopes and estimates a rebuild or respec of an existing system for a proposal: read-only discovery agents write cited inventories, a capability register gives every capability a status and a proposed disposition, area agents turn it into sized functional requirements, and a bundled Bun scheduler groups them into slices and contracts-first work packages and schedules them across engineers x worktrees at fast, planning and floor rates. The scheduler refuses a plan that misses or double-counts a requirement or has a dependency cycle, so every figure in the docs comes from one reproducible run. Also covers current and target architecture pages, proposal review, and a two-week calibration before committing to a date. Lighter than migrate: no store and no phase gates.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",
7
- "tools": ["claude"],
8
- "files": { "prompt": "SKILL.md" },
7
+ "tools": [
8
+ "claude"
9
+ ],
10
+ "files": {
11
+ "prompt": "SKILL.md"
12
+ },
9
13
  "bundle": {
10
- "include": ["README.md", "references", "scripts", "templates"],
11
- "exclude": ["scripts/__tests__/"]
14
+ "include": [
15
+ "README.md",
16
+ "references",
17
+ "scripts",
18
+ "templates"
19
+ ],
20
+ "exclude": [
21
+ "scripts/__tests__/"
22
+ ]
12
23
  },
13
24
  "install": {
14
25
  "claude": {
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: sluice
3
3
  description: Use at the start of any request that changes code. Routes the work into one of four channels (bypass, fast, main, deep) by change shape, and applies only the rules that channel needs. Also use when asked to work faster or with less process.
4
+ argument-hint: "[fast|main|deep] [<what to change>]"
4
5
  ---
5
6
 
6
7
  # Sluice
@@ -77,7 +78,9 @@ yourself wanting to skip the rule, or arguing that this one is the exception.
77
78
  - **Test first.** The test comes before the code; run it while it should
78
79
  still be failing, then write the least code that turns it green. Skip that
79
80
  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`
81
+ plausible change could turn red is not one you owe. A changed existing test
82
+ is recorded with `status.sh tests-changed` before the handback.
83
+ `references/test-first.md`
81
84
  - **Root cause** before fix. A fix that only hides the symptom has not fixed
82
85
  anything. Three failed fixes point at the design, not your guesswork.
83
86
  `references/root-cause.md`
@@ -117,7 +120,8 @@ Escalate out loud. A `fast` task that turns out to need a new interface becomes
117
120
  `main`, and you say so with another `status.sh route` call in that message.
118
121
  Finishing quietly in the wrong channel is the failure this prevents; the same
119
122
  goes for dropping to a shallower one. Explicit instruction wins: "just do it"
120
- collapses to `fast`.
123
+ collapses to `fast`, and a channel named when invoking (`/sluice main`) is that
124
+ channel.
121
125
 
122
126
  ## Deep channel
123
127
 
@@ -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