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,249 @@
1
+ #!/usr/bin/env node
2
+ // check-scenario-overlap — project-spec/scenario-overlap's concrete engine. Audits ACROSS the nodes
3
+ // of one project spec and surfaces where the same behavior lives in more than one node's `.feature`:
4
+ // the intra-project spec-level SSA partner of the code-side collision ladder, and the cross-node
5
+ // axis check-spec-structure (intra-node node-shape) leaves uncovered (see this skill's README.md).
6
+ //
7
+ // Two deterministic candidate kinds, each with a severity:
8
+ // - exact-duplicate (blocking) — two DISTINCT nodes whose suites each carry a scenario with an
9
+ // identical normalized step fingerprint. `--check` fails on it.
10
+ // - title-overlap (advisory) — two distinct nodes sharing a normalized scenario title but with
11
+ // DIFFERING step fingerprints. A weaker hint; never fails `--check`.
12
+ // Confirming real behavioral overlap and assigning the owning node is Warden judgment (the @rubric
13
+ // scenario) — no engine code here.
14
+ //
15
+ // The fingerprint is computed from step BODIES only (title/tags/comments/prose never reach it), so
16
+ // the signal is behavior-shaped, not cosmetic. For a `Scenario Outline` the steps are a template —
17
+ // every canonical `@trigger` outline shares byte-identical steps by construction — so the outline's
18
+ // `Examples` table (header + rows, normalized) is folded into the fingerprint too: two outlines are
19
+ // an exact-duplicate only when their steps AND their rows match. Title and tags stay excluded from
20
+ // both — a plain `Scenario` and an outline's Examples rows are the only places distinguishing
21
+ // content can live. Detection is cross-node only: a within-node duplicate and a once-corpus-wide
22
+ // scenario both raise nothing. Pure derivation, writes nothing, no deps (the repo's node->=23.6 /
23
+ // no-deps convention). Pure functions are exported for node:test; running the file directly drives
24
+ // the CLI.
25
+
26
+ import { readdirSync, readFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+
29
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
30
+
31
+ export type CandidateKind = 'exact-duplicate' | 'title-overlap'
32
+ export type Severity = 'blocking' | 'advisory'
33
+
34
+ export interface Scenario {
35
+ title: string
36
+ /** Normalized ordered step bodies, plus an outline's Examples rows — the behavior fingerprint. */
37
+ fingerprint: string
38
+ /** Normalized title — the weaker signal. */
39
+ titleKey: string
40
+ }
41
+
42
+ export interface NodeSuite {
43
+ /** Display form of the owning node — its folder path. */
44
+ node: string
45
+ scenarios: Scenario[]
46
+ }
47
+
48
+ export interface Candidate {
49
+ kind: CandidateKind
50
+ severity: Severity
51
+ nodes: [string, string]
52
+ scenario: string
53
+ detail: string
54
+ }
55
+
56
+ // ── Normalization ──
57
+ export function normalize(s: string): string {
58
+ return s.trim().replace(/\s+/g, ' ').toLowerCase()
59
+ }
60
+
61
+ // ── Parse a .feature into its scenarios (steps + an outline's Examples rows reach the fingerprint) ──
62
+ const STEP = /^\s*(Given|When|Then|And|But)\s+(.*\S)\s*$/
63
+ const SCENARIO = /^\s*Scenario(?: Outline)?:\s*(.*\S)\s*$/
64
+ const TABLE_ROW = /^\s*\|(.*)\|\s*$/
65
+
66
+ // Normalize one `| cell | cell |` row cell-by-cell so a row survives reordered
67
+ // whitespace inside a cell but not a reordered or differing cell.
68
+ function normalizeRow(line: string): string {
69
+ const inner = TABLE_ROW.exec(line)?.[1] ?? line
70
+ return inner
71
+ .split('|')
72
+ .map((cell) => normalize(cell))
73
+ .join('|')
74
+ }
75
+
76
+ export function parseFeature(featureText: string): Scenario[] {
77
+ const scenarios: Scenario[] = []
78
+ let title: string | null = null
79
+ let steps: string[] = []
80
+ let exampleRows: string[] = []
81
+ const flush = () => {
82
+ if (title !== null) {
83
+ const fingerprint =
84
+ exampleRows.length === 0
85
+ ? steps.map(normalize).join('\n')
86
+ : `${steps.map(normalize).join('\n')}\n EXAMPLES \n${exampleRows.join('\n')}`
87
+ scenarios.push({ title, titleKey: normalize(title), fingerprint })
88
+ }
89
+ title = null
90
+ steps = []
91
+ exampleRows = []
92
+ }
93
+ for (const line of featureText.split('\n')) {
94
+ const sc = SCENARIO.exec(line)
95
+ if (sc) {
96
+ flush()
97
+ title = sc[1]
98
+ continue
99
+ }
100
+ if (title === null) continue
101
+ const st = STEP.exec(line)
102
+ if (st) {
103
+ steps.push(st[2])
104
+ continue
105
+ }
106
+ if (TABLE_ROW.test(line)) exampleRows.push(normalizeRow(line))
107
+ }
108
+ flush()
109
+ return scenarios
110
+ }
111
+
112
+ function displayPath(relPath: string): string {
113
+ const p = relPath.replace(/\\/g, '/')
114
+ return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
115
+ }
116
+
117
+ // ── Scan the project-spec — one suite per node folder that carries a .feature ──
118
+ export function scanSuites(specDir: string): NodeSuite[] {
119
+ const suites: NodeSuite[] = []
120
+ walk(specDir, specDir, suites)
121
+ return suites.sort((a, b) => a.node.localeCompare(b.node))
122
+ }
123
+
124
+ function walk(dir: string, specDir: string, out: NodeSuite[]): void {
125
+ const entries = readdirSync(dir, { withFileTypes: true })
126
+ for (const entry of entries) {
127
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
128
+ const full = join(dir, entry.name)
129
+ if (entry.isDirectory()) {
130
+ walk(full, specDir, out)
131
+ continue
132
+ }
133
+ if (!entry.name.endsWith('.feature')) continue
134
+ const scenarios = parseFeature(readFileSync(full, 'utf8'))
135
+ if (scenarios.length === 0) continue
136
+ const relDir = dir.slice(specDir.length + 1).replace(/\\/g, '/')
137
+ out.push({ node: displayPath(`${relDir}/`), scenarios })
138
+ }
139
+ }
140
+
141
+ // ── Detect — cross-node only (distinct nodes), fingerprint then title ──
142
+ export function detect(suites: NodeSuite[]): Candidate[] {
143
+ // fingerprint -> (node -> first scenario title in that node)
144
+ const byFingerprint = new Map<string, Map<string, string>>()
145
+ // titleKey -> (node -> { fingerprints, display title })
146
+ const byTitle = new Map<string, Map<string, { fps: Set<string>; title: string }>>()
147
+ for (const suite of suites) {
148
+ for (const sc of suite.scenarios) {
149
+ if (sc.fingerprint !== '') {
150
+ let m = byFingerprint.get(sc.fingerprint)
151
+ if (!m) byFingerprint.set(sc.fingerprint, (m = new Map()))
152
+ if (!m.has(suite.node)) m.set(suite.node, sc.title)
153
+ }
154
+ let t = byTitle.get(sc.titleKey)
155
+ if (!t) byTitle.set(sc.titleKey, (t = new Map()))
156
+ let e = t.get(suite.node)
157
+ if (!e) t.set(suite.node, (e = { fps: new Set(), title: sc.title }))
158
+ e.fps.add(sc.fingerprint)
159
+ }
160
+ }
161
+ const candidates: Candidate[] = []
162
+ // exact-duplicate: same fingerprint across >=2 distinct nodes
163
+ for (const [, nodeMap] of byFingerprint) {
164
+ if (nodeMap.size < 2) continue
165
+ const nodes = [...nodeMap.keys()].sort()
166
+ for (let i = 0; i < nodes.length; i++) {
167
+ for (let j = i + 1; j < nodes.length; j++) {
168
+ candidates.push({
169
+ kind: 'exact-duplicate',
170
+ severity: 'blocking',
171
+ nodes: [nodes[i], nodes[j]],
172
+ scenario: nodeMap.get(nodes[i]) ?? '',
173
+ detail:
174
+ 'identical step fingerprint — the same behavior is specified in both nodes; one behavior = one owning node',
175
+ })
176
+ }
177
+ }
178
+ }
179
+ // title-overlap: same normalized title across >=2 distinct nodes with DIFFERING fingerprints
180
+ for (const [, nodeMap] of byTitle) {
181
+ if (nodeMap.size < 2) continue
182
+ const nodes = [...nodeMap.keys()].sort()
183
+ for (let i = 0; i < nodes.length; i++) {
184
+ for (let j = i + 1; j < nodes.length; j++) {
185
+ const a = nodeMap.get(nodes[i])
186
+ const b = nodeMap.get(nodes[j])
187
+ if (!a || !b) continue
188
+ const shareFp = [...a.fps].some((fp) => b.fps.has(fp))
189
+ if (shareFp) continue // this title pair is already an exact-duplicate
190
+ candidates.push({
191
+ kind: 'title-overlap',
192
+ severity: 'advisory',
193
+ nodes: [nodes[i], nodes[j]],
194
+ scenario: a.title,
195
+ detail: 'same scenario title, differing steps — a weaker overlap hint the Warden judges',
196
+ })
197
+ }
198
+ }
199
+ }
200
+ return candidates
201
+ }
202
+
203
+ export function hasBlocking(candidates: Candidate[]): boolean {
204
+ return candidates.some((c) => c.severity === 'blocking')
205
+ }
206
+
207
+ // ── Render ──
208
+ export function renderCandidates(candidates: Candidate[], specDir: string): string {
209
+ const blocking = candidates.filter((c) => c.severity === 'blocking')
210
+ const advisory = candidates.filter((c) => c.severity === 'advisory')
211
+ const lines: string[] = [`check-scenario-overlap: spec-dir=${specDir}`]
212
+ lines.push(`blocking[${blocking.length}]:`)
213
+ for (const c of blocking) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
214
+ lines.push(`advisory[${advisory.length}]:`)
215
+ for (const c of advisory) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
216
+ lines.push('note: advisory — candidates feed the Warden formation pass; the engine writes nothing')
217
+ return lines.join('\n')
218
+ }
219
+
220
+ // ── CLI ──
221
+ export function main(argv: string[]): number {
222
+ let specDir = '.'
223
+ let mode: 'audit' | 'check' = 'audit'
224
+ let format: 'toon' | 'json' = 'toon'
225
+ for (let i = 0; i < argv.length; i++) {
226
+ const a = argv[i]
227
+ if (a === '--spec-dir') specDir = argv[++i] ?? '.'
228
+ else if (a === '--check') mode = 'check'
229
+ else if (a === '--format') format = (argv[++i] as 'toon' | 'json') ?? 'toon'
230
+ }
231
+ const candidates = detect(scanSuites(specDir))
232
+ if (mode === 'check') {
233
+ if (hasBlocking(candidates)) {
234
+ process.stderr.write(
235
+ `check-scenario-overlap: ${candidates.filter((c) => c.severity === 'blocking').length} exact-duplicate candidate(s)\n`,
236
+ )
237
+ return 1
238
+ }
239
+ process.stdout.write('check-scenario-overlap: no exact-duplicate candidates\n')
240
+ return 0
241
+ }
242
+ if (format === 'json') process.stdout.write(`${JSON.stringify(candidates)}\n`)
243
+ else process.stdout.write(`${renderCandidates(candidates, specDir)}\n`)
244
+ return 0
245
+ }
246
+
247
+ if (import.meta.url === `file://${process.argv[1]}`) {
248
+ process.exit(main(process.argv.slice(2)))
249
+ }
@@ -0,0 +1,17 @@
1
+ # check-spec-structure
2
+
3
+ Internal SDD skill — the concrete engine for **spec-structure checking**. Audits one project spec's
4
+ internal node-shape and emits a finding set for the formation Warden: the intra-spec successor to the
5
+ retired cross-spec `dedupe-specs`/`split-spec` tools, now that one project is one spec.
6
+
7
+ ```bash
8
+ node scripts/check-spec-structure.mts --spec-dir <spec> # audit (TOON finding set)
9
+ node scripts/check-spec-structure.mts --spec-dir <spec> --check # CI guard (fails on blocking)
10
+ ```
11
+
12
+ Three deterministic checks — **untagged-node** (blocking: a spec-typed node with no `concept:`),
13
+ **oversized-node** (advisory: `.feature` over the granularity threshold), and **incomplete-node**
14
+ (advisory: a behavioral leaf spec missing one of the four required `spec.md` sections — `## What`,
15
+ `## Use Cases`, `## Control Flow`, `## Scenario map`) — plus an intra-spec contradiction arm judged
16
+ by the Warden. Read-only, frontmatter + scenario-count + section-heading only; writes nothing. See
17
+ [`SKILL.md`](./SKILL.md) for the full contract. Not user-invocable.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: check-spec-structure
3
+ description: "Partial Skill: invoke by name only — project-spec/check-spec-structure's engine that audits a project spec's internal node-shape — feeds the formation Warden, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Spec Structure
10
+
11
+ The concrete engine for **spec-structure checking**.
12
+ It audits the **internal node-shape** of one project spec and returns a finding set for the
13
+ formation **Warden** — the **intra-spec** successor to the retired cross-spec `dedupe-specs`/`split-spec`
14
+ tools, now that one project is **one spec**. It carries a self-contained `.mts` script (the repo's
15
+ node-≥23.6 / no-deps convention). It is the **node-shape** sibling of `align-spec` (prose↔suite
16
+ alignment) and `concept-index` (the by-concept view).
17
+
18
+ ## The three deterministic checks (and two judgment arms)
19
+
20
+ - **untagged-node** (blocking) — a spec-typed node README carrying no `concept:` tag, so it never
21
+ appears in the by-concept index (`concept-index`). `--check` fails on it.
22
+ - **oversized-node** (advisory) — a node whose sibling `.feature` scenario count exceeds the
23
+ granularity threshold. Carries a deterministic **shape profile** — plain scenario count, tagged
24
+ (`@rubric`-preceded) scenario count, and section-cluster count (comment headers of either
25
+ `# ── … ──` or `# ---- … ----` style) as a soft breadth hint — and prescribes **no route**.
26
+ Surfaced in the audit but **never** fails `--check`.
27
+ - **incomplete-node** (advisory) — a **behavioral leaf spec** (a README with `spec-type: behavioral`
28
+ and a colocated `.feature`) whose `spec.md` is missing one of the four required sections —
29
+ `## What`, `## Use Cases`, `## Control Flow`, `## Scenario map` (`sdd:spec-format-governance`). A
30
+ spec that stops at `## Use Cases` never draws its CFG or scenario map. Spec-type-aware: a reference
31
+ artifact (`## Subject`, no scenario map) and an index node (no colocated `.feature`) are **not**
32
+ held to the shape. **Advisory** while a corpus is brought up to the four-section shape; flip it to
33
+ **blocking** with a follow-up once the corpus is clean.
34
+ - **breadth-vs-depth routing** and **intra-spec contradiction** are **Warden judgments** (the spec's
35
+ `@rubric` scenarios) — the engine ships no code for them. The Warden reads the shape profile and
36
+ routes: breadth (many clusters/plain scenarios) → propose a node split; depth with a deterministic
37
+ suite → down-level via the verify-scenarios bridge; depth with agent-behavior scenarios → redesign.
38
+ **placement-drift** is deliberately *not* a deterministic check: a concept legitimately scatters
39
+ across folders (that scatter is what `concept-index` re-unifies), so a concept-vs-folder scan would
40
+ false-positive on correctly-placed nodes.
41
+
42
+ ## Run the scan
43
+
44
+ ```bash
45
+ node "<skill>/scripts/check-spec-structure.mts" [--spec-dir <spec>] [--check] [--max-scenarios <n>] [--format toon|json]
46
+ ```
47
+
48
+ - Default `--spec-dir` is the current directory; default `--format` is **TOON** (the token-efficient
49
+ form the Warden scans); default `--max-scenarios` is **40**.
50
+ - **Audit mode** (default) emits the finding set — a `blocking[]` group then an `advisory[]` group,
51
+ each finding naming the node. `--format json` emits the same findings as a flat JSON array.
52
+ - **`--check`** (CI guard) exits **non-zero** iff a **blocking** finding exists and **writes
53
+ nothing**; advisory-only findings still exit zero. Wire it after `concept-index --check` in
54
+ `verify:specs-new` so the project-spec stays structurally clean.
55
+
56
+ When `node` is absent, an agent performs the same derivation by hand: for each `<cap>/<unit>/README.md`
57
+ with a `spec-type`, flag it untagged if it carries no `concept:`; for each with a sibling `.feature`,
58
+ count `Scenario:` lines and flag oversized over the threshold.
59
+
60
+ ## Boundaries
61
+
62
+ Frontmatter + scenario-count only — it never reads a node body, owns no lifecycle state, and writes
63
+ nothing. It **never acts** on a finding (a split, a reconcile) — that is the Warden's
64
+ (`sdd:formation-loop`) under its own self-clear-vs-escalate verdict. It does **not** render the
65
+ by-concept view (`concept-index`), advise a new node's home (`place-node`), or check gate legality
66
+ (`spec-gate`'s `check-spec-state`, fail-closed at the gate).
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // check-spec-structure — project-spec/check-spec-structure's concrete engine. Audits the internal
3
+ // node-shape of one project spec and emits a finding set for the formation Warden: the intra-spec
4
+ // successor to the retired cross-spec dedupe/split tools, now that one project is one spec
5
+ // (see this skill's README.md).
6
+ //
7
+ // Two deterministic checks, each with a severity:
8
+ // - untagged-node (blocking) — a spec-typed node README with no `concept:` tag, so it never
9
+ // appears in the by-concept index (../concept-index/). `--check` fails on it.
10
+ // - oversized-node (advisory) — a node whose sibling `.feature` scenario count exceeds the
11
+ // granularity threshold; carries a deterministic shape profile (plain/tagged counts + section-
12
+ // cluster count) but prescribes no route. Never fails `--check`.
13
+ // - missing-glossary (advisory) — the project spec has no root `glossary.md`, so its ubiquitous
14
+ // language has no home and a term can be used without ever being defined. A root FILE, not a
15
+ // folder: every mandated folder is an exception to screaming architecture. Never fails `--check`.
16
+ // - incomplete-node (advisory) — a behavioral leaf spec (a README with `spec-type: behavioral` and
17
+ // a colocated `.feature`) missing one of the four required `spec.md` sections (`## What`,
18
+ // `## Use Cases`, `## Control Flow`, `## Scenario map` — `sdd:spec-format-governance`). A spec
19
+ // that stops at `## Use Cases` never draws its CFG or scenario map. Advisory while a corpus is
20
+ // brought up to the four-section shape; a follow-up flips it to blocking once clean.
21
+ // Breadth-vs-depth routing and intra-spec contradiction are Warden judgment (the @rubric scenarios)
22
+ // — no engine code here.
23
+ //
24
+ // Pure derivation from frontmatter + scenario counts + the README's level-2 section headings (the
25
+ // four-section-shape signal): no node *prose* reaches a finding, and the engine writes nothing. No
26
+ // dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
27
+ // node:test; running the file directly drives the CLI.
28
+
29
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
33
+ export const DEFAULT_MAX_SCENARIOS = 40
34
+
35
+ // The four `spec.md` sections a behavioral leaf spec must carry, in order
36
+ // (`sdd:spec-format-governance`). `## References` is optional and not required here.
37
+ export const REQUIRED_BEHAVIORAL_SECTIONS = ['What', 'Use Cases', 'Control Flow', 'Scenario map']
38
+
39
+ export type FindingKind = 'untagged-node' | 'oversized-node' | 'missing-glossary' | 'incomplete-node'
40
+ export type Severity = 'blocking' | 'advisory'
41
+
42
+ export interface NodeRecord {
43
+ /** Path relative to the spec directory (POSIX). */
44
+ relPath: string
45
+ /** The top-level folder — the capability. */
46
+ capability: string
47
+ /** Display form — a README.md node shows its folder. */
48
+ display: string
49
+ concepts: string[]
50
+ specType?: string
51
+ hasFeature: boolean
52
+ /** Level-2 `## ` headings in the README body, in document order. */
53
+ sectionHeadings: string[]
54
+ scenarioCount: number
55
+ plainCount: number
56
+ taggedCount: number
57
+ clusterCount: number
58
+ }
59
+
60
+ export interface Finding {
61
+ kind: FindingKind
62
+ severity: Severity
63
+ node: string
64
+ detail: string
65
+ }
66
+
67
+ // ── Frontmatter parse (concept + spec-type only — the classification signal) ──
68
+ export interface NodeFrontmatter {
69
+ concepts: string[]
70
+ specType?: string
71
+ }
72
+
73
+ export function parseNodeFrontmatter(text: string): NodeFrontmatter {
74
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
75
+ if (!m) return { concepts: [] }
76
+ const fm: NodeFrontmatter = { concepts: [] }
77
+ const lines = m[1].split('\n').map((l) => l.replace(/\r$/, ''))
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i]
80
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
81
+ if (line.length - line.trimStart().length !== 0) continue // top-level keys only
82
+ const [key, ...rest] = line.trim().split(':')
83
+ const value = rest.join(':').trim()
84
+ if (key === 'spec-type') fm.specType = unquote(value)
85
+ else if (key === 'concept') {
86
+ if (value === '' || value === '|' || value === '>') {
87
+ for (let j = i + 1; j < lines.length; j++) {
88
+ const item = lines[j]
89
+ if (item.trim() === '') continue
90
+ if (item.length - item.trimStart().length === 0) break
91
+ const dash = /^\s*-\s+(.*)$/.exec(item)
92
+ if (dash) fm.concepts.push(unquote(dash[1].trim()))
93
+ }
94
+ } else {
95
+ fm.concepts.push(...parseScalarOrFlow(value))
96
+ }
97
+ }
98
+ }
99
+ return fm
100
+ }
101
+
102
+ export function parseScalarOrFlow(value: string): string[] {
103
+ const v = value.trim()
104
+ if (v.startsWith('[') && v.endsWith(']')) {
105
+ return v
106
+ .slice(1, -1)
107
+ .split(',')
108
+ .map((s) => unquote(s.trim()))
109
+ .filter((s) => s.length > 0)
110
+ }
111
+ const single = unquote(v)
112
+ return single.length > 0 ? [single] : []
113
+ }
114
+
115
+ function unquote(v: string): string {
116
+ return v.replace(/^["']|["']$/g, '')
117
+ }
118
+
119
+ // ── Section headings — the four-section shape signal (level-2 `## ` headings, fence-aware) ──
120
+ export function parseSectionHeadings(text: string): string[] {
121
+ const headings: string[] = []
122
+ let inFence = false
123
+ for (const raw of text.split('\n')) {
124
+ const line = raw.replace(/\r$/, '')
125
+ if (/^\s*(```|~~~)/.test(line)) {
126
+ inFence = !inFence
127
+ continue
128
+ }
129
+ if (inFence) continue
130
+ const m = /^##\s+(.+?)\s*$/.exec(line)
131
+ if (m) headings.push(m[1].replace(/`/g, '').trim())
132
+ }
133
+ return headings
134
+ }
135
+
136
+ // ── Scenario count — the granularity signal (frontmatter-free, count `Scenario:` lines) ──
137
+ export function countScenarios(featureText: string): number {
138
+ let n = 0
139
+ for (const line of featureText.split('\n')) {
140
+ if (/^\s*Scenario:/.test(line)) n++
141
+ }
142
+ return n
143
+ }
144
+
145
+ // ── Shape profile — the deterministic breadth-vs-depth signal for the Warden ──
146
+ export interface ShapeProfile {
147
+ scenarioCount: number
148
+ plainCount: number
149
+ taggedCount: number
150
+ clusterCount: number
151
+ }
152
+
153
+ const SECTION_HEADER = /^\s*#\s*(?:──|-{3,})/
154
+
155
+ export function profileFeature(featureText: string): ShapeProfile {
156
+ const lines = featureText.split('\n')
157
+ let scenarioCount = 0
158
+ let taggedCount = 0
159
+ let clusterCount = 0
160
+ let pendingRubric = false
161
+ for (const line of lines) {
162
+ if (/^\s*Scenario:/.test(line)) {
163
+ scenarioCount++
164
+ if (pendingRubric) taggedCount++
165
+ pendingRubric = false
166
+ continue
167
+ }
168
+ const trimmed = line.trim()
169
+ if (trimmed === '') continue
170
+ if (trimmed.startsWith('@')) {
171
+ if (/(^|\s)@rubric(\s|$)/.test(trimmed)) pendingRubric = true
172
+ continue
173
+ }
174
+ if (SECTION_HEADER.test(line)) {
175
+ clusterCount++
176
+ pendingRubric = false
177
+ continue
178
+ }
179
+ pendingRubric = false
180
+ }
181
+ return { scenarioCount, plainCount: scenarioCount - taggedCount, taggedCount, clusterCount }
182
+ }
183
+
184
+ function displayPath(relPath: string): string {
185
+ const p = relPath.replace(/\\/g, '/')
186
+ return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
187
+ }
188
+
189
+ // ── Scan the project-spec — every node carrying a spec-type or concept tag ──
190
+ export function scanProjectSpec(specDir: string): NodeRecord[] {
191
+ const records: NodeRecord[] = []
192
+ walk(specDir, specDir, records)
193
+ return records.sort((a, b) => a.display.localeCompare(b.display))
194
+ }
195
+
196
+ function walk(dir: string, specDir: string, out: NodeRecord[]): void {
197
+ const entries = readdirSync(dir, { withFileTypes: true })
198
+ for (const entry of entries) {
199
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
200
+ const full = join(dir, entry.name)
201
+ if (entry.isDirectory()) {
202
+ walk(full, specDir, out)
203
+ } else if (entry.name === 'README.md') {
204
+ const readmeText = readFileSync(full, 'utf8')
205
+ const fm = parseNodeFrontmatter(readmeText)
206
+ if (fm.specType === undefined && fm.concepts.length === 0) continue
207
+ const sectionHeadings = parseSectionHeadings(readmeText)
208
+ const features = entries.filter((e) => e.isFile() && e.name.endsWith('.feature'))
209
+ const hasFeature = features.length > 0
210
+ const profile = hasFeature
211
+ ? profileFeature(readFileSync(join(dir, features[0].name), 'utf8'))
212
+ : { scenarioCount: 0, plainCount: 0, taggedCount: 0, clusterCount: 0 }
213
+ const relPath = full.slice(specDir.length + 1).replace(/\\/g, '/')
214
+ out.push({
215
+ relPath,
216
+ capability: relPath.split('/')[0],
217
+ display: displayPath(relPath),
218
+ concepts: fm.concepts,
219
+ specType: fm.specType,
220
+ sectionHeadings,
221
+ hasFeature,
222
+ scenarioCount: profile.scenarioCount,
223
+ plainCount: profile.plainCount,
224
+ taggedCount: profile.taggedCount,
225
+ clusterCount: profile.clusterCount,
226
+ })
227
+ }
228
+ }
229
+ }
230
+
231
+ // ── Checks ──
232
+ // A spec-typed node with no concept is orphaned from the by-concept index → blocking.
233
+ export function checkUntagged(records: NodeRecord[]): Finding[] {
234
+ return records
235
+ .filter((r) => r.specType !== undefined && r.concepts.length === 0)
236
+ .map((r) => ({
237
+ kind: 'untagged-node' as const,
238
+ severity: 'blocking' as const,
239
+ node: r.display,
240
+ detail: `spec-type: ${r.specType} but no concept tag — orphaned from the by-concept index`,
241
+ }))
242
+ }
243
+
244
+ // A node whose suite exceeds the granularity threshold → advisory split candidate.
245
+ export function checkOversized(records: NodeRecord[], maxScenarios: number): Finding[] {
246
+ return records
247
+ .filter((r) => r.scenarioCount > maxScenarios)
248
+ .map((r) => ({
249
+ kind: 'oversized-node' as const,
250
+ severity: 'advisory' as const,
251
+ node: r.display,
252
+ detail: `${r.scenarioCount} scenarios > ${maxScenarios} — shape profile: plain ${r.plainCount}, tagged ${r.taggedCount}, clusters ${r.clusterCount} (soft breadth hint); the Warden routes breadth-vs-depth`,
253
+ }))
254
+ }
255
+
256
+ // A project spec with no root glossary.md has nowhere to define its ubiquitous language → advisory.
257
+ export function checkGlossary(specDir: string): Finding[] {
258
+ if (existsSync(join(specDir, 'glossary.md'))) return []
259
+ return [
260
+ {
261
+ kind: 'missing-glossary' as const,
262
+ severity: 'advisory' as const,
263
+ node: 'glossary.md',
264
+ detail:
265
+ 'no root glossary.md — the project has no home for its ubiquitous language, so a term can be used without ever being defined; a root file, never a folder',
266
+ },
267
+ ]
268
+ }
269
+
270
+ // A behavioral leaf spec (README with `spec-type: behavioral` + a colocated `.feature`) whose
271
+ // spec.md stops short of the four required sections never drew its CFG or scenario map → advisory
272
+ // until a corpus is brought up to the four-section shape, then flipped to blocking by a follow-up.
273
+ export function checkIncomplete(records: NodeRecord[]): Finding[] {
274
+ return records
275
+ .filter((r) => r.specType === 'behavioral' && r.hasFeature)
276
+ .map((r) => {
277
+ const missing = REQUIRED_BEHAVIORAL_SECTIONS.filter((s) => !r.sectionHeadings.includes(s))
278
+ return { record: r, missing }
279
+ })
280
+ .filter(({ missing }) => missing.length > 0)
281
+ .map(({ record, missing }) => ({
282
+ kind: 'incomplete-node' as const,
283
+ severity: 'advisory' as const,
284
+ node: record.display,
285
+ detail: `behavioral leaf spec missing required section(s): ${missing.map((s) => `## ${s}`).join(', ')} — a spec that stops at ## Use Cases never draws its CFG or scenario map (sdd:spec-format-governance)`,
286
+ }))
287
+ }
288
+
289
+ export function audit(records: NodeRecord[], maxScenarios: number, specDir?: string): Finding[] {
290
+ return [
291
+ ...checkUntagged(records),
292
+ ...checkOversized(records, maxScenarios),
293
+ ...checkIncomplete(records),
294
+ ...(specDir === undefined ? [] : checkGlossary(specDir)),
295
+ ]
296
+ }
297
+
298
+ export function hasBlocking(findings: Finding[]): boolean {
299
+ return findings.some((f) => f.severity === 'blocking')
300
+ }
301
+
302
+ // ── Render ──
303
+ export function renderFindings(findings: Finding[], specDir: string): string {
304
+ const blocking = findings.filter((f) => f.severity === 'blocking')
305
+ const advisory = findings.filter((f) => f.severity === 'advisory')
306
+ const lines: string[] = [`check-spec-structure: spec-dir=${specDir}`]
307
+ lines.push(`blocking[${blocking.length}]:`)
308
+ for (const f of blocking) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`)
309
+ lines.push(`advisory[${advisory.length}]:`)
310
+ for (const f of advisory) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`)
311
+ lines.push('note: advisory — findings feed the Warden formation pass; the engine writes nothing')
312
+ return lines.join('\n')
313
+ }
314
+
315
+ // ── CLI ──
316
+ export function main(argv: string[]): number {
317
+ let specDir = '.'
318
+ let mode: 'audit' | 'check' = 'audit'
319
+ let format: 'toon' | 'json' = 'toon'
320
+ let maxScenarios = DEFAULT_MAX_SCENARIOS
321
+ for (let i = 0; i < argv.length; i++) {
322
+ const a = argv[i]
323
+ if (a === '--spec-dir') specDir = argv[++i] ?? '.'
324
+ else if (a === '--check') mode = 'check'
325
+ else if (a === '--max-scenarios') maxScenarios = Number(argv[++i] ?? DEFAULT_MAX_SCENARIOS)
326
+ else if (a === '--format') format = (argv[++i] as 'toon' | 'json') ?? 'toon'
327
+ }
328
+ const findings = audit(scanProjectSpec(specDir), maxScenarios, specDir)
329
+ if (mode === 'check') {
330
+ if (hasBlocking(findings)) {
331
+ process.stderr.write(
332
+ `check-spec-structure: ${findings.filter((f) => f.severity === 'blocking').length} blocking finding(s)\n`,
333
+ )
334
+ return 1
335
+ }
336
+ process.stdout.write('check-spec-structure: no blocking findings\n')
337
+ return 0
338
+ }
339
+ if (format === 'json') process.stdout.write(`${JSON.stringify(findings)}\n`)
340
+ else process.stdout.write(`${renderFindings(findings, specDir)}\n`)
341
+ return 0
342
+ }
343
+
344
+ if (import.meta.url === `file://${process.argv[1]}`) {
345
+ process.exit(main(process.argv.slice(2)))
346
+ }