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,657 @@
1
+ #!/usr/bin/env node
2
+ // collision-ladder — the finer-than-node ladder (issue #189, second bullet). A read-only, pairwise
3
+ // collision CLASSIFIER: given a KNOWN node-level collision between two missions (the mission-graph
4
+ // WAW-mutex already found it), descend a ladder of finer grains — file → region → semantic(scenario)
5
+ // — and stop at the first rung that classifies the clash HARD (must serialize) vs SOFT (can run in
6
+ // parallel, reconciled by rebase). Plus the shared-thin-file hard→soft downgrade: a file touched by
7
+ // many missions (router/barrel/registry) that would over-serialize gets the region/semantic descent
8
+ // to downgrade, and is flagged as an architectural smell. See
9
+ // .agents/specs/sdd/collision-ladder/README.md for the full contract.
10
+ //
11
+ // Architecture — pure derivation kept apart from IO, on purpose (touch-set-correction.mts's
12
+ // convention it mirrors):
13
+ // - isFeature / isCode / hunksDisjoint / classifyFile / classify are PURE: they take and return
14
+ // plain data — no fs/network access. Tests exercise these directly over CONSTRUCTED pairwise
15
+ // touched-detail — never a live git diff or the live mission-graph store.
16
+ // - readFileHunks / collectMissionTouch are the thin IO SEAM: readFileHunks shells out to
17
+ // `git diff -U0` for a file's touched line-ranges (the new region source); collectMissionTouch
18
+ // REUSES the sibling touch-set-correction's `collectChangedFiles` (which itself composes
19
+ // `resolve-governances` for artifact-type and `gherkin-cli diff` for changed scenarios — never a
20
+ // reimplemented differ) and layers the hunks on top. NOT unit-tested (network/binary/fs boundary).
21
+ // - main() is a thin CLI: it classifies a CONSTRUCTED ClassifyInput read as JSON (--input) or one
22
+ // sourced from two git ranges (--from-git), rendering TOON by default or `--format json`.
23
+ //
24
+ // This tool is READ-ONLY with respect to the mission graph: it never writes to it, never detects a
25
+ // collision (that is the mission-graph WAW-mutex's job — this runs only AFTER one is found), and
26
+ // never schedules (it returns a verdict; the scheduler consumes it). It descends a CODE collision to
27
+ // the SYMBOL rung — produced/consumed symbols classify it disjoint(soft) / write-write(hard,
28
+ // `symbol-waw`) / read-after-write(hard, `symbol-raw`); when the symbols cannot be inferred it stays
29
+ // hard, flagged `symbol-rung-deferred`. It does NOT do the ★ SSA-lowering doctrine (issue #189's
30
+ // third-bullet capstone: partitioning a change into missions, versioning a write-write into an ordered
31
+ // dependency) — that stays deferred. It descends only to DOWNGRADE a suspected false-hard —
32
+ // conservative-first, relax-on-evidence — never to raise a new collision.
33
+ //
34
+ // No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
35
+ // node:test; running the file directly drives the CLI.
36
+
37
+ import { execFileSync } from 'node:child_process'
38
+ import { dirname, join } from 'node:path'
39
+ import { fileURLToPath } from 'node:url'
40
+ import {
41
+ collectChangedFiles,
42
+ fileToNode,
43
+ type ProjectLayout,
44
+ } from '../../touch-set-correction/scripts/touch-set-correction.mts'
45
+
46
+ // ── Types ──
47
+
48
+ /** An inclusive touched line-range within a file (the region rung's grain). */
49
+ export interface LineRange {
50
+ start: number
51
+ end: number
52
+ }
53
+
54
+ /** The produced (written) and consumed (read) symbol names a code file's diff yields — the symbol
55
+ * rung's grain. */
56
+ export interface SymbolSet {
57
+ produced: string[]
58
+ consumed: string[]
59
+ }
60
+
61
+ /** One changed file of one mission under the colliding node. `hunks: null` means the line-hunks
62
+ * were not recorded (disjointness cannot be proven — the region rung must not clear it). `[]` means
63
+ * recorded-but-empty. `changedScenarios` is meaningful only for a `.feature` (the semantic rung).
64
+ * `symbols` is meaningful only for code (the symbol rung): `null` or `undefined` (both treated
65
+ * identically — un-inferable) means the produced/consumed detail could not be extracted, so the
66
+ * symbol rung stays hard, flagged `symbol-rung-deferred`. Optional so existing constructed fixtures
67
+ * that predate the symbol rung stay valid. */
68
+ export interface FileTouch {
69
+ path: string
70
+ artifactType: string
71
+ hunks: LineRange[] | null
72
+ changedScenarios: string[]
73
+ symbols?: SymbolSet | null
74
+ }
75
+
76
+ /** One mission's touched detail for the single colliding node. */
77
+ export interface MissionTouch {
78
+ mission: string
79
+ files: FileTouch[]
80
+ }
81
+
82
+ /** The classifier's whole input: the colliding node, the two missions' touched detail, an optional
83
+ * fleet-wide touching-mission degree per path (for shared-thin detection — defaults to the pair's
84
+ * 2), and the shared-thin degree threshold (default 3). */
85
+ export interface ClassifyInput {
86
+ node: string
87
+ x: MissionTouch
88
+ y: MissionTouch
89
+ degrees?: Record<string, number>
90
+ sharedThinThreshold?: number
91
+ }
92
+
93
+ export type Collision = 'hard' | 'soft'
94
+ export type Rung = 'file' | 'region' | 'semantic' | 'symbol' | 'node'
95
+ export type Confidence = 'high' | 'medium' | 'low'
96
+
97
+ /** The per-shared-file verdict — the atom the node rollup is folded from. */
98
+ export interface FileVerdict {
99
+ path: string
100
+ collision: Collision
101
+ rung: Rung
102
+ reason: string
103
+ sharedThin: boolean
104
+ }
105
+
106
+ /** The node-level verdict — hard if ANY shared file is hard; the decisive rung + a confidence that
107
+ * decays down the ladder; the per-shared-file detail; the shared-thin smells; and the files whose
108
+ * downgrade is deferred to the ★ symbol rung. */
109
+ export interface LadderVerdict {
110
+ node: string
111
+ collision: Collision
112
+ rung: Rung
113
+ confidence: Confidence
114
+ sharedFiles: FileVerdict[]
115
+ smells: string[]
116
+ deferrals: string[]
117
+ }
118
+
119
+ // ── Pure derivations ──
120
+
121
+ const DEFAULT_SHARED_THIN_THRESHOLD = 3
122
+
123
+ /** The scenario-rung gate is STRUCTURAL — the `.feature` extension — never the resolved
124
+ * artifact-type (matches the sibling touch-set-correction's isFeature). */
125
+ export function isFeature(path: string): boolean {
126
+ return path.endsWith('.feature')
127
+ }
128
+
129
+ const CODE_EXTENSIONS = [
130
+ '.mts',
131
+ '.ts',
132
+ '.tsx',
133
+ '.cts',
134
+ '.mjs',
135
+ '.js',
136
+ '.jsx',
137
+ '.cjs',
138
+ '.py',
139
+ '.go',
140
+ '.rs',
141
+ '.java',
142
+ '.rb',
143
+ '.c',
144
+ '.h',
145
+ '.cpp',
146
+ '.hpp',
147
+ '.cs',
148
+ '.swift',
149
+ '.kt',
150
+ '.sh',
151
+ ]
152
+
153
+ /** A file whose semantic-rung anchor is a SYMBOL (code) rather than a scenario or prose section.
154
+ * Its downgrade needs symbol-level analysis — the ★ deferred capstone — so an overlapping-region
155
+ * code file stays hard. Detected by extension (structural, like isFeature); never a `.feature`. */
156
+ export function isCode(path: string): boolean {
157
+ if (isFeature(path)) return false
158
+ return CODE_EXTENSIONS.some((ext) => path.endsWith(ext))
159
+ }
160
+
161
+ /** Two hunk sets are disjoint when no range in one overlaps any range in the other. A `null` (unknown)
162
+ * hunk set is NOT disjoint from anything — disjointness cannot be proven, so the region rung must not
163
+ * clear it. Two inclusive ranges [a,b] and [c,d] overlap iff a ≤ d and c ≤ b. */
164
+ export function hunksDisjoint(a: LineRange[] | null, b: LineRange[] | null): boolean {
165
+ if (a === null || b === null) return false
166
+ for (const ra of a) {
167
+ for (const rb of b) {
168
+ if (ra.start <= rb.end && rb.start <= ra.end) return false
169
+ }
170
+ }
171
+ return true
172
+ }
173
+
174
+ const RUNG_CONFIDENCE: Record<Rung, Confidence> = {
175
+ file: 'high',
176
+ region: 'medium',
177
+ semantic: 'low',
178
+ symbol: 'low',
179
+ node: 'low',
180
+ }
181
+
182
+ const RUNG_DEPTH: Record<Rung, number> = { file: 1, region: 2, semantic: 3, symbol: 4, node: 5 }
183
+ const CONFIDENCE_RANK: Record<Confidence, number> = { low: 1, medium: 2, high: 3 }
184
+
185
+ /**
186
+ * classifyFile — the ladder descent for ONE shared file (present in both missions). Region first: if
187
+ * both sides' hunks are known and disjoint ⇒ SOFT at `region`. Else descend to the semantic rung,
188
+ * split by artifact-type (keyed structurally off the path):
189
+ * - `.feature` (behavioral prose) ⇒ the SCENARIO: different scenarios ⇒ SOFT, the same scenario ⇒ HARD.
190
+ * - code ⇒ descend further to the SYMBOL rung (see `classifySymbols`).
191
+ * - non-behavioral prose (no suite to anchor) ⇒ do NOT descend, stay node-serial: HARD, reason `no-anchor`.
192
+ * The shared-thin flag rides along (degree ≥ threshold) — the descent above IS its hard→soft downgrade.
193
+ */
194
+ export function classifyFile(fx: FileTouch, fy: FileTouch, degree: number, threshold: number): FileVerdict {
195
+ const path = fx.path
196
+ const sharedThin = degree >= threshold
197
+ const base = { path, sharedThin }
198
+
199
+ // region rung — textual, artifact-neutral
200
+ if (hunksDisjoint(fx.hunks, fy.hunks)) {
201
+ return { ...base, collision: 'soft', rung: 'region', reason: 'disjoint-hunks' }
202
+ }
203
+
204
+ // semantic rung — artifact-type-specific
205
+ if (isFeature(path)) {
206
+ const ys = new Set(fy.changedScenarios)
207
+ const shared = fx.changedScenarios.some((s) => ys.has(s))
208
+ return shared
209
+ ? { ...base, collision: 'hard', rung: 'semantic', reason: 'same-scenario' }
210
+ : { ...base, collision: 'soft', rung: 'semantic', reason: 'disjoint-scenarios' }
211
+ }
212
+ if (isCode(path)) {
213
+ return classifySymbols(fx, fy, base)
214
+ }
215
+ return { ...base, collision: 'hard', rung: 'node', reason: 'no-anchor' }
216
+ }
217
+
218
+ /**
219
+ * classifySymbols — the symbol rung, a code collision's finest grain (★ #189, first half). Compares
220
+ * each side's produced (written) and consumed (read) symbol names, in precedence order:
221
+ * 1. either side's `symbols` is `null`/`undefined` (un-inferable) ⇒ HARD, `symbol-rung-deferred`
222
+ * (conservative-first — a parse gap must never relax a real clash).
223
+ * 2. the two `produced` sets intersect (both write the same symbol) ⇒ HARD, `symbol-waw`.
224
+ * 3. one side's `consumed` intersects the other side's `produced` (either direction) ⇒ HARD,
225
+ * `symbol-raw`.
226
+ * 4. no symbol in common ⇒ SOFT, `disjoint-symbols`.
227
+ * Pure — no fs/network access; deterministic.
228
+ */
229
+ export function classifySymbols(
230
+ fx: FileTouch,
231
+ fy: FileTouch,
232
+ base: { path: string; sharedThin: boolean },
233
+ ): FileVerdict {
234
+ if (fx.symbols === null || fx.symbols === undefined || fy.symbols === null || fy.symbols === undefined) {
235
+ return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-rung-deferred' }
236
+ }
237
+ const xProduced = new Set(fx.symbols.produced)
238
+ const yProduced = new Set(fy.symbols.produced)
239
+ if (fx.symbols.produced.some((s) => yProduced.has(s))) {
240
+ return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-waw' }
241
+ }
242
+ const rawClash =
243
+ fx.symbols.consumed.some((s) => yProduced.has(s)) || fy.symbols.consumed.some((s) => xProduced.has(s))
244
+ if (rawClash) {
245
+ return { ...base, collision: 'hard', rung: 'symbol', reason: 'symbol-raw' }
246
+ }
247
+ return { ...base, collision: 'soft', rung: 'symbol', reason: 'disjoint-symbols' }
248
+ }
249
+
250
+ function bySharedThenPath(a: FileVerdict, b: FileVerdict): number {
251
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
252
+ }
253
+
254
+ /**
255
+ * classify — the whole pure ladder. Recovers the SHARED files (same path in both missions' detail),
256
+ * classifies each, and folds the node rollup: the node collision is HARD if ANY shared file is hard,
257
+ * else SOFT. When no file is shared, the file rung already clears it (soft, high confidence). The
258
+ * decisive rung is the deepest rung among the files that DECIDE the verdict (the hard files when hard,
259
+ * the soft-cleared files when soft), and the confidence is that rung's — so it decays down the ladder.
260
+ * Deterministic + stably ordered for fixed inputs; no fs/network access of its own.
261
+ */
262
+ export function classify(input: ClassifyInput): LadderVerdict {
263
+ const threshold = input.sharedThinThreshold ?? DEFAULT_SHARED_THIN_THRESHOLD
264
+ const degrees = input.degrees ?? {}
265
+ const xByPath = new Map(input.x.files.map((f) => [f.path, f]))
266
+ const sharedPaths = input.y.files.filter((f) => xByPath.has(f.path)).map((f) => f.path)
267
+ const uniqueSharedPaths = [...new Set(sharedPaths)].sort()
268
+
269
+ // file rung — disjoint files never really collide
270
+ if (uniqueSharedPaths.length === 0) {
271
+ return {
272
+ node: input.node,
273
+ collision: 'soft',
274
+ rung: 'file',
275
+ confidence: 'high',
276
+ sharedFiles: [],
277
+ smells: [],
278
+ deferrals: [],
279
+ }
280
+ }
281
+
282
+ const yByPath = new Map(input.y.files.map((f) => [f.path, f]))
283
+ const sharedFiles: FileVerdict[] = uniqueSharedPaths
284
+ .map((path) => {
285
+ const fx = xByPath.get(path) as FileTouch
286
+ const fy = yByPath.get(path) as FileTouch
287
+ const degree = degrees[path] ?? 2
288
+ return classifyFile(fx, fy, degree, threshold)
289
+ })
290
+ .sort(bySharedThenPath)
291
+
292
+ const collision: Collision = sharedFiles.some((f) => f.collision === 'hard') ? 'hard' : 'soft'
293
+ const deciding = sharedFiles.filter((f) => f.collision === collision)
294
+ const decisive = deciding.reduce(
295
+ (deepest, f) => (RUNG_DEPTH[f.rung] > RUNG_DEPTH[deepest.rung] ? f : deepest),
296
+ deciding[0],
297
+ )
298
+
299
+ const smells = sharedFiles
300
+ .filter((f) => f.sharedThin)
301
+ .map((f) => f.path)
302
+ .sort()
303
+ const deferrals = sharedFiles
304
+ .filter((f) => f.reason === 'symbol-rung-deferred')
305
+ .map((f) => f.path)
306
+ .sort()
307
+
308
+ return {
309
+ node: input.node,
310
+ collision,
311
+ rung: decisive.rung,
312
+ confidence: RUNG_CONFIDENCE[decisive.rung],
313
+ sharedFiles,
314
+ smells,
315
+ deferrals,
316
+ }
317
+ }
318
+
319
+ /** Confidence ordering helper (exported for callers comparing two verdicts, e.g. the confidence-decay
320
+ * scenario): higher rank = more trustworthy. */
321
+ export function confidenceRank(c: Confidence): number {
322
+ return CONFIDENCE_RANK[c]
323
+ }
324
+
325
+ // ── IO seam (thin — network/binary/fs boundary; not unit-tested, mirrors touch-set-correction.mts) ──
326
+
327
+ /** A file's touched line-ranges of `base..head` via `git diff -U0` (zero context, so each hunk's
328
+ * `+A,B` header is exactly the changed lines). Returns the added-side ranges (the new-file lines the
329
+ * mission wrote). On any git failure (absent binary, bad refs, not a repo) returns null (unknown —
330
+ * the region rung will not clear it). */
331
+ export function readFileHunks(base: string, head: string, path: string, cwd: string): LineRange[] | null {
332
+ let out: string
333
+ try {
334
+ out = execFileSync('git', ['diff', '-U0', `${base}..${head}`, '--', path], {
335
+ encoding: 'utf8',
336
+ cwd,
337
+ stdio: ['ignore', 'pipe', 'ignore'],
338
+ })
339
+ } catch {
340
+ return null
341
+ }
342
+ const ranges: LineRange[] = []
343
+ for (const line of out.split('\n')) {
344
+ // @@ -a,b +c,d @@ — the +c,d side is the added lines; d defaults to 1 when omitted
345
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
346
+ if (!m) continue
347
+ const start = Number(m[1])
348
+ const count = m[2] === undefined ? 1 : Number(m[2])
349
+ if (count === 0) continue // a pure deletion touches no new-file line
350
+ ranges.push({ start, end: start + count - 1 })
351
+ }
352
+ return ranges
353
+ }
354
+
355
+ // TS/JS-family extensions the symbol extractor attempts — a reasonable heuristic subset of
356
+ // CODE_EXTENSIONS. Any other code language (.py .go .rs etc.) always defers (returns null).
357
+ const JS_TS_EXTENSIONS = ['.mts', '.ts', '.tsx', '.cts', '.mjs', '.js', '.jsx', '.cjs']
358
+
359
+ const JS_KEYWORDS = new Set([
360
+ 'const',
361
+ 'let',
362
+ 'var',
363
+ 'function',
364
+ 'class',
365
+ 'return',
366
+ 'if',
367
+ 'else',
368
+ 'for',
369
+ 'while',
370
+ 'do',
371
+ 'switch',
372
+ 'case',
373
+ 'break',
374
+ 'continue',
375
+ 'default',
376
+ 'new',
377
+ 'this',
378
+ 'super',
379
+ 'import',
380
+ 'export',
381
+ 'from',
382
+ 'as',
383
+ 'async',
384
+ 'await',
385
+ 'try',
386
+ 'catch',
387
+ 'finally',
388
+ 'throw',
389
+ 'typeof',
390
+ 'instanceof',
391
+ 'in',
392
+ 'of',
393
+ 'null',
394
+ 'undefined',
395
+ 'true',
396
+ 'false',
397
+ 'void',
398
+ 'delete',
399
+ 'yield',
400
+ 'extends',
401
+ 'implements',
402
+ 'interface',
403
+ 'type',
404
+ 'enum',
405
+ 'namespace',
406
+ 'public',
407
+ 'private',
408
+ 'protected',
409
+ 'readonly',
410
+ 'static',
411
+ 'get',
412
+ 'set',
413
+ ])
414
+
415
+ const IDENTIFIER_RE = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g
416
+
417
+ /** Best-effort, CONSERVATIVE symbol extraction for a TS/JS-family code file's `base..head` diff (the
418
+ * symbol rung's thin IO seam, mirroring `readFileHunks`). Shells `git diff <base>..<head> -- <path>`;
419
+ * from ADDED lines (`+`) derives `produced` (declared names — `function NAME`, `class NAME`,
420
+ * `(export )?(const|let|var) NAME`, a method/assignment head `NAME(` or `NAME =`, `export function
421
+ * NAME`) and `consumed` (other referenced identifiers minus the produced set and minus JS keywords).
422
+ * Only attempted for TS/JS-family extensions — any other code language, or any failure, returns
423
+ * `null` (defer — a parse gap must never relax a real clash). Untested (fs/binary boundary). */
424
+ export function extractSymbols(base: string, head: string, path: string, cwd: string): SymbolSet | null {
425
+ if (!JS_TS_EXTENSIONS.some((ext) => path.endsWith(ext))) return null
426
+ let out: string
427
+ try {
428
+ out = execFileSync('git', ['diff', `${base}..${head}`, '--', path], {
429
+ encoding: 'utf8',
430
+ cwd,
431
+ stdio: ['ignore', 'pipe', 'ignore'],
432
+ })
433
+ } catch {
434
+ return null
435
+ }
436
+
437
+ const produced = new Set<string>()
438
+ const consumed = new Set<string>()
439
+ const declPatterns = [
440
+ /^\s*export\s+function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
441
+ /^\s*function\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
442
+ /^\s*export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
443
+ /^\s*class\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
444
+ /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
445
+ /^\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
446
+ /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/, // method/function-call head
447
+ /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*=[^=]/, // plain assignment head
448
+ ]
449
+
450
+ let sawAddedLine = false
451
+ for (const rawLine of out.split('\n')) {
452
+ if (!rawLine.startsWith('+') || rawLine.startsWith('+++')) continue
453
+ const line = rawLine.slice(1)
454
+ if (line.trim() === '') continue
455
+ sawAddedLine = true
456
+
457
+ let declared: string | null = null
458
+ for (const re of declPatterns) {
459
+ const m = re.exec(line)
460
+ if (m?.[1]) {
461
+ declared = m[1]
462
+ break
463
+ }
464
+ }
465
+ if (declared) produced.add(declared)
466
+
467
+ const ids = line.match(IDENTIFIER_RE) ?? []
468
+ for (const id of ids) {
469
+ if (id === declared) continue
470
+ if (JS_KEYWORDS.has(id)) continue
471
+ if (/^\d/.test(id)) continue
472
+ consumed.add(id)
473
+ }
474
+ }
475
+
476
+ if (!sawAddedLine) return null
477
+ for (const p of produced) consumed.delete(p)
478
+ if (produced.size === 0 && consumed.size === 0) return null
479
+
480
+ return { produced: [...produced].sort(), consumed: [...consumed].sort() }
481
+ }
482
+
483
+ /** Sources one mission's MissionTouch for the colliding node from a `base..head` range: REUSES the
484
+ * sibling touch-set-correction's `collectChangedFiles` (git diff + resolve-governances + gherkin-cli
485
+ * diff) for each file's artifact-type + changed scenarios, keeps only the files that map to `node`
486
+ * (via the shared `fileToNode`), and layers the region hunks + (for code) produced/consumed symbols
487
+ * on top. Thin IO — not unit-tested. */
488
+ export function collectMissionTouch(
489
+ mission: string,
490
+ node: string,
491
+ base: string,
492
+ head: string,
493
+ root: string,
494
+ cwd: string,
495
+ layouts: ProjectLayout[],
496
+ ): MissionTouch {
497
+ const changed = collectChangedFiles(base, head, root, cwd)
498
+ const files: FileTouch[] = changed
499
+ .filter((f) => fileToNode(f.path, layouts) === node)
500
+ .map((f) => ({
501
+ path: f.path,
502
+ artifactType: f.artifactType,
503
+ hunks: readFileHunks(base, head, f.path, cwd),
504
+ changedScenarios: f.changedScenarios,
505
+ symbols: isCode(f.path) ? extractSymbols(base, head, f.path, cwd) : null,
506
+ }))
507
+ return { mission, files }
508
+ }
509
+
510
+ // ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
511
+
512
+ function toonQuote(v: string): string {
513
+ if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
514
+ return v
515
+ }
516
+
517
+ export function renderVerdictToon(v: LadderVerdict): string {
518
+ const lines: string[] = []
519
+ lines.push(`node: ${toonQuote(v.node)}`)
520
+ lines.push(`collision: ${v.collision}`)
521
+ lines.push(`rung: ${v.rung}`)
522
+ lines.push(`confidence: ${v.confidence}`)
523
+ lines.push(`sharedFiles[${v.sharedFiles.length}]{path,collision,rung,reason,sharedThin}:`)
524
+ for (const f of v.sharedFiles) {
525
+ lines.push(` ${toonQuote(f.path)},${f.collision},${f.rung},${f.reason},${f.sharedThin}`)
526
+ }
527
+ lines.push(`smells[${v.smells.length}]: ${v.smells.map(toonQuote).join(';')}`)
528
+ lines.push(`deferrals[${v.deferrals.length}]: ${v.deferrals.map(toonQuote).join(';')}`)
529
+ return lines.join('\n')
530
+ }
531
+
532
+ // ── CLI ──
533
+
534
+ function flag(argv: string[], name: string): string | undefined {
535
+ const i = argv.indexOf(name)
536
+ return i === -1 ? undefined : argv[i + 1]
537
+ }
538
+
539
+ function allFlags(argv: string[], name: string): string[] {
540
+ const out: string[] = []
541
+ for (let i = 0; i < argv.length; i++) {
542
+ if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1])
543
+ }
544
+ return out
545
+ }
546
+
547
+ function splitCsv(v: string | undefined): string[] {
548
+ if (v === undefined || v === '') return []
549
+ return v
550
+ .split(',')
551
+ .map((s) => s.trim())
552
+ .filter((s) => s.length > 0)
553
+ }
554
+
555
+ /** Parses one `--layout '<project>:<root1>,<root2>'` flag value into a ProjectLayout (same grammar as
556
+ * the sibling touch-set-correction CLI). */
557
+ export function parseLayoutFlag(value: string): ProjectLayout | null {
558
+ const idx = value.indexOf(':')
559
+ if (idx === -1) return null
560
+ const project = value.slice(0, idx).trim()
561
+ const roots = splitCsv(value.slice(idx + 1))
562
+ if (project === '' || roots.length === 0) return null
563
+ return { project, roots }
564
+ }
565
+
566
+ function readStdin(): string {
567
+ try {
568
+ return execFileSync('cat', [], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] })
569
+ } catch {
570
+ return ''
571
+ }
572
+ }
573
+
574
+ const HERE = dirname(fileURLToPath(import.meta.url))
575
+ const DISCOVER_SPECS_PATH = join(HERE, '..', '..', 'discover-specs', 'scripts', 'discover-specs.mts')
576
+
577
+ interface DiscoveredSpec {
578
+ path: string
579
+ name: string
580
+ projectPath: string
581
+ }
582
+
583
+ /** Best-effort project-layout discovery via `discover-specs` (same convention as the sibling). Used
584
+ * only by --from-git; --layout overrides it. On any failure returns []. */
585
+ function discoverLayouts(root: string, cwd: string): ProjectLayout[] {
586
+ try {
587
+ const out = execFileSync('node', [DISCOVER_SPECS_PATH, '--root', root, '--format', 'json'], {
588
+ encoding: 'utf8',
589
+ cwd,
590
+ stdio: ['ignore', 'pipe', 'ignore'],
591
+ })
592
+ const specs = JSON.parse(out) as DiscoveredSpec[]
593
+ return specs.map((s) => {
594
+ const roots = [s.path]
595
+ const projectPath = s.projectPath ?? ''
596
+ const pluginsMatch = /^plugins\/([^/]+)$/.exec(projectPath)
597
+ const packagesMatch = /^packages\/([^/]+)$/.exec(projectPath)
598
+ if (pluginsMatch) roots.push(`plugins/${pluginsMatch[1]}/skills`)
599
+ else if (packagesMatch) roots.push(`packages/${packagesMatch[1]}/src`)
600
+ else if (projectPath) roots.push(projectPath)
601
+ return { project: s.name, roots }
602
+ })
603
+ } catch {
604
+ return []
605
+ }
606
+ }
607
+
608
+ export function main(argv: string[]): number {
609
+ const format = flag(argv, '--format') === 'json' ? 'json' : 'toon'
610
+ let input: ClassifyInput
611
+
612
+ const fromGit = argv.includes('--from-git')
613
+ if (fromGit) {
614
+ const node = flag(argv, '--node')
615
+ const xRange = flag(argv, '--x')
616
+ const yRange = flag(argv, '--y')
617
+ if (node === undefined || xRange === undefined || yRange === undefined) {
618
+ process.stderr.write('collision-ladder: --from-git needs --node <id> --x <base..head> --y <base..head>\n')
619
+ return 1
620
+ }
621
+ const root = flag(argv, '--root') ?? '.'
622
+ const [xb, xh] = xRange.split('..')
623
+ const [yb, yh] = yRange.split('..')
624
+ const layoutFlags = allFlags(argv, '--layout')
625
+ const layouts =
626
+ layoutFlags.length > 0
627
+ ? layoutFlags.map(parseLayoutFlag).filter((l): l is ProjectLayout => l !== null)
628
+ : discoverLayouts(root, root)
629
+ const x = collectMissionTouch(xRange, node, xb, xh ?? 'HEAD', root, root, layouts)
630
+ const y = collectMissionTouch(yRange, node, yb, yh ?? 'HEAD', root, root, layouts)
631
+ const threshold = flag(argv, '--shared-thin-threshold')
632
+ input = { node, x, y, sharedThinThreshold: threshold ? Number(threshold) : undefined }
633
+ } else {
634
+ const inputSrc = flag(argv, '--input')
635
+ if (inputSrc === undefined) {
636
+ process.stderr.write('collision-ladder: --input <file|-> (a ClassifyInput JSON) or --from-git is required\n')
637
+ return 1
638
+ }
639
+ let raw: string
640
+ try {
641
+ raw =
642
+ inputSrc === '-'
643
+ ? readStdin()
644
+ : execFileSync('cat', [inputSrc], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
645
+ input = JSON.parse(raw) as ClassifyInput
646
+ } catch {
647
+ process.stderr.write(`collision-ladder: could not read/parse ClassifyInput from ${inputSrc}\n`)
648
+ return 1
649
+ }
650
+ }
651
+
652
+ const verdict = classify(input)
653
+ process.stdout.write(`${format === 'json' ? JSON.stringify(verdict, null, 2) : renderVerdictToon(verdict)}\n`)
654
+ return 0
655
+ }
656
+
657
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))
@@ -0,0 +1,13 @@
1
+ # combat-log-governance
2
+
3
+ Internal SDD governance (`user-invocable: false`). The **combat-log** contract — the shape of the
4
+ production provenance record: the two-face record (current-state frontmatter + the durable ledger),
5
+ the tracked combat log (in the plan) vs the durable ledger (root-spec sibling), the seven entry kinds
6
+ (`report` / `correction` / `halt` → the combat log; `leash` / `gate` / `strategy` / `followup` → the
7
+ ledger), the CR-scoped `seq`, the write-time UTC `ts`, the pseudonymous `handle`, the safe-to-publish
8
+ floor, and the matchable `cause` enum.
9
+
10
+ A fixed-universal SDD governance, invariant per role. Loaded by the conductor, spec-gate, and the
11
+ doctrine-loop Scanner. The tracked deletion of a retired plan is the `plan-retirement` skill;
12
+ freeze/gating in `lifecycle-governance`; write-ownership in `ownership-governance`. Not triggered by
13
+ users directly.