@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
@@ -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',
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.9.0",
4
- "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
3
+ "version": "0.10.0",
4
+ "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request. Splits oversized diffs into budgeted shards and rebuilds the diff from the local clone when gh pr diff refuses it.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",
7
7
  "tools": [
@@ -183,6 +183,11 @@ button {
183
183
  color: var(--ink);
184
184
  }
185
185
 
186
+ .diff-source {
187
+ font-size: 0.78rem;
188
+ opacity: 0.7;
189
+ }
190
+
186
191
  .header-spacer {
187
192
  flex: 1;
188
193
  }
@@ -0,0 +1,194 @@
1
+ # migrate
2
+
3
+ Source-agnostic legacy migration mapping. `migrate` walks a legacy codebase
4
+ through probe, enumerate, seam, extract, parity, and queue, building an
5
+ auditable requirements ledger with mandatory citations instead of a
6
+ self-reported one. It enumerates the legacy surface from two independent
7
+ directions per lens, derives a capability seam empirically rather than by
8
+ guesswork, extracts cited functional requirements, plans parity against the
9
+ source, and routes every ambiguity to a batch decision queue for a human to
10
+ adjudicate. The coverage arithmetic, citation resolution, and phase ordering
11
+ are enforced by a bundled Bun CLI instead of being self-reported. Two more
12
+ phases, adjudicate and handoff, complete the walkthrough but ship no CLI verb
13
+ yet; see the phases table below.
14
+
15
+ ## Using it
16
+
17
+ Invoke `/migrate` in a target repo that is a git working copy, pointed at a
18
+ read-only checkout of the legacy source. The skill (`SKILL.md`) is the
19
+ walkthrough: it names, phase by phase, what to read, what to dispatch, and
20
+ what `migrate` command closes that phase out. Each phase's manual under
21
+ `references/phases/` carries the actual judgment calls, loaded only when
22
+ that phase is current so a run never pays for prose it does not need yet.
23
+
24
+ ### The phases
25
+
26
+ | # | Phase | Manual | What it produces |
27
+ |---|---|---|---|
28
+ | 0 | Probe | `references/phases/probe.md` | `.migrate/config.toml` (detected source stack, basis, target profile) and `.migrate/parity-basis.md` (the detection evidence, hand-written) |
29
+ | 1 | Enumerate | `references/phases/enumerate.md` | `elements.jsonl` and a lens census record per surface |
30
+ | 2 | Seam | `references/phases/seam.md` | `capabilities.jsonl`, `seam.json`, `seam.md`: the capability partition and its evidence |
31
+ | 3 | Extract | `references/phases/extract.md` | `requirements.jsonl`, attribute/rule-sweep/closer census records, terminal element dispositions |
32
+ | 4 | Parity | `references/phases/parity.md` | `deltas.jsonl` and a parity plan on every non-queued requirement |
33
+ | 5 | Queue | `references/phases/queue.md` | Queue items carrying evidence, options and a recommendation for anything ambiguous |
34
+ | 6 | Adjudicate | none yet | No verb ships in this version; `migrate status` and `migrate queue list` are the terminus |
35
+ | 7 | Handoff | none yet | Same as adjudicate: no verb yet |
36
+
37
+ A run in this version stops at the queue. `adjudicate` and `handoff` have no
38
+ CLI verbs to complete them, so `migrate check --phase queue` is the
39
+ practical terminus: its exit 0 is what "done, for now" means. Plain `migrate
40
+ check` gates every phase through `handoff` and cannot pass yet for the same
41
+ reason. `references/run-ops.md` covers what applies across every phase
42
+ rather than any one of them: subagent dispatch, the batch-checkpoint
43
+ discipline, and what happens when two agents contend for the store lock.
44
+
45
+ ### Recipes
46
+
47
+ Enumerate reads the source stack `probe` detected and looks for a matching
48
+ file in `references/recipes/`, one file per stack family
49
+ (`references/recipes/aspnet.md` covers `aspnet-webforms`, `aspnet-mvc`, and
50
+ `aspnet-webapi`). A recipe answers one narrow question: for each declared
51
+ surface type, at least two independent directions for enumerating it and the
52
+ probe command that realises each one. Nothing else; the lens contract itself
53
+ lives once in `enumerate.md`, and a recipe does not restate it, carry
54
+ classification rules, or gate anything.
55
+
56
+ If no file matches the detected stack, that is contract-only mode: a
57
+ supported path, not a degraded one. The enumerating agent derives its own two
58
+ directions per surface, and the census gates them exactly as it would a
59
+ recipe's.
60
+
61
+ **Adding a stack is one new file in `references/recipes/` and no edit
62
+ anywhere else.** `SKILL.md`, the phase manuals, and the CLI never name an
63
+ individual stack; they only read `[source].stack` and look in that
64
+ directory. See `references/recipes/README.md` for the exact file shape.
65
+
66
+ ## Checking as you go
67
+
68
+ ```
69
+ migrate check --phase <current-phase>
70
+ ```
71
+
72
+ bounds the run-state gate at that phase; the other nine gates always read
73
+ the whole store, so a coverage or census gap past your current phase still
74
+ fails on its own gate regardless of `--phase`. Citations are checked by
75
+ default; pass `--no-citations` to skip that gate.
76
+
77
+ ## Fixtures
78
+
79
+ Two fixtures, each with a committed `GROUND-TRUTH.md`, drive the skill end to
80
+ end:
81
+
82
+ - **`fixtures/tiny-express/`**: a small Express/Node app, twelve elements
83
+ across all eight default surfaces. Its stack (`express`) matches no file
84
+ in `references/recipes/`, so it proves the contract-only path: enumerate
85
+ deriving its own two directions per surface with no recipe to lean on, and
86
+ the census gating them exactly the same as it would a recipe's.
87
+ - **`fixtures/tiny-webforms/`**: a small ASP.NET Web Forms app, sixteen
88
+ elements across the same eight surfaces. Its stack (`aspnet-webforms`)
89
+ matches `references/recipes/aspnet.md`, so it is that recipe's first run
90
+ against committed code, not just the throwaway trees it was written
91
+ against.
92
+
93
+ `scripts/__tests__/e2e-express.test.ts` and
94
+ `scripts/__tests__/e2e-webforms.test.ts` copy the respective fixture to a
95
+ temp directory and drive the real CLI as a subprocess, probe through queue,
96
+ through `init`, `import`, `census`, `phase`, `queue add`, `queue list`, and
97
+ `check`, reconciling every row against the fixture's ground truth. Both end at
98
+ `migrate check --phase queue` on exit 0, and then show plain `migrate check`
99
+ failing on exactly `adjudicate` and `handoff`, the two phases with no verb in
100
+ this version.
101
+
102
+ Both show gates in both directions. Failing before they pass: the mid-run check
103
+ after enumerate names the three closer records extract has not written yet, and
104
+ the `deltas` gate names the sanctioned difference each run files unsigned
105
+ before an owner signs it. Failing after they pass: each run closes green, then
106
+ mutates the store (nulling every parity plan, then removing an element row) and
107
+ asserts the gate that should catch it does.
108
+
109
+ ## Documentation
110
+
111
+ - **[docs/reference.md](docs/reference.md)** is what you need to drive the CLI:
112
+ the batch-file and census formats with worked examples, the row schemas and
113
+ their grammars, what each of the ten gates enforces, and the exit-code
114
+ convention. Ships with the installed skill.
115
+ - **[docs/architecture.md](docs/architecture.md)** is for working on the skill
116
+ itself: the module map, the rule that decides what belongs in the CLI rather
117
+ than the prompt, how to add a gate or a surface type, the testing
118
+ conventions, and the known limits.
119
+
120
+ ## Store layout
121
+
122
+ The store lives at `.migrate/` in the target repo and is committed.
123
+
124
+ | Path | Shape | Holds |
125
+ |---|---|---|
126
+ | `.migrate/config.toml` | declarative | source pointer and scope, detected source stack, target profile, surface-type set, closer set, handoff adapter |
127
+ | `.migrate/elements.jsonl` | rows | surface ledger |
128
+ | `.migrate/requirements.jsonl` | rows | functional requirements and their dispositions |
129
+ | `.migrate/capabilities.jsonl` | rows | the seam partition |
130
+ | `.migrate/seam.json` | object | run-level seam metadata: validators run, modularity, status |
131
+ | `.migrate/deltas.jsonl` | rows | sanctioned delta catalog |
132
+ | `.migrate/census.jsonl` | rows | one accounting record per lens run |
133
+ | `.migrate/phases.json` | object | per-phase status, batches, resume pointers |
134
+ | `.migrate/seam.md` | prose | validator scripts and their raw output |
135
+ | `.migrate/parity-basis.md` | prose | runnable-versus-source-only detection evidence |
136
+ | `.migrate/queue/q-<slug>.md` | prose | evidence, options, recommendation |
137
+ | `.migrate/.env` | secrets | runtime-lens credentials, gitignored |
138
+ | `docs/migrate/*.md` | generated | human-readable views, written by `migrate report` |
139
+
140
+ ## CLI
141
+
142
+ | Command | Does |
143
+ |---|---|
144
+ | `migrate init --source <path> --scope <text> --name <target>` | Writes `.migrate/config.toml` |
145
+ | `migrate import <elements\|reqs\|deltas> <batch.json>` | Validated bulk append to the store |
146
+ | `migrate census <record.json>` | Records a lens accounting record |
147
+ | `migrate phase [<name>] [--status <s>]` | Prints phase state, or sets one phase's status to any value you name |
148
+ | `migrate queue add <file.md>` | Adds a queue item |
149
+ | `migrate queue list [--open]` | Lists queue items, severity first |
150
+ | `migrate queue show <id>` | Prints one queue item |
151
+ | `migrate check [--phase <p>] [--no-citations] [--leaks]` | Runs the gates |
152
+ | `migrate status` | Phase state, counts, resume pointer |
153
+ | `migrate reset --phase <phase>` | Clears one phase's derived rows and returns it to `pending` |
154
+ | `migrate report [--out <dir>]` | Renders markdown views |
155
+
156
+ Run `migrate --help` for the same list from the CLI itself.
157
+
158
+ **Four commands write a phase's status, each to a different extent.** `phase
159
+ --status <s>` is the only one that writes any value you ask for, and the only
160
+ one whose whole purpose is that write. `reset --phase <p>` also writes it
161
+ directly, but only ever to `pending`, and it empties that phase's `batches`
162
+ list at the same time. `import` and `census` touch `phases.json` incidentally,
163
+ each moving a phase to `running` (unless it is already `done`) when they record
164
+ a batch. Nothing else writes it at all.
165
+
166
+ A lock failure on `import`, `census`, `phase --status`, or `reset` exits `3`;
167
+ pass `--force-unlock` once you have confirmed no other agent is actually
168
+ writing.
169
+
170
+ ## Install
171
+
172
+ ```
173
+ bunx @iceinvein/agent-skills install migrate -g
174
+ ```
175
+
176
+ This skill ships in two parts: the prompt (`SKILL.md`) and a companion Bun
177
+ CLI (`bin` + `scripts`). The agent-skills installer writes both into
178
+ `~/.claude/skills/migrate/` and then runs the bundled `install.sh` as a
179
+ postinstall step, which symlinks `bin/migrate` onto your PATH (preferring
180
+ `/usr/local/bin`, falling back to `~/.local/bin`). Removing the skill with
181
+ `agent-skills remove migrate -g` runs `uninstall.sh` first to undo the PATH
182
+ symlink.
183
+
184
+ If you cloned this repo and want to run from source, you can also invoke
185
+ `./install.sh` directly: it does the PATH-link step against the local source
186
+ tree.
187
+
188
+ ## Development
189
+
190
+ ```
191
+ bun test # Run all tests
192
+ bun run lint # Biome check
193
+ bun run typecheck # tsc --noEmit
194
+ ```