@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.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 (112) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +112 -114
  13. package/package.json +5 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -13
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  31. package/pipeline/scripts/log-metric.sh +9 -3
  32. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  33. package/pipeline/scripts/pre-commit-check.sh +119 -27
  34. package/pipeline/scripts/scan-agent-config.sh +9 -9
  35. package/pipeline/scripts/unattended_policy.py +12 -3
  36. package/pipeline/scripts/uninstall.mjs +88 -1
  37. package/pipeline/scripts/usage-identity.mjs +1 -1
  38. package/pipeline/scripts/usage-register.mjs +1 -1
  39. package/pipeline/skills/.skill-manifest.json +30 -30
  40. package/pipeline/skills/shared/README.md +64 -64
  41. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  42. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  43. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  44. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  45. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  46. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  47. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  48. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  49. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  50. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  51. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  53. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  54. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  55. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  56. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  57. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  58. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  59. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  60. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  61. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  62. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
  63. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
  64. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
  65. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
  66. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
  67. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
  68. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
  69. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  70. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
  71. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
  72. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
  73. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  74. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
  75. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
  76. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
  77. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
  78. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
  79. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  80. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  81. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  82. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  83. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  84. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  85. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  86. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  87. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  88. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  89. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  90. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  91. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  92. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  93. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  94. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  95. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  96. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  97. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  98. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  99. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  100. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  101. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  102. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  103. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  104. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  105. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  106. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  107. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  108. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  109. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
  110. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  111. package/pipeline/scripts/make-manifest.mjs +0 -199
  112. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -1,279 +0,0 @@
1
- #!/usr/bin/env node
2
- // gen-ref-toc.mjs - a table of contents on every long reference file.
3
- //
4
- // Anthropic's published Agent Skills guidance: "For reference files longer than
5
- // 100 lines, include a table of contents". The reason is the same one behind
6
- // the one-level-deep rule - an agent that reads a long file partially needs to
7
- // know from the top what is further down, and without that it answers from the
8
- // first screen and stops.
9
- //
10
- // Generated, never hand-written. Fifty-six files' worth of hand-maintained
11
- // contents lists would be wrong by the second edit, and a wrong ToC is worse
12
- // than none: it tells a reader a section exists where it does not.
13
- //
14
- // Section level is DERIVED, not assumed. These files are not consistent: some
15
- // open at `##` and section at `###` (keychain.md), others open at `###` and
16
- // section at `##` (phases/phase-2-dev.md). The rule is the shallowest heading
17
- // depth that occurs more than once - a depth used exactly once is a title, not
18
- // a section level.
19
- //
20
- // Not every long file is eligible, and the exclusion is measured rather than
21
- // preferred. A contents list helps a file that is OPENED AND SKIMMED. It is
22
- // pure cost on a file that is loaded whole by contract, because there is no
23
- // partial read for it to rescue - and those files are already at their limit:
24
- //
25
- // the 8 phase docs a ToC each puts 5 of 8 over their per-phase max and the
26
- // aggregate 1,533 tokens over 62,700
27
- // rules.md always loaded; 59,908 of a 60,000 byte ceiling, and a
28
- // ToC takes it to 60,997
29
- // the analysis refs mounted as one set per analysis run; 158,500 byte
30
- // ceiling, and the ToCs put them 2,298 over
31
- //
32
- // So the two published rules genuinely conflict there, and the ToC is the one
33
- // that loses: raising either ceiling to buy navigation nobody navigates is the
34
- // move this repo spent a release removing.
35
- //
36
- // The exclusion is DERIVED from those two budgets, never hand-listed. A
37
- // document entering or leaving a budget changes its ToC status by itself, which
38
- // is the difference between a rule and a list somebody has to remember.
39
- //
40
- // Usage:
41
- // node gen-ref-toc.mjs write/refresh every eligible file
42
- // node gen-ref-toc.mjs --check report drift, write nothing, exit 1 if any
43
- // node gen-ref-toc.mjs --stats byte cost, write nothing
44
- // node gen-ref-toc.mjs --verify every generated anchor resolves to a heading
45
-
46
- import { readdirSync, readFileSync, writeFileSync } from "node:fs";
47
- import { dirname, join, relative } from "node:path";
48
- import { fileURLToPath } from "node:url";
49
-
50
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
51
- const REFS = join(ROOT, "pipeline", "multi-agent-refs");
52
- const MIN_LINES = 100;
53
- const MARK_OPEN = "<!-- toc -->";
54
- const MARK_CLOSE = "<!-- /toc -->";
55
-
56
- // Every file that some other gate already holds to a byte or token ceiling.
57
- function budgeted() {
58
- const out = new Set();
59
- const budget = JSON.parse(readFileSync(join(ROOT, "pipeline/schemas/token-budget.json"), "utf8"));
60
- for (const phase of Object.keys(budget.phases)) {
61
- out.add(join(REFS, "phases", `${phase}.md`));
62
- }
63
- // The always-loaded set, read out of the gate that owns it rather than copied.
64
- const ctx = readFileSync(join(ROOT, "pipeline/scripts/smoke-context-budget.sh"), "utf8");
65
- const block = ctx.match(/TRACKED="\n([\s\S]*?)"/);
66
- if (block) {
67
- for (const line of block[1].split("\n")) {
68
- const t = line.trim();
69
- if (t) out.add(join(ROOT, t));
70
- }
71
- }
72
-
73
- // The analysis set, mounted whole per analysis run under its own ceiling. Same
74
- // two globs that gate uses - `analysis/<name>.md` named by the analysis
75
- // command, plus every analysis-template*.md.
76
- const cmd = readFileSync(join(ROOT, "pipeline/commands/multi-agent/analysis/SKILL.md"), "utf8");
77
- for (const m of cmd.matchAll(/analysis\/[a-z-]+\.md/g)) out.add(join(REFS, m[0]));
78
- for (const e of readdirSync(REFS, { withFileTypes: true })) {
79
- if (e.isFile() && /^analysis-template.*\.md$/.test(e.name)) out.add(join(REFS, e.name));
80
- }
81
- return out;
82
- }
83
-
84
- function walk(dir, out = []) {
85
- for (const e of readdirSync(dir, { withFileTypes: true })) {
86
- const p = join(dir, e.name);
87
- if (e.isDirectory()) walk(p, out);
88
- else if (e.name.endsWith(".md")) out.push(p);
89
- }
90
- return out;
91
- }
92
-
93
- // GitHub's anchor rule, which is what a `](#...)` link resolves against.
94
- function slug(text) {
95
- return text
96
- .toLowerCase()
97
- .replace(/`/g, "")
98
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
99
- .replace(/[^\w\s-]/g, "")
100
- .trim()
101
- .replace(/\s+/g, "-");
102
- }
103
-
104
- function sections(lines) {
105
- const heads = [];
106
- let fenced = false;
107
- lines.forEach((l, i) => {
108
- if (/^\s*```/.test(l)) fenced = !fenced;
109
- if (fenced) return;
110
- const m = l.match(/^(#{1,6})\s+(.*\S)\s*$/);
111
- if (m) heads.push({ depth: m[1].length, text: m[2], line: i });
112
- });
113
- if (!heads.length) return { level: 0, heads: [] };
114
- const counts = {};
115
- for (const h of heads) counts[h.depth] = (counts[h.depth] || 0) + 1;
116
- const depths = Object.keys(counts)
117
- .map(Number)
118
- .sort((a, b) => a - b);
119
- // The shallowest depth used more than once. A depth used exactly once is the
120
- // document's title and listing it as the only entry is a ToC of one.
121
- const level = depths.find((d) => counts[d] > 1) ?? depths[depths.length - 1];
122
- return { level, heads: heads.filter((h) => h.depth === level) };
123
- }
124
-
125
- function render(heads) {
126
- const seen = new Map();
127
- const rows = heads.map((h) => {
128
- let a = slug(h.text);
129
- const n = (seen.get(a) || 0) + 1;
130
- seen.set(a, n);
131
- if (n > 1) a = `${a}-${n - 1}`;
132
- return `- [${h.text.replace(/\|/g, "\\|")}](#${a})`;
133
- });
134
- return [MARK_OPEN, ...rows, MARK_CLOSE].join("\n");
135
- }
136
-
137
- // After the frontmatter and after the first heading, so the reader sees what the
138
- // file IS before a list of what is in it.
139
- function insertionPoint(lines) {
140
- let i = 0;
141
- if (lines[0] === "---") {
142
- i = 1;
143
- while (i < lines.length && lines[i] !== "---") i += 1;
144
- i += 1;
145
- }
146
- for (let j = i; j < lines.length; j += 1) {
147
- if (/^#{1,6}\s/.test(lines[j])) return j + 1;
148
- }
149
- return i;
150
- }
151
-
152
- const BUDGETED = budgeted();
153
- const excluded = [];
154
- const files = walk(REFS)
155
- .filter((f) => readFileSync(f, "utf8").split("\n").length - 1 > MIN_LINES)
156
- .filter((f) => {
157
- if (!BUDGETED.has(f)) return true;
158
- excluded.push(relative(ROOT, f));
159
- return false;
160
- })
161
- .sort();
162
-
163
- // Does every `](#anchor)` in a generated block name a heading that exists in the
164
- // same file? --check cannot answer that: it compares the file against THIS
165
- // script's output, so a wrong slug rule agrees with itself. This validates the
166
- // rule against the real headings instead, and it lives here so there is one
167
- // definition of `slug`, not a second copy inside a gate.
168
- if (process.argv.includes("--verify")) {
169
- let total = 0;
170
- const bad = [];
171
- for (const f of walk(REFS)) {
172
- const raw = readFileSync(f, "utf8");
173
- if (!raw.includes(MARK_OPEN)) continue;
174
- const lines = raw.split("\n");
175
- const seen = new Map();
176
- const anchors = new Set();
177
- let fenced = false;
178
- for (const l of lines) {
179
- if (/^\s*```/.test(l)) fenced = !fenced;
180
- if (fenced) continue;
181
- const m = l.match(/^#{1,6}\s+(.*\S)\s*$/);
182
- if (!m) continue;
183
- let a = slug(m[1]);
184
- const n = (seen.get(a) || 0) + 1;
185
- seen.set(a, n);
186
- if (n > 1) a = `${a}-${n - 1}`;
187
- anchors.add(a);
188
- }
189
- const s0 = lines.indexOf(MARK_OPEN);
190
- const e0 = lines.indexOf(MARK_CLOSE, s0);
191
- for (const l of lines.slice(s0 + 1, e0)) {
192
- const m = l.match(/\]\(#(.+)\)\s*$/);
193
- if (!m) continue;
194
- total += 1;
195
- if (!anchors.has(m[1])) bad.push(`${relative(ROOT, f)} -> #${m[1]}`);
196
- }
197
- }
198
- if (total === 0) {
199
- console.log("gen-ref-toc: no anchors found at all - the generator produced nothing");
200
- process.exit(1);
201
- }
202
- if (bad.length) {
203
- console.log(`gen-ref-toc: ${bad.length} of ${total} anchor(s) point at no heading:`);
204
- for (const b of bad.slice(0, 10)) console.log(` ${b}`);
205
- process.exit(1);
206
- }
207
- console.log(`gen-ref-toc: ${total} anchors, all resolve to a heading in their own file`);
208
- process.exit(0);
209
- }
210
-
211
- const mode = process.argv.includes("--check")
212
- ? "check"
213
- : process.argv.includes("--stats")
214
- ? "stats"
215
- : "write";
216
-
217
- const drifted = [];
218
- const skipped = [];
219
- let added = 0;
220
-
221
- for (const f of files) {
222
- const raw = readFileSync(f, "utf8");
223
- const rel = relative(ROOT, f);
224
- const lines = raw.split("\n");
225
- const { heads } = sections(lines);
226
- // Fewer than three sections is a file that does not need a map of itself.
227
- if (heads.length < 3) {
228
- skipped.push(`${rel} (${heads.length} section(s))`);
229
- continue;
230
- }
231
- const block = render(heads);
232
-
233
- const start = lines.indexOf(MARK_OPEN);
234
- let next;
235
- if (start !== -1) {
236
- const end = lines.indexOf(MARK_CLOSE, start);
237
- if (end === -1) {
238
- drifted.push(`${rel}: ${MARK_OPEN} with no ${MARK_CLOSE}`);
239
- continue;
240
- }
241
- const current = lines.slice(start, end + 1).join("\n");
242
- if (current === block) continue;
243
- next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end + 1)];
244
- drifted.push(`${rel}: contents no longer match the headings`);
245
- } else {
246
- const at = insertionPoint(lines);
247
- next = [...lines.slice(0, at), "", ...block.split("\n"), ...lines.slice(at)];
248
- drifted.push(`${rel}: no table of contents`);
249
- }
250
- const out = next.join("\n");
251
- added += out.length - raw.length;
252
- if (mode === "write") writeFileSync(f, out);
253
- }
254
-
255
- if (mode === "stats") {
256
- console.log(`eligible: ${files.length} ref files over ${MIN_LINES} lines`);
257
- console.log(`excluded as loaded-whole-by-contract: ${excluded.length}`);
258
- console.log(`skipped (under 3 sections): ${skipped.length}`);
259
- console.log(
260
- `would add: ${added} bytes total, ~${Math.round(added / 4)} tokens if every one were read`,
261
- );
262
- process.exit(0);
263
- }
264
-
265
- if (mode === "check") {
266
- if (drifted.length === 0) {
267
- console.log(`gen-ref-toc: ${files.length} long ref(s) carry a current table of contents`);
268
- process.exit(0);
269
- }
270
- console.log(
271
- `gen-ref-toc: ${drifted.length} file(s) need \`node pipeline/scripts/gen-ref-toc.mjs\`:`,
272
- );
273
- for (const d of drifted) console.log(` ${d}`);
274
- process.exit(1);
275
- }
276
-
277
- console.log(
278
- `gen-ref-toc: ${drifted.length} file(s) updated, ${skipped.length} skipped, ${added} bytes added`,
279
- );
@@ -1,199 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * make-manifest.mjs - a digest of everything the package ships.
4
- *
5
- * "Is my install the one that was published, or has something edited it" is a
6
- * question this tree could not answer. The install is a COPY: `install.js`
7
- * writes into ~/.claude, ~/.copilot and ~/.codex, and from that moment the two
8
- * halves drift independently. An edit made in the installed copy looks like a
9
- * feature nobody can find the source of; a file the installer failed to write
10
- * looks like a bug in a script that was never there.
11
- *
12
- * The manifest turns both into one command. It records a SHA-256 for every
13
- * file the package ships, the version, and the commit it was built from, and
14
- * `multi-agent-pipeline verify` compares an installed tree against it, file by
15
- * file.
16
- *
17
- * The file set is not guessed: it comes from `npm pack --dry-run --json`, so
18
- * it is by construction the same set npm will publish, `files` globs and all.
19
- * `--ignore-scripts` is on the inner call because this script runs FROM
20
- * prepack, and without it npm would run prepack again.
21
- *
22
- * Two things the manifest cannot cover, said here rather than discovered later:
23
- * it cannot hash itself (it does not exist when the list is taken), and a
24
- * signature over it does not authenticate the tarball - the verifier and the
25
- * public key travel inside the same tarball, so anyone able to rewrite one can
26
- * rewrite the others. What a signature DOES catch is a manifest damaged in
27
- * transit or edited after install, which is the failure this file exists for.
28
- * Provenance belongs to npm's own integrity field, not to us.
29
- *
30
- * Usage:
31
- * make-manifest.mjs write manifest.json at the repo root
32
- * make-manifest.mjs --sign also write manifest.sig (needs a signing key)
33
- * make-manifest.mjs --check recompute and report drift, write nothing
34
- * make-manifest.mjs --json
35
- *
36
- * Exit codes:
37
- * 0 written, or --check found no drift
38
- * 1 --check found drift
39
- * 2 usage or environment error
40
- */
41
-
42
- import { execFileSync } from "node:child_process";
43
- import { createHash, createPrivateKey, sign as cryptoSign } from "node:crypto";
44
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
45
- import { dirname, join } from "node:path";
46
- import { fileURLToPath } from "node:url";
47
- import { runMain } from "../lib/fatal.mjs";
48
-
49
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
50
- const MANIFEST = join(ROOT, "manifest.json");
51
-
52
- const argv = process.argv.slice(2);
53
- const has = (f) => argv.includes(f);
54
-
55
- /** The exact set npm would publish, in npm's own words. */
56
- function shippedFiles() {
57
- const out = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
58
- cwd: ROOT,
59
- encoding: "utf-8",
60
- maxBuffer: 64 * 1024 * 1024,
61
- stdio: ["ignore", "pipe", "ignore"],
62
- });
63
- const parsed = JSON.parse(out);
64
- const files = parsed?.[0]?.files;
65
- if (!Array.isArray(files) || !files.length) {
66
- throw new Error("npm pack --dry-run returned no file list");
67
- }
68
- return files
69
- .map((f) => f.path)
70
- .filter((p) => p !== "manifest.json" && p !== "manifest.sig")
71
- .sort();
72
- }
73
-
74
- function sha256(path) {
75
- return createHash("sha256").update(readFileSync(path)).digest("hex");
76
- }
77
-
78
- function gitCommit() {
79
- try {
80
- return execFileSync("git", ["rev-parse", "HEAD"], {
81
- cwd: ROOT,
82
- encoding: "utf-8",
83
- stdio: ["ignore", "pipe", "ignore"],
84
- }).trim();
85
- } catch {
86
- // A tarball unpacked outside a checkout has no git. The manifest is still
87
- // valid; only the provenance line is unknown, and saying so beats writing
88
- // a placeholder that reads like a real commit.
89
- return null;
90
- }
91
- }
92
-
93
- function build() {
94
- const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
95
- const files = {};
96
- for (const rel of shippedFiles()) {
97
- const abs = join(ROOT, rel);
98
- if (!existsSync(abs)) continue;
99
- files[rel] = sha256(abs);
100
- }
101
- return {
102
- name: pkg.name,
103
- version: pkg.version,
104
- commit: gitCommit(),
105
- generatedAt: new Date().toISOString(),
106
- algorithm: "sha256",
107
- fileCount: Object.keys(files).length,
108
- files,
109
- };
110
- }
111
-
112
- /**
113
- * Sign the manifest with an ed25519 key. Optional, and silent about its absence
114
- * on the common path: a package with no signature still verifies on hashes
115
- * alone, which is the property that actually gets used.
116
- *
117
- * The key is read through the credential store, never taken from argv and never
118
- * printed. MULTI_AGENT_SIGNING_KEY is the fallback for a build host that has no
119
- * store (CI), and it holds the PEM itself.
120
- *
121
- * @param {string} manifestJson exact bytes that were written to disk
122
- * @returns {string|null} base64 signature, or null when no key is configured
123
- */
124
- function sign(manifestJson) {
125
- let pem = process.env.MULTI_AGENT_SIGNING_KEY || "";
126
- if (!pem) {
127
- try {
128
- pem = execFileSync(
129
- join(ROOT, "pipeline", "lib", "credential-store.sh"),
130
- ["get", "pipeline_signing"],
131
- {
132
- encoding: "utf-8",
133
- stdio: ["ignore", "pipe", "ignore"],
134
- },
135
- ).trim();
136
- } catch {
137
- pem = "";
138
- }
139
- }
140
- if (!pem) {
141
- process.stderr.write(
142
- "make-manifest: no signing key configured - manifest written unsigned (hashes still verify)\n",
143
- );
144
- return null;
145
- }
146
- const privateKey = createPrivateKey(pem);
147
- return cryptoSign(null, Buffer.from(manifestJson), privateKey).toString("base64");
148
- }
149
-
150
- function main() {
151
- if (has("--check")) {
152
- if (!existsSync(MANIFEST)) {
153
- process.stderr.write("make-manifest: no manifest.json to check\n");
154
- process.exit(2);
155
- }
156
- const stored = JSON.parse(readFileSync(MANIFEST, "utf-8"));
157
- const fresh = build();
158
- const changed = [];
159
- for (const [rel, hash] of Object.entries(fresh.files)) {
160
- if (stored.files[rel] !== hash) changed.push(rel);
161
- }
162
- const gone = Object.keys(stored.files).filter((r) => !(r in fresh.files));
163
- const report = { changed, gone, stale: changed.length + gone.length > 0 };
164
- if (has("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
165
- else if (!report.stale)
166
- process.stdout.write(`manifest.json is current (${fresh.fileCount} files)\n`);
167
- else {
168
- process.stdout.write(
169
- `manifest.json is stale: ${changed.length} changed, ${gone.length} gone\n`,
170
- );
171
- for (const r of [...changed, ...gone].slice(0, 20)) process.stdout.write(` ${r}\n`);
172
- }
173
- process.exitCode = report.stale ? 1 : 0;
174
- return;
175
- }
176
-
177
- const manifest = build();
178
- const json = `${JSON.stringify(manifest, null, 2)}\n`;
179
- writeFileSync(MANIFEST, json);
180
- if (has("--sign")) {
181
- const sig = sign(json);
182
- if (sig) writeFileSync(join(ROOT, "manifest.sig"), `${sig}\n`);
183
- }
184
- if (has("--json"))
185
- process.stdout.write(
186
- `${JSON.stringify({ written: MANIFEST, ...manifest, files: undefined }, null, 2)}\n`,
187
- );
188
- else
189
- // STDERR, not stdout. This runs as `prepack`, and npm passes a prepack
190
- // script's stdout straight through - so one friendly line here lands INSIDE
191
- // `npm pack --dry-run --json` and every consumer that parses that JSON
192
- // fails with "Unexpected token 'm'". Two gates caught it within minutes;
193
- // a publish would not have.
194
- process.stderr.write(
195
- `manifest.json: ${manifest.fileCount} files, version ${manifest.version}, commit ${manifest.commit?.slice(0, 12) ?? "unknown"}\n`,
196
- );
197
- }
198
-
199
- runMain("make-manifest", main);
@@ -1,178 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scorecard-snapshot.mjs - keep what the scorecard said, and say what moved.
4
- *
5
- * The scorecard answers "does every mechanical claim hold right now". It
6
- * cannot answer "is this better or worse than last week", and that second
7
- * question is the one that catches slow rot: a metric that has been failing
8
- * for a month reads identically to one that broke an hour ago.
9
- *
10
- * WHAT THIS DELIBERATELY DOES NOT DO: collapse the result into a 0-100 score.
11
- * ruflo's scorecard does, and the discipline worth taking from it is
12
- * "measurable and comparable over time", not the number. The scorecard already
13
- * reports twelve measured metrics AND four it refuses to measure - a single
14
- * figure would hide both halves, and an unmeasured category would silently
15
- * count as zero or as full marks depending on an arithmetic choice nobody
16
- * would ever read. A diff keeps every metric answering for itself.
17
- *
18
- * Snapshots live in ~/.claude/state/scorecard/<iso>.json and are never pruned
19
- * by this script: they are small, and a history that deletes itself cannot
20
- * answer the question it was kept for.
21
- *
22
- * Usage:
23
- * scorecard-snapshot.mjs --save run the scorecard, store a snapshot
24
- * scorecard-snapshot.mjs --diff latest vs the one before it
25
- * scorecard-snapshot.mjs --diff a.json b.json two snapshots by path
26
- * scorecard-snapshot.mjs --list what is stored
27
- *
28
- * Exit codes:
29
- * 0 - nothing regressed (or a save succeeded)
30
- * 1 - at least one metric that used to pass now fails
31
- * 2 - usage error, or not enough snapshots to compare
32
- */
33
-
34
- import { execFileSync } from "node:child_process";
35
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
36
- import { homedir } from "node:os";
37
- import { dirname, join } from "node:path";
38
- import { fileURLToPath } from "node:url";
39
- import { runMain } from "../lib/fatal.mjs";
40
-
41
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
42
- const STORE = process.env.SCORECARD_STORE || join(homedir(), ".claude", "state", "scorecard");
43
-
44
- /** Metric identity. Category plus metric name, because neither is unique alone. */
45
- const keyOf = (r) => `${r.category} :: ${r.metric}`;
46
-
47
- function runScorecard() {
48
- // `--json` prints the report on stdout; a failing metric is a non-zero exit
49
- // and is exactly the case worth snapshotting, so the status is captured
50
- // rather than thrown.
51
- try {
52
- return JSON.parse(
53
- execFileSync("node", ["pipeline/scripts/scorecard.mjs", "--json"], {
54
- cwd: ROOT,
55
- encoding: "utf-8",
56
- maxBuffer: 32 * 1024 * 1024,
57
- }),
58
- );
59
- } catch (err) {
60
- const out = err?.stdout;
61
- if (typeof out === "string" && out.trim().startsWith("{")) return JSON.parse(out);
62
- throw new Error(`scorecard did not produce JSON: ${err?.message ?? err}`, { cause: err });
63
- }
64
- }
65
-
66
- function snapshots() {
67
- if (!existsSync(STORE)) return [];
68
- return (
69
- readdirSync(STORE)
70
- .filter((f) => f.endsWith(".json"))
71
- // ISO-8601 sorts lexically in time order, which is the whole reason the
72
- // file is named after the timestamp rather than a counter.
73
- .sort()
74
- .map((f) => join(STORE, f))
75
- );
76
- }
77
-
78
- function save() {
79
- const report = runScorecard();
80
- mkdirSync(STORE, { recursive: true });
81
- const at = new Date().toISOString().replace(/[:.]/g, "-");
82
- const path = join(STORE, `${at}.json`);
83
- writeFileSync(path, JSON.stringify({ at: new Date().toISOString(), ...report }, null, 2) + "\n");
84
- process.stdout.write(`scorecard snapshot: ${path}\n`);
85
- process.stdout.write(` ${report.passed} passed, ${report.failed} failed\n`);
86
- return 0;
87
- }
88
-
89
- function diff(aPath, bPath) {
90
- const a = JSON.parse(readFileSync(aPath, "utf-8"));
91
- const b = JSON.parse(readFileSync(bPath, "utf-8"));
92
- // UNMEASURED rows carry no `ok` at all - they are the categories the
93
- // scorecard refuses to score. Comparing verdicts across them would invent
94
- // one, which is the thing that section exists to avoid.
95
- const measured = (rs) => (rs || []).filter((r) => r.kind !== "UNMEASURED");
96
- const ma = new Map(measured(a.results).map((r) => [keyOf(r), r]));
97
- const mb = new Map(measured(b.results).map((r) => [keyOf(r), r]));
98
-
99
- const regressed = [];
100
- const fixed = [];
101
- const added = [];
102
- const removed = [];
103
- const changed = [];
104
-
105
- for (const [k, rb] of mb) {
106
- const ra = ma.get(k);
107
- if (!ra) {
108
- added.push(rb);
109
- continue;
110
- }
111
- if (ra.ok && !rb.ok) regressed.push({ k, ra, rb });
112
- else if (!ra.ok && rb.ok) fixed.push({ k, ra, rb });
113
- // A metric whose verdict held but whose DETAIL moved is the early warning:
114
- // coverage sliding from 72 to 69 passes the floor and is still the thing
115
- // that will fail next month.
116
- else if (ra.detail !== rb.detail) changed.push({ k, ra, rb });
117
- }
118
- for (const [k, ra] of ma) if (!mb.has(k)) removed.push({ k, ra });
119
-
120
- const out = [];
121
- out.push(`scorecard diff - ${a.at ?? aPath} → ${b.at ?? bPath}`);
122
- out.push(` ${a.passed}/${a.passed + a.failed} → ${b.passed}/${b.passed + b.failed} passing`);
123
- const section = (title, rows, fmt) => {
124
- if (!rows.length) return;
125
- out.push("");
126
- out.push(`${title} (${rows.length})`);
127
- for (const r of rows) out.push(` ${fmt(r)}`);
128
- };
129
- section("REGRESSED", regressed, ({ k, rb }) => `${k}\n now: ${rb.detail ?? "(no detail)"}`);
130
- section("FIXED", fixed, ({ k, rb }) => `${k}\n now: ${rb.detail ?? "(no detail)"}`);
131
- section("NEW METRIC", added, (r) => `${keyOf(r)} ${r.ok ? "passing" : "FAILING"}`);
132
- section("METRIC GONE", removed, ({ k }) => `${k} - no longer measured`);
133
- section(
134
- "SAME VERDICT, DIFFERENT NUMBERS",
135
- changed,
136
- ({ k, ra, rb }) =>
137
- `${k}\n was: ${ra.detail ?? "(none)"}\n now: ${rb.detail ?? "(none)"}`,
138
- );
139
-
140
- if (!regressed.length && !fixed.length && !added.length && !removed.length && !changed.length) {
141
- out.push("");
142
- out.push(" nothing moved");
143
- }
144
- process.stdout.write(out.join("\n") + "\n");
145
- return regressed.length ? 1 : 0;
146
- }
147
-
148
- function main() {
149
- const args = process.argv.slice(2);
150
- if (args.includes("--save")) return save();
151
- if (args.includes("--list")) {
152
- const s = snapshots();
153
- process.stdout.write(s.length ? s.join("\n") + "\n" : `no snapshots under ${STORE}\n`);
154
- return 0;
155
- }
156
- if (args.includes("--diff")) {
157
- const paths = args.filter((a) => !a.startsWith("--"));
158
- if (paths.length === 2) return diff(paths[0], paths[1]);
159
- const s = snapshots();
160
- if (s.length < 2) {
161
- process.stderr.write(
162
- `need two snapshots to compare, found ${s.length} under ${STORE}\n` +
163
- ` take one with: node pipeline/scripts/scorecard-snapshot.mjs --save\n`,
164
- );
165
- return 2;
166
- }
167
- return diff(s[s.length - 2], s[s.length - 1]);
168
- }
169
- process.stderr.write("usage: scorecard-snapshot.mjs --save | --diff [a.json b.json] | --list\n");
170
- return 2;
171
- }
172
-
173
- // `process.exitCode`, never `process.exit()`: stdout to a pipe is asynchronous
174
- // and exit() discards whatever has not drained, which truncated two scripts in
175
- // this repo at a buffer boundary and only ever when piped.
176
- runMain("scorecard-snapshot", () => {
177
- process.exitCode = main();
178
- });