@dalzoubi/dev-agents-sync 2.0.24 → 2.0.26

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/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
+ ];