cyber-sdd 0.7.0 → 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 (149) 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/package.json +11 -13
  5. package/plugin.json +55 -0
  6. package/skills/align-spec/SKILL.md +1 -1
  7. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  8. package/skills/blast-estimate/README.md +3 -3
  9. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  10. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  11. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  12. package/skills/check-plan-safety/SKILL.md +2 -3
  13. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  14. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  15. package/skills/check-project-specs/SKILL.md +1 -1
  16. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  17. package/skills/check-retired-terms/SKILL.md +2 -3
  18. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  19. package/skills/check-scenario-overlap/SKILL.md +1 -2
  20. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  21. package/skills/check-spec-references/SKILL.md +2 -2
  22. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  23. package/skills/check-spec-structure/SKILL.md +1 -2
  24. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  25. package/skills/collision-ladder/README.md +2 -2
  26. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  27. package/skills/combat-log-governance/SKILL.md +1 -1
  28. package/skills/concept-index/SKILL.md +1 -1
  29. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  30. package/skills/discover-plans/README.md +3 -3
  31. package/skills/discover-plans/SKILL.md +1 -1
  32. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  33. package/skills/discover-specs/README.md +3 -3
  34. package/skills/discover-specs/SKILL.md +1 -1
  35. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  36. package/skills/doctrine-loop/SKILL.md +1 -1
  37. package/skills/gate-validation-governance/SKILL.md +1 -1
  38. package/skills/init/README.md +2 -2
  39. package/skills/init/scripts/wire-statusline.mjs +168 -12
  40. package/skills/manage-ignore/README.md +3 -3
  41. package/skills/manage-ignore/SKILL.md +1 -2
  42. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  43. package/skills/manage-scenario-bridge/README.md +3 -3
  44. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  45. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  46. package/skills/manage-spec-anchors/README.md +3 -3
  47. package/skills/manage-spec-anchors/SKILL.md +1 -1
  48. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  49. package/skills/mission-graph/README.md +2 -2
  50. package/skills/mission-graph/SKILL.md +1 -1
  51. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  52. package/skills/pause-mission/SKILL.md +1 -1
  53. package/skills/place-node/SKILL.md +2 -2
  54. package/skills/place-node/scripts/place-node.mjs +117 -12
  55. package/skills/plan-retirement/README.md +2 -2
  56. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  57. package/skills/resolve-governances/README.md +3 -3
  58. package/skills/resolve-governances/SKILL.md +1 -1
  59. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  60. package/skills/resolve-tracking/SKILL.md +1 -1
  61. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  62. package/skills/scaffold-project-spec/SKILL.md +1 -1
  63. package/skills/spec-gate/README.md +12 -9
  64. package/skills/spec-gate/SKILL.md +2 -2
  65. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  66. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  67. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  68. package/skills/spec-producer-governance/SKILL.md +2 -2
  69. package/skills/start-mission/SKILL.md +1 -1
  70. package/skills/suite-format-governance/README.md +1 -1
  71. package/skills/suite-format-governance/SKILL.md +1 -1
  72. package/skills/touch-set-correction/README.md +5 -5
  73. package/skills/touch-set-correction/SKILL.md +1 -1
  74. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  75. package/skills/verify-scenarios/README.md +6 -6
  76. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  77. package/.plugin/pins.json +0 -3
  78. package/.plugin/plugin.json +0 -19
  79. package/dist/align-spec/scripts/align-spec.js +0 -102
  80. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  81. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  82. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  83. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  84. package/dist/concept-index/scripts/concept-index.js +0 -181
  85. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  86. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  87. package/dist/spec-gate/scripts/check-suite.js +0 -317
  88. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  89. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  90. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  91. package/skills/align-spec/scripts/align-spec.mts +0 -188
  92. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  93. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  94. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  95. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  96. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  97. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  98. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  99. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  100. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  102. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  103. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  104. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  105. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  106. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  108. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  109. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  110. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  111. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  112. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  113. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  114. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  115. package/skills/concept-index/scripts/concept-index.mts +0 -246
  116. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  117. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  118. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  119. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  120. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  121. package/skills/init/scripts/wire-statusline.mts +0 -279
  122. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  126. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  128. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  129. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  130. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  131. package/skills/place-node/scripts/place-node.mts +0 -158
  132. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  133. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  134. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  135. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  136. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  137. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  138. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  139. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  140. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  141. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  142. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  143. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  144. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  145. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  146. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  147. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  148. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  149. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,233 +0,0 @@
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 };
@@ -1,338 +0,0 @@
1
- #!/usr/bin/env node
2
- // check-partition-quality — project-spec/partition-quality's concrete engine. Measures, from a
3
- // project's own git history, how much parallel work its layout permits, and compares candidate
4
- // layouts on the same evidence (see this skill's README.md).
5
- //
6
- // THE METRIC IS COLLISION RATE, AND THAT CHOICE IS LOAD-BEARING. The scheduler cuts one mission per
7
- // spec-node, so two changes touching a shared node must serialize. Collision rate is the share of
8
- // change pairs that do. The headline is its complement — the parallelizable share.
9
- //
10
- // Two obvious alternatives were tried first and BOTH preferred a layered partition (the wrong
11
- // answer), because both are confounded by node count:
12
- // - within-node co-change ratio — a coarser partition scores well for being coarse
13
- // - mean nodes touched per change — a single-node partition scores PERFECTLY while permitting
14
- // zero parallel work
15
- // They are computed and reported here as DIAGNOSTICS, explicitly labelled, so that a later reader
16
- // does not "simplify" the engine back onto one of them. Do not headline them.
17
- //
18
- // Read-only: it runs `git log`, writes nothing, and renders no verdict — layout is the owner's call.
19
- // No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
20
- // node:test; running the file directly drives the CLI.
21
-
22
- import { execFileSync } from 'node:child_process'
23
- import { realpathSync } from 'node:fs'
24
- import { pathToFileURL } from 'node:url'
25
-
26
- /** Below this many usable multi-file commits, a rate would be noise — report, never score. */
27
- export const DEFAULT_FLOOR = 20
28
-
29
- export interface Change {
30
- /** In-scope files touched by one commit. */
31
- files: string[]
32
- }
33
-
34
- /** A partition names the node each file belongs to; `undefined` drops the file from the measure. */
35
- export type Partition = (file: string) => string | undefined
36
-
37
- export interface Measurement {
38
- nodes: number
39
- pairs: number
40
- collisionRate: number
41
- parallelizableShare: number
42
- control: number
43
- /** Positive when the partition explains more than a shuffle of the same node sizes. */
44
- marginOverControl: number
45
- explainsNothing: boolean
46
- diagnostics: {
47
- /** CONFOUNDED by node count — a coarser partition scores higher. Never headline. */
48
- withinNodeCoChangeRatio: number
49
- /** CONFOUNDED by node count — a single-node partition scores a perfect 1. Never headline. */
50
- meanNodesTouched: number
51
- }
52
- }
53
-
54
- export interface ThinHistory {
55
- thin: true
56
- usableCommits: number
57
- floor: number
58
- }
59
-
60
- // ── Reading history ──────────────────────────────────────────────────────────
61
- // Only multi-file changes inform the measure: a single-file commit contributes no pair, because one
62
- // file can collide with nothing.
63
-
64
- export function parseGitLog(stdout: string, inScope: (f: string) => boolean): Change[] {
65
- const out: Change[] = []
66
- let cur: string[] | null = null
67
- for (const line of stdout.split('\n')) {
68
- const t = line.trimEnd()
69
- if (t === '') {
70
- if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
71
- cur = null
72
- continue
73
- }
74
- if (/^[0-9a-f]{40}$/.test(t)) {
75
- if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
76
- cur = []
77
- continue
78
- }
79
- if (cur !== null && inScope(t)) cur.push(t)
80
- }
81
- if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
82
- return out.filter((c) => c.files.length > 1)
83
- }
84
-
85
- export function readHistory(repo: string, inScope: (f: string) => boolean, limit = 4000): Change[] {
86
- const stdout = execFileSync('git', ['-C', repo, 'log', `-${limit}`, '--name-only', '--pretty=format:%H'], {
87
- encoding: 'utf8',
88
- maxBuffer: 256 * 1024 * 1024,
89
- })
90
- return parseGitLog(stdout, inScope)
91
- }
92
-
93
- // ── The metric ───────────────────────────────────────────────────────────────
94
-
95
- /** The set of nodes one change touches. A change touching none drops out of the measure. */
96
- function nodesOf(c: Change, p: Partition): Set<string> {
97
- const s = new Set<string>()
98
- for (const f of c.files) {
99
- const n = p(f)
100
- if (n !== undefined) s.add(n)
101
- }
102
- return s
103
- }
104
-
105
- export function collisionRate(changes: Change[], p: Partition): { rate: number; pairs: number } {
106
- const sets = changes.map((c) => nodesOf(c, p)).filter((s) => s.size > 0)
107
- let pairs = 0
108
- let collided = 0
109
- for (let i = 0; i < sets.length; i++) {
110
- for (let j = i + 1; j < sets.length; j++) {
111
- pairs++
112
- const a = sets[i] as Set<string>
113
- const b = sets[j] as Set<string>
114
- for (const n of a) {
115
- if (b.has(n)) {
116
- collided++
117
- break
118
- }
119
- }
120
- }
121
- }
122
- return { rate: pairs === 0 ? 0 : collided / pairs, pairs }
123
- }
124
-
125
- // ── The control ──────────────────────────────────────────────────────────────
126
- // The same measurement over a SHUFFLED partition of identical node sizes. A partition whose rate
127
- // matches its shuffle explains nothing, and the headline should not be trusted. Deterministic seed:
128
- // the run must be reproducible.
129
-
130
- function mulberry32(seed: number): () => number {
131
- let a = seed >>> 0
132
- return () => {
133
- a = (a + 0x6d2b79f5) >>> 0
134
- let t = Math.imul(a ^ (a >>> 15), 1 | a)
135
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
136
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296
137
- }
138
- }
139
-
140
- export function shuffledControl(changes: Change[], p: Partition, seed = 7): number {
141
- const files = [...new Set(changes.flatMap((c) => c.files))].filter((f) => p(f) !== undefined).sort()
142
- const labels = files.map((f) => p(f) as string)
143
- const rnd = mulberry32(seed)
144
- for (let i = labels.length - 1; i > 0; i--) {
145
- const j = Math.floor(rnd() * (i + 1))
146
- const li = labels[i] as string
147
- const lj = labels[j] as string
148
- labels[i] = lj
149
- labels[j] = li
150
- }
151
- const m = new Map(files.map((f, i) => [f, labels[i] as string]))
152
- return collisionRate(changes, (f) => m.get(f)).rate
153
- }
154
-
155
- /**
156
- * Rename `diagnostics` to `confoundedDiagnostics` on the way out to JSON.
157
- *
158
- * The text render carries the warning inline; a JSON consumer sees only keys, so a plain
159
- * `diagnostics` object hands over two numbers indistinguishable from the headline. Renaming the key
160
- * beats adding a note field: a consumer cannot read these numbers without typing the word
161
- * "confounded", whereas a sibling note is trivially skipped.
162
- */
163
- export function toJson([name, m]: [string, Measurement | ThinHistory]): [string, unknown] {
164
- if (!('diagnostics' in m)) return [name, m]
165
- const { diagnostics, ...rest } = m
166
- return [name, { ...rest, confoundedDiagnostics: diagnostics }]
167
- }
168
-
169
- // ── Confounded diagnostics — labelled, never the headline ────────────────────
170
-
171
- export function withinNodeCoChangeRatio(changes: Change[], p: Partition): number {
172
- let within = 0
173
- let total = 0
174
- for (const c of changes) {
175
- const ns = c.files.map((f) => p(f)).filter((n): n is string => n !== undefined)
176
- for (let i = 0; i < ns.length; i++) {
177
- for (let j = i + 1; j < ns.length; j++) {
178
- total++
179
- if (ns[i] === ns[j]) within++
180
- }
181
- }
182
- }
183
- return total === 0 ? 0 : within / total
184
- }
185
-
186
- export function meanNodesTouched(changes: Change[], p: Partition): number {
187
- const counts = changes.map((c) => nodesOf(c, p).size).filter((n) => n > 0)
188
- return counts.length === 0 ? 0 : counts.reduce((a, b) => a + b, 0) / counts.length
189
- }
190
-
191
- // ── Measure ──────────────────────────────────────────────────────────────────
192
-
193
- export function measure(changes: Change[], p: Partition, floor = DEFAULT_FLOOR): Measurement | ThinHistory {
194
- const usable = changes.filter((c) => nodesOf(c, p).size > 0)
195
- if (usable.length < floor) return { thin: true, usableCommits: usable.length, floor }
196
- const { rate, pairs } = collisionRate(usable, p)
197
- const control = shuffledControl(usable, p)
198
- const margin = control - rate // lower collision than the shuffle is the improvement
199
- return {
200
- nodes: new Set(usable.flatMap((c) => [...nodesOf(c, p)])).size,
201
- pairs,
202
- collisionRate: rate,
203
- parallelizableShare: 1 - rate,
204
- control,
205
- marginOverControl: margin,
206
- explainsNothing: margin <= 0,
207
- diagnostics: {
208
- withinNodeCoChangeRatio: withinNodeCoChangeRatio(usable, p),
209
- meanNodesTouched: meanNodesTouched(usable, p),
210
- },
211
- }
212
- }
213
-
214
- export function isThin(m: Measurement | ThinHistory): m is ThinHistory {
215
- return (m as ThinHistory).thin === true
216
- }
217
-
218
- // ── Built-in candidate partitions ────────────────────────────────────────────
219
- // Named so a caller can compare "what the tree does now" against "what a capability cut would do"
220
- // without writing code.
221
-
222
- export const PARTITIONS: Record<string, (scope: string) => Partition> = {
223
- /** Top-level folder under the scope — the capability cut for a screaming layout. */
224
- 'top-folder': (scope) => (f) => {
225
- const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, '') : undefined
226
- if (rel === undefined) return undefined
227
- const seg = rel.split('/')[0]
228
- return seg === undefined || seg === '' || !rel.includes('/') ? undefined : seg
229
- },
230
- /** Second-level folder — a finer cut, for a capability/unit tree. */
231
- 'second-folder': (scope) => (f) => {
232
- const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, '') : undefined
233
- if (rel === undefined) return undefined
234
- const p = rel.split('/')
235
- return p.length > 2 ? `${p[0]}/${p[1]}` : undefined
236
- },
237
- /** File extension / artifact role — the layered analogue, useful as a contrast candidate. */
238
- role: (scope) => (f) => {
239
- if (!f.startsWith(scope)) return undefined
240
- const n = f.split('/').pop() ?? ''
241
- const dot = n.indexOf('.')
242
- return dot === -1 ? n : n.slice(dot + 1)
243
- },
244
- /** Everything in one node — the degenerate floor: no parallel work is possible. */
245
- single: (scope) => (f) => (f.startsWith(scope) ? 'all' : undefined),
246
- }
247
-
248
- // ── Render ───────────────────────────────────────────────────────────────────
249
-
250
- function pct(n: number): string {
251
- return `${(n * 100).toFixed(1)}%`
252
- }
253
-
254
- export function renderOne(label: string, m: Measurement | ThinHistory): string {
255
- if (isThin(m)) {
256
- return ` ${label}: history too thin to measure — ${m.usableCommits} usable multi-file commits, floor ${m.floor}. No rate emitted.`
257
- }
258
- const lines = [
259
- ` ${label}`,
260
- ` parallelizable share : ${pct(m.parallelizableShare)} ← the headline`,
261
- ` collision rate : ${pct(m.collisionRate)} over ${m.pairs} change pairs, ${m.nodes} nodes`,
262
- ` shuffled control : ${pct(m.control)} (margin ${m.marginOverControl >= 0 ? '+' : ''}${pct(m.marginOverControl)})`,
263
- ]
264
- if (m.collisionRate >= 1) {
265
- lines.push(' ⚠ every change pair collides — this partition permits no parallel work')
266
- }
267
- if (m.explainsNothing) {
268
- lines.push(' ⚠ this partition explains no more than chance — do not trust the headline')
269
- }
270
- lines.push(
271
- ` diagnostics (CONFOUNDED by node count — not the headline): within-node co-change ${pct(m.diagnostics.withinNodeCoChangeRatio)}, mean nodes touched ${m.diagnostics.meanNodesTouched.toFixed(2)}`,
272
- )
273
- return lines.join('\n')
274
- }
275
-
276
- export function render(results: [string, Measurement | ThinHistory][], repo: string, scope: string): string {
277
- const out = [`check-partition-quality: repo=${repo} scope=${scope || '(whole repo)'}`]
278
- for (const [label, m] of results) out.push(renderOne(label, m))
279
- if (results.length > 1) {
280
- const scored = results.filter(([, m]) => !isThin(m)) as [string, Measurement][]
281
- if (scored.length > 1) {
282
- const best = scored.reduce((a, b) => (b[1].parallelizableShare > a[1].parallelizableShare ? b : a))
283
- out.push(`\nOn this history, "${best[0]}" permits the most parallel work (${pct(best[1].parallelizableShare)}).`)
284
- }
285
- }
286
- out.push('note: a measurement, not a verdict — it moves no file and gates nothing; layout is the owner’s call')
287
- return out.join('\n')
288
- }
289
-
290
- // ── CLI ──────────────────────────────────────────────────────────────────────
291
-
292
- /**
293
- * The effects `main` reaches the outside world through. Dependencies enter at the CLI boundary and
294
- * nowhere below it: everything under `main` is pure over the history it is handed.
295
- */
296
- export type Context = { readHistory: typeof readHistory }
297
-
298
- export function main(argv: string[], context: Context = { readHistory }): number {
299
- let repo = '.'
300
- let scope = ''
301
- let floor = DEFAULT_FLOOR
302
- let limit = 4000
303
- let format: 'text' | 'json' = 'text'
304
- const candidates: string[] = []
305
- for (let i = 0; i < argv.length; i++) {
306
- const a = argv[i]
307
- if (a === '--repo') repo = argv[++i] ?? '.'
308
- else if (a === '--scope') scope = argv[++i] ?? ''
309
- else if (a === '--floor') floor = Number(argv[++i] ?? DEFAULT_FLOOR)
310
- else if (a === '--limit') limit = Number(argv[++i] ?? 4000)
311
- else if (a === '--format') format = (argv[++i] as 'text' | 'json') ?? 'text'
312
- else if (a === '--partition') candidates.push(argv[++i] ?? '')
313
- }
314
- if (candidates.length === 0) candidates.push('top-folder', 'role')
315
- for (const c of candidates) {
316
- if (!(c in PARTITIONS)) {
317
- process.stderr.write(`unknown --partition "${c}" — known: ${Object.keys(PARTITIONS).join(', ')}\n`)
318
- return 1
319
- }
320
- }
321
- // Read ONCE, then measure every candidate over that one history — the comparison is only
322
- // meaningful if each candidate is scored against the same commits and the same scope.
323
- const changes = context.readHistory(repo, (f) => (scope ? f.startsWith(scope) : true), limit)
324
- const results = candidates.map(
325
- (c) =>
326
- [c, measure(changes, (PARTITIONS[c] as (s: string) => Partition)(scope), floor)] as [
327
- string,
328
- Measurement | ThinHistory,
329
- ],
330
- )
331
- if (format === 'json') process.stdout.write(`${JSON.stringify(Object.fromEntries(results.map(toJson)), null, 2)}\n`)
332
- else process.stdout.write(`${render(results, repo, scope)}\n`)
333
- return 0
334
- }
335
-
336
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
337
- process.exit(main(process.argv.slice(2)))
338
- }
@@ -1,107 +0,0 @@
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 };