@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,5 +1,5 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test'
2
- import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { type ServerHandle, startServer } from '../server.ts'
@@ -37,6 +37,35 @@ test('serves the newest HTML file verbatim (HTML is self-contained at render tim
37
37
  expect(html).toContain('inline-helper')
38
38
  })
39
39
 
40
+ /** Writes a screen page with an explicit mtime, in seconds since the epoch. */
41
+ async function writeScreen(name: string, body: string, mtime: number): Promise<void> {
42
+ const path = join(runDir, 'screen', name)
43
+ await writeFile(path, body)
44
+ await utimes(path, mtime, mtime)
45
+ }
46
+
47
+ test('a progress page rendered after the findings page does not replace it', async () => {
48
+ await writeScreen('findings.html', '<h1>Findings</h1>', 1000)
49
+ await writeScreen('progress-v9.html', '<h1>Progress, report done</h1>', 2000)
50
+ server = await startServer({ runDir, idleMs: 60_000 })
51
+ expect(await fetchHtml(server)).toContain('<h1>Findings</h1>')
52
+ })
53
+
54
+ test('before any findings page exists, the newest progress page is served', async () => {
55
+ await writeScreen('progress.html', '<h1>Progress one</h1>', 1000)
56
+ await writeScreen('progress-v2.html', '<h1>Progress two</h1>', 2000)
57
+ server = await startServer({ runDir, idleMs: 60_000 })
58
+ expect(await fetchHtml(server)).toContain('<h1>Progress two</h1>')
59
+ })
60
+
61
+ test('the newest findings version is served when several exist', async () => {
62
+ await writeScreen('findings.html', '<h1>Findings one</h1>', 1000)
63
+ await writeScreen('findings-v2.html', '<h1>Findings two</h1>', 2000)
64
+ await writeScreen('progress-v5.html', '<h1>Progress</h1>', 3000)
65
+ server = await startServer({ runDir, idleMs: 60_000 })
66
+ expect(await fetchHtml(server)).toContain('<h1>Findings two</h1>')
67
+ })
68
+
40
69
  test('POST /events appends a JSON line to state/events', async () => {
41
70
  await writeFile(join(runDir, 'screen', 'a.html'), '<h1>X</h1>')
42
71
  server = await startServer({ runDir, idleMs: 60_000 })
@@ -1,7 +1,17 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test'
2
- import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ realpath,
8
+ rm,
9
+ symlink,
10
+ writeFile,
11
+ } from 'node:fs/promises'
3
12
  import { tmpdir } from 'node:os'
4
13
  import { join } from 'node:path'
14
+ import { diffSha256, localBranchDiff } from '../local-diff.ts'
5
15
  import { runSetup } from '../setup-cmd.ts'
6
16
 
7
17
  const FAKE_GH = new URL('../../fixtures/fake-gh.sh', import.meta.url).pathname
@@ -42,6 +52,7 @@ afterEach(async () => {
42
52
 
43
53
  test('runSetup completes happy path: writes pr.json, diff.patch, worktree', async () => {
44
54
  const exit = await runSetup({
55
+ mode: 'pr',
45
56
  runDir,
46
57
  prNumber: 1234,
47
58
  repoPath: repo,
@@ -52,10 +63,13 @@ test('runSetup completes happy path: writes pr.json, diff.patch, worktree', asyn
52
63
  expect(contents).toContain('pr.json')
53
64
  expect(contents).toContain('diff.patch')
54
65
  expect(contents).toContain('worktree')
66
+ const target = JSON.parse(await readFile(join(runDir, 'target.json'), 'utf8'))
67
+ expect(target).toEqual({ mode: 'pr', root: join(runDir, 'worktree') })
55
68
  })
56
69
 
57
70
  test('runSetup with missing dep returns non-zero and cleans up', async () => {
58
71
  const exit = await runSetup({
72
+ mode: 'pr',
59
73
  runDir,
60
74
  prNumber: 1234,
61
75
  repoPath: repo,
@@ -68,6 +82,7 @@ test('runSetup with missing dep returns non-zero and cleans up', async () => {
68
82
 
69
83
  test('runSetup filters excluded files by default and preserves raw diff', async () => {
70
84
  const exit = await runSetup({
85
+ mode: 'pr',
71
86
  runDir,
72
87
  prNumber: 1234,
73
88
  repoPath: repo,
@@ -93,6 +108,7 @@ test('runSetup filters excluded files by default and preserves raw diff', async
93
108
 
94
109
  test('runSetup with no excludable files does not write filter sidecars', async () => {
95
110
  const exit = await runSetup({
111
+ mode: 'pr',
96
112
  runDir,
97
113
  prNumber: 1234,
98
114
  repoPath: repo,
@@ -107,6 +123,7 @@ test('runSetup with no excludable files does not write filter sidecars', async (
107
123
  test('runSetup honors .magpie.json useDefaults=false', async () => {
108
124
  await writeFile(join(repo, '.magpie.json'), JSON.stringify({ useDefaults: false, exclude: [] }))
109
125
  const exit = await runSetup({
126
+ mode: 'pr',
110
127
  runDir,
111
128
  prNumber: 1234,
112
129
  repoPath: repo,
@@ -129,6 +146,7 @@ test('runSetup writes incremental.json when a prior run for the same PR exists',
129
146
  process.env.MAGPIE_HOME = magpieHome
130
147
  try {
131
148
  const exit = await runSetup({
149
+ mode: 'pr',
132
150
  runDir,
133
151
  prNumber: 1234,
134
152
  repoPath: repo,
@@ -152,6 +170,7 @@ test('runSetup omits incremental.json when no prior run exists', async () => {
152
170
  process.env.MAGPIE_HOME = magpieHome
153
171
  try {
154
172
  const exit = await runSetup({
173
+ mode: 'pr',
155
174
  runDir,
156
175
  prNumber: 1234,
157
176
  repoPath: repo,
@@ -169,6 +188,7 @@ test('runSetup omits incremental.json when no prior run exists', async () => {
169
188
 
170
189
  test('runSetup with failing gh removes any partial state', async () => {
171
190
  const exit = await runSetup({
191
+ mode: 'pr',
172
192
  runDir,
173
193
  prNumber: 1234,
174
194
  repoPath: repo,
@@ -178,10 +198,12 @@ test('runSetup with failing gh removes any partial state', async () => {
178
198
  const contents = await readdir(runDir).catch(() => [])
179
199
  expect(contents).not.toContain('worktree')
180
200
  expect(contents).not.toContain('pr.json')
201
+ expect(contents).not.toContain('target.json')
181
202
  })
182
203
 
183
204
  test('runSetup shards the filtered diff and logs the result', async () => {
184
205
  const exit = await runSetup({
206
+ mode: 'pr',
185
207
  runDir,
186
208
  prNumber: 1234,
187
209
  repoPath: repo,
@@ -210,6 +232,7 @@ test('runSetup cleans up a populated shards/ when worktree creation fails after
210
232
  await writeFile(join(runDir, 'worktree', 'occupied'), 'x')
211
233
 
212
234
  const exit = await runSetup({
235
+ mode: 'pr',
213
236
  runDir,
214
237
  prNumber: 1234,
215
238
  repoPath: repo,
@@ -230,6 +253,7 @@ test('runSetup cleans up a populated shards/ when worktree creation fails after
230
253
  const contents = await readdir(runDir).catch(() => [])
231
254
  expect(contents).not.toContain('shards')
232
255
  expect(contents).not.toContain('worktree')
256
+ expect(contents).not.toContain('target.json')
233
257
  })
234
258
 
235
259
  test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6', async () => {
@@ -242,6 +266,7 @@ test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6
242
266
  await writeFile(join(runDir, 'shards'), 'not a directory')
243
267
 
244
268
  const exit = await runSetup({
269
+ mode: 'pr',
245
270
  runDir,
246
271
  prNumber: 1234,
247
272
  repoPath: repo,
@@ -261,3 +286,301 @@ test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6
261
286
  const contents = await readdir(runDir).catch(() => [])
262
287
  expect(contents.filter((c) => c !== 'log.jsonl')).toHaveLength(0)
263
288
  })
289
+
290
+ const LOCAL_DEPS = { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' }
291
+
292
+ async function prepareLocalBranch(): Promise<void> {
293
+ await sh(repo, 'git', 'checkout', '-q', 'feature-x')
294
+ await writeFile(join(repo, 'b.txt'), 'two\n')
295
+ await sh(repo, 'git', 'add', 'b.txt')
296
+ await sh(repo, 'git', 'commit', '-q', '-m', 'add b')
297
+ await writeFile(join(repo, 'a.txt'), 'one\nedited\n')
298
+ await writeFile(join(repo, 'new.txt'), 'untracked\n')
299
+ }
300
+
301
+ async function setupCapturingStderr(
302
+ input: Parameters<typeof runSetup>[0],
303
+ ): Promise<{ exit: number; stderr: string }> {
304
+ const errs: string[] = []
305
+ const origWrite = process.stderr.write.bind(process.stderr)
306
+ process.stderr.write = ((s: string) => {
307
+ errs.push(s)
308
+ return true
309
+ }) as typeof process.stderr.write
310
+ try {
311
+ const exit = await runSetup(input)
312
+ return { exit, stderr: errs.join('') }
313
+ } finally {
314
+ process.stderr.write = origWrite
315
+ }
316
+ }
317
+
318
+ async function readLog(): Promise<Array<Record<string, unknown>>> {
319
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
320
+ return log
321
+ .split('\n')
322
+ .filter(Boolean)
323
+ .map((l) => JSON.parse(l))
324
+ }
325
+
326
+ test('local setup writes the diff, its sidecars and a target rooted at the repo', async () => {
327
+ await prepareLocalBranch()
328
+ const exit = await runSetup({
329
+ mode: 'local',
330
+ runDir,
331
+ repoPath: repo,
332
+ baseRef: 'main',
333
+ deps: LOCAL_DEPS,
334
+ })
335
+ expect(exit).toBe(0)
336
+ const contents = await readdir(runDir)
337
+ expect(contents).not.toContain('pr.json')
338
+ expect(contents).not.toContain('incremental.json')
339
+ expect(contents).not.toContain('worktree')
340
+
341
+ const diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
342
+ expect(diff).toContain('+++ b/b.txt')
343
+ expect(diff).toContain('+edited')
344
+ expect(diff).toContain('+++ b/new.txt')
345
+
346
+ const source = JSON.parse(await readFile(join(runDir, 'diff-source.json'), 'utf8'))
347
+ expect(source.source).toBe('local')
348
+ expect(source.baseRef).toBe('main')
349
+ expect(source.mergeBase).toMatch(/^[0-9a-f]{40}$/)
350
+ expect(source.head).toMatch(/^[0-9a-f]{40}$/)
351
+ expect(source.diffSha256).toMatch(/^[0-9a-f]{64}$/)
352
+
353
+ const meta = JSON.parse(await readFile(join(runDir, 'local.json'), 'utf8'))
354
+ expect(meta.branch).toBe('feature-x')
355
+ expect(meta.baseRef).toBe('main')
356
+ expect(meta.dirty).toBe(true)
357
+ expect(meta.intentFile).toBeNull()
358
+
359
+ const target = JSON.parse(await readFile(join(runDir, 'target.json'), 'utf8'))
360
+ expect(target).toEqual({ mode: 'local', root: repo })
361
+
362
+ expect((await readLog()).find((e) => e.stage === 'setup')).toMatchObject({
363
+ status: 'done',
364
+ root: repo,
365
+ })
366
+ })
367
+
368
+ test('local setup hashes the raw diff, not the path-filtered patch', async () => {
369
+ await sh(repo, 'git', 'checkout', '-q', 'feature-x')
370
+ await writeFile(join(repo, 'bun.lock'), 'lock\n')
371
+ await writeFile(join(repo, 'src.ts'), 'export const x = 1\n')
372
+ await sh(repo, 'git', 'add', '.')
373
+ await sh(repo, 'git', 'commit', '-q', '-m', 'lock and src')
374
+
375
+ const exit = await runSetup({
376
+ mode: 'local',
377
+ runDir,
378
+ repoPath: repo,
379
+ baseRef: 'main',
380
+ deps: LOCAL_DEPS,
381
+ })
382
+ expect(exit).toBe(0)
383
+ const filtered = await readFile(join(runDir, 'diff.patch'), 'utf8')
384
+ expect(filtered).not.toContain('bun.lock')
385
+
386
+ const raw = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
387
+ if (!raw.ok) throw new Error(raw.error)
388
+ const source = JSON.parse(await readFile(join(runDir, 'diff-source.json'), 'utf8'))
389
+ expect(source.diffSha256).toBe(diffSha256(raw.diff))
390
+ })
391
+
392
+ test('local setup with an unresolvable base logs the error and keeps only the log', async () => {
393
+ await prepareLocalBranch()
394
+ const exit = await runSetup({
395
+ mode: 'local',
396
+ runDir,
397
+ repoPath: repo,
398
+ baseRef: 'nope',
399
+ deps: LOCAL_DEPS,
400
+ })
401
+ expect(exit).toBe(4)
402
+ expect((await readLog()).find((e) => e.stage === 'local-diff')).toMatchObject({
403
+ status: 'error',
404
+ })
405
+ expect(await readdir(runDir)).toEqual(['log.jsonl'])
406
+ })
407
+
408
+ test('local setup with a missing intent file fails naming the path', async () => {
409
+ await prepareLocalBranch()
410
+ const missing = join(runDir, 'no-such-intent.md')
411
+ const { exit, stderr } = await setupCapturingStderr({
412
+ mode: 'local',
413
+ runDir,
414
+ repoPath: repo,
415
+ baseRef: 'main',
416
+ intentFile: missing,
417
+ deps: LOCAL_DEPS,
418
+ })
419
+ expect(exit).not.toBe(0)
420
+ expect(stderr).toContain(`magpie: intent file ${missing} does not exist`)
421
+ expect((await readLog()).find((e) => e.stage === 'local-diff')).toMatchObject({
422
+ status: 'error',
423
+ })
424
+ expect(await readdir(runDir)).toEqual(['log.jsonl'])
425
+ })
426
+
427
+ test('local setup with an intent path that is a directory fails naming the path', async () => {
428
+ await prepareLocalBranch()
429
+ const intentDir = await mkdtemp(join(tmpdir(), 'magpie-intent-dir-'))
430
+ try {
431
+ const { exit, stderr } = await setupCapturingStderr({
432
+ mode: 'local',
433
+ runDir,
434
+ repoPath: repo,
435
+ baseRef: 'main',
436
+ intentFile: intentDir,
437
+ deps: LOCAL_DEPS,
438
+ })
439
+ expect(exit).not.toBe(0)
440
+ expect(stderr).toContain(`magpie: intent file ${intentDir} is not a file`)
441
+ expect((await readLog()).find((e) => e.stage === 'local-diff')).toMatchObject({
442
+ status: 'error',
443
+ })
444
+ expect(await readdir(runDir)).toEqual(['log.jsonl'])
445
+ } finally {
446
+ await rm(intentDir, { recursive: true, force: true })
447
+ }
448
+ })
449
+
450
+ test('local setup refuses a run dir that is the repository and leaves it untouched', async () => {
451
+ await prepareLocalBranch()
452
+ await mkdir(join(repo, 'findings'))
453
+ await writeFile(join(repo, 'findings', 'user.txt'), 'mine\n')
454
+ await sh(repo, 'git', 'add', 'findings/user.txt')
455
+ await sh(repo, 'git', 'commit', '-q', '-m', 'user findings')
456
+ const before = (await readdir(repo)).sort()
457
+
458
+ const { exit, stderr } = await setupCapturingStderr({
459
+ mode: 'local',
460
+ runDir: repo,
461
+ repoPath: repo,
462
+ baseRef: 'nope',
463
+ deps: LOCAL_DEPS,
464
+ })
465
+ expect(exit).toBe(2)
466
+ expect(stderr).toContain(
467
+ `magpie: run dir ${repo} is inside the repository under review ${await realpath(repo)}\n`,
468
+ )
469
+ expect(await readFile(join(repo, 'findings', 'user.txt'), 'utf8')).toBe('mine\n')
470
+ expect((await readdir(repo)).sort()).toEqual(before)
471
+ })
472
+
473
+ test('local setup refuses a not-yet-created run dir under the repository and creates nothing', async () => {
474
+ await prepareLocalBranch()
475
+ const before = (await readdir(repo)).sort()
476
+ const nested = join(repo, 'runs', 'r1')
477
+
478
+ const { exit, stderr } = await setupCapturingStderr({
479
+ mode: 'local',
480
+ runDir: nested,
481
+ repoPath: repo,
482
+ baseRef: 'main',
483
+ deps: LOCAL_DEPS,
484
+ })
485
+ expect(exit).toBe(2)
486
+ expect(stderr).toContain(`magpie: run dir ${nested} is inside the repository under review`)
487
+ expect((await readdir(repo)).sort()).toEqual(before)
488
+ })
489
+
490
+ async function gitStatus(cwd: string): Promise<string> {
491
+ const proc = Bun.spawn(['git', 'status', '--porcelain'], { cwd, stdout: 'pipe', stderr: 'pipe' })
492
+ const out = await new Response(proc.stdout).text()
493
+ expect(await proc.exited).toBe(0)
494
+ return out
495
+ }
496
+
497
+ test('local setup with --repo at a subdirectory fails before writing a run dir into the repository', async () => {
498
+ await mkdir(join(repo, 'sub'))
499
+ await writeFile(join(repo, 'sub', 's.txt'), 's\n')
500
+ await sh(repo, 'git', 'add', '.')
501
+ await sh(repo, 'git', 'commit', '-q', '-m', 'sub')
502
+ const before = (await readdir(repo)).sort()
503
+ const statusBefore = await gitStatus(repo)
504
+ const sub = join(repo, 'sub')
505
+
506
+ const { exit, stderr } = await setupCapturingStderr({
507
+ mode: 'local',
508
+ runDir: join(repo, 'run'),
509
+ repoPath: sub,
510
+ baseRef: 'main',
511
+ deps: LOCAL_DEPS,
512
+ })
513
+ expect(exit).toBe(2)
514
+ expect(stderr).toContain(`magpie: --repo ${sub} is not the repository root`)
515
+ expect((await readdir(repo)).sort()).toEqual(before)
516
+ expect(await gitStatus(repo)).toBe(statusBefore)
517
+ })
518
+
519
+ test('local setup with a run dir that climbs out of a symlink into the repository leaves the repository unchanged', async () => {
520
+ await mkdir(join(repo, 'sub'))
521
+ await writeFile(join(repo, 'sub', 's.txt'), 's\n')
522
+ await sh(repo, 'git', 'add', '.')
523
+ await sh(repo, 'git', 'commit', '-q', '-m', 'sub')
524
+ await symlink(join(repo, 'sub'), join(runDir, 'L'))
525
+ const before = (await readdir(repo)).sort()
526
+ const statusBefore = await gitStatus(repo)
527
+
528
+ await setupCapturingStderr({
529
+ mode: 'local',
530
+ runDir: `${join(runDir, 'L')}/../run5`,
531
+ repoPath: repo,
532
+ baseRef: 'nope',
533
+ deps: LOCAL_DEPS,
534
+ })
535
+ expect((await readdir(repo)).sort()).toEqual(before)
536
+ expect(await gitStatus(repo)).toBe(statusBefore)
537
+ })
538
+
539
+ test('local setup with a --repo that does not exist fails with exit 2 and creates no run dir', async () => {
540
+ const missing = join(runDir, 'no-such-repo')
541
+ const nested = join(runDir, 'r1')
542
+ const { exit, stderr } = await setupCapturingStderr({
543
+ mode: 'local',
544
+ runDir: nested,
545
+ repoPath: missing,
546
+ baseRef: 'main',
547
+ deps: LOCAL_DEPS,
548
+ })
549
+ expect(exit).toBe(2)
550
+ expect(stderr).toBe(`magpie: --repo ${missing} does not exist\n`)
551
+ expect(await readdir(runDir)).toEqual([])
552
+ })
553
+
554
+ test('local setup with a --repo that is a file fails with exit 2 and creates no run dir', async () => {
555
+ const file = join(repo, 'a.txt')
556
+ const nested = join(runDir, 'r1')
557
+ const { exit, stderr } = await setupCapturingStderr({
558
+ mode: 'local',
559
+ runDir: nested,
560
+ repoPath: file,
561
+ baseRef: 'main',
562
+ deps: LOCAL_DEPS,
563
+ })
564
+ expect(exit).toBe(2)
565
+ expect(stderr).toBe(`magpie: --repo ${file} is not a directory\n`)
566
+ expect(await readdir(runDir)).toEqual([])
567
+ })
568
+
569
+ test('local setup with --repo outside any git repository fails with exit 2 and creates no run dir', async () => {
570
+ const notRepo = await mkdtemp(join(tmpdir(), 'magpie-setup-notrepo-'))
571
+ const nested = join(runDir, 'r1')
572
+ try {
573
+ const { exit, stderr } = await setupCapturingStderr({
574
+ mode: 'local',
575
+ runDir: nested,
576
+ repoPath: notRepo,
577
+ baseRef: 'main',
578
+ deps: LOCAL_DEPS,
579
+ })
580
+ expect(exit).toBe(2)
581
+ expect(stderr).toContain(`magpie: --repo ${notRepo} is not inside a git work tree: `)
582
+ expect(await readdir(runDir)).toEqual([])
583
+ } finally {
584
+ await rm(notRepo, { recursive: true, force: true })
585
+ }
586
+ })
@@ -180,7 +180,7 @@ test('references/scout.md holds the scout prompt and the brief contract', async
180
180
  }
181
181
  expect(text).toContain('brief: <N> changes, <K> watch items, <R> review rules')
182
182
  expect(text).not.toContain('"subsystems"')
183
- for (const ph of ['<<RUN_DIR>>', '<<PR_NUMBER>>']) {
183
+ for (const ph of ['<<RUN_DIR>>', '<<WORKTREE>>', '<<TARGET>>']) {
184
184
  expect(text).toContain(ph)
185
185
  }
186
186
  // watchItems are context for specialists, not findings in their own right.
@@ -219,8 +219,9 @@ test('SKILL.md no longer inlines the prompt bodies it moved out', async () => {
219
219
  }
220
220
  // The walkthrough is the always-read part; keep it small enough to be cheap.
221
221
  // Raised from 2600 when the sharded-dispatch and fallback-diff prose was added:
222
- // that's real, load-bearing procedure, not bloat.
223
- expect(text.split(/\s+/).length).toBeLessThan(3000)
222
+ // that's real, load-bearing procedure, not bloat. Raised from 3000 for the
223
+ // local-mode section, which the walkthrough already sat 3 words under.
224
+ expect(text.split(/\s+/).length).toBeLessThan(3500)
224
225
  })
225
226
 
226
227
  test('styles.css declares a prefers-color-scheme:dark block that overrides core tokens', async () => {
@@ -413,11 +414,13 @@ test('the stage-4 gate offers only options the pipeline can carry out', async ()
413
414
  expect(gate).toContain('--max-files')
414
415
  // Option 3 must name a mechanism that exists. There is no unreviewed marker in
415
416
  // the report, so the record is a diagnostic log entry, on a stage outside
416
- // status-cmd's ORDER ladder and never `status: error`.
417
- const { ORDER } = await import('../status-cmd.ts')
417
+ // status-cmd's ladders (a local run can hit the same gate) and never
418
+ // `status: error`.
419
+ const { ORDER, LOCAL_ORDER } = await import('../status-cmd.ts')
418
420
  const stage = gate.match(/\{stage: ([a-z-]+), status: ([a-z-]+)/)
419
421
  expect(stage?.[1]).toBeDefined()
420
422
  expect(ORDER as readonly string[]).not.toContain(stage?.[1])
423
+ expect(LOCAL_ORDER as readonly string[]).not.toContain(stage?.[1])
421
424
  expect(stage?.[2]).not.toBe('error')
422
425
  expect(gate).toMatch(/skipped/)
423
426
  })
@@ -53,3 +53,47 @@ test('error stage halts progression', async () => {
53
53
  expect(result.lastCompleted).toBe('setup')
54
54
  expect(result.error).toBe('context')
55
55
  })
56
+
57
+ function logLines(entries: [string, string][]): string {
58
+ return entries.map(([stage, status]) => `${JSON.stringify({ stage, status })}\n`).join('')
59
+ }
60
+
61
+ const throughPeerReview: [string, string][] = [
62
+ ['setup', 'done'],
63
+ ['context', 'done'],
64
+ ['specialists', 'done'],
65
+ ['dedupe', 'done'],
66
+ ['critic', 'done'],
67
+ ['peer-review', 'done'],
68
+ ]
69
+
70
+ test('a local run after peer-review resumes at handoff', async () => {
71
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: runDir }))
72
+ await writeFile(join(runDir, 'log.jsonl'), logLines(throughPeerReview))
73
+ const result = await runStatus(runDir)
74
+ expect(result.lastCompleted).toBe('peer-review')
75
+ expect(result.next).toBe('handoff')
76
+ })
77
+
78
+ test('a local run with handoff done goes to cleanup', async () => {
79
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: runDir }))
80
+ await writeFile(join(runDir, 'log.jsonl'), logLines([...throughPeerReview, ['handoff', 'done']]))
81
+ const result = await runStatus(runDir)
82
+ expect(result.lastCompleted).toBe('handoff')
83
+ expect(result.next).toBe('cleanup')
84
+ })
85
+
86
+ test('a PR or archived run with no target.json resumes at report after peer-review', async () => {
87
+ await writeFile(join(runDir, 'log.jsonl'), logLines(throughPeerReview))
88
+ const result = await runStatus(runDir)
89
+ expect(result.lastCompleted).toBe('peer-review')
90
+ expect(result.next).toBe('report')
91
+ })
92
+
93
+ test('a PR run with target.json resumes at report after peer-review', async () => {
94
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'pr', root: runDir }))
95
+ await writeFile(join(runDir, 'log.jsonl'), logLines(throughPeerReview))
96
+ const result = await runStatus(runDir)
97
+ expect(result.lastCompleted).toBe('peer-review')
98
+ expect(result.next).toBe('report')
99
+ })
@@ -0,0 +1,84 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { readTarget, reviewRoot, writeTarget } from '../target.ts'
6
+
7
+ let runDir: string
8
+
9
+ beforeEach(async () => {
10
+ runDir = await mkdtemp(join(tmpdir(), 'magpie-target-'))
11
+ })
12
+
13
+ afterEach(async () => {
14
+ await rm(runDir, { recursive: true, force: true })
15
+ })
16
+
17
+ test('a run without target.json reads as PR mode rooted at its worktree', async () => {
18
+ await writeFile(join(runDir, 'log.jsonl'), '')
19
+ expect(await readTarget(runDir)).toEqual({ mode: 'pr', root: join(runDir, 'worktree') })
20
+ })
21
+
22
+ test('a directory with neither target.json nor log.jsonl is not a run', async () => {
23
+ await expect(readTarget(runDir)).rejects.toThrow(
24
+ `${runDir}: not a magpie run (no target.json or log.jsonl)`,
25
+ )
26
+ })
27
+
28
+ test('a missing directory is not a run', async () => {
29
+ const missing = join(runDir, 'mistyped')
30
+ await expect(reviewRoot(missing)).rejects.toThrow(
31
+ `${missing}: not a magpie run (no target.json or log.jsonl)`,
32
+ )
33
+ })
34
+
35
+ test('a written local target reads back', async () => {
36
+ await writeTarget(runDir, { mode: 'local', root: '/x' })
37
+ expect(await readTarget(runDir)).toEqual({ mode: 'local', root: '/x' })
38
+ })
39
+
40
+ test('writeTarget writes two-space JSON with a trailing newline', async () => {
41
+ await writeTarget(runDir, { mode: 'local', root: '/x' })
42
+ expect(await readFile(join(runDir, 'target.json'), 'utf8')).toBe(
43
+ '{\n "mode": "local",\n "root": "/x"\n}\n',
44
+ )
45
+ })
46
+
47
+ test('reviewRoot returns the root named in target.json', async () => {
48
+ await writeTarget(runDir, { mode: 'local', root: '/repo' })
49
+ expect(await reviewRoot(runDir)).toBe('/repo')
50
+ })
51
+
52
+ test('an unknown mode throws naming target.json', async () => {
53
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'other', root: '/x' }))
54
+ await expect(readTarget(runDir)).rejects.toThrow(/target\.json.*mode/)
55
+ })
56
+
57
+ test('a non-string root throws naming target.json', async () => {
58
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: 7 }))
59
+ await expect(readTarget(runDir)).rejects.toThrow(/target\.json.*root/)
60
+ })
61
+
62
+ test('an empty root throws as not absolute', async () => {
63
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: '' }))
64
+ await expect(readTarget(runDir)).rejects.toThrow(
65
+ `${join(runDir, 'target.json')}: root must be an absolute path`,
66
+ )
67
+ })
68
+
69
+ test('a relative root throws as not absolute', async () => {
70
+ await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: 'repo' }))
71
+ await expect(readTarget(runDir)).rejects.toThrow(
72
+ `${join(runDir, 'target.json')}: root must be an absolute path`,
73
+ )
74
+ })
75
+
76
+ test('a target.json that cannot be read for a reason other than absence rejects', async () => {
77
+ await mkdir(join(runDir, 'target.json'))
78
+ await expect(readTarget(runDir)).rejects.toThrow(/EISDIR/)
79
+ })
80
+
81
+ test('unparseable target.json throws naming the file', async () => {
82
+ await writeFile(join(runDir, 'target.json'), 'not json')
83
+ await expect(readTarget(runDir)).rejects.toThrow(/target\.json/)
84
+ })
@@ -10,13 +10,14 @@ import {
10
10
  parseFinding,
11
11
  } from '../types.ts'
12
12
 
13
- test('FOCUS_IDS contains the LLM focuses plus the deterministic tests domain', () => {
13
+ test('FOCUS_IDS contains the LLM focuses, the checks pass and the deterministic tests domain', () => {
14
14
  expect(FOCUS_IDS).toEqual([
15
15
  'security',
16
16
  'bugs',
17
17
  'performance',
18
18
  'code-smells',
19
19
  'architecture',
20
+ 'checks',
20
21
  'tests',
21
22
  ])
22
23
  })
@@ -1,6 +1,7 @@
1
1
  import { appendFile, readFile, rename, stat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { writeLabels } from './labels-cmd.ts'
4
+ import { readTarget } from './target.ts'
4
5
  import { removeWorktree } from './worktree.ts'
5
6
 
6
7
  export type RunCleanupInput = {
@@ -67,6 +68,9 @@ export async function killServer(
67
68
  }
68
69
 
69
70
  export async function runCleanup(input: RunCleanupInput): Promise<number> {
71
+ // Read before any side effect, so a malformed target.json leaves the run untouched.
72
+ // Local mode promises the caller no report, so its cleanup does not point at one.
73
+ const { mode } = await readTarget(input.runDir)
70
74
  const worktreePath = join(input.runDir, 'worktree')
71
75
  const worktreeExists = await stat(worktreePath)
72
76
  .then(() => true)
@@ -95,7 +99,7 @@ export async function runCleanup(input: RunCleanupInput): Promise<number> {
95
99
  await rename(input.runDir, target)
96
100
  process.stdout.write(`archived to ${target}\n`)
97
101
  const archivedId = target.split('/').filter(Boolean).pop()
98
- if (archivedId) {
102
+ if (archivedId && mode !== 'local') {
99
103
  process.stdout.write(`view later: magpie open ${archivedId}\n`)
100
104
  }
101
105
  if (kill.outcome === 'failed') {