@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,98 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import { loadConfig } from './config.ts'
4
+ import { assertNotUnderSource, storePaths } from './paths.ts'
5
+ import { isMissingFrontmatter, loadQueue, parseQueueItem } from './queue.ts'
6
+ import { readTextFile } from './store.ts'
7
+
8
+ export async function runQueue(opts: { root: string; args: string[] }): Promise<number> {
9
+ const [verb, ...rest] = opts.args
10
+ const cfg = await loadConfig(opts.root)
11
+ const queueDir = storePaths(opts.root).queueDir
12
+
13
+ if (verb === 'add') {
14
+ const file = rest[0]
15
+ if (!file) {
16
+ process.stderr.write('queue add: missing <file.md>\n')
17
+ return 2
18
+ }
19
+ let text: string
20
+ try {
21
+ text = await readTextFile(file)
22
+ } catch (e) {
23
+ // Missing file, a directory passed as a file, or an unreadable file:
24
+ // the request can never be serviced, the same usage-error class as
25
+ // import-cmd.ts's and census-cmd.ts's readJsonFile catch.
26
+ process.stderr.write(`queue add: ${(e as Error).message}\n`)
27
+ return 2
28
+ }
29
+ const dest = join(queueDir, basename(file))
30
+ const parsed = parseQueueItem(text, dest)
31
+ if (!parsed.ok) {
32
+ for (const e of parsed.errors) process.stderr.write(`queue add: ${e}\n`)
33
+ // No frontmatter at all belongs to the same usage-error class as the
34
+ // read failure above; every other grammar violation (bad severity, a
35
+ // missing or empty section, a filename/id mismatch, an adjudicated
36
+ // item with no ruling) is a well-formed file with bad content -- a
37
+ // content failure instead.
38
+ return isMissingFrontmatter(parsed.errors) ? 2 : 1
39
+ }
40
+ try {
41
+ await assertNotUnderSource(dest, cfg.source.path)
42
+ } catch (e) {
43
+ // A store whose configured source.path resolves to include its own
44
+ // queue directory (e.g. source.path: '.') can never be written to,
45
+ // for any well-formed item whatsoever: an environment/config
46
+ // problem, invariant across every possible file, not a property of
47
+ // this item's own content, so it is a usage error (2), not the
48
+ // content failure (1) returned just above for a bad grammar.
49
+ process.stderr.write(`queue add: ${(e as Error).message}\n`)
50
+ return 2
51
+ }
52
+ await mkdir(queueDir, { recursive: true })
53
+ await copyFile(file, dest)
54
+ process.stdout.write(`queue add: ${parsed.value.id} [${parsed.value.severity}]\n`)
55
+ return 0
56
+ }
57
+
58
+ if (verb === 'list') {
59
+ const openOnly = rest.includes('--open')
60
+ const { items, errors } = await loadQueue(queueDir)
61
+ for (const e of errors) process.stderr.write(`queue: ${e}\n`)
62
+ const shown = openOnly ? items.filter((i) => i.status === 'open') : items
63
+ for (const item of shown) {
64
+ process.stdout.write(`${item.id}\t${item.severity}\t${item.status}\n`)
65
+ }
66
+ process.stdout.write(`${shown.length} item(s)\n`)
67
+ return errors.length > 0 ? 1 : 0
68
+ }
69
+
70
+ if (verb === 'show') {
71
+ const id = rest[0]
72
+ if (!id) {
73
+ process.stderr.write('queue show: missing <id>\n')
74
+ return 2
75
+ }
76
+ const { items } = await loadQueue(queueDir)
77
+ const item = items.find((i) => i.id === id)
78
+ if (!item) {
79
+ process.stderr.write(`queue show: no item ${id}\n`)
80
+ return 1
81
+ }
82
+ let text: string
83
+ try {
84
+ text = await readTextFile(item.path)
85
+ } catch (e) {
86
+ // The item was in the index a moment ago; its file vanishing or
87
+ // becoming unreadable between load and show is a domain failure on an
88
+ // otherwise well-formed request, not a malformed one.
89
+ process.stderr.write(`queue show: ${(e as Error).message}\n`)
90
+ return 1
91
+ }
92
+ process.stdout.write(text)
93
+ return 0
94
+ }
95
+
96
+ process.stderr.write('queue: want add <file.md> | list [--open] | show <id>\n')
97
+ return 2
98
+ }
@@ -0,0 +1,258 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir } from 'node:fs/promises'
3
+ import { basename, join } from 'node:path'
4
+ import { isValidSlug } from './ids.ts'
5
+ import { readTextFile } from './store.ts'
6
+ import type { QueueItem, Severity } from './types.ts'
7
+
8
+ const SEVERITIES: Severity[] = ['critical', 'moderate', 'minor']
9
+ const SECTIONS = ['Evidence', 'Options', 'Recommendation'] as const
10
+
11
+ // A file with no --- frontmatter block at all never gets far enough to be
12
+ // treated as a queue item; the message below is the sole diagnostic on that
13
+ // path (see parseQueueItem). isMissingFrontmatter lets callers tell that
14
+ // case apart from every other grammar violation.
15
+ const NO_FRONTMATTER = 'missing --- frontmatter block'
16
+
17
+ export type ParsedQueue = { ok: true; value: QueueItem } | { ok: false; errors: string[] }
18
+
19
+ // Files authored on Windows commonly carry a leading byte-order mark and/or
20
+ // CRLF line endings; neither changes the file's actual content, so both are
21
+ // normalized away before any grammar check runs. Nothing further: no other
22
+ // encoding normalization is in scope.
23
+ function normalizeText(text: string): string {
24
+ const noBom = text.startsWith('\uFEFF') ? text.slice(1) : text
25
+ return noBom.replace(/\r\n/g, '\n')
26
+ }
27
+
28
+ function splitFrontmatter(text: string): { fm: Record<string, string>; body: string } | null {
29
+ if (!text.startsWith('---\n')) return null
30
+ const end = text.indexOf('\n---', 3)
31
+ if (end === -1) return null
32
+ const fmText = text.slice(4, end)
33
+ const body = text.slice(end + 4).replace(/^\n/, '')
34
+ const fm: Record<string, string> = {}
35
+ for (const line of fmText.split('\n')) {
36
+ const at = line.indexOf(':')
37
+ if (at === -1) continue
38
+ fm[line.slice(0, at).trim()] = line.slice(at + 1).trim()
39
+ }
40
+ return { fm, body }
41
+ }
42
+
43
+ type Heading = { name: string; start: number; end: number }
44
+
45
+ // Only a line that starts with exactly '## ' (two hashes, one space, at the
46
+ // very start of a line) counts as a section heading. Requiring exactly two
47
+ // hashes at the true start of the line means '### Options' (one hash too
48
+ // many) or '#Options' (too few), or an indented/blockquoted/tab-indented
49
+ // '##' line, are ordinary body text, not headings: a level typo can no
50
+ // longer be mistaken for a real heading, corrupt a neighboring section's
51
+ // content, or silently stand in for a missing one.
52
+ const HEADING_LINE = /^## (.+)$/
53
+
54
+ // A fenced code block (three or more backticks, or three or more tildes, at
55
+ // the start of a line once surrounding whitespace is trimmed) hides
56
+ // everything inside it from heading detection: a pasted shell script's
57
+ // '## Section' comments, a quoted markdown file, or a diff are Evidence
58
+ // content, not document structure, and must not be mistaken for one. This
59
+ // is deliberately not a conforming CommonMark fence parser -- no info
60
+ // string handling, no requirement that a closing fence be at least as long
61
+ // as the one that opened it -- it only needs to stop quoted code from
62
+ // reading as a heading, which a same-character open/close toggle already
63
+ // does; a fence line of the other character (e.g. a '~~~' divider inside a
64
+ // backtick-fenced block) does not close it.
65
+ const FENCE_LINE = /^(`{3,}|~{3,})/
66
+
67
+ type HeadingScan = {
68
+ headings: Heading[]
69
+ // The 1-indexed body line where an opening fence was never matched by a
70
+ // closing one, or null if every fence (if any) closed. A null-vs-number
71
+ // result, not a boolean, so the error message can point at exactly where
72
+ // the unterminated fence began.
73
+ unclosedFenceAt: number | null
74
+ }
75
+
76
+ function findHeadings(body: string): HeadingScan {
77
+ const headings: Heading[] = []
78
+ const lines = body.split('\n')
79
+ let fenceChar: string | null = null
80
+ let fenceStartLine: number | null = null
81
+ let offset = 0
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i] ?? ''
84
+ const fenceMatch = FENCE_LINE.exec(line.trim())
85
+ if (fenceMatch) {
86
+ const ch = fenceMatch[1]?.[0] ?? ''
87
+ if (fenceChar === null) {
88
+ fenceChar = ch
89
+ fenceStartLine = i + 1
90
+ } else if (ch === fenceChar) {
91
+ fenceChar = null
92
+ fenceStartLine = null
93
+ }
94
+ // A fence line of the other character while already inside a fence
95
+ // is just content (e.g. a '~~~' rule inside a backtick block): no
96
+ // state change, and it can never look like a heading anyway.
97
+ } else if (fenceChar === null) {
98
+ const m = HEADING_LINE.exec(line)
99
+ if (m) {
100
+ headings.push({ name: (m[1] ?? '').trim(), start: offset, end: offset + line.length })
101
+ }
102
+ }
103
+ offset += line.length + 1
104
+ }
105
+ return { headings, unclosedFenceAt: fenceChar === null ? null : fenceStartLine }
106
+ }
107
+
108
+ type SectionResult = { kind: 'ok'; text: string } | { kind: 'missing' } | { kind: 'duplicate' }
109
+
110
+ // A section is identified by an exact, case-sensitive, line-anchored
111
+ // '## <name>' heading. Zero matches is a missing section; more than one is
112
+ // a duplicate, reported as an error rather than silently taking the first
113
+ // (which used to truncate the section and drop everything after the
114
+ // second heading with no diagnostic). Content runs from the end of the
115
+ // matched heading's line to the start of the next heading of any name, or
116
+ // to the end of the document if there is none.
117
+ function sectionBody(headings: Heading[], body: string, name: string): SectionResult {
118
+ const matches = headings.filter((h) => h.name === name)
119
+ if (matches.length === 0) return { kind: 'missing' }
120
+ if (matches.length > 1) return { kind: 'duplicate' }
121
+ const match = matches[0]
122
+ if (!match) return { kind: 'missing' }
123
+ const at = headings.indexOf(match)
124
+ const next = headings[at + 1]
125
+ const contentEnd = next ? next.start : body.length
126
+ return { kind: 'ok', text: body.slice(match.end, contentEnd).trim() }
127
+ }
128
+
129
+ // A queue file that cannot be parsed at all (no --- frontmatter block found)
130
+ // is a usage error: the request never resolved to a queue item in the first
131
+ // place. Every other error below comes from a file that did parse, so it is
132
+ // a well-formed file with invalid content -- a content failure instead.
133
+ // Callers (queue-cmd.ts) use this to pick the right exit code without
134
+ // re-parsing the file themselves.
135
+ export function isMissingFrontmatter(errors: string[]): boolean {
136
+ return errors.length === 1 && (errors[0]?.includes(NO_FRONTMATTER) ?? false)
137
+ }
138
+
139
+ export function parseQueueItem(rawText: string, path: string): ParsedQueue {
140
+ const errors: string[] = []
141
+ const text = normalizeText(rawText)
142
+ const split = splitFrontmatter(text)
143
+ if (!split) return { ok: false, errors: [`${path}: ${NO_FRONTMATTER}`] }
144
+ const { fm, body } = split
145
+
146
+ const id = fm.id ?? ''
147
+ if (!id) errors.push(`${path}: frontmatter is missing id`)
148
+ const stem = basename(path).replace(/\.md$/, '')
149
+ if (id && stem !== id) errors.push(`${path}: filename ${stem} does not match id ${id}`)
150
+ if (id && !(id.startsWith('q-') && isValidSlug(id.slice(2)))) {
151
+ errors.push(`${path}: id ${id} must be q- followed by a lowercase kebab-case slug`)
152
+ }
153
+
154
+ const severity = fm.severity ?? ''
155
+ if (!SEVERITIES.includes(severity as Severity)) {
156
+ errors.push(
157
+ `${path}: severity must be one of ${SEVERITIES.join(', ')}, got ${severity || '<none>'}`,
158
+ )
159
+ }
160
+ const status = fm.status ?? ''
161
+ if (status !== 'open' && status !== 'adjudicated') {
162
+ errors.push(`${path}: status must be open or adjudicated, got ${status || '<none>'}`)
163
+ }
164
+ if (status === 'adjudicated' && !fm.ruling) {
165
+ errors.push(`${path}: an adjudicated item needs a ruling`)
166
+ }
167
+
168
+ const { headings, unclosedFenceAt } = findHeadings(body)
169
+ const found: Record<string, string> = {}
170
+ if (unclosedFenceAt !== null) {
171
+ // The document's structure past an unterminated fence cannot be
172
+ // trusted -- any '##' line after it might belong inside the fence or
173
+ // outside it, and there is no reliable way to tell. Rather than guess
174
+ // (which risks exactly the silent corruption Finding 2 fixed) or pile
175
+ // on confusing per-section "missing"/"duplicate" noise that would
176
+ // misdirect the author toward the wrong section entirely, this is one
177
+ // clear, specific, loud error naming the real problem, and every
178
+ // per-section check below is skipped.
179
+ errors.push(
180
+ `${path}: unclosed code fence starting at body line ${unclosedFenceAt} (add a matching closing fence; headings after this point cannot be recognized)`,
181
+ )
182
+ } else {
183
+ for (const name of SECTIONS) {
184
+ const section = sectionBody(headings, body, name)
185
+ if (section.kind === 'missing') {
186
+ // States the grammar explicitly (line-anchored, exactly two hashes,
187
+ // case-sensitive) so a level typo or a wrong-case heading (which also
188
+ // lands here, since neither is recognized as this heading) reads as
189
+ // a grammar mismatch rather than "you forgot this entirely".
190
+ errors.push(
191
+ `${path}: missing ## ${name} section (a line reading exactly "## ${name}", case-sensitive)`,
192
+ )
193
+ } else if (section.kind === 'duplicate') {
194
+ errors.push(
195
+ `${path}: duplicate ## ${name} section (the heading "## ${name}" appears more than once)`,
196
+ )
197
+ } else if (section.text.length === 0) {
198
+ errors.push(`${path}: ## ${name} section is empty`)
199
+ } else {
200
+ found[name] = section.text
201
+ }
202
+ }
203
+ }
204
+
205
+ if (errors.length > 0) return { ok: false, errors }
206
+ return {
207
+ ok: true,
208
+ value: {
209
+ id,
210
+ severity: severity as Severity,
211
+ status: status as QueueItem['status'],
212
+ ...(fm.ruling ? { ruling: fm.ruling } : {}),
213
+ ...(fm.adjudicated ? { adjudicated: fm.adjudicated } : {}),
214
+ evidence: found.Evidence ?? '',
215
+ options: found.Options ?? '',
216
+ recommendation: found.Recommendation ?? '',
217
+ path,
218
+ },
219
+ }
220
+ }
221
+
222
+ export async function loadQueue(
223
+ queueDir: string,
224
+ ): Promise<{ items: QueueItem[]; errors: string[] }> {
225
+ if (!existsSync(queueDir)) return { items: [], errors: [] }
226
+ let names: string[]
227
+ try {
228
+ names = (await readdir(queueDir)).filter((n) => n.endsWith('.md')).sort()
229
+ } catch {
230
+ // existsSync just confirmed the path exists, so a readdir failure here
231
+ // means it is not a readable directory (e.g. a file sits at the queue
232
+ // path). One clean diagnostic naming the path, not an uncaught throw.
233
+ return { items: [], errors: [`${queueDir}: cannot be read as a directory`] }
234
+ }
235
+ const items: QueueItem[] = []
236
+ const errors: string[] = []
237
+ for (const name of names) {
238
+ const path = join(queueDir, name)
239
+ let text: string
240
+ try {
241
+ text = await readTextFile(path)
242
+ } catch (e) {
243
+ // One unreadable entry (permission denied, a directory named *.md, or
244
+ // a file removed between readdir and read) must not hide the rest of
245
+ // the directory: report it and keep going.
246
+ errors.push((e as Error).message)
247
+ continue
248
+ }
249
+ const parsed = parseQueueItem(text, path)
250
+ if (parsed.ok) items.push(parsed.value)
251
+ else errors.push(...parsed.errors)
252
+ }
253
+ items.sort((a, b) => {
254
+ const bySeverity = SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity)
255
+ return bySeverity !== 0 ? bySeverity : a.id.localeCompare(b.id)
256
+ })
257
+ return { items, errors }
258
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { runCheck } from './check.ts'
4
+ import { loadConfig } from './config.ts'
5
+ import { assertNotUnderSource, storePaths } from './paths.ts'
6
+ import { loadQueue } from './queue.ts'
7
+ import { renderLedger, renderQueueReport, renderRequirements } from './report.ts'
8
+ import { readRows, writeAtomically } from './store.ts'
9
+ import type { Element, Requirement } from './types.ts'
10
+
11
+ export async function runReport(opts: { root: string; outDir?: string }): Promise<number> {
12
+ const cfg = await loadConfig(opts.root)
13
+ const p = storePaths(opts.root)
14
+ const outDir = opts.outDir ?? join(opts.root, 'docs', 'migrate')
15
+ await assertNotUnderSource(outDir, cfg.source.path)
16
+ await mkdir(outDir, { recursive: true })
17
+
18
+ const elements = await readRows<Element>(p.elements)
19
+ const requirements = await readRows<Requirement>(p.requirements)
20
+ const { items, errors } = await loadQueue(p.queueDir)
21
+ // A queue item the report cannot parse is not represented in queue.md at
22
+ // all: silently emitting a file that only covers the parseable items,
23
+ // under a banner claiming it was generated from the store, would let an
24
+ // owner reading it conclude there is nothing else to adjudicate. Surfaced
25
+ // the same way every other command in this CLI surfaces a domain problem
26
+ // on an otherwise well-formed request: named on stderr, non-zero exit.
27
+ for (const e of errors) process.stderr.write(`report: ${e}\n`)
28
+ const { summary } = await runCheck({ root: opts.root })
29
+
30
+ // Through writeAtomically rather than writeFile, so the three rendered views
31
+ // get temp-plus-rename like every store file: a report is what a human opens
32
+ // to read the run, and a truncated one is worse than a stale one. The
33
+ // explicit assertNotUnderSource on outDir above is still needed regardless,
34
+ // because the mkdir happens before any of these three run.
35
+ const src = cfg.source.path
36
+ await writeAtomically(join(outDir, 'ledger.md'), `${renderLedger(elements)}\n${summary}\n`, src)
37
+ await writeAtomically(join(outDir, 'requirements.md'), renderRequirements(requirements), src)
38
+ await writeAtomically(join(outDir, 'queue.md'), renderQueueReport(items), src)
39
+
40
+ process.stdout.write(`report: wrote ledger.md, requirements.md, queue.md to ${outDir}\n`)
41
+ // A malformed queue item is a content failure on an otherwise well-formed
42
+ // request (the store exists and could be read), the same class `queue
43
+ // list` already reports as exit 1 for the identical condition -- not a
44
+ // usage error (2, this request was perfectly serviceable) and not success
45
+ // (0, the report is incomplete).
46
+ return errors.length > 0 ? 1 : 0
47
+ }
@@ -0,0 +1,131 @@
1
+ import type {
2
+ Confidence,
3
+ Disposition,
4
+ Element,
5
+ Parity,
6
+ QueueItem,
7
+ Ref,
8
+ Requirement,
9
+ } from './types.ts'
10
+
11
+ const BANNER = '<!-- generated by `migrate report`; edit the store, not this file -->'
12
+
13
+ // A cell's content is free text an agent wrote while reading a legacy
14
+ // codebase, so it can contain anything. Two things actually break a GFM
15
+ // table's structure (change the column count a row parses to, or split one
16
+ // row into several): an unescaped '|', which reads as a new column
17
+ // boundary, and a line break, which ends the row outright. Both are
18
+ // neutralized here: '|' is escaped to '\|', and every line-break variant
19
+ // (bare '\n', '\r\n', and a lone '\r' from old Mac-authored text) is
20
+ // flattened to a space, not just '\n' alone, so a stray '\r' can't survive
21
+ // as an unflattened character in the middle of a row.
22
+ //
23
+ // A run of backticks is deliberately left alone. It cannot change the
24
+ // column count or split the row: GFM splits a table row into cells by its
25
+ // literal, unescaped '|' characters first, and only afterward parses each
26
+ // cell's inline content (code spans, emphasis, ...) independently, so an
27
+ // unbalanced backtick run is confined to the cell it's in and renders as
28
+ // literal backticks at worst. Wrapping such a cell in a matching fence was
29
+ // considered, but it would have to run after the '|' escaping above, and a
30
+ // backtick code span does not itself interpret backslash escapes, so an
31
+ // escaped pipe inside it would render as the literal two characters '\|'
32
+ // instead of a pipe: a cosmetic regression in exchange for guarding against
33
+ // a break that was never structural in the first place.
34
+ function cell(text: string): string {
35
+ return text.replaceAll('|', '\\|').replaceAll(/\r\n|\r|\n/g, ' ')
36
+ }
37
+
38
+ export function refToText(ref: Ref): string {
39
+ if (ref.kind === 'src')
40
+ return ref.lines ? `src:${ref.path}:${ref.lines[0]}-${ref.lines[1]}` : `src:${ref.path}`
41
+ if (ref.kind === 'ledger') return ref.id
42
+ if (ref.kind === 'doc') return `doc:${ref.path}`
43
+ return `observed:${ref.host}${ref.path}`
44
+ }
45
+
46
+ export function dispositionToText(d: Disposition): string {
47
+ if (d.kind === 'mapped') return `mapped:${d.fr}`
48
+ if (d.kind === 'out-of-scope') return `out-of-scope:${d.queue}`
49
+ return 'unaccounted'
50
+ }
51
+
52
+ export function confidenceToText(c: Confidence): string {
53
+ return c.kind === 'queued' ? `queued:${c.queue}` : c.kind
54
+ }
55
+
56
+ export function parityToText(p: Parity | null): string {
57
+ if (p === null) return '-'
58
+ if (p.kind === 'rubric')
59
+ return p.level === 'high' ? 'rubric:high' : `rubric:${p.level}:${p.queue}`
60
+ return `${p.kind}:${p.ref}`
61
+ }
62
+
63
+ export function renderLedger(elements: Element[]): string {
64
+ const lines = [BANNER, '', '# Surface ledger', '']
65
+ if (elements.length === 0) {
66
+ lines.push('No surface elements recorded yet.', '')
67
+ return lines.join('\n')
68
+ }
69
+ const bySurface = new Map<string, Element[]>()
70
+ for (const el of elements) {
71
+ const list = bySurface.get(el.surface) ?? []
72
+ list.push(el)
73
+ bySurface.set(el.surface, list)
74
+ }
75
+ for (const surface of [...bySurface.keys()].sort()) {
76
+ lines.push(`## ${surface}`, '')
77
+ lines.push('| id | element | found-by | disposition | refs | notes |')
78
+ lines.push('| --- | --- | --- | --- | --- | --- |')
79
+ for (const el of bySurface.get(surface) ?? []) {
80
+ lines.push(
81
+ `| ${cell(el.id)} | ${cell(el.element)} | ${el.found_by.join(',')} | ${dispositionToText(el.disposition)} | ${cell(el.refs.map(refToText).join(', '))} | ${cell(el.notes)} |`,
82
+ )
83
+ }
84
+ lines.push('')
85
+ }
86
+ return lines.join('\n')
87
+ }
88
+
89
+ export function renderRequirements(reqs: Requirement[]): string {
90
+ const lines = [BANNER, '', '# Functional requirements', '']
91
+ if (reqs.length === 0) {
92
+ lines.push('No requirements recorded yet.', '')
93
+ return lines.join('\n')
94
+ }
95
+ const byCap = new Map<string, Requirement[]>()
96
+ for (const req of reqs) {
97
+ const list = byCap.get(req.cap) ?? []
98
+ list.push(req)
99
+ byCap.set(req.cap, list)
100
+ }
101
+ for (const cap of [...byCap.keys()].sort()) {
102
+ lines.push(`## ${cap}`, '')
103
+ lines.push(
104
+ '| id | requirement | actors | objects | rules | confidence | origin | citations | parity |',
105
+ )
106
+ lines.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- |')
107
+ for (const req of byCap.get(cap) ?? []) {
108
+ lines.push(
109
+ `| ${cell(req.id)} | ${cell(req.requirement)} | ${cell(req.actors)} | ${cell(req.objects)} | ${cell(req.rules)} | ${confidenceToText(req.confidence)} | ${req.origin} | ${cell(req.citations.map(refToText).join(', '))} | ${cell(parityToText(req.parity))} |`,
110
+ )
111
+ }
112
+ lines.push('')
113
+ }
114
+ return lines.join('\n')
115
+ }
116
+
117
+ export function renderQueueReport(items: QueueItem[]): string {
118
+ const lines = [BANNER, '', '# Decision queue', '']
119
+ if (items.length === 0) {
120
+ lines.push('No queue items.', '')
121
+ return lines.join('\n')
122
+ }
123
+ lines.push('| id | severity | status | recommendation |')
124
+ lines.push('| --- | --- | --- | --- |')
125
+ for (const item of items) {
126
+ const first = item.recommendation.split('\n')[0] ?? ''
127
+ lines.push(`| ${cell(item.id)} | ${item.severity} | ${item.status} | ${cell(first)} |`)
128
+ }
129
+ lines.push('')
130
+ return lines.join('\n')
131
+ }
@@ -0,0 +1,120 @@
1
+ import { rm } from 'node:fs/promises'
2
+ import { loadConfig } from './config.ts'
3
+ import { LockError, withStoreLock } from './lock.ts'
4
+ import { type StorePaths, storePaths } from './paths.ts'
5
+ import { isPhase, loadPhases, type Phase, savePhases } from './phases.ts'
6
+ import { readRows, writeRows } from './store.ts'
7
+ import type { Census, Element, Requirement } from './types.ts'
8
+
9
+ export async function runReset(opts: {
10
+ root: string
11
+ phase: string
12
+ forceUnlock?: boolean
13
+ }): Promise<number> {
14
+ if (!isPhase(opts.phase)) {
15
+ process.stderr.write(`reset: unknown phase ${opts.phase}\n`)
16
+ return 2
17
+ }
18
+ const phase = opts.phase
19
+ const cfg = await loadConfig(opts.root)
20
+ const p = storePaths(opts.root)
21
+ const src = cfg.source.path
22
+
23
+ // reset performs the same read-modify-write over whole store files that
24
+ // import and census do -- it reads census.jsonl and elements.jsonl, filters
25
+ // or rewrites every row, and writes the file back, then does the same to
26
+ // phases.json -- and it was the one such command with no lock at all.
27
+ // Concurrently with an importer that is mid-critical-section, either
28
+ // rewrite can discard every row the other wrote, which is exactly the loss
29
+ // the lock exists to stop. Nothing inside this critical section takes the
30
+ // lock again: writeRows, readRows, loadPhases and savePhases are all
31
+ // lock-free, and savePhases is deliberately called below instead of
32
+ // setPhaseStatus, which is the one phases.ts helper that does take it.
33
+ // withStoreLock is not reentrant, so that distinction is load-bearing
34
+ // rather than incidental.
35
+ let cleared: string[]
36
+ try {
37
+ cleared = await withStoreLock(opts.root, () => clearPhase(opts.root, phase, p, src), {
38
+ cmd: 'reset',
39
+ ...(opts.forceUnlock ? { force: true } : {}),
40
+ onWait: (m) => process.stderr.write(`reset: ${m}\n`),
41
+ })
42
+ } catch (e) {
43
+ // Same classification import, census and phase --status already use: a
44
+ // lock failure is neither an unknown phase (2) nor a content failure (1);
45
+ // the request is fine and would likely succeed on retry.
46
+ if (e instanceof LockError) {
47
+ process.stderr.write(`reset: ${e.message}\n`)
48
+ return 3
49
+ }
50
+ throw e
51
+ }
52
+
53
+ const what = cleared.length > 0 ? `: cleared ${cleared.join(', ')}` : ' (state only)'
54
+ process.stdout.write(`reset ${phase}${what}\n`)
55
+ return 0
56
+ }
57
+
58
+ // The whole mutation, extracted so the critical section above is one call and
59
+ // the lock's extent is impossible to misread. Returns what it cleared, which
60
+ // is used only to build the stdout line, after the lock has been released.
61
+ async function clearPhase(
62
+ root: string,
63
+ phase: Phase,
64
+ p: StorePaths,
65
+ src: string,
66
+ ): Promise<string[]> {
67
+ const cleared: string[] = []
68
+
69
+ if (phase === 'enumerate') {
70
+ await writeRows(p.elements, [], src)
71
+ const census = await readRows<Census>(p.census)
72
+ await writeRows(
73
+ p.census,
74
+ census.filter((c) => c.kind !== 'lens'),
75
+ src,
76
+ )
77
+ cleared.push('elements', 'lens census')
78
+ }
79
+
80
+ if (phase === 'seam') {
81
+ await writeRows(p.capabilities, [], src)
82
+ await rm(p.seamJson, { force: true })
83
+ await rm(p.seamMd, { force: true })
84
+ cleared.push('capabilities', 'seam')
85
+ }
86
+
87
+ if (phase === 'extract') {
88
+ await writeRows(p.requirements, [], src)
89
+ const census = await readRows<Census>(p.census)
90
+ await writeRows(
91
+ p.census,
92
+ census.filter((c) => c.kind === 'lens'),
93
+ src,
94
+ )
95
+ const elements = await readRows<Element>(p.elements)
96
+ await writeRows(
97
+ p.elements,
98
+ elements.map((e) => ({ ...e, disposition: { kind: 'unaccounted' as const } })),
99
+ src,
100
+ )
101
+ cleared.push('requirements', 'attribute/rule-sweep/closer census', 'element dispositions')
102
+ }
103
+
104
+ if (phase === 'parity') {
105
+ await writeRows(p.deltas, [], src)
106
+ const reqs = await readRows<Requirement>(p.requirements)
107
+ await writeRows(
108
+ p.requirements,
109
+ reqs.map((r) => ({ ...r, parity: null })),
110
+ src,
111
+ )
112
+ cleared.push('deltas', 'parity plans')
113
+ }
114
+
115
+ const phases = await loadPhases(root)
116
+ phases[phase] = { status: 'pending', batches: [], pending: [] }
117
+ await savePhases(root, phases, src)
118
+
119
+ return cleared
120
+ }