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,583 @@
1
+ #!/usr/bin/env node
2
+ // blast-estimate — the concrete engine for blast-estimate's derivation: compute a Mission's BLAST
3
+ // (low/medium/high) from its touch-set + the project corpus instead of trusting the hand-typed guess,
4
+ // then line the computed level up against the declared one (agrees / under-called / over-called). See
5
+ // .agents/specs/sdd/blast-estimate/README.md for the full contract this mirrors.
6
+ //
7
+ // Three inputs, each measured — never inferred — from the corpus:
8
+ // - count — how many of the touch-set's areas resolve to a known work area
9
+ // - centrality — dependency fan-in: how many OTHER work areas' files reference a touched one
10
+ // - sensitivity — whether a touched area is MARKED in the opt-in `.agents/sdd/sensitive-paths.toml`
11
+ // (absent file = no area sensitive, not an error; a file that fails to parse fails LOUD — the
12
+ // estimate computes no level rather than silently reading it as "nothing marked")
13
+ //
14
+ // Two exclusions are structural, not just documented: this engine takes no "breaking"/compatibility
15
+ // input at all (that dimension cannot leak in), and centrality is measured fan-in only — a work area's
16
+ // NAME (e.g. "public") never enters the score.
17
+ //
18
+ // A work area not found in the corpus is SURFACED (`unresolved`), never silently dropped. An empty
19
+ // touch-set — or one that resolves to zero known areas — computes `unknown`, never `low` ("nothing
20
+ // touched is not evidence of low reach").
21
+ //
22
+ // Work-area recovery is NOT this engine's to invent: it REUSES the sibling touch-set-correction's
23
+ // pure `fileToNode(path, layouts)` over `discoverLayouts`' declared `ProjectLayout[]` — the same
24
+ // cross-skill reuse collision-ladder does (which imports `fileToNode` + `collectChangedFiles` from
25
+ // the same module). This matters and is not cosmetic: a work area spans MULTIPLE declared roots — a
26
+ // spec root AND an impl root (`sdd/mission-graph` lives at BOTH `.agents/specs/sdd/mission-graph/`
27
+ // and `plugins/sdd/skills/mission-graph/`) — so a node's identity comes from the declared layout,
28
+ // never from a path's shape. Any local walk that keyed on "first two segments" would split one node
29
+ // in two, miss it entirely from the repo root, and measure fan-in over spec prose alone.
30
+ //
31
+ // Read-only: only readdirSync/readFileSync/statSync ever run here. It RETURNS the estimate; the
32
+ // mission-graph's single writer records it. Pure functions are exported for node:test; running the
33
+ // file directly drives the CLI.
34
+
35
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
36
+ import { join, relative } from 'node:path'
37
+ import {
38
+ discoverLayouts,
39
+ fileToNode,
40
+ type ProjectLayout,
41
+ } from '../../touch-set-correction/scripts/touch-set-correction.mts'
42
+
43
+ export type { ProjectLayout }
44
+
45
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
46
+ const SENSITIVE_PATHS_FILE = '.agents/sdd/sensitive-paths.toml'
47
+
48
+ export type BlastLevel = 'low' | 'medium' | 'high'
49
+ export type DeclaredBlast = BlastLevel | 'unknown'
50
+
51
+ // ── Corpus scan — work-area discovery over the DECLARED layouts (never a path-shape guess) ──
52
+
53
+ /** Every repo-relative file path under `dir` (recursively), skipping build/vendor noise. Paths are
54
+ * made relative with `path.relative`, never string-sliced: `join('.', x)` normalizes the `./` away,
55
+ * so slicing by `root.length` silently corrupts every path under a RELATIVE root (`--root .`, the
56
+ * CLI's default) while working fine under an absolute one. */
57
+ function walkFiles(dir: string, root: string, out: string[]): void {
58
+ let entries: import('node:fs').Dirent[]
59
+ try {
60
+ entries = readdirSync(dir, { withFileTypes: true })
61
+ } catch {
62
+ return // an undeclared or absent root contributes nothing
63
+ }
64
+ for (const entry of entries) {
65
+ if (SKIP_DIRS.has(entry.name)) continue
66
+ const full = join(dir, entry.name)
67
+ if (entry.isDirectory()) walkFiles(full, root, out)
68
+ else out.push(relative(root, full).replace(/\\/g, '/'))
69
+ }
70
+ }
71
+
72
+ /**
73
+ * discoverWorkAreas — the corpus's work areas, grouped by node id, over the DECLARED layouts. Walks
74
+ * every root of every project, maps each file through touch-set-correction's pure `fileToNode`, and
75
+ * groups by the node it names. Because a node's roots include BOTH its spec root and its impl root,
76
+ * one node's file set spans both trees — which is exactly what makes fan-in measure real dependency
77
+ * rather than spec-prose cross-reference. A file that maps to no node (`fileToNode` returns null —
78
+ * the existing "unmapped" concept) is dropped from the area map, never invented into an atom.
79
+ *
80
+ * Values are repo-relative paths; `root` is the repo root they are resolved against.
81
+ */
82
+ export function discoverWorkAreas(layouts: ProjectLayout[], root: string): Map<string, string[]> {
83
+ const byNode = new Map<string, string[]>()
84
+ const seen = new Set<string>()
85
+ for (const layout of layouts) {
86
+ for (const rawRoot of layout.roots) {
87
+ const rel = rawRoot.replace(/\/+$/, '')
88
+ const files: string[] = []
89
+ walkFiles(join(root, rel), root, files)
90
+ for (const path of files) {
91
+ if (seen.has(path)) continue // a path under two declared roots is counted once
92
+ seen.add(path)
93
+ const node = fileToNode(path, layouts)
94
+ if (node === null) continue // unmapped — surfaced by touch-set-correction, not an atom here
95
+ const bucket = byNode.get(node)
96
+ if (bucket) bucket.push(path)
97
+ else byNode.set(node, [path])
98
+ }
99
+ }
100
+ }
101
+ return byNode
102
+ }
103
+
104
+ function escapeRegExp(s: string): string {
105
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
106
+ }
107
+
108
+ // ── The reference matcher — the forms the corpus ACTUALLY uses to name a work area ──
109
+ //
110
+ // A bare `project/capability` id is what a touch-set and the ledger write, but it is NOT how prose
111
+ // references an area. Real references are links and paths; matching the bare id alone scored 56 of
112
+ // this repo's 62 `sdd` areas at fan-in 0 — including `sdd/spec-gate`, referenced by a dozen files —
113
+ // while the few non-zero scores came from glossary rows using an id as a typographic EXAMPLE. That
114
+ // measures "how often a name got used as documentation filler", not "how much of the project leans
115
+ // on this area", which is the opposite of what the rubric asks for and worse than no signal at all.
116
+ //
117
+ // Every path form is derived from the project's DECLARED layout roots, never hardcoded, so a
118
+ // re-rooted project keeps working. The `(?:[\w.-]+/)*` segment matters: a node's spec can sit NESTED
119
+ // under its root (`.agents/specs/sdd/authoring/spec-gate/`), in which case `fileToNode`'s
120
+ // capability-first rule maps the spec side to `sdd/authoring` and only the impl side to
121
+ // `sdd/spec-gate` — so a flat `<root>/<capability>/` derivation would still miss the spec path.
122
+ //
123
+ // This stays MENTION-based and cheap by design. Real produced/consumed symbol dependency is
124
+ // ssa-lowering / collision-ladder territory; the boundary was never the problem, only the recall.
125
+
126
+ /** Left boundary: a reference must not start mid-token. */
127
+ const LB = '(?:^|[^\\w/:.-])'
128
+
129
+ function stripTrailingSlash(s: string): string {
130
+ return s.replace(/\/+$/, '')
131
+ }
132
+
133
+ /** The reference forms that name `nodeId` unambiguously from ANYWHERE (each is project-qualified):
134
+ * the bare id (`sdd/spec-gate`), the skill-style ref (`sdd:spec-gate`), and a path under any of the
135
+ * project's declared roots, at any nesting depth (`plugins/sdd/skills/spec-gate/`,
136
+ * `.agents/specs/sdd/authoring/spec-gate/`). */
137
+ function globalReferencePattern(project: string, capability: string, roots: string[]): RegExp {
138
+ const alts = [
139
+ `${LB}${escapeRegExp(`${project}/${capability}`)}(?![\\w-])`,
140
+ `${LB}${escapeRegExp(`${project}:${capability}`)}(?![\\w-])`,
141
+ ]
142
+ for (const root of roots) {
143
+ alts.push(`${escapeRegExp(stripTrailingSlash(root))}/(?:[\\w.-]+/)*${escapeRegExp(capability)}/`)
144
+ }
145
+ return new RegExp(alts.join('|'))
146
+ }
147
+
148
+ /** The relative-link form (`../spec-gate/`, `../../authoring/spec-gate/`) — a sibling-node link. It
149
+ * carries no project, so it is only counted from a file in the SAME project; otherwise two projects
150
+ * sharing a capability name (`manage`, `design`) would cross-credit each other's fan-in. */
151
+ function relativeReferencePattern(capability: string): RegExp {
152
+ return new RegExp(`(?:\\.\\./)+(?:[\\w.-]+/)*${escapeRegExp(capability)}/`)
153
+ }
154
+
155
+ function projectRoots(project: string, layouts: ProjectLayout[]): string[] {
156
+ return layouts.filter((l) => l.project === project).flatMap((l) => l.roots)
157
+ }
158
+
159
+ /**
160
+ * computeFanInMap — centrality for the requested areas: area A's fan-in is the number of OTHER areas
161
+ * holding at least one file that REFERENCES A in any of the forms the corpus actually uses (see
162
+ * `globalReferencePattern` / `relativeReferencePattern`). Because a node's file set spans its spec
163
+ * AND impl roots, a reference from an implementation file counts exactly as spec prose does — fan-in
164
+ * measures the project's real lean on an area. An area's own files never count toward its own fan-in.
165
+ *
166
+ * `targets` defaults to every known area; passing only the areas actually being scored keeps the CLI
167
+ * cheap (a one-area touch-set costs 1 pattern × N files instead of 229 × N). Each file is read at
168
+ * most once, and an owner already credited for a target is skipped.
169
+ */
170
+ export function computeFanInMap(
171
+ byNode: Map<string, string[]>,
172
+ root: string,
173
+ layouts: ProjectLayout[],
174
+ targets?: string[],
175
+ ): Map<string, number> {
176
+ const ids = targets ?? [...byNode.keys()]
177
+ const specs = ids.map((id) => {
178
+ const slash = id.indexOf('/')
179
+ const project = id.slice(0, slash)
180
+ const capability = id.slice(slash + 1)
181
+ return {
182
+ id,
183
+ project,
184
+ global: globalReferencePattern(project, capability, projectRoots(project, layouts)),
185
+ relative: relativeReferencePattern(capability),
186
+ }
187
+ })
188
+ const referencers = new Map<string, Set<string>>(ids.map((id) => [id, new Set<string>()]))
189
+ for (const [owner, files] of byNode) {
190
+ const ownerProject = projectOf(owner)
191
+ for (const rel of files) {
192
+ let text: string
193
+ try {
194
+ text = readFileSync(join(root, rel), 'utf8')
195
+ } catch {
196
+ continue
197
+ }
198
+ for (const spec of specs) {
199
+ if (spec.id === owner) continue
200
+ const seen = referencers.get(spec.id)
201
+ if (seen?.has(owner)) continue // this owner already counted for `spec.id`
202
+ // A relative link carries no project, so it only counts within the same project.
203
+ if (spec.global.test(text) || (ownerProject === spec.project && spec.relative.test(text))) {
204
+ seen?.add(owner)
205
+ }
206
+ }
207
+ }
208
+ }
209
+ return new Map(ids.map((id) => [id, referencers.get(id)?.size ?? 0]))
210
+ }
211
+
212
+ // ── Sensitivity — declared, never inferred ──
213
+
214
+ /**
215
+ * Parses the opt-in `.agents/sdd/sensitive-paths.toml`: a `sensitive = [ "id", ... ]` string array.
216
+ *
217
+ * LINE-anchored and lenient, matching manage-spec-anchors' `anchors = [ … ]` parser byte for byte in
218
+ * shape — a leading comment, a trailing comment, or a neighbouring key are ordinary TOML and must
219
+ * parse. An earlier whole-file-anchored regex rejected all three, so a perfectly valid file that
220
+ * marked an area computed no level at all.
221
+ *
222
+ * An EMPTY file is `[]` (a real "nothing marked" declaration). A file with no `sensitive` array at
223
+ * all throws — that is genuinely malformed, and an unreadable marking is not evidence of no
224
+ * markings.
225
+ */
226
+ export function parseSensitivePaths(text: string): string[] {
227
+ if (text.trim() === '') return []
228
+ const m = /(^|\n)\s*sensitive\s*=\s*\[([\s\S]*?)\]/.exec(text)
229
+ if (!m) throw new Error('expected a `sensitive = [...]` array')
230
+ const out: string[] = []
231
+ for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim())
232
+ return out.filter((s) => s !== '')
233
+ }
234
+
235
+ export type SensitiveResult = { ok: true; marked: string[] } | { ok: false; error: string }
236
+
237
+ /** Reads the opt-in sensitive-paths file. **Absent** (ENOENT) = `{ ok: true, marked: [] }` — no area
238
+ * is sensitive, and that is NOT an error. Anything else — present-but-unparseable, unreadable
239
+ * (permissions), or not a regular file — = `{ ok: false, error }`, and the caller computes no level.
240
+ *
241
+ * ONLY ENOENT is benign. A bare `catch` here would swallow EACCES and a directory-at-the-path into
242
+ * "no markings", which fails in the DANGEROUS direction: the estimate silently UNDER-calls blast on
243
+ * exactly the areas a project took the trouble to mark. A read that cannot classify must fail loud,
244
+ * never default to the safe-looking answer — the same duty the absent-vs-unparseable scenarios draw:
245
+ * an unreadable marking is not evidence of no markings. */
246
+ export function readSensitivePaths(corpusRoot: string): SensitiveResult {
247
+ const file = join(corpusRoot, SENSITIVE_PATHS_FILE)
248
+ let text: string
249
+ try {
250
+ if (!statSync(file).isFile()) {
251
+ return { ok: false, error: `${SENSITIVE_PATHS_FILE} is not a regular file` }
252
+ }
253
+ text = readFileSync(file, 'utf8')
254
+ } catch (err) {
255
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, marked: [] }
256
+ return { ok: false, error: `${SENSITIVE_PATHS_FILE} is unreadable: ${(err as Error).message}` }
257
+ }
258
+ try {
259
+ return { ok: true, marked: parseSensitivePaths(text) }
260
+ } catch (err) {
261
+ return { ok: false, error: `${SENSITIVE_PATHS_FILE} is unreadable: ${(err as Error).message}` }
262
+ }
263
+ }
264
+
265
+ // ── The computation — count × centrality × sensitivity ──
266
+ //
267
+ // The rubric fixes the three inputs and their ordering properties; the exact arithmetic is this
268
+ // engine's choice (deliberately unpinned — see the node README). Buckets, not a smooth curve, keep
269
+ // the mapping easy to explain and to hold constant under review:
270
+ // - breadth max(countScore, coverageScore) — how much of the project is disturbed, measured
271
+ // two ways, whichever says MORE:
272
+ // * countScore 1 → 0; 2-3 → 1; 4+ → 3 (absolute reach: touching many areas is broad even
273
+ // in a large project the touch-set nowhere near covers)
274
+ // * coverageScore 3 iff the touch-set covers EVERY work area of a touched project that has
275
+ // >= 2 work areas, else 0 (relative reach: a 3-area project touched entirely IS
276
+ // project-wide — the barrier agreement holds at every project size >= 2, not just 4+)
277
+ // - centrality 0 → 0; 1-2 → 1; 3+ → 2
278
+ // - sensitivity any touched area marked → +2 (a single marking is enough to move the level)
279
+ // score >= 3 → high; score >= 1 → medium; score 0 → low.
280
+ //
281
+ // The `>= 2 work areas` guard on coverage is load-bearing, not a nicety, and since #238 it is the
282
+ // CONTRACT rather than this engine's private tiebreaker. It is keyed per PROJECT, not per corpus: a
283
+ // 1-area project inside a larger multi-project corpus is still never project-wide.
284
+ //
285
+ // Before #238 the frozen suite was self-contradictory here — on a 1-area project "a single
286
+ // peripheral work area" (→ low) and "a touch-set reaching across every work area of its project"
287
+ // (→ high) described the SAME input with opposite Thens, and this guard silently picked the winner.
288
+ // The suite now states the precondition itself: the project-wide scenario requires a project holding
289
+ // more than one work area, and "a lone work area is its whole project but is not project-wide reach"
290
+ // pins the 1-area answer to low FOR A LONE AREA WITH NO FAN-IN AND NO MARKING. Coverage is reach
291
+ // RELATIVE to a project, and a project of one has none to cover, so coverage never fires there and
292
+ // BREADTH rests on absolute count alone — centrality and sensitivity still score, so a lone area that
293
+ // is central or marked computes medium or high.
294
+
295
+ function countScore(n: number): number {
296
+ if (n <= 1) return 0
297
+ if (n <= 3) return 1
298
+ return 3
299
+ }
300
+
301
+ const COVERAGE_SCORE = 3
302
+
303
+ // Centrality thresholds are calibrated against REAL fan-in, which spans roughly 0..17 on a corpus of
304
+ // this size. They were originally 0/1-2/3+ — tuned while the reference matcher only saw bare ids, so
305
+ // the observed corpus max was 4 and "3+" read as the top of the scale. With the matcher fixed, "3+"
306
+ // covered most of the distribution and no single area could ever reach `high` on reach alone, which
307
+ // under-called every hub (`sdd/spec-gate`, referenced by 10 other areas, computed `medium`). The top
308
+ // tier now marks a genuine hub: touching an area that a large share of the project leans on IS
309
+ // project-scale reach, even at count 1.
310
+ function centralityScore(fanIn: number): number {
311
+ if (fanIn <= 0) return 0
312
+ if (fanIn <= 2) return 1
313
+ if (fanIn <= 6) return 2
314
+ return 3
315
+ }
316
+
317
+ const SENSITIVITY_SCORE = 2
318
+
319
+ export function levelFromScore(score: number): BlastLevel {
320
+ if (score >= 3) return 'high'
321
+ if (score >= 1) return 'medium'
322
+ return 'low'
323
+ }
324
+
325
+ export interface Reasons {
326
+ count: number
327
+ maxFanIn: number
328
+ sensitiveAreas: string[]
329
+ /** Every touched project the touch-set covers ENTIRELY (and which has >= 2 work areas) — the
330
+ * coverage half of breadth, named so a `high` on reach alone is explainable. */
331
+ projectWide: string[]
332
+ }
333
+
334
+ /** The project a work area belongs to — the first segment of its `project/capability` id. */
335
+ function projectOf(nodeId: string): string {
336
+ return nodeId.split('/')[0]
337
+ }
338
+
339
+ /**
340
+ * projectsCoveredEntirely — the touched projects whose EVERY work area is in the touch-set, among
341
+ * projects holding >= 2 work areas. Coverage is relative reach: it asks what fraction of a project
342
+ * is disturbed, where `count` only asks how many areas in absolute terms. A project with a single
343
+ * work area never qualifies (see the guard note above).
344
+ *
345
+ * "Every work area of its project" means every area the LAYOUTS declare — `byNode` is the discovered
346
+ * area map keyed by node id, so a project's area set is its nodes across all of its declared roots
347
+ * (spec + impl), not whatever a directory walk happened to find.
348
+ */
349
+ export function projectsCoveredEntirely(resolved: string[], byNode: Map<string, string[]>): string[] {
350
+ const areasByProject = new Map<string, string[]>()
351
+ for (const node of byNode.keys()) {
352
+ const p = projectOf(node)
353
+ const areas = areasByProject.get(p)
354
+ if (areas) areas.push(node)
355
+ else areasByProject.set(p, [node])
356
+ }
357
+ const resolvedSet = new Set(resolved)
358
+ const covered: string[] = []
359
+ for (const project of new Set(resolved.map(projectOf))) {
360
+ const areas = areasByProject.get(project) ?? []
361
+ if (areas.length < 2) continue // a 1-area project is never "project-wide" — see the guard note
362
+ if (areas.every((a) => resolvedSet.has(a))) covered.push(project)
363
+ }
364
+ return covered.sort()
365
+ }
366
+
367
+ /** The pure scoring step, once `count`/`maxFanIn`/`sensitiveAreas`/`projectWide` are known. Exported
368
+ * so the ordering-property scenarios (hub-vs-leaf, marked-vs-unmarked) can be driven directly. */
369
+ export function scoreBlast(
370
+ count: number,
371
+ maxFanIn: number,
372
+ sensitiveAreas: string[],
373
+ projectWide: string[] = [],
374
+ ): { level: BlastLevel; reasons: Reasons } {
375
+ const breadth = Math.max(countScore(count), projectWide.length > 0 ? COVERAGE_SCORE : 0)
376
+ const score = breadth + centralityScore(maxFanIn) + (sensitiveAreas.length > 0 ? SENSITIVITY_SCORE : 0)
377
+ return { level: levelFromScore(score), reasons: { count, maxFanIn, sensitiveAreas, projectWide } }
378
+ }
379
+
380
+ // ── The line-up — declared against computed ──
381
+
382
+ export type LineUpOutcome = 'agrees' | 'under-called' | 'over-called' | 'no-declared'
383
+
384
+ export interface LineUp {
385
+ outcome: LineUpOutcome
386
+ computed: BlastLevel
387
+ declared?: BlastLevel
388
+ }
389
+
390
+ const ORDER: BlastLevel[] = ['low', 'medium', 'high']
391
+
392
+ /** Lines the computed level up against the hand-typed declared one. An absent or `unknown` declared
393
+ * blast is not an error — it is `no-declared`, and the computed level still stands on its own. */
394
+ export function lineUp(computed: BlastLevel, declared?: DeclaredBlast): LineUp {
395
+ if (declared === undefined || declared === 'unknown') return { outcome: 'no-declared', computed }
396
+ const di = ORDER.indexOf(declared)
397
+ const ci = ORDER.indexOf(computed)
398
+ if (di === ci) return { outcome: 'agrees', computed, declared }
399
+ if (di < ci) return { outcome: 'under-called', computed, declared }
400
+ return { outcome: 'over-called', computed, declared }
401
+ }
402
+
403
+ // ── The whole estimate — pure once handed a corpus scan ──
404
+
405
+ export interface EstimateResult {
406
+ touchSet: string[]
407
+ resolved: string[]
408
+ unresolved: string[]
409
+ computed: BlastLevel | 'unknown' | null
410
+ reasons: Reasons | null
411
+ lineUp: LineUp | null
412
+ error?: string
413
+ }
414
+
415
+ /** What `estimateBlast` needs beyond the touch-set and the layouts. `root` is the repo root the
416
+ * layouts' repo-relative roots (and the opt-in sensitive-paths file) resolve against. */
417
+ export interface EstimateOptions {
418
+ root?: string
419
+ declared?: DeclaredBlast
420
+ }
421
+
422
+ /**
423
+ * estimateBlast — the whole derivation, over INJECTED layouts. Discovers the corpus's work areas by
424
+ * mapping every file under every declared root through touch-set-correction's pure `fileToNode`,
425
+ * resolves the touch-set against those areas (unresolved areas are surfaced, never dropped), reads
426
+ * the opt-in sensitive-paths file (fails loud on a malformed one — computed/reasons/lineUp all come
427
+ * back null, `error` names why), and — when at least one area resolved — scores
428
+ * breadth × centrality × sensitivity and lines the result up against `opts.declared`. A touch-set
429
+ * that resolves to zero known areas (including the empty touch-set) computes `unknown`: nothing
430
+ * touched is not evidence of low reach.
431
+ *
432
+ * Layouts are INJECTED rather than discovered here: `fileToNode` is pure, so tests construct layouts
433
+ * as fixtures over a constructed corpus (the frozen suite's "never the live corpus" preamble holds),
434
+ * while the CLI sources them from `discoverLayouts`.
435
+ */
436
+ export function estimateBlast(
437
+ touchSet: string[],
438
+ layouts: ProjectLayout[],
439
+ opts: EstimateOptions = {},
440
+ ): EstimateResult {
441
+ const root = opts.root ?? '.'
442
+ const byNode = discoverWorkAreas(layouts, root)
443
+ const known = new Set(byNode.keys())
444
+ // A touch-set is a SET of work areas: dedupe before counting. `count` measures how many distinct
445
+ // areas are disturbed, so naming one area twice must not read as twice the reach — otherwise the
446
+ // same change scores a higher level for being typed redundantly. The sibling touch-set-correction
447
+ // dedupes its own output, but the mission-graph store does not, so a duplicate genuinely arrives.
448
+ const unique = [...new Set(touchSet)]
449
+ const resolved = unique.filter((a) => known.has(a))
450
+ const unresolved = unique.filter((a) => !known.has(a))
451
+
452
+ const sensitive = readSensitivePaths(root)
453
+ if (!sensitive.ok) {
454
+ return { touchSet, resolved, unresolved, computed: null, reasons: null, lineUp: null, error: sensitive.error }
455
+ }
456
+
457
+ if (resolved.length === 0) {
458
+ return {
459
+ touchSet,
460
+ resolved,
461
+ unresolved,
462
+ computed: 'unknown',
463
+ reasons: { count: 0, maxFanIn: 0, sensitiveAreas: [], projectWide: [] },
464
+ lineUp: null,
465
+ }
466
+ }
467
+
468
+ const markedSet = new Set(sensitive.marked)
469
+ // Only the resolved areas are scored — fan-in for the rest of the corpus is never asked for.
470
+ const fanInMap = computeFanInMap(byNode, root, layouts, resolved)
471
+ const maxFanIn = Math.max(...resolved.map((a) => fanInMap.get(a) ?? 0))
472
+ const sensitiveAreas = resolved.filter((a) => markedSet.has(a))
473
+ const projectWide = projectsCoveredEntirely(resolved, byNode)
474
+ const { level, reasons } = scoreBlast(resolved.length, maxFanIn, sensitiveAreas, projectWide)
475
+
476
+ return { touchSet, resolved, unresolved, computed: level, reasons, lineUp: lineUp(level, opts.declared) }
477
+ }
478
+
479
+ // ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
480
+
481
+ function toonQuote(v: string): string {
482
+ if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
483
+ return v
484
+ }
485
+
486
+ export function renderResultToon(r: EstimateResult): string {
487
+ if (r.error) {
488
+ return [
489
+ `blast-estimate: error="${r.error}"`,
490
+ `unresolved[${r.unresolved.length}]: ${r.unresolved.map(toonQuote).join(';')}`,
491
+ ].join('\n')
492
+ }
493
+ const lines: string[] = []
494
+ const lu = r.lineUp
495
+ const lineUpPart = lu
496
+ ? lu.outcome === 'no-declared'
497
+ ? 'lineup=no-declared'
498
+ : `lineup=${lu.outcome}(declared=${lu.declared})`
499
+ : 'lineup=n/a'
500
+ lines.push(`blast-estimate: computed=${r.computed} ${lineUpPart}`)
501
+ if (r.reasons) {
502
+ lines.push(
503
+ `reasons: count=${r.reasons.count} maxFanIn=${r.reasons.maxFanIn} sensitiveAreas=[${r.reasons.sensitiveAreas.join(';')}] projectWide=[${r.reasons.projectWide.join(';')}]`,
504
+ )
505
+ }
506
+ lines.push(`resolved[${r.resolved.length}]: ${r.resolved.map(toonQuote).join(';')}`)
507
+ lines.push(`unresolved[${r.unresolved.length}]: ${r.unresolved.map(toonQuote).join(';')}`)
508
+ return lines.join('\n')
509
+ }
510
+
511
+ // ── CLI ──
512
+
513
+ function flag(argv: string[], name: string): string | undefined {
514
+ const i = argv.indexOf(name)
515
+ return i === -1 ? undefined : argv[i + 1]
516
+ }
517
+
518
+ function allFlags(argv: string[], name: string): string[] {
519
+ const out: string[] = []
520
+ for (let i = 0; i < argv.length; i++) {
521
+ if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1])
522
+ }
523
+ return out
524
+ }
525
+
526
+ function splitCsv(v: string | undefined): string[] {
527
+ if (v === undefined || v === '') return []
528
+ return v
529
+ .split(',')
530
+ .map((s) => s.trim())
531
+ .filter((s) => s.length > 0)
532
+ }
533
+
534
+ /** Parses one `--layout '<project>:<root1>,<root2>'` flag value into a ProjectLayout (the same shape
535
+ * and flag touch-set-correction accepts, so the two tools take identical layout overrides). */
536
+ export function parseLayoutFlag(value: string): ProjectLayout | null {
537
+ const idx = value.indexOf(':')
538
+ if (idx === -1) return null
539
+ const project = value.slice(0, idx).trim()
540
+ const roots = splitCsv(value.slice(idx + 1))
541
+ if (project === '' || roots.length === 0) return null
542
+ return { project, roots }
543
+ }
544
+
545
+ /** The legal `--declared` values, exactly. Returns `null` for anything else so the CLI can fail loud
546
+ * rather than let an unranked value masquerade as "below everything". Case-sensitive on purpose: the
547
+ * store writes these lowercase, and quietly accepting `High` would invite a second spelling. */
548
+ export function parseDeclared(raw: string): DeclaredBlast | null {
549
+ return raw === 'low' || raw === 'medium' || raw === 'high' || raw === 'unknown' ? raw : null
550
+ }
551
+
552
+ export function main(argv: string[]): number {
553
+ const root = flag(argv, '--root') ?? '.'
554
+ const touchSet = splitCsv(flag(argv, '--touch-set'))
555
+ const declaredRaw = flag(argv, '--declared')
556
+ // Validate rather than cast. `lineUp` ranks via ORDER.indexOf, so an unrecognized value scores -1
557
+ // and reads as "below every computed level" — silently fabricating the `under-called` finding this
558
+ // tool exists to raise, out of nothing but a typo. Fail loud instead: the same duty the
559
+ // sensitive-paths reader honors, and the dangerous direction is the one to refuse.
560
+ const declared = declaredRaw === undefined ? undefined : parseDeclared(declaredRaw)
561
+ if (declared === null) {
562
+ process.stderr.write(
563
+ `blast-estimate: --declared must be one of low, medium, high, unknown (got "${declaredRaw}")\n`,
564
+ )
565
+ return 1
566
+ }
567
+ const format = flag(argv, '--format') === 'json' ? 'json' : 'toon'
568
+
569
+ // Layouts come from `--layout` when given, else from discover-specs via discoverLayouts — the
570
+ // same resolution order touch-set-correction uses.
571
+ const layoutFlags = allFlags(argv, '--layout')
572
+ const layouts =
573
+ layoutFlags.length > 0
574
+ ? layoutFlags.map(parseLayoutFlag).filter((l): l is ProjectLayout => l !== null)
575
+ : discoverLayouts(root, root)
576
+
577
+ const result = estimateBlast(touchSet, layouts, { root, declared })
578
+
579
+ process.stdout.write(`${format === 'json' ? JSON.stringify(result, null, 2) : renderResultToon(result)}\n`)
580
+ return result.error ? 1 : 0
581
+ }
582
+
583
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))
@@ -0,0 +1,47 @@
1
+ # builder-impl-governance
2
+
3
+ This is an internal SDD governance about the Builder's bar at the **impl gate**.
4
+
5
+ It answers two questions: does the implementation meet the frozen suite, and is each scenario
6
+ verified at a level that earns confidence? In SDD, the `.feature` suite is frozen at the spec gate —
7
+ it is the contract the implementation is built against and cannot be edited to make the build pass.
8
+ This bar governs how conformance to that contract is demonstrated.
9
+
10
+ It is one half of a matched pair: its sibling **`builder-spec-governance`** asks "is the contract
11
+ testable and covered?" at the spec gate; this bar asks "does the implementation meet that frozen
12
+ contract?" at the impl gate.
13
+
14
+ ## What it requires
15
+
16
+ | Requirement | What it means |
17
+ | --- | --- |
18
+ | **The bar is not self-set** | Each check derives from the frozen suite — one per scenario — never free-authored from the producer's sense of done. The cold impl-judge re-derives the oracle (the expected answer) independently (ADR-0016). |
19
+ | **Verify as high as it doesn't hurt** | Choose each scenario's verification *level* to maximize confidence until cost, fragility, or feasibility bites: a cheap base, a thin end-to-end cap on the paths that matter, and boundary (the external dependency mocked) as the honest substitute where end-to-end is infeasible or unsafe. Record the level chosen and why. |
20
+ | **A graded subject still yields a boolean** | A non-deterministic subject reaches the per-scenario boolean through a rubric plus a threshold over N runs; the rubric stays out of the `.feature`. |
21
+ | **No green-by-tampering** | Passing means the behavior holds — never that a check was edited to pass or the frozen suite modified to make the implementation conform. |
22
+ | **Deterministic combinatorics go to units** | Where the domain has a deterministic inner layer, its combinatorial space (truth tables, matrices) is covered with unit tests drawn from the inner rules — the pyramid's base, separate from the one-verification-per-scenario duty. Missing that coverage is its own finding and withholds the pass. A non-deterministic subject has no such layer — verify at the acceptance level only. |
23
+
24
+ This is the SDD default for the `builder` impl bar; a plugin may bind its own per artifact-type, and
25
+ this one loads when the registry leaves `builder`/`impl` unbound.
26
+
27
+ ## Usage
28
+
29
+ One merged bar loaded by both faces at the **impl gate**:
30
+
31
+ - **impl-producer:** builds to it — derives its checks from the frozen suite and picks each
32
+ scenario's verification level
33
+ - **impl-judge:** verifies against it, re-deriving the oracle independently
34
+
35
+ `producer ≠ judge` holds at the agent level — the same bar, two independent readers.
36
+
37
+ ## Related governances
38
+
39
+ This bar owns conformance and per-scenario verification level. Its neighbors own everything
40
+ around that:
41
+
42
+ - **`builder-spec-governance`** — the other half of the pair: testability and coverage of the
43
+ contract, judged at the spec gate. That bar freezes what must hold; this one verifies it held.
44
+ - **`architect-impl-governance`** — the suite's overall pyramid shape. This bar picks each
45
+ scenario's level; the whole-suite shape is the architect's call.
46
+
47
+ Internal SDD governance (`user-invocable: false`). Not triggered by users directly.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: builder-impl-governance
3
+ description: "Partial Skill: invoke by name only"
4
+ user-invocable: false
5
+ metadata:
6
+ actor: builder
7
+ gate: impl
8
+ compose: union
9
+ ---
10
+
11
+ # Builder-Impl Governance — the conformance & verification-level bar
12
+
13
+ The **Builder** bar at the **impl gate**: does the implementation meet the frozen suite, and is
14
+ each scenario verified at a level that earns confidence? Loaded by both faces. The SDD default for the
15
+ `builder` impl bar; a plugin may bind its own, and this loads when the registry leaves `builder`/`impl`
16
+ unbound.
17
+
18
+ ## The bar
19
+
20
+ - **The bar is not self-set.** Each check derives from the frozen suite — one per scenario —
21
+ never free-authored from the producer's sense of done. The cold impl-judge re-derives the oracle
22
+ independently (ADR-0016).
23
+ - **Verify as high as it doesn't hurt.** Choose each scenario's verification **level** to maximize
24
+ confidence until cost, fragility, or feasibility bites: a cheap base, a **thin e2e cap** on the
25
+ paths that matter, **boundary** (the external mocked) as the honest substitute where e2e is
26
+ infeasible or unsafe. **Record the level and why.** The suite's overall pyramid shape is the
27
+ architect's call (`sdd:architect-impl-governance`).
28
+ - **A graded subject still yields a boolean.** Reach the per-scenario boolean through a rubric +
29
+ threshold over N runs; the rubric stays out of the `.feature`.
30
+ - **No green-by-tampering.** Passing means the behavior holds, not that a check was edited to pass;
31
+ the frozen suite is never modified to make the implementation conform.
32
+ - **Deterministic combinatorics go to units.** Where the domain has a deterministic inner layer, cover
33
+ its combinatorial space (truth tables, matrices) with unit tests drawn from the inner rules — the
34
+ pyramid's base, separate from the one-verification-per-scenario duty. Missing that coverage is its
35
+ own finding and withholds the pass. A non-deterministic subject has no such layer — verify at the
36
+ acceptance level only.
37
+
38
+ ## Key points (read-check)
39
+
40
+ 1. **The bar is not self-set** — checks derive from the frozen suite, one per scenario; the judge
41
+ re-derives the oracle independently.
42
+ 2. **Verify as high as it doesn't hurt** — cheap base, thin e2e cap, boundary as substitute where e2e
43
+ is infeasible/unsafe; record level and why.
44
+ 3. **No green-by-tampering** — passing is the behavior holding, never an edited check or a modified
45
+ suite.
46
+ 4. **Deterministic combinatorics go to units** (the pyramid base); missing that coverage withholds the
47
+ pass; a non-deterministic subject verifies at the acceptance level only.