@dalzoubi/dev-agents-sync 2.0.15 → 2.0.17

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
@@ -99,6 +99,44 @@ Show lockfile status:
99
99
  npx --yes @dalzoubi/dev-agents-sync@1 status
100
100
  ```
101
101
 
102
+ ## Steering and hooks
103
+
104
+ For the `claude` target, `init` and `update` set up two extra pieces of project memory and automation.
105
+
106
+ ### Steering scaffold (consumer-owned)
107
+
108
+ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions.md`, `glossary.md`, and `architecture.md` — on first run. These files are **consumer-owned**: they carry no managed-by marker, are never tracked in the lockfile, and are **never overwritten** once they exist. Agents read all three at session start and append to them; the toolkit only seeds the blank files once. Editing them is expected.
109
+
110
+ ### Hook scripts (managed)
111
+
112
+ `init` and `update` install three hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
113
+
114
+ | Script | Claude Code event | Purpose |
115
+ |---|---|---|
116
+ | `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
117
+ | `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
118
+ | `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
119
+
120
+ Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX.Y.Z`. Like other managed files, the CLI overwrites them on `update` and `check` reports drift (exit 1) if one is deleted or locally modified.
121
+
122
+ ### settings.json auto-wiring
123
+
124
+ By default, `init` and `update` also register the three hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
125
+
126
+ - **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
127
+ - **Idempotent** — running `update` twice produces no duplicate entries.
128
+ - **Tagged** — each injected entry is marked as CLI-managed so it can be identified and removed later without disturbing consumer-authored hooks.
129
+
130
+ > **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.
131
+
132
+ To install the hook scripts but skip the `settings.json` merge entirely, pass `--no-hooks-wiring`:
133
+
134
+ ```bash
135
+ npx --yes @dalzoubi/dev-agents-sync@1 update --no-hooks-wiring
136
+ ```
137
+
138
+ 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
+
102
140
  ## Agents
103
141
 
104
142
  Syncing installs six agents that form a spec-driven workflow:
@@ -137,6 +175,7 @@ and build if relevant) and summarizes what changed. It does not push or open a P
137
175
  - `--range ^1` selects the content version range. The default is `^1`.
138
176
  - `--dry-run` prints the planned changes without writing files.
139
177
  - `--force` allows overwriting unmanaged file collisions.
178
+ - `--no-hooks-wiring` installs the hook scripts but skips the `.claude/settings.json` merge — see [Steering and hooks](#steering-and-hooks).
140
179
  - `--token <token>` passes a GitHub token directly.
141
180
 
142
181
  ## Safety
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
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",
package/src/cli.mjs CHANGED
@@ -109,6 +109,7 @@ async function main() {
109
109
  range: typeof flags.range === 'string' ? flags.range : undefined,
110
110
  force: Boolean(flags.force),
111
111
  dryRun: Boolean(flags['dry-run']),
112
+ noHooksWiring: Boolean(flags['no-hooks-wiring']),
112
113
  };
113
114
 
114
115
  try {
@@ -16,6 +16,9 @@ import { resolveRange } from '../range.mjs';
16
16
  import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
17
17
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
18
18
  import { injectClaudeMd } from '../claudeMd.mjs';
19
+ import { seedSteering, steeringSummary } from '../steering.mjs';
20
+ import { mergeHookSettings } from '../settings.mjs';
21
+ import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
19
22
 
20
23
  const _require = createRequire(import.meta.url);
21
24
  const CLI_VERSION = _require('../../package.json').version;
@@ -84,6 +87,7 @@ export async function runInit(consumerCwd, opts = {}) {
84
87
  token,
85
88
  fetcher,
86
89
  availableTags,
90
+ noHooksWiring = false,
87
91
  source = DEFAULT_SOURCE,
88
92
  repo = 'dalzoubi/dev-agents',
89
93
  } = opts;
@@ -135,6 +139,11 @@ export async function runInit(consumerCwd, opts = {}) {
135
139
  ? injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent, { dryRun: true })
136
140
  : null;
137
141
 
142
+ // Steering seed plan (claude target only). seed-if-absent; writes nothing in dry-run.
143
+ const steering = claudeTargetActive
144
+ ? seedSteering(consumerCwd, { dryRun: true })
145
+ : null;
146
+
138
147
  return {
139
148
  dryRun: true,
140
149
  resolvedVersion,
@@ -142,6 +151,7 @@ export async function runInit(consumerCwd, opts = {}) {
142
151
  plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
143
152
  files: plannedWrites.map(({ relKey }) => relKey),
144
153
  claudeMd: claudeMdResult,
154
+ steering,
145
155
  };
146
156
  }
147
157
 
@@ -179,11 +189,33 @@ export async function runInit(consumerCwd, opts = {}) {
179
189
  injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
180
190
  }
181
191
 
192
+ // Seed the consumer-owned steering scaffold (claude target only). Seed-if-absent:
193
+ // never overwrites edited steering, never recorded in the lockfile (it is unmanaged
194
+ // by design). Fails loud with ESTEER_WRITE on a write error.
195
+ let steering = null;
196
+ if (claudeTargetActive) {
197
+ steering = seedSteering(consumerCwd);
198
+ process.stderr.write(`info: dev-agents-sync: ${steeringSummary(steering)}\n`);
199
+ }
200
+
201
+ // Wire the installed hook scripts into .claude/settings.json (claude target
202
+ // only), unless the consumer opted out with --no-hooks-wiring. The merge is
203
+ // additive + idempotent and fails loud (ESETTINGS_PARSE) on a malformed file.
204
+ const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
205
+ const hookPaths = collectHookPaths(installedHookKeys);
206
+ if (claudeTargetActive && !noHooksWiring) {
207
+ const hookSpec = hookScriptsForInstalled(installedHookKeys);
208
+ if (hookSpec.length > 0) {
209
+ mergeHookSettings(consumerCwd, hookSpec);
210
+ }
211
+ }
212
+
182
213
  writeLockfile(consumerCwd, {
183
214
  source,
184
215
  range,
185
216
  resolvedVersion,
186
217
  targets: [...resolvedTargets].sort(),
218
+ ...(hookPaths.length > 0 ? { hooks: hookPaths } : {}),
187
219
  lastUpdated: new Date().toISOString(),
188
220
  });
189
221
 
@@ -192,5 +224,7 @@ export async function runInit(consumerCwd, opts = {}) {
192
224
  targets: [...resolvedTargets].sort(),
193
225
  plannedWrites: plannedWrites.map(({ relKey, absPath }) => ({ relKey, absPath })),
194
226
  files: plannedWrites.map(({ relKey }) => relKey),
227
+ hooks: hookPaths,
228
+ steering,
195
229
  };
196
230
  }
@@ -14,6 +14,9 @@ import { resolveRange } from '../range.mjs';
14
14
  import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writer.mjs';
15
15
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
16
16
  import { injectClaudeMd } from '../claudeMd.mjs';
17
+ import { seedSteering, steeringSummary } from '../steering.mjs';
18
+ import { mergeHookSettings, DEFAULT_HOOK_SCRIPTS } from '../settings.mjs';
19
+ import { collectHookPaths, hookScriptsForInstalled } from '../hooks.mjs';
17
20
 
18
21
  const _require = createRequire(import.meta.url);
19
22
  const CLI_VERSION = _require('../../package.json').version;
@@ -31,7 +34,15 @@ function filterFileMapByTargets(fileMap, targets) {
31
34
  }
32
35
 
33
36
  export async function runUpdate(consumerCwd, opts = {}) {
34
- const { force = false, dryRun = false, token, fetcher, availableTags, repo = 'dalzoubi/dev-agents' } = opts;
37
+ const {
38
+ force = false,
39
+ dryRun = false,
40
+ token,
41
+ fetcher,
42
+ availableTags,
43
+ noHooksWiring = false,
44
+ repo = 'dalzoubi/dev-agents',
45
+ } = opts;
35
46
 
36
47
  if (typeof fetcher !== 'function') {
37
48
  const err = new Error('runUpdate requires an injected fetcher');
@@ -48,11 +59,40 @@ export async function runUpdate(consumerCwd, opts = {}) {
48
59
 
49
60
  const targetVersion = resolveRange(availableTags, lock.range);
50
61
 
62
+ // Seed the consumer-owned steering scaffold (claude target only), seed-if-absent,
63
+ // BEFORE the up-to-date short-circuit so existing consumers adopt steering on any
64
+ // `update` — not only when a version bump is available. Never overwrites edited
65
+ // steering; never recorded in the lockfile (unmanaged by design); respects --dry-run.
66
+ const claudeTargetActive = lock.targets.includes('claude');
67
+ let steering = null;
68
+ if (claudeTargetActive) {
69
+ steering = seedSteering(consumerCwd, { dryRun });
70
+ if (!dryRun) {
71
+ process.stderr.write(`info: dev-agents-sync: ${steeringSummary(steering)}\n`);
72
+ }
73
+ }
74
+
51
75
  if (targetVersion === lock.resolvedVersion) {
76
+ // Up to date — but mirror the STEERING precedent above: a consumer already
77
+ // pinned to the latest version who never had hooks wired must still get the
78
+ // settings merge so adoption isn't gated on a version bump. We do NOT fetch
79
+ // here (the short-circuit's whole point is to avoid a fetch); instead we wire
80
+ // only the hook scripts that already exist on disk. The merge is additive +
81
+ // idempotent, so running twice == once. --dry-run still writes nothing.
82
+ if (claudeTargetActive && !noHooksWiring && !dryRun) {
83
+ const hookSpec = DEFAULT_HOOK_SCRIPTS.filter((h) =>
84
+ existsSync(path.join(consumerCwd, h.command)),
85
+ );
86
+ if (hookSpec.length > 0) {
87
+ mergeHookSettings(consumerCwd, hookSpec);
88
+ }
89
+ }
90
+
52
91
  return {
53
92
  upToDate: true,
54
93
  resolvedVersion: lock.resolvedVersion,
55
94
  message: `already up to date at v${lock.resolvedVersion}`,
95
+ steering,
56
96
  };
57
97
  }
58
98
 
@@ -68,7 +108,6 @@ export async function runUpdate(consumerCwd, opts = {}) {
68
108
  }
69
109
 
70
110
  // Auto-route content for CLAUDE.md injection (claude target only).
71
- const claudeTargetActive = lock.targets.includes('claude');
72
111
  const routingContent = claudeTargetActive
73
112
  ? (normalized['claude/skills/auto-route.md'] ?? null)
74
113
  : null;
@@ -86,6 +125,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
86
125
  previousVersion: lock.resolvedVersion,
87
126
  files: plannedWrites.map(({ relKey }) => relKey),
88
127
  claudeMd: claudeMdResult,
128
+ steering,
89
129
  };
90
130
  }
91
131
 
@@ -122,11 +162,30 @@ export async function runUpdate(consumerCwd, opts = {}) {
122
162
  injectClaudeMd(claudeMdPath, CLI_VERSION, routingContent);
123
163
  }
124
164
 
165
+ // Wire installed hook scripts into .claude/settings.json (claude target only),
166
+ // unless --no-hooks-wiring. Additive + idempotent; fails loud (ESETTINGS_PARSE)
167
+ // on a malformed settings.json, leaving it byte-identical.
168
+ const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
169
+ const hookPaths = collectHookPaths(installedHookKeys);
170
+ if (claudeTargetActive && !noHooksWiring) {
171
+ const hookSpec = hookScriptsForInstalled(installedHookKeys);
172
+ if (hookSpec.length > 0) {
173
+ mergeHookSettings(consumerCwd, hookSpec);
174
+ }
175
+ }
176
+
125
177
  writeLockfile(consumerCwd, {
126
178
  source: lock.source,
127
179
  range: lock.range,
128
180
  resolvedVersion: targetVersion,
129
181
  targets: lock.targets,
182
+ // Carry forward installed hooks (additive §8). Falls back to any hooks the
183
+ // pre-existing lockfile already recorded when this update shipped none.
184
+ ...(hookPaths.length > 0
185
+ ? { hooks: hookPaths }
186
+ : Array.isArray(lock.hooks)
187
+ ? { hooks: lock.hooks }
188
+ : {}),
130
189
  lastUpdated: new Date().toISOString(),
131
190
  });
132
191
 
@@ -134,5 +193,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
134
193
  resolvedVersion: targetVersion,
135
194
  previousVersion: lock.resolvedVersion,
136
195
  files: plannedWrites.map(({ relKey }) => relKey),
196
+ hooks: hookPaths,
197
+ steering,
137
198
  };
138
199
  }
package/src/hooks.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Hook install helpers shared by `init` and `update`.
3
+ *
4
+ * Hook scripts ride the normal managed-file install path (they arrive in the
5
+ * fetched FileMap under `claude/hooks/*.sh` and are written by writeManagedFile,
6
+ * which also sets the exec bit). These helpers derive, from the set of files
7
+ * that were actually planned/installed, (a) the consumer-relative hook paths to
8
+ * record in the lockfile and (b) the settings.json registration spec — so the
9
+ * CLI only wires hooks that genuinely shipped in the resolved version.
10
+ */
11
+
12
+ import { DEFAULT_HOOK_SCRIPTS } from './settings.mjs';
13
+
14
+ /** True for a FileMap key that is a managed hook script (claude/hooks/*.sh). */
15
+ function isHookKey(relKey) {
16
+ const norm = relKey.replace(/\\/g, '/');
17
+ return /^claude\/hooks\/.+\.sh$/.test(norm);
18
+ }
19
+
20
+ /**
21
+ * Maps a `claude/hooks/foo.sh` FileMap key to its consumer-relative path
22
+ * `.claude/hooks/foo.sh` (the form recorded in the lockfile and used as the
23
+ * settings.json command).
24
+ */
25
+ function toConsumerHookPath(relKey) {
26
+ const norm = relKey.replace(/\\/g, '/');
27
+ return norm.replace(/^claude\//, '.claude/');
28
+ }
29
+
30
+ /**
31
+ * Returns the sorted list of consumer-relative hook script paths present in the
32
+ * given FileMap keys. Used to record installed hooks in the lockfile.
33
+ */
34
+ export function collectHookPaths(relKeys) {
35
+ const paths = relKeys.filter(isHookKey).map(toConsumerHookPath);
36
+ return [...new Set(paths)].sort();
37
+ }
38
+
39
+ /**
40
+ * Returns the settings.json hook registration spec restricted to the hook
41
+ * scripts that were actually installed (present in `relKeys`). Each registration
42
+ * is taken from DEFAULT_HOOK_SCRIPTS so the event/matcher stay authoritative.
43
+ */
44
+ export function hookScriptsForInstalled(relKeys) {
45
+ const installed = new Set(collectHookPaths(relKeys));
46
+ return DEFAULT_HOOK_SCRIPTS.filter((h) => installed.has(h.command));
47
+ }
package/src/lockfile.mjs CHANGED
@@ -19,12 +19,18 @@ export const SCHEMA_URL =
19
19
  const VALID_TARGETS = new Set(['claude', 'cursor', 'copilot', 'codex']);
20
20
 
21
21
  // Field order is load-bearing for determinism.
22
+ //
23
+ // `hooks` is an ADDITIVE, OPTIONAL field (§8): it records the relative paths of
24
+ // the managed hook scripts installed under .claude/hooks/. Pre-hooks lockfiles
25
+ // omit it entirely and still parse/validate — it is never a required field, so
26
+ // the upgrade is non-breaking and needs no migration prompt.
22
27
  const FIELD_ORDER = [
23
28
  '$schema',
24
29
  'source',
25
30
  'range',
26
31
  'resolvedVersion',
27
32
  'targets',
33
+ 'hooks',
28
34
  'lastUpdated',
29
35
  ];
30
36
 
@@ -71,6 +77,13 @@ export function validateLockfile(lock) {
71
77
  );
72
78
  }
73
79
  }
80
+ // `hooks` is optional (additive §8). When present it must be a string array;
81
+ // its absence is valid and must never be treated as an error (back-compat).
82
+ if (lock.hooks !== undefined) {
83
+ if (!Array.isArray(lock.hooks) || lock.hooks.some((h) => typeof h !== 'string')) {
84
+ throw new LockfileError('invalid lockfile: `hooks` must be an array of strings when present');
85
+ }
86
+ }
74
87
  return true;
75
88
  }
76
89
 
@@ -89,6 +102,11 @@ export function serializeLockfile(lock) {
89
102
  if (lock[key] === undefined) continue;
90
103
  if (key === 'targets') {
91
104
  out.targets = [...lock.targets].sort();
105
+ } else if (key === 'hooks') {
106
+ // Optional additive field; only emit when present. Sorted for determinism.
107
+ if (Array.isArray(lock.hooks)) {
108
+ out.hooks = [...lock.hooks].sort();
109
+ }
92
110
  } else {
93
111
  out[key] = lock[key];
94
112
  }
@@ -108,6 +126,8 @@ export function writeLockfile(consumerCwd, lock, _options) {
108
126
  range: lock.range,
109
127
  resolvedVersion: lock.resolvedVersion,
110
128
  targets: lock.targets,
129
+ // Additive optional field — only carried forward when supplied.
130
+ ...(Array.isArray(lock.hooks) ? { hooks: lock.hooks } : {}),
111
131
  lastUpdated: lock.lastUpdated,
112
132
  };
113
133
  validateLockfile(normalized);
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Claude `settings.json` hook auto-wiring.
3
+ *
4
+ * `init`/`update` install the three managed hook scripts into `.claude/hooks/`
5
+ * via the normal managed-file path. This module performs the genuinely-new step:
6
+ * an ADDITIVE, IDEMPOTENT merge of the corresponding hook registrations into the
7
+ * consumer's `.claude/settings.json`.
8
+ *
9
+ * Invariants enforced here:
10
+ * - Additive: every existing setting and every consumer-authored hook is
11
+ * preserved untouched. We only append our own registrations.
12
+ * - Idempotent (§9): a second merge adds no duplicates. Identity is the
13
+ * installed command path, so re-running is a no-op.
14
+ * - Tagged (§4): each injected hook entry carries `HOOK_TAG: true` so it is
15
+ * identifiable and removable later (update/uninstall) without touching
16
+ * consumer-authored entries. Consumer entries never receive the tag.
17
+ * - Fail loud (§5/§12): a malformed `settings.json` surfaces a stable
18
+ * `ESETTINGS_PARSE` code and the file is left byte-identical (no partial
19
+ * write). We never half-write.
20
+ * - Deterministic (§9): stable 2-space JSON + trailing newline, no timestamps.
21
+ * - --dry-run writes nothing.
22
+ * - Privacy (§11): no tokens are ever read or written here.
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ /**
29
+ * Property injected onto every CLI-managed hook entry so it can be identified
30
+ * and removed without disturbing consumer-authored entries. The value is the
31
+ * literal property NAME (the tests look for it in the serialized settings and
32
+ * assert it is absent on consumer entries).
33
+ */
34
+ export const HOOK_TAG = 'managedBy:dev-agents-sync';
35
+
36
+ export class SettingsParseError extends Error {
37
+ constructor(message, { cause } = {}) {
38
+ super(message);
39
+ this.name = 'SettingsParseError';
40
+ this.code = 'ESETTINGS_PARSE';
41
+ this.exitCode = 1;
42
+ if (cause) this.cause = cause;
43
+ }
44
+ }
45
+
46
+ function settingsPathFor(consumerCwd) {
47
+ return path.join(consumerCwd, '.claude', 'settings.json');
48
+ }
49
+
50
+ /** True if `value` is a plain (non-array, non-null) object. */
51
+ function isPlainObject(value) {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ }
54
+
55
+ /**
56
+ * Fail-loud validation of the INTERNAL shape of a settings `hooks` field before
57
+ * any merge or removal touches it (§5/§12). `readSettings` already guarantees
58
+ * the top-level value is a plain object; this guards the sub-tree so a
59
+ * wrong-shape `hooks` (e.g. a string or array) is never silently spread into an
60
+ * index-keyed object, and a wrong-shape event value (e.g. a non-array) is never
61
+ * silently discarded. On any violation we throw SettingsParseError so NO write
62
+ * happens and the consumer file is left byte-identical.
63
+ *
64
+ * Accepts an absent `hooks` (undefined). Otherwise `hooks` must be a plain
65
+ * object and every `hooks[event]` must be an array (or absent/undefined).
66
+ */
67
+ function assertHooksShape(hooks, absPath) {
68
+ if (hooks === undefined) return;
69
+ if (!isPlainObject(hooks)) {
70
+ throw new SettingsParseError(
71
+ `ESETTINGS_PARSE: "hooks" in ${absPath} must be a JSON object — fix or remove it, then re-run`,
72
+ );
73
+ }
74
+ for (const [event, entries] of Object.entries(hooks)) {
75
+ if (entries === undefined) continue;
76
+ if (!Array.isArray(entries)) {
77
+ throw new SettingsParseError(
78
+ `ESETTINGS_PARSE: "hooks.${event}" in ${absPath} must be a JSON array — fix or remove it, then re-run`,
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reads and parses the consumer's settings.json.
86
+ * Returns {} when the file is absent. Throws SettingsParseError on malformed JSON.
87
+ */
88
+ function readSettings(absPath) {
89
+ if (!existsSync(absPath)) return {};
90
+ const raw = readFileSync(absPath, 'utf8');
91
+ if (raw.trim().length === 0) return {};
92
+ try {
93
+ const parsed = JSON.parse(raw);
94
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
95
+ throw new SettingsParseError(
96
+ `ESETTINGS_PARSE: ${absPath} is not a JSON object — fix or remove it, then re-run`,
97
+ );
98
+ }
99
+ return parsed;
100
+ } catch (err) {
101
+ if (err instanceof SettingsParseError) throw err;
102
+ throw new SettingsParseError(
103
+ `ESETTINGS_PARSE: could not parse ${absPath} as JSON — fix the file or remove it, then re-run`,
104
+ { cause: err },
105
+ );
106
+ }
107
+ }
108
+
109
+ /** True if `event` already has a CLI-managed entry whose command targets `command`. */
110
+ function hasManagedCommand(eventEntries, command) {
111
+ 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;
120
+ }
121
+
122
+ /**
123
+ * Builds the managed hook entry for one registration. The HOOK_TAG property
124
+ * marks it as CLI-owned.
125
+ */
126
+ function buildManagedEntry({ matcher, command }) {
127
+ return {
128
+ [HOOK_TAG]: true,
129
+ matcher,
130
+ hooks: [{ type: 'command', command }],
131
+ };
132
+ }
133
+
134
+ /**
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.
139
+ */
140
+ function computeMerge(current, hookScripts, absPath) {
141
+ // Validate the internal shape of the consumer's hooks before merging so a
142
+ // wrong-shape value is never silently corrupted or discarded (§5/§12).
143
+ assertHooksShape(current.hooks, absPath);
144
+
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.
147
+ const next = { ...current };
148
+ const hooks = { ...(next.hooks ?? {}) };
149
+ let changed = false;
150
+
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
+ }
161
+
162
+ if (changed) {
163
+ next.hooks = hooks;
164
+ }
165
+ return { settings: next, changed };
166
+ }
167
+
168
+ /**
169
+ * Idempotent, additive merge of the given hook registrations into the
170
+ * consumer's `.claude/settings.json`.
171
+ *
172
+ * @param {string} consumerCwd
173
+ * @param {{ event: string, matcher: string, command: string }[]} hookScripts
174
+ * @param {{ dryRun?: boolean }} [opts]
175
+ * @returns {{ merged: boolean, dryRun: boolean, settingsPath: string }}
176
+ * @throws {SettingsParseError} with .code === 'ESETTINGS_PARSE' on malformed settings.json
177
+ */
178
+ export function mergeHookSettings(consumerCwd, hookScripts, opts = {}) {
179
+ const { dryRun = false } = opts;
180
+ const absPath = settingsPathFor(consumerCwd);
181
+
182
+ // Parse first — a malformed file fails loud BEFORE any write so the consumer's
183
+ // file is never clobbered (§5/§12).
184
+ const current = readSettings(absPath);
185
+ const { settings, changed } = computeMerge(current, hookScripts, absPath);
186
+
187
+ if (dryRun) {
188
+ return { merged: changed, dryRun: true, settingsPath: absPath };
189
+ }
190
+
191
+ if (!changed) {
192
+ // Nothing to do — leave the file byte-identical (idempotent §9).
193
+ return { merged: false, dryRun: false, settingsPath: absPath };
194
+ }
195
+
196
+ mkdirSync(path.dirname(absPath), { recursive: true });
197
+ writeFileSync(absPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
198
+ return { merged: true, dryRun: false, settingsPath: absPath };
199
+ }
200
+
201
+ /**
202
+ * Removes all CLI-managed hook entries (those carrying HOOK_TAG) from the
203
+ * consumer's settings.json, leaving consumer-authored entries untouched. Used by
204
+ * uninstall flows. Returns { removed, dryRun } and writes nothing in dry-run.
205
+ *
206
+ * @param {string} consumerCwd
207
+ * @param {{ dryRun?: boolean }} [opts]
208
+ */
209
+ export function removeHookSettings(consumerCwd, opts = {}) {
210
+ const { dryRun = false } = opts;
211
+ const absPath = settingsPathFor(consumerCwd);
212
+
213
+ if (!existsSync(absPath)) {
214
+ return { removed: false, dryRun, settingsPath: absPath };
215
+ }
216
+
217
+ const current = readSettings(absPath);
218
+ // Same fail-loud posture as the merge path: a wrong-shape `hooks` sub-tree
219
+ // surfaces ESETTINGS_PARSE and leaves the file byte-identical (§5/§12) rather
220
+ // than crashing or silently mangling it.
221
+ assertHooksShape(current.hooks, absPath);
222
+ const hooks = current.hooks ?? {};
223
+ let changed = false;
224
+ const nextHooks = {};
225
+
226
+ for (const [event, entries] of Object.entries(hooks)) {
227
+ if (!Array.isArray(entries)) {
228
+ nextHooks[event] = entries;
229
+ continue;
230
+ }
231
+ const kept = entries.filter((entry) => !(entry && entry[HOOK_TAG] === true));
232
+ if (kept.length !== entries.length) changed = true;
233
+ if (kept.length > 0) {
234
+ 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
+ }
239
+ }
240
+
241
+ if (!changed) {
242
+ return { removed: false, dryRun, settingsPath: absPath };
243
+ }
244
+
245
+ const next = { ...current };
246
+ if (Object.keys(nextHooks).length > 0) {
247
+ next.hooks = nextHooks;
248
+ } else {
249
+ delete next.hooks;
250
+ }
251
+
252
+ if (dryRun) {
253
+ return { removed: true, dryRun: true, settingsPath: absPath };
254
+ }
255
+
256
+ writeFileSync(absPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
257
+ return { removed: true, dryRun: false, settingsPath: absPath };
258
+ }
259
+
260
+ /**
261
+ * The default hook registration spec — maps each installed hook script to its
262
+ * Claude Code event. Kept here so init/update share one source of truth.
263
+ */
264
+ export const DEFAULT_HOOK_SCRIPTS = [
265
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
266
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
267
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
268
+ ];