@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
@@ -3,10 +3,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { fetchPr } from '../gh.ts'
6
+ import { createGitFixtureRepo } from './helpers/git-fixture.ts'
6
7
 
7
8
  const FAKE_GH = new URL('../../fixtures/fake-gh.sh', import.meta.url).pathname
9
+ const FAKE_GH_NODIFF = new URL('../../fixtures/fake-gh-nodiff.sh', import.meta.url).pathname
8
10
 
9
11
  let runDir: string
12
+ let repo = ''
10
13
 
11
14
  beforeEach(async () => {
12
15
  runDir = await mkdtemp(join(tmpdir(), 'magpie-gh-'))
@@ -14,6 +17,10 @@ beforeEach(async () => {
14
17
 
15
18
  afterEach(async () => {
16
19
  await rm(runDir, { recursive: true, force: true })
20
+ if (repo) {
21
+ await rm(repo, { recursive: true, force: true })
22
+ repo = ''
23
+ }
17
24
  })
18
25
 
19
26
  test('fetchPr writes pr.json and diff.patch', async () => {
@@ -66,3 +73,65 @@ test('fetchPr carries commits and linked issues into pr.json', async () => {
66
73
  expect(Array.isArray(pr.closingIssuesReferences)).toBe(true)
67
74
  expect(pr.closingIssuesReferences[0]).toMatchObject({ number: 42 })
68
75
  })
76
+
77
+ test('fetchPr falls back to local git when gh pr diff fails', async () => {
78
+ const { repoPath, head } = await createGitFixtureRepo()
79
+ repo = repoPath
80
+ const result = await fetchPr({
81
+ ghBin: FAKE_GH_NODIFF,
82
+ gitBin: 'git',
83
+ prNumber: 7,
84
+ runDir,
85
+ cwd: repo,
86
+ env: { MAGPIE_FAKE_HEAD_OID: head },
87
+ })
88
+ expect(result.ok).toBe(true)
89
+ if (!result.ok) return
90
+ expect(result.source).toBe('git')
91
+ expect(result.mergeBase).toMatch(/^[0-9a-f]{40}$/)
92
+ const diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
93
+ expect(diff).toContain('export const x = 2')
94
+ })
95
+
96
+ test('fetchPr falls back when gh returns an empty diff for a non-empty PR', async () => {
97
+ const { repoPath, head } = await createGitFixtureRepo()
98
+ repo = repoPath
99
+ const result = await fetchPr({
100
+ ghBin: FAKE_GH_NODIFF,
101
+ gitBin: 'git',
102
+ prNumber: 7,
103
+ runDir,
104
+ cwd: repo,
105
+ env: { MAGPIE_FAKE_HEAD_OID: head, MAGPIE_FAKE_DIFF_MODE: 'empty' },
106
+ })
107
+ expect(result.ok).toBe(true)
108
+ if (!result.ok) return
109
+ expect(result.source).toBe('git')
110
+ const diff = await readFile(join(runDir, 'diff.patch'), 'utf8')
111
+ expect(diff).toContain('export const x = 2')
112
+ })
113
+
114
+ test('fetchPr reports source gh when gh succeeds', async () => {
115
+ const result = await fetchPr({ ghBin: FAKE_GH, prNumber: 1234, runDir })
116
+ expect(result.ok).toBe(true)
117
+ if (!result.ok) return
118
+ expect(result.source).toBe('gh')
119
+ expect(result.mergeBase).toBeUndefined()
120
+ })
121
+
122
+ test('fetchPr fails when gh refuses and the head SHA is not local', async () => {
123
+ const { repoPath } = await createGitFixtureRepo()
124
+ repo = repoPath
125
+ const result = await fetchPr({
126
+ ghBin: FAKE_GH_NODIFF,
127
+ gitBin: 'git',
128
+ prNumber: 7,
129
+ runDir,
130
+ cwd: repo,
131
+ // No MAGPIE_FAKE_HEAD_OID, so pr.json carries a SHA this repo has never seen.
132
+ })
133
+ expect(result.ok).toBe(false)
134
+ if (result.ok) return
135
+ expect(result.error).toContain('cannot resolve PR head')
136
+ expect(result.error).toContain('406')
137
+ })
@@ -0,0 +1,83 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { localPrDiff } from '../git-diff.ts'
4
+ import { createGitFixtureRepo, git } from './helpers/git-fixture.ts'
5
+
6
+ let repo = ''
7
+
8
+ afterEach(async () => {
9
+ if (repo) {
10
+ await rm(repo, { recursive: true, force: true })
11
+ repo = ''
12
+ }
13
+ })
14
+
15
+ test('localPrDiff produces the three-dot diff from the merge base', async () => {
16
+ const { repoPath, head } = await createGitFixtureRepo()
17
+ repo = repoPath
18
+ const result = await localPrDiff({
19
+ gitBin: 'git',
20
+ repoPath,
21
+ prNumber: 7,
22
+ baseRefName: 'main',
23
+ headRefOid: head,
24
+ })
25
+ expect(result.ok).toBe(true)
26
+ if (!result.ok) return
27
+ expect(result.diff).toContain('export const x = 2')
28
+ // b.ts landed on main after the branch point. A two-dot diff against the
29
+ // base tip would show it as a deletion; the three-dot diff must not.
30
+ expect(result.diff).not.toContain('b.ts')
31
+ })
32
+
33
+ test('localPrDiff errors when the head SHA is not present locally', async () => {
34
+ const { repoPath } = await createGitFixtureRepo()
35
+ repo = repoPath
36
+ const result = await localPrDiff({
37
+ gitBin: 'git',
38
+ repoPath,
39
+ prNumber: 7,
40
+ baseRefName: 'main',
41
+ headRefOid: '0'.repeat(40),
42
+ })
43
+ expect(result.ok).toBe(false)
44
+ if (result.ok) return
45
+ expect(result.error).toContain('cannot resolve PR head')
46
+ })
47
+
48
+ test('localPrDiff errors when the base branch is not present locally', async () => {
49
+ const { repoPath, head } = await createGitFixtureRepo()
50
+ repo = repoPath
51
+ const result = await localPrDiff({
52
+ gitBin: 'git',
53
+ repoPath,
54
+ prNumber: 7,
55
+ baseRefName: 'no-such-branch',
56
+ headRefOid: head,
57
+ })
58
+ expect(result.ok).toBe(false)
59
+ if (result.ok) return
60
+ expect(result.error).toContain('cannot resolve base branch')
61
+ })
62
+
63
+ test('localPrDiff errors when a stale refs/magpie/pr-<n> ref points at an older commit', async () => {
64
+ const { repoPath, head } = await createGitFixtureRepo()
65
+ repo = repoPath
66
+ // Simulate a previous run's fetch having landed an older commit under the
67
+ // PR ref, with no origin remote configured to refresh it from. head~1 is
68
+ // the pre-branch "base" commit: a valid, resolvable, genuinely older commit.
69
+ const staleSha = await git(repoPath, 'rev-parse', `${head}~1`)
70
+ await git(repoPath, 'update-ref', 'refs/magpie/pr-7', staleSha)
71
+ const result = await localPrDiff({
72
+ gitBin: 'git',
73
+ repoPath,
74
+ prNumber: 7,
75
+ baseRefName: 'main',
76
+ headRefOid: head,
77
+ })
78
+ expect(result.ok).toBe(false)
79
+ if (result.ok) return
80
+ expect(result.error).toContain('stale')
81
+ expect(result.error).toContain(staleSha)
82
+ expect(result.error).toContain(head)
83
+ })
@@ -0,0 +1,47 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /** Spawn `git` in `cwd` and return trimmed stdout, throwing on a non-zero exit. */
6
+ export async function git(cwd: string, ...args: string[]): Promise<string> {
7
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
8
+ const stdout = await new Response(proc.stdout).text()
9
+ const exit = await proc.exited
10
+ if (exit !== 0) {
11
+ const stderr = await new Response(proc.stderr).text()
12
+ throw new Error(`git ${args.join(' ')} exit ${exit}: ${stderr}`)
13
+ }
14
+ return stdout.trim()
15
+ }
16
+
17
+ export type GitFixtureRepo = {
18
+ repoPath: string
19
+ head: string
20
+ baseTip: string
21
+ }
22
+
23
+ /**
24
+ * A fresh repo with `main` and a `feature-x` branch one commit ahead, plus a
25
+ * commit on `main` after the branch point so a two-dot diff would differ
26
+ * from a three-dot one. Shared by gh.test.ts and git-diff.test.ts, which both
27
+ * exercise the local-git PR-diff fallback against a real clone.
28
+ */
29
+ export async function createGitFixtureRepo(): Promise<GitFixtureRepo> {
30
+ const repoPath = await mkdtemp(join(tmpdir(), 'magpie-git-fixture-'))
31
+ await git(repoPath, 'init', '-b', 'main')
32
+ await git(repoPath, 'config', 'user.email', 'test@example.com')
33
+ await git(repoPath, 'config', 'user.name', 'Test')
34
+ await writeFile(join(repoPath, 'a.ts'), 'export const x = 1\n')
35
+ await git(repoPath, 'add', '.')
36
+ await git(repoPath, 'commit', '-m', 'base')
37
+ await git(repoPath, 'checkout', '-b', 'feature-x')
38
+ await writeFile(join(repoPath, 'a.ts'), 'export const x = 2\n')
39
+ await git(repoPath, 'commit', '-am', 'change x')
40
+ const head = await git(repoPath, 'rev-parse', 'HEAD')
41
+ await git(repoPath, 'checkout', 'main')
42
+ await writeFile(join(repoPath, 'b.ts'), 'export const y = 9\n')
43
+ await git(repoPath, 'add', '.')
44
+ await git(repoPath, 'commit', '-m', 'unrelated base commit')
45
+ const baseTip = await git(repoPath, 'rev-parse', 'HEAD')
46
+ return { repoPath, head, baseTip }
47
+ }
@@ -159,3 +159,30 @@ test('filterDiff: include records not-in-include reason', () => {
159
159
  })
160
160
  expect(excluded.find((e) => e.path === 'bun.lock')?.pattern).toBe('not-in-include')
161
161
  })
162
+
163
+ test('DEFAULT_EXCLUDES covers generated .NET sources', () => {
164
+ const generated = [
165
+ 'src/Data/Migrations/20260801_AddUsers.Designer.cs',
166
+ 'src/Data/Migrations/AppDbContextModelSnapshot.cs',
167
+ 'src/Generated/Api.g.cs',
168
+ 'src/Generated/Api.g.i.cs',
169
+ 'src/Forms/MainForm.designer.vb',
170
+ ]
171
+ for (const path of generated) {
172
+ expect(matchesAny(path, DEFAULT_EXCLUDES)).not.toBeNull()
173
+ }
174
+ })
175
+
176
+ test('DEFAULT_EXCLUDES leaves hand-written C# and docs alone', () => {
177
+ // The migration itself is reviewable; only its generated designer is not.
178
+ const reviewable = [
179
+ 'src/Data/Migrations/20260801_AddUsers.cs',
180
+ 'src/Services/UserService.cs',
181
+ 'docs/architecture.md',
182
+ 'docs/work/notes.md',
183
+ 'README.md',
184
+ ]
185
+ for (const path of reviewable) {
186
+ expect(matchesAny(path, DEFAULT_EXCLUDES)).toBeNull()
187
+ }
188
+ })
@@ -192,3 +192,98 @@ test('issue entries with a missing, zero, or negative number are dropped, not de
192
192
  expect(html).not.toContain('https://example.test/issues/zero')
193
193
  expect(html).not.toContain('https://example.test/issues/negative')
194
194
  })
195
+
196
+ test('progress sums specialist counts across shards', async () => {
197
+ await mkdir(join(runDir, 'shards'), { recursive: true })
198
+ await writeFile(
199
+ join(runDir, 'shards', 'manifest.json'),
200
+ JSON.stringify({
201
+ budget: 6000,
202
+ maxFiles: 80,
203
+ totalFiles: 2,
204
+ totalLines: 20,
205
+ shards: [
206
+ { id: 1, path: 'shards/shard-1.patch', files: ['a.ts'], lines: 10 },
207
+ { id: 2, path: 'shards/shard-2.patch', files: ['b.ts'], lines: 10 },
208
+ ],
209
+ }),
210
+ )
211
+ await writeFile(
212
+ join(runDir, 'log.jsonl'),
213
+ [
214
+ JSON.stringify({ stage: 'specialists', status: 'running' }),
215
+ JSON.stringify({
216
+ stage: 'specialist',
217
+ focus: 'security',
218
+ shard: 1,
219
+ status: 'done',
220
+ findings: 2,
221
+ }),
222
+ JSON.stringify({
223
+ stage: 'specialist',
224
+ focus: 'security',
225
+ shard: 2,
226
+ status: 'done',
227
+ findings: 3,
228
+ }),
229
+ JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 1 }),
230
+ ].join('\n'),
231
+ )
232
+ const exit = await runRender(runDir, 'progress')
233
+ expect(exit).toBe(0)
234
+ const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
235
+ expect(html).toContain('Five reviewers across 2 shards')
236
+ // security is 2 + 3, not 3.
237
+ expect(html).toContain('security <span class="count">5</span>')
238
+ })
239
+
240
+ /** Log with the specialists stage in flight, which is the only state where the
241
+ * shard count reaches the rendered wording. */
242
+ async function specialistsRunning(): Promise<void> {
243
+ await writeFile(
244
+ join(runDir, 'log.jsonl'),
245
+ JSON.stringify({ stage: 'specialists', status: 'running' }),
246
+ )
247
+ }
248
+
249
+ test('a malformed shards/manifest.json degrades to the unsharded progress wording', async () => {
250
+ await mkdir(join(runDir, 'shards'), { recursive: true })
251
+ await writeFile(join(runDir, 'shards', 'manifest.json'), '{ "shards": [ truncated')
252
+ await specialistsRunning()
253
+ expect(await runRender(runDir, 'progress')).toBe(0)
254
+ const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
255
+ expect(html).toContain('Five reviewers reading the diff in parallel')
256
+ expect(html).not.toMatch(/across \S+ shards/)
257
+ })
258
+
259
+ test('a manifest with shards: [] renders the unsharded progress wording', async () => {
260
+ // A lockfile-only PR filters down to nothing, and shardDiff writes an
261
+ // otherwise-normal manifest with no shards. Zero must not read as "across 0
262
+ // shards"; stage 4 treats it as the unsharded path.
263
+ await mkdir(join(runDir, 'shards'), { recursive: true })
264
+ await writeFile(
265
+ join(runDir, 'shards', 'manifest.json'),
266
+ JSON.stringify({ budget: 6000, maxFiles: 80, totalFiles: 0, totalLines: 0, shards: [] }),
267
+ )
268
+ await specialistsRunning()
269
+ expect(await runRender(runDir, 'progress')).toBe(0)
270
+ const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
271
+ expect(html).toContain('Five reviewers reading the diff in parallel')
272
+ expect(html).not.toContain('across 0 shards')
273
+ expect(html).not.toMatch(/across \S+ shards/)
274
+ })
275
+
276
+ test('progress re-dispatch of one shard replaces rather than doubles its count', async () => {
277
+ await writeFile(
278
+ join(runDir, 'log.jsonl'),
279
+ [
280
+ JSON.stringify({ stage: 'specialists', status: 'running' }),
281
+ JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 4 }),
282
+ JSON.stringify({ stage: 'specialist', focus: 'bugs', shard: 1, status: 'done', findings: 2 }),
283
+ ].join('\n'),
284
+ )
285
+ const exit = await runRender(runDir, 'progress')
286
+ expect(exit).toBe(0)
287
+ const html = await readFile(join(runDir, 'screen', 'progress.html'), 'utf8')
288
+ expect(html).toContain('bugs <span class="count">2</span>')
289
+ })
@@ -317,3 +317,36 @@ test('subsystem role and issue title escape quotes in their title="" attribute c
317
317
  // purpose escaping (already covered above) must remain intact alongside this.
318
318
  expect(html).toContain('Adds bounded retries to the upload path')
319
319
  })
320
+
321
+ test('findings header notes a locally-built diff', () => {
322
+ const html = renderFindingsHtml({
323
+ findings: [],
324
+ postStatus: {},
325
+ pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
326
+ diffSource: { source: 'git', mergeBase: 'a'.repeat(40) },
327
+ highlighter: hl,
328
+ })
329
+ expect(html).toContain('local clone')
330
+ expect(html).toContain('aaaaaaaaaaaa')
331
+ })
332
+
333
+ test('findings header stays quiet when the diff came from gh', () => {
334
+ const html = renderFindingsHtml({
335
+ findings: [],
336
+ postStatus: {},
337
+ pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
338
+ diffSource: { source: 'gh', mergeBase: null },
339
+ highlighter: hl,
340
+ })
341
+ expect(html).not.toContain('local clone')
342
+ })
343
+
344
+ test('findings header stays quiet when there is no diff source at all', () => {
345
+ const html = renderFindingsHtml({
346
+ findings: [],
347
+ postStatus: {},
348
+ pr: { number: 7, branch: 'feature-x', headSha: 'abc123abc123' },
349
+ highlighter: hl,
350
+ })
351
+ expect(html).not.toContain('local clone')
352
+ })
@@ -157,3 +157,45 @@ test('progress-pane block replaces diff-pane on the progress page', () => {
157
157
  expect(html).toContain('data-role="progress-pane"')
158
158
  expect(html).toContain('Indexing repo symbols')
159
159
  })
160
+
161
+ test('progress names the shard count while specialists run', () => {
162
+ const html = renderProgressHtml({
163
+ prNumber: 7,
164
+ headSha: 'abc123abc123',
165
+ branch: 'feature-x',
166
+ stages: {
167
+ setup: 'done',
168
+ context: 'done',
169
+ specialists: 'running',
170
+ dedupe: 'pending',
171
+ critic: 'pending',
172
+ 'peer-review': 'pending',
173
+ report: 'pending',
174
+ post: 'pending',
175
+ },
176
+ specialistCounts: {},
177
+ shardCount: 6,
178
+ })
179
+ expect(html).toContain('Five reviewers across 6 shards')
180
+ })
181
+
182
+ test('progress keeps the single-shard wording when there is one shard', () => {
183
+ const html = renderProgressHtml({
184
+ prNumber: 7,
185
+ headSha: 'abc123abc123',
186
+ branch: 'feature-x',
187
+ stages: {
188
+ setup: 'done',
189
+ context: 'done',
190
+ specialists: 'running',
191
+ dedupe: 'pending',
192
+ critic: 'pending',
193
+ 'peer-review': 'pending',
194
+ report: 'pending',
195
+ post: 'pending',
196
+ },
197
+ specialistCounts: {},
198
+ shardCount: 1,
199
+ })
200
+ expect(html).toContain('Five reviewers reading the diff in parallel')
201
+ })
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test'
2
- import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { runSetup } from '../setup-cmd.ts'
@@ -179,3 +179,85 @@ test('runSetup with failing gh removes any partial state', async () => {
179
179
  expect(contents).not.toContain('worktree')
180
180
  expect(contents).not.toContain('pr.json')
181
181
  })
182
+
183
+ test('runSetup shards the filtered diff and logs the result', async () => {
184
+ const exit = await runSetup({
185
+ runDir,
186
+ prNumber: 1234,
187
+ repoPath: repo,
188
+ deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
189
+ })
190
+ expect(exit).toBe(0)
191
+ const manifest = JSON.parse(await readFile(join(runDir, 'shards', 'manifest.json'), 'utf8'))
192
+ expect(manifest.shards.length).toBeGreaterThanOrEqual(1)
193
+ // The fake PR is one small file, so it stays a single-shard passthrough.
194
+ expect(manifest.shards[0].path).toBe('diff.patch')
195
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
196
+ const shardEntry = log
197
+ .split('\n')
198
+ .filter(Boolean)
199
+ .map((l) => JSON.parse(l))
200
+ .find((e) => e.stage === 'shard')
201
+ expect(shardEntry).toMatchObject({ status: 'done', shards: 1 })
202
+ })
203
+
204
+ test('runSetup cleans up a populated shards/ when worktree creation fails after sharding', async () => {
205
+ // `git worktree add` refuses a target path that already exists and is
206
+ // non-empty, which forces the worktree step to fail *after* fetchPr,
207
+ // applyPathFilter, and runShard have already run successfully, without
208
+ // needing a fake git binary.
209
+ await mkdir(join(runDir, 'worktree'), { recursive: true })
210
+ await writeFile(join(runDir, 'worktree', 'occupied'), 'x')
211
+
212
+ const exit = await runSetup({
213
+ runDir,
214
+ prNumber: 1234,
215
+ repoPath: repo,
216
+ deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
217
+ })
218
+ expect(exit).toBe(5)
219
+
220
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
221
+ const entries = log
222
+ .split('\n')
223
+ .filter(Boolean)
224
+ .map((l) => JSON.parse(l))
225
+ // Sharding really did happen before the worktree failure: this isn't a
226
+ // false pass from the worktree step failing before shard ever ran.
227
+ expect(entries.find((e) => e.stage === 'shard')).toMatchObject({ status: 'done', shards: 1 })
228
+ expect(entries.find((e) => e.stage === 'worktree')).toMatchObject({ status: 'error' })
229
+
230
+ const contents = await readdir(runDir).catch(() => [])
231
+ expect(contents).not.toContain('shards')
232
+ expect(contents).not.toContain('worktree')
233
+ })
234
+
235
+ test('runSetup treats a shardDiff throw as a hard stop: logs, cleans up, exits 6', async () => {
236
+ // shardDiff creates $RUN_DIR/shards via a plain recursive mkdir. Seeding
237
+ // 'shards' as a regular file ahead of time forces that mkdir to throw
238
+ // EEXIST, a genuine non-ENOENT failure out of shardDiff. diff.patch itself
239
+ // can't be used for this: fetchPr and applyPathFilter both write it earlier
240
+ // in the pipeline, so pre-seeding it as something unreadable would fail
241
+ // before the shard step is ever reached.
242
+ await writeFile(join(runDir, 'shards'), 'not a directory')
243
+
244
+ const exit = await runSetup({
245
+ runDir,
246
+ prNumber: 1234,
247
+ repoPath: repo,
248
+ deps: { bun: 'bun', gh: FAKE_GH, codex: 'echo', git: 'git' },
249
+ })
250
+ expect(exit).toBe(6)
251
+
252
+ const log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
253
+ const entries = log
254
+ .split('\n')
255
+ .filter(Boolean)
256
+ .map((l) => JSON.parse(l))
257
+ const shardEntry = entries.find((e) => e.stage === 'shard')
258
+ expect(shardEntry).toMatchObject({ status: 'error' })
259
+ expect(typeof (shardEntry as { error: unknown }).error).toBe('string')
260
+
261
+ const contents = await readdir(runDir).catch(() => [])
262
+ expect(contents.filter((c) => c !== 'log.jsonl')).toHaveLength(0)
263
+ })