@dalzoubi/dev-agents-sync 2.0.25 → 2.0.27

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.
package/README.md CHANGED
@@ -109,12 +109,11 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
109
109
 
110
110
  ### Hook scripts (managed)
111
111
 
112
- `init` and `update` install four hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
112
+ `init` and `update` install three hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
113
113
 
114
114
  | Script | Claude Code event | Purpose |
115
115
  |---|---|---|
116
116
  | `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
117
- | `rules-injector.sh` | `SessionStart` | Injects the always-on shared rule bodies (`structured-choices`, `tone`) from `.claude/rules/` into every session; fail-safe (skips absent rules, exits 0). |
118
117
  | `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
119
118
  | `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
120
119
 
@@ -122,11 +121,12 @@ Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX
122
121
 
123
122
  ### settings.json auto-wiring
124
123
 
125
- By default, `init` and `update` also register the four hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
124
+ By default, `init` and `update` also register the three hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
126
125
 
127
126
  - **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
128
127
  - **Idempotent** — running `update` twice produces no duplicate entries.
129
128
  - **Tagged** — each injected entry is marked as CLI-managed so it can be identified and removed later without disturbing consumer-authored hooks.
129
+ - **Self-cleaning** — when the toolkit retires a hook, `update` also prunes the CLI-managed registration for it, so no consumer is left with an entry pointing at a script that no longer ships. Only tagged entries are pruned, and only the retired command within them: a consumer-authored entry for the same command is left untouched, and a command you added yourself inside a tagged entry survives alongside. The prune is reported on stderr (`info: dev-agents-sync: pruned N retired hook registration(s)`, counting registrations removed) and is computed — and surfaced in the `--dry-run` result — without writing anything. `rules-injector.sh` is the first retired hook — Claude Code now loads `.claude/rules/*.md` natively, so the hook is redundant.
130
130
 
131
131
  > **Carve-out.** `settings.json` is a consumer-owned configuration file, **not** a marker-managed file. The CLI never replaces it wholesale; it only merges in its own tagged hook registrations and otherwise leaves the file alone. If `settings.json` contains malformed JSON, `update` fails loud with the stable code `ESETTINGS_PARSE` and leaves the file byte-identical — it will never half-write or clobber an unparseable file.
132
132
 
@@ -136,7 +136,7 @@ To install the hook scripts but skip the `settings.json` merge entirely, pass `-
136
136
  npx --yes @dalzoubi/dev-agents-sync@1 update --no-hooks-wiring
137
137
  ```
138
138
 
139
- With `--no-hooks-wiring`, the hook scripts are still installed under `.claude/hooks/`, but `settings.json` is neither created nor modified — wire the hooks yourself if you want them active.
139
+ With `--no-hooks-wiring`, the hook scripts are still installed under `.claude/hooks/`, but `settings.json` is neither created nor modified — wire the hooks yourself if you want them active. Note that the flag skips the whole merge, **including the self-cleaning de-registration above**: a registration for a retired hook stays in your `settings.json` until you run without the flag or remove it yourself.
140
140
 
141
141
  ## Agents
142
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.25",
3
+ "version": "2.0.27",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -17,8 +17,7 @@ import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writ
17
17
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
18
18
  import { injectClaudeMd } from '../claudeMd.mjs';
19
19
  import { seedSteering, steeringSummary } from '../steering.mjs';
20
- import { mergeHookSettings } from '../settings.mjs';
21
- import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
20
+ import { collectHookPaths, hookScriptsForInstalled, wireHookSettings } from '../hooks.mjs';
22
21
 
23
22
  const _require = createRequire(import.meta.url);
24
23
  const CLI_VERSION = _require('../../package.json').version;
@@ -134,6 +133,7 @@ export async function runInit(consumerCwd, opts = {}) {
134
133
  const claudeMdPath = path.join(consumerCwd, 'CLAUDE.md');
135
134
 
136
135
  if (dryRun) {
136
+ const plannedKeys = plannedWrites.map(({ relKey }) => relKey);
137
137
  // Run inject in dry-run mode to compute the planned change.
138
138
  const claudeMdResult = routingContent
139
139
  ? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
@@ -144,13 +144,24 @@ export async function runInit(consumerCwd, opts = {}) {
144
144
  ? seedSteering(consumerCwd, { dryRun: true })
145
145
  : null;
146
146
 
147
+ // Compute the planned settings.json merge too, mirroring update's dry-run
148
+ // branch: skipping it would under-report a real, intended change to
149
+ // consumer-owned state — and `init` over an existing .claude/ can meet a
150
+ // pre-retirement settings.json (same exposure the wiring call below cites).
151
+ // The merge writes nothing in dry-run, so settings.json stays byte-identical.
152
+ const plannedPrunedRetired =
153
+ claudeTargetActive && !noHooksWiring
154
+ ? wireHookSettings(consumerCwd, hookScriptsForInstalled(plannedKeys), { dryRun: true })
155
+ : [];
156
+
147
157
  return {
148
158
  dryRun: true,
149
159
  resolvedVersion,
150
160
  targets: resolvedTargets,
151
161
  plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
152
- files: plannedWrites.map(({ relKey }) => relKey),
162
+ files: plannedKeys,
153
163
  claudeMd: claudeMdResult,
164
+ prunedRetired: plannedPrunedRetired,
154
165
  steering,
155
166
  };
156
167
  }
@@ -203,11 +214,11 @@ export async function runInit(consumerCwd, opts = {}) {
203
214
  // additive + idempotent and fails loud (ESETTINGS_PARSE) on a malformed file.
204
215
  const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
205
216
  const hookPaths = collectHookPaths(installedHookKeys);
217
+ let prunedRetired = [];
206
218
  if (claudeTargetActive && !noHooksWiring) {
207
- const hookSpec = hookScriptsForInstalled(installedHookKeys);
208
- if (hookSpec.length > 0) {
209
- mergeHookSettings(consumerCwd, hookSpec);
210
- }
219
+ // Unconditional on the spec being non-empty: the prune half needs no spec,
220
+ // and `init` over an existing .claude/ can meet a pre-retirement settings.json.
221
+ prunedRetired = wireHookSettings(consumerCwd, hookScriptsForInstalled(installedHookKeys));
211
222
  }
212
223
 
213
224
  writeLockfile(consumerCwd, {
@@ -225,6 +236,7 @@ export async function runInit(consumerCwd, opts = {}) {
225
236
  plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
226
237
  files: plannedWrites.map(({ relKey }) => relKey),
227
238
  hooks: hookPaths,
239
+ prunedRetired,
228
240
  steering,
229
241
  };
230
242
  }
@@ -15,8 +15,8 @@ import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writ
15
15
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
16
16
  import { injectClaudeMd } from '../claudeMd.mjs';
17
17
  import { seedSteering, steeringSummary } from '../steering.mjs';
18
- import { mergeHookSettings, DEFAULT_HOOK_SCRIPTS } from '../settings.mjs';
19
- import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
18
+ import { DEFAULT_HOOK_SCRIPTS } from '../settings.mjs';
19
+ import { collectHookPaths, hookScriptsForInstalled, wireHookSettings } from '../hooks.mjs';
20
20
 
21
21
  const _require = createRequire(import.meta.url);
22
22
  const CLI_VERSION = _require('../../package.json').version;
@@ -144,19 +144,21 @@ export async function runUpdate(consumerCwd, opts = {}) {
144
144
  // here (the short-circuit's whole point is to avoid a fetch); instead we wire
145
145
  // only the hook scripts that already exist on disk. The merge is additive +
146
146
  // idempotent, so running twice == once. --dry-run still writes nothing.
147
- if (claudeTargetActive && !noHooksWiring && !dryRun) {
147
+ let prunedRetired = [];
148
+ if (claudeTargetActive && !noHooksWiring) {
148
149
  const hookSpec = DEFAULT_HOOK_SCRIPTS.filter((h) =>
149
150
  existsSync(path.join(consumerCwd, h.command)),
150
151
  );
151
- if (hookSpec.length > 0) {
152
- mergeHookSettings(consumerCwd, hookSpec);
153
- }
152
+ // Unconditional on hookSpec.length: the prune needs no spec, so a consumer
153
+ // with no hook scripts on disk still gets retired registrations removed.
154
+ prunedRetired = wireHookSettings(consumerCwd, hookSpec, { dryRun });
154
155
  }
155
156
 
156
157
  return {
157
158
  upToDate: true,
158
159
  resolvedVersion: lock.resolvedVersion,
159
160
  message: `already up to date at v${lock.resolvedVersion}`,
161
+ prunedRetired,
160
162
  steering,
161
163
  };
162
164
  }
@@ -185,18 +187,27 @@ export async function runUpdate(consumerCwd, opts = {}) {
185
187
  const orphans = computeOrphans(consumerCwd, scopedKeys, lock.targets);
186
188
 
187
189
  if (dryRun) {
190
+ const plannedKeys = plannedWrites.map(({ relKey }) => relKey);
188
191
  // Compute the planned CLAUDE.md change so callers can inspect it without
189
192
  // the result shape silently diverging from the init --dry-run contract.
190
193
  const claudeMdResult = routingContent
191
194
  ? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
192
195
  : null;
196
+ // Compute the planned settings.json merge too — a dry run that skipped it
197
+ // would under-report a real, intended change to consumer-owned state. The
198
+ // merge writes nothing in dry-run, so settings.json stays byte-identical.
199
+ const plannedPrunedRetired =
200
+ claudeTargetActive && !noHooksWiring
201
+ ? wireHookSettings(consumerCwd, hookScriptsForInstalled(plannedKeys), { dryRun: true })
202
+ : [];
193
203
  return {
194
204
  dryRun: true,
195
205
  resolvedVersion: targetVersion,
196
206
  previousVersion: lock.resolvedVersion,
197
- files: plannedWrites.map(({ relKey }) => relKey),
207
+ files: plannedKeys,
198
208
  claudeMd: claudeMdResult,
199
209
  plannedPrune: orphans,
210
+ prunedRetired: plannedPrunedRetired,
200
211
  steering,
201
212
  };
202
213
  }
@@ -239,11 +250,10 @@ export async function runUpdate(consumerCwd, opts = {}) {
239
250
  // on a malformed settings.json, leaving it byte-identical.
240
251
  const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
241
252
  const hookPaths = collectHookPaths(installedHookKeys);
253
+ let prunedRetired = [];
242
254
  if (claudeTargetActive && !noHooksWiring) {
243
- const hookSpec = hookScriptsForInstalled(installedHookKeys);
244
- if (hookSpec.length > 0) {
245
- mergeHookSettings(consumerCwd, hookSpec);
246
- }
255
+ // Unconditional on the spec being non-empty: the prune half needs no spec.
256
+ prunedRetired = wireHookSettings(consumerCwd, hookScriptsForInstalled(installedHookKeys));
247
257
  }
248
258
 
249
259
  // Prune orphans: marker-bearing managed files no longer shipped. Delete in
@@ -293,6 +303,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
293
303
  hooks: hookPaths,
294
304
  pruned,
295
305
  prunedCount: pruned.length,
306
+ prunedRetired,
296
307
  summary: pruneSummary,
297
308
  steering,
298
309
  };
package/src/hooks.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * CLI only wires hooks that genuinely shipped in the resolved version.
10
10
  */
11
11
 
12
- import { DEFAULT_HOOK_SCRIPTS } from './settings.mjs';
12
+ import { DEFAULT_HOOK_SCRIPTS, mergeHookSettings } from './settings.mjs';
13
13
 
14
14
  /** True for a FileMap key that is a managed hook script (claude/hooks/*.sh). */
15
15
  function isHookKey(relKey) {
@@ -45,3 +45,40 @@ export function hookScriptsForInstalled(relKeys) {
45
45
  const installed = new Set(collectHookPaths(relKeys));
46
46
  return DEFAULT_HOOK_SCRIPTS.filter((h) => installed.has(h.command));
47
47
  }
48
+
49
+ /**
50
+ * Merges `hookScripts` into the consumer's .claude/settings.json and reports any
51
+ * retired registrations the merge de-registered. Shared by init and update so
52
+ * all three wiring call sites report identically.
53
+ *
54
+ * Called even when `hookScripts` is EMPTY: the prune half of the merge needs no
55
+ * spec, so a consumer with no hook scripts on disk still gets stale
56
+ * registrations for retired hooks cleaned up. With nothing to add and nothing to
57
+ * prune the merge is a no-op that writes nothing — it does not create the file.
58
+ *
59
+ * De-registration is a real edit to consumer-owned state, so it is never silent
60
+ * (§6): it emits the same `info: dev-agents-sync: ...` line as update's
61
+ * orphaned-file prune. Nothing is printed under --dry-run (planning is not
62
+ * doing) or when nothing was pruned (no spurious line on every run).
63
+ *
64
+ * The line reports `prunedRetiredCount` — REGISTRATIONS removed — not
65
+ * `prunedRetired.length`, which is the same list deduped by command. One retired
66
+ * command registered under two event buckets is two registrations gone from the
67
+ * consumer's file; saying "1" there would under-report the edit we just made.
68
+ *
69
+ * @returns {string[]} the DISTINCT retired commands de-registered (or, in
70
+ * dry-run, that would be de-registered) by this call. Always an array. Callers
71
+ * match on it ("was X pruned?"); the user-facing count stays inside this
72
+ * function, which is the toolkit's single reporting site for the prune.
73
+ */
74
+ export function wireHookSettings(consumerCwd, hookScripts, { dryRun = false } = {}) {
75
+ const { prunedRetired, prunedRetiredCount } = mergeHookSettings(consumerCwd, hookScripts, {
76
+ dryRun,
77
+ });
78
+ if (!dryRun && prunedRetiredCount > 0) {
79
+ process.stderr.write(
80
+ `info: dev-agents-sync: pruned ${prunedRetiredCount} retired hook registration(s)\n`,
81
+ );
82
+ }
83
+ return prunedRetired;
84
+ }
package/src/settings.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Invariants enforced here:
10
10
  * - Additive: every existing setting and every consumer-authored hook is
11
- * preserved untouched. We only append our own registrations.
11
+ * preserved untouched. We only append our own registrations — and remove
12
+ * our own registrations for RETIRED_HOOK_SCRIPTS, never anyone else's.
12
13
  * - Idempotent (§9): a second merge adds no duplicates. Identity is the
13
14
  * installed command path, so re-running is a no-op.
14
15
  * - Tagged (§4): each injected hook entry carries `HOOK_TAG: true` so it is
@@ -106,17 +107,25 @@ function readSettings(absPath) {
106
107
  }
107
108
  }
108
109
 
110
+ /**
111
+ * The command strings carried by one CLI-managed settings entry, in order, or []
112
+ * when the entry is absent, malformed, or consumer-authored.
113
+ *
114
+ * The HOOK_TAG check lives here rather than at each call site because EVERY read
115
+ * path — the idempotence check and the retirement prune alike — must ignore a
116
+ * consumer-authored entry pointing at the very same command: that is
117
+ * consumer-owned state and must survive untouched (spec risk 6).
118
+ */
119
+ function managedCommandsIn(entry) {
120
+ if (!entry || entry[HOOK_TAG] !== true) return [];
121
+ const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
122
+ return hookList.filter((h) => h && typeof h.command === 'string').map((h) => h.command);
123
+ }
124
+
109
125
  /** True if `event` already has a CLI-managed entry whose command targets `command`. */
110
126
  function hasManagedCommand(eventEntries, command) {
111
127
  if (!Array.isArray(eventEntries)) return false;
112
- for (const entry of eventEntries) {
113
- if (!entry || entry[HOOK_TAG] !== true) continue;
114
- const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
115
- for (const h of hookList) {
116
- if (h && typeof h.command === 'string' && h.command === command) return true;
117
- }
118
- }
119
- return false;
128
+ return eventEntries.some((entry) => managedCommandsIn(entry).includes(command));
120
129
  }
121
130
 
122
131
  /**
@@ -131,48 +140,174 @@ function buildManagedEntry({ matcher, command }) {
131
140
  };
132
141
  }
133
142
 
143
+ /** True if `command` is on the retired list (exact match, never a prefix/substring). */
144
+ function isRetiredCommand(command) {
145
+ return RETIRED_HOOK_SCRIPTS.some((h) => h.command === command);
146
+ }
147
+
134
148
  /**
135
- * Computes the merged settings object additively. Pure — does not touch disk.
136
- * Returns { settings, changed }. Fails loud (ESETTINGS_PARSE) if the consumer's
137
- * `hooks` sub-tree has the wrong internal shape, BEFORE any merge — so no write
138
- * happens and the file stays byte-identical.
149
+ * True for a single `hooks[]` element that invokes a retired command.
150
+ *
151
+ * Shared by the two halves of the prune — the one that REPORTS what was removed
152
+ * (via `retiredCommandsIn`, which applies `isRetiredCommand` to the same
153
+ * `command`/shape guards `managedCommandsIn` enforces) and the one that DOES the
154
+ * removing. Those two must agree exactly or the prune would report a command it
155
+ * left in place, so the predicate is named once rather than spelled out twice.
156
+ */
157
+ function isRetiredHook(hook) {
158
+ return Boolean(hook) && typeof hook.command === 'string' && isRetiredCommand(hook.command);
159
+ }
160
+
161
+ /** The retired commands carried by a single CLI-managed settings entry, or [] if none. */
162
+ function retiredCommandsIn(entry) {
163
+ return managedCommandsIn(entry).filter(isRetiredCommand);
164
+ }
165
+
166
+ /**
167
+ * Removes managed registrations for retired hooks from `hooks` IN PLACE, across
168
+ * every event bucket.
169
+ *
170
+ * Returns two deliberately different views of the same prune:
171
+ * - `commands` — the distinct retired commands removed (deduped, first-seen
172
+ * order, so the result is deterministic §9). Callers answer "was X
173
+ * de-registered?" from this.
174
+ * - `count` — how many registrations were actually removed. One command can be
175
+ * registered under several event buckets (a pre-retirement CLI, or a
176
+ * hand-edit), so this is >= `commands.length`. It is the number a consumer
177
+ * must read on their terminal: reporting the deduped length there would
178
+ * under-count a real, multi-bucket edit to consumer-owned state (§6).
179
+ *
180
+ * Scans all buckets rather than only the ones named in RETIRED_HOOK_SCRIPTS: a
181
+ * pre-retirement CLI may have registered the command under a different event,
182
+ * and a stale registration pointing at a script that no longer ships fails
183
+ * every session with no detection (§5).
184
+ *
185
+ * The prune is HOOK-granular, not entry-granular: it filters `entry.hooks` down
186
+ * to its non-retired commands and drops the entry only once that array empties.
187
+ * `buildManagedEntry` only ever emits one command per entry, so the CLI cannot
188
+ * produce a multi-command entry itself — but a multi-command `hooks[]` is
189
+ * idiomatic Claude Code settings shape, and a consumer hand-adding their own
190
+ * command into our tagged entry produces exactly that. Dropping the whole entry
191
+ * would delete their command permanently (nothing managed re-adds it), breaking
192
+ * the module invariant above — and would equally un-register a still-shipped
193
+ * managed command sharing the entry, which `appendMissingEntries` has already
194
+ * seen as present and so will not re-add.
195
+ *
196
+ * Entries are REPLACED, never mutated, so the objects and arrays inside the
197
+ * consumer's parsed settings are never written through.
198
+ */
199
+ function pruneRetiredEntries(hooks) {
200
+ const pruned = [];
201
+ for (const [event, entries] of Object.entries(hooks)) {
202
+ if (!Array.isArray(entries)) continue;
203
+ let changed = false;
204
+ const kept = [];
205
+ for (const entry of entries) {
206
+ const retired = retiredCommandsIn(entry);
207
+ if (retired.length === 0) {
208
+ // Untagged (consumer-authored) or carrying nothing retired — untouched.
209
+ kept.push(entry);
210
+ continue;
211
+ }
212
+ pruned.push(...retired);
213
+ changed = true;
214
+ // `retired` being non-empty means managedCommandsIn found commands, which
215
+ // it only does for a tagged entry whose `hooks` is an array — so this is
216
+ // reachable only when `entry.hooks` is safe to filter.
217
+ const survivors = entry.hooks.filter((h) => !isRetiredHook(h));
218
+ // Only drop the entry outright once nothing of it is left.
219
+ if (survivors.length > 0) kept.push({ ...entry, hooks: survivors });
220
+ }
221
+ if (!changed) continue;
222
+ if (kept.length > 0) {
223
+ hooks[event] = kept;
224
+ } else {
225
+ // Drop the now-empty event bucket so we don't leave a dangling key.
226
+ delete hooks[event];
227
+ }
228
+ }
229
+ return { commands: [...new Set(pruned)], count: pruned.length };
230
+ }
231
+
232
+ /**
233
+ * Appends a managed entry for every registration in `hookScripts` not already
234
+ * wired, mutating the caller's `hooks` copy IN PLACE, and returns true when at
235
+ * least one was added.
236
+ *
237
+ * Event arrays are REPLACED, never mutated, so the arrays inside the consumer's
238
+ * parsed settings are never written through.
239
+ */
240
+ function appendMissingEntries(hooks, hookScripts) {
241
+ let added = false;
242
+ for (const { event, matcher, command } of hookScripts) {
243
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
244
+ // Already wired — idempotent no-op for this registration (§9).
245
+ if (hasManagedCommand(existing, command)) continue;
246
+ hooks[event] = [...existing, buildManagedEntry({ matcher, command })];
247
+ added = true;
248
+ }
249
+ return added;
250
+ }
251
+
252
+ /**
253
+ * Assigns `hooks` onto `settings` IN PLACE, dropping the key outright when no
254
+ * bucket survived, so neither write path can leave a dangling `"hooks": {}`
255
+ * behind. Both the merge and the removal path empty buckets and so must treat
256
+ * that end state identically; callers pass a shallow clone they own.
257
+ */
258
+ function assignHooks(settings, hooks) {
259
+ if (Object.keys(hooks).length > 0) {
260
+ settings.hooks = hooks;
261
+ } else {
262
+ delete settings.hooks;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Computes the merged settings object: the additive pass, then a prune of
268
+ * managed registrations for retired hooks. Pure — does not touch disk.
269
+ * Returns { settings, changed, prunedRetired, prunedRetiredCount }. Fails loud (ESETTINGS_PARSE) if
270
+ * the consumer's `hooks` sub-tree has the wrong internal shape, BEFORE any merge
271
+ * or prune — so no write happens and the file stays byte-identical.
139
272
  */
140
273
  function computeMerge(current, hookScripts, absPath) {
141
274
  // Validate the internal shape of the consumer's hooks before merging so a
142
275
  // wrong-shape value is never silently corrupted or discarded (§5/§12).
143
276
  assertHooksShape(current.hooks, absPath);
144
277
 
145
- // Shallow clone so we never mutate the caller's object; hooks is the only
146
- // sub-tree we touch and we clone it per-event below before appending.
278
+ // Shallow clone so we never mutate the caller's object; `hooks` is the only
279
+ // sub-tree we touch, and both passes below replace event arrays rather than
280
+ // writing through to the consumer's own arrays.
147
281
  const next = { ...current };
148
282
  const hooks = { ...(next.hooks ?? {}) };
149
- let changed = false;
150
283
 
151
- for (const { event, matcher, command } of hookScripts) {
152
- const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
153
- if (hasManagedCommand(existing, command)) {
154
- // Already wired — idempotent no-op for this registration.
155
- hooks[event] = existing;
156
- continue;
157
- }
158
- hooks[event] = [...existing, buildManagedEntry({ matcher, command })];
159
- changed = true;
160
- }
284
+ const added = appendMissingEntries(hooks, hookScripts);
285
+ // Merge-then-prune: retirement is subtractive and runs AFTER the additive
286
+ // pass, so a command that is both shipped and retired cannot survive by
287
+ // being re-added in the same run.
288
+ const { commands: prunedRetired, count: prunedRetiredCount } = pruneRetiredEntries(hooks);
161
289
 
162
- if (changed) {
163
- next.hooks = hooks;
164
- }
165
- return { settings: next, changed };
290
+ const changed = added || prunedRetiredCount > 0;
291
+ if (changed) assignHooks(next, hooks);
292
+ return { settings: next, changed, prunedRetired, prunedRetiredCount };
166
293
  }
167
294
 
168
295
  /**
169
- * Idempotent, additive merge of the given hook registrations into the
170
- * consumer's `.claude/settings.json`.
296
+ * Idempotent merge of the given hook registrations into the consumer's
297
+ * `.claude/settings.json`: additive for shipped hooks, then a prune of managed
298
+ * registrations for retired ones (RETIRED_HOOK_SCRIPTS).
171
299
  *
172
300
  * @param {string} consumerCwd
173
301
  * @param {{ event: string, matcher: string, command: string }[]} hookScripts
174
302
  * @param {{ dryRun?: boolean }} [opts]
175
- * @returns {{ merged: boolean, dryRun: boolean, settingsPath: string }}
303
+ * @returns {{ merged: boolean, dryRun: boolean, settingsPath: string, prunedRetired: string[], prunedRetiredCount: number }}
304
+ * `merged` is true when EITHER the additive pass or the prune changed the
305
+ * settings. `prunedRetired` lists the DISTINCT retired commands this call
306
+ * removed and is always an array — empty when nothing was pruned — so a caller
307
+ * can tell "no retired entries existed" apart from "retirement isn't
308
+ * supported". `prunedRetiredCount` is how many registrations were removed to
309
+ * get there: >= `prunedRetired.length`, since one command can be registered
310
+ * under several event buckets. Report the COUNT to users; match on the LIST.
176
311
  * @throws {SettingsParseError} with .code === 'ESETTINGS_PARSE' on malformed settings.json
177
312
  */
178
313
  export function mergeHookSettings(consumerCwd, hookScripts, opts = {}) {
@@ -182,20 +317,24 @@ export function mergeHookSettings(consumerCwd, hookScripts, opts = {}) {
182
317
  // Parse first — a malformed file fails loud BEFORE any write so the consumer's
183
318
  // file is never clobbered (§5/§12).
184
319
  const current = readSettings(absPath);
185
- const { settings, changed } = computeMerge(current, hookScripts, absPath);
320
+ const { settings, changed, prunedRetired, prunedRetiredCount } = computeMerge(
321
+ current,
322
+ hookScripts,
323
+ absPath,
324
+ );
186
325
 
187
326
  if (dryRun) {
188
- return { merged: changed, dryRun: true, settingsPath: absPath };
327
+ return { merged: changed, dryRun: true, settingsPath: absPath, prunedRetired, prunedRetiredCount };
189
328
  }
190
329
 
191
330
  if (!changed) {
192
331
  // Nothing to do — leave the file byte-identical (idempotent §9).
193
- return { merged: false, dryRun: false, settingsPath: absPath };
332
+ return { merged: false, dryRun: false, settingsPath: absPath, prunedRetired, prunedRetiredCount };
194
333
  }
195
334
 
196
335
  mkdirSync(path.dirname(absPath), { recursive: true });
197
336
  writeFileSync(absPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
198
- return { merged: true, dryRun: false, settingsPath: absPath };
337
+ return { merged: true, dryRun: false, settingsPath: absPath, prunedRetired, prunedRetiredCount };
199
338
  }
200
339
 
201
340
  /**
@@ -230,11 +369,10 @@ export function removeHookSettings(consumerCwd, opts = {}) {
230
369
  }
231
370
  const kept = entries.filter((entry) => !(entry && entry[HOOK_TAG] === true));
232
371
  if (kept.length !== entries.length) changed = true;
372
+ // An emptied bucket is simply not carried over, so we don't leave a dangling
373
+ // key; `changed` is already true in that case via the length check above.
233
374
  if (kept.length > 0) {
234
375
  nextHooks[event] = kept;
235
- } else {
236
- // Drop the now-empty event bucket so we don't leave dangling keys.
237
- changed = changed || entries.length > 0;
238
376
  }
239
377
  }
240
378
 
@@ -243,11 +381,7 @@ export function removeHookSettings(consumerCwd, opts = {}) {
243
381
  }
244
382
 
245
383
  const next = { ...current };
246
- if (Object.keys(nextHooks).length > 0) {
247
- next.hooks = nextHooks;
248
- } else {
249
- delete next.hooks;
250
- }
384
+ assignHooks(next, nextHooks);
251
385
 
252
386
  if (dryRun) {
253
387
  return { removed: true, dryRun: true, settingsPath: absPath };
@@ -263,7 +397,30 @@ export function removeHookSettings(consumerCwd, opts = {}) {
263
397
  */
264
398
  export const DEFAULT_HOOK_SCRIPTS = [
265
399
  { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
266
- { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/rules-injector.sh' },
267
400
  { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
268
401
  { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
269
402
  ];
403
+
404
+ /**
405
+ * Hook registrations the toolkit once shipped and has since retired.
406
+ *
407
+ * On every `init`/`update`, `mergeHookSettings` removes any CLI-managed entry
408
+ * whose command appears here. Without it, deleting a hook script would leave a
409
+ * live registration pointing at a file that no longer exists — a per-session
410
+ * failure with no detection and no recovery path (§5).
411
+ *
412
+ * APPEND-ONLY, and deliberately separate from DEFAULT_HOOK_SCRIPTS. A consumer
413
+ * may upgrade from any older version, so this list has to name every command
414
+ * the CLI has ever registered and no longer ships — removing an entry once
415
+ * published would strand that registration in every consumer who had not yet
416
+ * updated past it. Being additive is also what makes the change §8-compatible:
417
+ * an older CLI reading newer content simply does not prune rather than erroring.
418
+ * Reversing a retirement is done by moving the entry back to
419
+ * DEFAULT_HOOK_SCRIPTS, not by silently dropping it from here.
420
+ */
421
+ export const RETIRED_HOOK_SCRIPTS = [
422
+ // Retired by docs/specs/260724-opus5-native-overlap/ (S1): Claude Code loads
423
+ // `.claude/rules/*.md` natively as project instructions, which is strictly
424
+ // broader than this hook's fixed injection list.
425
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/rules-injector.sh' },
426
+ ];