@iceinvein/agent-skills 0.2.0 → 0.4.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 (68) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/index.js +14 -10
  3. package/package.json +1 -1
  4. package/skills/index.json +4 -4
  5. package/skills/migrate/README.md +35 -23
  6. package/skills/migrate/SKILL.md +75 -15
  7. package/skills/migrate/bin/migrate.ts +90 -0
  8. package/skills/migrate/docs/architecture.md +61 -26
  9. package/skills/migrate/docs/reference.md +53 -8
  10. package/skills/migrate/fixtures/fake-gh.ts +113 -0
  11. package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
  12. package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
  13. package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
  14. package/skills/migrate/package.json +1 -1
  15. package/skills/migrate/references/phases/adjudicate.md +161 -0
  16. package/skills/migrate/references/phases/handoff.md +220 -0
  17. package/skills/migrate/references/phases/probe.md +2 -2
  18. package/skills/migrate/references/phases/queue.md +21 -14
  19. package/skills/migrate/references/run-ops.md +17 -13
  20. package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
  21. package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
  22. package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
  23. package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
  24. package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
  25. package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
  26. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
  27. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
  28. package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
  29. package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
  30. package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
  31. package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
  32. package/skills/migrate/scripts/adapters/flow.ts +280 -0
  33. package/skills/migrate/scripts/adapters/github.ts +260 -0
  34. package/skills/migrate/scripts/adapters/markdown.ts +175 -0
  35. package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
  36. package/skills/migrate/scripts/assumptions.ts +188 -0
  37. package/skills/migrate/scripts/check.ts +119 -320
  38. package/skills/migrate/scripts/coverage-cmd.ts +86 -0
  39. package/skills/migrate/scripts/coverage.ts +151 -0
  40. package/skills/migrate/scripts/dates.ts +17 -0
  41. package/skills/migrate/scripts/forecast-cmd.ts +124 -0
  42. package/skills/migrate/scripts/forecast.ts +264 -0
  43. package/skills/migrate/scripts/gates/adjudication.ts +30 -0
  44. package/skills/migrate/scripts/gates/census.ts +107 -0
  45. package/skills/migrate/scripts/gates/citations.ts +11 -0
  46. package/skills/migrate/scripts/gates/context.ts +76 -0
  47. package/skills/migrate/scripts/gates/coverage.ts +22 -0
  48. package/skills/migrate/scripts/gates/deltas.ts +15 -0
  49. package/skills/migrate/scripts/gates/handoff.ts +145 -0
  50. package/skills/migrate/scripts/gates/leaks.ts +11 -0
  51. package/skills/migrate/scripts/gates/parity.ts +15 -0
  52. package/skills/migrate/scripts/gates/queue.ts +9 -0
  53. package/skills/migrate/scripts/gates/refs.ts +97 -0
  54. package/skills/migrate/scripts/gates/run-state.ts +67 -0
  55. package/skills/migrate/scripts/gates/source.ts +28 -0
  56. package/skills/migrate/scripts/handoff-cmd.ts +186 -0
  57. package/skills/migrate/scripts/handoff.ts +330 -0
  58. package/skills/migrate/scripts/paths.ts +4 -0
  59. package/skills/migrate/scripts/types.ts +43 -0
  60. package/skills/migrate/scripts/validate.ts +12 -0
  61. package/skills/migrate/skill.json +2 -2
  62. package/skills/migrate/templates/forecast-assumptions.md +59 -0
  63. package/skills/sluice/SKILL.md +20 -7
  64. package/skills/sluice/references/deep-channel.md +20 -0
  65. package/skills/sluice/references/finish.md +4 -2
  66. package/skills/sluice/references/meter.md +38 -0
  67. package/skills/sluice/scripts/run-stats.sh +236 -0
  68. package/skills/sluice/skill.json +4 -3
@@ -1,35 +1,24 @@
1
- import { existsSync } from 'node:fs'
2
- import { balanceOf, boundsOf, censusKey, validateCensus } from './census.ts'
3
- import { resolveCitations } from './citations.ts'
1
+ import { validateCensus } from './census.ts'
4
2
  import { loadConfig } from './config.ts'
5
- import { scanLeaks } from './leaks.ts'
3
+ import { gate as adjudicationGate } from './gates/adjudication.ts'
4
+ import { gate as censusGate } from './gates/census.ts'
5
+ import { gate as citationsGate } from './gates/citations.ts'
6
+ import type { CensusRow, Gate, GateContext } from './gates/context.ts'
7
+ import { gate as coverageGate } from './gates/coverage.ts'
8
+ import { gate as deltasGate } from './gates/deltas.ts'
9
+ import { gate as handoffGate } from './gates/handoff.ts'
10
+ import { gate as leaksGate } from './gates/leaks.ts'
11
+ import { gate as parityGate } from './gates/parity.ts'
12
+ import { gate as queueGate } from './gates/queue.ts'
13
+ import { gate as refsGate } from './gates/refs.ts'
14
+ import { gate as runStateGate } from './gates/run-state.ts'
15
+ import { gate as sourceGate } from './gates/source.ts'
16
+ import { loadHandoff } from './handoff.ts'
6
17
  import { storePaths } from './paths.ts'
7
18
  import { loadPhases, PHASES, type Phase } from './phases.ts'
8
19
  import { loadQueue } from './queue.ts'
9
20
  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
- }
21
+ import type { Capability, Delta, Element, Requirement, Violation } from './types.ts'
33
22
 
34
23
  export type CheckResult = { summary: string; violations: Violation[] }
35
24
 
@@ -44,18 +33,56 @@ export const GATE_ORDER = [
44
33
  'leaks',
45
34
  'source',
46
35
  'run-state',
36
+ 'adjudication',
37
+ 'handoff',
47
38
  ] as const
48
39
 
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',
40
+ // A gate may declare the earliest phase at which it becomes applicable, and is
41
+ // skipped when the checked terminus has not reached it.
42
+ //
43
+ // Every other gate reads the whole store regardless of --phase, which is right
44
+ // for them: a coverage or census gap at phase 5 is a real failure whenever it
45
+ // is found. These two describe phases 6 and 7, so firing them unconditionally
46
+ // would make `migrate check --phase queue` red for an entire mid-run campaign,
47
+ // which is the exact failure the posture split exists to prevent. The rule is
48
+ // deliberately narrow: a gate is skipped only when it describes work the
49
+ // checked terminus has not reached.
50
+ const GATE_PHASE: Partial<Record<(typeof GATE_ORDER)[number], Phase>> = {
51
+ adjudication: 'adjudicate',
52
+ handoff: 'handoff',
53
+ }
54
+
55
+ const GATES: Record<(typeof GATE_ORDER)[number], Gate> = {
56
+ coverage: coverageGate,
57
+ census: censusGate,
58
+ refs: refsGate,
59
+ queue: queueGate,
60
+ deltas: deltasGate,
61
+ parity: parityGate,
62
+ citations: citationsGate,
63
+ leaks: leaksGate,
64
+ source: sourceGate,
65
+ 'run-state': runStateGate,
66
+ adjudication: adjudicationGate,
67
+ handoff: handoffGate,
68
+ }
69
+
70
+ // census.jsonl is the one store file that cannot be assumed to have been
71
+ // written by census-cmd.ts, since nothing stops a hand edit, and readRows only
72
+ // asserts a type onto whatever JSON.parse returns rather than checking it. So
73
+ // every row goes through the same validateCensus every real write goes
74
+ // through, once, here. Two gates consume the result and neither re-derives it:
75
+ // gate 2 reports the shape failures and does the arithmetic, gate 10
76
+ // cross-checks the valid records' batch fields. Doing it in one place is also
77
+ // what keeps a gate from depending on another gate having run first. File
78
+ // order is preserved, because gate 2's messages follow it.
79
+ function validateRows(rows: { line: number; raw: unknown }[]): CensusRow[] {
80
+ return rows.map(({ line, raw }) => {
81
+ const result = validateCensus(raw)
82
+ return result.ok
83
+ ? ({ ok: true, line, record: result.value } as const)
84
+ : ({ ok: false, line, raw, errors: result.errors } as const)
55
85
  })
56
- const out = await new Response(proc.stdout).text()
57
- await proc.exited
58
- return out.trim().length > 0
59
86
  }
60
87
 
61
88
  export async function runCheck(opts: {
@@ -73,302 +100,74 @@ export async function runCheck(opts: {
73
100
  const requirements = await readRows<Requirement>(paths.requirements)
74
101
  const capabilities = await readRows<Capability>(paths.capabilities)
75
102
  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)
103
+ const censusRows = validateRows(await readRawRows(paths.census))
84
104
  const { items: queueItems, errors: queueErrors } = await loadQueue(paths.queueDir)
105
+ const phases = await loadPhases(opts.root)
85
106
 
86
- const violations: Violation[] = []
107
+ const terminusIndex = opts.phase ? PHASES.indexOf(opts.phase) : PHASES.length - 1
108
+ // A gate is skipped only when NOTHING claims its phase was reached. Keyed on
109
+ // the furthest phase `phases.json` marks done as well as on the requested
110
+ // terminus, because a store whose own state file says it reached handoff,
111
+ // with nothing emitted, must not be able to hide that by being checked at an
112
+ // earlier terminus. This mirrors run-state's done-over-pending check, which
113
+ // deliberately looks across all eight phases regardless of terminus on the
114
+ // grounds that hand-edited state is worth naming whether or not the caller
115
+ // asked about it.
116
+ let furthestDone = -1
117
+ PHASES.forEach((phase, i) => {
118
+ if (phases[phase].status === 'done') furthestDone = i
119
+ })
120
+ const scopeIndex = Math.max(terminusIndex, furthestDone)
121
+
122
+ const ctx: GateContext = {
123
+ root: opts.root,
124
+ cfg,
125
+ paths,
126
+ elements,
127
+ requirements,
128
+ capabilities,
129
+ deltas,
130
+ censusRows,
131
+ handoff: undefined,
132
+ queueItems,
133
+ queueErrors,
134
+ phases,
135
+ terminusIndex,
136
+ citations: opts.citations !== false,
137
+ leaks: opts.leaks === true,
138
+ gitBin,
139
+ }
87
140
 
88
- // Gate 1: coverage.
141
+ const violations: Violation[] = []
142
+ for (const name of GATE_ORDER) {
143
+ const from = GATE_PHASE[name]
144
+ if (from && PHASES.indexOf(from) > scopeIndex) continue
145
+ // Loaded here rather than with the rest of the store: the only consumer is
146
+ // the handoff gate, and reading it eagerly meant a corrupt handoff.json
147
+ // killed every bounded check, plus `status` and `report`, which call
148
+ // runCheck purely for the summary and never look at this file.
149
+ if (name === 'handoff' && ctx.handoff === undefined) ctx.handoff = await loadHandoff(opts.root)
150
+ violations.push(...(await GATES[name](ctx)))
151
+ }
152
+
153
+ // Built here rather than inside the coverage gate: report-cmd.ts reads this
154
+ // out of CheckResult on a store that is expected to still have violations,
155
+ // so it is a property of the run rather than something a gate returns.
89
156
  let mapped = 0
90
157
  let outOfScope = 0
91
158
  let unaccounted = 0
92
159
  for (const el of elements) {
93
160
  if (el.disposition.kind === 'mapped') mapped++
94
161
  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
- }
162
+ else unaccounted++
102
163
  }
103
164
  const summary = `${mapped}/${elements.length} mapped, ${outOfScope} out-of-scope, ${unaccounted} unaccounted`
104
165
 
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
-
166
+ // The gates already run in GATE_ORDER, so this sort is only load-bearing for
167
+ // violations a gate did not label with its own name. Kept because the gate
168
+ // field is what check-cmd.ts groups its output by, and a mislabelled
169
+ // violation should still land in a predictable place rather than wherever
170
+ // its producing gate happened to sit.
372
171
  const order = new Map(GATE_ORDER.map((g, i) => [g as string, i]))
373
172
  violations.sort((a, b) => (order.get(a.gate) ?? 99) - (order.get(b.gate) ?? 99))
374
173
 
@@ -0,0 +1,86 @@
1
+ import { flow } from './adapters/flow.ts'
2
+ import { github } from './adapters/github.ts'
3
+ import { markdown } from './adapters/markdown.ts'
4
+ import { loadConfig } from './config.ts'
5
+ import { computeCoverage, renderCoverage } from './coverage.ts'
6
+ import { type Adapter, type HandoffInput, loadHandoff } from './handoff.ts'
7
+ import { storePaths } from './paths.ts'
8
+ import { readRows } from './store.ts'
9
+ import type { Capability, Delta, Requirement } from './types.ts'
10
+
11
+ const ADAPTERS: Record<string, Adapter> = { markdown, github, flow }
12
+
13
+ export async function runCoverage(opts: {
14
+ root: string
15
+ adapter?: string
16
+ gitBin?: string
17
+ ghBin?: string
18
+ }): Promise<number> {
19
+ const cfg = await loadConfig(opts.root)
20
+ const name = opts.adapter ?? cfg.handoff.adapter
21
+ const adapter = ADAPTERS[name]
22
+ if (!adapter) {
23
+ process.stderr.write(
24
+ `coverage: unknown adapter ${name}; want one of ${Object.keys(ADAPTERS).sort().join(', ')}\n`,
25
+ )
26
+ return 2
27
+ }
28
+
29
+ const loaded = await loadHandoff(opts.root)
30
+ if (loaded.kind === 'invalid') {
31
+ for (const e of loaded.errors) process.stderr.write(`coverage: handoff.json ${e}\n`)
32
+ return 1
33
+ }
34
+ const handoff = loaded.kind === 'ok' ? loaded.value : null
35
+ if (!handoff) {
36
+ // Not zero built. There is no denominator at all, because nothing has been
37
+ // emitted, and reporting 0/0 would read as a measurement.
38
+ process.stderr.write(
39
+ 'coverage: no handoff.json in the store; run `migrate handoff` before reading progress back\n',
40
+ )
41
+ return 1
42
+ }
43
+
44
+ if (!adapter.throughput) {
45
+ // Named rather than reported as zero built: "this adapter cannot tell you"
46
+ // and "nothing has been delivered" are very different claims.
47
+ process.stderr.write(
48
+ `coverage: adapter ${name} reports no throughput, so built-versus-total cannot be read back through it\n`,
49
+ )
50
+ return 1
51
+ }
52
+
53
+ const paths = storePaths(opts.root)
54
+ const input: HandoffInput = {
55
+ requirements: await readRows<Requirement>(paths.requirements),
56
+ capabilities: await readRows<Capability>(paths.capabilities),
57
+ deltas: await readRows<Delta>(paths.deltas),
58
+ config: cfg,
59
+ root: opts.root,
60
+ gitBin: opts.gitBin ?? 'git',
61
+ ghBin: opts.ghBin ?? 'gh',
62
+ }
63
+
64
+ let throughput: Awaited<ReturnType<NonNullable<Adapter['throughput']>>>
65
+ try {
66
+ throughput = await adapter.throughput(input)
67
+ } catch (e) {
68
+ process.stderr.write(`coverage: ${(e as Error).message}\n`)
69
+ return 1
70
+ }
71
+
72
+ const report = computeCoverage({ requirements: input.requirements, handoff, throughput })
73
+ process.stdout.write(`${renderCoverage(report)}\n`)
74
+
75
+ if (report.unknown.length > 0) {
76
+ // The emitted work and the store have diverged: something out there is
77
+ // reporting progress on a requirement this store has never heard of.
78
+ for (const fr of report.unknown) {
79
+ process.stderr.write(
80
+ `coverage: ${fr} was reported complete but is not in the registry; the emitted work and the store have diverged\n`,
81
+ )
82
+ }
83
+ return 1
84
+ }
85
+ return 0
86
+ }