cyber-sdd 0.0.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 (133) hide show
  1. package/.claude-plugin/plugin.json +17 -0
  2. package/.codex-plugin/plugin.json +17 -0
  3. package/.plugin/plugin.json +17 -0
  4. package/README.md +159 -0
  5. package/agents/sdd-automaton.md +97 -0
  6. package/agents/sdd-impl-judge.md +214 -0
  7. package/agents/sdd-scanner.md +120 -0
  8. package/agents/sdd-spec-judge.md +224 -0
  9. package/agents/sdd-warden.md +101 -0
  10. package/package.json +24 -0
  11. package/skills/align-spec/README.md +20 -0
  12. package/skills/align-spec/SKILL.md +111 -0
  13. package/skills/align-spec/scripts/align-spec.mts +187 -0
  14. package/skills/architect-impl-governance/README.md +46 -0
  15. package/skills/architect-impl-governance/SKILL.md +45 -0
  16. package/skills/architect-spec-governance/README.md +48 -0
  17. package/skills/architect-spec-governance/SKILL.md +59 -0
  18. package/skills/blast-estimate/README.md +47 -0
  19. package/skills/blast-estimate/SKILL.md +133 -0
  20. package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
  21. package/skills/builder-impl-governance/README.md +47 -0
  22. package/skills/builder-impl-governance/SKILL.md +47 -0
  23. package/skills/builder-spec-governance/README.md +49 -0
  24. package/skills/builder-spec-governance/SKILL.md +36 -0
  25. package/skills/check-partition-quality/README.md +22 -0
  26. package/skills/check-partition-quality/SKILL.md +51 -0
  27. package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
  28. package/skills/check-plan-safety/README.md +17 -0
  29. package/skills/check-plan-safety/SKILL.md +60 -0
  30. package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
  31. package/skills/check-project-specs/README.md +19 -0
  32. package/skills/check-project-specs/SKILL.md +69 -0
  33. package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
  34. package/skills/check-scenario-overlap/README.md +19 -0
  35. package/skills/check-scenario-overlap/SKILL.md +74 -0
  36. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
  37. package/skills/check-spec-structure/README.md +17 -0
  38. package/skills/check-spec-structure/SKILL.md +66 -0
  39. package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
  40. package/skills/collision-ladder/README.md +18 -0
  41. package/skills/collision-ladder/SKILL.md +83 -0
  42. package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
  43. package/skills/combat-log-governance/README.md +13 -0
  44. package/skills/combat-log-governance/SKILL.md +257 -0
  45. package/skills/concept-index/README.md +13 -0
  46. package/skills/concept-index/SKILL.md +38 -0
  47. package/skills/concept-index/scripts/concept-index.mts +245 -0
  48. package/skills/discover-plans/README.md +16 -0
  49. package/skills/discover-plans/SKILL.md +74 -0
  50. package/skills/discover-plans/scripts/discover-plans.mts +212 -0
  51. package/skills/discover-specs/README.md +15 -0
  52. package/skills/discover-specs/SKILL.md +76 -0
  53. package/skills/discover-specs/scripts/discover-specs.mts +396 -0
  54. package/skills/doctrine-loop/README.md +15 -0
  55. package/skills/doctrine-loop/SKILL.md +97 -0
  56. package/skills/formation-loop/README.md +17 -0
  57. package/skills/formation-loop/SKILL.md +140 -0
  58. package/skills/gate-validation-governance/README.md +12 -0
  59. package/skills/gate-validation-governance/SKILL.md +87 -0
  60. package/skills/impl-producer-governance/README.md +48 -0
  61. package/skills/impl-producer-governance/SKILL.md +85 -0
  62. package/skills/init/README.md +27 -0
  63. package/skills/init/SKILL.md +68 -0
  64. package/skills/init/scripts/wire-statusline.mts +276 -0
  65. package/skills/lifecycle-governance/README.md +11 -0
  66. package/skills/lifecycle-governance/SKILL.md +168 -0
  67. package/skills/manage/README.md +9 -0
  68. package/skills/manage/SKILL.md +62 -0
  69. package/skills/manage-ignore/README.md +19 -0
  70. package/skills/manage-ignore/SKILL.md +52 -0
  71. package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
  72. package/skills/manage-scenario-bridge/README.md +20 -0
  73. package/skills/manage-scenario-bridge/SKILL.md +60 -0
  74. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
  75. package/skills/manage-spec-anchors/README.md +18 -0
  76. package/skills/manage-spec-anchors/SKILL.md +56 -0
  77. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
  78. package/skills/mission-graph/README.md +15 -0
  79. package/skills/mission-graph/SKILL.md +67 -0
  80. package/skills/mission-graph/scripts/mission-graph.mts +844 -0
  81. package/skills/oracle-spec-governance/README.md +45 -0
  82. package/skills/oracle-spec-governance/SKILL.md +45 -0
  83. package/skills/ownership-governance/README.md +65 -0
  84. package/skills/ownership-governance/SKILL.md +104 -0
  85. package/skills/pause-mission/README.md +18 -0
  86. package/skills/pause-mission/SKILL.md +112 -0
  87. package/skills/place-node/README.md +12 -0
  88. package/skills/place-node/SKILL.md +47 -0
  89. package/skills/place-node/scripts/place-node.mts +157 -0
  90. package/skills/plan-retirement/README.md +32 -0
  91. package/skills/plan-retirement/SKILL.md +90 -0
  92. package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
  93. package/skills/plugin-contract-governance/README.md +12 -0
  94. package/skills/plugin-contract-governance/SKILL.md +112 -0
  95. package/skills/remediation-governance/README.md +46 -0
  96. package/skills/remediation-governance/SKILL.md +78 -0
  97. package/skills/resolve-governances/README.md +18 -0
  98. package/skills/resolve-governances/SKILL.md +50 -0
  99. package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
  100. package/skills/resolve-tracking/SKILL.md +64 -0
  101. package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
  102. package/skills/resume-mission/README.md +12 -0
  103. package/skills/resume-mission/SKILL.md +53 -0
  104. package/skills/scaffold-project-spec/README.md +7 -0
  105. package/skills/scaffold-project-spec/SKILL.md +192 -0
  106. package/skills/sdd/README.md +7 -0
  107. package/skills/sdd/SKILL.md +92 -0
  108. package/skills/solution-producer-governance/README.md +9 -0
  109. package/skills/solution-producer-governance/SKILL.md +44 -0
  110. package/skills/spec-format-governance/README.md +73 -0
  111. package/skills/spec-format-governance/SKILL.md +114 -0
  112. package/skills/spec-gate/README.md +26 -0
  113. package/skills/spec-gate/SKILL.md +201 -0
  114. package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
  115. package/skills/spec-gate/scripts/check-suite.mts +501 -0
  116. package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
  117. package/skills/spec-producer-governance/README.md +7 -0
  118. package/skills/spec-producer-governance/SKILL.md +86 -0
  119. package/skills/spec-structure-governance/README.md +40 -0
  120. package/skills/spec-structure-governance/SKILL.md +169 -0
  121. package/skills/ssa-lowering/README.md +26 -0
  122. package/skills/ssa-lowering/SKILL.md +181 -0
  123. package/skills/start-mission/README.md +7 -0
  124. package/skills/start-mission/SKILL.md +115 -0
  125. package/skills/suite-format-governance/README.md +75 -0
  126. package/skills/suite-format-governance/SKILL.md +299 -0
  127. package/skills/suite-format-governance/references/rubric.md +313 -0
  128. package/skills/touch-set-correction/README.md +16 -0
  129. package/skills/touch-set-correction/SKILL.md +67 -0
  130. package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
  131. package/skills/verify-scenarios/README.md +17 -0
  132. package/skills/verify-scenarios/SKILL.md +109 -0
  133. package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
@@ -0,0 +1,601 @@
1
+ #!/usr/bin/env node
2
+ // Static state check for an SDD project spec (the project-spec model). Two
3
+ // fail-closed responsibilities, both the safety net that makes an illegal state
4
+ // uncommittable:
5
+ // 1. the root spec.md lifecycle tuple — status / open-markers / approval
6
+ // attribution. The frontmatter is the router's upfront index (ADR-0017):
7
+ // minimal status + project-path. `aligned` was dropped — impl-sync is the
8
+ // impl gate's runtime suite run, contract-sync is judged, per-node settled
9
+ // state is the @frozen scan. The root is a descriptive index that owns no
10
+ // .feature of its own, so there is no per-spec .feature requirement — the
11
+ // behavior suite lives in the behavioral nodes.
12
+ // 2. the per-node spec-type reconcile — a node README's `spec-type` marker must
13
+ // agree with its shape: reference => ## Subject and NO sibling .feature;
14
+ // behavioral => ## Use Cases; descriptive (no marker) => no requirement.
15
+ // A node README also carries `spec-type` as its ONLY frontmatter: lifecycle
16
+ // (status / project-path / approval / produced-by / freeze) is root-spec.md-
17
+ // only (lifecycle-governance), so a stray lifecycle field on a node fails closed.
18
+ // 3. referenced-artifact-exists (--files only) — diff-scoped + surface-for-
19
+ // judgment: a backtick path a CR *introduces* (vs its committed baseline)
20
+ // that does not resolve on disk is a judgment finding, not a fail-closed
21
+ // block; a pre-existing ref the CR left untouched is never gated. The
22
+ // use-case-coverage check in the same pass stays fail-closed.
23
+ // 4. the gate-line floor — a root spec.md at `approved`/`implemented` must have the
24
+ // DURABLE proof of the gate in its sibling `ledger/` shards: a `gate` line with the
25
+ // matching `verdict: approve`. The spec.md `approval` map is the overwritten
26
+ // current-state twin (checked in 1); the ledger line is the immutable durable twin,
27
+ // and a status advance with no ledger gate line is an unenforced gate. `draft`
28
+ // requires no ledger (no gate ran). Provenance write is the conductor/gate's job
29
+ // (combat-log-governance); this is the static floor that makes the missing-line
30
+ // state uncommittable.
31
+ // `project-path` (the source dir a spec governs) is parsed for the router; its
32
+ // presence is the producer's job, not a lifecycle-legality concern, so it is not
33
+ // enforced here. See sdd:lifecycle-governance (legal-state tuples + per-node
34
+ // spec-type checks; spec types). Pure functions are
35
+ // exported for node:test; running the file directly drives the CLI. No dependencies.
36
+
37
+ import { execFileSync } from 'node:child_process'
38
+ import { type Dirent, existsSync, readdirSync, readFileSync } from 'node:fs'
39
+ import { basename, dirname, join } from 'node:path'
40
+
41
+ export interface GateVerdict {
42
+ verdict?: string
43
+ by?: string
44
+ hasWhy: boolean
45
+ }
46
+
47
+ export interface SpecState {
48
+ status: string
49
+ projectPath: string | null
50
+ markerCount: number
51
+ approval: Record<string, GateVerdict> | null
52
+ }
53
+
54
+ export interface NodeSpec {
55
+ type: string | null
56
+ hasSubject: boolean
57
+ hasUseCases: boolean
58
+ lifecycleFields: string[]
59
+ }
60
+
61
+ export interface LedgerGate {
62
+ gate: string
63
+ verdict: string
64
+ }
65
+
66
+ const GATES = ['spec', 'impl']
67
+ const VERDICTS = ['approve', 'pause', 'reject']
68
+ const SPEC_TYPES = ['reference', 'behavioral']
69
+ // Lifecycle frontmatter is root-spec.md-only (lifecycle-governance). A node README
70
+ // carries `spec-type` and nothing else; any of these on a node fails closed —
71
+ // including the retired schema fields, which must never reappear on a node.
72
+ const NODE_FORBIDDEN_FIELDS = ['status', 'project-path', 'approval', 'produced-by', 'aligned', 'spec-layout', 'leash']
73
+
74
+ function frontmatter(text: string): string[] {
75
+ const m = /^---\n([\s\S]*?)\n---/.exec(text)
76
+ return m ? m[1].split('\n') : []
77
+ }
78
+
79
+ // Strip fenced + inline code so a doc that *documents* a marker or a heading
80
+ // (e.g. "wraps `## Subject` in backticks") does not trip its own checks.
81
+ function prose(text: string): string {
82
+ return text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '')
83
+ }
84
+
85
+ export function parseSpecState(text: string): SpecState {
86
+ const lines = frontmatter(text)
87
+ let status = ''
88
+ let projectPath: string | null = null
89
+ let approval: Record<string, GateVerdict> | null = null
90
+
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const s = /^status:\s*(.+)$/.exec(lines[i])
93
+ if (s) {
94
+ status = s[1].trim().replace(/^["']|["']$/g, '')
95
+ continue
96
+ }
97
+ const p = /^project-path:\s*(.+)$/.exec(lines[i])
98
+ if (p) {
99
+ projectPath = p[1].trim().replace(/^["']|["']$/g, '')
100
+ continue
101
+ }
102
+ const ap = /^approval:\s*(.*)$/.exec(lines[i])
103
+ if (ap) {
104
+ approval = {}
105
+ if (ap[1].trim() && ap[1].trim() !== '{}') continue // inline non-empty unsupported; treat as empty map
106
+ let gate: string | null = null
107
+ for (let j = i + 1; j < lines.length; j++) {
108
+ if (!/^\s/.test(lines[j])) break // dedent to top level ends the block
109
+ const g = /^ {2}(\w+):\s*$/.exec(lines[j])
110
+ if (g) {
111
+ gate = g[1]
112
+ approval[gate] = { hasWhy: false }
113
+ continue
114
+ }
115
+ if (!gate) continue
116
+ const verdict = /^ {4}verdict:\s*(.+)$/.exec(lines[j])
117
+ if (verdict) approval[gate].verdict = verdict[1].trim().replace(/^["']|["']$/g, '')
118
+ const by = /^ {4}by:\s*(.+)$/.exec(lines[j])
119
+ if (by) approval[gate].by = by[1].trim().replace(/^["']|["']$/g, '')
120
+ if (/^ {4}why:/.test(lines[j])) approval[gate].hasWhy = true
121
+ }
122
+ }
123
+ }
124
+
125
+ const markerCount = (prose(text).match(/<!--\s*open:/g) ?? []).length
126
+ return { status, projectPath, markerCount, approval }
127
+ }
128
+
129
+ // The root project spec.md lifecycle tuple.
130
+ export function checkSpec(slug: string, state: SpecState): string[] {
131
+ const { status, markerCount, approval } = state
132
+ const v: string[] = []
133
+ const tag = (msg: string) => v.push(`${slug}: ${msg}`)
134
+
135
+ // `implemented` is backed by the impl gate's runtime suite run (ADR-0017), not a
136
+ // stored flag — the static guard here is the recorded approval.impl ratification
137
+ // (below). No `aligned` cross-check.
138
+ if ((status === 'approved' || status === 'implemented') && markerCount > 0)
139
+ tag(`illegal state — ${markerCount} open marker(s) but status is ${status} (markers block the gate)`)
140
+
141
+ if (approval) {
142
+ for (const [gate, entry] of Object.entries(approval)) {
143
+ if (!GATES.includes(gate)) tag(`approval has unknown gate "${gate}" (expected spec | impl)`)
144
+ if (entry.verdict && !VERDICTS.includes(entry.verdict))
145
+ tag(`approval.${gate} has unknown verdict "${entry.verdict}" (expected approve | pause | reject)`)
146
+ if (entry.verdict === 'pause' && entry.by)
147
+ tag(`approval.${gate} is a pause but carries by — a pause is always the agent's act and omits by`)
148
+ if (entry.verdict === 'approve' && !entry.by)
149
+ tag(`approval.${gate} is an approve with no by — an approve must record its approver`)
150
+ if (entry.by === 'agent' && !entry.hasWhy)
151
+ tag(`approval.${gate} is by:agent but has no why block (a self-assertion must record its derivation)`)
152
+ const passed =
153
+ (gate === 'spec' && (status === 'approved' || status === 'implemented')) ||
154
+ (gate === 'impl' && status === 'implemented')
155
+ if (entry.verdict === 'pause' && passed)
156
+ tag(`approval.${gate} is a pause but the ${gate} gate is already passed (status ${status})`)
157
+ }
158
+ }
159
+ if (
160
+ (status === 'approved' || status === 'implemented') &&
161
+ !(approval?.spec?.verdict === 'approve' && approval?.spec?.by)
162
+ )
163
+ tag(
164
+ `status is ${status} but approval.spec has no approve verdict with an approver — the spec gate has no recorded ratification`,
165
+ )
166
+ if (status === 'implemented' && !(approval?.impl?.verdict === 'approve' && approval?.impl?.by))
167
+ tag(
168
+ 'status is implemented but approval.impl has no approve verdict with an approver — the impl gate has no recorded ratification',
169
+ )
170
+
171
+ return v
172
+ }
173
+
174
+ // Referenced-artifact-exists: a backtick-wrapped token shaped like a path (a
175
+ // relative `./`/`../` reference, or repo-root-relative under a known top-level
176
+ // dir) must resolve to a real file/dir. Bounded to these prefixes deliberately —
177
+ // unprefixed slash-containing tokens ("Given / When / Then", "oracle/builder")
178
+ // are prose, not paths, and must never false-positive. Diff-scoped + surface-
179
+ // for-judgment: only paths a CR *introduces* vs the committed baseline are
180
+ // checked — a pre-existing reference the CR left untouched is never gated, and
181
+ // an unresolved introduced ref is a judgment finding, not a hard fail-closed
182
+ // block (referenced ≠ must-exist).
183
+ const PATH_PREFIXES = ['.agents/', 'plugins/', 'packages/', 'apps/', 'docs/', '.claude/']
184
+
185
+ export function extractPathRefs(text: string): string[] {
186
+ const out: string[] = []
187
+ const re = /`([^`\n]+)`/g
188
+ let m: RegExpExecArray | null
189
+ while ((m = re.exec(text))) {
190
+ const token = m[1].trim()
191
+ // A template placeholder (`<project>`) or glob (`*.plan.md`) names a pattern,
192
+ // not a real file — never a violation regardless of prefix.
193
+ if (/[<*]/.test(token)) continue
194
+ const isRelative = token.startsWith('./') || token.startsWith('../')
195
+ const isRooted = PATH_PREFIXES.some((p) => token.startsWith(p))
196
+ if (isRelative || isRooted) out.push(token)
197
+ }
198
+ return out
199
+ }
200
+
201
+ // Diff at the ref-token level: which of `currentText`'s path refs are absent from
202
+ // `baselineText`'s. Empty baselineText (a brand-new file, or none supplied) means
203
+ // every ref in currentText is introduced.
204
+ export function introducedPathRefs(baselineText: string, currentText: string): string[] {
205
+ const baseRefs = new Set(extractPathRefs(baselineText))
206
+ return extractPathRefs(currentText).filter((ref) => !baseRefs.has(ref))
207
+ }
208
+
209
+ // `dir` is the checked file's own directory (root-relative, matching this
210
+ // script's CWD-is-repo-root convention) — the base a `./`/`../` token resolves
211
+ // against. A repo-root-relative token resolves against the CWD directly.
212
+ // `baselineText` (default '', i.e. every ref is introduced) scopes the check to
213
+ // the CR's own delta — a pre-existing unresolved ref the CR left untouched is
214
+ // never gated. An unresolved introduced ref is returned as a *finding* for
215
+ // judgment, not a hard violation.
216
+ export function checkReferencedArtifacts(slug: string, dir: string, text: string, baselineText = ''): string[] {
217
+ const v: string[] = []
218
+ const tag = (msg: string) => v.push(`${slug}: introduces unresolved reference \`${msg}\` — surfaced for judgment`)
219
+ for (const ref of introducedPathRefs(baselineText, text)) {
220
+ const clean = ref.replace(/#.*$/, '')
221
+ const isRelative = clean.startsWith('./') || clean.startsWith('../')
222
+ const resolved = isRelative ? join(dir, clean) : clean
223
+ if (!existsSync(resolved)) tag(clean)
224
+ }
225
+ return v
226
+ }
227
+
228
+ export function parseNode(text: string): NodeSpec {
229
+ let type: string | null = null
230
+ const lifecycleFields: string[] = []
231
+ for (const l of frontmatter(text)) {
232
+ const key = /^([\w-]+):/.exec(l) // top-level key (no leading whitespace)
233
+ if (!key) continue
234
+ if (key[1] === 'spec-type') {
235
+ const m = /^spec-type:\s*(.+)$/.exec(l)
236
+ if (m) type = m[1].trim().replace(/^["']|["']$/g, '')
237
+ } else if (NODE_FORBIDDEN_FIELDS.includes(key[1])) {
238
+ lifecycleFields.push(key[1])
239
+ }
240
+ }
241
+ const body = prose(text)
242
+ return {
243
+ type,
244
+ hasSubject: /^##\s+Subject\b/m.test(body),
245
+ hasUseCases: /^##\s+Use Cases\b/m.test(body),
246
+ lifecycleFields,
247
+ }
248
+ }
249
+
250
+ // The per-node spec-type reconcile. A node README's marker must agree with its
251
+ // shape; descriptive (no marker) carries no requirement.
252
+ export function checkNode(slug: string, node: NodeSpec, hasFeature: boolean): string[] {
253
+ const { type, hasSubject, hasUseCases, lifecycleFields } = node
254
+ const v: string[] = []
255
+ const tag = (msg: string) => v.push(`${slug}: ${msg}`)
256
+
257
+ // Lifecycle is root-spec.md-only — flagged for every node, descriptive included.
258
+ for (const f of lifecycleFields)
259
+ tag(
260
+ `carries lifecycle field "${f}" — lifecycle frontmatter is root-spec.md-only (a node README carries only spec-type)`,
261
+ )
262
+
263
+ if (type === null) return v
264
+ if (!SPEC_TYPES.includes(type)) {
265
+ tag(`unknown spec-type "${type}" (expected reference | behavioral)`)
266
+ return v
267
+ }
268
+ if (type === 'reference') {
269
+ if (hasFeature)
270
+ tag('spec-type: reference but a sibling .feature exists — a reference artifact is suite-less by design')
271
+ if (!hasSubject) tag('spec-type: reference but no ## Subject section')
272
+ }
273
+ if (type === 'behavioral' && !hasUseCases) tag('spec-type: behavioral but no ## Use Cases section')
274
+
275
+ return v
276
+ }
277
+
278
+ // The durable gate-line floor. Parse the sibling ledger (see readLedgerText) for its `gate` lines — the
279
+ // immutable durable verdicts (one JSON object per line; a malformed or non-gate line is skipped,
280
+ // integrity being a separate concern).
281
+ export function parseLedgerGates(text: string): LedgerGate[] {
282
+ const gates: LedgerGate[] = []
283
+ for (const line of text.split('\n')) {
284
+ const s = line.trim()
285
+ if (!s) continue
286
+ let obj: { kind?: string; gate?: string; verdict?: string }
287
+ try {
288
+ obj = JSON.parse(s)
289
+ } catch {
290
+ continue
291
+ }
292
+ if (obj.kind === 'gate' && typeof obj.gate === 'string' && typeof obj.verdict === 'string')
293
+ gates.push({ gate: obj.gate, verdict: obj.verdict })
294
+ }
295
+ return gates
296
+ }
297
+
298
+ // The durable ledger is a `ledger/` directory of per-CR-per-writer shard files (ADR-0020),
299
+ // with a legacy single-file `ledger.jsonl` tolerated for pre-shard corpora. Concatenate every
300
+ // `*.jsonl` shard plus the legacy file so the gate floor sees the whole durable history across
301
+ // shards, regardless of storage generation. Returns "" when neither exists.
302
+ export function readLedgerText(root: string, slug: string): string {
303
+ const parts: string[] = []
304
+ const legacy = join(root, slug, 'ledger.jsonl')
305
+ if (existsSync(legacy)) parts.push(readFileSync(legacy, 'utf8'))
306
+ const dir = join(root, slug, 'ledger')
307
+ if (existsSync(dir)) {
308
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
309
+ if (e.isFile() && e.name.endsWith('.jsonl')) parts.push(readFileSync(join(dir, e.name), 'utf8'))
310
+ }
311
+ }
312
+ return parts.join('\n')
313
+ }
314
+
315
+ // A root spec.md at `approved`/`implemented` must carry the matching durable `gate` approve line
316
+ // in its sibling ledger — the `approval` map (checked in checkSpec) is the overwritten current-state
317
+ // twin; this is the immutable durable twin. A status advance with no ledger gate line is an
318
+ // unenforced gate. `draft` requires no ledger (no gate ran).
319
+ export function checkGateFloor(slug: string, status: string, gates: LedgerGate[]): string[] {
320
+ const v: string[] = []
321
+ const tag = (msg: string) => v.push(`${slug}: ${msg}`)
322
+ const approved = (gate: string) => gates.some((g) => g.gate === gate && g.verdict === 'approve')
323
+
324
+ if ((status === 'approved' || status === 'implemented') && !approved('spec'))
325
+ tag(`status is ${status} but the ledger has no spec gate approve line — the durable gate floor is missing`)
326
+ if (status === 'implemented' && !approved('impl'))
327
+ tag('status is implemented but the ledger has no impl gate approve line — the durable gate floor is missing')
328
+
329
+ return v
330
+ }
331
+
332
+ function hasFeatureFile(dir: string): boolean {
333
+ try {
334
+ return readdirSync(dir).some((f) => f.endsWith('.feature'))
335
+ } catch {
336
+ return false
337
+ }
338
+ }
339
+
340
+ // Walk the tree for every dir holding a file named `name`; the slug is the
341
+ // root-relative dir path. Nested projects are real and must be enforced too.
342
+ function discoverDirsWith(root: string, name: string): string[] {
343
+ const out: string[] = []
344
+ const walk = (dir: string, rel: string) => {
345
+ let entries: Dirent[]
346
+ try {
347
+ entries = readdirSync(dir, { withFileTypes: true })
348
+ } catch {
349
+ return
350
+ }
351
+ if (entries.some((e) => e.isFile() && e.name === name)) out.push(rel)
352
+ for (const e of entries) {
353
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue
354
+ walk(join(dir, e.name), rel ? join(rel, e.name) : e.name)
355
+ }
356
+ }
357
+ walk(root, '')
358
+ return out
359
+ }
360
+
361
+ export const discoverSpecDirs = (root: string): string[] => discoverDirsWith(root, 'spec.md')
362
+ export const discoverNodeDirs = (root: string): string[] => discoverDirsWith(root, 'README.md')
363
+
364
+ // referenced-artifact-exists is deliberately CR-scoped only (--files), never part of
365
+ // the --root tree sweep: the existing corpus's accumulated prose legitimately names
366
+ // example/convention paths (an opt-in config not yet created, a hypothetical nested
367
+ // project) that a blind tree-wide scan cannot distinguish from a real broken
368
+ // reference. Scoped to a CR's own touched files, same shape as check-suite.mts.
369
+ export function parseFilesArg(argv: string[]): string[] {
370
+ const idx = argv.indexOf('--files')
371
+ if (idx === -1) return []
372
+ const paths: string[] = []
373
+ for (let i = idx + 1; i < argv.length; i++) {
374
+ if (argv[i].startsWith('--')) break
375
+ paths.push(argv[i])
376
+ }
377
+ return paths
378
+ }
379
+
380
+ // Reads a path's content at a given git ref (the committed baseline the CR diffs
381
+ // against). Any failure (new file at base, path not tracked at base, git absent)
382
+ // returns '' — the safe default that treats every ref in the file as introduced.
383
+ function readBaselineFromGit(base: string, path: string): string {
384
+ try {
385
+ return execFileSync('git', ['show', `${base}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
386
+ } catch {
387
+ return ''
388
+ }
389
+ }
390
+
391
+ export function checkReferencedArtifactsInFiles(
392
+ paths: string[],
393
+ baseline: (path: string) => string = () => '',
394
+ ): { findings: string[]; violations: string[] } {
395
+ const findings: string[] = []
396
+ const violations: string[] = []
397
+ for (const p of paths) {
398
+ let text: string
399
+ try {
400
+ text = readFileSync(p, 'utf8')
401
+ } catch {
402
+ violations.push(`${p}: cannot read file`)
403
+ continue
404
+ }
405
+ findings.push(...checkReferencedArtifacts(p, dirname(p), text, baseline(p)))
406
+ }
407
+ return { findings, violations }
408
+ }
409
+
410
+ // The referenced-artifact-exists sweep is widened from the two hardcoded names
411
+ // (spec.md/README.md) to every touched prose `.md` under the spec tree — a
412
+ // `design/*.md` or nested node doc gets the same diff-scoped, surface-for-
413
+ // judgment treatment. This is the boundary: a `.md` the CR touched but that lies
414
+ // outside the three fixed spec-tree roots (`.agents/spec/`, `.agents/specs/`, a
415
+ // nested `<project-path>/.agents/spec/` — mirroring discover-specs.mts) is never
416
+ // swept. Deliberately filters the caller-supplied `--files` list only; it is
417
+ // never a tree-wide `--root` sweep, same rationale as
418
+ // checkReferencedArtifactsInFiles above.
419
+ export function isUnderSpecTree(path: string): boolean {
420
+ return /(^|\/)\.agents\/specs?(\/|$)/.test(path)
421
+ }
422
+
423
+ export function filterProseMdInSpecTree(paths: string[]): string[] {
424
+ return paths.filter((p) => p.endsWith('.md') && isUnderSpecTree(p))
425
+ }
426
+
427
+ // ---- Use-case-coverage pre-filter ----
428
+ // The row->scenario link (spec-format-governance): when a behavioral node's
429
+ // "## Use Cases" section is written as a table with a `Scenario` column, each
430
+ // row names its covering scenario in a backtick-wrapped `Scenario: <title>` (or
431
+ // a shared `@tag`). This is non-mandating — a reference/descriptive doc with no
432
+ // Use Cases section, or a behavioral doc whose Use Cases are prose/EARS (or a
433
+ // table with no Scenario column) carries no row to link, so it raises nothing
434
+ // and stays the spec-judge's coverage backstop.
435
+
436
+ export interface UseCaseScenarioRefs {
437
+ hasSection: boolean
438
+ refs: string[]
439
+ }
440
+
441
+ // A markdown table row: strip the leading/trailing `|` then split on `|`, trimming each cell.
442
+ function splitTableRow(line: string): string[] {
443
+ return line
444
+ .trim()
445
+ .replace(/^\|/, '')
446
+ .replace(/\|$/, '')
447
+ .split('|')
448
+ .map((c) => c.trim())
449
+ }
450
+
451
+ // Slices out the body between a `## <heading>` line and the next top-level `## `
452
+ // heading (or end of text). Manual index-based slicing avoids the multiline-`$`
453
+ // pitfall a lookahead-based regex would hit (`$` in `/m` mode matches every line end).
454
+ function extractSection(body: string, heading: string): string | null {
455
+ const start = new RegExp(`(^|\\n)##\\s+${heading}\\b`).exec(body)
456
+ if (!start) return null
457
+ const rest = body.slice(start.index + start[0].length)
458
+ const end = /\n##\s/.exec(rest)
459
+ return end ? rest.slice(0, end.index) : rest
460
+ }
461
+
462
+ export function extractUseCaseScenarioRefs(text: string): UseCaseScenarioRefs {
463
+ // Strip fenced code blocks only (not inline `code` spans — the row->scenario
464
+ // link itself lives inside a backtick span, so `prose()`'s inline-span strip
465
+ // would erase the very refs this function extracts).
466
+ const body = text.replace(/```[\s\S]*?```/g, '')
467
+ const section = extractSection(body, 'Use Cases')
468
+ if (section === null) return { hasSection: false, refs: [] }
469
+ const lines = section.split('\n')
470
+ const headerIdx = lines.findIndex((l) => l.trim().startsWith('|'))
471
+ if (headerIdx === -1) return { hasSection: true, refs: [] } // prose or EARS — no table
472
+ const header = splitTableRow(lines[headerIdx])
473
+ const scenarioIdx = header.findIndex((c) => /^scenario$/i.test(c))
474
+ if (scenarioIdx === -1) return { hasSection: true, refs: [] } // table with no Scenario column
475
+
476
+ const refs: string[] = []
477
+ // data rows start after the header separator (`|---|---|`); stop at the first
478
+ // non-`|` line, which ends the contiguous table block.
479
+ for (let i = headerIdx + 2; i < lines.length; i++) {
480
+ if (!lines[i].trim().startsWith('|')) break
481
+ const cells = splitTableRow(lines[i])
482
+ const cell = cells[scenarioIdx]
483
+ if (!cell) continue
484
+ const ref = /`([^`\n]+)`/.exec(cell)
485
+ if (ref) refs.push(ref[1].trim())
486
+ }
487
+ return { hasSection: true, refs }
488
+ }
489
+
490
+ function escapeRegExp(s: string): string {
491
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
492
+ }
493
+
494
+ // A ref is either a shared `@tag` (present anywhere as a whole token in the
495
+ // feature text) or a `Scenario: <title>` (an exact sibling `Scenario:` line).
496
+ export function resolveScenarioRef(ref: string, featureText: string): boolean {
497
+ if (ref.startsWith('@')) return new RegExp(`(^|\\s)${escapeRegExp(ref)}(\\s|$)`, 'm').test(featureText)
498
+ const m = /^Scenario:\s*(.+)$/.exec(ref)
499
+ if (!m) return false
500
+ const title = m[1].trim()
501
+ return new RegExp(`^\\s*Scenario:\\s*${escapeRegExp(title)}\\s*$`, 'm').test(featureText)
502
+ }
503
+
504
+ // `dir` is the checked spec.md/README's own directory — the sibling `.feature` is
505
+ // `<node>.feature` (named after the containing dir) or, failing that, the single
506
+ // `.feature` file in the dir (mirrors hasFeatureFile's discovery, disambiguated by name).
507
+ export function findSiblingFeature(dir: string): string | null {
508
+ let entries: string[]
509
+ try {
510
+ entries = readdirSync(dir).filter((f) => f.endsWith('.feature'))
511
+ } catch {
512
+ return null
513
+ }
514
+ if (entries.length === 0) return null
515
+ const named = `${basename(dir)}.feature`
516
+ if (entries.includes(named)) return join(dir, named)
517
+ return join(dir, entries[0])
518
+ }
519
+
520
+ export function checkUseCaseCoverage(slug: string, dir: string, text: string): string[] {
521
+ const v: string[] = []
522
+ const tag = (msg: string) => v.push(`${slug}: ${msg}`)
523
+ const { hasSection, refs } = extractUseCaseScenarioRefs(text)
524
+ if (!hasSection || refs.length === 0) return v
525
+
526
+ const featurePath = findSiblingFeature(dir)
527
+ const featureText = featurePath ? readFileSync(featurePath, 'utf8') : ''
528
+ for (const ref of refs) {
529
+ if (!resolveScenarioRef(ref, featureText))
530
+ tag(`Use Cases table names scenario \`${ref}\` that does not resolve in the sibling .feature`)
531
+ }
532
+ return v
533
+ }
534
+
535
+ export function checkUseCaseCoverageInFiles(paths: string[]): string[] {
536
+ const violations: string[] = []
537
+ for (const p of paths) {
538
+ let text: string
539
+ try {
540
+ text = readFileSync(p, 'utf8')
541
+ } catch {
542
+ continue // an unreadable file is already reported by the referenced-artifact check
543
+ }
544
+ violations.push(...checkUseCaseCoverage(p, dirname(p), text))
545
+ }
546
+ return violations
547
+ }
548
+
549
+ export function main(argv: string[]): number {
550
+ if (argv.includes('--files')) {
551
+ const paths = parseFilesArg(argv)
552
+ if (paths.length === 0) {
553
+ console.error('✗ --files requires at least one .md path under the spec tree')
554
+ return 1
555
+ }
556
+ const proseFiles = filterProseMdInSpecTree(paths)
557
+ const base = argv.includes('--base') ? argv[argv.indexOf('--base') + 1] : undefined
558
+ const baseline = base ? (p: string) => readBaselineFromGit(base, p) : () => ''
559
+ const { findings, violations: refReadErrors } = checkReferencedArtifactsInFiles(proseFiles, baseline)
560
+ const ucViolations = checkUseCaseCoverageInFiles(proseFiles)
561
+ const violations = [...refReadErrors, ...ucViolations]
562
+ for (const f of findings) process.stdout.write(`⚠ ${f}\n`)
563
+ if (violations.length) {
564
+ for (const line of violations) console.error(`✗ ${line}`)
565
+ return 1
566
+ }
567
+ process.stdout.write(
568
+ findings.length
569
+ ? `referenced-artifact findings surfaced for judgment (${findings.length}); use-case-coverage OK\n`
570
+ : 'referenced-artifact and use-case-coverage checks OK\n',
571
+ )
572
+ return 0
573
+ }
574
+
575
+ const root = argv.includes('--root') ? argv[argv.indexOf('--root') + 1] : '.agents/specs'
576
+ let violations: string[] = []
577
+
578
+ for (const slug of discoverSpecDirs(root)) {
579
+ const specPath = join(root, slug, 'spec.md')
580
+ if (!existsSync(specPath)) continue
581
+ const state = parseSpecState(readFileSync(specPath, 'utf8'))
582
+ violations = violations.concat(checkSpec(slug, state))
583
+ const gates = parseLedgerGates(readLedgerText(root, slug))
584
+ violations = violations.concat(checkGateFloor(slug, state.status, gates))
585
+ }
586
+ for (const slug of discoverNodeDirs(root)) {
587
+ const dir = join(root, slug)
588
+ const readme = join(dir, 'README.md')
589
+ if (!existsSync(readme)) continue
590
+ violations = violations.concat(checkNode(slug, parseNode(readFileSync(readme, 'utf8')), hasFeatureFile(dir)))
591
+ }
592
+
593
+ if (violations.length) {
594
+ for (const line of violations) console.error(`✗ ${line}`)
595
+ return 1
596
+ }
597
+ process.stdout.write('spec states OK\n')
598
+ return 0
599
+ }
600
+
601
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))