@iceinvein/agent-skills 0.1.40 → 0.2.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 (139) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +105 -28
  3. package/package.json +1 -1
  4. package/skills/index.json +14 -2
  5. package/skills/magpie/SKILL.md +118 -40
  6. package/skills/magpie/bin/magpie.ts +43 -0
  7. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  8. package/skills/magpie/package.json +1 -1
  9. package/skills/magpie/references/peer-review.md +7 -2
  10. package/skills/magpie/references/specialists.md +38 -7
  11. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  12. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  13. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  14. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  15. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  16. package/skills/magpie/scripts/__tests__/gh.test.ts +69 -0
  17. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  18. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  19. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  20. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +95 -0
  21. package/skills/magpie/scripts/__tests__/render-findings.test.ts +33 -0
  22. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  23. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  24. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  25. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +96 -1
  26. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  27. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  28. package/skills/magpie/scripts/findings-files.ts +32 -0
  29. package/skills/magpie/scripts/gh.ts +64 -13
  30. package/skills/magpie/scripts/git-diff.ts +111 -0
  31. package/skills/magpie/scripts/path-filter.ts +9 -5
  32. package/skills/magpie/scripts/refresh.ts +8 -0
  33. package/skills/magpie/scripts/render-cmd.ts +28 -9
  34. package/skills/magpie/scripts/render-findings.ts +11 -1
  35. package/skills/magpie/scripts/render-progress.ts +6 -1
  36. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  37. package/skills/magpie/scripts/shard.ts +171 -0
  38. package/skills/magpie/scripts/status-cmd.ts +4 -1
  39. package/skills/magpie/skill.json +2 -2
  40. package/skills/magpie/templates/styles.css +5 -0
  41. package/skills/migrate/README.md +194 -0
  42. package/skills/migrate/SKILL.md +197 -0
  43. package/skills/migrate/bin/migrate +15 -0
  44. package/skills/migrate/bin/migrate.ts +309 -0
  45. package/skills/migrate/biome.json +35 -0
  46. package/skills/migrate/bun.lock +24 -0
  47. package/skills/migrate/docs/architecture.md +294 -0
  48. package/skills/migrate/docs/reference.md +590 -0
  49. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  50. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  51. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  52. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  53. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  54. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  55. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  56. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  57. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  58. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  59. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  60. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  61. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  62. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  63. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  64. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  67. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  68. package/skills/migrate/install.sh +68 -0
  69. package/skills/migrate/package.json +17 -0
  70. package/skills/migrate/references/phases/enumerate.md +291 -0
  71. package/skills/migrate/references/phases/extract.md +652 -0
  72. package/skills/migrate/references/phases/parity.md +275 -0
  73. package/skills/migrate/references/phases/probe.md +135 -0
  74. package/skills/migrate/references/phases/queue.md +242 -0
  75. package/skills/migrate/references/phases/seam.md +416 -0
  76. package/skills/migrate/references/recipes/README.md +116 -0
  77. package/skills/migrate/references/recipes/aspnet.md +287 -0
  78. package/skills/migrate/references/run-ops.md +280 -0
  79. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  80. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  81. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  82. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  83. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  84. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  85. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  86. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  87. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  88. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  89. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  90. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  91. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  92. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  93. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  94. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  95. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  96. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  97. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  98. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  99. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  100. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  101. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  102. package/skills/migrate/scripts/census-cmd.ts +109 -0
  103. package/skills/migrate/scripts/census.ts +342 -0
  104. package/skills/migrate/scripts/check-cmd.ts +24 -0
  105. package/skills/migrate/scripts/check.ts +376 -0
  106. package/skills/migrate/scripts/citations.ts +92 -0
  107. package/skills/migrate/scripts/config.ts +237 -0
  108. package/skills/migrate/scripts/ids.ts +31 -0
  109. package/skills/migrate/scripts/import-cmd.ts +141 -0
  110. package/skills/migrate/scripts/init-cmd.ts +118 -0
  111. package/skills/migrate/scripts/leaks.ts +184 -0
  112. package/skills/migrate/scripts/lock.ts +188 -0
  113. package/skills/migrate/scripts/paths.ts +103 -0
  114. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  115. package/skills/migrate/scripts/phases.ts +113 -0
  116. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  117. package/skills/migrate/scripts/queue.ts +258 -0
  118. package/skills/migrate/scripts/report-cmd.ts +47 -0
  119. package/skills/migrate/scripts/report.ts +131 -0
  120. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  121. package/skills/migrate/scripts/status-cmd.ts +52 -0
  122. package/skills/migrate/scripts/store.ts +159 -0
  123. package/skills/migrate/scripts/types.ts +137 -0
  124. package/skills/migrate/scripts/validate.ts +221 -0
  125. package/skills/migrate/skill.json +33 -0
  126. package/skills/migrate/templates/config.toml +27 -0
  127. package/skills/migrate/templates/queue-item.md +17 -0
  128. package/skills/migrate/tsconfig.json +18 -0
  129. package/skills/migrate/uninstall.sh +31 -0
  130. package/skills/sluice/SKILL.md +82 -0
  131. package/skills/sluice/references/deep-channel.md +94 -0
  132. package/skills/sluice/references/finish.md +35 -0
  133. package/skills/sluice/references/intent.md +29 -0
  134. package/skills/sluice/references/review.md +42 -0
  135. package/skills/sluice/references/root-cause.md +38 -0
  136. package/skills/sluice/references/show-or-say.md +36 -0
  137. package/skills/sluice/references/test-first.md +35 -0
  138. package/skills/sluice/references/verify.md +26 -0
  139. package/skills/sluice/skill.json +32 -0
@@ -10,6 +10,8 @@ Subcommands:
10
10
  setup <run-dir> --pr <n> Pre-flight, fetch PR, create worktree
11
11
  serve <run-dir-or-id> Start the HTML server (accepts active or archived run id)
12
12
  dedupe <run-dir> Merge specialist findings into deduped set
13
+ shard <run-dir> [--budget N] [--max-files N]
14
+ Re-split diff.patch into budgeted shards
13
15
  render <run-dir> <page> Render progress.html or findings.html
14
16
  cleanup <run-dir> Remove worktree, stop server, archive run
15
17
  status <run-dir> Print highest completed stage
@@ -116,6 +118,47 @@ const HANDLERS: Record<string, Handler> = {
116
118
  const { runDedupe } = await import('../scripts/dedupe-cmd.ts')
117
119
  return runDedupe(runDir, threshold !== undefined ? { threshold } : {})
118
120
  },
121
+ shard: async (args) => {
122
+ const runDir = args[0]
123
+ if (!runDir) {
124
+ process.stderr.write('shard: missing <run-dir> [--budget <n>] [--max-files <n>]\n')
125
+ return 2
126
+ }
127
+ const numFlag = (name: string): number | undefined | null => {
128
+ const idx = args.indexOf(name)
129
+ if (idx === -1) return undefined
130
+ const n = Number(args[idx + 1])
131
+ if (!Number.isFinite(n) || n <= 0) {
132
+ process.stderr.write(`shard: invalid ${name} ${args[idx + 1]} (want a positive number)\n`)
133
+ return null
134
+ }
135
+ return n
136
+ }
137
+ const budget = numFlag('--budget')
138
+ if (budget === null) return 2
139
+ const maxFiles = numFlag('--max-files')
140
+ if (maxFiles === null) return 2
141
+ // `shardDiff` tolerates a missing diff.patch (setup calls it before the diff
142
+ // can exist) and mkdirs `shards/` on the way, so a typo'd run directory would
143
+ // otherwise be invented on disk and reported as a successful zero-shard split.
144
+ const { stat } = await import('node:fs/promises')
145
+ const isRunDir = await stat(runDir)
146
+ .then((s) => s.isDirectory())
147
+ .catch(() => false)
148
+ if (!isRunDir) {
149
+ process.stderr.write(`shard: no such run directory: ${runDir}\n`)
150
+ return 2
151
+ }
152
+ const { shardDiff } = await import('../scripts/shard.ts')
153
+ const manifest = await shardDiff(runDir, {
154
+ ...(budget !== undefined ? { budget } : {}),
155
+ ...(maxFiles !== undefined ? { maxFiles } : {}),
156
+ })
157
+ process.stdout.write(
158
+ `${manifest.shards.length} shard(s), ${manifest.totalFiles} files, ${manifest.totalLines} patch lines\n`,
159
+ )
160
+ return 0
161
+ },
119
162
  render: async (args) => {
120
163
  const runDir = args[0]
121
164
  const page = args[1]
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Fake gh whose `pr diff` refuses to serve, the way GitHub does above ~300
3
+ # files. MAGPIE_FAKE_HEAD_OID lets a test point pr.json at a real local commit.
4
+ # MAGPIE_FAKE_DIFF_MODE=fail (default) exits non-zero; =empty exits 0 with no
5
+ # output, which is the truncation case fetchPr also has to catch.
6
+ case "$1 $2" in
7
+ "pr view")
8
+ cat <<JSON
9
+ {
10
+ "number": 7,
11
+ "title": "Fake big PR",
12
+ "headRefName": "feature-x",
13
+ "baseRefName": "main",
14
+ "headRefOid": "${MAGPIE_FAKE_HEAD_OID:-deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}",
15
+ "baseRefOid": "cafebabecafebabecafebabecafebabecafebabe",
16
+ "author": { "login": "octocat" },
17
+ "body": "Fake body",
18
+ "url": "https://github.com/octocat/Hello-World/pull/7",
19
+ "files": [
20
+ { "path": "a.ts", "additions": 1, "deletions": 1, "changeType": "modified" }
21
+ ],
22
+ "commits": [],
23
+ "closingIssuesReferences": []
24
+ }
25
+ JSON
26
+ ;;
27
+ "pr diff")
28
+ if [ "${MAGPIE_FAKE_DIFF_MODE:-fail}" = "empty" ]; then
29
+ exit 0
30
+ fi
31
+ echo "the diff exceeded the maximum number of files (300) (HTTP 406)" >&2
32
+ exit 1
33
+ ;;
34
+ *)
35
+ echo "fake-gh-nodiff: unsupported args: $*" >&2
36
+ exit 1
37
+ ;;
38
+ esac
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,9 +1,14 @@
1
1
  # Peer-review prompt
2
2
 
3
- The agent substitutes the placeholders below and writes the result to `<run-dir>/peer-prompt.md`. Step 6 then feeds that prompt to the peer reviewer: `codex exec < <run-dir>/peer-prompt.md > <run-dir>/peer.out` when codex is available, or a Claude `general-purpose` subagent (with the `magpie-peer-review-claude-preamble` prepended) writing to `<run-dir>/peer.out` when it is not.
3
+ The agent substitutes the placeholders below and writes the result to `<run-dir>/peer-prompt.md`. Stage 7 then feeds that prompt to the peer reviewer: `codex exec < <run-dir>/peer-prompt.md > <run-dir>/peer.out` when codex is available, or a Claude `general-purpose` subagent (with the `magpie-peer-review-claude-preamble` prepended) writing to `<run-dir>/peer.out` when it is not.
4
4
 
5
5
  Either way, extract the fenced `review-peer-review` block from `peer.out` and save it to `<run-dir>/peer.json`.
6
6
 
7
+ The filenames above are the single-batch case. Stage 7 batches above 40 findings, and
8
+ those runs use `peer-prompt-<k>.md` and `peer-<k>.out` per batch, one reviewer
9
+ invocation each, with every batch's verdicts merged into one `peer.json` after the
10
+ last batch. Everything below is per batch and otherwise unchanged.
11
+
7
12
  ## Substitute before use
8
13
 
9
14
  Replace each `<<NAME>>` placeholder in the block below:
@@ -72,7 +77,7 @@ Rules:
72
77
 
73
78
  ## Claude peer-review preamble
74
79
 
75
- Used only by the Claude fallback path in step 6. Prepend this block verbatim (no substitutions) to the substituted `magpie-peer-review` prompt before dispatching the subagent. Its job is to buy back the independence you lose by using the same model family that produced the findings: the reviewer must re-derive each verdict from the diff rather than trusting the finding text, and must actively resist rubber-stamping.
80
+ Used only by the Claude fallback path in stage 7. Prepend this block verbatim (no substitutions) to the substituted `magpie-peer-review` prompt before dispatching the subagent. Its job is to buy back the independence you lose by using the same model family that produced the findings: the reviewer must re-derive each verdict from the diff rather than trusting the finding text, and must actively resist rubber-stamping.
76
81
 
77
82
  ````magpie-peer-review-claude-preamble
78
83
  You are a fresh, independent second-opinion reviewer. You have no memory of, and no stake in, how the findings below were produced. They were generated by other agents that share your model family, so they may carry the same blind spots you would: do not defer to them, and do not assume they are correct because they sound confident.
@@ -1,10 +1,12 @@
1
1
  # Specialist prompts
2
2
 
3
- Stage 4 of the walkthrough dispatches five subagents from this file. Build each prompt
4
- from up to five parts, in this order, and send it as the agent's entire task:
3
+ Stage 4 of the walkthrough dispatches five subagents per shard from this file. Build
4
+ each prompt from up to six parts, in this order, and send it as the agent's entire
5
+ task. Each part is a fenced block or the named section it points at; the prose around
6
+ them is instruction to you, not text for the specialist.
5
7
 
6
8
  1. The focus block for that focus (the fenced `magpie-specialist-<focus>` blocks below), verbatim.
7
- 2. The run header, with the two placeholders filled in:
9
+ 2. The run header, with the placeholders filled in. Unsharded:
8
10
 
9
11
  ```
10
12
  You are reviewing PR #<PR_NUMBER>.
@@ -12,11 +14,37 @@ Working directory: <RUN_DIR>/worktree
12
14
  Diff: <RUN_DIR>/diff.patch
13
15
  ```
14
16
 
17
+ When stage 4 is fanning this focus across shards, use instead:
18
+
19
+ ```
20
+ You are reviewing PR #<PR_NUMBER>.
21
+ Working directory: <RUN_DIR>/worktree
22
+ Shard: <n> of <N>
23
+ Diff: <RUN_DIR>/shards/shard-<n>.patch
24
+
25
+ This shard is your review scope. The other shards belong to other agents working
26
+ in parallel: do not review files outside your shard, and do not report findings
27
+ anchored to them. Reading any file in the worktree for context is expected and
28
+ encouraged.
29
+ ```
30
+
15
31
  3. The `## Output Contract` section below, verbatim.
16
- 4. The `magpie-codebase-intelligence` block below, verbatim, **only** when the context
32
+ 4. The excluded-files block below, verbatim, when `<RUN_DIR>/excluded-files.json`
33
+ exists (setup writes it only when the path filter removed something, and without
34
+ it the two paths the block names do not exist):
35
+
36
+ ```
37
+ Files excluded by the path filter are absent from your diff but present in the
38
+ worktree, with their full patches in <RUN_DIR>/diff.full.patch and the exclusion
39
+ list in <RUN_DIR>/excluded-files.json. When a change in your scope implies a change
40
+ in an excluded file (a migration implies a model snapshot, a schema change implies
41
+ generated types), open it and cross-check rather than treating it as out of scope.
42
+ ```
43
+
44
+ 5. The `magpie-codebase-intelligence` block below, verbatim, **only** when the context
17
45
  stage logged `codeIntelligence: true`. Omit it entirely otherwise: telling a
18
46
  specialist to use tools it does not have wastes a turn per specialist on discovery.
19
- 5. The brief, when `<RUN_DIR>/brief.json` exists, rendered as:
47
+ 6. The brief, when `<RUN_DIR>/brief.json` exists, rendered as:
20
48
 
21
49
  ```
22
50
  ## What this PR is for
@@ -52,7 +80,10 @@ into the same generic pass. Do not paraphrase, summarise, or trim either one.
52
80
 
53
81
  ## Output Contract
54
82
 
55
- Write findings to <RUN_DIR>/findings/<focus>.json before returning. The file MUST be a JSON array. Each entry MUST conform to this schema exactly (no extra top-level keys, no renamed keys):
83
+ Write findings to <RUN_DIR>/findings/<focus>.json before returning, or to
84
+ <RUN_DIR>/findings/<focus>.shard-<n>.json when your run header names a shard. The file
85
+ MUST be a JSON array. Each entry MUST conform to this schema exactly (no extra
86
+ top-level keys, no renamed keys):
56
87
 
57
88
  ```
58
89
  {
@@ -114,7 +145,7 @@ If you have no findings, write []. Return as your final tool result a single lin
114
145
 
115
146
  ## Codebase intelligence
116
147
 
117
- Include this block as part 4 only when the context stage logged `codeIntelligence: true`.
148
+ Include this block as part 5 only when the context stage logged `codeIntelligence: true`.
118
149
 
119
150
  ```magpie-codebase-intelligence
120
151
  ## Codebase intelligence
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
4
  import { tmpdir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
 
@@ -274,3 +275,102 @@ test('top-level --help surfaces the preview subcommand', async () => {
274
275
  expect(stdout).toContain('preview')
275
276
  expect(stdout).toContain('--help-preview')
276
277
  })
278
+
279
+ test('shard rejects missing <run-dir>', async () => {
280
+ const proc = Bun.spawn(['bun', CLI, 'shard'], { stdout: 'pipe', stderr: 'pipe' })
281
+ const stderr = await new Response(proc.stderr).text()
282
+ const exit = await proc.exited
283
+ expect(exit).toBe(2)
284
+ expect(stderr).toContain('shard: missing <run-dir>')
285
+ })
286
+
287
+ test('shard rejects a zero, negative, or non-numeric --budget, naming the flag and value', async () => {
288
+ for (const bad of ['0', '-5', 'abc']) {
289
+ const proc = Bun.spawn(['bun', CLI, 'shard', '/tmp/does-not-matter', '--budget', bad], {
290
+ stdout: 'pipe',
291
+ stderr: 'pipe',
292
+ })
293
+ const stderr = await new Response(proc.stderr).text()
294
+ const exit = await proc.exited
295
+ expect(exit).toBe(2)
296
+ expect(stderr).toContain(`shard: invalid --budget ${bad}`)
297
+ }
298
+ })
299
+
300
+ test('shard rejects an invalid --max-files, naming the flag and value', async () => {
301
+ const proc = Bun.spawn(['bun', CLI, 'shard', '/tmp/does-not-matter', '--max-files', '-1'], {
302
+ stdout: 'pipe',
303
+ stderr: 'pipe',
304
+ })
305
+ const stderr = await new Response(proc.stderr).text()
306
+ const exit = await proc.exited
307
+ expect(exit).toBe(2)
308
+ expect(stderr).toContain('shard: invalid --max-files -1')
309
+ })
310
+
311
+ test('shard rejects a run directory that does not exist instead of creating it', async () => {
312
+ const missing = join(tmpdir(), `magpie-cli-no-run-${Date.now()}`, 'nested')
313
+ const proc = Bun.spawn(['bun', CLI, 'shard', missing], { stdout: 'pipe', stderr: 'pipe' })
314
+ const stdout = await new Response(proc.stdout).text()
315
+ const stderr = await new Response(proc.stderr).text()
316
+ const exit = await proc.exited
317
+ expect(exit).toBe(2)
318
+ expect(stderr).toContain(`shard: no such run directory: ${missing}`)
319
+ // The old behaviour mkdir -p'd the typo and reported success.
320
+ expect(stdout).not.toContain('shard(s)')
321
+ expect(existsSync(missing)).toBe(false)
322
+ })
323
+
324
+ test('--help shows the shard budget flags', async () => {
325
+ const proc = Bun.spawn(['bun', CLI, '--help'], { stdout: 'pipe' })
326
+ const stdout = await new Response(proc.stdout).text()
327
+ expect(await proc.exited).toBe(0)
328
+ expect(stdout).toContain('shard <run-dir> [--budget N] [--max-files N]')
329
+ })
330
+
331
+ test('shard with a custom --budget and --max-files reaches shardDiff with those values', async () => {
332
+ const runDir = await mkdtemp(join(tmpdir(), 'magpie-cli-shard-'))
333
+ try {
334
+ // Two files under the same two-segment directory group ('src'), each with
335
+ // its own diff --git block, so a --max-files 1 cap forces the group apart
336
+ // into two shards regardless of --budget: proof the flags actually reach
337
+ // shardDiff rather than the defaults (budget 6000, max-files 80) taking
338
+ // over silently.
339
+ const diff = [
340
+ 'diff --git a/src/a.ts b/src/a.ts',
341
+ 'index 0000000..1111111 100644',
342
+ '--- a/src/a.ts',
343
+ '+++ b/src/a.ts',
344
+ '@@ -1 +1 @@',
345
+ '-x',
346
+ '+y',
347
+ 'diff --git a/src/b.ts b/src/b.ts',
348
+ 'index 0000000..1111111 100644',
349
+ '--- a/src/b.ts',
350
+ '+++ b/src/b.ts',
351
+ '@@ -1 +1 @@',
352
+ '-x',
353
+ '+y',
354
+ '',
355
+ ].join('\n')
356
+ await writeFile(join(runDir, 'diff.patch'), diff)
357
+
358
+ const proc = Bun.spawn(['bun', CLI, 'shard', runDir, '--budget', '3', '--max-files', '1'], {
359
+ stdout: 'pipe',
360
+ stderr: 'pipe',
361
+ })
362
+ const stdout = await new Response(proc.stdout).text()
363
+ const exit = await proc.exited
364
+ expect(exit).toBe(0)
365
+ expect(stdout).toContain('2 shard(s)')
366
+
367
+ const manifest = JSON.parse(
368
+ await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'),
369
+ ) as { budget: number; maxFiles: number; shards: unknown[] }
370
+ expect(manifest.budget).toBe(3)
371
+ expect(manifest.maxFiles).toBe(1)
372
+ expect(manifest.shards).toHaveLength(2)
373
+ } finally {
374
+ await rm(runDir, { recursive: true, force: true })
375
+ }
376
+ })
@@ -122,3 +122,190 @@ test('runDedupe with threshold 0 keeps everything', async () => {
122
122
  const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
123
123
  expect(kept).toHaveLength(1)
124
124
  })
125
+
126
+ test('runDedupe collects sharded focus files', async () => {
127
+ await writeFile(
128
+ join(runDir, 'findings', 'security.shard-1.json'),
129
+ JSON.stringify([f('security-1', 'a.ts', 10, 'hardcoded token in the client', 'security')]),
130
+ )
131
+ await writeFile(
132
+ join(runDir, 'findings', 'security.shard-2.json'),
133
+ JSON.stringify([f('security-1', 'z.ts', 99, 'missing auth check on the handler', 'security')]),
134
+ )
135
+ const exit = await runDedupe(runDir, { threshold: 0 })
136
+ expect(exit).toBe(0)
137
+ const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
138
+ expect(out).toHaveLength(2)
139
+ const ids = out.map((x: { id: string }) => x.id).sort()
140
+ expect(ids).toEqual(['security-s1-1', 'security-s2-1'])
141
+ })
142
+
143
+ test('runDedupe leaves unsharded ids untouched', async () => {
144
+ await writeFile(
145
+ join(runDir, 'findings', 'bugs.json'),
146
+ JSON.stringify([f('bugs-1', 'a.ts', 10, 'off by one in the loop bound', 'bugs')]),
147
+ )
148
+ const exit = await runDedupe(runDir, { threshold: 0 })
149
+ expect(exit).toBe(0)
150
+ const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
151
+ expect(out[0]?.id).toBe('bugs-1')
152
+ })
153
+
154
+ const LLM_FOCUSES = ['security', 'bugs', 'performance', 'code-smells', 'architecture'] as const
155
+
156
+ /** A manifest with `count` shards, shaped like the one `shardDiff` writes. */
157
+ async function writeManifest(count: number): Promise<void> {
158
+ await mkdir(join(runDir, 'shards'), { recursive: true })
159
+ const shards = Array.from({ length: count }, (_, i) => ({
160
+ id: i + 1,
161
+ path: count === 1 ? 'diff.patch' : `shards/shard-${i + 1}.patch`,
162
+ files: [`src/f${i + 1}.ts`],
163
+ lines: 10,
164
+ }))
165
+ await writeFile(
166
+ join(runDir, 'shards', 'manifest.json'),
167
+ JSON.stringify({
168
+ budget: 6000,
169
+ maxFiles: 80,
170
+ totalFiles: count,
171
+ totalLines: 10 * count,
172
+ shards,
173
+ }),
174
+ )
175
+ }
176
+
177
+ /** Run dedupe with stdout captured, the way a human at the terminal sees it. */
178
+ async function dedupeCapturingStdout(): Promise<{ exit: number; stdout: string }> {
179
+ const out: string[] = []
180
+ const origWrite = process.stdout.write.bind(process.stdout)
181
+ process.stdout.write = ((s: string) => {
182
+ out.push(s)
183
+ return true
184
+ }) as typeof process.stdout.write
185
+ try {
186
+ const exit = await runDedupe(runDir, { threshold: 0 })
187
+ return { exit, stdout: out.join('') }
188
+ } finally {
189
+ process.stdout.write = origWrite
190
+ }
191
+ }
192
+
193
+ async function dedupeLogEntry(status: string): Promise<Record<string, unknown> | undefined> {
194
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
195
+ return log
196
+ .split('\n')
197
+ .filter(Boolean)
198
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
199
+ .find((e) => e.stage === 'dedupe' && e.status === status)
200
+ }
201
+
202
+ test('runDedupe reports nothing missing when every (focus, shard) file is present', async () => {
203
+ await writeManifest(3)
204
+ for (const focus of LLM_FOCUSES) {
205
+ for (const shard of [1, 2, 3]) {
206
+ await writeFile(
207
+ join(runDir, 'findings', `${focus}.shard-${shard}.json`),
208
+ JSON.stringify([f(`${focus}-1`, `s${shard}.ts`, 1, `${focus} on shard ${shard}`, focus)]),
209
+ )
210
+ }
211
+ }
212
+ // Setup writes tests.json once for the whole run, never per shard.
213
+ await writeFile(join(runDir, 'findings', 'tests.json'), JSON.stringify([]))
214
+ const { exit, stdout } = await dedupeCapturingStdout()
215
+ expect(exit).toBe(0)
216
+ expect(stdout).not.toContain('missing')
217
+ expect(stdout).toContain('15')
218
+ const done = await dedupeLogEntry('done')
219
+ expect(done?.coverage).toEqual({ expected: 15, missing: [] })
220
+ })
221
+
222
+ test('runDedupe names the (focus, shard) pairs that have no findings file', async () => {
223
+ await writeManifest(2)
224
+ for (const focus of LLM_FOCUSES) {
225
+ await writeFile(
226
+ join(runDir, 'findings', `${focus}.shard-1.json`),
227
+ JSON.stringify([f(`${focus}-1`, 'a.ts', 1, `${focus} on shard 1`, focus)]),
228
+ )
229
+ }
230
+ // Shard 2 lost security and bugs: two of ten agents never wrote a file.
231
+ for (const focus of ['performance', 'code-smells', 'architecture']) {
232
+ await writeFile(
233
+ join(runDir, 'findings', `${focus}.shard-2.json`),
234
+ JSON.stringify([f(`${focus}-1`, 'b.ts', 2, `${focus} on shard 2`, focus)]),
235
+ )
236
+ }
237
+ const { exit, stdout } = await dedupeCapturingStdout()
238
+ expect(exit).toBe(0)
239
+ expect(stdout).toContain('missing')
240
+ expect(stdout).toContain('security.shard-2.json')
241
+ expect(stdout).toContain('bugs.shard-2.json')
242
+ const done = await dedupeLogEntry('done')
243
+ expect(done?.coverage).toEqual({
244
+ expected: 10,
245
+ missing: ['security.shard-2.json', 'bugs.shard-2.json'],
246
+ })
247
+ })
248
+
249
+ test('a single-shard manifest expects the unsharded findings filenames', async () => {
250
+ await writeManifest(1)
251
+ for (const focus of ['security', 'bugs', 'performance', 'code-smells']) {
252
+ await writeFile(
253
+ join(runDir, 'findings', `${focus}.json`),
254
+ JSON.stringify([f(`${focus}-1`, 'a.ts', 1, `${focus} finding here`, focus)]),
255
+ )
256
+ }
257
+ const { stdout } = await dedupeCapturingStdout()
258
+ // Stage 4 takes the unsharded path on a single-shard run, so the expected name
259
+ // has no shard suffix.
260
+ expect(stdout).toContain('architecture.json')
261
+ const done = await dedupeLogEntry('done')
262
+ expect(done?.coverage).toEqual({ expected: 5, missing: ['architecture.json'] })
263
+ })
264
+
265
+ test('runDedupe without a manifest reports no coverage at all', async () => {
266
+ await writeFile(
267
+ join(runDir, 'findings', 'bugs.json'),
268
+ JSON.stringify([f('bugs-1', 'a.ts', 10, 'off by one in the loop bound', 'bugs')]),
269
+ )
270
+ const { exit, stdout } = await dedupeCapturingStdout()
271
+ expect(exit).toBe(0)
272
+ // A run predating the sharder has nothing to reconcile against; behaviour is
273
+ // exactly as before.
274
+ expect(stdout).toBe('')
275
+ const done = await dedupeLogEntry('done')
276
+ expect(done).toBeDefined()
277
+ expect(done?.coverage).toBeUndefined()
278
+ })
279
+
280
+ test('a zero-shard manifest expects nothing (the diff filtered down to nothing)', async () => {
281
+ await writeManifest(0)
282
+ const { exit, stdout } = await dedupeCapturingStdout()
283
+ expect(exit).toBe(0)
284
+ expect(stdout).toBe('')
285
+ const done = await dedupeLogEntry('done')
286
+ expect(done?.coverage).toBeUndefined()
287
+ })
288
+
289
+ test('a malformed shards/manifest.json is treated as no manifest', async () => {
290
+ await mkdir(join(runDir, 'shards'), { recursive: true })
291
+ await writeFile(join(runDir, 'shards', 'manifest.json'), '{ not json')
292
+ const { exit, stdout } = await dedupeCapturingStdout()
293
+ expect(exit).toBe(0)
294
+ expect(stdout).toBe('')
295
+ const done = await dedupeLogEntry('done')
296
+ expect(done?.coverage).toBeUndefined()
297
+ })
298
+
299
+ test('runDedupe still skips files it cannot map to a focus', async () => {
300
+ await writeFile(join(runDir, 'findings', 'nonsense.json'), JSON.stringify([]))
301
+ await writeFile(join(runDir, 'findings', 'security.shard-x.json'), JSON.stringify([]))
302
+ const exit = await runDedupe(runDir)
303
+ expect(exit).toBe(0)
304
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
305
+ const skips = log
306
+ .split('\n')
307
+ .filter(Boolean)
308
+ .map((l) => JSON.parse(l))
309
+ .filter((e) => e.status === 'skip' && e.reason === 'unknown-focus')
310
+ expect(skips.map((s) => s.file).sort()).toEqual(['nonsense.json', 'security.shard-x.json'])
311
+ })
@@ -0,0 +1,51 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { chunkPath, splitFileChunks, splitRawChunks } from '../diff-chunks.ts'
3
+
4
+ /** A file chunk with `bodyLines` added lines, shaped like a real patch. */
5
+ function chunk(path: string, bodyLines: number): string {
6
+ const body = Array.from({ length: bodyLines }, (_, i) => `+line ${i}`).join('\n')
7
+ return [
8
+ `diff --git a/${path} b/${path}`,
9
+ 'index 0000000..1111111 100644',
10
+ `--- a/${path}`,
11
+ `+++ b/${path}`,
12
+ `@@ -0,0 +1,${bodyLines} @@`,
13
+ body,
14
+ '',
15
+ ].join('\n')
16
+ }
17
+
18
+ test('splitFileChunks finds one chunk per file with its path and line count', () => {
19
+ const diff = chunk('src/a.ts', 3) + chunk('src/b.ts', 5)
20
+ const chunks = splitFileChunks(diff)
21
+ expect(chunks.map((c) => c.path)).toEqual(['src/a.ts', 'src/b.ts'])
22
+ expect(chunks[0]?.lines).toBeGreaterThan(3)
23
+ expect(chunks[0]?.text).toContain('diff --git a/src/a.ts')
24
+ })
25
+
26
+ test('splitFileChunks returns nothing for an empty diff', () => {
27
+ expect(splitFileChunks('')).toEqual([])
28
+ expect(splitFileChunks('\n\n')).toEqual([])
29
+ })
30
+
31
+ test('splitRawChunks keeps a leading preamble as its own element', () => {
32
+ const diff = `some preamble text\n${chunk('src/a.ts', 2)}`
33
+ const raw = splitRawChunks(diff)
34
+ expect(raw[0]).toBe('some preamble text\n')
35
+ expect(raw[1]?.startsWith('diff --git ')).toBe(true)
36
+ })
37
+
38
+ test('chunkPath prefers the b-side path when a rename changes it', () => {
39
+ const rename = [
40
+ 'diff --git a/old/name.ts b/new/name.ts',
41
+ 'similarity index 100%',
42
+ 'rename from old/name.ts',
43
+ 'rename to new/name.ts',
44
+ '',
45
+ ].join('\n')
46
+ expect(chunkPath(rename)).toBe('new/name.ts')
47
+ })
48
+
49
+ test('chunkPath returns null when the header cannot be parsed', () => {
50
+ expect(chunkPath('not a diff header at all')).toBeNull()
51
+ })
@@ -0,0 +1,54 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { filterDiff } from '../path-filter.ts'
3
+
4
+ // filterDiff's refactor onto the shared diff-chunks module (scripts/diff-chunks.ts)
5
+ // must preserve two branches that scripts/__tests__/path-filter.test.ts's DIFF
6
+ // fixture never exercises, because that fixture always starts with a well-formed
7
+ // `diff --git a/... b/...` header:
8
+ //
9
+ // 1. a leading non-`diff --git` preamble chunk is kept in `filtered`
10
+ // 2. a chunk whose header cannot be parsed is kept in `filtered`
11
+ //
12
+ // These tests assert directly on filterDiff's output for both, so a future edit
13
+ // to diff-chunks.ts that breaks either preservation is caught here rather than
14
+ // resting on a manual code trace.
15
+
16
+ const VALID_CHUNK = [
17
+ 'diff --git a/src/a.ts b/src/a.ts',
18
+ 'index 1..2 100644',
19
+ '--- a/src/a.ts',
20
+ '+++ b/src/a.ts',
21
+ '@@ -1 +1 @@',
22
+ '-x',
23
+ '+y',
24
+ '',
25
+ ].join('\n')
26
+
27
+ test('filterDiff keeps a leading preamble that appears before the first file header', () => {
28
+ const preamble = 'Note: this diff was truncated by the transport layer\n'
29
+ const diff = preamble + VALID_CHUNK
30
+ const { filtered } = filterDiff(diff, { exclude: [], include: [], useDefaults: false })
31
+ expect(filtered).toContain('Note: this diff was truncated by the transport layer')
32
+ })
33
+
34
+ test('filterDiff keeps a chunk whose diff --git header cannot be parsed', () => {
35
+ // No " b/" separator after the a-side path, so FILE_HEADER cannot match and
36
+ // chunkPath returns null; filterDiff must keep the chunk rather than drop it.
37
+ const malformed = [
38
+ 'diff --git a/foo.ts b-badformat',
39
+ 'index 1234567..89abcde 100644',
40
+ '--- a/foo.ts',
41
+ '+++ b-badformat',
42
+ '@@ -1 +1 @@',
43
+ '-x',
44
+ '+y',
45
+ '',
46
+ ].join('\n')
47
+ const { filtered, excluded } = filterDiff(malformed, {
48
+ exclude: ['**/*'],
49
+ include: [],
50
+ useDefaults: false,
51
+ })
52
+ expect(filtered).toContain('b-badformat')
53
+ expect(excluded).toEqual([])
54
+ })
@@ -0,0 +1,35 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { namespaceId, parseFindingsFilename } from '../findings-files.ts'
3
+
4
+ test('parseFindingsFilename reads an unsharded focus file', () => {
5
+ expect(parseFindingsFilename('security.json')).toEqual({ focus: 'security', shard: null })
6
+ expect(parseFindingsFilename('code-smells.json')).toEqual({ focus: 'code-smells', shard: null })
7
+ })
8
+
9
+ test('parseFindingsFilename reads a sharded focus file', () => {
10
+ expect(parseFindingsFilename('security.shard-2.json')).toEqual({ focus: 'security', shard: 2 })
11
+ expect(parseFindingsFilename('architecture.shard-11.json')).toEqual({
12
+ focus: 'architecture',
13
+ shard: 11,
14
+ })
15
+ })
16
+
17
+ test('parseFindingsFilename rejects names it cannot read', () => {
18
+ expect(parseFindingsFilename('notes.txt')).toBeNull()
19
+ expect(parseFindingsFilename('security.shard-x.json')).toBeNull()
20
+ expect(parseFindingsFilename('security.shard-2.extra.json')).toBeNull()
21
+ expect(parseFindingsFilename('.json')).toBeNull()
22
+ })
23
+
24
+ test('namespaceId leaves unsharded ids alone', () => {
25
+ expect(namespaceId('security-1', 'security', null)).toBe('security-1')
26
+ })
27
+
28
+ test('namespaceId inserts the shard tag after the focus prefix', () => {
29
+ expect(namespaceId('security-1', 'security', 2)).toBe('security-s2-1')
30
+ expect(namespaceId('code-smells-7', 'code-smells', 3)).toBe('code-smells-s3-7')
31
+ })
32
+
33
+ test('namespaceId prefixes ids that do not carry the focus', () => {
34
+ expect(namespaceId('finding-1', 'security', 2)).toBe('s2-finding-1')
35
+ })