@iceinvein/agent-skills 0.1.39 → 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 (148) 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/README.md +2 -1
  6. package/skills/magpie/SKILL.md +138 -45
  7. package/skills/magpie/bin/magpie.ts +43 -0
  8. package/skills/magpie/fixtures/example-pr/brief.json +18 -0
  9. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  10. package/skills/magpie/fixtures/fake-gh.sh +14 -0
  11. package/skills/magpie/package.json +1 -1
  12. package/skills/magpie/references/peer-review.md +7 -2
  13. package/skills/magpie/references/scout.md +90 -0
  14. package/skills/magpie/references/specialists.md +117 -6
  15. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  16. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  17. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  18. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  19. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  20. package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  22. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  23. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  24. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
  25. package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
  26. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
  27. package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
  28. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  29. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  30. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  31. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
  32. package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
  33. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  34. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  35. package/skills/magpie/scripts/findings-files.ts +32 -0
  36. package/skills/magpie/scripts/gh.ts +68 -14
  37. package/skills/magpie/scripts/git-diff.ts +111 -0
  38. package/skills/magpie/scripts/path-filter.ts +9 -5
  39. package/skills/magpie/scripts/preview-cmd.ts +11 -1
  40. package/skills/magpie/scripts/refresh.ts +32 -3
  41. package/skills/magpie/scripts/render-cmd.ts +35 -11
  42. package/skills/magpie/scripts/render-findings.ts +78 -2
  43. package/skills/magpie/scripts/render-progress.ts +6 -1
  44. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  45. package/skills/magpie/scripts/shard.ts +171 -0
  46. package/skills/magpie/scripts/status-cmd.ts +7 -4
  47. package/skills/magpie/scripts/types.ts +50 -0
  48. package/skills/magpie/skill.json +2 -2
  49. package/skills/magpie/templates/styles.css +66 -0
  50. package/skills/migrate/README.md +194 -0
  51. package/skills/migrate/SKILL.md +197 -0
  52. package/skills/migrate/bin/migrate +15 -0
  53. package/skills/migrate/bin/migrate.ts +309 -0
  54. package/skills/migrate/biome.json +35 -0
  55. package/skills/migrate/bun.lock +24 -0
  56. package/skills/migrate/docs/architecture.md +294 -0
  57. package/skills/migrate/docs/reference.md +590 -0
  58. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  59. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  60. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  61. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  62. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  63. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  64. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  67. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  68. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  69. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  70. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  71. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  72. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  73. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  74. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  75. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  76. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  77. package/skills/migrate/install.sh +68 -0
  78. package/skills/migrate/package.json +17 -0
  79. package/skills/migrate/references/phases/enumerate.md +291 -0
  80. package/skills/migrate/references/phases/extract.md +652 -0
  81. package/skills/migrate/references/phases/parity.md +275 -0
  82. package/skills/migrate/references/phases/probe.md +135 -0
  83. package/skills/migrate/references/phases/queue.md +242 -0
  84. package/skills/migrate/references/phases/seam.md +416 -0
  85. package/skills/migrate/references/recipes/README.md +116 -0
  86. package/skills/migrate/references/recipes/aspnet.md +287 -0
  87. package/skills/migrate/references/run-ops.md +280 -0
  88. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  89. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  90. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  91. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  92. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  93. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  94. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  95. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  96. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  97. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  98. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  99. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  100. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  101. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  102. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  103. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  104. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  105. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  106. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  107. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  108. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  109. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  110. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  111. package/skills/migrate/scripts/census-cmd.ts +109 -0
  112. package/skills/migrate/scripts/census.ts +342 -0
  113. package/skills/migrate/scripts/check-cmd.ts +24 -0
  114. package/skills/migrate/scripts/check.ts +376 -0
  115. package/skills/migrate/scripts/citations.ts +92 -0
  116. package/skills/migrate/scripts/config.ts +237 -0
  117. package/skills/migrate/scripts/ids.ts +31 -0
  118. package/skills/migrate/scripts/import-cmd.ts +141 -0
  119. package/skills/migrate/scripts/init-cmd.ts +118 -0
  120. package/skills/migrate/scripts/leaks.ts +184 -0
  121. package/skills/migrate/scripts/lock.ts +188 -0
  122. package/skills/migrate/scripts/paths.ts +103 -0
  123. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  124. package/skills/migrate/scripts/phases.ts +113 -0
  125. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  126. package/skills/migrate/scripts/queue.ts +258 -0
  127. package/skills/migrate/scripts/report-cmd.ts +47 -0
  128. package/skills/migrate/scripts/report.ts +131 -0
  129. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  130. package/skills/migrate/scripts/status-cmd.ts +52 -0
  131. package/skills/migrate/scripts/store.ts +159 -0
  132. package/skills/migrate/scripts/types.ts +137 -0
  133. package/skills/migrate/scripts/validate.ts +221 -0
  134. package/skills/migrate/skill.json +33 -0
  135. package/skills/migrate/templates/config.toml +27 -0
  136. package/skills/migrate/templates/queue-item.md +17 -0
  137. package/skills/migrate/tsconfig.json +18 -0
  138. package/skills/migrate/uninstall.sh +31 -0
  139. package/skills/sluice/SKILL.md +82 -0
  140. package/skills/sluice/references/deep-channel.md +94 -0
  141. package/skills/sluice/references/finish.md +35 -0
  142. package/skills/sluice/references/intent.md +29 -0
  143. package/skills/sluice/references/review.md +42 -0
  144. package/skills/sluice/references/root-cause.md +38 -0
  145. package/skills/sluice/references/show-or-say.md +36 -0
  146. package/skills/sluice/references/test-first.md +35 -0
  147. package/skills/sluice/references/verify.md +26 -0
  148. package/skills/sluice/skill.json +32 -0
@@ -0,0 +1,111 @@
1
+ export type LocalDiffInput = {
2
+ gitBin: string
3
+ repoPath: string
4
+ prNumber: number
5
+ baseRefName: string
6
+ headRefOid: string
7
+ }
8
+
9
+ export type LocalDiffResult =
10
+ | { ok: true; diff: string; mergeBase: string }
11
+ | { ok: false; error: string }
12
+
13
+ async function git(
14
+ gitBin: string,
15
+ cwd: string,
16
+ args: string[],
17
+ ): Promise<{ exit: number; stdout: string; stderr: string }> {
18
+ const proc = Bun.spawn([gitBin, ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
19
+ const stdout = await new Response(proc.stdout).text()
20
+ const stderr = await new Response(proc.stderr).text()
21
+ const exit = await proc.exited
22
+ return { exit, stdout, stderr }
23
+ }
24
+
25
+ async function resolveCommit(gitBin: string, cwd: string, ref: string): Promise<string | null> {
26
+ const r = await git(gitBin, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
27
+ const sha = r.stdout.trim()
28
+ return r.exit === 0 && sha.length > 0 ? sha : null
29
+ }
30
+
31
+ /**
32
+ * Render a fetch's outcome for an error message, so a genuine failure (wrong
33
+ * remote, auth failure, rate limiting) can be told apart from a benign one
34
+ * (no origin, offline, non-GitHub remote) when resolution ends up failing.
35
+ * Empty when the fetch succeeded, since a successful fetch explains nothing.
36
+ */
37
+ function fetchDiagnostic(result: { exit: number; stderr: string }): string {
38
+ if (result.exit === 0) return ''
39
+ const stderr = result.stderr.trim()
40
+ return stderr ? `; git fetch: ${stderr}` : `; git fetch exited ${result.exit}`
41
+ }
42
+
43
+ /**
44
+ * Reproduce `gh pr diff` from the local clone. GitHub refuses the `.diff` media
45
+ * type above roughly 300 files, and a review that cannot get a diff is worth
46
+ * less than one built from local objects.
47
+ *
48
+ * Diffs from the merge base rather than the base tip so the output matches
49
+ * `gh pr diff`'s three-dot semantics: the PR's own changes, not every commit
50
+ * the base branch gained since the branch point.
51
+ */
52
+ export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResult> {
53
+ const { gitBin, repoPath, prNumber, baseRefName, headRefOid } = input
54
+ const prRef = `refs/magpie/pr-${prNumber}`
55
+
56
+ // Best effort. `pull/<n>/head` is a GitHub convention and resolves fork PRs
57
+ // that `headRefName` cannot, but a repo with no `origin`, an offline run, or
58
+ // a non-GitHub remote must still work when the commits are already local.
59
+ // Each fetch's own result is kept (not discarded) so, if resolution below
60
+ // still fails, the error can say why the fetch didn't help.
61
+ const headFetch = await git(gitBin, repoPath, [
62
+ 'fetch',
63
+ 'origin',
64
+ `pull/${prNumber}/head:${prRef}`,
65
+ ])
66
+ const baseFetch = await git(gitBin, repoPath, ['fetch', 'origin', baseRefName])
67
+
68
+ let head: string | null = null
69
+ for (const ref of [prRef, headRefOid]) {
70
+ head = await resolveCommit(gitBin, repoPath, ref)
71
+ if (head) break
72
+ }
73
+ if (!head) {
74
+ return {
75
+ ok: false,
76
+ error: `cannot resolve PR head ${headRefOid} locally (tried ${prRef} and the SHA); fetch the PR branch and retry${fetchDiagnostic(headFetch)}`,
77
+ }
78
+ }
79
+ // Reviewing a stale local ref and reporting it as a review of the PR is worse
80
+ // than failing, so a mismatch is fatal rather than a warning.
81
+ if (head !== headRefOid) {
82
+ return {
83
+ ok: false,
84
+ error: `local PR head ${head} does not match the PR head ${headRefOid}; the local ref is stale`,
85
+ }
86
+ }
87
+
88
+ let base: string | null = null
89
+ for (const ref of [`origin/${baseRefName}`, baseRefName]) {
90
+ base = await resolveCommit(gitBin, repoPath, ref)
91
+ if (base) break
92
+ }
93
+ if (!base) {
94
+ return {
95
+ ok: false,
96
+ error: `cannot resolve base branch ${baseRefName} locally (tried origin/${baseRefName} and ${baseRefName})${fetchDiagnostic(baseFetch)}`,
97
+ }
98
+ }
99
+
100
+ const mb = await git(gitBin, repoPath, ['merge-base', base, head])
101
+ if (mb.exit !== 0) {
102
+ return { ok: false, error: `git merge-base exit ${mb.exit}: ${mb.stderr.trim()}` }
103
+ }
104
+ const mergeBase = mb.stdout.trim()
105
+
106
+ const d = await git(gitBin, repoPath, ['diff', '--no-color', '--find-renames', mergeBase, head])
107
+ if (d.exit !== 0) {
108
+ return { ok: false, error: `git diff exit ${d.exit}: ${d.stderr.trim()}` }
109
+ }
110
+ return { ok: true, diff: d.stdout, mergeBase }
111
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
+ import { chunkPath, splitRawChunks } from './diff-chunks.ts'
4
5
 
5
6
  export type PathFilterConfig = {
6
7
  exclude: string[]
@@ -43,6 +44,12 @@ export const DEFAULT_EXCLUDES: readonly string[] = [
43
44
  '**/*.min.js',
44
45
  '**/*.min.css',
45
46
  '**/*.map',
47
+ // Generated .NET / C#
48
+ '**/*.Designer.cs',
49
+ '**/*ModelSnapshot.cs',
50
+ '**/*.g.cs',
51
+ '**/*.g.i.cs',
52
+ '**/*.designer.vb',
46
53
  // Snapshot fixtures
47
54
  '**/*.snap',
48
55
  '**/__snapshots__/**',
@@ -113,11 +120,9 @@ export async function loadPathFilterConfig(cwd: string): Promise<PathFilterConfi
113
120
  export type ExcludedFile = { path: string; pattern: string }
114
121
  export type FilterDiffResult = { filtered: string; excluded: ExcludedFile[] }
115
122
 
116
- const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
117
-
118
123
  export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDiffResult {
119
124
  if (!rawDiff) return { filtered: '', excluded: [] }
120
- const chunks = rawDiff.split(/^(?=diff --git )/m)
125
+ const chunks = splitRawChunks(rawDiff)
121
126
  const kept: string[] = []
122
127
  const excluded: ExcludedFile[] = []
123
128
  for (const chunk of chunks) {
@@ -125,8 +130,7 @@ export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDif
125
130
  if (chunk.trim()) kept.push(chunk)
126
131
  continue
127
132
  }
128
- const m = chunk.match(FILE_HEADER)
129
- const path = m?.[2] ?? m?.[1]
133
+ const path = chunkPath(chunk)
130
134
  if (!path) {
131
135
  kept.push(chunk)
132
136
  continue
@@ -4,7 +4,7 @@ import type { PostStatusMap } from './render-findings.ts'
4
4
  import { renderFindingsToDisk } from './render-findings.ts'
5
5
  import type { RenderProgressInput, StageId, StageStatus } from './render-progress.ts'
6
6
  import { renderProgressToDisk } from './render-progress.ts'
7
- import { parseFinding } from './types.ts'
7
+ import { type PrBrief, parseBrief, parseFinding } from './types.ts'
8
8
 
9
9
  export type PreviewPage = 'findings' | 'progress' | 'both'
10
10
 
@@ -181,6 +181,7 @@ async function readFixture(fixtureDir: string): Promise<{
181
181
  postStatus: PostStatusMap
182
182
  files: Array<{ path: string; additions: number; deletions: number }>
183
183
  diff: string
184
+ brief: PrBrief | undefined
184
185
  }> {
185
186
  const prRaw = JSON.parse(await readFile(join(fixtureDir, 'pr.json'), 'utf8')) as Record<
186
187
  string,
@@ -214,6 +215,13 @@ async function readFixture(fixtureDir: string): Promise<{
214
215
  } catch {
215
216
  // optional file
216
217
  }
218
+ let brief: PrBrief | undefined
219
+ try {
220
+ const briefRaw = JSON.parse(await readFile(join(fixtureDir, 'brief.json'), 'utf8'))
221
+ brief = parseBrief(briefRaw) ?? undefined
222
+ } catch {
223
+ // optional file
224
+ }
217
225
  return {
218
226
  pr: {
219
227
  number: Number(prRaw.number ?? 0),
@@ -224,6 +232,7 @@ async function readFixture(fixtureDir: string): Promise<{
224
232
  postStatus: postStatusRaw as PostStatusMap,
225
233
  files: filesArray,
226
234
  diff,
235
+ brief,
227
236
  }
228
237
  }
229
238
 
@@ -270,6 +279,7 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewResult> {
270
279
  pr: fixture.pr,
271
280
  files: fixture.files,
272
281
  diff: fixture.diff,
282
+ brief: fixture.brief,
273
283
  },
274
284
  findingsPath,
275
285
  )
@@ -1,7 +1,7 @@
1
1
  import { readdir, readFile, unlink } from 'node:fs/promises'
2
2
  import { basename, join } from 'node:path'
3
- import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
4
- import { type PrFileEntry, parseFinding } from './types.ts'
3
+ import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
4
+ import { type PrFileEntry, parseBrief, parseFinding } from './types.ts'
5
5
 
6
6
  export type RefreshResult = {
7
7
  refreshed: boolean
@@ -61,6 +61,7 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
61
61
 
62
62
  let pr: { number: number; branch: string; headSha: string } | undefined
63
63
  let files: PrFileEntry[] = []
64
+ let issues: ReturnType<typeof parseClosingIssues> = []
64
65
  try {
65
66
  const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
66
67
  const prNumber = Number(prJson.number ?? 0)
@@ -80,14 +81,42 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
80
81
  deletions: Number(entry.deletions ?? 0),
81
82
  }
82
83
  })
84
+ issues = parseClosingIssues(prJson)
83
85
  } catch {
84
86
  // optional file; archived runs may not include pr.json
85
87
  }
86
88
 
89
+ // Scout-produced summary. Same lenient-degrade contract as render-cmd.ts: a
90
+ // missing or malformed brief.json simply omits the header rather than
91
+ // failing the refresh (older archives predate the scout stage entirely).
92
+ let brief: ReturnType<typeof parseBrief> | undefined
93
+ try {
94
+ brief = parseBrief(await Bun.file(join(runDir, 'brief.json')).json())
95
+ } catch {
96
+ // optional file
97
+ }
98
+
87
99
  const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
88
100
 
101
+ let diffSource: { source: 'gh' | 'git'; mergeBase: string | null } | undefined
102
+ try {
103
+ diffSource = (await Bun.file(join(runDir, 'diff-source.json')).json()) as typeof diffSource
104
+ } catch {
105
+ diffSource = undefined
106
+ }
107
+
89
108
  await renderFindingsToDisk(
90
- { findings, postStatus, runId: basename(runDir), pr, files, diff },
109
+ {
110
+ findings,
111
+ postStatus,
112
+ runId: basename(runDir),
113
+ pr,
114
+ files,
115
+ diff,
116
+ brief: brief ?? undefined,
117
+ issues,
118
+ diffSource,
119
+ },
91
120
  join(screenDir, 'findings.html'),
92
121
  )
93
122
 
@@ -1,8 +1,8 @@
1
1
  import { readdir, readFile } from 'node:fs/promises'
2
2
  import { basename, join } from 'node:path'
3
- import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
3
+ import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
4
4
  import { renderProgressToDisk } from './render-progress.ts'
5
- import { parseFinding } from './types.ts'
5
+ import { parseBrief, parseFinding } from './types.ts'
6
6
 
7
7
  async function nextVersionedPath(screenDir: string, base: string): Promise<string> {
8
8
  const entries: string[] = await readdir(screenDir).catch(() => [] as string[])
@@ -48,12 +48,15 @@ function summarizeStages(log: Array<Record<string, unknown>>): {
48
48
  report: 'pending',
49
49
  post: 'pending',
50
50
  }
51
- const specialistCounts: Record<string, number> = {
52
- security: 0,
53
- bugs: 0,
54
- performance: 0,
55
- 'code-smells': 0,
56
- architecture: 0,
51
+ // focus -> shard key -> count. Keyed by shard so a re-dispatched (focus, shard)
52
+ // pair replaces its own count rather than doubling it, while distinct shards
53
+ // of the same focus add up.
54
+ const perShard: Record<string, Record<string, number>> = {
55
+ security: {},
56
+ bugs: {},
57
+ performance: {},
58
+ 'code-smells': {},
59
+ architecture: {},
57
60
  }
58
61
  for (const entry of log) {
59
62
  const stage = entry.stage as string
@@ -63,11 +66,16 @@ function summarizeStages(log: Array<Record<string, unknown>>): {
63
66
  if (stage in stages && status === 'error') stages[stage] = 'error'
64
67
  if (stage in stages && status === 'skipped') stages[stage] = 'skipped'
65
68
  if (stage === 'specialist' && typeof entry.focus === 'string') {
66
- if (entry.focus in specialistCounts && typeof entry.findings === 'number') {
67
- specialistCounts[entry.focus] = entry.findings
69
+ const bucket = perShard[entry.focus]
70
+ if (bucket && typeof entry.findings === 'number') {
71
+ bucket[String(entry.shard ?? 'all')] = entry.findings
68
72
  }
69
73
  }
70
74
  }
75
+ const specialistCounts: Record<string, number> = {}
76
+ for (const [focus, bucket] of Object.entries(perShard)) {
77
+ specialistCounts[focus] = Object.values(bucket).reduce((a, b) => a + b, 0)
78
+ }
71
79
  return { stages, specialistCounts }
72
80
  }
73
81
 
@@ -81,6 +89,11 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
81
89
  })
82
90
  const log = await readLog(runDir)
83
91
  const summary = summarizeStages(log)
92
+ const manifest = await readJson<{ shards?: unknown[] }>(
93
+ join(runDir, 'shards', 'manifest.json'),
94
+ {},
95
+ )
96
+ const shardCount = Array.isArray(manifest.shards) ? manifest.shards.length : 1
84
97
  const outPath = await nextVersionedPath(screenDir, 'progress')
85
98
  await renderProgressToDisk(
86
99
  {
@@ -89,6 +102,7 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
89
102
  branch: String(prJson.headRefName ?? '?'),
90
103
  stages: summary.stages as never,
91
104
  specialistCounts: summary.specialistCounts,
105
+ shardCount,
92
106
  },
93
107
  outPath,
94
108
  )
@@ -119,9 +133,19 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
119
133
  }
120
134
  })
121
135
  const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
136
+ // `readJson` swallows both a missing file and malformed JSON, and `parseBrief`
137
+ // returns null for a brief that parsed but is unusable. Either way the header
138
+ // is simply omitted.
139
+ const brief = parseBrief(await readJson<unknown>(join(runDir, 'brief.json'), null)) ?? undefined
140
+ const issues = parseClosingIssues(prJson)
141
+ const diffSource =
142
+ (await readJson<{ source: 'gh' | 'git'; mergeBase: string | null } | null>(
143
+ join(runDir, 'diff-source.json'),
144
+ null,
145
+ )) ?? undefined
122
146
  const outPath = await nextVersionedPath(screenDir, 'findings')
123
147
  await renderFindingsToDisk(
124
- { findings, postStatus, runId: basename(runDir), pr, files, diff },
148
+ { findings, postStatus, runId: basename(runDir), pr, files, diff, brief, issues, diffSource },
125
149
  outPath,
126
150
  )
127
151
  return 0
@@ -7,7 +7,7 @@ import { renderActionBar } from './render-action-bar.ts'
7
7
  import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
8
8
  import { renderFileTree } from './render-file-tree.ts'
9
9
  import { renderIssuesList } from './render-issues-list.ts'
10
- import type { PostStatusMap, PrFileEntry, ReviewFinding } from './types.ts'
10
+ import type { PostStatusMap, PrBrief, PrFileEntry, ReviewFinding } from './types.ts'
11
11
 
12
12
  export type { PostStatusEntry, PostStatusMap } from './types.ts'
13
13
 
@@ -17,6 +17,31 @@ export type FindingsPrMeta = {
17
17
  headSha: string
18
18
  }
19
19
 
20
+ export type BriefIssue = {
21
+ number: number
22
+ title: string
23
+ url: string
24
+ }
25
+
26
+ /**
27
+ * Maps pr.json's `closingIssuesReferences` into the brief header's issue links.
28
+ * Shared by render-cmd's findings branch and refresh (which re-renders
29
+ * findings.html for `magpie open` / `magpie serve <archived-id>`), so both
30
+ * call sites stay in lockstep instead of drifting apart.
31
+ */
32
+ export function parseClosingIssues(prJson: Record<string, unknown>): BriefIssue[] {
33
+ const issuesRaw = Array.isArray(prJson.closingIssuesReferences)
34
+ ? (prJson.closingIssuesReferences as unknown[])
35
+ : []
36
+ return issuesRaw.flatMap((entry) => {
37
+ if (!entry || typeof entry !== 'object') return []
38
+ const e = entry as Record<string, unknown>
39
+ const number = Number(e.number ?? 0)
40
+ if (!Number.isFinite(number) || number <= 0) return []
41
+ return [{ number, title: String(e.title ?? ''), url: String(e.url ?? '') }]
42
+ })
43
+ }
44
+
20
45
  export type RenderFindingsInput = {
21
46
  findings: ReviewFinding[]
22
47
  postStatus: PostStatusMap
@@ -28,8 +53,14 @@ export type RenderFindingsInput = {
28
53
  files?: PrFileEntry[]
29
54
  /** Raw unified diff text for the PR. */
30
55
  diff?: string
56
+ /** Scout-produced PR summary. When absent, the report renders no brief header. */
57
+ brief?: PrBrief
58
+ /** Issues this PR closes, from pr.json's closingIssuesReferences. */
59
+ issues?: BriefIssue[]
31
60
  /** Shiki highlighter, prepared by the caller. */
32
61
  highlighter: Highlighter
62
+ /** Where diff.patch came from. Absent means gh, which needs no note. */
63
+ diffSource?: { source: 'gh' | 'git'; mergeBase: string | null }
33
64
  }
34
65
 
35
66
  function esc(s: string): string {
@@ -57,13 +88,21 @@ function brandBlock(): string {
57
88
 
58
89
  function prHeader(input: RenderFindingsInput): string {
59
90
  const pr = input.pr
91
+ const src = input.diffSource
92
+ const diffNote =
93
+ src?.source === 'git'
94
+ ? `<span class="diff-source" title="gh pr diff could not serve this PR, so the diff was rebuilt from the local clone">diff from local clone${
95
+ src.mergeBase ? ` at <code>${esc(src.mergeBase.slice(0, 12))}</code>` : ''
96
+ }</span>`
97
+ : ''
60
98
  const prMeta = pr
61
99
  ? `<div class="pr-meta">
62
100
  <span class="pr-number">PR #${pr.number}</span>
63
101
  <span class="pr-branch">${esc(pr.branch)}</span>
64
102
  <code>${esc(pr.headSha.slice(0, 12))}</code>
103
+ ${diffNote}
65
104
  </div>`
66
- : `<div class="pr-meta"></div>`
105
+ : `<div class="pr-meta">${diffNote}</div>`
67
106
  const total = input.findings.length
68
107
  return `<header class="pr-header">
69
108
  ${brandBlock()}
@@ -76,6 +115,40 @@ function prHeader(input: RenderFindingsInput): string {
76
115
  </header>`
77
116
  }
78
117
 
118
+ function briefBlock(brief: PrBrief | undefined, issues: BriefIssue[]): string {
119
+ // Absent brief renders nothing: archived runs from before the scout stage
120
+ // existed must still open cleanly.
121
+ if (!brief) return ''
122
+ const changes =
123
+ brief.changes.length > 0
124
+ ? `<ul class="brief-changes">${brief.changes.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
125
+ : ''
126
+ const subsystems =
127
+ brief.subsystems.length > 0
128
+ ? `<div class="brief-subsystems">${brief.subsystems
129
+ .map((s) => `<span class="brief-chip" title="${esc(s.role)}">${esc(s.name)}</span>`)
130
+ .join('')}</div>`
131
+ : ''
132
+ const issueLinks =
133
+ issues.length > 0
134
+ ? `<div class="brief-issues">${issues
135
+ .map(
136
+ (i) =>
137
+ `<a class="brief-issue" href="${esc(i.url)}" title="${esc(i.title)}">#${i.number}</a>`,
138
+ )
139
+ .join('')}</div>`
140
+ : ''
141
+ return `<details class="pr-brief" open>
142
+ <summary class="brief-summary">What this PR is for</summary>
143
+ <div class="brief-body">
144
+ <p class="brief-purpose">${esc(brief.purpose)}</p>
145
+ ${changes}
146
+ ${subsystems}
147
+ ${issueLinks}
148
+ </div>
149
+ </details>`
150
+ }
151
+
79
152
  function filePane(opts: {
80
153
  file: PrFileEntry
81
154
  fileDiff: string
@@ -163,6 +236,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
163
236
  const diff = input.diff ?? ''
164
237
  const splitDiffs = splitDiffByFile(diff)
165
238
  const selectedIds = new Set<string>()
239
+ const briefHtml = briefBlock(input.brief, input.issues ?? [])
166
240
 
167
241
  if (input.findings.length === 0) {
168
242
  return `<!DOCTYPE html>
@@ -175,6 +249,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
175
249
  </head>
176
250
  <body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
177
251
  ${prHeader(input)}
252
+ ${briefHtml}
178
253
  <main class="page-main">
179
254
  <div class="empty-state"><h1>No findings</h1><p>All specialists returned cleanly.</p></div>
180
255
  </main>
@@ -223,6 +298,7 @@ ${prHeader(input)}
223
298
  </head>
224
299
  <body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
225
300
  ${prHeader(input)}
301
+ ${briefHtml}
226
302
  <main class="page-main">
227
303
  <div class="view files-view">
228
304
  ${tree}
@@ -41,6 +41,8 @@ export type RenderProgressInput = {
41
41
  branch: string
42
42
  stages: Record<StageId, StageStatus>
43
43
  specialistCounts: Record<string, number>
44
+ /** Shard count from shards/manifest.json. Absent or 1 means unsharded. */
45
+ shardCount?: number
44
46
  }
45
47
 
46
48
  function esc(s: string): string {
@@ -82,7 +84,10 @@ function progressPane(input: RenderProgressInput): string {
82
84
  const cur = current(input.stages)
83
85
  let lead: string
84
86
  if (cur.status === 'running') {
85
- lead = STAGE_NOW_DOING[cur.id]
87
+ lead =
88
+ cur.id === 'specialists' && (input.shardCount ?? 1) > 1
89
+ ? `Five reviewers across ${input.shardCount} shards`
90
+ : STAGE_NOW_DOING[cur.id]
86
91
  } else if (cur.status === 'error') {
87
92
  lead = `${STAGE_NOW_DOING[cur.id]} stalled`
88
93
  } else if (cur.status === 'pending') {
@@ -4,6 +4,7 @@ import { fetchPr } from './gh.ts'
4
4
  import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
5
5
  import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
6
6
  import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
7
+ import { shardDiff } from './shard.ts'
7
8
  import { detectMissingTests } from './tests-check.ts'
8
9
  import { createWorktree } from './worktree.ts'
9
10
 
@@ -47,6 +48,7 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
47
48
 
48
49
  const fetched = await fetchPr({
49
50
  ghBin: deps.gh,
51
+ gitBin: deps.git,
50
52
  prNumber: input.prNumber,
51
53
  runDir: input.runDir,
52
54
  cwd: input.repoPath,
@@ -57,10 +59,31 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
57
59
  await cleanup(input.runDir)
58
60
  return 4
59
61
  }
60
- await logLine(input.runDir, { stage: 'fetch-pr', status: 'done' })
62
+ await logLine(input.runDir, {
63
+ stage: 'fetch-pr',
64
+ status: 'done',
65
+ source: fetched.source,
66
+ ...(fetched.mergeBase ? { mergeBase: fetched.mergeBase } : {}),
67
+ })
68
+ // Sidecar so the report can say where the diff came from without re-parsing
69
+ // log.jsonl, the same way incremental.json carries the incremental context.
70
+ await writeFile(
71
+ join(input.runDir, 'diff-source.json'),
72
+ `${JSON.stringify({ source: fetched.source, mergeBase: fetched.mergeBase ?? null }, null, 2)}\n`,
73
+ )
61
74
 
62
75
  await applyPathFilter(input.runDir, input.repoPath)
63
76
 
77
+ try {
78
+ await runShard(input.runDir)
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err)
81
+ await logLine(input.runDir, { stage: 'shard', status: 'error', error: message })
82
+ process.stderr.write(`magpie: ${message}\n`)
83
+ await cleanup(input.runDir)
84
+ return 6
85
+ }
86
+
64
87
  await runTestsCheck(input.runDir)
65
88
 
66
89
  await detectIncrementalReview(input.runDir, input.prNumber)
@@ -90,7 +113,9 @@ async function cleanup(runDir: string): Promise<void> {
90
113
  'pr.json',
91
114
  'diff.patch',
92
115
  'diff.full.patch',
116
+ 'diff-source.json',
93
117
  'excluded-files.json',
118
+ 'shards',
94
119
  'incremental.json',
95
120
  'findings',
96
121
  'screen',
@@ -100,6 +125,18 @@ async function cleanup(runDir: string): Promise<void> {
100
125
  }
101
126
  }
102
127
 
128
+ async function runShard(runDir: string): Promise<void> {
129
+ const manifest = await shardDiff(runDir)
130
+ await logLine(runDir, {
131
+ stage: 'shard',
132
+ status: 'done',
133
+ shards: manifest.shards.length,
134
+ budget: manifest.budget,
135
+ files: manifest.totalFiles,
136
+ lines: manifest.totalLines,
137
+ })
138
+ }
139
+
103
140
  async function runTestsCheck(runDir: string): Promise<void> {
104
141
  const diff = await Bun.file(join(runDir, 'diff.patch')).text()
105
142
  const findings = detectMissingTests(diff)