cyber-sdd 0.7.0 → 0.7.2

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. 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 };