@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.
@@ -0,0 +1,1666 @@
1
+ /**
2
+ * tests/hooks-cli-install.test.mjs
3
+ *
4
+ * Slice S4 — CLI installs hooks + lockfile + settings auto-wire.
5
+ * Spec: docs/specs/260616-agent-steering-and-hooks/
6
+ *
7
+ * These tests FAIL before the implement agent wires up the behavior and PASS after.
8
+ * They are behavior-anchored (§7): each criterion maps to at least one named test
9
+ * that would fail if the behavior broke.
10
+ *
11
+ * Behavior anchors:
12
+ *
13
+ * H1. init/update write three hook scripts into .claude/hooks/ as executable,
14
+ * shell-comment-marker-managed, lockfile-tracked files.
15
+ * H2. Running update twice leaves hooks and settings byte-identical (idempotent).
16
+ * H3. After install, deleting a .claude/hooks/*.sh makes check report drift (exit 1).
17
+ * H4. After install, editing a .claude/hooks/*.sh makes check report drift (exit 1).
18
+ * H5. Clean tree (hooks installed and unmodified) makes check exit 0.
19
+ * H6. Settings merge is additive + idempotent: merging into a settings.json that
20
+ * already has unrelated consumer settings + a consumer hook preserves them all
21
+ * and adds the three tagged hook registrations; a second merge is a no-op.
22
+ * H7. Injected settings entries are tagged so they can be identified and removed
23
+ * without touching consumer-authored entries.
24
+ * H8. --dry-run writes nothing (no hooks, no settings, no lockfile change) but
25
+ * returns the plan; exit 0.
26
+ * H9. --no-hooks-wiring installs hook scripts but does NOT modify settings.json.
27
+ * H10. Malformed settings.json causes update to fail loud with code ESETTINGS_PARSE
28
+ * and leaves the file unmodified.
29
+ * H11. An existing lockfile WITHOUT a hooks section parses and update upgrades it
30
+ * additively (no error, hooks section added).
31
+ * H12. CLI never logs the auth token (privacy invariant).
32
+ *
33
+ * Assumed src/settings.mjs interface (noted for the implement agent):
34
+ *
35
+ * import { mergeHookSettings, removeHookSettings, HOOK_TAG } from '../src/settings.mjs';
36
+ *
37
+ * mergeHookSettings(consumerCwd, hookScripts, opts?)
38
+ * - consumerCwd: string — path to consumer repo root
39
+ * - hookScripts: { event: string, matcher: string, command: string }[]
40
+ * Each element describes one hook to wire in settings.json.
41
+ * Example: [
42
+ * { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
43
+ * { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
44
+ * { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
45
+ * ]
46
+ * - opts.dryRun: boolean — when true, computes plan but writes nothing
47
+ * Returns: { merged: boolean, dryRun: boolean } (or equivalent plan object in dry-run)
48
+ * Throws: SettingsParseError with .code === 'ESETTINGS_PARSE' on malformed settings.json
49
+ *
50
+ * HOOK_TAG: string — the tag value injected into managed hook entries so they are
51
+ * identifiable and removable. Its exact shape is an implementation detail for the
52
+ * implement agent; tests only require that:
53
+ * (a) it is present as a property on each injected hook entry OR as a metadata
54
+ * field elsewhere in the settings object, AND
55
+ * (b) consumer-authored settings survive a merge unchanged (the tag is NOT present
56
+ * on consumer entries).
57
+ *
58
+ * Lockfile hooks section (additive, §8):
59
+ * Existing lockfiles without a `hooks` section parse and are upgraded. The lockfile
60
+ * may store a list of managed hook paths so `check` can detect drift. The exact field
61
+ * name is an implement-agent decision; tests only assert observable behavior.
62
+ *
63
+ * Three hook names (matching the .claude/hooks/ directory from S3):
64
+ * - steering-injector.sh — SessionStart
65
+ * - managed-file-guard.sh — PostToolUse
66
+ * - uncommitted-check.sh — Stop
67
+ *
68
+ * Shell-comment marker format (POSIX-sh): `# managed-by: dev-agents-sync vX.Y.Z`
69
+ *
70
+ * Test runner: `node --test packages/dev-agents-sync/tests/hooks-cli-install.test.mjs`
71
+ * No third-party dependencies. All tests use isolated tmp dirs.
72
+ */
73
+
74
+ import { describe, it, before, beforeEach, afterEach } from 'node:test';
75
+ import assert from 'node:assert/strict';
76
+ import {
77
+ mkdtempSync,
78
+ mkdirSync,
79
+ writeFileSync,
80
+ readFileSync,
81
+ existsSync,
82
+ statSync,
83
+ rmSync,
84
+ readdirSync,
85
+ } from 'node:fs';
86
+ import { tmpdir } from 'node:os';
87
+ import path from 'node:path';
88
+ import { fileURLToPath } from 'node:url';
89
+
90
+ import { runInit } from '../src/commands/init.mjs';
91
+ import { runUpdate } from '../src/commands/update.mjs';
92
+ import { runCheck } from '../src/commands/check.mjs';
93
+ import { writeLockfile, readLockfile } from '../src/lockfile.mjs';
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Module under test — settings.mjs does NOT EXIST yet (implement agent creates it).
97
+ // The import WILL THROW on module-not-found, which is the expected RED failure for
98
+ // the module-existence criterion. All describe blocks that use settings.mjs import
99
+ // it locally inside the test to capture the per-test failure reason rather than
100
+ // aborting the entire file.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ // We use a lazy importer so individual tests can fail independently.
104
+ let _settingsMod = null;
105
+ async function importSettings() {
106
+ if (_settingsMod) return _settingsMod;
107
+ // Will throw ERR_MODULE_NOT_FOUND when settings.mjs doesn't exist — the
108
+ // expected failure mode during RED phase.
109
+ _settingsMod = await import('../src/settings.mjs');
110
+ return _settingsMod;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Paths
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
118
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Fixture helpers — mirrors the pattern used by init.test.mjs / update.test.mjs
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function collectFiles(baseDir, currentDir, map, prefix) {
125
+ const entries = readdirSync(currentDir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ const full = path.join(currentDir, entry.name);
128
+ if (entry.isDirectory()) {
129
+ collectFiles(baseDir, full, map, prefix);
130
+ } else {
131
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
132
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Builds a fixture FileMap from the release-v1.0.0 fixture directory.
139
+ * Extended with fake hook script entries so the fetcher returns them as
140
+ * part of the managed-file set.
141
+ */
142
+ function buildFixtureFileMap(targets = ['claude']) {
143
+ const map = {};
144
+ for (const target of targets) {
145
+ const targetDir = path.join(FIXTURE_DIR, target);
146
+ if (!existsSync(targetDir)) continue;
147
+ collectFiles(targetDir, targetDir, map, target);
148
+ }
149
+ // Inject hook script entries that look like what S3's build.mjs emits.
150
+ // These must be under the `claude` target prefix so they resolve into
151
+ // .claude/hooks/. The shell-comment marker is the canonical form for .sh files.
152
+ map['claude/hooks/steering-injector.sh'] =
153
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart hook.\nexit 0\n';
154
+ map['claude/hooks/managed-file-guard.sh'] =
155
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse hook.\nexit 0\n';
156
+ map['claude/hooks/uncommitted-check.sh'] =
157
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# Stop hook.\nexit 0\n';
158
+ return map;
159
+ }
160
+
161
+ /** A fetcher that returns the fixture file map (including hook scripts). */
162
+ function makeFixtureFetcher() {
163
+ return async (_repo, _tag, _token) => buildFixtureFileMap(['claude']);
164
+ }
165
+
166
+ /** A fetcher that returns the fixture file map but WITHOUT hook scripts (pre-S3 baseline). */
167
+ function makeNoHooksFetcher() {
168
+ const map = {};
169
+ const targetDir = path.join(FIXTURE_DIR, 'claude');
170
+ if (existsSync(targetDir)) {
171
+ collectFiles(targetDir, targetDir, map, 'claude');
172
+ }
173
+ return async (_repo, _tag, _token) => ({ ...map });
174
+ }
175
+
176
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
177
+
178
+ /** The three hook file names installed under .claude/hooks/. */
179
+ const HOOK_FILES = [
180
+ 'steering-injector.sh',
181
+ 'managed-file-guard.sh',
182
+ 'uncommitted-check.sh',
183
+ ];
184
+
185
+ function makeTmpDir() {
186
+ return mkdtempSync(path.join(tmpdir(), 'das-hooks-test-'));
187
+ }
188
+
189
+ /**
190
+ * Seeds a minimal pre-initialized consumer repo (lockfile + one agent file).
191
+ * Optionally writes an existing lockfile without a hooks section (for back-compat).
192
+ */
193
+ function setupInitializedRepo(consumerDir, opts = {}) {
194
+ const {
195
+ targets = ['claude'],
196
+ resolvedVersion = '1.0.0',
197
+ range = '^1',
198
+ omitHooksSection = false,
199
+ } = opts;
200
+
201
+ const lockfileData = {
202
+ source: 'github:dalzoubi/dev-agents',
203
+ range,
204
+ resolvedVersion,
205
+ targets,
206
+ lastUpdated: '2026-06-01T00:00:00Z',
207
+ };
208
+
209
+ // For back-compat test: write the lockfile as raw JSON without a `hooks` section.
210
+ if (omitHooksSection) {
211
+ const raw =
212
+ JSON.stringify(
213
+ {
214
+ $schema:
215
+ 'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
216
+ source: lockfileData.source,
217
+ range: lockfileData.range,
218
+ resolvedVersion: lockfileData.resolvedVersion,
219
+ targets: lockfileData.targets,
220
+ lastUpdated: lockfileData.lastUpdated,
221
+ // no `hooks` key at all
222
+ },
223
+ null,
224
+ 2,
225
+ ) + '\n';
226
+ writeFileSync(path.join(consumerDir, '.dev-agents-sync.json'), raw, 'utf8');
227
+ } else {
228
+ writeLockfile(consumerDir, lockfileData);
229
+ }
230
+
231
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
232
+ mkdirSync(agentsDir, { recursive: true });
233
+ const fixtureContent = readFileSync(
234
+ path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'),
235
+ 'utf8',
236
+ );
237
+ writeFileSync(path.join(agentsDir, 'define.md'), fixtureContent, 'utf8');
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // H1 — init writes three hook scripts into .claude/hooks/
242
+ //
243
+ // Criterion: init/update write three hook scripts, executable, with the
244
+ // shell-comment marker, recorded in the lockfile.
245
+ // ---------------------------------------------------------------------------
246
+
247
+ describe('S4 H1 — init installs hook scripts', () => {
248
+ let consumerDir;
249
+
250
+ beforeEach(() => {
251
+ consumerDir = makeTmpDir();
252
+ });
253
+
254
+ afterEach(() => {
255
+ rmSync(consumerDir, { recursive: true, force: true });
256
+ });
257
+
258
+ it('H1a: init creates .claude/hooks/steering-injector.sh', async () => {
259
+ await runInit(consumerDir, {
260
+ targets: ['claude'],
261
+ fetcher: makeFixtureFetcher(),
262
+ availableTags: AVAILABLE_TAGS,
263
+ });
264
+
265
+ assert.ok(
266
+ existsSync(path.join(consumerDir, '.claude', 'hooks', 'steering-injector.sh')),
267
+ 'init must write .claude/hooks/steering-injector.sh',
268
+ );
269
+ });
270
+
271
+ it('H1b: init creates .claude/hooks/managed-file-guard.sh', async () => {
272
+ await runInit(consumerDir, {
273
+ targets: ['claude'],
274
+ fetcher: makeFixtureFetcher(),
275
+ availableTags: AVAILABLE_TAGS,
276
+ });
277
+
278
+ assert.ok(
279
+ existsSync(path.join(consumerDir, '.claude', 'hooks', 'managed-file-guard.sh')),
280
+ 'init must write .claude/hooks/managed-file-guard.sh',
281
+ );
282
+ });
283
+
284
+ it('H1c: init creates .claude/hooks/uncommitted-check.sh', async () => {
285
+ await runInit(consumerDir, {
286
+ targets: ['claude'],
287
+ fetcher: makeFixtureFetcher(),
288
+ availableTags: AVAILABLE_TAGS,
289
+ });
290
+
291
+ assert.ok(
292
+ existsSync(path.join(consumerDir, '.claude', 'hooks', 'uncommitted-check.sh')),
293
+ 'init must write .claude/hooks/uncommitted-check.sh',
294
+ );
295
+ });
296
+
297
+ it('H1d: all installed hook scripts carry the shell-comment marker', async () => {
298
+ await runInit(consumerDir, {
299
+ targets: ['claude'],
300
+ fetcher: makeFixtureFetcher(),
301
+ availableTags: AVAILABLE_TAGS,
302
+ });
303
+
304
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
305
+ for (const file of HOOK_FILES) {
306
+ const content = readFileSync(path.join(hooksDir, file), 'utf8');
307
+ assert.ok(
308
+ /^#\s*managed-by:\s*dev-agents-sync\s+v[0-9]+\.[0-9]+\.[0-9]+/m.test(content),
309
+ `${file} must contain the shell-comment marker "# managed-by: dev-agents-sync vX.Y.Z"`,
310
+ );
311
+ }
312
+ });
313
+
314
+ it('H1e: all installed hook scripts are executable', async () => {
315
+ await runInit(consumerDir, {
316
+ targets: ['claude'],
317
+ fetcher: makeFixtureFetcher(),
318
+ availableTags: AVAILABLE_TAGS,
319
+ });
320
+
321
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
322
+ for (const file of HOOK_FILES) {
323
+ const absPath = path.join(hooksDir, file);
324
+ const st = statSync(absPath);
325
+ // On POSIX: mode & 0o111 checks owner+group+other execute bits.
326
+ assert.ok(
327
+ (st.mode & 0o111) !== 0,
328
+ `${file} must be executable after install (mode: 0o${st.mode.toString(8)})`,
329
+ );
330
+ }
331
+ });
332
+
333
+ it('H1f: hook scripts are recorded in the lockfile after init', async () => {
334
+ await runInit(consumerDir, {
335
+ targets: ['claude'],
336
+ fetcher: makeFixtureFetcher(),
337
+ availableTags: AVAILABLE_TAGS,
338
+ });
339
+
340
+ // Read raw lockfile JSON so we catch any field name the implement agent uses.
341
+ const raw = readFileSync(path.join(consumerDir, '.dev-agents-sync.json'), 'utf8');
342
+
343
+ // At minimum, each hook file name must appear somewhere in the lockfile,
344
+ // since these are lockfile-tracked managed files.
345
+ for (const file of HOOK_FILES) {
346
+ assert.ok(
347
+ raw.includes(file),
348
+ `lockfile must reference ${file} (it is a lockfile-tracked managed file)`,
349
+ );
350
+ }
351
+ });
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // H1 (update variant) — update installs hook scripts on a pre-existing repo
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('S4 H1 — update installs hook scripts', () => {
359
+ let consumerDir;
360
+
361
+ beforeEach(() => {
362
+ consumerDir = makeTmpDir();
363
+ });
364
+
365
+ afterEach(() => {
366
+ rmSync(consumerDir, { recursive: true, force: true });
367
+ });
368
+
369
+ it('H1g: update installs all three hook scripts when not previously installed', async () => {
370
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
371
+
372
+ await runUpdate(consumerDir, {
373
+ fetcher: makeFixtureFetcher(),
374
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
375
+ });
376
+
377
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
378
+ for (const file of HOOK_FILES) {
379
+ assert.ok(
380
+ existsSync(path.join(hooksDir, file)),
381
+ `update must install .claude/hooks/${file}`,
382
+ );
383
+ }
384
+ });
385
+
386
+ it('H1h: update-installed hook scripts carry the shell-comment marker', async () => {
387
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
388
+
389
+ await runUpdate(consumerDir, {
390
+ fetcher: makeFixtureFetcher(),
391
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
392
+ });
393
+
394
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
395
+ for (const file of HOOK_FILES) {
396
+ const content = readFileSync(path.join(hooksDir, file), 'utf8');
397
+ assert.ok(
398
+ /^#\s*managed-by:\s*dev-agents-sync\s+v[0-9]+\.[0-9]+\.[0-9]+/m.test(content),
399
+ `update-installed ${file} must carry the shell-comment marker`,
400
+ );
401
+ }
402
+ });
403
+
404
+ it('H1i: update-installed hook scripts are executable', async () => {
405
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
406
+
407
+ await runUpdate(consumerDir, {
408
+ fetcher: makeFixtureFetcher(),
409
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
410
+ });
411
+
412
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
413
+ for (const file of HOOK_FILES) {
414
+ const st = statSync(path.join(hooksDir, file));
415
+ assert.ok(
416
+ (st.mode & 0o111) !== 0,
417
+ `update-installed ${file} must be executable`,
418
+ );
419
+ }
420
+ });
421
+ });
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // H2 — idempotent update: running update twice leaves hooks + settings identical
425
+ // ---------------------------------------------------------------------------
426
+
427
+ describe('S4 H2 — idempotent update', () => {
428
+ let consumerDir;
429
+
430
+ beforeEach(() => {
431
+ consumerDir = makeTmpDir();
432
+ });
433
+
434
+ afterEach(() => {
435
+ rmSync(consumerDir, { recursive: true, force: true });
436
+ });
437
+
438
+ it('H2a: hook script content is byte-identical after a second update', async () => {
439
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
440
+
441
+ const updateOpts = {
442
+ fetcher: makeFixtureFetcher(),
443
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
444
+ };
445
+
446
+ await runUpdate(consumerDir, updateOpts);
447
+
448
+ // Capture hook contents after first update.
449
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
450
+ const firstPass = {};
451
+ for (const file of HOOK_FILES) {
452
+ firstPass[file] = readFileSync(path.join(hooksDir, file), 'utf8');
453
+ }
454
+
455
+ await runUpdate(consumerDir, updateOpts);
456
+
457
+ // After second update, hook contents must be byte-identical.
458
+ for (const file of HOOK_FILES) {
459
+ const secondPass = readFileSync(path.join(hooksDir, file), 'utf8');
460
+ assert.equal(
461
+ secondPass,
462
+ firstPass[file],
463
+ `${file} must be byte-identical after a second update (idempotent)`,
464
+ );
465
+ }
466
+ });
467
+
468
+ it('H2b: settings.json has no duplicate hook entries after a second update', async () => {
469
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
470
+
471
+ const updateOpts = {
472
+ fetcher: makeFixtureFetcher(),
473
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
474
+ };
475
+
476
+ await runUpdate(consumerDir, updateOpts);
477
+ await runUpdate(consumerDir, updateOpts);
478
+
479
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
480
+ assert.ok(existsSync(settingsPath), '.claude/settings.json must exist after update');
481
+
482
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
483
+ const hooks = settings.hooks ?? {};
484
+
485
+ // Collect all hook commands across all event types.
486
+ const allCommands = [];
487
+ for (const entries of Object.values(hooks)) {
488
+ if (Array.isArray(entries)) {
489
+ for (const entry of entries) {
490
+ const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
491
+ for (const h of hookList) {
492
+ if (h.command) allCommands.push(h.command);
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ // Each hook script must appear at most once.
499
+ for (const file of HOOK_FILES) {
500
+ const occurrences = allCommands.filter((cmd) => cmd.includes(file)).length;
501
+ assert.ok(
502
+ occurrences <= 1,
503
+ `${file} hook command must appear at most once in settings.json after two updates (found: ${occurrences})`,
504
+ );
505
+ }
506
+ });
507
+ });
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // H3/H4/H5 — check detects hook drift (exit 1); clean tree exits 0
511
+ // ---------------------------------------------------------------------------
512
+
513
+ describe('S4 H3 — check reports drift on deleted hook script (exit 1)', () => {
514
+ let consumerDir;
515
+
516
+ beforeEach(() => {
517
+ consumerDir = makeTmpDir();
518
+ });
519
+
520
+ afterEach(() => {
521
+ rmSync(consumerDir, { recursive: true, force: true });
522
+ });
523
+
524
+ it('H3: deleting .claude/hooks/steering-injector.sh causes check to exit 1', async () => {
525
+ // Install hooks first.
526
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
527
+ await runUpdate(consumerDir, {
528
+ fetcher: makeFixtureFetcher(),
529
+ availableTags: ['v1.0.0', 'v1.2.0'],
530
+ });
531
+
532
+ // Delete a hook script to simulate drift.
533
+ rmSync(path.join(consumerDir, '.claude', 'hooks', 'steering-injector.sh'));
534
+
535
+ let exitCode;
536
+ try {
537
+ await runCheck(consumerDir, {
538
+ fetcher: makeFixtureFetcher(),
539
+ availableTags: ['v1.2.0'],
540
+ });
541
+ exitCode = 0;
542
+ } catch (err) {
543
+ exitCode = err.exitCode;
544
+ }
545
+
546
+ assert.equal(exitCode, 1, 'check must exit 1 when a hook script has been deleted');
547
+ });
548
+
549
+ it('H3b: drift error names the deleted hook file path', async () => {
550
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
551
+ await runUpdate(consumerDir, {
552
+ fetcher: makeFixtureFetcher(),
553
+ availableTags: ['v1.0.0', 'v1.2.0'],
554
+ });
555
+
556
+ rmSync(path.join(consumerDir, '.claude', 'hooks', 'steering-injector.sh'));
557
+
558
+ let errorMessage = '';
559
+ try {
560
+ await runCheck(consumerDir, {
561
+ fetcher: makeFixtureFetcher(),
562
+ availableTags: ['v1.2.0'],
563
+ });
564
+ } catch (err) {
565
+ errorMessage = err.message;
566
+ }
567
+
568
+ assert.ok(
569
+ errorMessage.includes('steering-injector.sh') || errorMessage.includes('hooks'),
570
+ `drift error must name the offending path; got: ${errorMessage}`,
571
+ );
572
+ });
573
+ });
574
+
575
+ describe('S4 H4 — check reports drift on edited hook script (exit 1)', () => {
576
+ let consumerDir;
577
+
578
+ beforeEach(() => {
579
+ consumerDir = makeTmpDir();
580
+ });
581
+
582
+ afterEach(() => {
583
+ rmSync(consumerDir, { recursive: true, force: true });
584
+ });
585
+
586
+ it('H4: editing .claude/hooks/managed-file-guard.sh causes check to exit 1', async () => {
587
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
588
+ await runUpdate(consumerDir, {
589
+ fetcher: makeFixtureFetcher(),
590
+ availableTags: ['v1.0.0', 'v1.2.0'],
591
+ });
592
+
593
+ const guardPath = path.join(consumerDir, '.claude', 'hooks', 'managed-file-guard.sh');
594
+ writeFileSync(guardPath, readFileSync(guardPath, 'utf8') + '\n# extra line\n', 'utf8');
595
+
596
+ let exitCode;
597
+ try {
598
+ await runCheck(consumerDir, {
599
+ fetcher: makeFixtureFetcher(),
600
+ availableTags: ['v1.2.0'],
601
+ });
602
+ exitCode = 0;
603
+ } catch (err) {
604
+ exitCode = err.exitCode;
605
+ }
606
+
607
+ assert.equal(exitCode, 1, 'check must exit 1 when a hook script has been locally modified');
608
+ });
609
+ });
610
+
611
+ describe('S4 H5 — check exits 0 when hooks are installed and unmodified', () => {
612
+ let consumerDir;
613
+
614
+ beforeEach(() => {
615
+ consumerDir = makeTmpDir();
616
+ });
617
+
618
+ afterEach(() => {
619
+ rmSync(consumerDir, { recursive: true, force: true });
620
+ });
621
+
622
+ it('H5: check exits 0 on a clean tree after hook install', async () => {
623
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
624
+ await runUpdate(consumerDir, {
625
+ fetcher: makeFixtureFetcher(),
626
+ availableTags: ['v1.0.0', 'v1.2.0'],
627
+ });
628
+
629
+ let exitCode = 0;
630
+ try {
631
+ await runCheck(consumerDir, {
632
+ fetcher: makeFixtureFetcher(),
633
+ availableTags: ['v1.2.0'],
634
+ });
635
+ } catch (err) {
636
+ exitCode = err.exitCode ?? 1;
637
+ }
638
+
639
+ assert.equal(exitCode, 0, 'check must exit 0 when all managed files (including hooks) match');
640
+ });
641
+ });
642
+
643
+ // ---------------------------------------------------------------------------
644
+ // H6 — settings merge: additive, idempotent, preserves consumer entries
645
+ // ---------------------------------------------------------------------------
646
+
647
+ describe('S4 H6 — settings merge is additive and idempotent', () => {
648
+ let consumerDir;
649
+
650
+ beforeEach(() => {
651
+ consumerDir = makeTmpDir();
652
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
653
+ });
654
+
655
+ afterEach(() => {
656
+ rmSync(consumerDir, { recursive: true, force: true });
657
+ });
658
+
659
+ it('H6a: mergeHookSettings adds three hook registrations to an empty settings.json', async () => {
660
+ const { mergeHookSettings } = await importSettings();
661
+
662
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
663
+ writeFileSync(settingsPath, JSON.stringify({}, null, 2), 'utf8');
664
+
665
+ await mergeHookSettings(
666
+ consumerDir,
667
+ [
668
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
669
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
670
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
671
+ ],
672
+ );
673
+
674
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
675
+ const hooks = settings.hooks ?? {};
676
+
677
+ // Each event must have at least one entry referencing the installed script.
678
+ const findCmd = (eventName, script) => {
679
+ const entries = hooks[eventName] ?? [];
680
+ return entries.some(
681
+ (e) =>
682
+ Array.isArray(e.hooks) &&
683
+ e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(script)),
684
+ );
685
+ };
686
+
687
+ assert.ok(
688
+ findCmd('SessionStart', 'steering-injector.sh'),
689
+ 'settings.json must contain a SessionStart hook for steering-injector.sh',
690
+ );
691
+ assert.ok(
692
+ findCmd('PostToolUse', 'managed-file-guard.sh'),
693
+ 'settings.json must contain a PostToolUse hook for managed-file-guard.sh',
694
+ );
695
+ assert.ok(
696
+ findCmd('Stop', 'uncommitted-check.sh'),
697
+ 'settings.json must contain a Stop hook for uncommitted-check.sh',
698
+ );
699
+ });
700
+
701
+ it('H6b: mergeHookSettings preserves all existing consumer settings', async () => {
702
+ const { mergeHookSettings } = await importSettings();
703
+
704
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
705
+ writeFileSync(
706
+ settingsPath,
707
+ JSON.stringify(
708
+ {
709
+ env: { MY_API_KEY: 'value' },
710
+ permissions: { allow: ['Bash(git:*)'] },
711
+ hooks: {
712
+ PreToolUse: [
713
+ {
714
+ matcher: '.*',
715
+ hooks: [{ type: 'command', command: 'my-custom-hook.sh' }],
716
+ },
717
+ ],
718
+ },
719
+ },
720
+ null,
721
+ 2,
722
+ ),
723
+ 'utf8',
724
+ );
725
+
726
+ await mergeHookSettings(
727
+ consumerDir,
728
+ [
729
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
730
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
731
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
732
+ ],
733
+ );
734
+
735
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
736
+
737
+ // Consumer settings must be preserved.
738
+ assert.deepEqual(settings.env, { MY_API_KEY: 'value' }, 'env must be preserved');
739
+ assert.deepEqual(
740
+ settings.permissions,
741
+ { allow: ['Bash(git:*)'] },
742
+ 'permissions must be preserved',
743
+ );
744
+
745
+ // Consumer-authored PreToolUse hook must still be present.
746
+ const preToolUse = (settings.hooks ?? {}).PreToolUse ?? [];
747
+ const hasCustomHook = preToolUse.some(
748
+ (e) =>
749
+ Array.isArray(e.hooks) &&
750
+ e.hooks.some((h) => h.command === 'my-custom-hook.sh'),
751
+ );
752
+ assert.ok(
753
+ hasCustomHook,
754
+ 'consumer-authored PreToolUse hook (my-custom-hook.sh) must be preserved after merge',
755
+ );
756
+ });
757
+
758
+ it('H6c: calling mergeHookSettings twice produces no duplicate hook entries', async () => {
759
+ const { mergeHookSettings } = await importSettings();
760
+
761
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
762
+ writeFileSync(settingsPath, JSON.stringify({}, null, 2), 'utf8');
763
+
764
+ const hookSpec = [
765
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
766
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
767
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
768
+ ];
769
+
770
+ await mergeHookSettings(consumerDir, hookSpec);
771
+ await mergeHookSettings(consumerDir, hookSpec); // second call
772
+
773
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
774
+ const hooks = settings.hooks ?? {};
775
+
776
+ const allCommands = [];
777
+ for (const entries of Object.values(hooks)) {
778
+ if (Array.isArray(entries)) {
779
+ for (const entry of entries) {
780
+ const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
781
+ for (const h of hookList) {
782
+ if (h.command) allCommands.push(h.command);
783
+ }
784
+ }
785
+ }
786
+ }
787
+
788
+ for (const file of HOOK_FILES) {
789
+ const count = allCommands.filter((cmd) => cmd.includes(file)).length;
790
+ assert.ok(
791
+ count <= 1,
792
+ `${file} must appear at most once in settings.json after two merges (found: ${count})`,
793
+ );
794
+ }
795
+ });
796
+
797
+ it('H6d: mergeHookSettings on a non-existent settings.json creates it', async () => {
798
+ const { mergeHookSettings } = await importSettings();
799
+
800
+ // No settings.json exists yet.
801
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
802
+ assert.ok(!existsSync(settingsPath), 'settings.json must not pre-exist for this test');
803
+
804
+ await mergeHookSettings(
805
+ consumerDir,
806
+ [
807
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
808
+ ],
809
+ );
810
+
811
+ assert.ok(existsSync(settingsPath), 'mergeHookSettings must create settings.json when absent');
812
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf8'));
813
+ assert.ok(parsed.hooks, 'created settings.json must have a hooks section');
814
+ });
815
+ });
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // H7 — injected settings entries are tagged; consumer entries are not
819
+ // ---------------------------------------------------------------------------
820
+
821
+ describe('S4 H7 — injected hook entries are tagged (identifiable and removable)', () => {
822
+ let consumerDir;
823
+
824
+ beforeEach(() => {
825
+ consumerDir = makeTmpDir();
826
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
827
+ });
828
+
829
+ afterEach(() => {
830
+ rmSync(consumerDir, { recursive: true, force: true });
831
+ });
832
+
833
+ it('H7a: injected hook entries can be identified as CLI-managed (tagged)', async () => {
834
+ const { mergeHookSettings, HOOK_TAG } = await importSettings();
835
+
836
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
837
+ writeFileSync(settingsPath, JSON.stringify({}, null, 2), 'utf8');
838
+
839
+ await mergeHookSettings(consumerDir, [
840
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
841
+ ]);
842
+
843
+ const raw = readFileSync(settingsPath, 'utf8');
844
+
845
+ // The HOOK_TAG must be a non-empty string constant exported from settings.mjs.
846
+ assert.ok(
847
+ typeof HOOK_TAG === 'string' && HOOK_TAG.length > 0,
848
+ 'HOOK_TAG must be a non-empty string exported by settings.mjs',
849
+ );
850
+
851
+ // The tag value must appear in the serialized settings so entries are identifiable.
852
+ assert.ok(
853
+ raw.includes(HOOK_TAG),
854
+ `settings.json must contain the HOOK_TAG ("${HOOK_TAG}") on injected entries`,
855
+ );
856
+ });
857
+
858
+ it('H7b: consumer-authored hook entry does NOT carry the CLI tag after a merge', async () => {
859
+ const { mergeHookSettings, HOOK_TAG } = await importSettings();
860
+
861
+ const consumerHook = { type: 'command', command: 'my-consumer-hook.sh' };
862
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
863
+ writeFileSync(
864
+ settingsPath,
865
+ JSON.stringify(
866
+ {
867
+ hooks: {
868
+ PreToolUse: [{ matcher: '.*', hooks: [consumerHook] }],
869
+ },
870
+ },
871
+ null,
872
+ 2,
873
+ ),
874
+ 'utf8',
875
+ );
876
+
877
+ await mergeHookSettings(consumerDir, [
878
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
879
+ ]);
880
+
881
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
882
+ const preToolUseEntries = (settings.hooks?.PreToolUse ?? []);
883
+
884
+ for (const entry of preToolUseEntries) {
885
+ for (const h of entry.hooks ?? []) {
886
+ if (h.command === 'my-consumer-hook.sh') {
887
+ // This consumer-authored hook must NOT have the CLI's tag property.
888
+ // The exact property name is HOOK_TAG; assert it's absent on consumer entries.
889
+ assert.ok(
890
+ h[HOOK_TAG] === undefined && entry[HOOK_TAG] === undefined,
891
+ `consumer-authored hook must not carry the HOOK_TAG ("${HOOK_TAG}") after a merge`,
892
+ );
893
+ }
894
+ }
895
+ }
896
+ });
897
+ });
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // H8 — --dry-run writes nothing, returns plan, exits 0
901
+ // ---------------------------------------------------------------------------
902
+
903
+ describe('S4 H8 — --dry-run writes nothing', () => {
904
+ let consumerDir;
905
+
906
+ beforeEach(() => {
907
+ consumerDir = makeTmpDir();
908
+ });
909
+
910
+ afterEach(() => {
911
+ rmSync(consumerDir, { recursive: true, force: true });
912
+ });
913
+
914
+ it('H8a: init --dry-run does not write any hook scripts', async () => {
915
+ await runInit(consumerDir, {
916
+ targets: ['claude'],
917
+ dryRun: true,
918
+ fetcher: makeFixtureFetcher(),
919
+ availableTags: AVAILABLE_TAGS,
920
+ });
921
+
922
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
923
+ for (const file of HOOK_FILES) {
924
+ assert.ok(
925
+ !existsSync(path.join(hooksDir, file)),
926
+ `init --dry-run must not write .claude/hooks/${file}`,
927
+ );
928
+ }
929
+ });
930
+
931
+ it('H8b: update --dry-run does not write any hook scripts', async () => {
932
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
933
+
934
+ await runUpdate(consumerDir, {
935
+ dryRun: true,
936
+ fetcher: makeFixtureFetcher(),
937
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
938
+ });
939
+
940
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
941
+ for (const file of HOOK_FILES) {
942
+ assert.ok(
943
+ !existsSync(path.join(hooksDir, file)),
944
+ `update --dry-run must not write .claude/hooks/${file}`,
945
+ );
946
+ }
947
+ });
948
+
949
+ it('H8c: update --dry-run does not write or modify settings.json', async () => {
950
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
951
+
952
+ // Pre-exist a settings.json with consumer content.
953
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
954
+ const original = JSON.stringify({ env: { FOO: 'bar' } }, null, 2);
955
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
956
+ writeFileSync(settingsPath, original, 'utf8');
957
+
958
+ await runUpdate(consumerDir, {
959
+ dryRun: true,
960
+ fetcher: makeFixtureFetcher(),
961
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
962
+ });
963
+
964
+ const after = readFileSync(settingsPath, 'utf8');
965
+ assert.equal(
966
+ after,
967
+ original,
968
+ 'update --dry-run must not modify settings.json',
969
+ );
970
+ });
971
+
972
+ it('H8d: update --dry-run does not modify the lockfile', async () => {
973
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
974
+ const before = readFileSync(path.join(consumerDir, '.dev-agents-sync.json'), 'utf8');
975
+
976
+ await runUpdate(consumerDir, {
977
+ dryRun: true,
978
+ fetcher: makeFixtureFetcher(),
979
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
980
+ });
981
+
982
+ const after = readFileSync(path.join(consumerDir, '.dev-agents-sync.json'), 'utf8');
983
+ assert.equal(after, before, 'update --dry-run must not modify the lockfile');
984
+ });
985
+
986
+ it('H8e: update --dry-run returns a result describing the plan', async () => {
987
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
988
+
989
+ const result = await runUpdate(consumerDir, {
990
+ dryRun: true,
991
+ fetcher: makeFixtureFetcher(),
992
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
993
+ });
994
+
995
+ assert.ok(result, 'update --dry-run must return a result object');
996
+ assert.ok(
997
+ result.dryRun === true,
998
+ 'dry-run result must carry dryRun: true',
999
+ );
1000
+ });
1001
+ });
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // H9 — --no-hooks-wiring installs scripts but skips settings merge
1005
+ // ---------------------------------------------------------------------------
1006
+
1007
+ describe('S4 H9 — --no-hooks-wiring skips settings.json', () => {
1008
+ let consumerDir;
1009
+
1010
+ beforeEach(() => {
1011
+ consumerDir = makeTmpDir();
1012
+ });
1013
+
1014
+ afterEach(() => {
1015
+ rmSync(consumerDir, { recursive: true, force: true });
1016
+ });
1017
+
1018
+ it('H9a: update --no-hooks-wiring installs hook scripts', async () => {
1019
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1020
+
1021
+ await runUpdate(consumerDir, {
1022
+ noHooksWiring: true,
1023
+ fetcher: makeFixtureFetcher(),
1024
+ availableTags: ['v1.0.0', 'v1.2.0'],
1025
+ });
1026
+
1027
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1028
+ for (const file of HOOK_FILES) {
1029
+ assert.ok(
1030
+ existsSync(path.join(hooksDir, file)),
1031
+ `update --no-hooks-wiring must still install .claude/hooks/${file}`,
1032
+ );
1033
+ }
1034
+ });
1035
+
1036
+ it('H9b: update --no-hooks-wiring does not create or modify settings.json', async () => {
1037
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1038
+
1039
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1040
+
1041
+ await runUpdate(consumerDir, {
1042
+ noHooksWiring: true,
1043
+ fetcher: makeFixtureFetcher(),
1044
+ availableTags: ['v1.0.0', 'v1.2.0'],
1045
+ });
1046
+
1047
+ // settings.json must NOT have been created (no pre-existing file).
1048
+ assert.ok(
1049
+ !existsSync(settingsPath),
1050
+ 'update --no-hooks-wiring must not create settings.json',
1051
+ );
1052
+ });
1053
+
1054
+ it('H9c: update --no-hooks-wiring leaves an existing settings.json unmodified', async () => {
1055
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1056
+
1057
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1058
+ const original = JSON.stringify({ env: { KEEP: 'me' } }, null, 2);
1059
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
1060
+ writeFileSync(settingsPath, original, 'utf8');
1061
+
1062
+ await runUpdate(consumerDir, {
1063
+ noHooksWiring: true,
1064
+ fetcher: makeFixtureFetcher(),
1065
+ availableTags: ['v1.0.0', 'v1.2.0'],
1066
+ });
1067
+
1068
+ const after = readFileSync(settingsPath, 'utf8');
1069
+ assert.equal(
1070
+ after,
1071
+ original,
1072
+ 'update --no-hooks-wiring must leave an existing settings.json byte-identical',
1073
+ );
1074
+ });
1075
+
1076
+ it('H9d: init --no-hooks-wiring installs hook scripts but skips settings.json', async () => {
1077
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1078
+
1079
+ await runInit(consumerDir, {
1080
+ targets: ['claude'],
1081
+ noHooksWiring: true,
1082
+ fetcher: makeFixtureFetcher(),
1083
+ availableTags: AVAILABLE_TAGS,
1084
+ });
1085
+
1086
+ // Hook scripts must be installed.
1087
+ for (const file of HOOK_FILES) {
1088
+ assert.ok(
1089
+ existsSync(path.join(consumerDir, '.claude', 'hooks', file)),
1090
+ `init --no-hooks-wiring must still install ${file}`,
1091
+ );
1092
+ }
1093
+
1094
+ // settings.json must NOT be created.
1095
+ assert.ok(
1096
+ !existsSync(settingsPath),
1097
+ 'init --no-hooks-wiring must not create settings.json',
1098
+ );
1099
+ });
1100
+ });
1101
+
1102
+ // ---------------------------------------------------------------------------
1103
+ // H10 — malformed settings.json causes update to fail loud with ESETTINGS_PARSE
1104
+ // ---------------------------------------------------------------------------
1105
+
1106
+ describe('S4 H10 — malformed settings.json triggers ESETTINGS_PARSE (no write)', () => {
1107
+ let consumerDir;
1108
+
1109
+ beforeEach(() => {
1110
+ consumerDir = makeTmpDir();
1111
+ });
1112
+
1113
+ afterEach(() => {
1114
+ rmSync(consumerDir, { recursive: true, force: true });
1115
+ });
1116
+
1117
+ it('H10a: update fails loud with ESETTINGS_PARSE when settings.json is invalid JSON', async () => {
1118
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1119
+
1120
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1121
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
1122
+ writeFileSync(settingsPath, '{not: valid JSON}}', 'utf8');
1123
+
1124
+ let thrownError = null;
1125
+ try {
1126
+ await runUpdate(consumerDir, {
1127
+ fetcher: makeFixtureFetcher(),
1128
+ availableTags: ['v1.0.0', 'v1.2.0'],
1129
+ });
1130
+ } catch (err) {
1131
+ thrownError = err;
1132
+ }
1133
+
1134
+ assert.ok(thrownError !== null, 'update must throw when settings.json is malformed JSON');
1135
+ assert.ok(
1136
+ thrownError.code === 'ESETTINGS_PARSE' ||
1137
+ (thrownError.message && /ESETTINGS_PARSE/i.test(thrownError.message)),
1138
+ `error must carry code ESETTINGS_PARSE; got code=${thrownError?.code} message=${thrownError?.message}`,
1139
+ );
1140
+ });
1141
+
1142
+ it('H10b: malformed settings.json is left unmodified (no partial write)', async () => {
1143
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1144
+
1145
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1146
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
1147
+ const malformed = '{not: valid JSON}}';
1148
+ writeFileSync(settingsPath, malformed, 'utf8');
1149
+
1150
+ try {
1151
+ await runUpdate(consumerDir, {
1152
+ fetcher: makeFixtureFetcher(),
1153
+ availableTags: ['v1.0.0', 'v1.2.0'],
1154
+ });
1155
+ } catch {
1156
+ // Expected — swallow the error for the preservation check.
1157
+ }
1158
+
1159
+ const after = readFileSync(settingsPath, 'utf8');
1160
+ assert.equal(
1161
+ after,
1162
+ malformed,
1163
+ 'malformed settings.json must remain unmodified after a failed update (no partial write)',
1164
+ );
1165
+ });
1166
+
1167
+ it('H10c: mergeHookSettings throws an error with code ESETTINGS_PARSE on malformed JSON', async () => {
1168
+ const { mergeHookSettings } = await importSettings();
1169
+
1170
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1171
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
1172
+ writeFileSync(settingsPath, '{ "hooks": {bad json}', 'utf8');
1173
+
1174
+ let thrownError = null;
1175
+ try {
1176
+ await mergeHookSettings(consumerDir, [
1177
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
1178
+ ]);
1179
+ } catch (err) {
1180
+ thrownError = err;
1181
+ }
1182
+
1183
+ assert.ok(thrownError !== null, 'mergeHookSettings must throw on malformed settings.json');
1184
+ assert.ok(
1185
+ thrownError.code === 'ESETTINGS_PARSE',
1186
+ `error must carry .code === 'ESETTINGS_PARSE'; got: ${thrownError?.code}`,
1187
+ );
1188
+ });
1189
+
1190
+ it('H10d: mergeHookSettings leaves malformed settings.json byte-identical (no partial write)', async () => {
1191
+ const { mergeHookSettings } = await importSettings();
1192
+
1193
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1194
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
1195
+ const malformed = '{ "hooks": {bad json}';
1196
+ writeFileSync(settingsPath, malformed, 'utf8');
1197
+
1198
+ try {
1199
+ await mergeHookSettings(consumerDir, [
1200
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
1201
+ ]);
1202
+ } catch {
1203
+ // expected
1204
+ }
1205
+
1206
+ const after = readFileSync(settingsPath, 'utf8');
1207
+ assert.equal(
1208
+ after,
1209
+ malformed,
1210
+ 'mergeHookSettings must leave malformed settings.json byte-identical (no partial write)',
1211
+ );
1212
+ });
1213
+ });
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // H11 — back-compat: existing lockfile without hooks section upgrades cleanly
1217
+ // ---------------------------------------------------------------------------
1218
+
1219
+ describe('S4 H11 — back-compat: pre-hooks lockfile upgrades cleanly', () => {
1220
+ let consumerDir;
1221
+
1222
+ beforeEach(() => {
1223
+ consumerDir = makeTmpDir();
1224
+ });
1225
+
1226
+ afterEach(() => {
1227
+ rmSync(consumerDir, { recursive: true, force: true });
1228
+ });
1229
+
1230
+ it('H11a: update against a lockfile without a hooks section does not throw', async () => {
1231
+ // Write a lockfile that has no `hooks` field at all (pre-S4 format).
1232
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0', omitHooksSection: true });
1233
+
1234
+ await assert.doesNotReject(
1235
+ runUpdate(consumerDir, {
1236
+ fetcher: makeFixtureFetcher(),
1237
+ availableTags: ['v1.0.0', 'v1.2.0'],
1238
+ }),
1239
+ 'update must not throw when the lockfile has no hooks section',
1240
+ );
1241
+ });
1242
+
1243
+ it('H11b: after upgrading a pre-hooks lockfile, hook scripts are installed', async () => {
1244
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0', omitHooksSection: true });
1245
+
1246
+ await runUpdate(consumerDir, {
1247
+ fetcher: makeFixtureFetcher(),
1248
+ availableTags: ['v1.0.0', 'v1.2.0'],
1249
+ });
1250
+
1251
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1252
+ for (const file of HOOK_FILES) {
1253
+ assert.ok(
1254
+ existsSync(path.join(hooksDir, file)),
1255
+ `update must install ${file} even when upgrading a pre-hooks lockfile`,
1256
+ );
1257
+ }
1258
+ });
1259
+
1260
+ it('H11c: after upgrading, lockfile is rewritten with hooks information', async () => {
1261
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0', omitHooksSection: true });
1262
+
1263
+ await runUpdate(consumerDir, {
1264
+ fetcher: makeFixtureFetcher(),
1265
+ availableTags: ['v1.0.0', 'v1.2.0'],
1266
+ });
1267
+
1268
+ const raw = readFileSync(path.join(consumerDir, '.dev-agents-sync.json'), 'utf8');
1269
+
1270
+ // After upgrade, the lockfile must reference the installed hooks.
1271
+ for (const file of HOOK_FILES) {
1272
+ assert.ok(
1273
+ raw.includes(file),
1274
+ `upgraded lockfile must reference ${file}`,
1275
+ );
1276
+ }
1277
+ });
1278
+
1279
+ it('H11d: pre-hooks lockfile still validates (additive upgrade — no required fields missing)', async () => {
1280
+ // Verifies that readLockfile itself can parse the old format without crashing.
1281
+ // The hooks section is not a required field (additive change, §8).
1282
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0', omitHooksSection: true });
1283
+
1284
+ assert.doesNotThrow(
1285
+ () => readLockfile(consumerDir),
1286
+ 'readLockfile must not throw on a lockfile that lacks a hooks section',
1287
+ );
1288
+ });
1289
+ });
1290
+
1291
+ // ---------------------------------------------------------------------------
1292
+ // L5 — fail-loud on wrong-shape `hooks` field (H1 remediation, §5/§12)
1293
+ //
1294
+ // A consumer settings.json whose `hooks` field (or an event value within it) has
1295
+ // the wrong INTERNAL shape must surface ESETTINGS_PARSE and leave the file
1296
+ // byte-identical — never silently spread a string/array into an index-keyed
1297
+ // object, never silently discard a non-array event value.
1298
+ // ---------------------------------------------------------------------------
1299
+
1300
+ describe('S4 L5 — wrong-shape hooks field fails loud (ESETTINGS_PARSE, no write)', () => {
1301
+ let consumerDir;
1302
+
1303
+ beforeEach(() => {
1304
+ consumerDir = makeTmpDir();
1305
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
1306
+ });
1307
+
1308
+ afterEach(() => {
1309
+ rmSync(consumerDir, { recursive: true, force: true });
1310
+ });
1311
+
1312
+ const HOOK_SPEC = [
1313
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
1314
+ ];
1315
+
1316
+ // Each case: a syntactically valid JSON object whose `hooks` sub-tree is the
1317
+ // wrong shape. The original bug either corrupted (string/array → index-keyed
1318
+ // object) or discarded (non-array event value) the consumer's data.
1319
+ const cases = [
1320
+ ['hooks is a string', { hooks: 'oops' }],
1321
+ ['hooks is an array', { hooks: ['x'] }],
1322
+ ['a hooks event value is a string', { hooks: { SessionStart: 'x' } }],
1323
+ ];
1324
+
1325
+ for (const [label, settingsObj] of cases) {
1326
+ it(`L5: mergeHookSettings throws ESETTINGS_PARSE when ${label}`, async () => {
1327
+ const { mergeHookSettings } = await importSettings();
1328
+
1329
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1330
+ const original = JSON.stringify(settingsObj, null, 2);
1331
+ writeFileSync(settingsPath, original, 'utf8');
1332
+
1333
+ let thrownError = null;
1334
+ try {
1335
+ await mergeHookSettings(consumerDir, HOOK_SPEC);
1336
+ } catch (err) {
1337
+ thrownError = err;
1338
+ }
1339
+
1340
+ assert.ok(thrownError !== null, `mergeHookSettings must throw when ${label}`);
1341
+ assert.equal(
1342
+ thrownError.code,
1343
+ 'ESETTINGS_PARSE',
1344
+ `error must carry code ESETTINGS_PARSE; got: ${thrownError?.code}`,
1345
+ );
1346
+
1347
+ // The file must be byte-identical — no corruption, no partial write.
1348
+ const after = readFileSync(settingsPath, 'utf8');
1349
+ assert.equal(
1350
+ after,
1351
+ original,
1352
+ `settings.json must be byte-identical after a failed merge when ${label}`,
1353
+ );
1354
+ });
1355
+
1356
+ it(`L5: update fails loud and writes nothing when ${label}`, async () => {
1357
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1358
+
1359
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1360
+ const original = JSON.stringify(settingsObj, null, 2);
1361
+ writeFileSync(settingsPath, original, 'utf8');
1362
+
1363
+ let thrownError = null;
1364
+ try {
1365
+ await runUpdate(consumerDir, {
1366
+ fetcher: makeFixtureFetcher(),
1367
+ availableTags: ['v1.0.0', 'v1.2.0'],
1368
+ });
1369
+ } catch (err) {
1370
+ thrownError = err;
1371
+ }
1372
+
1373
+ assert.ok(thrownError !== null, `update must throw when ${label}`);
1374
+ assert.ok(
1375
+ thrownError.code === 'ESETTINGS_PARSE' ||
1376
+ (thrownError.message && /ESETTINGS_PARSE/i.test(thrownError.message)),
1377
+ `error must carry code ESETTINGS_PARSE; got code=${thrownError?.code} message=${thrownError?.message}`,
1378
+ );
1379
+
1380
+ const after = readFileSync(settingsPath, 'utf8');
1381
+ assert.equal(
1382
+ after,
1383
+ original,
1384
+ `settings.json must be byte-identical after a failed update when ${label}`,
1385
+ );
1386
+ });
1387
+ }
1388
+ });
1389
+
1390
+ // ---------------------------------------------------------------------------
1391
+ // L4 — reversibility crux: a consumer hook on the SAME event as a managed one
1392
+ // survives merge + remove untouched (only tagged managed entries are removed).
1393
+ // ---------------------------------------------------------------------------
1394
+
1395
+ describe('S4 L4 — consumer hook on a managed event survives merge then remove', () => {
1396
+ let consumerDir;
1397
+
1398
+ beforeEach(() => {
1399
+ consumerDir = makeTmpDir();
1400
+ mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
1401
+ });
1402
+
1403
+ afterEach(() => {
1404
+ rmSync(consumerDir, { recursive: true, force: true });
1405
+ });
1406
+
1407
+ it('L4: removeHookSettings keeps the consumer SessionStart hook and drops only managed ones', async () => {
1408
+ const { mergeHookSettings, removeHookSettings, HOOK_TAG } = await importSettings();
1409
+
1410
+ // Consumer authored a hook on SessionStart — the SAME event the managed
1411
+ // steering-injector registers on. This is the reversibility crux: removing
1412
+ // managed entries must not collaterally remove the consumer's entry.
1413
+ const consumerHook = { type: 'command', command: 'my-session-start.sh' };
1414
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1415
+ writeFileSync(
1416
+ settingsPath,
1417
+ JSON.stringify(
1418
+ {
1419
+ hooks: {
1420
+ SessionStart: [{ matcher: '.*', hooks: [consumerHook] }],
1421
+ },
1422
+ },
1423
+ null,
1424
+ 2,
1425
+ ),
1426
+ 'utf8',
1427
+ );
1428
+
1429
+ // Merge the managed registrations (steering-injector is also SessionStart).
1430
+ await mergeHookSettings(consumerDir, [
1431
+ { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
1432
+ { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
1433
+ { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
1434
+ ]);
1435
+
1436
+ // After merge: SessionStart has BOTH the consumer entry and the managed one.
1437
+ {
1438
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1439
+ const ss = settings.hooks.SessionStart;
1440
+ const hasConsumer = ss.some((e) =>
1441
+ (e.hooks ?? []).some((h) => h.command === 'my-session-start.sh'),
1442
+ );
1443
+ const hasManaged = ss.some((e) => e[HOOK_TAG] === true);
1444
+ assert.ok(hasConsumer, 'consumer SessionStart hook must survive the merge');
1445
+ assert.ok(hasManaged, 'a managed SessionStart entry must be added by the merge');
1446
+ }
1447
+
1448
+ // Now remove managed entries.
1449
+ await removeHookSettings(consumerDir);
1450
+
1451
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1452
+ const ss = settings.hooks?.SessionStart ?? [];
1453
+
1454
+ // The consumer hook is intact and untagged.
1455
+ const consumerEntries = ss.filter((e) =>
1456
+ (e.hooks ?? []).some((h) => h.command === 'my-session-start.sh'),
1457
+ );
1458
+ assert.equal(consumerEntries.length, 1, 'consumer SessionStart hook must remain after remove');
1459
+ for (const e of consumerEntries) {
1460
+ assert.equal(e[HOOK_TAG], undefined, 'consumer entry must NOT carry the HOOK_TAG');
1461
+ for (const h of e.hooks ?? []) {
1462
+ assert.equal(h[HOOK_TAG], undefined, 'consumer hook must NOT carry the HOOK_TAG');
1463
+ }
1464
+ }
1465
+
1466
+ // No tagged managed entry remains on SessionStart.
1467
+ const managedLeft = ss.filter((e) => e[HOOK_TAG] === true);
1468
+ assert.equal(managedLeft.length, 0, 'all tagged managed SessionStart entries must be removed');
1469
+
1470
+ // The managed steering-injector command is gone entirely.
1471
+ const stillManaged = ss.some((e) =>
1472
+ (e.hooks ?? []).some(
1473
+ (h) => typeof h.command === 'string' && h.command.includes('steering-injector.sh'),
1474
+ ),
1475
+ );
1476
+ assert.ok(!stillManaged, 'managed steering-injector.sh registration must be removed');
1477
+ });
1478
+ });
1479
+
1480
+ // ---------------------------------------------------------------------------
1481
+ // M2 — wire hooks on the up-to-date short-circuit (already-latest consumer)
1482
+ // ---------------------------------------------------------------------------
1483
+
1484
+ describe('S4 M2 — up-to-date update still wires hooks already on disk', () => {
1485
+ let consumerDir;
1486
+
1487
+ beforeEach(() => {
1488
+ consumerDir = makeTmpDir();
1489
+ });
1490
+
1491
+ afterEach(() => {
1492
+ rmSync(consumerDir, { recursive: true, force: true });
1493
+ });
1494
+
1495
+ it('M2a: a consumer already on latest with hook scripts on disk but no settings gets them wired', async () => {
1496
+ // Pin the consumer to the latest available tag so update short-circuits.
1497
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1498
+
1499
+ // Simulate hook scripts already present on disk (from a prior install) but
1500
+ // settings.json never wired.
1501
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1502
+ mkdirSync(hooksDir, { recursive: true });
1503
+ for (const file of HOOK_FILES) {
1504
+ writeFileSync(
1505
+ path.join(hooksDir, file),
1506
+ `#!/bin/sh\n# managed-by: dev-agents-sync v2.0.0\nexit 0\n`,
1507
+ 'utf8',
1508
+ );
1509
+ }
1510
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1511
+ assert.ok(!existsSync(settingsPath), 'settings.json must not pre-exist for this test');
1512
+
1513
+ const result = await runUpdate(consumerDir, {
1514
+ fetcher: makeFixtureFetcher(),
1515
+ availableTags: ['v1.0.0'],
1516
+ });
1517
+
1518
+ assert.ok(result.upToDate === true, 'update must report up-to-date (short-circuit taken)');
1519
+ assert.ok(existsSync(settingsPath), 'settings.json must be wired even on the up-to-date path');
1520
+
1521
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1522
+ const hooks = settings.hooks ?? {};
1523
+ const allCommands = [];
1524
+ for (const entries of Object.values(hooks)) {
1525
+ if (Array.isArray(entries)) {
1526
+ for (const entry of entries) {
1527
+ for (const h of entry.hooks ?? []) {
1528
+ if (h.command) allCommands.push(h.command);
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ for (const file of HOOK_FILES) {
1534
+ assert.ok(
1535
+ allCommands.some((cmd) => cmd.includes(file)),
1536
+ `up-to-date update must wire ${file} into settings.json`,
1537
+ );
1538
+ }
1539
+ });
1540
+
1541
+ it('M2b: up-to-date wiring is idempotent (second run adds no duplicates)', async () => {
1542
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1543
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1544
+ mkdirSync(hooksDir, { recursive: true });
1545
+ for (const file of HOOK_FILES) {
1546
+ writeFileSync(path.join(hooksDir, file), `#!/bin/sh\nexit 0\n`, 'utf8');
1547
+ }
1548
+
1549
+ const opts = { fetcher: makeFixtureFetcher(), availableTags: ['v1.0.0'] };
1550
+ await runUpdate(consumerDir, opts);
1551
+ await runUpdate(consumerDir, opts);
1552
+
1553
+ const settings = JSON.parse(
1554
+ readFileSync(path.join(consumerDir, '.claude', 'settings.json'), 'utf8'),
1555
+ );
1556
+ const hooks = settings.hooks ?? {};
1557
+ const allCommands = [];
1558
+ for (const entries of Object.values(hooks)) {
1559
+ if (Array.isArray(entries)) {
1560
+ for (const entry of entries) {
1561
+ for (const h of entry.hooks ?? []) {
1562
+ if (h.command) allCommands.push(h.command);
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+ for (const file of HOOK_FILES) {
1568
+ const count = allCommands.filter((cmd) => cmd.includes(file)).length;
1569
+ assert.ok(count <= 1, `${file} must appear at most once after two up-to-date runs (found ${count})`);
1570
+ }
1571
+ });
1572
+
1573
+ it('M2c: up-to-date dry-run wires nothing', async () => {
1574
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1575
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1576
+ mkdirSync(hooksDir, { recursive: true });
1577
+ for (const file of HOOK_FILES) {
1578
+ writeFileSync(path.join(hooksDir, file), `#!/bin/sh\nexit 0\n`, 'utf8');
1579
+ }
1580
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1581
+
1582
+ await runUpdate(consumerDir, {
1583
+ dryRun: true,
1584
+ fetcher: makeFixtureFetcher(),
1585
+ availableTags: ['v1.0.0'],
1586
+ });
1587
+
1588
+ assert.ok(!existsSync(settingsPath), 'up-to-date --dry-run must not create settings.json');
1589
+ });
1590
+ });
1591
+
1592
+ // ---------------------------------------------------------------------------
1593
+ // H12 — privacy: CLI never logs the auth token
1594
+ // ---------------------------------------------------------------------------
1595
+
1596
+ describe('S4 H12 — privacy: auth token never logged in hook install', () => {
1597
+ let consumerDir;
1598
+
1599
+ beforeEach(() => {
1600
+ consumerDir = makeTmpDir();
1601
+ });
1602
+
1603
+ afterEach(() => {
1604
+ rmSync(consumerDir, { recursive: true, force: true });
1605
+ });
1606
+
1607
+ it('H12a: update with a token never writes the token to any installed hook script', async () => {
1608
+ const secretToken = 'ghp_SUPERSECRETTOKEN_S4_TEST';
1609
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1610
+
1611
+ await runUpdate(consumerDir, {
1612
+ token: secretToken,
1613
+ fetcher: makeFixtureFetcher(),
1614
+ availableTags: ['v1.0.0', 'v1.2.0'],
1615
+ });
1616
+
1617
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
1618
+ for (const file of HOOK_FILES) {
1619
+ const absPath = path.join(hooksDir, file);
1620
+ if (!existsSync(absPath)) continue;
1621
+ const content = readFileSync(absPath, 'utf8');
1622
+ assert.ok(
1623
+ !content.includes(secretToken),
1624
+ `${file} must not contain the auth token (privacy invariant)`,
1625
+ );
1626
+ }
1627
+ });
1628
+
1629
+ it('H12b: update with a token never writes the token to settings.json', async () => {
1630
+ const secretToken = 'ghp_SUPERSECRETTOKEN_SETTINGS';
1631
+ setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
1632
+
1633
+ await runUpdate(consumerDir, {
1634
+ token: secretToken,
1635
+ fetcher: makeFixtureFetcher(),
1636
+ availableTags: ['v1.0.0', 'v1.2.0'],
1637
+ });
1638
+
1639
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
1640
+ if (existsSync(settingsPath)) {
1641
+ const content = readFileSync(settingsPath, 'utf8');
1642
+ assert.ok(
1643
+ !content.includes(secretToken),
1644
+ 'settings.json must not contain the auth token (privacy invariant)',
1645
+ );
1646
+ }
1647
+ // If settings.json was not created, the invariant is trivially satisfied.
1648
+ });
1649
+
1650
+ it('H12c: init with a token never writes the token to the lockfile', async () => {
1651
+ const secretToken = 'ghp_SUPERSECRETTOKEN_INIT';
1652
+
1653
+ await runInit(consumerDir, {
1654
+ targets: ['claude'],
1655
+ token: secretToken,
1656
+ fetcher: makeFixtureFetcher(),
1657
+ availableTags: AVAILABLE_TAGS,
1658
+ });
1659
+
1660
+ const raw = readFileSync(path.join(consumerDir, '.dev-agents-sync.json'), 'utf8');
1661
+ assert.ok(
1662
+ !raw.includes(secretToken),
1663
+ 'lockfile must not contain the auth token (privacy invariant)',
1664
+ );
1665
+ });
1666
+ });