@dalzoubi/dev-agents-sync 2.0.28 → 2.2.0

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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * tests/hooks-wave2-registration.test.mjs
3
+ *
4
+ * Slice S5 — CLI wiring for the 260805 hooks wave.
5
+ * Spec: docs/specs/260805-hooks-wave-2/
6
+ *
7
+ * The wave adds five hooks — three BLOCKING (PreToolUse, exit 2) and two
8
+ * warning — to a pipeline that previously carried only non-blocking reminders.
9
+ * Nothing in the CLI changed to carry them: `collectHookPaths` /
10
+ * `hookScriptsForInstalled` are predicate-driven, so the whole slice is five
11
+ * rows in `DEFAULT_HOOK_SCRIPTS`. That is precisely why it needs tests: a wrong
12
+ * event or matcher installs a hook that never fires (or fires on every tool
13
+ * call), and nothing else in the suite would notice.
14
+ *
15
+ * Behavior anchors:
16
+ *
17
+ * W1. Each of the five hooks is registered on the event and matcher the plan's
18
+ * registration table specifies.
19
+ * W2. `init` and `update` install and wire every registered hook — asserted
20
+ * against the registration table, not a pinned count, so a hook added
21
+ * later cannot slip through uncovered.
22
+ * W3. The settings merge stays idempotent with the larger table: a second
23
+ * `update` leaves settings.json byte-identical (twice == once).
24
+ * W4. A pre-wave lockfile (recording only the three 260616 hooks) upgrades
25
+ * additively — no error, and the new hooks land.
26
+ * W5. `check` reports drift (exit 1) when a wave-2 hook script is modified.
27
+ *
28
+ * The wave-2 hooks' own runtime behavior (deny/allow/bypass/fail-open) is
29
+ * proven by the content repo's `tests/hooks-*.test.mjs`; the fixture bodies
30
+ * here are inert stand-ins, since this file is about wiring, not detection.
31
+ *
32
+ * Fixture pattern mirrors tests/hooks-cli-install.test.mjs (release-v1.0.0
33
+ * FileMap + injected hook entries carrying the shell-comment marker).
34
+ *
35
+ * Test runner: `node --test packages/dev-agents-sync/tests/hooks-wave2-registration.test.mjs`
36
+ * i18n/accessibility: N/A — CLI-only, no UI surface and no translated strings.
37
+ */
38
+
39
+ import { describe, it, beforeEach, afterEach } from 'node:test';
40
+ import assert from 'node:assert/strict';
41
+ import {
42
+ mkdtempSync,
43
+ mkdirSync,
44
+ writeFileSync,
45
+ readFileSync,
46
+ existsSync,
47
+ rmSync,
48
+ readdirSync,
49
+ } from 'node:fs';
50
+ import { tmpdir } from 'node:os';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+
54
+ import { runInit } from '../src/commands/init.mjs';
55
+ import { runUpdate } from '../src/commands/update.mjs';
56
+ import { runCheck } from '../src/commands/check.mjs';
57
+ import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Paths / fixtures
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
64
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
65
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
66
+ const LOCKFILE_NAME = '.dev-agents-sync.json';
67
+
68
+ /**
69
+ * The registration table this slice adds, restated from
70
+ * docs/specs/260805-hooks-wave-2/plan.md.
71
+ *
72
+ * Deliberately a literal: this is the CONTRACT under test (which event fires
73
+ * which hook, and on which tools), not an inventory of what happens to exist.
74
+ * Deriving it from DEFAULT_HOOK_SCRIPTS would assert the table equals itself.
75
+ * It stays valid as later waves append rows — nothing here claims the table
76
+ * holds ONLY these five.
77
+ */
78
+ const WAVE2_REGISTRATIONS = [
79
+ { command: '.claude/hooks/force-push-guard.sh', event: 'PreToolUse', matcher: 'Bash' },
80
+ { command: '.claude/hooks/spec-approval-gate.sh', event: 'PreToolUse', matcher: 'Edit|Write' },
81
+ { command: '.claude/hooks/handoff-state-guard.sh', event: 'PreToolUse', matcher: 'Write' },
82
+ { command: '.claude/hooks/spec-drift-warn.sh', event: 'PostToolUse', matcher: 'Edit|Write' },
83
+ { command: '.claude/hooks/pre-pr-preflight.sh', event: 'PreToolUse', matcher: 'Bash' },
84
+ ];
85
+
86
+ function collectFiles(baseDir, currentDir, map, prefix) {
87
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
88
+ const full = path.join(currentDir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ collectFiles(baseDir, full, map, prefix);
91
+ } else {
92
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
93
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * A fetched FileMap carrying one hook script per registration — the shape the
100
+ * build emits (`claude/hooks/<name>.sh`, shell-comment marker, since the CLI
101
+ * keys managed hooks off that prefix).
102
+ */
103
+ function buildFixtureFileMap() {
104
+ const map = {};
105
+ const targetDir = path.join(FIXTURE_DIR, 'claude');
106
+ if (existsSync(targetDir)) collectFiles(targetDir, targetDir, map, 'claude');
107
+ for (const { event, command } of DEFAULT_HOOK_SCRIPTS) {
108
+ map[command.replace(/^\.claude\//, 'claude/')] =
109
+ `#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(command)}.\nexit 0\n`;
110
+ }
111
+ return map;
112
+ }
113
+
114
+ function makeFixtureFetcher() {
115
+ return async (_repo, _tag, _token) => buildFixtureFileMap();
116
+ }
117
+
118
+ function makeTmpDir() {
119
+ return mkdtempSync(path.join(tmpdir(), 'das-hooks-wave2-'));
120
+ }
121
+
122
+ /**
123
+ * Seeds a consumer that last synced BEFORE this wave: the lockfile records only
124
+ * the three 260616 hooks, and only those three exist on disk.
125
+ */
126
+ function setupPreWaveRepo(consumerDir) {
127
+ const raw =
128
+ JSON.stringify(
129
+ {
130
+ $schema:
131
+ 'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
132
+ source: 'github:dalzoubi/dev-agents',
133
+ range: '^1',
134
+ resolvedVersion: '1.0.0',
135
+ targets: ['claude'],
136
+ lastUpdated: '2026-06-01T00:00:00Z',
137
+ hooks: [
138
+ '.claude/hooks/managed-file-guard.sh',
139
+ '.claude/hooks/steering-injector.sh',
140
+ '.claude/hooks/uncommitted-check.sh',
141
+ ],
142
+ },
143
+ null,
144
+ 2,
145
+ ) + '\n';
146
+ writeFileSync(path.join(consumerDir, LOCKFILE_NAME), raw, 'utf8');
147
+
148
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
149
+ mkdirSync(agentsDir, { recursive: true });
150
+ writeFileSync(
151
+ path.join(agentsDir, 'define.md'),
152
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
153
+ 'utf8',
154
+ );
155
+ }
156
+
157
+ /** Every CLI-managed registration in settings.json, flattened to {event, matcher, command}. */
158
+ function managedRegistrations(consumerDir) {
159
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
160
+ if (!existsSync(settingsPath)) return [];
161
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
162
+ const out = [];
163
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
164
+ if (!Array.isArray(entries)) continue;
165
+ for (const entry of entries) {
166
+ if (entry?.[HOOK_TAG] !== true) continue;
167
+ for (const h of entry.hooks ?? []) {
168
+ if (typeof h.command === 'string') {
169
+ out.push({ event, matcher: entry.matcher, command: h.command });
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // W1 — the registration table matches the plan
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe('W1 — the five wave-2 hooks are registered on the specified event and matcher', () => {
182
+ for (const { command, event, matcher } of WAVE2_REGISTRATIONS) {
183
+ it(`W1: ${path.basename(command)} is registered on ${event} with matcher "${matcher}"`, () => {
184
+ const entries = DEFAULT_HOOK_SCRIPTS.filter((h) => h.command === command);
185
+ assert.equal(
186
+ entries.length,
187
+ 1,
188
+ `${command} must have exactly one DEFAULT_HOOK_SCRIPTS entry; got ${entries.length}`,
189
+ );
190
+ assert.deepEqual(
191
+ { event: entries[0].event, matcher: entries[0].matcher },
192
+ { event, matcher },
193
+ `${command} must be wired per docs/specs/260805-hooks-wave-2/plan.md. A wrong event ` +
194
+ `means the hook never sees the call it guards; a wrong matcher means it is spawned ` +
195
+ `on unrelated tool calls (or skipped on the ones that matter).`,
196
+ );
197
+ });
198
+ }
199
+
200
+ it('W1: every blocking hook is registered on PreToolUse (the only event that can deny)', () => {
201
+ // Exit 2 denies the tool call on PreToolUse and nowhere else — a blocker
202
+ // registered on any other event is inert, and silently so.
203
+ const blockers = [
204
+ '.claude/hooks/force-push-guard.sh',
205
+ '.claude/hooks/spec-approval-gate.sh',
206
+ '.claude/hooks/handoff-state-guard.sh',
207
+ ];
208
+ for (const command of blockers) {
209
+ const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.command === command);
210
+ assert.ok(entry, `${command} must be registered`);
211
+ assert.equal(
212
+ entry.event,
213
+ 'PreToolUse',
214
+ `${command} blocks by exiting 2, which only denies on PreToolUse; on ${entry.event} it ` +
215
+ `would be a hook that reports failure and lets the call through`,
216
+ );
217
+ }
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // W2 — init/update install and wire every registered hook
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('W2 — init and update install and wire every registered hook', () => {
226
+ let consumerDir;
227
+
228
+ beforeEach(() => {
229
+ consumerDir = makeTmpDir();
230
+ });
231
+
232
+ afterEach(() => {
233
+ rmSync(consumerDir, { recursive: true, force: true });
234
+ });
235
+
236
+ it('W2a: init installs a script on disk for every registration', async () => {
237
+ await runInit(consumerDir, {
238
+ targets: ['claude'],
239
+ fetcher: makeFixtureFetcher(),
240
+ availableTags: AVAILABLE_TAGS,
241
+ });
242
+
243
+ const missing = DEFAULT_HOOK_SCRIPTS.map((h) => h.command).filter(
244
+ (c) => !existsSync(path.join(consumerDir, c)),
245
+ );
246
+ assert.deepEqual(missing, [], `init must install every registered hook script; missing: ${missing}`);
247
+ });
248
+
249
+ it('W2b: init wires every registration into settings.json with its event and matcher', async () => {
250
+ await runInit(consumerDir, {
251
+ targets: ['claude'],
252
+ fetcher: makeFixtureFetcher(),
253
+ availableTags: AVAILABLE_TAGS,
254
+ });
255
+
256
+ const wired = managedRegistrations(consumerDir);
257
+ for (const { event, matcher, command } of DEFAULT_HOOK_SCRIPTS) {
258
+ assert.ok(
259
+ wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
260
+ `settings.json must wire ${command} on ${event} with matcher "${matcher}"; ` +
261
+ `got: ${JSON.stringify(wired, null, 2)}`,
262
+ );
263
+ }
264
+ });
265
+
266
+ it('W2c: update wires every registration into settings.json', async () => {
267
+ setupPreWaveRepo(consumerDir);
268
+ await runUpdate(consumerDir, {
269
+ fetcher: makeFixtureFetcher(),
270
+ availableTags: AVAILABLE_TAGS,
271
+ });
272
+
273
+ const wired = managedRegistrations(consumerDir);
274
+ for (const { event, matcher, command } of DEFAULT_HOOK_SCRIPTS) {
275
+ assert.ok(
276
+ wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
277
+ `update must wire ${command} on ${event} with matcher "${matcher}"; ` +
278
+ `got: ${JSON.stringify(wired, null, 2)}`,
279
+ );
280
+ }
281
+ });
282
+
283
+ it('W2d: the two Bash PreToolUse hooks are both wired (a shared event+matcher is not deduped)', async () => {
284
+ // force-push-guard and pre-pr-preflight share PreToolUse/Bash. Merge
285
+ // identity is the COMMAND, so both must survive — collapsing on
286
+ // event+matcher would silently drop one.
287
+ await runInit(consumerDir, {
288
+ targets: ['claude'],
289
+ fetcher: makeFixtureFetcher(),
290
+ availableTags: AVAILABLE_TAGS,
291
+ });
292
+
293
+ const commands = managedRegistrations(consumerDir)
294
+ .filter((w) => w.event === 'PreToolUse' && w.matcher === 'Bash')
295
+ .map((w) => w.command)
296
+ .sort();
297
+ assert.deepEqual(
298
+ commands,
299
+ ['.claude/hooks/force-push-guard.sh', '.claude/hooks/pre-pr-preflight.sh'],
300
+ 'both PreToolUse/Bash hooks must be wired independently',
301
+ );
302
+ });
303
+
304
+ it('W2e: installed hook scripts are recorded in the lockfile', async () => {
305
+ await runInit(consumerDir, {
306
+ targets: ['claude'],
307
+ fetcher: makeFixtureFetcher(),
308
+ availableTags: AVAILABLE_TAGS,
309
+ });
310
+
311
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
312
+ for (const { command } of WAVE2_REGISTRATIONS) {
313
+ assert.ok(raw.includes(path.basename(command)), `lockfile must track ${command}`);
314
+ }
315
+ });
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // W3 — idempotence with the larger registration table
320
+ // ---------------------------------------------------------------------------
321
+
322
+ describe('W3 — a second update is a no-op (twice == once)', () => {
323
+ let consumerDir;
324
+
325
+ beforeEach(() => {
326
+ consumerDir = makeTmpDir();
327
+ });
328
+
329
+ afterEach(() => {
330
+ rmSync(consumerDir, { recursive: true, force: true });
331
+ });
332
+
333
+ it('W3a: settings.json is byte-identical after a second update', async () => {
334
+ setupPreWaveRepo(consumerDir);
335
+ const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
336
+
337
+ await runUpdate(consumerDir, opts);
338
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
339
+ const first = readFileSync(settingsPath, 'utf8');
340
+
341
+ await runUpdate(consumerDir, opts);
342
+ const second = readFileSync(settingsPath, 'utf8');
343
+
344
+ assert.equal(
345
+ second,
346
+ first,
347
+ 'a second update must not rewrite settings.json — the merge is idempotent (§9)',
348
+ );
349
+ });
350
+
351
+ it('W3b: no hook command is registered twice after two updates', async () => {
352
+ setupPreWaveRepo(consumerDir);
353
+ const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
354
+
355
+ await runUpdate(consumerDir, opts);
356
+ await runUpdate(consumerDir, opts);
357
+
358
+ const commands = managedRegistrations(consumerDir).map((w) => w.command);
359
+ const duplicated = commands.filter((c, i) => commands.indexOf(c) !== i);
360
+ assert.deepEqual(duplicated, [], `duplicate registrations after two updates: ${duplicated}`);
361
+ });
362
+
363
+ it('W3c: hook scripts on disk are byte-identical after a second update', async () => {
364
+ setupPreWaveRepo(consumerDir);
365
+ const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
366
+
367
+ await runUpdate(consumerDir, opts);
368
+ const first = DEFAULT_HOOK_SCRIPTS.map((h) =>
369
+ readFileSync(path.join(consumerDir, h.command), 'utf8'),
370
+ );
371
+
372
+ await runUpdate(consumerDir, opts);
373
+ const second = DEFAULT_HOOK_SCRIPTS.map((h) =>
374
+ readFileSync(path.join(consumerDir, h.command), 'utf8'),
375
+ );
376
+
377
+ assert.deepEqual(second, first, 'hook scripts must be byte-identical after a second update');
378
+ });
379
+ });
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // W4 — pre-wave lockfile upgrades additively (§8)
383
+ // ---------------------------------------------------------------------------
384
+
385
+ describe('W4 — a pre-wave lockfile upgrades additively', () => {
386
+ let consumerDir;
387
+
388
+ beforeEach(() => {
389
+ consumerDir = makeTmpDir();
390
+ });
391
+
392
+ afterEach(() => {
393
+ rmSync(consumerDir, { recursive: true, force: true });
394
+ });
395
+
396
+ it('W4a: update against a lockfile listing only the pre-wave hooks does not throw', async () => {
397
+ setupPreWaveRepo(consumerDir);
398
+ await assert.doesNotReject(
399
+ runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
400
+ 'a lockfile written before this wave must upgrade without error (§8)',
401
+ );
402
+ });
403
+
404
+ it('W4b: the upgraded lockfile keeps the pre-wave hooks and gains the new ones', async () => {
405
+ setupPreWaveRepo(consumerDir);
406
+ await runUpdate(consumerDir, {
407
+ fetcher: makeFixtureFetcher(),
408
+ availableTags: AVAILABLE_TAGS,
409
+ });
410
+
411
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
412
+ // Additive: nothing the consumer already had is dropped...
413
+ for (const name of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
414
+ assert.ok(raw.includes(name), `upgrade must not drop the pre-wave hook ${name}`);
415
+ }
416
+ // ...and the wave's hooks are added.
417
+ for (const { command } of WAVE2_REGISTRATIONS) {
418
+ assert.ok(raw.includes(path.basename(command)), `upgrade must record ${command}`);
419
+ }
420
+ });
421
+
422
+ it('W4c: a consumer-authored settings.json survives the upgrade untouched', async () => {
423
+ setupPreWaveRepo(consumerDir);
424
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
425
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
426
+ writeFileSync(
427
+ settingsPath,
428
+ JSON.stringify(
429
+ {
430
+ env: { MY_KEY: 'value' },
431
+ hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine.sh' }] }] },
432
+ },
433
+ null,
434
+ 2,
435
+ ),
436
+ 'utf8',
437
+ );
438
+
439
+ await runUpdate(consumerDir, {
440
+ fetcher: makeFixtureFetcher(),
441
+ availableTags: AVAILABLE_TAGS,
442
+ });
443
+
444
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
445
+ assert.deepEqual(settings.env, { MY_KEY: 'value' }, 'consumer env must survive');
446
+ assert.ok(
447
+ settings.hooks.PreToolUse.some((e) =>
448
+ (e.hooks ?? []).some((h) => h.command === 'mine.sh'),
449
+ ),
450
+ 'a consumer-authored PreToolUse/Bash hook must survive alongside the new managed ones',
451
+ );
452
+ });
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // W5 — check reports drift on a modified wave-2 hook script
457
+ // ---------------------------------------------------------------------------
458
+
459
+ describe('W5 — check reports drift on a modified hook script', () => {
460
+ let consumerDir;
461
+
462
+ beforeEach(() => {
463
+ consumerDir = makeTmpDir();
464
+ });
465
+
466
+ afterEach(() => {
467
+ rmSync(consumerDir, { recursive: true, force: true });
468
+ });
469
+
470
+ async function runCheckExitCode(dir) {
471
+ try {
472
+ await runCheck(dir, { fetcher: makeFixtureFetcher(), availableTags: ['v1.2.0'] });
473
+ return 0;
474
+ } catch (err) {
475
+ return err.exitCode ?? 1;
476
+ }
477
+ }
478
+
479
+ it('W5a: check exits 0 on a clean tree with the full hook set installed', async () => {
480
+ setupPreWaveRepo(consumerDir);
481
+ await runUpdate(consumerDir, {
482
+ fetcher: makeFixtureFetcher(),
483
+ availableTags: ['v1.0.0', 'v1.2.0'],
484
+ });
485
+
486
+ assert.equal(await runCheckExitCode(consumerDir), 0, 'a synced tree must be clean');
487
+ });
488
+
489
+ it('W5b: editing a blocking hook script makes check exit 1', async () => {
490
+ // The drift signal matters more for a blocker than for a reminder: a
491
+ // locally weakened force-push-guard still looks installed and wired.
492
+ setupPreWaveRepo(consumerDir);
493
+ await runUpdate(consumerDir, {
494
+ fetcher: makeFixtureFetcher(),
495
+ availableTags: ['v1.0.0', 'v1.2.0'],
496
+ });
497
+
498
+ const hookPath = path.join(consumerDir, '.claude', 'hooks', 'force-push-guard.sh');
499
+ writeFileSync(hookPath, readFileSync(hookPath, 'utf8') + '\n# locally weakened\n', 'utf8');
500
+
501
+ assert.equal(
502
+ await runCheckExitCode(consumerDir),
503
+ 1,
504
+ 'check must report drift when a managed hook script is locally modified',
505
+ );
506
+ });
507
+
508
+ it('W5c: deleting a wave-2 hook script makes check exit 1', async () => {
509
+ setupPreWaveRepo(consumerDir);
510
+ await runUpdate(consumerDir, {
511
+ fetcher: makeFixtureFetcher(),
512
+ availableTags: ['v1.0.0', 'v1.2.0'],
513
+ });
514
+
515
+ rmSync(path.join(consumerDir, '.claude', 'hooks', 'handoff-state-guard.sh'));
516
+
517
+ assert.equal(
518
+ await runCheckExitCode(consumerDir),
519
+ 1,
520
+ 'check must report drift when a managed hook script is deleted',
521
+ );
522
+ });
523
+ });
@@ -26,8 +26,8 @@
26
26
  * Behavior anchors:
27
27
  *
28
28
  * R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) no
29
- * longer has an entry targeting `.claude/hooks/rules-injector.sh`; it
30
- * registers exactly the three surviving hooks.
29
+ * longer has an entry targeting `.claude/hooks/rules-injector.sh`, and no
30
+ * retired command is ever re-registered; the three surviving hooks remain.
31
31
  * R2. `runInit` on a fresh consumer never wires a SessionStart registration
32
32
  * for rules-injector.sh into `.claude/settings.json` — the three
33
33
  * surviving managed hooks are still wired additively.
@@ -65,7 +65,7 @@ import { fileURLToPath } from 'node:url';
65
65
  import { runInit } from '../src/commands/init.mjs';
66
66
  import { runUpdate } from '../src/commands/update.mjs';
67
67
  import { writeLockfile } from '../src/lockfile.mjs';
68
- import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
68
+ import { DEFAULT_HOOK_SCRIPTS, RETIRED_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
69
69
 
70
70
  // ---------------------------------------------------------------------------
71
71
  // Paths / fixture helpers (mirrors tests/hooks-cli-install.test.mjs)
@@ -220,18 +220,21 @@ describe('S1 R1 — DEFAULT_HOOK_SCRIPTS no longer includes rules-injector.sh (r
220
220
  );
221
221
  });
222
222
 
223
- it('R1b: DEFAULT_HOOK_SCRIPTS registers exactly the three surviving hooks', () => {
224
- const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command).sort();
225
- const expected = [
226
- '.claude/hooks/managed-file-guard.sh',
227
- '.claude/hooks/steering-injector.sh',
228
- '.claude/hooks/uncommitted-check.sh',
229
- ].sort();
223
+ it('R1b: DEFAULT_HOOK_SCRIPTS and RETIRED_HOOK_SCRIPTS are disjoint (no retired command is re-registered)', () => {
224
+ // Generalizes what a pinned three-command roster used to assert here. The
225
+ // invariant R1 actually protects is that retirement holds — a command on
226
+ // the append-only retired list must never reappear in the shipped table, or
227
+ // merge-then-prune would add it and immediately remove it on every run.
228
+ // Stating it against the two tables keeps that guard exact while letting a
229
+ // wave register new hooks (260805 registered five) without a hand-edit.
230
+ const registered = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
231
+ const retired = new Set(RETIRED_HOOK_SCRIPTS.map((h) => h.command));
232
+ const reRegistered = registered.filter((c) => retired.has(c)).sort();
230
233
  assert.deepEqual(
231
- commands,
232
- expected,
233
- `DEFAULT_HOOK_SCRIPTS must register exactly the three surviving hooks, no more, no fewer; ` +
234
- `got: ${commands.join(', ')}`,
234
+ reRegistered,
235
+ [],
236
+ `DEFAULT_HOOK_SCRIPTS must not register any command on RETIRED_HOOK_SCRIPTS; ` +
237
+ `got: ${reRegistered.join(', ')}`,
235
238
  );
236
239
  });
237
240