@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.
@@ -1,27 +1,47 @@
1
1
  /**
2
2
  * tests/rules-injector-registration.test.mjs
3
3
  *
4
- * Slice S2 — SessionStart rules-injector.sh hook (registration half).
5
- * Spec: docs/specs/260708-claude-rules-enforcement/
4
+ * Originally Slice S2 (docs/specs/260708-claude-rules-enforcement/) — proved
5
+ * that `rules-injector.sh` got wired into a consumer's `.claude/settings.json`
6
+ * as a managed SessionStart hook.
6
7
  *
7
- * These tests FAIL before the implement agent registers rules-injector.sh as a
8
- * managed SessionStart hook and PASS after. They are behavior-anchored (§7):
9
- * each criterion maps to a named test that would fail if the behavior broke.
8
+ * Slice S1 of docs/specs/260724-opus5-native-overlap/ ("retire-rules-injector-hook")
9
+ * retires that hook: Claude Code now loads `.claude/rules/*.md` natively as
10
+ * project instructions, so the hook is redundant, and the CLI gains a
11
+ * `RETIRED_HOOK_SCRIPTS` prune (see tests/hook-retirement.test.mjs for the
12
+ * settings.mjs-level merge-then-prune contract). T004 of that spec's tasks.md
13
+ * requires updating this file "to the retired-hook expectation" so it proves
14
+ * the OPPOSITE of what it proved before: rules-injector.sh is no longer
15
+ * registered by DEFAULT_HOOK_SCRIPTS, `init` never wires it for a new
16
+ * consumer, and `update` PRUNES it from an existing consumer who has it from
17
+ * before S1 shipped.
18
+ *
19
+ * These tests are written RED-first, alongside tests/hook-retirement.test.mjs
20
+ * and the S1 additions to tests/hooks-build-emission.test.mjs: they FAIL today
21
+ * because `.agents/hooks/rules-injector.sh` still exists, DEFAULT_HOOK_SCRIPTS
22
+ * still lists it, and mergeHookSettings has no prune branch yet. They go GREEN
23
+ * once S1 lands: the canonical hook source is deleted, DEFAULT_HOOK_SCRIPTS
24
+ * drops the entry, and RETIRED_HOOK_SCRIPTS + the merge-then-prune pass ship.
10
25
  *
11
26
  * Behavior anchors:
12
27
  *
13
- * R1. DEFAULT_HOOK_SCRIPTS (packages/dev-agents-sync/src/settings.mjs) includes
14
- * a SessionStart entry whose command targets
15
- * `.claude/hooks/rules-injector.sh`.
16
- * R2. When the fetched FileMap ships `claude/hooks/rules-injector.sh` (as S3's
17
- * build.mjs will once the canonical `.agents/hooks/rules-injector.sh`
18
- * exists), `runInit` wires a HOOK_TAG-tagged SessionStart registration for
19
- * it into the consumer's `.claude/settings.json`, additively alongside the
20
- * three pre-existing managed hooks (steering-injector.sh,
21
- * managed-file-guard.sh, uncommitted-check.sh).
22
- * R3. `runUpdate` does the same on a pre-initialized consumer repo.
23
- * R4. Running update twice produces no duplicate rules-injector.sh
24
- * registration (idempotent, §9).
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.
31
+ * R2. `runInit` on a fresh consumer never wires a SessionStart registration
32
+ * for rules-injector.sh into `.claude/settings.json` — the three
33
+ * surviving managed hooks are still wired additively.
34
+ * R3. `runUpdate` on a consumer who already has a managed (HOOK_TAG-tagged)
35
+ * rules-injector.sh SessionStart entry from before S1 shipped PRUNES
36
+ * that entry, without disturbing the three surviving hooks.
37
+ * R4. Running update twice against that same pre-S1 consumer leaves the
38
+ * registration pruned — it is never reintroduced (idempotent removal,
39
+ * §9).
40
+ *
41
+ * This file stays a CLI-integration-level check (exercising the real
42
+ * runInit/runUpdate entry points end to end); it deliberately does not
43
+ * duplicate tests/hook-retirement.test.mjs's settings.mjs-level unit coverage
44
+ * of the merge-then-prune mechanism itself (P1-P6, T0 there).
25
45
  *
26
46
  * Test runner: `node --test packages/dev-agents-sync/tests/rules-injector-registration.test.mjs`
27
47
  * No third-party dependencies. All tests use isolated tmp dirs.
@@ -53,6 +73,7 @@ import { DEFAULT_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
53
73
 
54
74
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
75
  const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
76
+ const RETIRED_COMMAND = '.claude/hooks/rules-injector.sh';
56
77
 
57
78
  function collectFiles(baseDir, currentDir, map, prefix) {
58
79
  const entries = readdirSync(currentDir, { withFileTypes: true });
@@ -68,9 +89,11 @@ function collectFiles(baseDir, currentDir, map, prefix) {
68
89
  }
69
90
 
70
91
  /**
71
- * Builds a fixture FileMap that includes the four managed hook scripts —
72
- * the three pre-existing ones plus rules-injector.sh (as S3's build.mjs will
73
- * emit once the canonical `.agents/hooks/rules-injector.sh` source exists).
92
+ * Builds a fixture FileMap that includes the three surviving managed hook
93
+ * scripts — matching what S1's build.mjs emits once
94
+ * `.agents/hooks/rules-injector.sh` is deleted (planHookEmissions directory-
95
+ * scans `.agents/hooks/`, so a deleted source simply stops appearing here).
96
+ * rules-injector.sh is deliberately NOT included: a post-S1 fetch never ships it.
74
97
  */
75
98
  function buildFixtureFileMap() {
76
99
  const map = {};
@@ -84,8 +107,6 @@ function buildFixtureFileMap() {
84
107
  '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse hook.\nexit 0\n';
85
108
  map['claude/hooks/uncommitted-check.sh'] =
86
109
  '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# Stop hook.\nexit 0\n';
87
- map['claude/hooks/rules-injector.sh'] =
88
- '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
89
110
  return map;
90
111
  }
91
112
 
@@ -119,6 +140,55 @@ function setupInitializedRepo(consumerDir, opts = {}) {
119
140
  writeFileSync(path.join(agentsDir, 'define.md'), fixtureContent, 'utf8');
120
141
  }
121
142
 
143
+ /**
144
+ * Simulates a consumer who ran `update` before S1 shipped: their
145
+ * settings.json already carries a managed (HOOK_TAG-tagged) SessionStart
146
+ * registration for rules-injector.sh, wired the normal way (buildManagedEntry
147
+ * shape in src/settings.mjs).
148
+ */
149
+ function writePreS1SettingsWithRulesInjector(consumerDir) {
150
+ const settingsDir = path.join(consumerDir, '.claude');
151
+ mkdirSync(settingsDir, { recursive: true });
152
+ const settings = {
153
+ hooks: {
154
+ SessionStart: [
155
+ {
156
+ [HOOK_TAG]: true,
157
+ matcher: '.*',
158
+ hooks: [{ type: 'command', command: RETIRED_COMMAND }],
159
+ },
160
+ ],
161
+ },
162
+ };
163
+ writeFileSync(
164
+ path.join(settingsDir, 'settings.json'),
165
+ JSON.stringify(settings, null, 2) + '\n',
166
+ 'utf8',
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Captures all output written to process.stderr.write during the execution of
172
+ * `fn`. Returns the concatenated string.
173
+ *
174
+ * Matches the same capture pattern used in first-sync-notice.test.mjs /
175
+ * root-target.test.mjs.
176
+ */
177
+ async function captureStderr(fn) {
178
+ const origWrite = process.stderr.write.bind(process.stderr);
179
+ let captured = '';
180
+ process.stderr.write = (chunk) => {
181
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
182
+ return true;
183
+ };
184
+ try {
185
+ await fn();
186
+ } finally {
187
+ process.stderr.write = origWrite;
188
+ }
189
+ return captured;
190
+ }
191
+
122
192
  /** Collects every hook `command` string registered anywhere in settings.hooks. */
123
193
  function allHookCommands(settings) {
124
194
  const hooks = settings.hooks ?? {};
@@ -136,34 +206,36 @@ function allHookCommands(settings) {
136
206
  }
137
207
 
138
208
  // ---------------------------------------------------------------------------
139
- // R1 — DEFAULT_HOOK_SCRIPTS registers rules-injector.sh on SessionStart
209
+ // R1 — DEFAULT_HOOK_SCRIPTS no longer registers rules-injector.sh
140
210
  // ---------------------------------------------------------------------------
141
211
 
142
- describe('S2 R1 — DEFAULT_HOOK_SCRIPTS includes rules-injector.sh', () => {
143
- it('R1a: DEFAULT_HOOK_SCRIPTS has an entry whose command is .claude/hooks/rules-injector.sh', () => {
144
- const entry = DEFAULT_HOOK_SCRIPTS.find(
145
- (h) => h.command === '.claude/hooks/rules-injector.sh',
146
- );
147
- assert.ok(
212
+ describe('S1 R1 — DEFAULT_HOOK_SCRIPTS no longer includes rules-injector.sh (retired)', () => {
213
+ it('R1a: DEFAULT_HOOK_SCRIPTS has no entry whose command is .claude/hooks/rules-injector.sh', () => {
214
+ const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.command === RETIRED_COMMAND);
215
+ assert.equal(
148
216
  entry,
149
- `DEFAULT_HOOK_SCRIPTS must register .claude/hooks/rules-injector.sh; ` +
217
+ undefined,
218
+ `DEFAULT_HOOK_SCRIPTS must no longer register ${RETIRED_COMMAND} (retired by S1); ` +
150
219
  `got commands: ${DEFAULT_HOOK_SCRIPTS.map((h) => h.command).join(', ')}`,
151
220
  );
152
221
  });
153
222
 
154
- it('R1b: the rules-injector.sh registration fires on SessionStart', () => {
155
- const entry = DEFAULT_HOOK_SCRIPTS.find(
156
- (h) => h.command === '.claude/hooks/rules-injector.sh',
157
- );
158
- assert.ok(entry, 'rules-injector.sh must be registered in DEFAULT_HOOK_SCRIPTS');
159
- assert.equal(
160
- entry.event,
161
- 'SessionStart',
162
- `rules-injector.sh must be registered on the SessionStart event; got: ${entry?.event}`,
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();
230
+ 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(', ')}`,
163
235
  );
164
236
  });
165
237
 
166
- it('R1c: the three pre-existing managed hooks are still registered (additive, not replaced)', () => {
238
+ it('R1c: the three pre-existing managed hooks are still registered (retirement is subtractive only, not destructive)', () => {
167
239
  const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
168
240
  for (const expected of [
169
241
  '.claude/hooks/steering-injector.sh',
@@ -172,17 +244,17 @@ describe('S2 R1 — DEFAULT_HOOK_SCRIPTS includes rules-injector.sh', () => {
172
244
  ]) {
173
245
  assert.ok(
174
246
  commands.includes(expected),
175
- `registering rules-injector.sh must not drop the existing ${expected} registration`,
247
+ `retiring rules-injector.sh must not drop the existing ${expected} registration`,
176
248
  );
177
249
  }
178
250
  });
179
251
  });
180
252
 
181
253
  // ---------------------------------------------------------------------------
182
- // R2 — init wires a tagged SessionStart registration for rules-injector.sh
254
+ // R2 — init never wires rules-injector.sh into settings.json
183
255
  // ---------------------------------------------------------------------------
184
256
 
185
- describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
257
+ describe('S1 R2 — init does not wire rules-injector.sh into settings.json (retired)', () => {
186
258
  let consumerDir;
187
259
 
188
260
  beforeEach(() => {
@@ -193,7 +265,7 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
193
265
  rmSync(consumerDir, { recursive: true, force: true });
194
266
  });
195
267
 
196
- it('R2a: init registers a SessionStart entry for rules-injector.sh', async () => {
268
+ it('R2a: init does not register a SessionStart entry for rules-injector.sh', async () => {
197
269
  await runInit(consumerDir, {
198
270
  targets: ['claude'],
199
271
  fetcher: makeFixtureFetcher(),
@@ -211,34 +283,25 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
211
283
  e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
212
284
  );
213
285
  assert.ok(
214
- found,
215
- 'settings.json must contain a SessionStart hook entry for rules-injector.sh after init',
286
+ !found,
287
+ 'settings.json must NOT contain a SessionStart hook entry for rules-injector.sh after init (retired)',
216
288
  );
217
289
  });
218
290
 
219
- it('R2b: the injected rules-injector.sh entry carries the HOOK_TAG', async () => {
291
+ it('R2b: init does not install .claude/hooks/rules-injector.sh on disk', async () => {
220
292
  await runInit(consumerDir, {
221
293
  targets: ['claude'],
222
294
  fetcher: makeFixtureFetcher(),
223
295
  availableTags: AVAILABLE_TAGS,
224
296
  });
225
297
 
226
- const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
227
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
228
- const sessionStart = settings.hooks?.SessionStart ?? [];
229
- const taggedEntry = sessionStart.find(
230
- (e) =>
231
- e[HOOK_TAG] === true &&
232
- Array.isArray(e.hooks) &&
233
- e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
234
- );
235
298
  assert.ok(
236
- taggedEntry,
237
- `rules-injector.sh SessionStart entry must carry HOOK_TAG ("${HOOK_TAG}") so it is identifiable as CLI-managed`,
299
+ !existsSync(path.join(consumerDir, '.claude', 'hooks', 'rules-injector.sh')),
300
+ 'init must not write .claude/hooks/rules-injector.sh — the fetched FileMap no longer ships it (retired)',
238
301
  );
239
302
  });
240
303
 
241
- it('R2c: rules-injector.sh registration coexists with the three pre-existing managed hooks', async () => {
304
+ it('R2c: the three surviving managed hooks are still wired by init (additive behavior unaffected by retirement)', async () => {
242
305
  await runInit(consumerDir, {
243
306
  targets: ['claude'],
244
307
  fetcher: makeFixtureFetcher(),
@@ -249,25 +312,24 @@ describe('S2 R2 — init wires rules-injector.sh into settings.json', () => {
249
312
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
250
313
  const commands = allHookCommands(settings).map((c) => c.command);
251
314
 
252
- for (const expected of [
253
- 'steering-injector.sh',
254
- 'managed-file-guard.sh',
255
- 'uncommitted-check.sh',
256
- 'rules-injector.sh',
257
- ]) {
315
+ for (const expected of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
258
316
  assert.ok(
259
317
  commands.some((cmd) => cmd.includes(expected)),
260
- `settings.json must register ${expected} alongside the other managed hooks after init`,
318
+ `settings.json must still register ${expected} after init`,
261
319
  );
262
320
  }
321
+ assert.ok(
322
+ !commands.some((cmd) => cmd.includes('rules-injector.sh')),
323
+ 'settings.json must not register rules-injector.sh after init',
324
+ );
263
325
  });
264
326
  });
265
327
 
266
328
  // ---------------------------------------------------------------------------
267
- // R3 — update wires rules-injector.sh on a pre-initialized consumer repo
329
+ // R3 — update PRUNES a pre-S1 consumer's existing rules-injector.sh entry
268
330
  // ---------------------------------------------------------------------------
269
331
 
270
- describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
332
+ describe('S1 R3 — update prunes a pre-existing rules-injector.sh registration (migration)', () => {
271
333
  let consumerDir;
272
334
 
273
335
  beforeEach(() => {
@@ -278,8 +340,9 @@ describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
278
340
  rmSync(consumerDir, { recursive: true, force: true });
279
341
  });
280
342
 
281
- it('R3a: update registers a SessionStart entry for rules-injector.sh', async () => {
343
+ it('R3a: a consumer with a managed rules-injector.sh SessionStart entry from before S1 has it pruned by update', async () => {
282
344
  setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
345
+ writePreS1SettingsWithRulesInjector(consumerDir);
283
346
 
284
347
  await runUpdate(consumerDir, {
285
348
  fetcher: makeFixtureFetcher(),
@@ -287,41 +350,47 @@ describe('S2 R3 — update wires rules-injector.sh into settings.json', () => {
287
350
  });
288
351
 
289
352
  const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
290
- assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after update');
291
-
292
353
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
293
354
  const sessionStart = settings.hooks?.SessionStart ?? [];
294
- const found = sessionStart.some(
355
+ const stillPresent = sessionStart.some(
295
356
  (e) =>
296
357
  Array.isArray(e.hooks) &&
297
358
  e.hooks.some((h) => typeof h.command === 'string' && h.command.includes('rules-injector.sh')),
298
359
  );
299
360
  assert.ok(
300
- found,
301
- 'settings.json must contain a SessionStart hook entry for rules-injector.sh after update',
361
+ !stillPresent,
362
+ 'update must prune the pre-S1 managed rules-injector.sh SessionStart entry; ' +
363
+ `got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
302
364
  );
303
365
  });
304
366
 
305
- it('R3b: rules-injector.sh script itself is installed by update', async () => {
367
+ it('R3b: pruning the retired entry does not disturb the three surviving hooks getting wired', async () => {
306
368
  setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
369
+ writePreS1SettingsWithRulesInjector(consumerDir);
307
370
 
308
371
  await runUpdate(consumerDir, {
309
372
  fetcher: makeFixtureFetcher(),
310
373
  availableTags: AVAILABLE_TAGS,
311
374
  });
312
375
 
313
- assert.ok(
314
- existsSync(path.join(consumerDir, '.claude', 'hooks', 'rules-injector.sh')),
315
- 'update must write .claude/hooks/rules-injector.sh',
316
- );
376
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
377
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
378
+ const commands = allHookCommands(settings).map((c) => c.command);
379
+
380
+ for (const expected of ['steering-injector.sh', 'managed-file-guard.sh', 'uncommitted-check.sh']) {
381
+ assert.ok(
382
+ commands.some((cmd) => cmd.includes(expected)),
383
+ `settings.json must register ${expected} after update, alongside the rules-injector.sh prune`,
384
+ );
385
+ }
317
386
  });
318
387
  });
319
388
 
320
389
  // ---------------------------------------------------------------------------
321
- // R4 — idempotent: running update twice adds no duplicate registration
390
+ // R4 — repeated update never reintroduces the pruned registration
322
391
  // ---------------------------------------------------------------------------
323
392
 
324
- describe('S2 R4 — repeated update produces no duplicate rules-injector.sh registration', () => {
393
+ describe('S1 R4 — repeated update leaves the rules-injector.sh registration pruned (idempotent removal)', () => {
325
394
  let consumerDir;
326
395
 
327
396
  beforeEach(() => {
@@ -332,8 +401,9 @@ describe('S2 R4 — repeated update produces no duplicate rules-injector.sh regi
332
401
  rmSync(consumerDir, { recursive: true, force: true });
333
402
  });
334
403
 
335
- it('R4: rules-injector.sh command appears at most once after two updates', async () => {
404
+ it('R4: rules-injector.sh command is absent after two updates starting from a pre-S1 tagged entry', async () => {
336
405
  setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
406
+ writePreS1SettingsWithRulesInjector(consumerDir);
337
407
 
338
408
  const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
339
409
  await runUpdate(consumerDir, opts);
@@ -344,9 +414,378 @@ describe('S2 R4 — repeated update produces no duplicate rules-injector.sh regi
344
414
  const commands = allHookCommands(settings).map((c) => c.command);
345
415
  const occurrences = commands.filter((cmd) => cmd.includes('rules-injector.sh')).length;
346
416
 
417
+ assert.equal(
418
+ occurrences,
419
+ 0,
420
+ `rules-injector.sh hook command must be absent (pruned, never reintroduced) after two updates ` +
421
+ `(found: ${occurrences})`,
422
+ );
423
+ });
424
+ });
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // S1 cycle 2 RED — deliver-validate finding H2 (major).
428
+ //
429
+ // mergeHookSettings returns `prunedRetired`, and all three call sites in
430
+ // update.mjs / init.mjs discard it — nothing is ever printed when a retired
431
+ // hook registration is pruned from a consumer's settings.json. Meanwhile the
432
+ // FILE-orphan prune two blocks below in update.mjs DOES emit an
433
+ // "info: dev-agents-sync: pruned N orphaned managed file(s)" line
434
+ // (update.mjs:269-272) — so the silence on the hook-registration prune is an
435
+ // inconsistency within one function, not house style.
436
+ //
437
+ // Worse: `runUpdate --dry-run` returns at update.mjs:187-202 BEFORE
438
+ // mergeHookSettings is ever called, so the planned prune is never computed
439
+ // or reported at all — while README.md:129's "Self-cleaning" bullet
440
+ // advertises the behavior unconditionally.
441
+ //
442
+ // Intended fix (not yet implemented): `update` (and `init`) report the prune
443
+ // on stderr, in the same "info: dev-agents-sync: ..." style as the existing
444
+ // orphan-prune line, when prunedRetired.length > 0; and `update --dry-run`
445
+ // computes and surfaces the planned prune (result.prunedRetired) instead of
446
+ // skipping the computation.
447
+ // ---------------------------------------------------------------------------
448
+
449
+ describe('S1 H2 — update reports the retired-hook-registration prune (not silent)', () => {
450
+ let consumerDir;
451
+
452
+ beforeEach(() => {
453
+ consumerDir = makeTmpDir();
454
+ });
455
+
456
+ afterEach(() => {
457
+ rmSync(consumerDir, { recursive: true, force: true });
458
+ });
459
+
460
+ it('H2a: runUpdate emits an info: dev-agents-sync: line naming the retired-hook prune when one occurs', async () => {
461
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
462
+ writePreS1SettingsWithRulesInjector(consumerDir);
463
+
464
+ const stderr = await captureStderr(() =>
465
+ runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
466
+ );
467
+
468
+ assert.match(
469
+ stderr,
470
+ /info: dev-agents-sync:.*(prun|remov)[a-z]*.*(hook|registration|rules-injector)/i,
471
+ 'runUpdate must report the retired-hook-registration prune on stderr, in the same ' +
472
+ '"info: dev-agents-sync: ..." style as the orphaned-file-prune line (update.mjs:269-272), ' +
473
+ `not silently discard mergeHookSettings’s prunedRetired result; got stderr:\n${stderr || '(empty)'}`,
474
+ );
475
+ });
476
+
477
+ it('H2b: update --dry-run computes and surfaces the planned retired-hook prune instead of skipping it', async () => {
478
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
479
+ writePreS1SettingsWithRulesInjector(consumerDir);
480
+
481
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
482
+ const before = readFileSync(settingsPath, 'utf8');
483
+
484
+ const result = await runUpdate(consumerDir, {
485
+ dryRun: true,
486
+ fetcher: makeFixtureFetcher(),
487
+ availableTags: AVAILABLE_TAGS,
488
+ });
489
+
347
490
  assert.ok(
348
- occurrences <= 1,
349
- `rules-injector.sh hook command must appear at most once after two updates (found: ${occurrences})`,
491
+ Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
492
+ '--dry-run must compute and surface the planned retired-hook-registration prune ' +
493
+ '(result.prunedRetired) rather than returning before mergeHookSettings is ever called ' +
494
+ `(README.md:129’s "Self-cleaning" bullet must hold under --dry-run too); got: ${JSON.stringify(result)}`,
495
+ );
496
+
497
+ const after = readFileSync(settingsPath, 'utf8');
498
+ assert.equal(after, before, '--dry-run must never write to settings.json');
499
+ });
500
+
501
+ it('H2c: no prune line is emitted when there is nothing to prune (guards against a spurious message every run)', async () => {
502
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
503
+ // settings.json is already fully wired to the three surviving hooks, with
504
+ // no retired registration anywhere — nothing to prune.
505
+ const settingsDir = path.join(consumerDir, '.claude');
506
+ mkdirSync(settingsDir, { recursive: true });
507
+ writeFileSync(
508
+ path.join(settingsDir, 'settings.json'),
509
+ JSON.stringify(
510
+ {
511
+ hooks: {
512
+ SessionStart: [
513
+ {
514
+ [HOOK_TAG]: true,
515
+ matcher: '.*',
516
+ hooks: [{ type: 'command', command: '.claude/hooks/steering-injector.sh' }],
517
+ },
518
+ ],
519
+ PostToolUse: [
520
+ {
521
+ [HOOK_TAG]: true,
522
+ matcher: '.*',
523
+ hooks: [{ type: 'command', command: '.claude/hooks/managed-file-guard.sh' }],
524
+ },
525
+ ],
526
+ Stop: [
527
+ {
528
+ [HOOK_TAG]: true,
529
+ matcher: '.*',
530
+ hooks: [{ type: 'command', command: '.claude/hooks/uncommitted-check.sh' }],
531
+ },
532
+ ],
533
+ },
534
+ },
535
+ null,
536
+ 2,
537
+ ) + '\n',
538
+ 'utf8',
539
+ );
540
+
541
+ const stderr = await captureStderr(() =>
542
+ runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
543
+ );
544
+
545
+ assert.doesNotMatch(
546
+ stderr,
547
+ /rules-injector|retired hook|hook registration/i,
548
+ `no retired-hook-registration prune message must be emitted when nothing was pruned ` +
549
+ `(guards against a spurious message on every run); got stderr:\n${stderr}`,
550
+ );
551
+ });
552
+ });
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // S1 cycle 3 — closing deferred Minor/Low findings from deliver-validate.
556
+ //
557
+ // M2 (Group A — regression guard): cycle 2 removed the `hookSpec.length > 0`
558
+ // guard from all three settings-wiring call sites (update.mjs:149-154,
559
+ // update.mjs:254-256, init.mjs:206-209) so a consumer with a stale retired
560
+ // registration but NO hook scripts on disk still gets pruned. Validate's exact
561
+ // words: "re-adding the guard at all three sites would leave both suites
562
+ // green" — nothing pinned it before this test. This exercises the
563
+ // up-to-date short-circuit branch specifically, since that is the one branch
564
+ // that derives its hook spec from files present ON DISK (update.mjs:149-151)
565
+ // rather than from the fetched FileMap, so it is the only branch where "no
566
+ // hook scripts on disk" naturally produces an empty spec.
567
+ //
568
+ // M3 (Group A — regression guard): R2a/R2b only assert init never ADDS the
569
+ // retired registration; nothing asserted it REMOVES a pre-existing one.
570
+ // init.mjs:207-208 justifies its unconditional wiring call by claiming init
571
+ // run over an existing .claude/ can meet a pre-retirement settings.json —
572
+ // e.g. a second `init` re-run, or a consumer who lost their lockfile. That
573
+ // claim was unpinned before this test.
574
+ //
575
+ // M1 (Group B — RED): init.mjs:135-155 returns before any hook wiring is
576
+ // computed, so `runInit --dry-run`'s result never carries a `prunedRetired`
577
+ // field at all — update.mjs:196-198 explicitly calls out that under-reporting
578
+ // "a real, intended change to consumer-owned state" as the reason update's
579
+ // dry-run computes it. init's write path is correct (see M3); only its PLAN
580
+ // is incomplete.
581
+ //
582
+ // L5 (Group B — RED): hooks.mjs:69-73 prints `prunedRetired.length`, but
583
+ // settings.mjs:220 dedupes that list with `[...new Set(pruned)]`. So the same
584
+ // retired command registered under TWO event buckets is removed twice but
585
+ // reported as pruning only 1 distinct command.
586
+ // ---------------------------------------------------------------------------
587
+
588
+ describe('S1 M2 — update prunes a retired registration on the up-to-date short-circuit even with no hook scripts on disk', () => {
589
+ let consumerDir;
590
+
591
+ beforeEach(() => {
592
+ consumerDir = makeTmpDir();
593
+ });
594
+
595
+ afterEach(() => {
596
+ rmSync(consumerDir, { recursive: true, force: true });
597
+ });
598
+
599
+ it('M2: an up-to-date consumer with a pre-S1 tagged rules-injector.sh entry and an empty .claude/hooks/ dir still gets it pruned', async () => {
600
+ // Pin resolvedVersion to the highest tag AVAILABLE_TAGS/`^1` resolves to
601
+ // (1.2.0) so update takes the up-to-date short-circuit (update.mjs:140-164)
602
+ // rather than fetching. That branch filters DEFAULT_HOOK_SCRIPTS down to
603
+ // commands that `existsSync` on disk BEFORE calling wireHookSettings — with
604
+ // no hooks installed, that filtered spec is empty. The prune must still run
605
+ // because it was deliberately made unconditional on the spec's length
606
+ // (update.mjs:152-154's comment); re-adding a `hookSpec.length > 0` guard
607
+ // would make this a silent no-op and fail the assertions below.
608
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.2.0' });
609
+ writePreS1SettingsWithRulesInjector(consumerDir);
610
+
611
+ // No hook scripts on disk anywhere — .claude/hooks/ exists but is empty.
612
+ mkdirSync(path.join(consumerDir, '.claude', 'hooks'), { recursive: true });
613
+
614
+ const result = await runUpdate(consumerDir, {
615
+ fetcher: makeFixtureFetcher(),
616
+ availableTags: AVAILABLE_TAGS,
617
+ });
618
+
619
+ assert.equal(
620
+ result.upToDate,
621
+ true,
622
+ 'this test must exercise the up-to-date short-circuit (update.mjs:140-164); ' +
623
+ `got: ${JSON.stringify(result)}`,
624
+ );
625
+ assert.ok(
626
+ Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
627
+ `result.prunedRetired must report "${RETIRED_COMMAND}" pruned even though no hook scripts ` +
628
+ `are on disk; got: ${JSON.stringify(result.prunedRetired)}`,
629
+ );
630
+
631
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
632
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
633
+ const sessionStart = settings.hooks?.SessionStart ?? [];
634
+ const stillPresent = sessionStart.some(
635
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
636
+ );
637
+ assert.ok(
638
+ !stillPresent,
639
+ `the pre-S1 rules-injector.sh entry must be pruned even though no hook scripts are on disk; ` +
640
+ `got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
641
+ );
642
+ });
643
+ });
644
+
645
+ describe('S1 M3 — init prunes a pre-existing tagged retired entry (mirrors R3a, but for runInit)', () => {
646
+ let consumerDir;
647
+
648
+ beforeEach(() => {
649
+ consumerDir = makeTmpDir();
650
+ });
651
+
652
+ afterEach(() => {
653
+ rmSync(consumerDir, { recursive: true, force: true });
654
+ });
655
+
656
+ it('M3: runInit prunes a pre-existing managed rules-injector.sh SessionStart entry and reports it in result.prunedRetired', async () => {
657
+ // Simulates a consumer whose .claude/settings.json already carries a
658
+ // pre-retirement managed registration (e.g. a lost lockfile, or a second
659
+ // `init` re-run over an already-hook-wired .claude/) — the scenario
660
+ // init.mjs:207-208's comment claims is possible but that R2a/R2b never
661
+ // exercised (those only prove init never ADDS the retired registration).
662
+ writePreS1SettingsWithRulesInjector(consumerDir);
663
+
664
+ const result = await runInit(consumerDir, {
665
+ targets: ['claude'],
666
+ fetcher: makeFixtureFetcher(),
667
+ availableTags: AVAILABLE_TAGS,
668
+ });
669
+
670
+ assert.ok(
671
+ Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
672
+ `runInit's result must report "${RETIRED_COMMAND}" pruned; got: ${JSON.stringify(result.prunedRetired)}`,
673
+ );
674
+
675
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
676
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
677
+ const sessionStart = settings.hooks?.SessionStart ?? [];
678
+ const stillPresent = sessionStart.some(
679
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === RETIRED_COMMAND),
680
+ );
681
+ assert.ok(
682
+ !stillPresent,
683
+ `init must prune the pre-existing managed rules-injector.sh entry; ` +
684
+ `got hooks.SessionStart: ${JSON.stringify(sessionStart)}`,
685
+ );
686
+ });
687
+ });
688
+
689
+ describe('S1 M1 — init --dry-run computes and reports the planned retired-hook prune', () => {
690
+ let consumerDir;
691
+
692
+ beforeEach(() => {
693
+ consumerDir = makeTmpDir();
694
+ });
695
+
696
+ afterEach(() => {
697
+ rmSync(consumerDir, { recursive: true, force: true });
698
+ });
699
+
700
+ it('M1: runInit --dry-run over a consumer with a pre-existing tagged retired entry reports the planned prune and writes nothing', async () => {
701
+ writePreS1SettingsWithRulesInjector(consumerDir);
702
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
703
+ const before = readFileSync(settingsPath, 'utf8');
704
+
705
+ const result = await runInit(consumerDir, {
706
+ dryRun: true,
707
+ targets: ['claude'],
708
+ fetcher: makeFixtureFetcher(),
709
+ availableTags: AVAILABLE_TAGS,
710
+ });
711
+
712
+ assert.ok(
713
+ Array.isArray(result.prunedRetired) && result.prunedRetired.includes(RETIRED_COMMAND),
714
+ 'runInit --dry-run must compute and report the planned retired-hook-registration prune ' +
715
+ `(mirroring update's prunedRetired field name), not skip it because init.mjs:135-155 ` +
716
+ `returns before any hook wiring is computed; got: ${JSON.stringify(result)}`,
717
+ );
718
+
719
+ const after = readFileSync(settingsPath, 'utf8');
720
+ assert.equal(after, before, '--dry-run must never write to settings.json');
721
+ });
722
+ });
723
+
724
+ describe('S1 L5 — the prune info line counts registrations removed, not distinct commands', () => {
725
+ let consumerDir;
726
+
727
+ beforeEach(() => {
728
+ consumerDir = makeTmpDir();
729
+ });
730
+
731
+ afterEach(() => {
732
+ rmSync(consumerDir, { recursive: true, force: true });
733
+ });
734
+
735
+ it('L5: a retired command registered under two event buckets is reported as 2 registrations pruned, not deduped to 1', async () => {
736
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
737
+
738
+ // The SAME retired command, tagged, registered under TWO different event
739
+ // buckets — settings.mjs:220 dedupes prunedRetired with
740
+ // `[...new Set(pruned)]` before hooks.mjs reports its length, so this
741
+ // removes TWO registrations but (today) is reported as pruning only 1
742
+ // distinct command.
743
+ const settingsDir = path.join(consumerDir, '.claude');
744
+ mkdirSync(settingsDir, { recursive: true });
745
+ writeFileSync(
746
+ path.join(settingsDir, 'settings.json'),
747
+ JSON.stringify(
748
+ {
749
+ hooks: {
750
+ SessionStart: [
751
+ {
752
+ [HOOK_TAG]: true,
753
+ matcher: '.*',
754
+ hooks: [{ type: 'command', command: RETIRED_COMMAND }],
755
+ },
756
+ ],
757
+ PostToolUse: [
758
+ {
759
+ [HOOK_TAG]: true,
760
+ matcher: '.*',
761
+ hooks: [{ type: 'command', command: RETIRED_COMMAND }],
762
+ },
763
+ ],
764
+ },
765
+ },
766
+ null,
767
+ 2,
768
+ ) + '\n',
769
+ 'utf8',
770
+ );
771
+
772
+ const stderr = await captureStderr(() =>
773
+ runUpdate(consumerDir, { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS }),
774
+ );
775
+
776
+ // Assert on the emitted MESSAGE (not on settings.mjs's internal array shape)
777
+ // so a fix is free to report the count however it likes internally, as long
778
+ // as the number a consumer reads on their terminal is correct.
779
+ const match = stderr.match(/(\d+)\D{0,40}regist/i);
780
+ assert.ok(
781
+ match,
782
+ `stderr must report a registration count for the prune; got stderr:\n${stderr || '(empty)'}`,
783
+ );
784
+ assert.equal(
785
+ match[1],
786
+ '2',
787
+ 'the prune info line must report 2 registrations removed (one per event bucket the retired ' +
788
+ `command was registered under), not the deduped distinct-command count (1); got stderr:\n${stderr}`,
350
789
  );
351
790
  });
352
791
  });