cyber-sdd 0.7.0 → 0.7.2

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