@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
@@ -0,0 +1,107 @@
1
+ import { balanceOf, boundsOf, censusKey } from '../census.ts'
2
+ import type { Census, Violation } from '../types.ts'
3
+ import { isRecord } from '../validate.ts'
4
+ import type { Gate } from './context.ts'
5
+
6
+ // A hand-edited census.jsonl never passes through census-cmd.ts's
7
+ // validateCensus call, so this is the only label available for a row that
8
+ // fails the check below: its 1-based position in the file, plus (when kind
9
+ // and the field censusKey needs both parse as strings) the same key
10
+ // census-cmd.ts uses to identify a record, so the message points at
11
+ // something the reader can find rather than an opaque line number alone.
12
+ function censusRowLabel(raw: unknown, line: number): string {
13
+ const base = `census.jsonl line ${line}`
14
+ if (!isRecord(raw)) return base
15
+ const kind = raw.kind
16
+ if (kind === 'lens' && typeof raw.surface === 'string')
17
+ return `${base} (${censusKey(raw as Census)})`
18
+ if (kind === 'attribute' && typeof raw.subject === 'string')
19
+ return `${base} (${censusKey(raw as Census)})`
20
+ if (kind === 'rule-sweep' && typeof raw.subject === 'string')
21
+ return `${base} (${censusKey(raw as Census)})`
22
+ if (kind === 'closer' && typeof raw.closer === 'string')
23
+ return `${base} (${censusKey(raw as Census)})`
24
+ return base
25
+ }
26
+
27
+ // Gate 2: census shape, balance and presence. Rows are validated in runCheck,
28
+ // not here, because gate 10 needs the same partition; this gate reports the
29
+ // shape failures and does the arithmetic. A row that failed validation is
30
+ // excluded from balanceOf, boundsOf, and the in_ledger + added reconciliation:
31
+ // all three assume a well-formed record, so running them on one that already
32
+ // failed shape validation would add confusing noise on top of the real defect
33
+ // rather than a second independent fact.
34
+ export const gate: Gate = (ctx): Violation[] => {
35
+ const violations: Violation[] = []
36
+ const surfacesWithCensus = new Set<string>()
37
+ const closersWithCensus = new Set<string>()
38
+
39
+ for (const row of ctx.censusRows) {
40
+ if (!row.ok) {
41
+ const label = censusRowLabel(row.raw, row.line)
42
+ for (const error of row.errors) {
43
+ violations.push({ gate: 'census', message: `${label}: ${error}` })
44
+ }
45
+ // A row that fails validation still ran and still named a surface or
46
+ // closer it claims to cover; only its shape is defective, not its
47
+ // existence. Registered defensively here (guarded the same way
48
+ // censusRowLabel is, since the row is not a trustworthy Census) so
49
+ // gate 2 does not also claim that surface or closer has no census
50
+ // record at all, which is a different and wrong accusation: that
51
+ // message means the lens never ran or never closed, not that it ran
52
+ // and produced something malformed. A row whose kind or identity
53
+ // field does not even parse as a string cannot make this claim, so it
54
+ // falls through to the genuinely-missing check below unregistered.
55
+ if (isRecord(row.raw)) {
56
+ if (row.raw.kind === 'lens' && typeof row.raw.surface === 'string') {
57
+ surfacesWithCensus.add(row.raw.surface)
58
+ } else if (row.raw.kind === 'closer' && typeof row.raw.closer === 'string') {
59
+ closersWithCensus.add(row.raw.closer)
60
+ }
61
+ }
62
+ continue
63
+ }
64
+ const record = row.record
65
+ const imbalance = balanceOf(record)
66
+ if (imbalance) violations.push({ gate: 'census', message: imbalance })
67
+ const outOfBounds = boundsOf(record)
68
+ if (outOfBounds) violations.push({ gate: 'census', message: outOfBounds })
69
+ if (record.kind === 'lens') {
70
+ surfacesWithCensus.add(record.surface)
71
+ // balanceOf only checks that the record's own numbers add up
72
+ // internally; nothing before this ties in_ledger + added to anything
73
+ // outside the record itself, so a lens census can balance perfectly
74
+ // while claiming a headcount elements.jsonl never received (total is
75
+ // self-reported and cannot be checked against anything, but in_ledger
76
+ // + added claims a specific number of rows now exist in the ledger
77
+ // for this surface, and that claim is directly countable).
78
+ const claimed = record.in_ledger + record.added
79
+ const actual = ctx.elements.filter((e) => e.surface === record.surface).length
80
+ if (actual !== claimed) {
81
+ violations.push({
82
+ gate: 'census',
83
+ 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}`,
84
+ })
85
+ }
86
+ }
87
+ if (record.kind === 'closer') closersWithCensus.add(record.closer)
88
+ }
89
+
90
+ for (const surface of ctx.cfg.surfaces) {
91
+ if (!surfacesWithCensus.has(surface)) {
92
+ violations.push({
93
+ gate: 'census',
94
+ message: `declared surface ${surface} has no lens census record; the lens did not run or did not close`,
95
+ })
96
+ }
97
+ }
98
+ for (const closer of ctx.cfg.closers) {
99
+ if (!closersWithCensus.has(closer)) {
100
+ violations.push({
101
+ gate: 'census',
102
+ message: `declared closer ${closer} has no census record`,
103
+ })
104
+ }
105
+ }
106
+ return violations
107
+ }
@@ -0,0 +1,11 @@
1
+ import { resolveCitations } from '../citations.ts'
2
+ import type { Violation } from '../types.ts'
3
+ import type { Gate } from './context.ts'
4
+
5
+ // Gate 7: citations. On unless explicitly disabled. An FR citing a path that
6
+ // does not exist is the never-fabricate rule's only mechanical expression, so
7
+ // it should not be something a run has to remember to ask for.
8
+ export const gate: Gate = async (ctx): Promise<Violation[]> => {
9
+ if (!ctx.citations) return []
10
+ return resolveCitations(ctx.requirements, ctx.cfg.source.path)
11
+ }
@@ -0,0 +1,76 @@
1
+ import type { Config } from '../config.ts'
2
+ import type { LoadedHandoff } from '../handoff.ts'
3
+ import type { StorePaths } from '../paths.ts'
4
+ import type { Phase, PhaseState } from '../phases.ts'
5
+ import type {
6
+ Capability,
7
+ Census,
8
+ Delta,
9
+ Element,
10
+ QueueItem,
11
+ Requirement,
12
+ Violation,
13
+ } from '../types.ts'
14
+
15
+ // One census.jsonl row, validated, in file order. A failed row keeps its raw
16
+ // value and 1-based line: the line is the only label a hand-edited row has,
17
+ // and the raw value still names the surface or closer the row claims to
18
+ // cover, which gate 2 registers so it does not also report that surface as
19
+ // having no census record at all.
20
+ //
21
+ // Kept as one ordered list rather than split into valid and invalid halves,
22
+ // because gate 2 reports both kinds and its message order follows the file.
23
+ // Splitting them would group every shape error ahead of every arithmetic
24
+ // error, which reorders the output for any store holding both.
25
+ export type CensusRow =
26
+ | { ok: true; line: number; record: Census }
27
+ | { ok: false; line: number; raw: unknown; errors: string[] }
28
+
29
+ export function validCensus(rows: CensusRow[]): Census[] {
30
+ const out: Census[] = []
31
+ for (const row of rows) {
32
+ if (row.ok) out.push(row.record)
33
+ }
34
+ return out
35
+ }
36
+
37
+ // Everything the gates read, loaded once by runCheck and handed to each gate
38
+ // unchanged. A gate is a pure function of this record: it may not read the
39
+ // filesystem for store content, so the store is parsed exactly once per run
40
+ // no matter how many gates consult it.
41
+ //
42
+ // `censusRows` is the one field this record does work for rather than merely
43
+ // carrying. validateCensus runs once, in runCheck, because two gates need the
44
+ // result and neither should re-derive it: gate 2 reports the shape failures
45
+ // and does the balance arithmetic, and gate 10 cross-checks the valid records'
46
+ // batch fields against phases.json. Before the split those two shared a local
47
+ // variable inside one function; this is what replaces that sharing without
48
+ // making one gate depend on another having run first.
49
+ export type GateContext = {
50
+ root: string
51
+ cfg: Config
52
+ paths: StorePaths
53
+ elements: Element[]
54
+ requirements: Requirement[]
55
+ capabilities: Capability[]
56
+ deltas: Delta[]
57
+ censusRows: CensusRow[]
58
+ // Loaded lazily, immediately before the handoff gate runs, because it is the
59
+ // only gate that reads it and a corrupt file must not break the others.
60
+ // `undefined` means "not loaded yet"; every other state is a LoadedHandoff.
61
+ handoff: LoadedHandoff | undefined
62
+ queueItems: QueueItem[]
63
+ queueErrors: string[]
64
+ phases: Record<Phase, PhaseState>
65
+ // The index in PHASES of the last phase this invocation gates. Only the
66
+ // run-state gate reads it today; every other gate reads the whole store
67
+ // regardless, because a coverage or census gap is a real failure whenever it
68
+ // is found rather than only once the run claims to have reached that phase.
69
+ // An index rather than a name, because both consumers compare positions.
70
+ terminusIndex: number
71
+ citations: boolean
72
+ leaks: boolean
73
+ gitBin: string
74
+ }
75
+
76
+ export type Gate = (ctx: GateContext) => Violation[] | Promise<Violation[]>
@@ -0,0 +1,22 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 1: coverage. Every element must carry a terminal disposition.
5
+ //
6
+ // The summary line runCheck returns ("612/612 mapped, ...") counts the same
7
+ // three dispositions this gate walks, but it is built in check.ts rather than
8
+ // here, because report-cmd.ts reads it out of CheckResult on a store that is
9
+ // expected to have violations. A gate returns violations; the summary is a
10
+ // property of the run.
11
+ export const gate: Gate = (ctx): Violation[] => {
12
+ const violations: Violation[] = []
13
+ for (const el of ctx.elements) {
14
+ if (el.disposition.kind === 'mapped') continue
15
+ if (el.disposition.kind === 'out-of-scope') continue
16
+ violations.push({
17
+ gate: 'coverage',
18
+ message: `${el.id} (${el.surface}) is still unaccounted`,
19
+ })
20
+ }
21
+ return violations
22
+ }
@@ -0,0 +1,15 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 5: deltas. A sanctioned delta is only sanctioned once an owner has
5
+ // signed it; an unsigned row at gate time is a difference from the source
6
+ // nobody agreed to.
7
+ export const gate: Gate = (ctx): Violation[] => {
8
+ const violations: Violation[] = []
9
+ for (const delta of ctx.deltas) {
10
+ if (!delta.owner_signed) {
11
+ violations.push({ gate: 'deltas', message: `${delta.id} is not owner-signed` })
12
+ }
13
+ }
14
+ return violations
15
+ }
@@ -0,0 +1,145 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 12: handoff. Whether the ratified requirements actually reached the
5
+ // emitted work.
6
+ //
7
+ // This is what stops a plain `migrate check` from meaning no more than "the
8
+ // phases are marked done". Every other gate proves the store is internally
9
+ // consistent about phases 0 through 5, and gate 10 asks whether the run
10
+ // happened; without this one, a store could pass with handoff.json absent and
11
+ // nothing emitted anywhere at all.
12
+ //
13
+ // The honest limit is the same in kind as gate 10's. This proves the emitted
14
+ // work covers the store's requirements. It cannot prove the issues were read
15
+ // or the roadmap was believed.
16
+ export const gate: Gate = (ctx): Violation[] => {
17
+ const violations: Violation[] = []
18
+ const loaded = ctx.handoff
19
+ if (!loaded || loaded.kind === 'absent') {
20
+ return [
21
+ {
22
+ gate: 'handoff',
23
+ message:
24
+ 'no handoff.json in the store; handoff has not run, so nothing has reached a delivery medium',
25
+ },
26
+ ]
27
+ }
28
+ if (loaded.kind === 'invalid') {
29
+ // A present-but-unusable file is a different fact from an absent one, and
30
+ // saying "handoff has not run" about a file that exists would send an
31
+ // operator to re-run the command rather than to look at what is in it.
32
+ return loaded.errors.map((e) => ({
33
+ gate: 'handoff',
34
+ message: `handoff.json ${e}`,
35
+ }))
36
+ }
37
+ const handoff = loaded.value
38
+
39
+ const known = new Set(ctx.requirements.map((r) => r.id))
40
+ const keys = new Set(handoff.items.map((i) => i.key))
41
+ const emittedIds = new Set<string>()
42
+ const seenKeys = new Set<string>()
43
+ const seenFrs = new Set<string>()
44
+
45
+ for (const item of handoff.items) {
46
+ // Two work items under one key make `refs`, `dependsOn` and the coverage
47
+ // order all ambiguous, and a Set of keys hides the collision from every
48
+ // check below it.
49
+ if (seenKeys.has(item.key)) {
50
+ violations.push({
51
+ gate: 'handoff',
52
+ message: `work item key ${item.key} appears more than once`,
53
+ })
54
+ }
55
+ seenKeys.add(item.key)
56
+ if (handoff.refs[item.key] === undefined) {
57
+ // `refs` is the only evidence in this file that anything actually
58
+ // reached the medium; every adapter writes one entry per item.
59
+ violations.push({
60
+ gate: 'handoff',
61
+ message: `work item ${item.key} has no entry in refs, so nothing records where it was emitted`,
62
+ })
63
+ }
64
+ for (const fr of item.frs) {
65
+ if (seenFrs.has(fr)) {
66
+ // The emitted count is a sum over frs lengths while membership is a
67
+ // set, so without this one requirement in two work items satisfies
68
+ // both and is delivered twice.
69
+ violations.push({
70
+ gate: 'handoff',
71
+ message: `requirement ${fr} appears in more than one work item`,
72
+ })
73
+ }
74
+ seenFrs.add(fr)
75
+ emittedIds.add(fr)
76
+ if (!known.has(fr)) {
77
+ violations.push({
78
+ gate: 'handoff',
79
+ message: `work item ${item.key} names requirement ${fr}, which is not in the registry`,
80
+ })
81
+ }
82
+ }
83
+ for (const dep of item.dependsOn) {
84
+ if (dep === item.key) {
85
+ violations.push({
86
+ gate: 'handoff',
87
+ message: `work item ${item.key} depends on itself`,
88
+ })
89
+ } else if (!keys.has(dep)) {
90
+ violations.push({
91
+ gate: 'handoff',
92
+ message: `work item ${item.key} depends on ${dep}, which is not a work item`,
93
+ })
94
+ }
95
+ }
96
+ }
97
+
98
+ // basis.order drives coverage's whole per-capability walk, so an order that
99
+ // does not match the emitted items makes coverage report a denominator
100
+ // narrower than the store without saying so.
101
+ for (const slug of handoff.basis.order) {
102
+ if (!keys.has(slug)) {
103
+ violations.push({
104
+ gate: 'handoff',
105
+ message: `handoff basis order names ${slug}, which is not a work item`,
106
+ })
107
+ }
108
+ }
109
+ for (const key of keys) {
110
+ if (!handoff.basis.order.includes(key)) {
111
+ violations.push({
112
+ gate: 'handoff',
113
+ message: `work item ${key} is missing from the handoff basis order, so coverage would not count it`,
114
+ })
115
+ }
116
+ }
117
+
118
+ // Every requirement, not only the confirmed ones. An inferred requirement is
119
+ // something the build team must see and decide about, so handoff emits it;
120
+ // confidence starts mattering at the coverage denominator, not here.
121
+ for (const r of ctx.requirements) {
122
+ if (!emittedIds.has(r.id)) {
123
+ violations.push({
124
+ gate: 'handoff',
125
+ message: `${r.id} appears in no work item; re-run migrate handoff after the store changed`,
126
+ })
127
+ }
128
+ }
129
+
130
+ const confirmed = ctx.requirements.filter((r) => r.confidence.kind === 'confirmed').length
131
+ if (handoff.basis.confirmed !== confirmed) {
132
+ violations.push({
133
+ gate: 'handoff',
134
+ message: `handoff basis records ${handoff.basis.confirmed} confirmed requirement(s), but the store has ${confirmed}`,
135
+ })
136
+ }
137
+ const emitted = handoff.items.reduce((n, i) => n + i.frs.length, 0)
138
+ if (handoff.basis.emitted !== emitted) {
139
+ violations.push({
140
+ gate: 'handoff',
141
+ message: `handoff basis records ${handoff.basis.emitted} emitted requirement(s), but its work items carry ${emitted}`,
142
+ })
143
+ }
144
+ return violations
145
+ }
@@ -0,0 +1,11 @@
1
+ import { scanLeaks } from '../leaks.ts'
2
+ import type { Violation } from '../types.ts'
3
+ import type { Gate } from './context.ts'
4
+
5
+ // Gate 8: leaks, opt-in. Unlike citations, the scan shells out to `git log -S`
6
+ // per secret and its cost scales with history depth rather than with store
7
+ // size, which is why it stays behind a flag while citations do not.
8
+ export const gate: Gate = async (ctx): Promise<Violation[]> => {
9
+ if (!ctx.leaks) return []
10
+ return scanLeaks({ root: ctx.root, gitBin: ctx.gitBin })
11
+ }
@@ -0,0 +1,15 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 6: parity coverage. A requirement whose confidence is `queued` is
5
+ // waiting on an owner decision, so it is not yet expected to carry a parity
6
+ // plan; every other requirement is.
7
+ export const gate: Gate = (ctx): Violation[] => {
8
+ const violations: Violation[] = []
9
+ for (const req of ctx.requirements) {
10
+ if (req.confidence.kind !== 'queued' && req.parity === null) {
11
+ violations.push({ gate: 'parity', message: `${req.id} has no parity plan` })
12
+ }
13
+ }
14
+ return violations
15
+ }
@@ -0,0 +1,9 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // Gate 4: queue grammar. loadQueue does the parsing and collects one error
5
+ // string per grammar violation; this gate only labels them, because a queue
6
+ // file that will not parse is equally a problem for `queue list`, `report`
7
+ // and `adjudicate`, all of which call loadQueue directly.
8
+ export const gate: Gate = (ctx): Violation[] =>
9
+ ctx.queueErrors.map((message) => ({ gate: 'queue', message }))
@@ -0,0 +1,97 @@
1
+ import type { Violation } from '../types.ts'
2
+ import type { Gate } from './context.ts'
3
+
4
+ // A duplicate id or slug within one store file is a real defect the refs
5
+ // gate must catch on its own, not something it can assume another gate or
6
+ // command already ruled out: `import reqs` upserts by id, but
7
+ // capabilities.jsonl has no import path at all today, so hand-editing is
8
+ // currently the only way a row lands there, and nothing stops two rows
9
+ // from hand-editing into the same identity with different content. A
10
+ // gate whose soundness depends on another gate having run first is not
11
+ // independently a gate. Counted off the raw row arrays, not a `Set`:
12
+ // a `Set` collapses duplicates by construction, which is exactly the
13
+ // evidence (which id, how many rows) this check exists to preserve.
14
+ function duplicatesOf(values: string[]): Map<string, number> {
15
+ const counts = new Map<string, number>()
16
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1)
17
+ const dups = new Map<string, number>()
18
+ for (const [v, count] of counts) {
19
+ if (count > 1) dups.set(v, count)
20
+ }
21
+ return dups
22
+ }
23
+
24
+ // Gate 3: referential integrity.
25
+ export const gate: Gate = (ctx): Violation[] => {
26
+ const violations: Violation[] = []
27
+ const reqIds = new Set(ctx.requirements.map((r) => r.id))
28
+ const elementIds = new Set(ctx.elements.map((e) => e.id))
29
+ const capSlugs = new Set(ctx.capabilities.map((c) => c.slug))
30
+ const queueIds = new Set(ctx.queueItems.map((q) => q.id))
31
+
32
+ for (const [id, count] of duplicatesOf(ctx.requirements.map((r) => r.id))) {
33
+ violations.push({
34
+ gate: 'refs',
35
+ message: `requirement id ${id} appears ${count} times in requirements.jsonl`,
36
+ })
37
+ }
38
+ for (const [slug, count] of duplicatesOf(ctx.capabilities.map((c) => c.slug))) {
39
+ violations.push({
40
+ gate: 'refs',
41
+ message: `capability slug ${slug} appears ${count} times in capabilities.jsonl`,
42
+ })
43
+ }
44
+ for (const [id, count] of duplicatesOf(ctx.elements.map((e) => e.id))) {
45
+ violations.push({
46
+ gate: 'refs',
47
+ message: `element id ${id} appears ${count} times in elements.jsonl`,
48
+ })
49
+ }
50
+
51
+ // `field` names where the queue reference came from (`disposition.queue`,
52
+ // `confidence.queue`, `parity.queue`) so that one requirement citing the
53
+ // same missing queue id from two different fields produces two
54
+ // violations that read as two distinct citations to fix, not one
55
+ // ambiguous duplicate-looking line.
56
+ const needQueue = (id: string, owner: string, field: string): void => {
57
+ if (!queueIds.has(id)) {
58
+ violations.push({
59
+ gate: 'refs',
60
+ message: `${owner} references queue item ${id} via ${field}, which does not exist`,
61
+ })
62
+ }
63
+ }
64
+ for (const el of ctx.elements) {
65
+ if (el.disposition.kind === 'mapped' && !reqIds.has(el.disposition.fr)) {
66
+ violations.push({
67
+ gate: 'refs',
68
+ message: `${el.id} is mapped to ${el.disposition.fr}, which is not in the registry`,
69
+ })
70
+ }
71
+ if (el.disposition.kind === 'out-of-scope') {
72
+ needQueue(el.disposition.queue, el.id, 'disposition.queue')
73
+ }
74
+ }
75
+ for (const req of ctx.requirements) {
76
+ if (!capSlugs.has(req.cap)) {
77
+ violations.push({
78
+ gate: 'refs',
79
+ message: `${req.id} names capability ${req.cap}, which is not in the partition`,
80
+ })
81
+ }
82
+ if (req.confidence.kind === 'queued')
83
+ needQueue(req.confidence.queue, req.id, 'confidence.queue')
84
+ if (req.parity?.kind === 'rubric' && req.parity.level !== 'high') {
85
+ needQueue(req.parity.queue, req.id, 'parity.queue')
86
+ }
87
+ for (const ref of req.citations) {
88
+ if (ref.kind === 'ledger' && !elementIds.has(ref.id)) {
89
+ violations.push({
90
+ gate: 'refs',
91
+ message: `${req.id} cites ledger id ${ref.id}, which is not in the ledger`,
92
+ })
93
+ }
94
+ }
95
+ }
96
+ return violations
97
+ }
@@ -0,0 +1,67 @@
1
+ import { PHASES, type Phase } from '../phases.ts'
2
+ import type { Violation } from '../types.ts'
3
+ import { type Gate, validCensus } from './context.ts'
4
+
5
+ // Gate 10: run-state. Every other gate proves the store is internally
6
+ // consistent, which an empty store satisfies. This one asks whether the run
7
+ // that was supposed to fill it actually happened, which is the only reason
8
+ // exit 0 can mean "complete" rather than "nothing contradicts anything".
9
+ export const gate: Gate = (ctx): Violation[] => {
10
+ const violations: Violation[] = []
11
+ const terminusName = PHASES[ctx.terminusIndex]
12
+
13
+ for (let i = 0; i <= ctx.terminusIndex; i++) {
14
+ const p = PHASES[i]
15
+ if (!p) continue
16
+ const status = ctx.phases[p].status
17
+ if (status !== 'done') {
18
+ violations.push({
19
+ gate: 'run-state',
20
+ message: `phase ${p} is ${status}; every phase through ${terminusName} must be done`,
21
+ })
22
+ }
23
+ }
24
+
25
+ // Checked across all eight phases, not just up to the terminus: a later
26
+ // phase marked done over a pending predecessor is hand-edited state, and it
27
+ // is worth naming whether or not the caller asked about that phase.
28
+ for (let i = 1; i < PHASES.length; i++) {
29
+ const current = PHASES[i]
30
+ const previous = PHASES[i - 1]
31
+ if (!current || !previous) continue
32
+ if (ctx.phases[current].status === 'done' && ctx.phases[previous].status === 'pending') {
33
+ violations.push({
34
+ gate: 'run-state',
35
+ message: `phase ${current} is done while ${previous} is still pending`,
36
+ })
37
+ }
38
+ }
39
+
40
+ // A census record naming a batch phases.json never committed means the two
41
+ // disagree about what happened. Only checked when the record exists, since
42
+ // gate 2 already names a declared surface or closer that has none.
43
+ const committedIn = (phase: Phase): Set<string> =>
44
+ new Set(ctx.phases[phase].batches.map((b) => b.id))
45
+ const census = validCensus(ctx.censusRows)
46
+ const enumerateBatches = committedIn('enumerate')
47
+ for (const surface of ctx.cfg.surfaces) {
48
+ const record = census.find((r) => r.kind === 'lens' && r.surface === surface)
49
+ if (record && !enumerateBatches.has(record.batch)) {
50
+ violations.push({
51
+ gate: 'run-state',
52
+ message: `lens census for ${surface} names batch ${record.batch}, which phases.json has no record of committing in enumerate`,
53
+ })
54
+ }
55
+ }
56
+ const extractBatches = committedIn('extract')
57
+ for (const closer of ctx.cfg.closers) {
58
+ const record = census.find((r) => r.kind === 'closer' && r.closer === closer)
59
+ if (record && !extractBatches.has(record.batch)) {
60
+ violations.push({
61
+ gate: 'run-state',
62
+ message: `closer census for ${closer} names batch ${record.batch}, which phases.json has no record of committing in extract`,
63
+ })
64
+ }
65
+ }
66
+ return violations
67
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync } from 'node:fs'
2
+ import type { Violation } from '../types.ts'
3
+ import type { Gate } from './context.ts'
4
+
5
+ async function sourceIsDirty(sourcePath: string, gitBin: string): Promise<boolean> {
6
+ if (!existsSync(`${sourcePath}/.git`)) return false
7
+ const proc = Bun.spawn([gitBin, 'status', '--porcelain'], {
8
+ cwd: sourcePath,
9
+ stdout: 'pipe',
10
+ stderr: 'ignore',
11
+ })
12
+ const out = await new Response(proc.stdout).text()
13
+ await proc.exited
14
+ return out.trim().length > 0
15
+ }
16
+
17
+ // Gate 9: source integrity. A source that is not a git checkout cannot be
18
+ // checked this way and is not reported as a violation; the CLI's own refusal
19
+ // to write any path under the source root is what covers that case.
20
+ export const gate: Gate = async (ctx): Promise<Violation[]> => {
21
+ if (!(await sourceIsDirty(ctx.cfg.source.path, ctx.gitBin))) return []
22
+ return [
23
+ {
24
+ gate: 'source',
25
+ message: `the source checkout at ${ctx.cfg.source.path} has uncommitted changes; it must stay read-only`,
26
+ },
27
+ ]
28
+ }