@mmerterden/multi-agent-pipeline 11.4.0 → 11.5.0

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 (54) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +1 -0
  3. package/index.js +9 -2
  4. package/install/_common.mjs +107 -5
  5. package/install/_telemetry.mjs +5 -23
  6. package/install/claude.mjs +70 -10
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  10. package/pipeline/lib/account-resolver.sh +61 -23
  11. package/pipeline/lib/channels-multi-repo.sh +7 -2
  12. package/pipeline/lib/count-lib.sh +27 -0
  13. package/pipeline/lib/credential-store.sh +23 -6
  14. package/pipeline/lib/extract-conventions.sh +27 -21
  15. package/pipeline/lib/fetch-confluence.sh +25 -13
  16. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  17. package/pipeline/lib/fetch-fortify.sh +11 -2
  18. package/pipeline/lib/fetch-swagger.sh +18 -7
  19. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  20. package/pipeline/lib/figma-screenshot.sh +11 -2
  21. package/pipeline/lib/issue-fetcher.sh +31 -6
  22. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  23. package/pipeline/lib/plan-todos.sh +12 -3
  24. package/pipeline/lib/post-pr-review.sh +5 -3
  25. package/pipeline/lib/repo-cache.sh +53 -9
  26. package/pipeline/lib/review-watch.sh +26 -6
  27. package/pipeline/lib/shadow-git.sh +45 -4
  28. package/pipeline/lib/vercel-deploy.sh +10 -1
  29. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  30. package/pipeline/scripts/check-md-links.mjs +34 -9
  31. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  32. package/pipeline/scripts/evidence-gate.mjs +10 -1
  33. package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
  34. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  35. package/pipeline/scripts/keychain-save.sh +13 -9
  36. package/pipeline/scripts/lint-skills.mjs +40 -2
  37. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  38. package/pipeline/scripts/phase-tracker.sh +71 -0
  39. package/pipeline/scripts/pre-commit-check.sh +39 -0
  40. package/pipeline/scripts/scan-skills.sh +217 -127
  41. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  42. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  43. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  44. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  45. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  46. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  47. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  48. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  49. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  50. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  51. package/pipeline/scripts/triage-memory.mjs +10 -1
  52. package/pipeline/scripts/uninstall.mjs +105 -36
  53. package/pipeline/scripts/update-issue-progress.sh +60 -41
  54. package/pipeline/scripts/write-state.mjs +14 -4
package/CHANGELOG.md CHANGED
@@ -14,6 +14,124 @@ Internal file-layout changes that don't affect the slash-command surface are sti
14
14
 
15
15
  ---
16
16
 
17
+ ## [11.5.0] - 2026-07-16
18
+
19
+ Full-project refactor round: 20 verified defect fixes (install lifecycle, shell
20
+ libs, fetchers, gate scripts), an install-time performance overhaul, and new
21
+ supply-chain / validation gates. Driven by a 4-band analysis (best-practices
22
+ research, bug hunt, category scoring, upstream-drift check).
23
+
24
+ ### Fixed - data loss and destructive paths
25
+
26
+ - **Install no longer wipes user agent files.** `installAgents` wiped the whole
27
+ `~/.claude/agents` / `~/.copilot/agents` dir on every install/update, deleting
28
+ user-authored subagents. It now removes only pipeline-owned files (list derived
29
+ from `pipeline/agents/` so it cannot go stale).
30
+ - **Plain install after `--link` no longer destroys the dev checkout.** Every
31
+ wipe/copy target now goes through a symlink guard (`ensureRealDir`); a `--link`
32
+ re-install just re-links instead of wiping through the symlink into the repo.
33
+ - **Unguarded `rm -rf` paths locked down.** `shadow-git.sh` validates task ids
34
+ (`prune ..` refused); `multi-repo-pipeline.sh teardown` refuses `/`, `$HOME`,
35
+ the project root, and their ancestors after path resolution.
36
+ - **`update-issue-progress.sh` no longer eats issue bodies.** The Progress-table
37
+ rewrite is now bounded at the legend comment, the next heading, or EOF; bodies
38
+ without the legend comment keep all trailing sections.
39
+ - **Copilot instructions merge is now marker-bounded.** An explicit end marker
40
+ preserves user content appended after the pipeline section across install,
41
+ update, and uninstall (previously sliced to EOF).
42
+ - **Uninstall actually removes what install creates.** Agent files (dir-vs-file
43
+ bug meant none were ever removed), `multi-agent-refs/`, `schemas/`, `lib/`,
44
+ and the compliance core skills are now covered on both targets; token and
45
+ preferences guarantees unchanged.
46
+
47
+ ### Fixed - security
48
+
49
+ - **Pre-commit secret-scan hook fired never.** The PreToolUse matcher used
50
+ permission-rule syntax that can never match the tool name; matcher is now
51
+ `Bash` with a fast stdin filter in `pre-commit-check.sh`, and existing installs
52
+ migrate the dead entry in place.
53
+ - **Secrets off argv.** OAuth refresh grants, bearer/PAT headers, Basic auth,
54
+ and keychain writes across nine call sites (fetchers, repo-cache, issue-fetcher,
55
+ figma-mcp-refresh, credential-store, keychain-save) moved to `curl -K <(...)`
56
+ configs and stdin so tokens never appear in `ps`.
57
+ - **Crashlytics service-account key hygiene.** The decoded Firebase SA JSON now
58
+ lives in a `mktemp` file created under `umask 077` with trap cleanup instead of
59
+ a predictable world-readable `/tmp` name.
60
+ - **Telemetry honesty.** The "anonymous" install ping no longer sends
61
+ `githubUser`, `gitEmail`, or `hostname`.
62
+
63
+ ### Fixed - correctness
64
+
65
+ - **`fetch-confluence.sh` worked never.** An env-prefix-on-assignment bug meant
66
+ the URL parser always saw empty input; every invocation failed. Parsing now
67
+ goes through argv and the fetcher honors its documented exit codes.
68
+ - **`diff-risk-score.mjs` default invocation** resolved the diff base after
69
+ using it (`null...HEAD` fatal, gate fail-open); base now resolves first.
70
+ - **`evidence-gate.mjs`** no longer flags passing logs containing "0 tests
71
+ failed" as failures.
72
+ - **`grep -c || echo 0` double-output idiom** fixed at ~20 sites via a shared
73
+ `count-lib.sh` helper (bogus `* PR:` bullets in Jira/Confluence bodies, silently
74
+ zeroed convention counters in `extract-conventions.sh`).
75
+ - **False-success family.** `multi-repo-pipeline.sh` no longer prints
76
+ "committed/pushed" on failure; `repo-cache.sh` keeps the previous cache when a
77
+ refresh fails instead of serving an empty file for the TTL; `review-watch.sh
78
+ --watch` survives transient gh failures and its cursor can no longer move
79
+ backwards.
80
+ - **`issue-fetcher.sh`** classifies bare `#316` / `316` correctly (repo picker
81
+ path instead of a malformed `//issues/` URL).
82
+ - **`phase-tracker.sh`** read-modify-write is now lock-protected; parallel
83
+ Phase 4 reviewers no longer lose token/cost deltas.
84
+ - **Shadow-git**: forced excludes now land in `info/exclude` (they were dead
85
+ code, ballooning snapshots with DerivedData), and `restore --files` removes
86
+ files created after the snapshot instead of keeping half the change.
87
+ - **macOS bash 3.2 portability**: `${URL,,}` in `fetch-swagger.sh`, `date -r
88
+ <file>` in `audit-log-rotate.sh` (which also rotates atomically now).
89
+ - **Glob translation** in `triage-memory.mjs` / `test-gap-scan.mjs`: `**` no
90
+ longer breaks after the `*` pass (nested-path filters match again).
91
+ - **`migrate-prefs.mjs`** errors on unknown flags (a `--dry-rnu` typo used to
92
+ perform a real write) and reports the correct target version.
93
+ - Minor hardening: `write-state.mjs` lock release on early exits,
94
+ `post-pr-review.sh` / `plan-todos.sh` null-safe jq, `account-resolver.sh`
95
+ JSON escaping, `scan-skills.sh --json` spurious empty finding removed.
96
+
97
+ ### Performance
98
+
99
+ - **Install: ~94s -> ~6s.** `scan-skills.sh` rewritten from per-file grep loops
100
+ to combined single-pass scans (identical findings output, verified by diff);
101
+ `countFiles` no longer shells out to `find | wc -l`.
102
+
103
+ ### Added - gates and tooling
104
+
105
+ - **OIDC trusted publishing.** `release.yml` publishes tokenless (no more
106
+ `NPM_TOKEN`); one-time npmjs.com trusted-publisher setup required before the
107
+ next release (see workflow header). Release gates now also run the layout
108
+ smoke, the install leak audit, and the new pack-contents smoke.
109
+ - **New smokes**: `smoke-plugin-validate.sh` (`claude plugin validate --strict`),
110
+ `smoke-workflow-audit.sh` (zizmor + actionlint, offline, skip-if-missing),
111
+ `smoke-pack-contents.sh` (packed-payload deny-list derived from package.json
112
+ negations at runtime + file-count band), `smoke-fetchers-offline.sh`
113
+ (44 offline assertions covering every fetcher's parse/credential/network
114
+ stages - the class of hole that let fetch-confluence ship dead).
115
+ - **`check-md-links.mjs` now covers `pipeline/**`** (700+ skill/command files,
116
+ `$HOME/.claude/multi-agent-refs/` links validated against the repo source).
117
+ - **`lint-skills.mjs` spec-conformance warnings** (agent-skills name pattern,
118
+ routing-clause descriptions, body size budget).
119
+ - **Coverage floor**: c8 `--check-coverage` at the measured baseline (lines 80).
120
+ - **DevEx**: `test:quick` script, `index.js --version`, unknown commands list
121
+ valid ones, smoke-runner env knobs documented in CONTRIBUTING, `cache: npm` +
122
+ strict `npm ci` in all workflows.
123
+ - **Real-module tests**: `test/index.test.mjs` suites now import the actual
124
+ install helpers instead of inlined copies (the dead hook shipped because the
125
+ copy-based tests could not fail); new temp-HOME integration tests for the full
126
+ install -> update -> uninstall lifecycle and the JS gate scripts. Unit tests
127
+ 145 -> 197.
128
+
129
+ ### Docs
130
+
131
+ - ROADMAP moved to the 11.x line, SECURITY.md supported-versions table updated
132
+ (11.x active), README Scorecard badge, dead `files[]` negations removed,
133
+ internal working notes moved to `docs/internal/`.
134
+
17
135
  ## [11.4.0] - 2026-07-10
18
136
 
19
137
  Review PR-picker, two readiness-review commands, and durable local-only wrappers.
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Node.js](https://img.shields.io/badge/Node.js-18%20%7C%2020%20%7C%2022-green)](https://nodejs.org)
6
6
  [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://github.com/mmerterden/multi-agent-pipeline/blob/main/package.json)
7
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/mmerterden/multi-agent-pipeline/badge)](https://scorecard.dev/viewer/?uri=github.com/mmerterden/multi-agent-pipeline)
7
8
 
8
9
  An 8-phase AI development pipeline for **Claude Code** and **Copilot CLI**. Drives a Jira issue or GitHub URL to a merged PR in one command — analysis → plan → TDD → review → test → commit → PR — with multi-repo orchestration, a plan-approval gate, CLI-aware parallel review, and store-compliance checks. Component and Figma-to-code work is dispatched to the per-stack marketplace plugins (iOS/SwiftUI, Android/Compose) rather than bundled, so component skills live in one place.
9
10
 
package/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { pathToFileURL } from "url";
13
13
  import { join, dirname } from "path";
14
14
  import { fileURLToPath } from "url";
15
+ import { readFileSync } from "fs";
15
16
 
16
17
  /** @type {string} */
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -29,7 +30,10 @@ if (noColor) {
29
30
  process.env.NO_COLOR = "1";
30
31
  }
31
32
 
32
- if (!command || command === "install") {
33
+ if (command === "--version" || command === "-v" || command === "version") {
34
+ const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
35
+ console.log(pkg.version);
36
+ } else if (!command || command === "install") {
33
37
  await import(pathToFileURL(join(__dirname, "install.js")).href);
34
38
  } else if (command === "uninstall" || command === "delete") {
35
39
  await import(pathToFileURL(join(__dirname, "pipeline", "scripts", "uninstall.mjs")).href);
@@ -52,6 +56,7 @@ if (!command || command === "install") {
52
56
 
53
57
  Help:
54
58
  npx @mmerterden/multi-agent-pipeline help
59
+ npx @mmerterden/multi-agent-pipeline --version Print the installed version
55
60
 
56
61
  Options:
57
62
  --no-color Disable colored output
@@ -65,6 +70,8 @@ if (!command || command === "install") {
65
70
  More info: https://github.com/mmerterden/multi-agent-pipeline
66
71
  `);
67
72
  } else {
68
- console.error(`Unknown command: ${command}. Run with 'help' for usage.`);
73
+ console.error(`Unknown command: ${command}`);
74
+ console.error("Valid commands: install, uninstall, delete, help, version (--version/-v)");
75
+ console.error("Run with 'help' for usage.");
69
76
  process.exit(1);
70
77
  }
@@ -16,12 +16,12 @@ import {
16
16
  mkdirSync,
17
17
  readdirSync,
18
18
  rmSync,
19
+ statSync,
19
20
  symlinkSync,
20
21
  unlinkSync,
21
22
  writeFileSync,
22
23
  } from "fs";
23
24
  import { dirname } from "path";
24
- import { execSync } from "child_process";
25
25
  import { join } from "path";
26
26
 
27
27
  let dryRun = false;
@@ -51,6 +51,85 @@ export function ensureDir(dir) {
51
51
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
52
52
  }
53
53
 
54
+ /**
55
+ * Replace a symlinked path with a real (empty) directory before installing
56
+ * into it. After a `--link` install, an owned dir (or a shared parent like
57
+ * `~/.claude/commands`) can be a symlink into the developer's repo checkout;
58
+ * wiping or copying through it would land inside the repo and destroy the
59
+ * pipeline sources. Non-symlink paths are left untouched.
60
+ *
61
+ * @param {string} dir
62
+ * @returns {boolean} true when a symlink was replaced
63
+ */
64
+ export function ensureRealDir(dir) {
65
+ let st;
66
+ try {
67
+ st = lstatSync(dir);
68
+ } catch {
69
+ return false;
70
+ }
71
+ if (!st.isSymbolicLink()) return false;
72
+ if (dryRun) {
73
+ console.log(` [dry-run] would replace symlink ${dir} with a real directory`);
74
+ return true;
75
+ }
76
+ unlinkSync(dir);
77
+ mkdirSync(dir, { recursive: true });
78
+ console.log(` -> replaced symlink ${dir} with a real directory (was --link mode)`);
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * File names of the agent definitions the pipeline ships, derived from the
84
+ * source tree so the list can never go stale. Includes legacy names shipped
85
+ * by older releases so their stale installed copies are still cleaned up.
86
+ *
87
+ * @param {string} agentsSrc - pipeline/agents source directory
88
+ * @returns {Set<string>}
89
+ */
90
+ export function pipelineAgentFileNames(agentsSrc) {
91
+ const names = new Set();
92
+ if (existsSync(agentsSrc)) {
93
+ for (const entry of readdirSync(agentsSrc, { withFileTypes: true })) {
94
+ if (entry.isFile()) names.add(entry.name);
95
+ }
96
+ }
97
+ // Legacy agent file names from older releases go here when an agent is
98
+ // renamed or removed upstream (currently none beyond the shipped set).
99
+ return names;
100
+ }
101
+
102
+ /**
103
+ * Remove ONLY pipeline-owned agent files from an installed agents dir.
104
+ * The dir also holds user-authored agent definitions, so it must never be
105
+ * wiped wholesale (that destroyed user files pre-v11.4.1).
106
+ *
107
+ * @param {string} destDir - installed agents dir (~/.claude/agents etc.)
108
+ * @param {string} agentsSrc - pipeline/agents source directory
109
+ * @returns {number} count of files removed
110
+ */
111
+ export function removePipelineAgentFiles(destDir, agentsSrc) {
112
+ if (!existsSync(destDir)) return 0;
113
+ const owned = pipelineAgentFileNames(agentsSrc);
114
+ let removed = 0;
115
+ for (const entry of readdirSync(destDir, { withFileTypes: true })) {
116
+ if (!entry.isFile()) continue;
117
+ if (!owned.has(entry.name)) continue;
118
+ if (dryRun) {
119
+ console.log(` [dry-run] would remove ${join(destDir, entry.name)}`);
120
+ removed++;
121
+ continue;
122
+ }
123
+ try {
124
+ rmSync(join(destDir, entry.name), { force: true });
125
+ removed++;
126
+ } catch {
127
+ /* non-fatal */
128
+ }
129
+ }
130
+ return removed;
131
+ }
132
+
54
133
  /**
55
134
  * Recursively copy a directory (or symlink it in `--link` mode).
56
135
  *
@@ -85,7 +164,13 @@ export function copyDir(src, dest, opts = {}) {
85
164
  recursive: true,
86
165
  force: true,
87
166
  filter: (source) => {
88
- const rel = source.slice(src.length).replace(/^\//, "");
167
+ // Normalize to forward slashes so exclude patterns (written posix-style)
168
+ // also match on win32, where cpSync hands back backslash paths.
169
+ const rel = source
170
+ .slice(src.length)
171
+ .split("\\")
172
+ .join("/")
173
+ .replace(/^\//, "");
89
174
  return !exclude.some((pat) =>
90
175
  typeof pat === "string"
91
176
  ? rel === pat || rel.startsWith(pat + "/")
@@ -187,17 +272,34 @@ export function writeFile(path, content) {
187
272
  }
188
273
 
189
274
  /**
190
- * Count files in a directory tree.
275
+ * Count files in a directory tree (pure fs recursion - works on win32 where
276
+ * `find` is unavailable, and avoids a subprocess per call). A plain-file path
277
+ * counts as 1, matching the old `find <path> -type f` behavior relied on by
278
+ * the DEV_ONLY_SCRIPTS exclusion accounting.
191
279
  * @param {string} dir
192
280
  * @returns {number}
193
281
  */
194
282
  export function countFiles(dir) {
283
+ let st;
284
+ try {
285
+ st = statSync(dir);
286
+ } catch {
287
+ return 0;
288
+ }
289
+ if (st.isFile()) return 1;
290
+ if (!st.isDirectory()) return 0;
291
+ let count = 0;
292
+ let entries;
195
293
  try {
196
- const out = execSync(`find "${dir}" -type f | wc -l`, { encoding: "utf-8" });
197
- return parseInt(out.trim(), 10);
294
+ entries = readdirSync(dir, { withFileTypes: true });
198
295
  } catch {
199
296
  return 0;
200
297
  }
298
+ for (const entry of entries) {
299
+ if (entry.isDirectory()) count += countFiles(join(dir, entry.name));
300
+ else if (entry.isFile()) count++;
301
+ }
302
+ return count;
201
303
  }
202
304
 
203
305
  /**
@@ -4,29 +4,17 @@
4
4
  * Set `MULTI_AGENT_TELEMETRY=1` to enable. Nothing is sent otherwise.
5
5
  * Fire-and-forget; never throws and never delays the install.
6
6
  *
7
+ * The payload is genuinely anonymous: package name, version, install
8
+ * method, flags, platform/arch, and Node version. No usernames, emails,
9
+ * hostnames, or other machine/user identifiers are collected.
10
+ *
7
11
  * @module install/_telemetry
8
12
  */
9
13
 
10
- import { execSync } from "child_process";
11
14
  import { readFileSync } from "fs";
12
- import { hostname, platform, arch } from "os";
15
+ import { platform, arch } from "os";
13
16
  import { join } from "path";
14
17
 
15
- /**
16
- * Run a shell command silently and return its trimmed stdout, or undefined on error.
17
- * @param {string} cmd
18
- * @returns {string|undefined}
19
- */
20
- function safe(cmd) {
21
- try {
22
- return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"], timeout: 3000 })
23
- .toString()
24
- .trim();
25
- } catch {
26
- return undefined;
27
- }
28
- }
29
-
30
18
  /**
31
19
  * @param {{ packageRoot: string, flags: string[] }} ctx
32
20
  * @returns {Promise<void>}
@@ -40,9 +28,6 @@ export async function sendInstallTelemetry(ctx) {
40
28
  else if (process.env.npm_config_global === "true") method = "global";
41
29
  else if (process.env.npm_lifecycle_event === "install") method = "npm-install";
42
30
 
43
- const githubUser = safe("gh api user --jq .login");
44
- const gitEmail = safe("git config user.email");
45
-
46
31
  let version = "unknown";
47
32
  try {
48
33
  const pkg = JSON.parse(readFileSync(join(ctx.packageRoot, "package.json"), "utf-8"));
@@ -56,9 +41,6 @@ export async function sendInstallTelemetry(ctx) {
56
41
  version,
57
42
  method,
58
43
  flags: ctx.flags.filter((f) => f.startsWith("--")),
59
- githubUser,
60
- gitEmail,
61
- hostname: hostname(),
62
44
  platform: `${platform()} ${arch()}`,
63
45
  nodeVersion: process.version,
64
46
  };
@@ -5,9 +5,11 @@
5
5
  * `CLAUDE.md` template, preferences template, and the secret-detection
6
6
  * pre-commit hook in `settings.json`.
7
7
  *
8
- * Behaviour MUST stay byte-equivalent to the pre-v8.0.0 monolithic install
9
- * path — install layout smoke (`smoke-install-layout.sh`) compares before/after
10
- * trees on every release.
8
+ * Layout MUST stay equivalent to the pre-v8.0.0 monolithic install path -
9
+ * install layout smoke (`smoke-install-layout.sh`) compares before/after
10
+ * trees on every release. (v11.4.1 intentionally changed two behaviours
11
+ * without changing the layout: agents are cleaned per-file instead of wiping
12
+ * the dir, and symlinked install targets are replaced before writing.)
11
13
  *
12
14
  * @module install/claude
13
15
  */
@@ -21,14 +23,23 @@ import {
21
23
  copyFile,
22
24
  countFiles,
23
25
  ensureDir,
26
+ ensureRealDir,
24
27
  isDryRun,
25
28
  pruneLegacyMultiAgentSkills,
29
+ removePipelineAgentFiles,
26
30
  wipeDir,
27
31
  writeFile,
28
32
  } from "./_common.mjs";
29
33
  import { copyExternalSkillsFiltered } from "./_platform-filter.mjs";
30
34
  import { DEV_ONLY_SCRIPTS } from "./_dev-only-files.mjs";
31
35
 
36
+ /**
37
+ * Dead matcher written by pre-v11.4.1 installs. Hook matchers only ever see
38
+ * the tool name, so this string never matched and the hook never ran.
39
+ * Kept so installs can migrate it and uninstall can clean it.
40
+ */
41
+ export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
42
+
32
43
  /**
33
44
  * @param {{
34
45
  * home: string,
@@ -72,10 +83,27 @@ function installCommands(pipelineSrc, dest, useSymlinks) {
72
83
  const commandsSrc = join(pipelineSrc, "commands");
73
84
  if (!existsSync(commandsSrc)) return;
74
85
 
86
+ const ownedCmdDir = join(dest, "multi-agent");
87
+
88
+ if (useSymlinks) {
89
+ // --link (dev) mode: dest becomes a symlink into the repo. Never pre-wipe
90
+ // or restore wrappers through an existing link; both would delete from or
91
+ // write into the developer's repo checkout itself.
92
+ copyDir(commandsSrc, dest, { useSymlinks });
93
+ console.log(` -> ${countFiles(commandsSrc)} files linked at ${dest}`);
94
+ return;
95
+ }
96
+
97
+ // After a prior --link install, dest (or the owned subdir) is a symlink
98
+ // into the repo. Replace it with a real directory before any wipe/copy so
99
+ // the install can never land inside the repo checkout.
100
+ ensureRealDir(dest);
101
+ ensureRealDir(ownedCmdDir);
102
+
75
103
  // Wipe only our namespace dir so files renamed/removed in the source don't
76
104
  // linger as ghost slash commands. Other namespaces under
77
105
  // `~/.claude/commands/` are untouched.
78
- const ownedCmdDir = join(dest, "multi-agent");
106
+ //
79
107
  // Preserve local-only alias wrappers (frontmatter `local-only: true`) across
80
108
  // the wipe. They live ONLY under ~/.claude (never in pipeline/commands, and
81
109
  // never synced), so without this snapshot+restore they would be destroyed on
@@ -133,6 +161,7 @@ function installMultiAgentRefs(pipelineSrc, dest, useSymlinks) {
133
161
  const refsSrc = join(pipelineSrc, "multi-agent-refs");
134
162
  if (!existsSync(refsSrc)) return;
135
163
  console.log(" [Claude Code] Installing multi-agent refs (non-command)...");
164
+ if (!useSymlinks) ensureRealDir(dest);
136
165
  wipeDir(dest);
137
166
  copyDir(refsSrc, dest, { useSymlinks });
138
167
  console.log(` -> ${countFiles(refsSrc)} ref files copied to ${dest}`);
@@ -145,6 +174,7 @@ function installScripts(pipelineSrc, dest, useSymlinks) {
145
174
 
146
175
  // Wipe-before-copy — scripts/ is a 100% pipeline-managed tree, so files
147
176
  // removed upstream should not linger.
177
+ if (!useSymlinks) ensureRealDir(dest);
148
178
  wipeDir(dest);
149
179
  copyDir(scriptsSrc, dest, { exclude: DEV_ONLY_SCRIPTS, useSymlinks });
150
180
  // Count files actually excluded, not the array length: DEV_ONLY_SCRIPTS mixes
@@ -161,7 +191,13 @@ function installAgents(pipelineSrc, dest, useSymlinks) {
161
191
  console.log(" [Claude Code] Installing agent definitions...");
162
192
  const agentsSrc = join(pipelineSrc, "agents");
163
193
  if (!existsSync(agentsSrc)) return;
164
- wipeDir(dest);
194
+ // ~/.claude/agents also holds user-authored agent files. Never wipe the
195
+ // whole dir; remove only the pipeline-shipped agent files (name set derived
196
+ // from the source tree so it cannot go stale), then copy the fresh set in.
197
+ if (!useSymlinks) {
198
+ ensureRealDir(dest);
199
+ removePipelineAgentFiles(dest, agentsSrc);
200
+ }
165
201
  copyDir(agentsSrc, dest, { useSymlinks });
166
202
  console.log(` -> ${countFiles(agentsSrc)} files copied to ${dest}`);
167
203
  }
@@ -179,6 +215,7 @@ function installRules(pipelineSrc, dest, useSymlinks) {
179
215
  if (useSymlinks) {
180
216
  copyDir(rulesSrc, dest, { useSymlinks });
181
217
  } else {
218
+ ensureRealDir(dest);
182
219
  copyDir(rulesSrc, dest, { skipExisting: true });
183
220
  }
184
221
  console.log(` -> rules present (existing local rules preserved)`);
@@ -190,6 +227,7 @@ function installSchemas(pipelineSrc, dest, useSymlinks) {
190
227
  if (!existsSync(schemasSrc)) return;
191
228
  // Wipe-before-copy — schemas/ is a 100% pipeline-managed tree, so files
192
229
  // removed upstream should not linger.
230
+ if (!useSymlinks) ensureRealDir(dest);
193
231
  wipeDir(dest);
194
232
  copyDir(schemasSrc, dest, { useSymlinks });
195
233
  console.log(` -> ${countFiles(schemasSrc)} files copied to ${dest}`);
@@ -200,6 +238,7 @@ function installLib(pipelineSrc, dest, useSymlinks) {
200
238
  const libSrc = join(pipelineSrc, "lib");
201
239
  if (!existsSync(libSrc)) return;
202
240
  // Wipe-before-copy — lib/ is a 100% pipeline-managed tree.
241
+ if (!useSymlinks) ensureRealDir(dest);
203
242
  wipeDir(dest);
204
243
  copyDir(libSrc, dest, { useSymlinks });
205
244
  console.log(` -> ${countFiles(libSrc)} files copied to ${dest}`);
@@ -249,6 +288,10 @@ function installSkills(opts) {
249
288
  const { pipelineSrc, dest, indexOnly, useSymlinks, platformFlag } = opts;
250
289
  console.log(" [Claude Code] Installing skills...");
251
290
 
291
+ // Same symlink guard as the other owned trees: never prune or copy through
292
+ // a --link-era symlink into the repo checkout.
293
+ if (!useSymlinks) ensureRealDir(dest);
294
+
252
295
  if (indexOnly) {
253
296
  ensureDir(dest);
254
297
  const indexJson = join(pipelineSrc, "skills", ".skills-index.json");
@@ -354,7 +397,7 @@ function ensurePreferences(prefsPath, pipelineSrc) {
354
397
  }
355
398
  }
356
399
 
357
- function configureSettings(home) {
400
+ export function configureSettings(home) {
358
401
  console.log(" [Claude Code] Configuring hooks + context management...");
359
402
  const SETTINGS_PATH = join(home, ".claude", "settings.json");
360
403
  try {
@@ -372,12 +415,29 @@ function configureSettings(home) {
372
415
 
373
416
  if (!settings.hooks) settings.hooks = {};
374
417
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
375
- const hasPreCommitHook = settings.hooks.PreToolUse.some(
376
- (h) => h.matcher === "Bash(git commit:*)",
377
- );
418
+ // PreToolUse matchers are regexes over the TOOL NAME only, so the correct
419
+ // matcher is "Bash". The pre-v11.4.1 entry used "Bash(git commit:*)",
420
+ // which never matches any tool name - the secret gate never fired.
421
+ // Migrate that dead entry in place instead of stacking a duplicate.
422
+ // Command-level filtering (only scan on actual `git commit`) happens
423
+ // inside pre-commit-check.sh via the hook's stdin JSON.
424
+ const isPreCommitEntry = (h) =>
425
+ Array.isArray(h.hooks) &&
426
+ h.hooks.some(
427
+ (sub) => typeof sub.command === "string" && sub.command.includes("pre-commit-check.sh"),
428
+ );
429
+ let hasPreCommitHook = false;
430
+ for (const h of settings.hooks.PreToolUse) {
431
+ if (!isPreCommitEntry(h)) continue;
432
+ if (h.matcher === LEGACY_PRE_COMMIT_MATCHER) {
433
+ h.matcher = "Bash";
434
+ settingsChanged = true;
435
+ }
436
+ if (h.matcher === "Bash") hasPreCommitHook = true;
437
+ }
378
438
  if (!hasPreCommitHook) {
379
439
  settings.hooks.PreToolUse.push({
380
- matcher: "Bash(git commit:*)",
440
+ matcher: "Bash",
381
441
  hooks: [
382
442
  {
383
443
  type: "command",