@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,654 @@
1
+ /**
2
+ * tests/e2e/steering-hooks-flow.test.mjs
3
+ *
4
+ * Slice S5 — Docs + e2e + back-compat.
5
+ * Spec: docs/specs/260616-agent-steering-and-hooks/
6
+ *
7
+ * Approach: node:test file in tests/e2e/ — matches the established pattern for
8
+ * this project (claudeMd-injection.test.mjs, readme-coverage.test.mjs, etc.).
9
+ * The e2e suite is `node --test tests/e2e/*.test.mjs` per `npm run test:e2e`.
10
+ *
11
+ * What these tests prove (cross-slice integration, NOT re-testing S1–S4 units):
12
+ *
13
+ * E1. INTEGRATED FLOW — init → update → check in sequence:
14
+ * E1a. .claude/steering/{decisions,glossary,architecture}.md present after init.
15
+ * E1b. Steering files have no managed-by marker after init (consumer-owned).
16
+ * E1c. Steering files are not referenced in the lockfile after init.
17
+ * E1d. .claude/hooks/{steering-injector,managed-file-guard,uncommitted-check}.sh
18
+ * present, executable, and lockfile-tracked after update.
19
+ * E1e. .claude/settings.json contains a registration for each of the three hooks
20
+ * after update.
21
+ * E1f. A SECOND update call produces byte-identical hooks and settings.json
22
+ * (idempotent no-op across the whole integrated path).
23
+ * E1g. check exits 0 on the fully-synced tree (steering + hooks + settings in place).
24
+ *
25
+ * E2. OLD-LOCKFILE UPGRADE (§8 back-compat e2e):
26
+ * E2a. update against a pre-hooks lockfile (no `hooks` field) does not throw.
27
+ * E2b. After the upgrade, hook scripts are installed.
28
+ * E2c. After the upgrade, settings.json is wired with the three hooks.
29
+ * E2d. After the upgrade, the lockfile references the installed hooks.
30
+ * E2e. check exits 0 on the upgraded tree.
31
+ *
32
+ * E3. README covers steering+hooks (cross-cutting doc criterion):
33
+ * E3a. README mentions "steering" in the context of the init/update flow.
34
+ * E3b. README mentions "hooks" and "settings.json" together.
35
+ * E3c. README documents "--no-hooks-wiring".
36
+ *
37
+ * These tests are RED until the implement agent wires the behaviors described in
38
+ * S1 (steering seed), S3 (hook scripts in fetcher), and S4 (CLI install + settings
39
+ * merge) into the e2e integrated path.
40
+ *
41
+ * Test runner: `node --test packages/dev-agents-sync/tests/e2e/steering-hooks-flow.test.mjs`
42
+ * No third-party dependencies. All stateful tests use isolated tmp dirs.
43
+ *
44
+ * i18n/accessibility: N/A — CLI-only, no translated strings, no UI surface.
45
+ */
46
+
47
+ import { describe, it, beforeEach, afterEach } from 'node:test';
48
+ import assert from 'node:assert/strict';
49
+ import {
50
+ mkdtempSync,
51
+ mkdirSync,
52
+ writeFileSync,
53
+ readFileSync,
54
+ existsSync,
55
+ statSync,
56
+ rmSync,
57
+ readdirSync,
58
+ } from 'node:fs';
59
+ import { tmpdir } from 'node:os';
60
+ import path from 'node:path';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ import { runInit } from '../../src/commands/init.mjs';
64
+ import { runUpdate } from '../../src/commands/update.mjs';
65
+ import { runCheck } from '../../src/commands/check.mjs';
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Paths
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
72
+ const FIXTURE_DIR = path.join(__dirname, '..', 'fixtures', 'release-v1.0.0');
73
+ const README_PATH = path.join(__dirname, '..', '..', 'README.md');
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Constants
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const STEERING_FILES = ['decisions.md', 'glossary.md', 'architecture.md'];
80
+
81
+ const HOOK_FILES = [
82
+ 'steering-injector.sh',
83
+ 'managed-file-guard.sh',
84
+ 'uncommitted-check.sh',
85
+ ];
86
+
87
+ /**
88
+ * Events paired with the hook script that handles each one. Used to assert
89
+ * that settings.json wiring is complete without hard-coding the internal
90
+ * shape (the implement agent owns that).
91
+ */
92
+ const HOOK_EVENT_PAIRS = [
93
+ { event: 'SessionStart', script: 'steering-injector.sh' },
94
+ { event: 'PostToolUse', script: 'managed-file-guard.sh' },
95
+ { event: 'Stop', script: 'uncommitted-check.sh' },
96
+ ];
97
+
98
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
99
+ const LOCKFILE_NAME = '.dev-agents-sync.json';
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Fixture helpers — same pattern as claudeMd-injection.test.mjs
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function collectFiles(baseDir, currentDir, map, prefix) {
106
+ const entries = readdirSync(currentDir, { withFileTypes: true });
107
+ for (const entry of entries) {
108
+ const full = path.join(currentDir, entry.name);
109
+ if (entry.isDirectory()) {
110
+ collectFiles(baseDir, full, map, prefix);
111
+ } else {
112
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
113
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Builds a fixture file map from the release-v1.0.0 fixture directory,
120
+ * extended with the three hook scripts that S3's build.mjs would emit.
121
+ * These use the shell-comment marker format for .sh files.
122
+ */
123
+ function buildFixtureFileMap(targets = ['claude']) {
124
+ const map = {};
125
+ for (const target of targets) {
126
+ const targetDir = path.join(FIXTURE_DIR, target);
127
+ if (!existsSync(targetDir)) continue;
128
+ collectFiles(targetDir, targetDir, map, target);
129
+ }
130
+
131
+ // Hook scripts injected as if emitted by S3's build pipeline.
132
+ // They carry the shell-comment marker (POSIX-sh format, not HTML).
133
+ map['claude/hooks/steering-injector.sh'] =
134
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# SessionStart: surface steering context.\n[ -d .claude/steering ] && cat .claude/steering/*.md 2>/dev/null >&2\nexit 0\n';
135
+ map['claude/hooks/managed-file-guard.sh'] =
136
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# PostToolUse: warn on managed-file edits.\nexit 0\n';
137
+ map['claude/hooks/uncommitted-check.sh'] =
138
+ '#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# Stop: remind about uncommitted work.\nexit 0\n';
139
+
140
+ return map;
141
+ }
142
+
143
+ function makeFixtureFetcher() {
144
+ return async (_repo, _tag, _token) => buildFixtureFileMap(['claude']);
145
+ }
146
+
147
+ function makeTmpDir() {
148
+ return mkdtempSync(path.join(tmpdir(), 'das-e2e-steering-hooks-'));
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Assertions helpers (readable error messages)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Asserts that settings.json at .claude/settings.json under consumerDir
157
+ * contains at least one hook registration referencing scriptName under eventName.
158
+ * Does NOT assert the exact shape — the implement agent owns that.
159
+ */
160
+ function assertSettingsWired(consumerDir, eventName, scriptName) {
161
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
162
+ assert.ok(
163
+ existsSync(settingsPath),
164
+ `.claude/settings.json must exist after update (required by settings wiring for ${scriptName})`,
165
+ );
166
+
167
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
168
+ const hooks = settings.hooks ?? {};
169
+
170
+ // Walk the hooks object and collect all commands.
171
+ const allCommands = [];
172
+ for (const entries of Object.values(hooks)) {
173
+ if (!Array.isArray(entries)) continue;
174
+ for (const entry of entries) {
175
+ const hookList = Array.isArray(entry.hooks) ? entry.hooks : [];
176
+ for (const h of hookList) {
177
+ if (typeof h.command === 'string') allCommands.push(h.command);
178
+ }
179
+ }
180
+ }
181
+
182
+ assert.ok(
183
+ allCommands.some((cmd) => cmd.includes(scriptName)),
184
+ `settings.json must contain a hook command referencing "${scriptName}" ` +
185
+ `(expected under event "${eventName}").\n` +
186
+ `All wired commands: ${JSON.stringify(allCommands, null, 2)}`,
187
+ );
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Shared setup: write a minimal pre-initialized lockfile (no hooks section).
192
+ // Mirrors the approach in hooks-cli-install.test.mjs's setupInitializedRepo.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function writePreHooksLockfile(consumerDir) {
196
+ const raw =
197
+ JSON.stringify(
198
+ {
199
+ $schema:
200
+ 'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
201
+ source: 'github:dalzoubi/dev-agents',
202
+ range: '^1',
203
+ resolvedVersion: '1.0.0',
204
+ targets: ['claude'],
205
+ lastUpdated: '2026-01-01T00:00:00Z',
206
+ // Intentionally omits the `hooks` field — this is the pre-S4 format.
207
+ },
208
+ null,
209
+ 2,
210
+ ) + '\n';
211
+ writeFileSync(path.join(consumerDir, LOCKFILE_NAME), raw, 'utf8');
212
+ }
213
+
214
+ /** Seeds the minimum managed-file that update needs to find in the consumer tree. */
215
+ function seedManagedFile(consumerDir) {
216
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
217
+ mkdirSync(agentsDir, { recursive: true });
218
+ writeFileSync(
219
+ path.join(agentsDir, 'define.md'),
220
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
221
+ 'utf8',
222
+ );
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // E1 — integrated init → update → check flow
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe('S5 E1 — integrated init → update → check flow', () => {
230
+ let consumerDir;
231
+
232
+ beforeEach(() => {
233
+ consumerDir = makeTmpDir();
234
+ });
235
+
236
+ afterEach(() => {
237
+ rmSync(consumerDir, { recursive: true, force: true });
238
+ });
239
+
240
+ // E1a — steering files present after init
241
+ it('E1a: init writes .claude/steering/{decisions,glossary,architecture}.md when absent', async () => {
242
+ // This is the INTEGRATED assertion: init as the first step of the full
243
+ // init → update → check sequence must produce the steering scaffold.
244
+ await runInit(consumerDir, {
245
+ targets: ['claude'],
246
+ fetcher: makeFixtureFetcher(),
247
+ availableTags: AVAILABLE_TAGS,
248
+ });
249
+
250
+ const steeringDir = path.join(consumerDir, '.claude', 'steering');
251
+ assert.ok(
252
+ existsSync(steeringDir),
253
+ '.claude/steering/ must exist after init in the integrated flow',
254
+ );
255
+
256
+ for (const file of STEERING_FILES) {
257
+ assert.ok(
258
+ existsSync(path.join(steeringDir, file)),
259
+ `init must write .claude/steering/${file} as the first step of the integrated flow`,
260
+ );
261
+ }
262
+ });
263
+
264
+ // E1b — steering files have no managed-by marker
265
+ it('E1b: init-seeded steering files carry no managed-by marker (consumer-owned)', async () => {
266
+ await runInit(consumerDir, {
267
+ targets: ['claude'],
268
+ fetcher: makeFixtureFetcher(),
269
+ availableTags: AVAILABLE_TAGS,
270
+ });
271
+
272
+ const steeringDir = path.join(consumerDir, '.claude', 'steering');
273
+ for (const file of STEERING_FILES) {
274
+ const content = readFileSync(path.join(steeringDir, file), 'utf8');
275
+ assert.ok(
276
+ !content.includes('managed-by'),
277
+ `steering/${file} must NOT carry the managed-by marker — it is consumer-owned`,
278
+ );
279
+ }
280
+ });
281
+
282
+ // E1c — steering files not in lockfile after init
283
+ it('E1c: steering files are not referenced in the lockfile after init', async () => {
284
+ await runInit(consumerDir, {
285
+ targets: ['claude'],
286
+ fetcher: makeFixtureFetcher(),
287
+ availableTags: AVAILABLE_TAGS,
288
+ });
289
+
290
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
291
+ assert.ok(
292
+ !raw.includes('steering/'),
293
+ `lockfile must not reference any steering/* path after init.\nLockfile content:\n${raw}`,
294
+ );
295
+ });
296
+
297
+ // E1d — hooks present, executable, lockfile-tracked after update
298
+ it('E1d: update installs executable, lockfile-tracked .claude/hooks/*.sh scripts', async () => {
299
+ // Run init first (step 1 of the integrated sequence).
300
+ await runInit(consumerDir, {
301
+ targets: ['claude'],
302
+ fetcher: makeFixtureFetcher(),
303
+ availableTags: AVAILABLE_TAGS,
304
+ });
305
+
306
+ // Run update (step 2).
307
+ await runUpdate(consumerDir, {
308
+ fetcher: makeFixtureFetcher(),
309
+ availableTags: AVAILABLE_TAGS,
310
+ });
311
+
312
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
313
+ for (const file of HOOK_FILES) {
314
+ const absPath = path.join(hooksDir, file);
315
+
316
+ assert.ok(existsSync(absPath), `update must install .claude/hooks/${file}`);
317
+
318
+ const st = statSync(absPath);
319
+ assert.ok(
320
+ (st.mode & 0o111) !== 0,
321
+ `${file} must be executable after install (mode: 0o${st.mode.toString(8)})`,
322
+ );
323
+ }
324
+
325
+ // Lockfile must reference each installed hook script.
326
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
327
+ for (const file of HOOK_FILES) {
328
+ assert.ok(
329
+ raw.includes(file),
330
+ `lockfile must reference ${file} (it is a lockfile-tracked managed file).\nLockfile:\n${raw}`,
331
+ );
332
+ }
333
+ });
334
+
335
+ // E1e — settings.json wired with all three hooks after update
336
+ it('E1e: .claude/settings.json is wired with registrations for all three hooks after update', async () => {
337
+ await runInit(consumerDir, {
338
+ targets: ['claude'],
339
+ fetcher: makeFixtureFetcher(),
340
+ availableTags: AVAILABLE_TAGS,
341
+ });
342
+
343
+ await runUpdate(consumerDir, {
344
+ fetcher: makeFixtureFetcher(),
345
+ availableTags: AVAILABLE_TAGS,
346
+ });
347
+
348
+ for (const { event, script } of HOOK_EVENT_PAIRS) {
349
+ assertSettingsWired(consumerDir, event, script);
350
+ }
351
+ });
352
+
353
+ // E1f — second update is a no-op (byte-identical hooks + settings)
354
+ it('E1f: a second update call is a byte-identical no-op (hooks + settings unchanged)', async () => {
355
+ await runInit(consumerDir, {
356
+ targets: ['claude'],
357
+ fetcher: makeFixtureFetcher(),
358
+ availableTags: AVAILABLE_TAGS,
359
+ });
360
+
361
+ const updateOpts = {
362
+ fetcher: makeFixtureFetcher(),
363
+ availableTags: AVAILABLE_TAGS,
364
+ };
365
+
366
+ // First update.
367
+ await runUpdate(consumerDir, updateOpts);
368
+
369
+ // Capture state after first update.
370
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
371
+ const hookSnapshots = {};
372
+ for (const file of HOOK_FILES) {
373
+ hookSnapshots[file] = readFileSync(path.join(hooksDir, file), 'utf8');
374
+ }
375
+ const settingsPath = path.join(consumerDir, '.claude', 'settings.json');
376
+ const settingsSnapshot = readFileSync(settingsPath, 'utf8');
377
+
378
+ // Second update — must be a no-op.
379
+ await runUpdate(consumerDir, updateOpts);
380
+
381
+ // Hooks must be byte-identical.
382
+ for (const file of HOOK_FILES) {
383
+ const after = readFileSync(path.join(hooksDir, file), 'utf8');
384
+ assert.equal(
385
+ after,
386
+ hookSnapshots[file],
387
+ `${file} must be byte-identical after a second update (integrated idempotency)`,
388
+ );
389
+ }
390
+
391
+ // settings.json must be byte-identical.
392
+ const settingsAfter = readFileSync(settingsPath, 'utf8');
393
+ assert.equal(
394
+ settingsAfter,
395
+ settingsSnapshot,
396
+ 'settings.json must be byte-identical after a second update (idempotent merge)',
397
+ );
398
+ });
399
+
400
+ // E1g — check exits 0 after init → update
401
+ it('E1g: check exits 0 on the fully-synced tree (steering + hooks + settings in place)', async () => {
402
+ // Run the full init → update sequence.
403
+ await runInit(consumerDir, {
404
+ targets: ['claude'],
405
+ fetcher: makeFixtureFetcher(),
406
+ availableTags: AVAILABLE_TAGS,
407
+ });
408
+
409
+ await runUpdate(consumerDir, {
410
+ fetcher: makeFixtureFetcher(),
411
+ availableTags: AVAILABLE_TAGS,
412
+ });
413
+
414
+ // check must exit 0 — all managed files (hooks) in sync.
415
+ let exitCode = 0;
416
+ try {
417
+ await runCheck(consumerDir, {
418
+ fetcher: makeFixtureFetcher(),
419
+ availableTags: ['v2.0.0'],
420
+ });
421
+ } catch (err) {
422
+ exitCode = err.exitCode ?? 1;
423
+ }
424
+
425
+ assert.equal(
426
+ exitCode,
427
+ 0,
428
+ 'check must exit 0 when init → update has been run and all managed files are in sync ' +
429
+ '(steering seeded, hooks installed, settings wired)',
430
+ );
431
+ });
432
+ });
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // E2 — old-lockfile upgrade (§8 back-compat) e2e
436
+ // ---------------------------------------------------------------------------
437
+
438
+ describe('S5 E2 — old-lockfile-without-hooks upgrade (§8 back-compat) e2e', () => {
439
+ let consumerDir;
440
+
441
+ beforeEach(() => {
442
+ consumerDir = makeTmpDir();
443
+ });
444
+
445
+ afterEach(() => {
446
+ rmSync(consumerDir, { recursive: true, force: true });
447
+ });
448
+
449
+ /**
450
+ * Sets up a consumer repo that looks like it was initialized with a pre-S4
451
+ * version of the CLI: lockfile present but with no `hooks` field, one
452
+ * managed agent file present on disk.
453
+ */
454
+ function setupPreHooksConsumer() {
455
+ writePreHooksLockfile(consumerDir);
456
+ seedManagedFile(consumerDir);
457
+ }
458
+
459
+ // E2a — update does not throw on a pre-hooks lockfile
460
+ it('E2a: update against a pre-hooks lockfile (no hooks field) does not throw', async () => {
461
+ setupPreHooksConsumer();
462
+
463
+ await assert.doesNotReject(
464
+ runUpdate(consumerDir, {
465
+ fetcher: makeFixtureFetcher(),
466
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
467
+ }),
468
+ 'update must not throw when the lockfile has no hooks section (additive §8 upgrade)',
469
+ );
470
+ });
471
+
472
+ // E2b — hook scripts are installed after the upgrade
473
+ it('E2b: hook scripts are installed after upgrading a pre-hooks lockfile', async () => {
474
+ setupPreHooksConsumer();
475
+
476
+ await runUpdate(consumerDir, {
477
+ fetcher: makeFixtureFetcher(),
478
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
479
+ });
480
+
481
+ const hooksDir = path.join(consumerDir, '.claude', 'hooks');
482
+ for (const file of HOOK_FILES) {
483
+ assert.ok(
484
+ existsSync(path.join(hooksDir, file)),
485
+ `${file} must be installed after upgrading a pre-hooks lockfile`,
486
+ );
487
+ }
488
+ });
489
+
490
+ // E2c — settings.json is wired after the upgrade
491
+ it('E2c: settings.json is wired with hook registrations after upgrading a pre-hooks lockfile', async () => {
492
+ setupPreHooksConsumer();
493
+
494
+ await runUpdate(consumerDir, {
495
+ fetcher: makeFixtureFetcher(),
496
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
497
+ });
498
+
499
+ for (const { event, script } of HOOK_EVENT_PAIRS) {
500
+ assertSettingsWired(consumerDir, event, script);
501
+ }
502
+ });
503
+
504
+ // E2d — lockfile references installed hooks after upgrade
505
+ it('E2d: upgraded lockfile references the installed hook scripts (additive §8 upgrade)', async () => {
506
+ setupPreHooksConsumer();
507
+
508
+ await runUpdate(consumerDir, {
509
+ fetcher: makeFixtureFetcher(),
510
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
511
+ });
512
+
513
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
514
+ for (const file of HOOK_FILES) {
515
+ assert.ok(
516
+ raw.includes(file),
517
+ `upgraded lockfile must reference ${file} (additive hooks section).\nLockfile:\n${raw}`,
518
+ );
519
+ }
520
+ });
521
+
522
+ // E2e — check exits 0 after upgrade
523
+ it('E2e: check exits 0 after upgrading a pre-hooks lockfile (upgrade is clean)', async () => {
524
+ setupPreHooksConsumer();
525
+
526
+ await runUpdate(consumerDir, {
527
+ fetcher: makeFixtureFetcher(),
528
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
529
+ });
530
+
531
+ let exitCode = 0;
532
+ try {
533
+ await runCheck(consumerDir, {
534
+ fetcher: makeFixtureFetcher(),
535
+ availableTags: ['v2.0.0'],
536
+ });
537
+ } catch (err) {
538
+ exitCode = err.exitCode ?? 1;
539
+ }
540
+
541
+ assert.equal(
542
+ exitCode,
543
+ 0,
544
+ 'check must exit 0 after upgrading a pre-hooks lockfile — the upgrade must leave a ' +
545
+ 'fully-synced tree with no drift',
546
+ );
547
+ });
548
+
549
+ // E2f — old-lockfile steering also seeded during upgrade
550
+ it('E2f: update on a pre-hooks lockfile also seeds steering scaffold when absent', async () => {
551
+ // This verifies cross-slice interaction: S1 (steering seed) + §8 (old lockfile)
552
+ // work together — the upgrade path also runs the seed-if-absent logic.
553
+ setupPreHooksConsumer();
554
+
555
+ await runUpdate(consumerDir, {
556
+ fetcher: makeFixtureFetcher(),
557
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
558
+ });
559
+
560
+ const steeringDir = path.join(consumerDir, '.claude', 'steering');
561
+ assert.ok(
562
+ existsSync(steeringDir),
563
+ '.claude/steering/ must be seeded during upgrade from a pre-hooks lockfile',
564
+ );
565
+ for (const file of STEERING_FILES) {
566
+ assert.ok(
567
+ existsSync(path.join(steeringDir, file)),
568
+ `steering/${file} must be seeded during the pre-hooks upgrade`,
569
+ );
570
+ }
571
+
572
+ // And steering still must not land in the lockfile.
573
+ const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
574
+ assert.ok(
575
+ !raw.includes('steering/'),
576
+ `lockfile must not reference steering/* even after pre-hooks upgrade.\nLockfile:\n${raw}`,
577
+ );
578
+ });
579
+ });
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // E3 — README documents steering and hooks
583
+ // ---------------------------------------------------------------------------
584
+
585
+ describe('S5 E3 — README documents steering layer and hooks', () => {
586
+ function readReadme() {
587
+ assert.ok(
588
+ existsSync(README_PATH),
589
+ `README.md must exist at ${README_PATH}`,
590
+ );
591
+ return readFileSync(README_PATH, 'utf8');
592
+ }
593
+
594
+ // E3a — README mentions "steering" near init/update
595
+ it('E3a: README mentions "steering" in the context of the init/update flow', () => {
596
+ const content = readReadme();
597
+
598
+ // Must mention steering (the scaffold) somewhere.
599
+ assert.ok(
600
+ content.includes('steering'),
601
+ 'README.md must mention "steering" to document the steering scaffold feature',
602
+ );
603
+
604
+ // Must also mention the three file names or "decisions", "glossary", "architecture"
605
+ // so consumers know what files will be seeded.
606
+ const mentionsSteeringFiles =
607
+ content.includes('decisions.md') ||
608
+ content.includes('glossary.md') ||
609
+ content.includes('architecture.md') ||
610
+ (content.includes('decisions') && content.includes('glossary'));
611
+
612
+ assert.ok(
613
+ mentionsSteeringFiles,
614
+ 'README.md must document at least one steering file name ' +
615
+ '(decisions.md, glossary.md, or architecture.md) ' +
616
+ 'so consumers understand what the scaffold contains',
617
+ );
618
+ });
619
+
620
+ // E3b — README mentions "hooks" and "settings.json" together
621
+ it('E3b: README mentions both "hooks" and "settings.json" as part of the wiring documentation', () => {
622
+ const content = readReadme();
623
+
624
+ assert.ok(
625
+ content.includes('hooks'),
626
+ 'README.md must mention "hooks" to document the event-driven hook scripts',
627
+ );
628
+
629
+ assert.ok(
630
+ content.includes('settings.json'),
631
+ 'README.md must mention "settings.json" to document the auto-wiring feature',
632
+ );
633
+
634
+ // Both must appear in some proximity (within 1500 chars of each other).
635
+ const hooksIdx = content.indexOf('hooks');
636
+ const settingsIdx = content.indexOf('settings.json');
637
+ assert.ok(
638
+ Math.abs(hooksIdx - settingsIdx) < 1500,
639
+ 'README.md must describe "hooks" and "settings.json" in proximity ' +
640
+ `(found hooks at ${hooksIdx}, settings.json at ${settingsIdx}, distance: ${Math.abs(hooksIdx - settingsIdx)})`,
641
+ );
642
+ });
643
+
644
+ // E3c — README documents "--no-hooks-wiring" opt-out
645
+ it('E3c: README documents the --no-hooks-wiring opt-out flag', () => {
646
+ const content = readReadme();
647
+
648
+ assert.ok(
649
+ content.includes('--no-hooks-wiring'),
650
+ 'README.md must document the "--no-hooks-wiring" flag so consumers know how ' +
651
+ 'to install hook scripts without auto-wiring settings.json',
652
+ );
653
+ });
654
+ });