@dalzoubi/dev-agents-sync 2.0.25 → 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 +4 -4
- 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/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
|
|
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
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
+
];
|