@dalzoubi/dev-agents-sync 2.0.15 → 2.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/package.json +1 -1
- package/src/cli.mjs +1 -0
- package/src/commands/init.mjs +34 -0
- package/src/commands/update.mjs +63 -2
- package/src/hooks.mjs +47 -0
- package/src/lockfile.mjs +20 -0
- package/src/settings.mjs +268 -0
- package/src/steering.mjs +117 -0
- package/src/writer.mjs +19 -1
- package/tests/e2e/steering-hooks-flow.test.mjs +654 -0
- package/tests/hooks-cli-install.test.mjs +1666 -0
- package/tests/steering-agent-behavior.test.mjs +491 -0
- package/tests/steering-seed.test.mjs +645 -0
|
@@ -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
|
+
});
|