@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,376 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { balanceOf, boundsOf, censusKey, validateCensus } from './census.ts'
3
+ import { resolveCitations } from './citations.ts'
4
+ import { loadConfig } from './config.ts'
5
+ import { scanLeaks } from './leaks.ts'
6
+ import { storePaths } from './paths.ts'
7
+ import { loadPhases, PHASES, type Phase } from './phases.ts'
8
+ import { loadQueue } from './queue.ts'
9
+ import { readRawRows, readRows } from './store.ts'
10
+ import type { Capability, Census, Delta, Element, Requirement, Violation } from './types.ts'
11
+ import { isRecord } from './validate.ts'
12
+
13
+ // A hand-edited census.jsonl never passes through census-cmd.ts's
14
+ // validateCensus call, so this is the only label available for a row that
15
+ // fails the check below: its 1-based position in the file, plus (when kind
16
+ // and the field censusKey needs both parse as strings) the same key
17
+ // census-cmd.ts uses to identify a record, so the message points at
18
+ // something the reader can find rather than an opaque line number alone.
19
+ function censusRowLabel(raw: unknown, line: number): string {
20
+ const base = `census.jsonl line ${line}`
21
+ if (!isRecord(raw)) return base
22
+ const kind = raw.kind
23
+ if (kind === 'lens' && typeof raw.surface === 'string')
24
+ return `${base} (${censusKey(raw as Census)})`
25
+ if (kind === 'attribute' && typeof raw.subject === 'string')
26
+ return `${base} (${censusKey(raw as Census)})`
27
+ if (kind === 'rule-sweep' && typeof raw.subject === 'string')
28
+ return `${base} (${censusKey(raw as Census)})`
29
+ if (kind === 'closer' && typeof raw.closer === 'string')
30
+ return `${base} (${censusKey(raw as Census)})`
31
+ return base
32
+ }
33
+
34
+ export type CheckResult = { summary: string; violations: Violation[] }
35
+
36
+ export const GATE_ORDER = [
37
+ 'coverage',
38
+ 'census',
39
+ 'refs',
40
+ 'queue',
41
+ 'deltas',
42
+ 'parity',
43
+ 'citations',
44
+ 'leaks',
45
+ 'source',
46
+ 'run-state',
47
+ ] as const
48
+
49
+ async function sourceIsDirty(sourcePath: string, gitBin: string): Promise<boolean> {
50
+ if (!existsSync(`${sourcePath}/.git`)) return false
51
+ const proc = Bun.spawn([gitBin, 'status', '--porcelain'], {
52
+ cwd: sourcePath,
53
+ stdout: 'pipe',
54
+ stderr: 'ignore',
55
+ })
56
+ const out = await new Response(proc.stdout).text()
57
+ await proc.exited
58
+ return out.trim().length > 0
59
+ }
60
+
61
+ export async function runCheck(opts: {
62
+ root: string
63
+ citations?: boolean
64
+ leaks?: boolean
65
+ gitBin?: string
66
+ phase?: Phase
67
+ }): Promise<CheckResult> {
68
+ const cfg = await loadConfig(opts.root)
69
+ const paths = storePaths(opts.root)
70
+ const gitBin = opts.gitBin ?? 'git'
71
+
72
+ const elements = await readRows<Element>(paths.elements)
73
+ const requirements = await readRows<Requirement>(paths.requirements)
74
+ const capabilities = await readRows<Capability>(paths.capabilities)
75
+ const deltas = await readRows<Delta>(paths.deltas)
76
+ // Read raw, not readRows<Census>: census.jsonl is the one store file this
77
+ // gate cannot assume was ever written by census-cmd.ts, since nothing
78
+ // stops a hand edit, and readRows only asserts a type onto whatever
79
+ // JSON.parse returns rather than checking it. Gate 2 below runs the same
80
+ // validateCensus every real write goes through, so a shape that only
81
+ // TypeScript ever believed in gets caught here instead of quietly reaching
82
+ // balanceOf and boundsOf, both of which assume a well-formed record.
83
+ const censusRows = await readRawRows(paths.census)
84
+ const { items: queueItems, errors: queueErrors } = await loadQueue(paths.queueDir)
85
+
86
+ const violations: Violation[] = []
87
+
88
+ // Gate 1: coverage.
89
+ let mapped = 0
90
+ let outOfScope = 0
91
+ let unaccounted = 0
92
+ for (const el of elements) {
93
+ if (el.disposition.kind === 'mapped') mapped++
94
+ else if (el.disposition.kind === 'out-of-scope') outOfScope++
95
+ else {
96
+ unaccounted++
97
+ violations.push({
98
+ gate: 'coverage',
99
+ message: `${el.id} (${el.surface}) is still unaccounted`,
100
+ })
101
+ }
102
+ }
103
+ const summary = `${mapped}/${elements.length} mapped, ${outOfScope} out-of-scope, ${unaccounted} unaccounted`
104
+
105
+ // Gate 2: census shape, balance and presence. Each row is validated here,
106
+ // not merely read, because census.jsonl is a store file that census-cmd.ts
107
+ // does not own exclusively: a hand edit reaches this gate without ever
108
+ // passing through validateCensus first. A row that fails is named by line
109
+ // (and by censusKey when its identity parses) and excluded from balanceOf,
110
+ // boundsOf, and the in_ledger + added reconciliation below: all three
111
+ // assume a well-formed record, so running them on one that already failed
112
+ // shape validation would add confusing noise on top of the real defect
113
+ // rather than a second independent fact.
114
+ const surfacesWithCensus = new Set<string>()
115
+ const closersWithCensus = new Set<string>()
116
+ const census: Census[] = []
117
+ for (const { line, raw } of censusRows) {
118
+ const result = validateCensus(raw)
119
+ if (!result.ok) {
120
+ const label = censusRowLabel(raw, line)
121
+ for (const error of result.errors) {
122
+ violations.push({ gate: 'census', message: `${label}: ${error}` })
123
+ }
124
+ // A row that fails validation still ran and still named a surface or
125
+ // closer it claims to cover; only its shape is defective, not its
126
+ // existence. Registered defensively here (guarded the same way
127
+ // censusRowLabel is, since the row is not a trustworthy Census) so
128
+ // gate 2 does not also claim that surface or closer has no census
129
+ // record at all, which is a different and wrong accusation: that
130
+ // message means the lens never ran or never closed, not that it ran
131
+ // and produced something malformed. A row whose kind or identity
132
+ // field does not even parse as a string cannot make this claim, so it
133
+ // falls through to the genuinely-missing check below unregistered.
134
+ if (isRecord(raw)) {
135
+ if (raw.kind === 'lens' && typeof raw.surface === 'string') {
136
+ surfacesWithCensus.add(raw.surface)
137
+ } else if (raw.kind === 'closer' && typeof raw.closer === 'string') {
138
+ closersWithCensus.add(raw.closer)
139
+ }
140
+ }
141
+ continue
142
+ }
143
+ const record = result.value
144
+ census.push(record)
145
+ const imbalance = balanceOf(record)
146
+ if (imbalance) violations.push({ gate: 'census', message: imbalance })
147
+ const outOfBounds = boundsOf(record)
148
+ if (outOfBounds) violations.push({ gate: 'census', message: outOfBounds })
149
+ if (record.kind === 'lens') {
150
+ surfacesWithCensus.add(record.surface)
151
+ // balanceOf only checks that the record's own numbers add up
152
+ // internally; nothing before this ties in_ledger + added to anything
153
+ // outside the record itself, so a lens census can balance perfectly
154
+ // while claiming a headcount elements.jsonl never received (total is
155
+ // self-reported and cannot be checked against anything, but in_ledger
156
+ // + added claims a specific number of rows now exist in the ledger
157
+ // for this surface, and that claim is directly countable).
158
+ const claimed = record.in_ledger + record.added
159
+ const actual = elements.filter((e) => e.surface === record.surface).length
160
+ if (actual !== claimed) {
161
+ violations.push({
162
+ gate: 'census',
163
+ message: `lens census for ${record.surface} claims in_ledger ${record.in_ledger} + added ${record.added} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
164
+ })
165
+ }
166
+ }
167
+ if (record.kind === 'closer') closersWithCensus.add(record.closer)
168
+ }
169
+ for (const surface of cfg.surfaces) {
170
+ if (!surfacesWithCensus.has(surface)) {
171
+ violations.push({
172
+ gate: 'census',
173
+ message: `declared surface ${surface} has no lens census record; the lens did not run or did not close`,
174
+ })
175
+ }
176
+ }
177
+ for (const closer of cfg.closers) {
178
+ if (!closersWithCensus.has(closer)) {
179
+ violations.push({
180
+ gate: 'census',
181
+ message: `declared closer ${closer} has no census record`,
182
+ })
183
+ }
184
+ }
185
+
186
+ // Gate 3: referential integrity.
187
+ const reqIds = new Set(requirements.map((r) => r.id))
188
+ const elementIds = new Set(elements.map((e) => e.id))
189
+ const capSlugs = new Set(capabilities.map((c) => c.slug))
190
+ const queueIds = new Set(queueItems.map((q) => q.id))
191
+
192
+ // A duplicate id or slug within one store file is a real defect the refs
193
+ // gate must catch on its own, not something it can assume another gate or
194
+ // command already ruled out: `import reqs` upserts by id, but
195
+ // capabilities.jsonl has no import path at all today, so hand-editing is
196
+ // currently the only way a row lands there, and nothing stops two rows
197
+ // from hand-editing into the same identity with different content. A
198
+ // gate whose soundness depends on another gate having run first is not
199
+ // independently a gate (see Task 10's inverted citation range for the
200
+ // same reasoning). Counted off the raw row arrays, not the `Set`s above:
201
+ // a `Set` collapses duplicates by construction, which is exactly the
202
+ // evidence (which id, how many rows) this check exists to preserve.
203
+ function duplicatesOf(values: string[]): Map<string, number> {
204
+ const counts = new Map<string, number>()
205
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1)
206
+ const dups = new Map<string, number>()
207
+ for (const [v, count] of counts) {
208
+ if (count > 1) dups.set(v, count)
209
+ }
210
+ return dups
211
+ }
212
+ for (const [id, count] of duplicatesOf(requirements.map((r) => r.id))) {
213
+ violations.push({
214
+ gate: 'refs',
215
+ message: `requirement id ${id} appears ${count} times in requirements.jsonl`,
216
+ })
217
+ }
218
+ for (const [slug, count] of duplicatesOf(capabilities.map((c) => c.slug))) {
219
+ violations.push({
220
+ gate: 'refs',
221
+ message: `capability slug ${slug} appears ${count} times in capabilities.jsonl`,
222
+ })
223
+ }
224
+ for (const [id, count] of duplicatesOf(elements.map((e) => e.id))) {
225
+ violations.push({
226
+ gate: 'refs',
227
+ message: `element id ${id} appears ${count} times in elements.jsonl`,
228
+ })
229
+ }
230
+
231
+ // `field` names where the queue reference came from (`disposition.queue`,
232
+ // `confidence.queue`, `parity.queue`) so that one requirement citing the
233
+ // same missing queue id from two different fields produces two
234
+ // violations that read as two distinct citations to fix, not one
235
+ // ambiguous duplicate-looking line.
236
+ const needQueue = (id: string, owner: string, field: string): void => {
237
+ if (!queueIds.has(id)) {
238
+ violations.push({
239
+ gate: 'refs',
240
+ message: `${owner} references queue item ${id} via ${field}, which does not exist`,
241
+ })
242
+ }
243
+ }
244
+ for (const el of elements) {
245
+ if (el.disposition.kind === 'mapped' && !reqIds.has(el.disposition.fr)) {
246
+ violations.push({
247
+ gate: 'refs',
248
+ message: `${el.id} is mapped to ${el.disposition.fr}, which is not in the registry`,
249
+ })
250
+ }
251
+ if (el.disposition.kind === 'out-of-scope') {
252
+ needQueue(el.disposition.queue, el.id, 'disposition.queue')
253
+ }
254
+ }
255
+ for (const req of requirements) {
256
+ if (!capSlugs.has(req.cap)) {
257
+ violations.push({
258
+ gate: 'refs',
259
+ message: `${req.id} names capability ${req.cap}, which is not in the partition`,
260
+ })
261
+ }
262
+ if (req.confidence.kind === 'queued')
263
+ needQueue(req.confidence.queue, req.id, 'confidence.queue')
264
+ if (req.parity?.kind === 'rubric' && req.parity.level !== 'high') {
265
+ needQueue(req.parity.queue, req.id, 'parity.queue')
266
+ }
267
+ for (const ref of req.citations) {
268
+ if (ref.kind === 'ledger' && !elementIds.has(ref.id)) {
269
+ violations.push({
270
+ gate: 'refs',
271
+ message: `${req.id} cites ledger id ${ref.id}, which is not in the ledger`,
272
+ })
273
+ }
274
+ }
275
+ }
276
+
277
+ // Gate 4: queue grammar.
278
+ for (const message of queueErrors) violations.push({ gate: 'queue', message })
279
+
280
+ // Gate 5: deltas.
281
+ for (const delta of deltas) {
282
+ if (!delta.owner_signed) {
283
+ violations.push({ gate: 'deltas', message: `${delta.id} is not owner-signed` })
284
+ }
285
+ }
286
+
287
+ // Gate 6: parity coverage.
288
+ for (const req of requirements) {
289
+ if (req.confidence.kind !== 'queued' && req.parity === null) {
290
+ violations.push({ gate: 'parity', message: `${req.id} has no parity plan` })
291
+ }
292
+ }
293
+
294
+ // Gate 7: citations. Citations are on unless explicitly disabled. An FR
295
+ // citing a path that does not exist is the never-fabricate rule's only
296
+ // mechanical expression, so it should not be something a run has to
297
+ // remember to ask for.
298
+ if (opts.citations !== false) {
299
+ violations.push(...(await resolveCitations(requirements, cfg.source.path)))
300
+ }
301
+
302
+ // Gate 8: leaks, opt-in.
303
+ if (opts.leaks) {
304
+ violations.push(...(await scanLeaks({ root: opts.root, gitBin })))
305
+ }
306
+
307
+ // Gate 9: source integrity.
308
+ if (await sourceIsDirty(cfg.source.path, gitBin)) {
309
+ violations.push({
310
+ gate: 'source',
311
+ message: `the source checkout at ${cfg.source.path} has uncommitted changes; it must stay read-only`,
312
+ })
313
+ }
314
+
315
+ // Gate 10: run-state. Every other gate proves the store is internally
316
+ // consistent, which an empty store satisfies. This one asks whether the run
317
+ // that was supposed to fill it actually happened, which is the only reason
318
+ // exit 0 can mean "complete" rather than "nothing contradicts anything".
319
+ const phases = await loadPhases(opts.root)
320
+ const terminus = opts.phase ? PHASES.indexOf(opts.phase) : PHASES.length - 1
321
+ const terminusName = PHASES[terminus]
322
+ for (let i = 0; i <= terminus; i++) {
323
+ const p = PHASES[i]
324
+ if (!p) continue
325
+ const status = phases[p].status
326
+ if (status !== 'done') {
327
+ violations.push({
328
+ gate: 'run-state',
329
+ message: `phase ${p} is ${status}; every phase through ${terminusName} must be done`,
330
+ })
331
+ }
332
+ }
333
+ // Checked across all eight phases, not just up to the terminus: a later
334
+ // phase marked done over a pending predecessor is hand-edited state, and it
335
+ // is worth naming whether or not the caller asked about that phase.
336
+ for (let i = 1; i < PHASES.length; i++) {
337
+ const current = PHASES[i]
338
+ const previous = PHASES[i - 1]
339
+ if (!current || !previous) continue
340
+ if (phases[current].status === 'done' && phases[previous].status === 'pending') {
341
+ violations.push({
342
+ gate: 'run-state',
343
+ message: `phase ${current} is done while ${previous} is still pending`,
344
+ })
345
+ }
346
+ }
347
+ // A census record naming a batch phases.json never committed means the two
348
+ // disagree about what happened. Only checked when the record exists, since
349
+ // gate 2 already names a declared surface or closer that has none.
350
+ const committedIn = (phase: Phase): Set<string> => new Set(phases[phase].batches.map((b) => b.id))
351
+ const enumerateBatches = committedIn('enumerate')
352
+ for (const surface of cfg.surfaces) {
353
+ const record = census.find((r) => r.kind === 'lens' && r.surface === surface)
354
+ if (record && !enumerateBatches.has(record.batch)) {
355
+ violations.push({
356
+ gate: 'run-state',
357
+ message: `lens census for ${surface} names batch ${record.batch}, which phases.json has no record of committing in enumerate`,
358
+ })
359
+ }
360
+ }
361
+ const extractBatches = committedIn('extract')
362
+ for (const closer of cfg.closers) {
363
+ const record = census.find((r) => r.kind === 'closer' && r.closer === closer)
364
+ if (record && !extractBatches.has(record.batch)) {
365
+ violations.push({
366
+ gate: 'run-state',
367
+ message: `closer census for ${closer} names batch ${record.batch}, which phases.json has no record of committing in extract`,
368
+ })
369
+ }
370
+ }
371
+
372
+ const order = new Map(GATE_ORDER.map((g, i) => [g as string, i]))
373
+ violations.sort((a, b) => (order.get(a.gate) ?? 99) - (order.get(b.gate) ?? 99))
374
+
375
+ return { summary, violations }
376
+ }
@@ -0,0 +1,92 @@
1
+ import { readFile, realpath } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+ import { isContained } from './paths.ts'
4
+ import type { Requirement, Violation } from './types.ts'
5
+
6
+ async function lineCount(path: string, cache: Map<string, number>): Promise<number> {
7
+ const cached = cache.get(path)
8
+ if (cached !== undefined) return cached
9
+ const text = await readFile(path, 'utf8')
10
+ // A trailing newline does not add a line: "a\nb\n" is two lines.
11
+ const count = text.length === 0 ? 0 : text.replace(/\n$/, '').split('\n').length
12
+ cache.set(path, count)
13
+ return count
14
+ }
15
+
16
+ export async function resolveCitations(
17
+ reqs: Requirement[],
18
+ sourcePath: string,
19
+ ): Promise<Violation[]> {
20
+ const violations: Violation[] = []
21
+ const cache = new Map<string, number>()
22
+ const sourceRoot = resolve(sourcePath)
23
+ let realSourceRoot: string
24
+ try {
25
+ realSourceRoot = await realpath(sourceRoot)
26
+ } catch {
27
+ // If sourceRoot itself cannot be resolved, fail cleanly
28
+ return [{ gate: 'citations', message: 'source path does not exist' }]
29
+ }
30
+
31
+ for (const req of reqs) {
32
+ for (const ref of req.citations) {
33
+ if (ref.kind !== 'src') continue
34
+
35
+ // Reject absolute paths
36
+ if (ref.path.startsWith('/') || /^[a-z]:/i.test(ref.path)) {
37
+ violations.push({
38
+ gate: 'citations',
39
+ message: `${req.id} cites ${ref.path}, which is an absolute path; citations must use relative paths`,
40
+ })
41
+ continue
42
+ }
43
+
44
+ const abs = resolve(join(sourceRoot, ref.path))
45
+ if (!isContained(abs, sourceRoot)) {
46
+ violations.push({
47
+ gate: 'citations',
48
+ message: `${req.id} cites ${ref.path}, which is outside the source tree`,
49
+ })
50
+ continue
51
+ }
52
+
53
+ // Resolve real path (following symlinks, detecting broken ones)
54
+ let realPath: string
55
+ try {
56
+ realPath = await realpath(abs)
57
+ } catch {
58
+ violations.push({
59
+ gate: 'citations',
60
+ message: `${req.id} cites ${ref.path}, which does not exist in the source tree`,
61
+ })
62
+ continue
63
+ }
64
+
65
+ // Check containment of the real path against the real sourceRoot
66
+ if (!isContained(realPath, realSourceRoot)) {
67
+ violations.push({
68
+ gate: 'citations',
69
+ message: `${req.id} cites ${ref.path}, which is outside the source tree`,
70
+ })
71
+ continue
72
+ }
73
+
74
+ if (ref.lines) {
75
+ const total = await lineCount(realPath, cache)
76
+ const [start, end] = ref.lines
77
+ if (start > end) {
78
+ violations.push({
79
+ gate: 'citations',
80
+ message: `${req.id} cites ${ref.path}:${start}-${end} but the range is inverted`,
81
+ })
82
+ } else if (start < 1 || end > total) {
83
+ violations.push({
84
+ gate: 'citations',
85
+ message: `${req.id} cites ${ref.path}:${start}-${end} but the file has ${total} line(s)`,
86
+ })
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return violations
92
+ }