@iceinvein/agent-skills 0.1.40 → 0.3.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 (141) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +115 -34
  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 +95 -0
  131. package/skills/sluice/references/deep-channel.md +114 -0
  132. package/skills/sluice/references/finish.md +37 -0
  133. package/skills/sluice/references/intent.md +29 -0
  134. package/skills/sluice/references/meter.md +38 -0
  135. package/skills/sluice/references/review.md +42 -0
  136. package/skills/sluice/references/root-cause.md +38 -0
  137. package/skills/sluice/references/show-or-say.md +36 -0
  138. package/skills/sluice/references/test-first.md +35 -0
  139. package/skills/sluice/references/verify.md +26 -0
  140. package/skills/sluice/scripts/run-stats.sh +236 -0
  141. package/skills/sluice/skill.json +33 -0
@@ -1,16 +1,21 @@
1
1
  import { writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
+ import { localPrDiff } from './git-diff.ts'
3
4
 
4
5
  export type FetchPrInput = {
5
6
  ghBin: string
6
7
  prNumber: number
7
8
  runDir: string
8
- /** Working directory for the gh invocation. Defaults to process.cwd(). */
9
+ /** Working directory for the gh and git invocations. Defaults to process.cwd(). */
9
10
  cwd?: string
11
+ /** git binary for the local-diff fallback. Defaults to `git` on PATH. */
12
+ gitBin?: string
13
+ /** Extra environment for the gh invocation. Test seam for the fake gh fixtures. */
14
+ env?: Record<string, string>
10
15
  }
11
16
 
12
17
  export type FetchPrResult =
13
- | { ok: true; prJsonPath: string; diffPath: string }
18
+ | { ok: true; prJsonPath: string; diffPath: string; source: 'gh' | 'git'; mergeBase?: string }
14
19
  | { ok: false; error: string }
15
20
 
16
21
  export const PR_VIEW_FIELDS = [
@@ -31,9 +36,23 @@ export const PR_VIEW_FIELDS = [
31
36
  'closingIssuesReferences',
32
37
  ].join(',')
33
38
 
39
+ function prFileCount(viewStdout: string): number {
40
+ try {
41
+ const parsed = JSON.parse(viewStdout) as { files?: unknown }
42
+ return Array.isArray(parsed.files) ? parsed.files.length : 0
43
+ } catch {
44
+ return 0
45
+ }
46
+ }
47
+
34
48
  export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {
35
49
  const { ghBin, prNumber, runDir, cwd } = input
36
- const spawnOpts = { stdout: 'pipe' as const, stderr: 'pipe' as const, ...(cwd ? { cwd } : {}) }
50
+ const spawnOpts = {
51
+ stdout: 'pipe' as const,
52
+ stderr: 'pipe' as const,
53
+ ...(cwd ? { cwd } : {}),
54
+ ...(input.env ? { env: { ...process.env, ...input.env } } : {}),
55
+ }
37
56
  const view = Bun.spawn(
38
57
  [ghBin, 'pr', 'view', String(prNumber), '--json', PR_VIEW_FIELDS],
39
58
  spawnOpts,
@@ -48,20 +67,52 @@ export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {
48
67
  }
49
68
  }
50
69
 
70
+ const prJsonPath = join(runDir, 'pr.json')
71
+ const diffPath = join(runDir, 'diff.patch')
72
+ await writeFile(prJsonPath, viewStdout)
73
+
51
74
  const diff = Bun.spawn([ghBin, 'pr', 'diff', String(prNumber)], spawnOpts)
52
75
  const diffStdout = await new Response(diff.stdout).text()
53
76
  const diffStderr = await new Response(diff.stderr).text()
54
77
  const diffExit = await diff.exited
55
- if (diffExit !== 0) {
56
- return {
57
- ok: false,
58
- error: `gh pr diff exit ${diffExit}: ${diffStderr.trim()}`,
59
- }
78
+
79
+ // GitHub refuses the `.diff` media type above roughly 300 files (HTTP 406),
80
+ // and a truncating proxy can answer 200 with nothing at all. Reviewing an
81
+ // empty diff as though it were the PR is the worse failure of the two, so
82
+ // both conditions fall through to the local clone.
83
+ const emptyForNonEmptyPr = diffStdout.trim() === '' && prFileCount(viewStdout) > 0
84
+ if (diffExit === 0 && !emptyForNonEmptyPr) {
85
+ await writeFile(diffPath, diffStdout)
86
+ return { ok: true, prJsonPath, diffPath, source: 'gh' }
60
87
  }
61
88
 
62
- const prJsonPath = join(runDir, 'pr.json')
63
- const diffPath = join(runDir, 'diff.patch')
64
- await writeFile(prJsonPath, viewStdout)
65
- await writeFile(diffPath, diffStdout)
66
- return { ok: true, prJsonPath, diffPath }
89
+ const ghError =
90
+ diffExit !== 0
91
+ ? `gh pr diff exit ${diffExit}: ${diffStderr.trim()}`
92
+ : 'gh pr diff returned an empty diff for a PR with changed files'
93
+
94
+ let pr: { baseRefName?: unknown; headRefOid?: unknown }
95
+ try {
96
+ pr = JSON.parse(viewStdout) as { baseRefName?: unknown; headRefOid?: unknown }
97
+ } catch (err) {
98
+ return { ok: false, error: `${ghError}; pr.json is unparseable: ${String(err)}` }
99
+ }
100
+ const baseRefName = typeof pr.baseRefName === 'string' ? pr.baseRefName : ''
101
+ const headRefOid = typeof pr.headRefOid === 'string' ? pr.headRefOid : ''
102
+ if (!baseRefName || !headRefOid) {
103
+ return { ok: false, error: `${ghError}; pr.json has no baseRefName/headRefOid to diff from` }
104
+ }
105
+
106
+ const local = await localPrDiff({
107
+ gitBin: input.gitBin ?? 'git',
108
+ repoPath: cwd ?? process.cwd(),
109
+ prNumber,
110
+ baseRefName,
111
+ headRefOid,
112
+ })
113
+ if (!local.ok) {
114
+ return { ok: false, error: `${ghError}; local fallback failed: ${local.error}` }
115
+ }
116
+ await writeFile(diffPath, local.diff)
117
+ return { ok: true, prJsonPath, diffPath, source: 'git', mergeBase: local.mergeBase }
67
118
  }
@@ -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
@@ -98,6 +98,13 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
98
98
 
99
99
  const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
100
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
+
101
108
  await renderFindingsToDisk(
102
109
  {
103
110
  findings,
@@ -108,6 +115,7 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
108
115
  diff,
109
116
  brief: brief ?? undefined,
110
117
  issues,
118
+ diffSource,
111
119
  },
112
120
  join(screenDir, 'findings.html'),
113
121
  )
@@ -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
  )
@@ -124,9 +138,14 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
124
138
  // is simply omitted.
125
139
  const brief = parseBrief(await readJson<unknown>(join(runDir, 'brief.json'), null)) ?? undefined
126
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
127
146
  const outPath = await nextVersionedPath(screenDir, 'findings')
128
147
  await renderFindingsToDisk(
129
- { findings, postStatus, runId: basename(runDir), pr, files, diff, brief, issues },
148
+ { findings, postStatus, runId: basename(runDir), pr, files, diff, brief, issues, diffSource },
130
149
  outPath,
131
150
  )
132
151
  return 0
@@ -59,6 +59,8 @@ export type RenderFindingsInput = {
59
59
  issues?: BriefIssue[]
60
60
  /** Shiki highlighter, prepared by the caller. */
61
61
  highlighter: Highlighter
62
+ /** Where diff.patch came from. Absent means gh, which needs no note. */
63
+ diffSource?: { source: 'gh' | 'git'; mergeBase: string | null }
62
64
  }
63
65
 
64
66
  function esc(s: string): string {
@@ -86,13 +88,21 @@ function brandBlock(): string {
86
88
 
87
89
  function prHeader(input: RenderFindingsInput): string {
88
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
+ : ''
89
98
  const prMeta = pr
90
99
  ? `<div class="pr-meta">
91
100
  <span class="pr-number">PR #${pr.number}</span>
92
101
  <span class="pr-branch">${esc(pr.branch)}</span>
93
102
  <code>${esc(pr.headSha.slice(0, 12))}</code>
103
+ ${diffNote}
94
104
  </div>`
95
- : `<div class="pr-meta"></div>`
105
+ : `<div class="pr-meta">${diffNote}</div>`
96
106
  const total = input.findings.length
97
107
  return `<header class="pr-header">
98
108
  ${brandBlock()}
@@ -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)
@@ -0,0 +1,171 @@
1
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { type FileChunk, splitFileChunks } from './diff-chunks.ts'
4
+
5
+ export const DEFAULT_SHARD_BUDGET = 6000
6
+ export const DEFAULT_SHARD_MAX_FILES = 80
7
+
8
+ export type ShardEntry = { id: number; path: string; files: string[]; lines: number }
9
+ export type ShardManifest = {
10
+ budget: number
11
+ maxFiles: number
12
+ totalFiles: number
13
+ totalLines: number
14
+ shards: ShardEntry[]
15
+ }
16
+
17
+ /**
18
+ * The directory a file is grouped under, capped at two segments. Cohesion is
19
+ * not cosmetic: the architecture and code-smells focuses look for duplication,
20
+ * boundary violations, and shotgun surgery, and splitting a module across
21
+ * shards hides exactly those.
22
+ */
23
+ export function groupKey(path: string): string {
24
+ const parts = path.split('/')
25
+ if (parts.length <= 1) return '.'
26
+ return parts.slice(0, Math.min(2, parts.length - 1)).join('/')
27
+ }
28
+
29
+ export function planShards(chunks: FileChunk[], budget: number, maxFiles: number): FileChunk[][] {
30
+ const groups = new Map<string, FileChunk[]>()
31
+ for (const c of chunks) {
32
+ const key = groupKey(c.path)
33
+ const existing = groups.get(key)
34
+ if (existing) existing.push(c)
35
+ else groups.set(key, [c])
36
+ }
37
+
38
+ const shards: FileChunk[][] = []
39
+ let current: FileChunk[] = []
40
+ let currentLines = 0
41
+ const flush = () => {
42
+ if (current.length > 0) {
43
+ shards.push(current)
44
+ current = []
45
+ currentLines = 0
46
+ }
47
+ }
48
+
49
+ for (const members of groups.values()) {
50
+ const groupLines = members.reduce((n, c) => n + c.lines, 0)
51
+ // Start a fresh shard rather than straddle a group across a boundary, when
52
+ // the whole group could fit on its own.
53
+ if (
54
+ current.length > 0 &&
55
+ (currentLines + groupLines > budget || current.length + members.length > maxFiles)
56
+ ) {
57
+ flush()
58
+ }
59
+ for (const c of members) {
60
+ if (
61
+ current.length > 0 &&
62
+ (currentLines + c.lines > budget || current.length + 1 > maxFiles)
63
+ ) {
64
+ flush()
65
+ }
66
+ current.push(c)
67
+ currentLines += c.lines
68
+ }
69
+ }
70
+ flush()
71
+ return shards
72
+ }
73
+
74
+ /** Read `$RUN_DIR/diff.patch`, tolerating only its absence. A missing
75
+ * diff.patch means "nothing to shard"; anything else (permission error,
76
+ * disk error, EISDIR, ...) is a genuine failure and must propagate rather
77
+ * than silently turn into an empty manifest. */
78
+ async function readDiffPatch(runDir: string): Promise<string> {
79
+ try {
80
+ return await Bun.file(join(runDir, 'diff.patch')).text()
81
+ } catch (err) {
82
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined
83
+ if (code === 'ENOENT') return ''
84
+ throw err
85
+ }
86
+ }
87
+
88
+ /** Delete `shard-<n>.patch` files left by an earlier split. Re-splitting 7 shards
89
+ * into 4 would otherwise leave `shard-5.patch` .. `shard-7.patch` on disk: the
90
+ * manifest is authoritative for dispatch, but a stale patch makes the directory
91
+ * claim coverage the manifest does not have, and a resume that reads shard ids
92
+ * off disk would review a file set that no longer matches its id. Only the shard
93
+ * patches are touched: `manifest.json` stays in place until it is overwritten at
94
+ * the end (so there is never a window with no manifest), and `diff.patch` lives
95
+ * outside this directory and is what a single-shard manifest points at. */
96
+ async function clearStaleShardPatches(shardsDir: string): Promise<void> {
97
+ let entries: string[]
98
+ try {
99
+ entries = await readdir(shardsDir)
100
+ } catch {
101
+ return
102
+ }
103
+ for (const name of entries) {
104
+ if (/^shard-\d+\.patch$/.test(name)) await rm(join(shardsDir, name), { force: true })
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Split `$RUN_DIR/diff.patch` into budgeted shards. `diff.patch` is never
110
+ * modified: shards are views over it, which is what keeps the critic's diff
111
+ * excerpts, dedupe's evidence check, and the report's rendering working
112
+ * unchanged.
113
+ *
114
+ * Re-splitting an already-sharded run invalidates any
115
+ * `findings/<focus>.shard-<n>.json` from the previous split: the same shard id
116
+ * now names a different file set. Stage 4's resume rule keys on those files, so
117
+ * the caller must delete them before re-dispatching.
118
+ */
119
+ export async function shardDiff(
120
+ runDir: string,
121
+ opts: { budget?: number; maxFiles?: number } = {},
122
+ ): Promise<ShardManifest> {
123
+ const budget = opts.budget ?? DEFAULT_SHARD_BUDGET
124
+ const maxFiles = opts.maxFiles ?? DEFAULT_SHARD_MAX_FILES
125
+ const diff = await readDiffPatch(runDir)
126
+ const chunks = splitFileChunks(diff)
127
+ const totalLines = chunks.reduce((n, c) => n + c.lines, 0)
128
+ const shardsDir = join(runDir, 'shards')
129
+ await clearStaleShardPatches(shardsDir)
130
+ await mkdir(shardsDir, { recursive: true })
131
+
132
+ const planned = planShards(chunks, budget, maxFiles)
133
+ let shards: ShardEntry[]
134
+ if (planned.length === 0) {
135
+ shards = []
136
+ } else if (planned.length === 1) {
137
+ // Single-shard passthrough: stage 4 takes the same path it took on 0.9.0.
138
+ const only = planned[0] ?? []
139
+ shards = [
140
+ {
141
+ id: 1,
142
+ path: 'diff.patch',
143
+ files: only.map((c) => c.path),
144
+ lines: only.reduce((n, c) => n + c.lines, 0),
145
+ },
146
+ ]
147
+ } else {
148
+ shards = []
149
+ for (const [i, members] of planned.entries()) {
150
+ const id = i + 1
151
+ const rel = `shards/shard-${id}.patch`
152
+ await writeFile(join(runDir, rel), members.map((c) => c.text).join(''))
153
+ shards.push({
154
+ id,
155
+ path: rel,
156
+ files: members.map((c) => c.path),
157
+ lines: members.reduce((n, c) => n + c.lines, 0),
158
+ })
159
+ }
160
+ }
161
+
162
+ const manifest: ShardManifest = {
163
+ budget,
164
+ maxFiles,
165
+ totalFiles: chunks.length,
166
+ totalLines,
167
+ shards,
168
+ }
169
+ await writeFile(join(shardsDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
170
+ return manifest
171
+ }
@@ -1,7 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
- const ORDER = [
4
+ /** The resume ladder. Exported so `skill-lint` can assert that the diagnostic
5
+ * stage names SKILL.md logs (`filter`, `tests-check`, `shard-coverage`, ...) stay
6
+ * out of it: a diagnostic entry that lands on this list would move `next`. */
7
+ export const ORDER = [
5
8
  'setup',
6
9
  'context',
7
10
  'specialists',