@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/README.md +35 -25
- package/package.json +1 -1
- package/src/commands/init.mjs +19 -7
- package/src/commands/update.mjs +22 -11
- package/src/hooks.mjs +38 -1
- package/src/settings.mjs +203 -46
- package/tests/hook-retirement.test.mjs +684 -0
- package/tests/hooks-cli-install.test.mjs +67 -0
- package/tests/rules-injector-registration.test.mjs +523 -84
- package/tests/rules-injector-runtime.test.mjs +0 -313
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
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
170
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
+
];
|