cyber-sdd 0.6.0 → 0.7.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 (137) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/.plugin/plugin.json +1 -1
  4. package/agents/sdd-automaton.md +2 -3
  5. package/agents/sdd-impl-judge.md +5 -4
  6. package/agents/sdd-scanner.md +1 -1
  7. package/agents/sdd-spec-judge.md +3 -3
  8. package/agents/sdd-warden.md +8 -0
  9. package/dist/check-project-specs/scripts/check-project-specs.js +1 -0
  10. package/package.json +2 -2
  11. package/skills/align-spec/README.md +3 -3
  12. package/skills/align-spec/SKILL.md +1 -1
  13. package/skills/align-spec/scripts/align-spec.bundle.mjs +6753 -0
  14. package/skills/align-spec/scripts/align-spec.mjs +12 -0
  15. package/skills/architect-impl-governance/SKILL.md +3 -1
  16. package/skills/architect-spec-governance/SKILL.md +5 -1
  17. package/skills/blast-estimate/README.md +1 -1
  18. package/skills/blast-estimate/SKILL.md +1 -1
  19. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +593 -0
  20. package/skills/blast-estimate/scripts/blast-estimate.mjs +12 -0
  21. package/skills/check-field-mandates/SKILL.md +1 -1
  22. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +251 -0
  23. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +12 -0
  24. package/skills/check-partition-quality/SKILL.md +1 -1
  25. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +233 -0
  26. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +12 -0
  27. package/skills/check-plan-safety/README.md +3 -3
  28. package/skills/check-plan-safety/SKILL.md +1 -1
  29. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +107 -0
  30. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +12 -0
  31. package/skills/check-plugin-manifests/SKILL.md +1 -1
  32. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +248 -0
  33. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +12 -0
  34. package/skills/check-project-specs/README.md +3 -3
  35. package/skills/check-project-specs/SKILL.md +2 -2
  36. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +584 -0
  37. package/skills/check-project-specs/scripts/check-project-specs.mjs +12 -0
  38. package/skills/check-project-specs/scripts/check-project-specs.mts +1 -0
  39. package/skills/check-retired-terms/README.md +2 -2
  40. package/skills/check-retired-terms/SKILL.md +2 -2
  41. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +184 -0
  42. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +12 -0
  43. package/skills/check-scenario-overlap/README.md +2 -2
  44. package/skills/check-scenario-overlap/SKILL.md +2 -3
  45. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +175 -0
  46. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +12 -0
  47. package/skills/check-spec-references/README.md +1 -1
  48. package/skills/check-spec-references/SKILL.md +1 -1
  49. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +285 -0
  50. package/skills/check-spec-references/scripts/check-spec-references.mjs +12 -0
  51. package/skills/check-spec-structure/README.md +2 -2
  52. package/skills/check-spec-structure/SKILL.md +1 -1
  53. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +241 -0
  54. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +12 -0
  55. package/skills/collision-ladder/SKILL.md +3 -3
  56. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +7041 -0
  57. package/skills/collision-ladder/scripts/collision-ladder.mjs +12 -0
  58. package/skills/collision-ladder/scripts/collision-ladder.mts +4 -2
  59. package/skills/combat-log-governance/SKILL.md +1 -1
  60. package/skills/concept-index/README.md +2 -2
  61. package/skills/concept-index/SKILL.md +1 -1
  62. package/skills/concept-index/scripts/concept-index.bundle.mjs +181 -0
  63. package/skills/concept-index/scripts/concept-index.mjs +12 -0
  64. package/skills/discover-plans/README.md +2 -2
  65. package/skills/discover-plans/SKILL.md +1 -1
  66. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +135 -0
  67. package/skills/discover-plans/scripts/discover-plans.mjs +12 -0
  68. package/skills/discover-specs/README.md +1 -1
  69. package/skills/discover-specs/SKILL.md +1 -1
  70. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +319 -0
  71. package/skills/discover-specs/scripts/discover-specs.mjs +12 -0
  72. package/skills/doctrine-loop/SKILL.md +2 -2
  73. package/skills/formation-loop/SKILL.md +11 -4
  74. package/skills/gate-validation-governance/SKILL.md +1 -1
  75. package/skills/init/README.md +3 -3
  76. package/skills/init/SKILL.md +3 -3
  77. package/skills/init/scripts/wire-statusline.bundle.mjs +166 -0
  78. package/skills/init/scripts/wire-statusline.mjs +12 -0
  79. package/skills/manage-ignore/README.md +5 -5
  80. package/skills/manage-ignore/SKILL.md +1 -1
  81. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +244 -0
  82. package/skills/manage-ignore/scripts/manage-ignore.mjs +12 -0
  83. package/skills/manage-scenario-bridge/README.md +3 -3
  84. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  85. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +237 -0
  86. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +12 -0
  87. package/skills/manage-spec-anchors/README.md +4 -4
  88. package/skills/manage-spec-anchors/SKILL.md +1 -1
  89. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +308 -0
  90. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +12 -0
  91. package/skills/mission-graph/SKILL.md +9 -9
  92. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +1038 -0
  93. package/skills/mission-graph/scripts/mission-graph.mjs +12 -0
  94. package/skills/pause-mission/SKILL.md +1 -1
  95. package/skills/place-node/README.md +1 -1
  96. package/skills/place-node/SKILL.md +1 -1
  97. package/skills/place-node/scripts/place-node.bundle.mjs +115 -0
  98. package/skills/place-node/scripts/place-node.mjs +12 -0
  99. package/skills/plan-retirement/SKILL.md +1 -1
  100. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +121 -0
  101. package/skills/plan-retirement/scripts/retire-plans.mjs +12 -0
  102. package/skills/plan-retirement/scripts/retire-plans.mts +6 -0
  103. package/skills/plugin-contract-governance/SKILL.md +5 -12
  104. package/skills/resolve-governances/README.md +1 -1
  105. package/skills/resolve-governances/SKILL.md +1 -1
  106. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +350 -0
  107. package/skills/resolve-governances/scripts/resolve-governances.mjs +12 -0
  108. package/skills/resolve-governances/scripts/resolve-governances.mts +1 -1
  109. package/skills/resolve-tracking/SKILL.md +1 -1
  110. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +150 -0
  111. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +12 -0
  112. package/skills/scaffold-project-spec/SKILL.md +2 -2
  113. package/skills/sdd/SKILL.md +1 -1
  114. package/skills/solution-producer-governance/README.md +1 -1
  115. package/skills/solution-producer-governance/SKILL.md +1 -1
  116. package/skills/spec-gate/SKILL.md +5 -5
  117. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +420 -0
  118. package/skills/spec-gate/scripts/check-spec-state.mjs +12 -0
  119. package/skills/spec-gate/scripts/check-suite.bundle.mjs +6498 -0
  120. package/skills/spec-gate/scripts/check-suite.mjs +12 -0
  121. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +6577 -0
  122. package/skills/spec-gate/scripts/classify-edit-class.mjs +12 -0
  123. package/skills/spec-producer-governance/README.md +1 -1
  124. package/skills/spec-producer-governance/SKILL.md +3 -3
  125. package/skills/spec-structure-governance/README.md +4 -2
  126. package/skills/spec-structure-governance/SKILL.md +42 -7
  127. package/skills/start-mission/README.md +1 -1
  128. package/skills/start-mission/SKILL.md +6 -6
  129. package/skills/suite-format-governance/references/rubric.md +1 -1
  130. package/skills/touch-set-correction/SKILL.md +2 -2
  131. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +6655 -0
  132. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +12 -0
  133. package/skills/touch-set-correction/scripts/touch-set-correction.mts +12 -3
  134. package/skills/verify-scenarios/README.md +3 -3
  135. package/skills/verify-scenarios/SKILL.md +3 -3
  136. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +6537 -0
  137. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +12 -0
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ //#region skills/check-retired-terms/scripts/check-retired-terms.mts
7
+ var RegistryParseError = class extends Error {};
8
+ const REGISTRY_PATH = ".agents/sdd/retired-terms.toml";
9
+ function isBlankOrComment(line) {
10
+ const t = line.trim();
11
+ return t === "" || t.startsWith("#");
12
+ }
13
+ function extractString(block, key) {
14
+ const m = new RegExp(`(^|\\n)\\s*${key}\\s*=\\s*"([^"]*)"[^\\n]*`).exec(block);
15
+ if (!m) throw new RegistryParseError(`[[retired]] entry is missing a string "${key}"`);
16
+ return m[2];
17
+ }
18
+ function extractArray(block, key) {
19
+ const openMatch = new RegExp(`(^|\\n)\\s*${key}\\s*=\\s*\\[`).exec(block);
20
+ if (!openMatch) return [];
21
+ const openIdx = openMatch.index + openMatch[0].length - 1;
22
+ const closeIdx = block.indexOf("]", openIdx);
23
+ if (closeIdx === -1) throw new RegistryParseError(`"${key}" array is never closed with "]"`);
24
+ const arrText = block.slice(openIdx, closeIdx);
25
+ const out = [];
26
+ for (const q of arrText.matchAll(/"([^"]*)"/g)) out.push(q[1]);
27
+ return out;
28
+ }
29
+ /** Parses the registry's minimal TOML subset: an array of `[[retired]]` tables carrying string
30
+ * keys (term/since/replacement, all required) and optional string-array keys (scope/allow). Throws
31
+ * `RegistryParseError` on anything that does not fit that shape — an absent registry is handled by
32
+ * the caller (`readRegistry`), not here; an empty/comment-only file parses to `[]`. */
33
+ function parseRegistryToml(text) {
34
+ const marker = "[[retired]]";
35
+ const firstIdx = text.indexOf(marker);
36
+ if (firstIdx === -1) {
37
+ if (text.split("\n").every(isBlankOrComment)) return [];
38
+ throw new RegistryParseError("no [[retired]] table found and the file is not empty/comment-only");
39
+ }
40
+ if (!text.slice(0, firstIdx).split("\n").every(isBlankOrComment)) throw new RegistryParseError("content found before the first [[retired]] table header");
41
+ return text.slice(firstIdx).split(marker).slice(1).map((block) => {
42
+ const term = extractString(block, "term");
43
+ const since = extractString(block, "since");
44
+ const replacement = extractString(block, "replacement");
45
+ const scope = extractArray(block, "scope");
46
+ const allow = extractArray(block, "allow");
47
+ const entry = {
48
+ term,
49
+ since,
50
+ replacement
51
+ };
52
+ if (scope.length) entry.scope = scope;
53
+ if (allow.length) entry.allow = allow;
54
+ return entry;
55
+ });
56
+ }
57
+ /** Reads and parses the registry at `<root>/.agents/sdd/retired-terms.toml`. An absent file yields
58
+ * `[]` (an "an absent registry sweeps clean" registry, not an error). A present-but-malformed file
59
+ * throws `RegistryParseError` — the caller must surface that loudly and exit non-zero. */
60
+ function readRegistry(root) {
61
+ const file = join(root, REGISTRY_PATH);
62
+ if (!existsSync(file)) return [];
63
+ return parseRegistryToml(readFileSync(file, "utf8"));
64
+ }
65
+ const DEFAULT_BUILTIN_EXCLUDED_FILES = [
66
+ REGISTRY_PATH,
67
+ "plugins/sdd/skills/check-retired-terms/scripts/check-retired-terms.mts",
68
+ "plugins/sdd/skills/check-retired-terms/scripts/check-retired-terms.test.mts",
69
+ ".agents/specs/sdd/corpus/retired-terms/README.md",
70
+ ".agents/specs/sdd/corpus/retired-terms/retired-terms.feature"
71
+ ];
72
+ const DEFAULT_BUILTIN_EXCLUDED_PREFIXES = [".agents/plans/"];
73
+ function isUnderLedgerDir(relPath) {
74
+ return relPath.split("/").slice(0, -1).includes("ledger");
75
+ }
76
+ function isBuiltinExcluded(relPath, excludedFiles, excludedPrefixes) {
77
+ if (excludedFiles.includes(relPath)) return true;
78
+ if (excludedPrefixes.some((p) => relPath.startsWith(p))) return true;
79
+ if (isUnderLedgerDir(relPath)) return true;
80
+ return false;
81
+ }
82
+ function inScope(relPath, scope) {
83
+ if (!scope || scope.length === 0) return true;
84
+ return scope.some((prefix) => relPath.startsWith(prefix));
85
+ }
86
+ function parseAllowEntry(raw) {
87
+ const idx = raw.indexOf("::");
88
+ if (idx === -1) return { file: raw.trim() };
89
+ return {
90
+ file: raw.slice(0, idx).trim(),
91
+ substring: raw.slice(idx + 2).trim()
92
+ };
93
+ }
94
+ function isAllowed(relPath, lineText, allow) {
95
+ if (!allow) return false;
96
+ for (const raw of allow) {
97
+ const parsed = parseAllowEntry(raw);
98
+ if (parsed.file !== relPath) continue;
99
+ if (parsed.substring === void 0) return true;
100
+ if (lineText.includes(parsed.substring)) return true;
101
+ }
102
+ return false;
103
+ }
104
+ /** The git-tracked file set, repo-relative, one per line. An untracked file is outside the sweep
105
+ * by construction — it is never in this list. */
106
+ function listTrackedFiles(root) {
107
+ return execFileSync("git", ["ls-files"], {
108
+ cwd: root,
109
+ encoding: "utf8"
110
+ }).split("\n").filter((l) => l !== "");
111
+ }
112
+ /** Sweeps `root` for survivors of every entry in `entries`. `options` lets callers (tests)
113
+ * substitute fixture-scoped config — the tracked-file source and the built-in exclusion lists —
114
+ * in place of the real repo and the real defaults, mirroring `findMetaphorViolations`'s
115
+ * `ScanOptions` (packages/cyberlegion/src/metaphor-free.ts). */
116
+ function sweep(root, entries, options = {}) {
117
+ const listFiles = options.listTrackedFiles ?? listTrackedFiles;
118
+ const excludedFiles = options.builtinExcludedFiles ?? DEFAULT_BUILTIN_EXCLUDED_FILES;
119
+ const excludedPrefixes = options.builtinExcludedPrefixes ?? DEFAULT_BUILTIN_EXCLUDED_PREFIXES;
120
+ const violations = [];
121
+ for (const relPath of listFiles(root)) {
122
+ if (isBuiltinExcluded(relPath, excludedFiles, excludedPrefixes)) continue;
123
+ let text;
124
+ try {
125
+ text = readFileSync(join(root, relPath), "utf8");
126
+ } catch {
127
+ continue;
128
+ }
129
+ const lines = text.split("\n");
130
+ for (const entry of entries) {
131
+ if (!inScope(relPath, entry.scope)) continue;
132
+ lines.forEach((lineText, i) => {
133
+ if (!lineText.includes(entry.term)) return;
134
+ if (isAllowed(relPath, lineText, entry.allow)) return;
135
+ violations.push({
136
+ file: relPath,
137
+ line: i + 1,
138
+ term: entry.term,
139
+ replacement: entry.replacement
140
+ });
141
+ });
142
+ }
143
+ }
144
+ return violations.sort((a, b) => a.file !== b.file ? a.file < b.file ? -1 : 1 : a.line - b.line);
145
+ }
146
+ /** Renders `--list` output: one line per registered term with its `since` and `replacement`, or a
147
+ * definitive empty-state line when nothing is registered — never silence. */
148
+ function formatList(entries) {
149
+ if (entries.length === 0) return "check-retired-terms: no term is registered\n";
150
+ return entries.map((e) => `${e.term} (since ${e.since}) -> ${e.replacement}\n`).join("");
151
+ }
152
+ /** Renders the sweep report: `file:line:term` plus the declared replacement, one per survivor
153
+ * (every survivor, not just the first), then a count summary. */
154
+ function formatViolations(violations) {
155
+ const lines = violations.map((v) => `${v.file}:${v.line}:${v.term} — replace with: ${v.replacement}\n`);
156
+ lines.push(`check-retired-terms: ${violations.length} survivor(s) found\n`);
157
+ return lines.join("");
158
+ }
159
+ function main(argv) {
160
+ const root = argv.includes("--root") ? argv[argv.indexOf("--root") + 1] ?? "." : ".";
161
+ const list = argv.includes("--list");
162
+ let entries;
163
+ try {
164
+ entries = readRegistry(root);
165
+ } catch (err) {
166
+ const message = err instanceof Error ? err.message : String(err);
167
+ process.stderr.write(`check-retired-terms: malformed registry — ${message}\n`);
168
+ return 1;
169
+ }
170
+ if (list) {
171
+ process.stdout.write(formatList(entries));
172
+ return 0;
173
+ }
174
+ const violations = sweep(root, entries);
175
+ if (violations.length === 0) {
176
+ process.stdout.write("check-retired-terms: clean — no survivors found\n");
177
+ return 0;
178
+ }
179
+ process.stdout.write(formatViolations(violations));
180
+ return 1;
181
+ }
182
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
183
+ //#endregion
184
+ export { RegistryParseError, formatList, formatViolations, listTrackedFiles, main, parseRegistryToml, readRegistry, sweep };
@@ -0,0 +1,12 @@
1
+ // Launcher for the engine of the same name. Run it as `node <skill>/scripts/<engine>.mjs`.
2
+ // A checkout runs the .mts source. An npm install keeps the skill under node_modules, where Node
3
+ // will not strip types, so it runs the self-contained bundle built for the package.
4
+ import { spawnSync } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const self = fileURLToPath(import.meta.url)
8
+ const installed = /[\\/]node_modules[\\/]/.test(self)
9
+ const target = self.replace(/\.mjs$/, installed ? '.bundle.mjs' : '.mts')
10
+ const run = spawnSync(process.execPath, [...process.execArgv, target, ...process.argv.slice(2)], { stdio: 'inherit' })
11
+ if (run.error) throw run.error
12
+ process.exitCode = run.status ?? 1
@@ -6,8 +6,8 @@ one node's `.feature`**, for the formation Warden: the intra-project **spec-leve
6
6
  collision ladder, and the cross-node sibling of `check-spec-structure` (intra-node node-shape).
7
7
 
8
8
  ```bash
9
- node scripts/check-scenario-overlap.mts --spec-dir <spec> # audit (TOON candidate set)
10
- node scripts/check-scenario-overlap.mts --spec-dir <spec> --check # CI guard (fails on exact-duplicate)
9
+ node scripts/check-scenario-overlap.mjs --spec-dir <spec> # audit (TOON candidate set)
10
+ node scripts/check-scenario-overlap.mjs --spec-dir <spec> --check # CI guard (fails on exact-duplicate)
11
11
  ```
12
12
 
13
13
  Two deterministic candidate kinds — **exact-duplicate** (blocking: two nodes share an identical
@@ -21,8 +21,7 @@ The scheduler treats two missions touching different `.feature` files as **file-
21
21
  the **same behavior** is specified in **two** files, a change to that behavior must touch both, so
22
22
  what looked disjoint is a **hard collision the scenario rung cannot see** (it diffs changed scenarios
23
23
  per file, never across files). One behavior = **one scenario in one owning node** keeps the scenario
24
- rung honest. Cross-*project* dedup (`dedupe-specs`) was retired when one project became one spec;
25
- cross-*node* overlap **inside** a project had no detector until this one.
24
+ rung honest. This detector covers cross-*node* overlap **inside** a project.
26
25
 
27
26
  ## The two deterministic candidate kinds (and one judgment arm)
28
27
 
@@ -49,7 +48,7 @@ only**: a scenario duplicated **within one node** raises no candidate, and a sce
49
48
  ## Run the scan
50
49
 
51
50
  ```bash
52
- node "<skill>/scripts/check-scenario-overlap.mts" [--spec-dir <spec>] [--check] [--format toon|json]
51
+ node "<skill>/scripts/check-scenario-overlap.mjs" [--spec-dir <spec>] [--check] [--format toon|json]
53
52
  ```
54
53
 
55
54
  - Default `--spec-dir` is the current directory; default `--format` is **TOON** (the token-efficient
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, readdirSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ //#region skills/check-scenario-overlap/scripts/check-scenario-overlap.mts
6
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
7
+ "node_modules",
8
+ ".git",
9
+ "dist",
10
+ ".turbo",
11
+ ".next",
12
+ "coverage"
13
+ ]);
14
+ function normalize(s) {
15
+ return s.trim().replace(/\s+/g, " ").toLowerCase();
16
+ }
17
+ const STEP = /^\s*(Given|When|Then|And|But)\s+(.*\S)\s*$/;
18
+ const SCENARIO = /^\s*Scenario(?: Outline)?:\s*(.*\S)\s*$/;
19
+ const TABLE_ROW = /^\s*\|(.*)\|\s*$/;
20
+ function normalizeRow(line) {
21
+ return (TABLE_ROW.exec(line)?.[1] ?? line).split("|").map((cell) => normalize(cell)).join("|");
22
+ }
23
+ function parseFeature(featureText) {
24
+ const scenarios = [];
25
+ let title = null;
26
+ let steps = [];
27
+ let exampleRows = [];
28
+ const flush = () => {
29
+ if (title !== null) {
30
+ const fingerprint = exampleRows.length === 0 ? steps.map(normalize).join("\n") : `${steps.map(normalize).join("\n")}\n EXAMPLES \n${exampleRows.join("\n")}`;
31
+ scenarios.push({
32
+ title,
33
+ titleKey: normalize(title),
34
+ fingerprint
35
+ });
36
+ }
37
+ title = null;
38
+ steps = [];
39
+ exampleRows = [];
40
+ };
41
+ for (const line of featureText.split("\n")) {
42
+ const sc = SCENARIO.exec(line);
43
+ if (sc) {
44
+ flush();
45
+ title = sc[1];
46
+ continue;
47
+ }
48
+ if (title === null) continue;
49
+ const st = STEP.exec(line);
50
+ if (st) {
51
+ steps.push(st[2]);
52
+ continue;
53
+ }
54
+ if (TABLE_ROW.test(line)) exampleRows.push(normalizeRow(line));
55
+ }
56
+ flush();
57
+ return scenarios;
58
+ }
59
+ function displayPath(relPath) {
60
+ const p = relPath.replace(/\\/g, "/");
61
+ return p.endsWith("/README.md") ? p.slice(0, -9) : p;
62
+ }
63
+ function scanSuites(specDir) {
64
+ const suites = [];
65
+ walk(specDir, specDir, suites);
66
+ return suites.sort((a, b) => a.node.localeCompare(b.node));
67
+ }
68
+ function walk(dir, specDir, out) {
69
+ const entries = readdirSync(dir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
72
+ const full = join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ walk(full, specDir, out);
75
+ continue;
76
+ }
77
+ if (!entry.name.endsWith(".feature")) continue;
78
+ const scenarios = parseFeature(readFileSync(full, "utf8"));
79
+ if (scenarios.length === 0) continue;
80
+ const relDir = dir.slice(specDir.length + 1).replace(/\\/g, "/");
81
+ out.push({
82
+ node: displayPath(`${relDir}/`),
83
+ scenarios
84
+ });
85
+ }
86
+ }
87
+ function detect(suites) {
88
+ const byFingerprint = /* @__PURE__ */ new Map();
89
+ const byTitle = /* @__PURE__ */ new Map();
90
+ for (const suite of suites) for (const sc of suite.scenarios) {
91
+ if (sc.fingerprint !== "") {
92
+ let m = byFingerprint.get(sc.fingerprint);
93
+ if (!m) byFingerprint.set(sc.fingerprint, m = /* @__PURE__ */ new Map());
94
+ if (!m.has(suite.node)) m.set(suite.node, sc.title);
95
+ }
96
+ let t = byTitle.get(sc.titleKey);
97
+ if (!t) byTitle.set(sc.titleKey, t = /* @__PURE__ */ new Map());
98
+ let e = t.get(suite.node);
99
+ if (!e) t.set(suite.node, e = {
100
+ fps: /* @__PURE__ */ new Set(),
101
+ title: sc.title
102
+ });
103
+ e.fps.add(sc.fingerprint);
104
+ }
105
+ const candidates = [];
106
+ for (const [, nodeMap] of byFingerprint) {
107
+ if (nodeMap.size < 2) continue;
108
+ const nodes = [...nodeMap.keys()].sort();
109
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) candidates.push({
110
+ kind: "exact-duplicate",
111
+ severity: "blocking",
112
+ nodes: [nodes[i], nodes[j]],
113
+ scenario: nodeMap.get(nodes[i]) ?? "",
114
+ detail: "identical step fingerprint — the same behavior is specified in both nodes; one behavior = one owning node"
115
+ });
116
+ }
117
+ for (const [, nodeMap] of byTitle) {
118
+ if (nodeMap.size < 2) continue;
119
+ const nodes = [...nodeMap.keys()].sort();
120
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
121
+ const a = nodeMap.get(nodes[i]);
122
+ const b = nodeMap.get(nodes[j]);
123
+ if (!a || !b) continue;
124
+ if ([...a.fps].some((fp) => b.fps.has(fp))) continue;
125
+ candidates.push({
126
+ kind: "title-overlap",
127
+ severity: "advisory",
128
+ nodes: [nodes[i], nodes[j]],
129
+ scenario: a.title,
130
+ detail: "same scenario title, differing steps — a weaker overlap hint the Warden judges"
131
+ });
132
+ }
133
+ }
134
+ return candidates;
135
+ }
136
+ function hasBlocking(candidates) {
137
+ return candidates.some((c) => c.severity === "blocking");
138
+ }
139
+ function renderCandidates(candidates, specDir) {
140
+ const blocking = candidates.filter((c) => c.severity === "blocking");
141
+ const advisory = candidates.filter((c) => c.severity === "advisory");
142
+ const lines = [`check-scenario-overlap: spec-dir=${specDir}`];
143
+ lines.push(`blocking[${blocking.length}]:`);
144
+ for (const c of blocking) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`);
145
+ lines.push(`advisory[${advisory.length}]:`);
146
+ for (const c of advisory) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`);
147
+ lines.push("note: advisory — candidates feed the Warden formation pass; the engine writes nothing");
148
+ return lines.join("\n");
149
+ }
150
+ function main(argv) {
151
+ let specDir = ".";
152
+ let mode = "audit";
153
+ let format = "toon";
154
+ for (let i = 0; i < argv.length; i++) {
155
+ const a = argv[i];
156
+ if (a === "--spec-dir") specDir = argv[++i] ?? ".";
157
+ else if (a === "--check") mode = "check";
158
+ else if (a === "--format") format = argv[++i] ?? "toon";
159
+ }
160
+ const candidates = detect(scanSuites(specDir));
161
+ if (mode === "check") {
162
+ if (hasBlocking(candidates)) {
163
+ process.stderr.write(`check-scenario-overlap: ${candidates.filter((c) => c.severity === "blocking").length} exact-duplicate candidate(s)\n`);
164
+ return 1;
165
+ }
166
+ process.stdout.write("check-scenario-overlap: no exact-duplicate candidates\n");
167
+ return 0;
168
+ }
169
+ if (format === "json") process.stdout.write(`${JSON.stringify(candidates)}\n`);
170
+ else process.stdout.write(`${renderCandidates(candidates, specDir)}\n`);
171
+ return 0;
172
+ }
173
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
174
+ //#endregion
175
+ export { detect, hasBlocking, main, normalize, parseFeature, renderCandidates, scanSuites };
@@ -0,0 +1,12 @@
1
+ // Launcher for the engine of the same name. Run it as `node <skill>/scripts/<engine>.mjs`.
2
+ // A checkout runs the .mts source. An npm install keeps the skill under node_modules, where Node
3
+ // will not strip types, so it runs the self-contained bundle built for the package.
4
+ import { spawnSync } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const self = fileURLToPath(import.meta.url)
8
+ const installed = /[\\/]node_modules[\\/]/.test(self)
9
+ const target = self.replace(/\.mjs$/, installed ? '.bundle.mjs' : '.mts')
10
+ const run = spawnSync(process.execPath, [...process.execArgv, target, ...process.argv.slice(2)], { stdio: 'inherit' })
11
+ if (run.error) throw run.error
12
+ process.exitCode = run.status ?? 1
@@ -5,7 +5,7 @@ Internal SDD skill — the concrete engine for the **spec-reference resolution c
5
5
  directory, and reports the ones that resolve to nothing.
6
6
 
7
7
  ```bash
8
- node scripts/check-spec-references.mts --spec-dir <specDir>
8
+ node scripts/check-spec-references.mjs --spec-dir <specDir>
9
9
  ```
10
10
 
11
11
  Exits non-zero on any unresolved reference, reporting each as `file:line: <ref> -> <resolved path>` — naming the resolved path is the
@@ -70,7 +70,7 @@ from **outside the code spans**, so a line that merely quotes the marker keeps i
70
70
  ## Run the check
71
71
 
72
72
  ```bash
73
- node "<skill>/scripts/check-spec-references.mts" --spec-dir <specDir>
73
+ node "<skill>/scripts/check-spec-references.mjs" --spec-dir <specDir>
74
74
  ```
75
75
 
76
76
  - Exits **zero** on a spec whose every reference resolves, printing a definitive clean line.