@mmerterden/multi-agent-pipeline 20.8.1 → 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 (75) hide show
  1. package/CHANGELOG.md +36 -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 +75 -78
  13. package/package.json +4 -1
  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 -14
  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/log-metric.sh +9 -3
  31. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  32. package/pipeline/scripts/pre-commit-check.sh +119 -31
  33. package/pipeline/scripts/scan-agent-config.sh +9 -9
  34. package/pipeline/scripts/unattended_policy.py +12 -3
  35. package/pipeline/scripts/uninstall.mjs +88 -1
  36. package/pipeline/scripts/usage-identity.mjs +1 -1
  37. package/pipeline/scripts/usage-register.mjs +1 -1
  38. package/pipeline/skills/.skill-manifest.json +11 -11
  39. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  40. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  41. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  42. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  43. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  44. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  47. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  60. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  61. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  62. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  63. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  64. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  65. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  66. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  67. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  68. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  69. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  70. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  71. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  72. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  73. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  74. package/pipeline/scripts/make-manifest.mjs +0 -199
  75. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Shared filesystem helpers for install/* modules.
3
3
  *
4
- * These primitives are 1:1 ports of the helpers that lived inline in the
5
- * pre-v8.0.0 monolithic install.js. Behaviour MUST stay byte-equivalent -
6
- * the install layout smoke test (smoke-install-layout.sh) compares before/after
7
- * trees and fails on any drift.
4
+ * The install layout smoke (smoke-install-layout.sh) compares the installed
5
+ * tree against a committed fixture, so a helper that changes what lands on
6
+ * disk changes that fixture too.
8
7
  *
9
8
  * @module install/_common
10
9
  */
@@ -25,8 +24,9 @@ import {
25
24
  unlinkSync,
26
25
  writeFileSync,
27
26
  } from "fs";
28
- import { dirname } from "path";
29
- import { join } from "path";
27
+ import { createHash } from "crypto";
28
+ import { dirname, join, relative, sep } from "path";
29
+ import { fileURLToPath } from "url";
30
30
 
31
31
  let dryRun = false;
32
32
 
@@ -55,32 +55,476 @@ export function ensureDir(dir) {
55
55
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
56
56
  }
57
57
 
58
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
59
+ const PACKAGE_NAME = (() => {
60
+ try {
61
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).name || "";
62
+ } catch {
63
+ return "";
64
+ }
65
+ })();
66
+
67
+ function isWithin(path, dir) {
68
+ const d = dir.endsWith(sep) ? dir : dir + sep;
69
+ return path === dir || path.startsWith(d);
70
+ }
71
+
72
+ function realOr(p) {
73
+ try {
74
+ return realpathSync(p);
75
+ } catch {
76
+ return p;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * True when `real` lies inside a pipeline source tree: the one this install
82
+ * runs from (`pipelineSrc`'s package root), or any checkout of this package,
83
+ * recognised by an ancestor package.json carrying the package name. A --link
84
+ * install points its trees at a checkout, and that checkout need not be the
85
+ * one the current install runs from.
86
+ *
87
+ * @param {string} real resolved path
88
+ * @param {string} [pipelineSrc]
89
+ * @returns {boolean}
90
+ */
91
+ export function isInsidePipelineSource(real, pipelineSrc) {
92
+ const roots = [PACKAGE_ROOT];
93
+ if (pipelineSrc) roots.push(dirname(realOr(pipelineSrc)), realOr(pipelineSrc));
94
+ if (roots.some((r) => isWithin(real, realOr(r)))) return true;
95
+ if (!PACKAGE_NAME) return false;
96
+ for (let dir = real; dir !== dirname(dir); dir = dirname(dir)) {
97
+ try {
98
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
99
+ if (pkg?.name === PACKAGE_NAME) return true;
100
+ } catch {
101
+ /* no package.json here */
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
58
107
  /**
59
- * Replace a symlinked path with a real (empty) directory before installing
60
- * into it. After a `--link` install, an owned dir (or a shared parent like
61
- * `~/.claude/commands`) can be a symlink into the developer's repo checkout;
62
- * wiping or copying through it would land inside the repo and destroy the
63
- * pipeline sources. Non-symlink paths are left untouched.
108
+ * What an install target currently is.
64
109
  *
65
110
  * @param {string} dir
66
- * @returns {boolean} true when a symlink was replaced
111
+ * @param {string} [pipelineSrc]
112
+ * @returns {"missing"|"real"|"pipeline-link"|"foreign-link"}
67
113
  */
68
- export function ensureRealDir(dir) {
114
+ export function classifyInstallDir(dir, pipelineSrc) {
69
115
  let st;
70
116
  try {
71
117
  st = lstatSync(dir);
72
118
  } catch {
73
- return false;
119
+ return "missing";
120
+ }
121
+ if (!st.isSymbolicLink()) return "real";
122
+ let real;
123
+ try {
124
+ real = realpathSync(dir);
125
+ } catch {
126
+ // A dangling link points at nothing, so replacing it loses nothing.
127
+ return "pipeline-link";
128
+ }
129
+ return isInsidePipelineSource(real, pipelineSrc) ? "pipeline-link" : "foreign-link";
130
+ }
131
+
132
+ /**
133
+ * Resolve the directory an install step should write into.
134
+ *
135
+ * - A real directory, or a missing one: returned as is.
136
+ * - A symlink into a pipeline source tree (left by `--link`): replaced with an
137
+ * empty real directory, because wiping or copying through it would land in
138
+ * the checkout and destroy the sources.
139
+ * - A symlink anywhere else belongs to the user (a dotfiles repo, typically).
140
+ * For a user-owned tree (`rules/`, `agents/`, `commands/`) the link target
141
+ * is returned and the install writes through it; for a pipeline-managed
142
+ * tree the link is left alone, a warning is printed and null is returned so
143
+ * the caller skips the tree.
144
+ *
145
+ * @param {string} dir
146
+ * @param {{pipelineSrc?: string, userOwned?: boolean}} [opts]
147
+ * @returns {string|null} the path to write into, or null to skip
148
+ */
149
+ export function ensureRealDir(dir, { pipelineSrc, userOwned = false } = {}) {
150
+ const kind = classifyInstallDir(dir, pipelineSrc);
151
+ if (kind === "missing" || kind === "real") return dir;
152
+ if (kind === "foreign-link") {
153
+ const target = realOr(dir);
154
+ if (userOwned) {
155
+ console.log(` -> ${dir} is a symlink to ${target}; installing through it`);
156
+ return target;
157
+ }
158
+ console.log(
159
+ ` -> WARNING: skipped ${dir}: it is a symlink to ${target}, outside the pipeline source. ` +
160
+ `Remove the link (or point it at a pipeline checkout) and re-run the install to update it.`,
161
+ );
162
+ return null;
74
163
  }
75
- if (!st.isSymbolicLink()) return false;
76
164
  if (dryRun) {
77
165
  console.log(` [dry-run] would replace symlink ${dir} with a real directory`);
78
- return true;
166
+ return dir;
79
167
  }
80
168
  unlinkSync(dir);
81
169
  mkdirSync(dir, { recursive: true });
82
- console.log(` -> replaced symlink ${dir} with a real directory (was --link mode)`);
83
- return true;
170
+ console.log(
171
+ ` -> replaced symlink ${dir} with a real directory (it pointed into a pipeline checkout)`,
172
+ );
173
+ return dir;
174
+ }
175
+
176
+ /**
177
+ * Raised when an install cannot finish. `index.mjs` turns it into one line and
178
+ * a non-zero exit; the stack trace prints only with MULTI_AGENT_INSTALL_DEBUG=1.
179
+ */
180
+ export class InstallIncompleteError extends Error {
181
+ /**
182
+ * @param {string} message
183
+ * @param {{cause?: unknown, untouched?: boolean}} [opts]
184
+ */
185
+ constructor(message, { cause, untouched = false } = {}) {
186
+ super(message);
187
+ this.name = "InstallIncompleteError";
188
+ this.code = "INSTALL_INCOMPLETE";
189
+ this.untouched = untouched;
190
+ if (cause) this.cause = cause;
191
+ }
192
+ }
193
+
194
+ function describeFsError(err) {
195
+ if (!err) return "unknown error";
196
+ if (err.code && err.path) return `${err.code} on ${err.path}`;
197
+ return String(err.message || err).split("\n")[0];
198
+ }
199
+
200
+ /**
201
+ * Stage every managed tree under `swapRoot` (a hidden directory of the host
202
+ * dir), then swap them all in by rename. Staging lives outside the managed
203
+ * trees so a half-written or undeletable copy never sits inside `commands/`,
204
+ * where the host would load it as a command namespace. Nothing at a
205
+ * destination changes until every tree staged cleanly; a failed swap puts
206
+ * back the trees already swapped. Previous copies wait under `swapRoot` until
207
+ * every swap succeeded.
208
+ *
209
+ * A leftover staged copy from an interrupted run is discarded, a leftover
210
+ * previous copy is restored when its destination is missing, and anything
211
+ * that cannot be deleted is moved aside inside `swapRoot`.
212
+ *
213
+ * @param {Array<{dest: string, label: string, build: (staged: string) => void}>} trees
214
+ * @param {{swapRoot: string}} opts
215
+ * @throws {InstallIncompleteError}
216
+ */
217
+ export function stageAndSwapTrees(trees, { swapRoot }) {
218
+ if (dryRun) {
219
+ for (const t of trees) console.log(` [dry-run] would stage and swap in ${t.dest}`);
220
+ return;
221
+ }
222
+ const paths = new Map(trees.map(({ dest }) => [dest, swapPaths(swapRoot, dest)]));
223
+ mkdirSync(swapRoot, { recursive: true });
224
+ for (const { dest } of trees) recoverInterruptedSwap(dest, paths.get(dest));
225
+
226
+ const staged = [];
227
+ try {
228
+ for (const t of trees) {
229
+ const tmp = paths.get(t.dest).staged;
230
+ staged.push(tmp);
231
+ t.build(tmp);
232
+ }
233
+ } catch (err) {
234
+ for (const tmp of staged) rmQuiet(tmp);
235
+ throw new InstallIncompleteError(
236
+ `could not stage the new files: ${describeFsError(err)}. The previous install was left in place`,
237
+ { cause: err, untouched: true },
238
+ );
239
+ }
240
+
241
+ const swapped = [];
242
+ try {
243
+ for (const { dest } of trees) {
244
+ const { staged: tmp, old } = paths.get(dest);
245
+ const had = pathExists(dest);
246
+ mkdirSync(dirname(dest), { recursive: true });
247
+ if (had) renameSync(dest, old);
248
+ try {
249
+ renameSync(tmp, dest);
250
+ } catch (err) {
251
+ if (had) renameSync(old, dest);
252
+ throw err;
253
+ }
254
+ swapped.push({ dest, had, old });
255
+ }
256
+ } catch (err) {
257
+ for (const { dest, had, old } of swapped.reverse()) {
258
+ try {
259
+ rmSync(dest, { recursive: true, force: true });
260
+ if (had) renameSync(old, dest);
261
+ } catch {
262
+ /* best effort: the next run's recovery restores a leftover previous copy */
263
+ }
264
+ }
265
+ for (const tmp of staged) rmQuiet(tmp);
266
+ throw new InstallIncompleteError(
267
+ `could not swap in the new files: ${describeFsError(err)}. The previous install was restored`,
268
+ { cause: err, untouched: true },
269
+ );
270
+ }
271
+
272
+ for (const { had, old } of swapped) {
273
+ if (!had) continue;
274
+ try {
275
+ rmSync(old, { recursive: true, force: true });
276
+ } catch (err) {
277
+ console.log(
278
+ ` -> note: could not remove ${old} (${describeFsError(err)}); ` +
279
+ "the next install moves it aside",
280
+ );
281
+ }
282
+ }
283
+ try {
284
+ if (readdirSync(swapRoot).length === 0) rmSync(swapRoot, { recursive: true, force: true });
285
+ } catch {
286
+ /* best effort */
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Staging and previous-copy paths for one destination: the destination's path
292
+ * relative to the host dir, flattened into one name under `swapRoot`.
293
+ *
294
+ * @param {string} swapRoot
295
+ * @param {string} dest
296
+ * @returns {{staged: string, old: string}}
297
+ */
298
+ export function swapPaths(swapRoot, dest) {
299
+ const key = relative(dirname(swapRoot), dest).split(sep).join("__");
300
+ return { staged: join(swapRoot, `${key}.new`), old: join(swapRoot, `${key}.old`) };
301
+ }
302
+
303
+ function pathExists(p) {
304
+ try {
305
+ lstatSync(p);
306
+ return true;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ function rmQuiet(p) {
313
+ try {
314
+ rmSync(p, { recursive: true, force: true });
315
+ } catch {
316
+ /* best effort */
317
+ }
318
+ }
319
+
320
+ function recoverInterruptedSwap(dest, { staged, old }) {
321
+ rmQuiet(staged);
322
+ rmQuiet(`${dest}.new`);
323
+ for (const prev of [old, `${dest}.old`]) {
324
+ if (!pathExists(prev)) continue;
325
+ if (!pathExists(dest)) {
326
+ try {
327
+ renameSync(prev, dest);
328
+ continue;
329
+ } catch {
330
+ /* fall through and clear it */
331
+ }
332
+ }
333
+ rmQuiet(prev);
334
+ if (!pathExists(prev)) continue;
335
+ // Something inside it cannot be deleted (a read-only subdirectory). Move it
336
+ // under the swap root so it is out of the managed trees, and say where.
337
+ const aside = `${old}-${Date.now()}`;
338
+ try {
339
+ renameSync(prev, aside);
340
+ console.log(` -> note: could not delete ${prev}; moved it to ${aside}, remove it by hand`);
341
+ } catch {
342
+ /* the swap below reports the failure */
343
+ }
344
+ }
345
+ }
346
+
347
+ /** File name of the install manifest, at the root of the host dir. */
348
+ export const INSTALL_MANIFEST = ".pipeline-manifest.json";
349
+
350
+ /**
351
+ * @param {string} path
352
+ * @returns {string} sha256 of the file's bytes
353
+ */
354
+ export function hashFile(path) {
355
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
356
+ }
357
+
358
+ /**
359
+ * The install manifest: every co-owned file the installer wrote, keyed by its
360
+ * path relative to the host dir, with the hash it had when written, plus the
361
+ * unknown files already reported so each is reported once.
362
+ *
363
+ * @param {string} hostDir e.g. `$HOME/.claude`
364
+ * @returns {{present: boolean, files: Record<string,string>, warned: string[]}}
365
+ */
366
+ export function readInstallManifest(hostDir) {
367
+ try {
368
+ const doc = JSON.parse(readFileSync(join(hostDir, INSTALL_MANIFEST), "utf-8"));
369
+ return {
370
+ present: true,
371
+ files: doc && typeof doc.files === "object" && doc.files ? { ...doc.files } : {},
372
+ warned: Array.isArray(doc?.warned) ? doc.warned.filter((x) => typeof x === "string") : [],
373
+ };
374
+ } catch {
375
+ return { present: false, files: {}, warned: [] };
376
+ }
377
+ }
378
+
379
+ /**
380
+ * @param {string} hostDir
381
+ * @param {{files: Record<string,string>, warned: string[]}} manifest
382
+ */
383
+ export function writeInstallManifest(hostDir, manifest) {
384
+ const doc = {
385
+ schema: 1,
386
+ files: Object.fromEntries(
387
+ Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)),
388
+ ),
389
+ warned: [...new Set(manifest.warned)].sort(),
390
+ };
391
+ writeFile(join(hostDir, INSTALL_MANIFEST), JSON.stringify(doc, null, 2) + "\n");
392
+ }
393
+
394
+ /**
395
+ * Relative paths of every regular file under `dir`, junk excluded.
396
+ * @param {string} dir
397
+ * @returns {string[]}
398
+ */
399
+ export function listFiles(dir) {
400
+ const out = [];
401
+ const walk = (abs, rel) => {
402
+ let entries;
403
+ try {
404
+ entries = readdirSync(abs, { withFileTypes: true });
405
+ } catch {
406
+ return;
407
+ }
408
+ for (const e of entries) {
409
+ const r = rel ? `${rel}/${e.name}` : e.name;
410
+ if (isCopyJunk(r)) continue;
411
+ if (e.isDirectory()) walk(join(abs, e.name), r);
412
+ else if (e.isFile()) out.push(r);
413
+ }
414
+ };
415
+ walk(dir, "");
416
+ return out.sort();
417
+ }
418
+
419
+ /**
420
+ * Bring the pipeline's files in a co-owned directory up to date without
421
+ * touching anything the user owns.
422
+ *
423
+ * A shipped file is written when it is absent, or when its current hash is the
424
+ * one the manifest recorded (unchanged since the last install). `adopt` covers
425
+ * the first run after an install that predates the manifest: that installer
426
+ * overwrote these files on every run, so one carrying a shipped name is its
427
+ * copy. Any other existing file is the user's: it is kept and reported. A file
428
+ * the manifest records but the source no longer ships is removed when it is
429
+ * still unchanged. Files neither shipped nor recorded are the user's; with
430
+ * `reportUnknown` they are reported once.
431
+ *
432
+ * @param {{
433
+ * srcDir: string,
434
+ * destDir: string,
435
+ * displayDir?: string,
436
+ * rels: string[],
437
+ * prefix: string,
438
+ * manifest: {files: Record<string,string>, warned: string[]},
439
+ * adopt: boolean,
440
+ * reportUnknown?: boolean,
441
+ * label: string,
442
+ * }} opts
443
+ * @returns {{written: number, kept: string[], removed: number}}
444
+ */
445
+ export function syncTrackedFiles(opts) {
446
+ const { srcDir, destDir, rels, prefix, manifest, adopt, reportUnknown = false, label } = opts;
447
+ const shownDir = opts.displayDir || destDir;
448
+ const shipped = new Set(rels);
449
+ const kept = [];
450
+ let written = 0;
451
+ let removed = 0;
452
+ const warn = (key, text) => {
453
+ if (manifest.warned.includes(key)) return;
454
+ manifest.warned.push(key);
455
+ console.log(` -> WARNING: ${text}`);
456
+ };
457
+ for (const rel of rels) {
458
+ const key = `${prefix}${rel}`;
459
+ const from = join(srcDir, rel);
460
+ const to = join(destDir, rel);
461
+ const want = hashFile(from);
462
+ let have;
463
+ try {
464
+ have = lstatSync(to).isFile() ? hashFile(to) : "not-a-file";
465
+ } catch {
466
+ have = null;
467
+ }
468
+ const recorded = manifest.files[key];
469
+ if (have === want) {
470
+ manifest.files[key] = want;
471
+ continue;
472
+ }
473
+ if (have === null || have === recorded || (adopt && recorded === undefined)) {
474
+ if (dryRun) {
475
+ console.log(` [dry-run] would write ${join(shownDir, rel)}`);
476
+ } else {
477
+ mkdirSync(dirname(to), { recursive: true });
478
+ cpSync(from, to, { force: true });
479
+ }
480
+ manifest.files[key] = want;
481
+ manifest.warned = manifest.warned.filter((w) => w !== key);
482
+ written++;
483
+ continue;
484
+ }
485
+ delete manifest.files[key];
486
+ kept.push(rel);
487
+ warn(
488
+ key,
489
+ `kept your ${join(shownDir, rel)}: it differs from what the pipeline installed there. ` +
490
+ `Delete it and re-run the install to get the ${label} version.`,
491
+ );
492
+ }
493
+ for (const [key, hash] of Object.entries(manifest.files)) {
494
+ if (!key.startsWith(prefix)) continue;
495
+ const rel = key.slice(prefix.length);
496
+ if (shipped.has(rel)) continue;
497
+ const to = join(destDir, rel);
498
+ let have;
499
+ try {
500
+ have = hashFile(to);
501
+ } catch {
502
+ have = null;
503
+ }
504
+ if (have === hash) {
505
+ if (dryRun) console.log(` [dry-run] would remove ${join(shownDir, rel)}`);
506
+ else rmSync(to, { force: true });
507
+ removed++;
508
+ }
509
+ delete manifest.files[key];
510
+ }
511
+ if (reportUnknown && existsSync(destDir)) {
512
+ const unknown = listFiles(destDir).filter(
513
+ (rel) => !shipped.has(rel) && manifest.files[`${prefix}${rel}`] === undefined,
514
+ );
515
+ const fresh = unknown.filter((rel) => !manifest.warned.includes(`${prefix}${rel}`));
516
+ if (fresh.length > 0) {
517
+ for (const rel of fresh) manifest.warned.push(`${prefix}${rel}`);
518
+ console.log(
519
+ ` -> note: kept ${fresh.length} file(s) in ${shownDir} the pipeline did not install: ` +
520
+ fresh.join(", "),
521
+ );
522
+ }
523
+ manifest.warned = manifest.warned.filter(
524
+ (w) => !w.startsWith(prefix) || existsSync(join(destDir, w.slice(prefix.length))),
525
+ );
526
+ }
527
+ return { written, kept, removed };
84
528
  }
85
529
 
86
530
  /**
@@ -105,8 +549,8 @@ function pipelineAgentFileNames(agentsSrc) {
105
549
 
106
550
  /**
107
551
  * Remove ONLY pipeline-owned agent files from an installed agents dir.
108
- * The dir also holds user-authored agent definitions, so it must never be
109
- * wiped wholesale (that destroyed user files pre-v11.4.1).
552
+ * The dir also holds user-authored agent definitions, so it is never wiped
553
+ * wholesale.
110
554
  *
111
555
  * @param {string} destDir - installed agents dir (~/.claude/agents etc.)
112
556
  * @param {string} agentsSrc - pipeline/agents source directory
@@ -134,25 +578,15 @@ export function removePipelineAgentFiles(destDir, agentsSrc) {
134
578
  return removed;
135
579
  }
136
580
 
137
- /**
138
- * Recursively copy a directory (or symlink it in `--link` mode).
139
- *
140
- * @param {string} src
141
- * @param {string} dest
142
- * @param {{ exclude?: Array<string|RegExp>, useSymlinks?: boolean }} [opts]
143
- */
144
581
  /**
145
582
  * Build and editor droppings that live inside a source tree and must never
146
583
  * reach a user's install. Python writes `__pycache__/<mod>.cpython-NNN.pyc`
147
- * next to any script it IMPORTS, so a tree that has merely been used has them;
584
+ * next to any script it imports, so a tree that has merely been used has them;
148
585
  * the Finder writes .DS_Store into any directory it has displayed.
149
586
  *
150
- * This is not cosmetic. The installed file count is a gate in this repo, and a
151
- * CI runner on Python 3.14 shipped 147 files where every developer tree shipped
152
- * 146 - for four rounds the difference looked like a fixture bug, because the
153
- * extra file was untracked and therefore invisible to every diff of the SOURCE.
154
- * Filtering here fixes the user-visible half too: a .pyc compiled against one
155
- * interpreter is worse than useless on another.
587
+ * Filtering them keeps the installed file count equal to the tracked source
588
+ * count (the layout smoke compares the two), and a .pyc compiled against one
589
+ * interpreter is of no use on another.
156
590
  *
157
591
  * @param {string} rel path relative to the copy root, posix-separated
158
592
  * @returns {boolean}
@@ -169,6 +603,13 @@ function isCopyJunk(rel) {
169
603
  );
170
604
  }
171
605
 
606
+ /**
607
+ * Recursively copy a directory (or symlink it in `--link` mode).
608
+ *
609
+ * @param {string} src
610
+ * @param {string} dest
611
+ * @param {{ exclude?: Array<string|RegExp>, useSymlinks?: boolean, skipExisting?: boolean }} [opts]
612
+ */
172
613
  export function copyDir(src, dest, opts = {}) {
173
614
  const { exclude = [], useSymlinks = false, skipExisting = false } = opts;
174
615
  if (useSymlinks) {
@@ -269,25 +710,17 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
269
710
  /**
270
711
  * Trees an older installer created and no current one manages.
271
712
  *
272
- * A tree that stops being copied does NOT stop existing: the wipe-before-copy
273
- * pattern only runs for trees the installer still writes, so dropping one from
274
- * the installer leaves whatever it last wrote on every existing machine forever.
275
- *
276
- * `eval/` is the case that prompted this. An older version copied the eval
277
- * corpora into `~/.claude/eval/`; the harnesses that read them are maintainer CI
278
- * tooling and no longer ship, so neither installer references `eval` any more and
279
- * neither did uninstall - 31 stale files sat there with nothing left that could
280
- * ever read or remove them.
281
- *
282
- * `~/.multi-agent/` is the larger case: the "shared runtime" the Cursor /
283
- * Antigravity / VS Code Copilot Chat adapters needed, so their emitted agents
284
- * could reach the gate scripts by absolute path. Those adapters were deleted in
285
- * v10.7.0 along with `installSharedRuntime`, `_base.mjs`, `rewriteScriptRefs` and
286
- * `smoke-shared-runtime.sh` - but not the tree they wrote. 221 files (174 scripts,
287
- * 23 lib, 24 schemas) frozen at whatever the last adapter-era install produced,
288
- * including a migrations directory missing `prefs-2.3.0-to-2.4.0.mjs`. Browsing it
289
- * looks exactly like browsing a current install, which is how it comes to be
290
- * mistaken for one.
713
+ * A tree that stops being copied does not stop existing: the wipe-before-copy
714
+ * pattern only runs for trees the installer still writes, so a tree dropped
715
+ * from the installer keeps whatever it last wrote until something removes it.
716
+ *
717
+ * `eval/` held eval corpora for harnesses that are maintainer CI tooling and do
718
+ * not ship, so nothing installed can read or remove it.
719
+ *
720
+ * `~/.multi-agent/` is the shared runtime the retired Cursor / Antigravity /
721
+ * VS Code Copilot Chat adapters used to reach the gate scripts by absolute
722
+ * path: scripts, lib and schemas that no current installer refreshes, and that
723
+ * look like a live install when browsed.
291
724
  *
292
725
  * `root: "home"` entries sit beside `~/.claude`, not inside it.
293
726
  *