cyber-sdd 0.6.1 → 0.7.1

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