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