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,251 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ //#region skills/check-field-mandates/scripts/check-field-mandates.mts
6
+ /** UPPER_CASE, or snake_case with at least one underscore — a bare lowercase word is prose. */
7
+ const TOKEN = "(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)";
8
+ const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`;
9
+ /** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
10
+ const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`);
11
+ /** A list item opening with one or more bold tokens joined by `+`. */
12
+ const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`);
13
+ const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, "g");
14
+ const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, "g");
15
+ const CODE_SPAN_RE = /`([^`\n]+)`/g;
16
+ const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`);
17
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
18
+ /** The marker must carry a reason — a bare marker excuses nothing. */
19
+ const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/;
20
+ /** A field token is two or more characters: a lone capital is a word, not a field. */
21
+ function isField(token) {
22
+ return token.length >= 2;
23
+ }
24
+ /** A token's case-folded form: the spelling-independent name of the field. */
25
+ function fold(token) {
26
+ return token.toUpperCase();
27
+ }
28
+ /** The folded forms a token may take and still be the same field — equal, or one trailing `S` apart. */
29
+ function sameFieldForms(token) {
30
+ const t = fold(token);
31
+ const forms = [t, `${t}S`];
32
+ if (t.endsWith("S") && t.length > 2) forms.push(t.slice(0, -1));
33
+ return forms;
34
+ }
35
+ /** Does the set of folded tokens hold the same field as `token`? */
36
+ function hasSameField(folded, token) {
37
+ return sameFieldForms(token).some((f) => folded.has(f));
38
+ }
39
+ /** Is the token spelled lowercase (snake_case) rather than uppercase? */
40
+ function isLower(token) {
41
+ return token !== fold(token);
42
+ }
43
+ function parseDefinition(text) {
44
+ const lines = text.split(/\r?\n/);
45
+ const declarations = [];
46
+ const mandates = [];
47
+ const proseWords = /* @__PURE__ */ new Set();
48
+ let i = 0;
49
+ if (lines[0]?.trim() === "---") {
50
+ const end = lines.findIndex((l, n) => n > 0 && l.trim() === "---");
51
+ if (end > 0) i = end + 1;
52
+ }
53
+ let fence = null;
54
+ let group = [];
55
+ /** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
56
+ function scanProse(text, lineNo) {
57
+ for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0]);
58
+ if (IGNORE_MARKER_RE.test(text)) return;
59
+ for (const m of text.matchAll(CODE_SPAN_RE)) {
60
+ const token = (m[1] ?? "").trim().match(MANDATE_LEAD_RE)?.[1];
61
+ if (token && isField(token)) mandates.push({
62
+ token,
63
+ line: lineNo
64
+ });
65
+ }
66
+ }
67
+ for (; i < lines.length; i++) {
68
+ const line = lines[i] ?? "";
69
+ const lineNo = i + 1;
70
+ const fenceMatch = line.match(FENCE_RE);
71
+ if (fence) {
72
+ if (fenceMatch && fenceMatch[1]?.[0] === fence.char && (fenceMatch[1]?.length ?? 0) >= fence.length && fenceMatch[2]?.trim() === "") {
73
+ fence = null;
74
+ group = [];
75
+ continue;
76
+ }
77
+ if (!fence.structured) continue;
78
+ const decl = line.match(BLOCK_DECLARATION_RE);
79
+ const tokens = decl ? (decl[1] ?? "").split(",").map((t) => t.trim().replace(/\(s\)$/, "")) : [];
80
+ const rest = decl?.[2];
81
+ if (decl !== null && (rest?.startsWith(":") || tokens.length >= 2)) {
82
+ const glossed = rest?.startsWith(":") === true && rest.slice(1).trim() !== "";
83
+ group = tokens.filter(isField).map((token) => ({
84
+ token,
85
+ line: lineNo,
86
+ glossed
87
+ }));
88
+ declarations.push(...group);
89
+ } else if (line.trim() !== "") for (const d of group) d.glossed = true;
90
+ continue;
91
+ }
92
+ if (fenceMatch) {
93
+ const info = fenceMatch[2]?.trim() ?? "";
94
+ fence = {
95
+ char: fenceMatch[1]?.[0] ?? "`",
96
+ length: fenceMatch[1]?.length ?? 3,
97
+ structured: info === "" || info === "text"
98
+ };
99
+ group = [];
100
+ continue;
101
+ }
102
+ const listDecl = line.match(LIST_DECLARATION_RE);
103
+ if (listDecl) {
104
+ const rest = listDecl[2] ?? "";
105
+ const glossed = rest.trim() !== "";
106
+ for (const m of (listDecl[1] ?? "").matchAll(BOLD_TOKEN_RE)) {
107
+ const token = m[1] ?? "";
108
+ if (isField(token)) declarations.push({
109
+ token,
110
+ line: lineNo,
111
+ glossed
112
+ });
113
+ }
114
+ scanProse(rest, lineNo);
115
+ continue;
116
+ }
117
+ scanProse(line, lineNo);
118
+ }
119
+ return {
120
+ declarations,
121
+ mandates,
122
+ proseWords
123
+ };
124
+ }
125
+ function walk(dir, out) {
126
+ let entries;
127
+ try {
128
+ entries = readdirSync(dir, { withFileTypes: true });
129
+ } catch {
130
+ return;
131
+ }
132
+ for (const e of entries) {
133
+ const full = join(dir, e.name);
134
+ if (e.isDirectory()) walk(full, out);
135
+ else if (e.isFile() && e.name.endsWith(".md")) out.push(full);
136
+ }
137
+ }
138
+ /** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
139
+ function isDefinitionPath(rel) {
140
+ const parts = rel.split("/");
141
+ if (parts[0] !== "plugins" || parts.length < 4) return false;
142
+ if (parts[2] === "agents") return parts.length === 4 && (parts[3] ?? "").endsWith(".md");
143
+ if (parts[2] === "skills") return parts.length >= 5 && parts[parts.length - 1] === "SKILL.md";
144
+ return false;
145
+ }
146
+ /** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
147
+ function discoverDefinitions(root) {
148
+ const pluginsDir = join(root, "plugins");
149
+ if (!existsSync(pluginsDir)) return [];
150
+ const files = [];
151
+ walk(pluginsDir, files);
152
+ return files.map((f) => relative(root, f).split(sep).join("/")).filter(isDefinitionPath).sort();
153
+ }
154
+ function check(parsed) {
155
+ const known = /* @__PURE__ */ new Set();
156
+ for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token));
157
+ const findings = [];
158
+ for (const [file, p] of parsed) {
159
+ if (p.declarations.length === 0) continue;
160
+ const declared = new Set(p.declarations.map((d) => fold(d.token)));
161
+ const proseWords = new Set([...p.proseWords].map(fold));
162
+ const seen = /* @__PURE__ */ new Set();
163
+ for (const d of p.declarations) {
164
+ if (seen.has(d.token)) continue;
165
+ seen.add(d.token);
166
+ if (!p.declarations.some((o) => o.token === d.token && o.glossed) && !hasSameField(proseWords, d.token)) findings.push({
167
+ kind: "unexplained",
168
+ file,
169
+ line: d.line,
170
+ token: d.token
171
+ });
172
+ }
173
+ const reported = /* @__PURE__ */ new Set();
174
+ for (const m of p.mandates) {
175
+ if (!hasSameField(known, m.token)) continue;
176
+ const key = `${m.line}:${m.token}`;
177
+ if (reported.has(key)) continue;
178
+ if (!hasSameField(declared, m.token)) {
179
+ reported.add(key);
180
+ findings.push({
181
+ kind: "undeclared",
182
+ file,
183
+ line: m.line,
184
+ token: m.token
185
+ });
186
+ continue;
187
+ }
188
+ const forms = sameFieldForms(m.token);
189
+ const same = p.declarations.filter((d) => forms.includes(fold(d.token)));
190
+ if (same.some((d) => isLower(d.token) === isLower(m.token))) continue;
191
+ reported.add(key);
192
+ findings.push({
193
+ kind: "miscased",
194
+ file,
195
+ line: m.line,
196
+ token: m.token,
197
+ declared: same[0]?.token
198
+ });
199
+ }
200
+ }
201
+ return findings.sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
202
+ }
203
+ const DETAIL = {
204
+ unexplained: "declared in a block, but no gloss and no prose explains it",
205
+ undeclared: "mandated in the prose, but no block in this file declares it",
206
+ miscased: "mandated in the prose, but this file declares it in the other case"
207
+ };
208
+ function formatReport(definitions, findings) {
209
+ if (definitions.length === 0) return "check-field-mandates: no skill or agent definition found\n";
210
+ if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`;
211
+ return `${findings.map((f) => ` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ""}`).join("\n")}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`;
212
+ }
213
+ function isReadableDirectory(path) {
214
+ try {
215
+ if (!statSync(path).isDirectory()) return false;
216
+ readdirSync(path);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+ function main(argv) {
223
+ let root = ".";
224
+ for (let i = 0; i < argv.length; i++) {
225
+ const a = argv[i];
226
+ if (a === "--root") {
227
+ const next = argv[++i];
228
+ if (next === void 0) {
229
+ process.stderr.write("check-field-mandates: --root needs a directory\n");
230
+ return 1;
231
+ }
232
+ root = next;
233
+ continue;
234
+ }
235
+ process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`);
236
+ return 1;
237
+ }
238
+ if (!isReadableDirectory(root)) {
239
+ process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`);
240
+ return 1;
241
+ }
242
+ const definitions = discoverDefinitions(root);
243
+ const parsed = /* @__PURE__ */ new Map();
244
+ for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), "utf8")));
245
+ const findings = check(parsed);
246
+ process.stdout.write(formatReport(definitions, findings));
247
+ return findings.length === 0 ? 0 : 1;
248
+ }
249
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
250
+ //#endregion
251
+ export { check, discoverDefinitions, formatReport, isDefinitionPath, main, parseDefinition };
@@ -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
@@ -19,7 +19,7 @@ owner's call (`sdd:spec-structure-governance` — adoption over purity).
19
19
  ## Run it
20
20
 
21
21
  ```bash
22
- node "<skill>/scripts/check-partition-quality.mts" \
22
+ node "<skill>/scripts/check-partition-quality.mjs" \
23
23
  --repo <path> --scope <dir> [--partition <name>]... [--floor N] [--limit N] [--format json]
24
24
  ```
25
25
 
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { realpathSync } from "node:fs";
4
+ import { pathToFileURL } from "node:url";
5
+ //#region skills/check-partition-quality/scripts/check-partition-quality.mts
6
+ /** Below this many usable multi-file commits, a rate would be noise — report, never score. */
7
+ const DEFAULT_FLOOR = 20;
8
+ function parseGitLog(stdout, inScope) {
9
+ const out = [];
10
+ let cur = null;
11
+ for (const line of stdout.split("\n")) {
12
+ const t = line.trimEnd();
13
+ if (t === "") {
14
+ if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
15
+ cur = null;
16
+ continue;
17
+ }
18
+ if (/^[0-9a-f]{40}$/.test(t)) {
19
+ if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
20
+ cur = [];
21
+ continue;
22
+ }
23
+ if (cur !== null && inScope(t)) cur.push(t);
24
+ }
25
+ if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
26
+ return out.filter((c) => c.files.length > 1);
27
+ }
28
+ function readHistory(repo, inScope, limit = 4e3) {
29
+ return parseGitLog(execFileSync("git", [
30
+ "-C",
31
+ repo,
32
+ "log",
33
+ `-${limit}`,
34
+ "--name-only",
35
+ "--pretty=format:%H"
36
+ ], {
37
+ encoding: "utf8",
38
+ maxBuffer: 268435456
39
+ }), inScope);
40
+ }
41
+ /** The set of nodes one change touches. A change touching none drops out of the measure. */
42
+ function nodesOf(c, p) {
43
+ const s = /* @__PURE__ */ new Set();
44
+ for (const f of c.files) {
45
+ const n = p(f);
46
+ if (n !== void 0) s.add(n);
47
+ }
48
+ return s;
49
+ }
50
+ function collisionRate(changes, p) {
51
+ const sets = changes.map((c) => nodesOf(c, p)).filter((s) => s.size > 0);
52
+ let pairs = 0;
53
+ let collided = 0;
54
+ for (let i = 0; i < sets.length; i++) for (let j = i + 1; j < sets.length; j++) {
55
+ pairs++;
56
+ const a = sets[i];
57
+ const b = sets[j];
58
+ for (const n of a) if (b.has(n)) {
59
+ collided++;
60
+ break;
61
+ }
62
+ }
63
+ return {
64
+ rate: pairs === 0 ? 0 : collided / pairs,
65
+ pairs
66
+ };
67
+ }
68
+ function mulberry32(seed) {
69
+ let a = seed >>> 0;
70
+ return () => {
71
+ a = a + 1831565813 >>> 0;
72
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
73
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
74
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
75
+ };
76
+ }
77
+ function shuffledControl(changes, p, seed = 7) {
78
+ const files = [...new Set(changes.flatMap((c) => c.files))].filter((f) => p(f) !== void 0).sort();
79
+ const labels = files.map((f) => p(f));
80
+ const rnd = mulberry32(seed);
81
+ for (let i = labels.length - 1; i > 0; i--) {
82
+ const j = Math.floor(rnd() * (i + 1));
83
+ const li = labels[i];
84
+ const lj = labels[j];
85
+ labels[i] = lj;
86
+ labels[j] = li;
87
+ }
88
+ const m = new Map(files.map((f, i) => [f, labels[i]]));
89
+ return collisionRate(changes, (f) => m.get(f)).rate;
90
+ }
91
+ /**
92
+ * Rename `diagnostics` to `confoundedDiagnostics` on the way out to JSON.
93
+ *
94
+ * The text render carries the warning inline; a JSON consumer sees only keys, so a plain
95
+ * `diagnostics` object hands over two numbers indistinguishable from the headline. Renaming the key
96
+ * beats adding a note field: a consumer cannot read these numbers without typing the word
97
+ * "confounded", whereas a sibling note is trivially skipped.
98
+ */
99
+ function toJson([name, m]) {
100
+ if (!("diagnostics" in m)) return [name, m];
101
+ const { diagnostics, ...rest } = m;
102
+ return [name, {
103
+ ...rest,
104
+ confoundedDiagnostics: diagnostics
105
+ }];
106
+ }
107
+ function withinNodeCoChangeRatio(changes, p) {
108
+ let within = 0;
109
+ let total = 0;
110
+ for (const c of changes) {
111
+ const ns = c.files.map((f) => p(f)).filter((n) => n !== void 0);
112
+ for (let i = 0; i < ns.length; i++) for (let j = i + 1; j < ns.length; j++) {
113
+ total++;
114
+ if (ns[i] === ns[j]) within++;
115
+ }
116
+ }
117
+ return total === 0 ? 0 : within / total;
118
+ }
119
+ function meanNodesTouched(changes, p) {
120
+ const counts = changes.map((c) => nodesOf(c, p).size).filter((n) => n > 0);
121
+ return counts.length === 0 ? 0 : counts.reduce((a, b) => a + b, 0) / counts.length;
122
+ }
123
+ function measure(changes, p, floor = 20) {
124
+ const usable = changes.filter((c) => nodesOf(c, p).size > 0);
125
+ if (usable.length < floor) return {
126
+ thin: true,
127
+ usableCommits: usable.length,
128
+ floor
129
+ };
130
+ const { rate, pairs } = collisionRate(usable, p);
131
+ const control = shuffledControl(usable, p);
132
+ const margin = control - rate;
133
+ return {
134
+ nodes: new Set(usable.flatMap((c) => [...nodesOf(c, p)])).size,
135
+ pairs,
136
+ collisionRate: rate,
137
+ parallelizableShare: 1 - rate,
138
+ control,
139
+ marginOverControl: margin,
140
+ explainsNothing: margin <= 0,
141
+ diagnostics: {
142
+ withinNodeCoChangeRatio: withinNodeCoChangeRatio(usable, p),
143
+ meanNodesTouched: meanNodesTouched(usable, p)
144
+ }
145
+ };
146
+ }
147
+ function isThin(m) {
148
+ return m.thin === true;
149
+ }
150
+ const PARTITIONS = {
151
+ /** Top-level folder under the scope — the capability cut for a screaming layout. */
152
+ "top-folder": (scope) => (f) => {
153
+ const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, "") : void 0;
154
+ if (rel === void 0) return void 0;
155
+ const seg = rel.split("/")[0];
156
+ return seg === void 0 || seg === "" || !rel.includes("/") ? void 0 : seg;
157
+ },
158
+ /** Second-level folder — a finer cut, for a capability/unit tree. */
159
+ "second-folder": (scope) => (f) => {
160
+ const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, "") : void 0;
161
+ if (rel === void 0) return void 0;
162
+ const p = rel.split("/");
163
+ return p.length > 2 ? `${p[0]}/${p[1]}` : void 0;
164
+ },
165
+ /** File extension / artifact role — the layered analogue, useful as a contrast candidate. */
166
+ role: (scope) => (f) => {
167
+ if (!f.startsWith(scope)) return void 0;
168
+ const n = f.split("/").pop() ?? "";
169
+ const dot = n.indexOf(".");
170
+ return dot === -1 ? n : n.slice(dot + 1);
171
+ },
172
+ /** Everything in one node — the degenerate floor: no parallel work is possible. */
173
+ single: (scope) => (f) => f.startsWith(scope) ? "all" : void 0
174
+ };
175
+ function pct(n) {
176
+ return `${(n * 100).toFixed(1)}%`;
177
+ }
178
+ function renderOne(label, m) {
179
+ if (isThin(m)) return ` ${label}: history too thin to measure — ${m.usableCommits} usable multi-file commits, floor ${m.floor}. No rate emitted.`;
180
+ const lines = [
181
+ ` ${label}`,
182
+ ` parallelizable share : ${pct(m.parallelizableShare)} ← the headline`,
183
+ ` collision rate : ${pct(m.collisionRate)} over ${m.pairs} change pairs, ${m.nodes} nodes`,
184
+ ` shuffled control : ${pct(m.control)} (margin ${m.marginOverControl >= 0 ? "+" : ""}${pct(m.marginOverControl)})`
185
+ ];
186
+ if (m.collisionRate >= 1) lines.push(" ⚠ every change pair collides — this partition permits no parallel work");
187
+ if (m.explainsNothing) lines.push(" ⚠ this partition explains no more than chance — do not trust the headline");
188
+ lines.push(` diagnostics (CONFOUNDED by node count — not the headline): within-node co-change ${pct(m.diagnostics.withinNodeCoChangeRatio)}, mean nodes touched ${m.diagnostics.meanNodesTouched.toFixed(2)}`);
189
+ return lines.join("\n");
190
+ }
191
+ function render(results, repo, scope) {
192
+ const out = [`check-partition-quality: repo=${repo} scope=${scope || "(whole repo)"}`];
193
+ for (const [label, m] of results) out.push(renderOne(label, m));
194
+ if (results.length > 1) {
195
+ const scored = results.filter(([, m]) => !isThin(m));
196
+ if (scored.length > 1) {
197
+ const best = scored.reduce((a, b) => b[1].parallelizableShare > a[1].parallelizableShare ? b : a);
198
+ out.push(`\nOn this history, "${best[0]}" permits the most parallel work (${pct(best[1].parallelizableShare)}).`);
199
+ }
200
+ }
201
+ out.push("note: a measurement, not a verdict — it moves no file and gates nothing; layout is the owner’s call");
202
+ return out.join("\n");
203
+ }
204
+ function main(argv, context = { readHistory }) {
205
+ let repo = ".";
206
+ let scope = "";
207
+ let floor = 20;
208
+ let limit = 4e3;
209
+ let format = "text";
210
+ const candidates = [];
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const a = argv[i];
213
+ if (a === "--repo") repo = argv[++i] ?? ".";
214
+ else if (a === "--scope") scope = argv[++i] ?? "";
215
+ else if (a === "--floor") floor = Number(argv[++i] ?? 20);
216
+ else if (a === "--limit") limit = Number(argv[++i] ?? 4e3);
217
+ else if (a === "--format") format = argv[++i] ?? "text";
218
+ else if (a === "--partition") candidates.push(argv[++i] ?? "");
219
+ }
220
+ if (candidates.length === 0) candidates.push("top-folder", "role");
221
+ for (const c of candidates) if (!(c in PARTITIONS)) {
222
+ process.stderr.write(`unknown --partition "${c}" — known: ${Object.keys(PARTITIONS).join(", ")}\n`);
223
+ return 1;
224
+ }
225
+ const changes = context.readHistory(repo, (f) => scope ? f.startsWith(scope) : true, limit);
226
+ const results = candidates.map((c) => [c, measure(changes, PARTITIONS[c](scope), floor)]);
227
+ if (format === "json") process.stdout.write(`${JSON.stringify(Object.fromEntries(results.map(toJson)), null, 2)}\n`);
228
+ else process.stdout.write(`${render(results, repo, scope)}\n`);
229
+ return 0;
230
+ }
231
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
232
+ //#endregion
233
+ export { DEFAULT_FLOOR, PARTITIONS, collisionRate, isThin, main, meanNodesTouched, measure, parseGitLog, readHistory, render, renderOne, shuffledControl, toJson, withinNodeCoChangeRatio };
@@ -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,9 +5,9 @@ the tracked, portable handoff artifacts under `.agents/plans` (the `*.plan.md` b
5
5
  docs) for machine-local references that must never enter git history.
6
6
 
7
7
  ```bash
8
- node scripts/check-plan-safety.mts --root . # audit (TOON finding set)
9
- node scripts/check-plan-safety.mts --root . --check # CI guard (fails on any leak)
10
- node scripts/check-plan-safety.mts --path <file> --check # check one explicit file
8
+ node scripts/check-plan-safety.mjs --root . # audit (TOON finding set)
9
+ node scripts/check-plan-safety.mjs --root . --check # CI guard (fails on any leak)
10
+ node scripts/check-plan-safety.mjs --path <file> --check # check one explicit file
11
11
  ```
12
12
 
13
13
  Flags a **home-directory absolute path** (`/home/<user>/…`, `/Users/<user>/…`, `C:\Users\<user>\…`)
@@ -38,7 +38,7 @@ describing home-rooted feature paths (a tool's own `~/.<tool>/` data root). `$HO
38
38
  ## Run the scan
39
39
 
40
40
  ```bash
41
- node "<skill>/scripts/check-plan-safety.mts" [--root .] [--path <file>]... [--check] [--format toon|json]
41
+ node "<skill>/scripts/check-plan-safety.mjs" [--root .] [--path <file>]... [--check] [--format toon|json]
42
42
  ```
43
43
 
44
44
  - Default `--root` is the current directory; default `--format` is **TOON** (the token-efficient
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ //#region skills/check-plan-safety/scripts/check-plan-safety.mts
6
+ const LEAK_PATTERNS = [
7
+ {
8
+ kind: "home-abs-path",
9
+ re: /\/(?:home|Users)\/[^\s'"`)\]]+/g
10
+ },
11
+ {
12
+ kind: "home-abs-path",
13
+ re: /[A-Za-z]:\\Users\\[^\s'"`)\]]+/g
14
+ },
15
+ {
16
+ kind: "env-home",
17
+ re: /\$\{?HOME\}?(?![A-Za-z])|%USERPROFILE%|%HOMEPATH%/g
18
+ },
19
+ {
20
+ kind: "env-user",
21
+ re: /\$\{?USER\}?(?![A-Za-z])|%USERNAME%/g
22
+ }
23
+ ];
24
+ /** Cap a matched token so a pathological long path does not blow up the report. */
25
+ function capToken(t) {
26
+ return t.length > 80 ? `${t.slice(0, 77)}...` : t;
27
+ }
28
+ function scanText(file, text) {
29
+ const out = [];
30
+ const lines = text.split("\n");
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i].replace(/\r$/, "");
33
+ for (const { kind, re } of LEAK_PATTERNS) {
34
+ re.lastIndex = 0;
35
+ let m = re.exec(line);
36
+ while (m !== null) {
37
+ out.push({
38
+ file,
39
+ line: i + 1,
40
+ kind,
41
+ token: capToken(m[0])
42
+ });
43
+ if (m.index === re.lastIndex) re.lastIndex++;
44
+ m = re.exec(line);
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ function planFiles(root) {
51
+ const dir = join(root, ".agents", "plans");
52
+ if (!existsSync(dir)) return [];
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return [];
58
+ }
59
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
60
+ }
61
+ function collectLeaks(root, paths) {
62
+ const targets = paths ? paths.map((p) => ({
63
+ rel: p,
64
+ abs: p
65
+ })) : planFiles(root).map((name) => ({
66
+ rel: join(".agents", "plans", name),
67
+ abs: join(root, ".agents", "plans", name)
68
+ }));
69
+ const out = [];
70
+ for (const { rel, abs } of targets) {
71
+ let text;
72
+ try {
73
+ text = readFileSync(abs, "utf8");
74
+ } catch {
75
+ continue;
76
+ }
77
+ out.push(...scanText(rel, text));
78
+ }
79
+ return out.sort((a, b) => a.file !== b.file ? a.file < b.file ? -1 : 1 : a.line - b.line);
80
+ }
81
+ const COLUMNS = [
82
+ "file",
83
+ "line",
84
+ "kind",
85
+ "token"
86
+ ];
87
+ function toonField(v) {
88
+ if (v === "" || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, "\"\"")}"`;
89
+ return v;
90
+ }
91
+ function toToon(leaks) {
92
+ return [`leaks[${leaks.length}]{${COLUMNS.join(",")}}:`, ...leaks.map((l) => ` ${COLUMNS.map((c) => toonField(String(l[c]))).join(",")}`)].join("\n");
93
+ }
94
+ function main(argv) {
95
+ const root = argv.includes("--root") ? argv[argv.indexOf("--root") + 1] ?? "." : ".";
96
+ const format = argv.includes("--format") ? argv[argv.indexOf("--format") + 1] : "toon";
97
+ const check = argv.includes("--check");
98
+ const paths = [];
99
+ for (let i = 0; i < argv.length; i++) if (argv[i] === "--path" && argv[i + 1]) paths.push(argv[++i]);
100
+ const leaks = collectLeaks(root, paths.length ? paths : void 0);
101
+ const out = format === "json" ? JSON.stringify(leaks, null, 2) : toToon(leaks);
102
+ process.stdout.write(`${out}\n`);
103
+ return check && leaks.length > 0 ? 1 : 0;
104
+ }
105
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
106
+ //#endregion
107
+ export { LEAK_PATTERNS, collectLeaks, main, scanText, toToon };
@@ -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
@@ -42,7 +42,7 @@ finding against every pointer in the package.
42
42
  ## Run it
43
43
 
44
44
  ```bash
45
- node "<skill>/scripts/check-plugin-manifests.mts" [--root <dir>]
45
+ node "<skill>/scripts/check-plugin-manifests.mjs" [--root <dir>]
46
46
  ```
47
47
 
48
48
  `--root` defaults to the working directory. A finding always exits non-zero — there is deliberately