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,386 @@
1
+ #!/usr/bin/env node
2
+ // verify-scenarios — the Gherkin-scenario -> test-report bridge verifier. For a frozen `.feature`
3
+ // and a spec-node path, reports per-scenario PASS / FAIL / UNBOUND by reading the reports of one or
4
+ // more configured test-result SOURCES, so an impl-judge reasons by hand only over the UNBOUND set
5
+ // instead of every scenario.
6
+ //
7
+ // Binding convention: KEY = a scenario's `@id:<slug>` tag if present, else its verbatim name. A test
8
+ // declares its node via a `describe('spec:<node>', …)` wrapper (or equivalent) — the bridge reads
9
+ // that segment out of the test-report's `" > "`-joined test name. A test's leaf title is either the
10
+ // exact scenario name or `@id:<slug>`; a Scenario Outline is ONE key (its outline name), not one per
11
+ // Examples row.
12
+ //
13
+ // Operations:
14
+ // --feature <path> frozen .feature to verify (required)
15
+ // --node <path> the spec node to bind against, e.g. cyberlegion/identity (required)
16
+ // --config <toml> .agents/sdd/scenario-bridge.toml (default, resolved under --root)
17
+ // --root <dir> bridge/report root: --config's default, --report, and every source's
18
+ // reportPath resolve here (default cwd)
19
+ // --feature-root <dir> where --feature resolves; defaults to --root when omitted (monorepo:
20
+ // specs at repo root, bridge/report under the project's own root)
21
+ // --report <xml> bypass --config: a single ad-hoc junit report (resolved under --root)
22
+ // --run execute each source's `command` before reading its report
23
+ // --format toon|json machine output (default: a readable text map)
24
+ //
25
+ // Config `.agents/sdd/scenario-bridge.toml` is an array-of-tables of sources:
26
+ // [[source]]
27
+ // adapter = "junit"
28
+ // command = "pnpm build && vitest run src --reporter=junit --outputFile=.agents/.scenario-report.xml"
29
+ // reportPath = ".agents/.scenario-report.xml"
30
+ //
31
+ // Adapter interface: (source, root, run) => Array<{node, key, outcome: 'pass'|'fail'|'skip'}>. Only
32
+ // `junit` is implemented; `tap`/`aced` slot into the same `switch(adapter)` seam later.
33
+ //
34
+ // Fold: union every source's results, keep only those whose `node` equals --node, group by KEY. Per
35
+ // feature scenario KEY: UNBOUND if no result; PASS if ≥1 result and none fail; FAIL if any fails.
36
+ // EXTRA = bound result keys matching no feature scenario key (diagnostic, not a failure).
37
+ //
38
+ // Pure functions are exported for node:test; running the file directly drives the CLI.
39
+
40
+ import { execSync } from 'node:child_process'
41
+ import { existsSync, readFileSync } from 'node:fs'
42
+ import { isAbsolute, join } from 'node:path'
43
+ import { parseFeatures } from 'gherkin-cli'
44
+
45
+ // Resolves a path argument against `--root`: relative paths join beneath root (which defaults to the
46
+ // current directory); an absolute path is used verbatim, never double-prefixed under root.
47
+ export function underRoot(root: string, p: string): string {
48
+ return isAbsolute(p) ? p : join(root, p)
49
+ }
50
+
51
+ // ── Config (a minimal TOML: `[[source]]` array-of-tables) ──
52
+
53
+ export interface SourceConfig {
54
+ adapter: string
55
+ command?: string
56
+ reportPath: string
57
+ }
58
+
59
+ // Parses `[[source]]` blocks; each block's `key = "value"` lines become fields on one source. Blocks
60
+ // are delimited by the next `[[source]]` or `[...]` header (or EOF). Malformed/incomplete blocks
61
+ // (no reportPath) are dropped rather than throwing — mirrors spec-anchors' lenient-read posture.
62
+ export function parseSourcesToml(text: string): SourceConfig[] {
63
+ const out: SourceConfig[] = []
64
+ const blocks = text.split(/(?=^\s*\[\[source\]\]\s*$)/m).filter((b) => /^\s*\[\[source\]\]\s*$/m.test(b))
65
+ for (const block of blocks) {
66
+ const body = block.replace(/^\s*\[\[source\]\]\s*$/m, '')
67
+ const fields: Record<string, string> = {}
68
+ for (const m of body.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')\s*$/gm)) {
69
+ fields[m[1]] = m[2] ?? m[3] ?? ''
70
+ }
71
+ if (!fields.adapter || !fields.reportPath) continue
72
+ out.push({ adapter: fields.adapter, command: fields.command, reportPath: fields.reportPath })
73
+ }
74
+ return out
75
+ }
76
+
77
+ export function readSourcesConfig(configPath: string): SourceConfig[] {
78
+ if (!existsSync(configPath)) return []
79
+ try {
80
+ return parseSourcesToml(readFileSync(configPath, 'utf8'))
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ // ── Scenario keys from the frozen .feature (via gherkin-cli) ──
87
+
88
+ export interface GherkinScenario {
89
+ name: string
90
+ keyword: string
91
+ tags: string[]
92
+ }
93
+
94
+ // Narrower than gherkin-cli's real `ParseResult` — this function reads only `files[].scenarios`,
95
+ // so its signature stays structural rather than demanding fields (summary, per-file counts) it
96
+ // never touches; a real `ParseResult` value satisfies this shape and passes straight through.
97
+ interface GherkinParseOutput {
98
+ files: { scenarios: GherkinScenario[] }[]
99
+ }
100
+
101
+ export interface ScenarioKey {
102
+ name: string
103
+ key: string
104
+ }
105
+
106
+ const ID_TAG_RE = /^@id:(.+)$/
107
+
108
+ // A scenario's KEY is its `@id:<slug>` tag if one is present, else its verbatim name. A Scenario
109
+ // Outline is one key (its outline name) — gherkin-cli already emits one entry per outline, not per
110
+ // Examples row, so no special-casing is needed here.
111
+ export function scenarioKeysFromParse(parsed: GherkinParseOutput): ScenarioKey[] {
112
+ const out: ScenarioKey[] = []
113
+ for (const file of parsed.files) {
114
+ for (const s of file.scenarios) {
115
+ const idTag = s.tags.map((t) => ID_TAG_RE.exec(t)).find((m): m is RegExpExecArray => m !== null)
116
+ out.push({ name: s.name, key: idTag ? idTag[1] : s.name })
117
+ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ // `featureRoot` resolves `featurePath` (defaults to `root` at the call site when the CLI omits
123
+ // `--feature-root` — see main()).
124
+ export function getScenarioKeys(root: string, featurePath: string, featureRoot: string = root): ScenarioKey[] {
125
+ const abs = underRoot(featureRoot, featurePath)
126
+ return scenarioKeysFromParse(parseFeatures([abs]))
127
+ }
128
+
129
+ // ── JUnit parsing (hand-rolled, no xml dep) ──
130
+
131
+ export type Outcome = 'pass' | 'fail' | 'skip'
132
+
133
+ export interface JUnitTestcase {
134
+ classname: string
135
+ name: string
136
+ outcome: Outcome
137
+ }
138
+
139
+ const XML_ENTITIES: [RegExp, string][] = [
140
+ [/&lt;/g, '<'],
141
+ [/&gt;/g, '>'],
142
+ [/&quot;/g, '"'],
143
+ [/&apos;/g, "'"],
144
+ [/&amp;/g, '&'], // last, so a literal "&amp;lt;" does not get double-unescaped
145
+ ]
146
+
147
+ export function unescapeXml(s: string): string {
148
+ let out = s
149
+ for (const [re, rep] of XML_ENTITIES) out = out.replace(re, rep)
150
+ return out
151
+ }
152
+
153
+ function attr(attrs: string, name: string): string {
154
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(attrs)
155
+ return m ? unescapeXml(m[1]) : ''
156
+ }
157
+
158
+ // Finds every `<testcase …>…</testcase>` (or self-closed `<testcase … />`), extracting `classname`
159
+ // and `name` BY ATTRIBUTE NAME (not position), unescaping both. Outcome: a child `<failure` -> fail;
160
+ // a child `<skipped` -> skip; else pass.
161
+ //
162
+ // The opening-tag regex walks attribute-by-attribute (quoted strings or a non-quote/non-`>` run) so
163
+ // an attribute VALUE containing a literal `>` — e.g. a scenario name built from `" > "`-joined
164
+ // segments — never truncates the match, which a naive `[^>]*` class would do.
165
+ const OPEN_TAG_RE = /<testcase\b((?:"[^"]*"|'[^']*'|[^"'>/])*)(\/)?>/g
166
+
167
+ export function parseJUnitTestcases(xml: string): JUnitTestcase[] {
168
+ const out: JUnitTestcase[] = []
169
+ for (const m of xml.matchAll(OPEN_TAG_RE)) {
170
+ const attrs = m[1]
171
+ const selfClosed = m[2] === '/'
172
+ let body = ''
173
+ if (!selfClosed) {
174
+ const closeIdx = xml.indexOf('</testcase>', m.index + m[0].length)
175
+ if (closeIdx !== -1) body = xml.slice(m.index + m[0].length, closeIdx)
176
+ }
177
+ const outcome: Outcome = /<failure\b/.test(body) ? 'fail' : /<skipped\b/.test(body) ? 'skip' : 'pass'
178
+ out.push({ classname: attr(attrs, 'classname'), name: attr(attrs, 'name'), outcome })
179
+ }
180
+ return out
181
+ }
182
+
183
+ // ── junit adapter: testcase -> {node, key, outcome} ──
184
+
185
+ export interface BoundResult {
186
+ node: string
187
+ key: string
188
+ outcome: Outcome
189
+ }
190
+
191
+ const NODE_SEGMENT_RE = /^spec:(.+)$/
192
+
193
+ // Splits an (already-unescaped) testcase `name` on `" > "`. NODE = the capture of whichever segment
194
+ // matches `/^spec:(.+)$/` (found at any depth); testcases with no such segment are not bound to a
195
+ // spec node and are dropped. LEAF = the last segment; KEY = its `@id:<slug>` capture if it matches,
196
+ // else the leaf verbatim.
197
+ export function junitTestcaseToResult(tc: JUnitTestcase): BoundResult | undefined {
198
+ const segments = tc.name.split(' > ')
199
+ let node: string | undefined
200
+ for (const seg of segments) {
201
+ const m = NODE_SEGMENT_RE.exec(seg)
202
+ if (m) {
203
+ node = m[1]
204
+ break
205
+ }
206
+ }
207
+ if (node === undefined) return undefined
208
+ const leaf = segments[segments.length - 1]
209
+ const idMatch = ID_TAG_RE.exec(leaf)
210
+ const key = idMatch ? idMatch[1] : leaf
211
+ return { node, key, outcome: tc.outcome }
212
+ }
213
+
214
+ export function junitResultsFromXml(xml: string): BoundResult[] {
215
+ const out: BoundResult[] = []
216
+ for (const tc of parseJUnitTestcases(xml)) {
217
+ const r = junitTestcaseToResult(tc)
218
+ if (r) out.push(r)
219
+ }
220
+ return out
221
+ }
222
+
223
+ function runJunitSource(source: SourceConfig, root: string, run: boolean): BoundResult[] {
224
+ if (run && source.command) {
225
+ execSync(source.command, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] })
226
+ }
227
+ const reportPath = underRoot(root, source.reportPath)
228
+ if (!existsSync(reportPath)) return []
229
+ return junitResultsFromXml(readFileSync(reportPath, 'utf8'))
230
+ }
231
+
232
+ // Adapter dispatch seam — `tap` / `aced` / others implement the same signature and slot in here.
233
+ export function runSource(source: SourceConfig, root: string, run: boolean): BoundResult[] {
234
+ switch (source.adapter) {
235
+ case 'junit':
236
+ return runJunitSource(source, root, run)
237
+ default:
238
+ return []
239
+ }
240
+ }
241
+
242
+ // ── Fold: union all sources, group by key, compare against the feature's scenario set ──
243
+
244
+ export type ScenarioState = 'pass' | 'fail' | 'unbound'
245
+
246
+ export interface ScenarioReport {
247
+ name: string
248
+ key: string
249
+ state: ScenarioState
250
+ resultCount: number
251
+ }
252
+
253
+ export interface FoldReport {
254
+ node: string
255
+ total: number
256
+ bound: number
257
+ pass: number
258
+ fail: number
259
+ unbound: number
260
+ scenarios: ScenarioReport[]
261
+ extras: string[]
262
+ }
263
+
264
+ export function foldResults(scenarioKeys: ScenarioKey[], results: BoundResult[], node: string): FoldReport {
265
+ const nodeResults = results.filter((r) => r.node === node)
266
+ const byKey = new Map<string, BoundResult[]>()
267
+ for (const r of nodeResults) {
268
+ const list = byKey.get(r.key)
269
+ if (list) list.push(r)
270
+ else byKey.set(r.key, [r])
271
+ }
272
+
273
+ const scenarios: ScenarioReport[] = scenarioKeys.map(({ name, key }) => {
274
+ const group = byKey.get(key) ?? []
275
+ const state: ScenarioState =
276
+ group.length === 0 ? 'unbound' : group.some((r) => r.outcome === 'fail') ? 'fail' : 'pass'
277
+ return { name, key, state, resultCount: group.length }
278
+ })
279
+
280
+ const featureKeys = new Set(scenarioKeys.map((s) => s.key))
281
+ const extras = [...byKey.keys()].filter((k) => !featureKeys.has(k)).sort()
282
+
283
+ const pass = scenarios.filter((s) => s.state === 'pass').length
284
+ const fail = scenarios.filter((s) => s.state === 'fail').length
285
+ const unbound = scenarios.filter((s) => s.state === 'unbound').length
286
+
287
+ return { node, total: scenarios.length, bound: pass + fail, pass, fail, unbound, scenarios, extras }
288
+ }
289
+
290
+ // ── Output ──
291
+
292
+ function toonField(v: string): string {
293
+ if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
294
+ return v
295
+ }
296
+
297
+ export function formatToon(report: FoldReport): string {
298
+ const header = `scenarios[${report.scenarios.length}]{name,key,state,resultCount}:`
299
+ const rows = report.scenarios.map(
300
+ (s) => ` ${[toonField(s.name), toonField(s.key), s.state, String(s.resultCount)].join(',')}`,
301
+ )
302
+ const extras = [`extras[${report.extras.length}]{key}:`, ...report.extras.map((e) => ` ${toonField(e)}`)]
303
+ const summary = `summary{node,total,bound,pass,fail,unbound}:\n ${[
304
+ toonField(report.node),
305
+ report.total,
306
+ report.bound,
307
+ report.pass,
308
+ report.fail,
309
+ report.unbound,
310
+ ].join(',')}`
311
+ return [header, ...rows, ...extras, summary].join('\n')
312
+ }
313
+
314
+ export function formatText(report: FoldReport): string {
315
+ const lines: string[] = []
316
+ for (const s of report.scenarios) lines.push(`${s.state.toUpperCase().padEnd(8)} ${s.name}`)
317
+ lines.push('')
318
+ lines.push(
319
+ `${report.bound}/${report.total} BOUND, ${report.pass} pass, ${report.fail} fail, ${report.unbound} unbound`,
320
+ )
321
+ if (report.extras.length > 0) {
322
+ lines.push('')
323
+ lines.push(`EXTRA (bound results matching no scenario): ${report.extras.join(', ')}`)
324
+ }
325
+ return lines.join('\n')
326
+ }
327
+
328
+ // ── CLI ──
329
+
330
+ function flag(argv: string[], name: string): string | undefined {
331
+ const i = argv.indexOf(name)
332
+ return i === -1 ? undefined : argv[i + 1]
333
+ }
334
+
335
+ // Resolves the source set for a run: `--report` bypasses the configured sources entirely with a
336
+ // single ad-hoc junit source; otherwise the sources come from the (resolved) config file.
337
+ export function resolveSources(argv: string[], root: string): SourceConfig[] {
338
+ const reportFlag = flag(argv, '--report')
339
+ if (reportFlag) {
340
+ return [{ adapter: 'junit', reportPath: reportFlag }]
341
+ }
342
+ const configPath = underRoot(root, flag(argv, '--config') ?? '.agents/sdd/scenario-bridge.toml')
343
+ return readSourcesConfig(configPath)
344
+ }
345
+
346
+ // Renders a fold report in the requested format, defaulting to text.
347
+ export function renderReport(report: FoldReport, format: string): string {
348
+ if (format === 'json') return JSON.stringify(report, null, 2)
349
+ if (format === 'toon') return formatToon(report)
350
+ return formatText(report)
351
+ }
352
+
353
+ // Non-zero exactly when any scenario is UNBOUND or FAIL; zero only when every scenario is bound
354
+ // and passing.
355
+ export function exitCode(report: FoldReport): number {
356
+ return report.unbound > 0 || report.fail > 0 ? 1 : 0
357
+ }
358
+
359
+ export function main(argv: string[]): number {
360
+ const root = flag(argv, '--root') ?? '.'
361
+ const featureRoot = flag(argv, '--feature-root') ?? root
362
+ const feature = flag(argv, '--feature')
363
+ const node = flag(argv, '--node')
364
+ const format = flag(argv, '--format') ?? 'text'
365
+ const run = argv.includes('--run')
366
+ const w = (s: string) => process.stdout.write(`${s}\n`)
367
+
368
+ if (!feature || !node) {
369
+ w(
370
+ 'usage: verify-scenarios --feature <path> --node <path> [--config <toml>] [--root <dir>] [--feature-root <dir>] [--report <xml>] [--run] [--format toon|json]',
371
+ )
372
+ return 1
373
+ }
374
+
375
+ const sources = resolveSources(argv, root)
376
+
377
+ const results = sources.flatMap((s) => runSource(s, root, run))
378
+ const scenarioKeys = getScenarioKeys(root, feature, featureRoot)
379
+ const report = foldResults(scenarioKeys, results, node)
380
+
381
+ w(renderReport(report, format))
382
+
383
+ return exitCode(report)
384
+ }
385
+
386
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))