@dalzoubi/dev-agents-sync 3.0.4 → 3.0.5
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
CHANGED
|
@@ -126,6 +126,8 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
|
|
|
126
126
|
| `handoff-state-guard.sh` | `PreToolUse` | `Write` | **blocking** | Denies a write to `.claude/state/*.json` whose `handoff.status`, `from_agent`, or `to_agent` is outside the vocabulary `.claude/rules/handoff.md` defines. |
|
|
127
127
|
| `spec-drift-warn.sh` | `PostToolUse` | `Edit\|Write` | warning | After an edit to `spec.md` / `design.md` / `tasks.md` under `docs/specs/`, warns when that folder's `spec.md` has already moved past `Draft`. Draft folders stay silent. |
|
|
128
128
|
| `pre-pr-preflight.sh` | `PreToolUse` | `Bash` | warning | On a command containing `gh pr create`, prints `reminder: run /deliver-preflight before opening a PR — it checks the branch is clean, rebased, and green`. Never blocks. |
|
|
129
|
+
| `main-sync-fetch.sh` | `SessionStart` | `.*` | warning | Resolves the default branch (`origin/HEAD`, fallback local `main` then `master`) and runs `git fetch origin <default>` once per session, printing the fetched ref+sha or a one-line skip reason to session context. Never blocks. |
|
|
130
|
+
| `worktree-guard.sh` | `PreToolUse` | `Bash`, `Edit\|Write` | **blocking** | Denies an `Edit`/`Write` or a `git commit` landing in the main checkout when its current branch is also checked out in an existing `/deliver`-managed worktree under `.deliver/worktrees/<slug>/`, naming the worktree path to use instead. Exempts `docs/specs/`, `.agents/state/`, and `.deliver/` itself. Fails open on no git repo, no worktree entries, or no branch match. |
|
|
129
131
|
|
|
130
132
|
Hook scripts use the shell-comment marker form `# managed-by: dev-agents-sync vX.Y.Z`. Like other managed files, the CLI overwrites them on `update` and `check` reports drift (exit 1) if one is deleted or locally modified.
|
|
131
133
|
|
|
@@ -141,6 +143,7 @@ A blocking hook runs on `PreToolUse`, where exiting 2 denies the tool call and t
|
|
|
141
143
|
| `EHOOK_DIRECT_PUSH` | `direct-push-guard.sh` | `blocked: EHOOK_DIRECT_PUSH — direct push to protected branch 'main' — open a pull request instead; to override, set DEV_AGENTS_HOOKS_BYPASS=direct-push-guard` |
|
|
142
144
|
| `EHOOK_SPEC_APPROVAL` | `spec-approval-gate.sh` | `blocked: EHOOK_SPEC_APPROVAL — <path> sets Status: Approved while an unresolved clarification marker (a bare bracketed NEEDS CLARIFICATION token) remains in prose — resolve it via /define-analyze or /define before approving; to override, set DEV_AGENTS_HOOKS_BYPASS=spec-approval-gate` |
|
|
143
145
|
| `EHOOK_HANDOFF_STATE` | `handoff-state-guard.sh` | `blocked: EHOOK_HANDOFF_STATE — handoff.status "done" is outside the status enum — fix the value in the state file before writing, or re-run with DEV_AGENTS_HOOKS_BYPASS=handoff-state-guard` |
|
|
146
|
+
| `EHOOK_WORKTREE_GUARD` | `worktree-guard.sh` | `blocked: EHOOK_WORKTREE_GUARD — <branch> is also checked out at <worktree-path> — make this change there instead; to override, set DEV_AGENTS_HOOKS_BYPASS=worktree-guard` |
|
|
144
147
|
|
|
145
148
|
**A blocker denies only on a positive match, and fails open on everything else.** An unreadable payload, an unextractable path, or JSON it cannot parse with confidence produces at most one stderr note (for example `warning: handoff-state-guard stood down — <reason>`) and exit 0. A hook is never the reason a session breaks.
|
|
146
149
|
|
package/package.json
CHANGED
package/src/hook-manifest.mjs
CHANGED
|
@@ -15,6 +15,9 @@ export const HOOK_MANIFEST = Object.freeze([
|
|
|
15
15
|
{ event: 'PostToolUse', matcher: 'Edit|Write', file: 'spec-drift-warn.sh' },
|
|
16
16
|
{ event: 'PreToolUse', matcher: 'Bash', file: 'pre-pr-preflight.sh' },
|
|
17
17
|
{ event: 'Stop', matcher: '.*', file: 'ui-verification-reminder.sh' },
|
|
18
|
+
{ event: 'SessionStart', matcher: '.*', file: 'main-sync-fetch.sh' },
|
|
19
|
+
{ event: 'PreToolUse', matcher: 'Bash', file: 'worktree-guard.sh' },
|
|
20
|
+
{ event: 'PreToolUse', matcher: 'Edit|Write', file: 'worktree-guard.sh' },
|
|
18
21
|
]);
|
|
19
22
|
|
|
20
23
|
export function hookPathFor(target, file) {
|
package/src/settings.mjs
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* preserved untouched. We only append our own registrations — and remove
|
|
12
12
|
* our own registrations for RETIRED_HOOK_SCRIPTS, never anyone else's.
|
|
13
13
|
* - Idempotent (§9): a second merge adds no duplicates. Identity is the
|
|
14
|
-
*
|
|
14
|
+
* (event, matcher, command) triple, so re-running is a no-op. Matcher is
|
|
15
|
+
* part of identity because one script can need two independent
|
|
16
|
+
* registrations under the SAME event with DIFFERENT matchers (e.g. a
|
|
17
|
+
* guard that fires on both `Bash` and `Edit|Write`); command alone would
|
|
18
|
+
* make the second registration look "already wired" and silently drop it.
|
|
15
19
|
* - Tagged (§4): each injected hook entry carries `HOOK_TAG: true` so it is
|
|
16
20
|
* identifiable and removable later (update/uninstall) without touching
|
|
17
21
|
* consumer-authored entries. Consumer entries never receive the tag.
|
|
@@ -123,10 +127,23 @@ function managedCommandsIn(entry) {
|
|
|
123
127
|
return hookList.filter((h) => h && typeof h.command === 'string').map((h) => h.command);
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
130
|
+
/**
|
|
131
|
+
* True if `event` already has a CLI-managed entry with THIS `matcher` whose
|
|
132
|
+
* command targets `command`.
|
|
133
|
+
*
|
|
134
|
+
* Matcher is part of the identity check, not just command: a single script can
|
|
135
|
+
* legitimately need two registrations under the same event with different
|
|
136
|
+
* matchers (worktree-guard.sh registers on both `PreToolUse`/`Bash` and
|
|
137
|
+
* `PreToolUse`/`Edit|Write`). Matching on command alone would find the first
|
|
138
|
+
* registration's entry, conclude the second is "already wired," and silently
|
|
139
|
+
* never append it — each managed entry already carries its own `matcher` via
|
|
140
|
+
* `buildManagedEntry`, so filtering on it here costs nothing extra.
|
|
141
|
+
*/
|
|
142
|
+
function hasManagedCommand(eventEntries, matcher, command) {
|
|
128
143
|
if (!Array.isArray(eventEntries)) return false;
|
|
129
|
-
return eventEntries.some(
|
|
144
|
+
return eventEntries.some(
|
|
145
|
+
(entry) => entry?.matcher === matcher && managedCommandsIn(entry).includes(command),
|
|
146
|
+
);
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
/**
|
|
@@ -242,8 +259,10 @@ function appendMissingEntries(hooks, hookScripts) {
|
|
|
242
259
|
let added = false;
|
|
243
260
|
for (const { event, matcher, command } of hookScripts) {
|
|
244
261
|
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
245
|
-
// Already wired — idempotent no-op for this
|
|
246
|
-
|
|
262
|
+
// Already wired under THIS matcher — idempotent no-op for this
|
|
263
|
+
// registration (§9). A different matcher on the same event/command is a
|
|
264
|
+
// distinct registration and must still be appended below.
|
|
265
|
+
if (hasManagedCommand(existing, matcher, command)) continue;
|
|
247
266
|
hooks[event] = [...existing, buildManagedEntry({ matcher, command })];
|
|
248
267
|
added = true;
|
|
249
268
|
}
|
|
@@ -496,9 +515,26 @@ export const RETIRED_HOOK_SCRIPTS = [
|
|
|
496
515
|
// or by a future regression, and pruning it is always correct since that form
|
|
497
516
|
// is never in DEFAULT_HOOK_SCRIPTS.
|
|
498
517
|
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/ui-verification-reminder.sh' },
|
|
518
|
+
// main-sync-fetch.sh ships only in the `bash …` form — same reasoning as
|
|
519
|
+
// ui-verification-reminder.sh above: it never shipped bare, but the
|
|
520
|
+
// bare-path form is pruned defensively so a hand-edit or regression can't
|
|
521
|
+
// register it live alongside the real one. (Gap found by running the full
|
|
522
|
+
// package test suite while fixing the worktree-guard.sh matcher-dedup bug —
|
|
523
|
+
// this hook shipped in an earlier slice on the same branch (S4) without this
|
|
524
|
+
// entry and C3a caught it.)
|
|
525
|
+
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/main-sync-fetch.sh' },
|
|
499
526
|
// direct-push-guard.sh ships only in the `bash …` form — same reasoning as
|
|
500
527
|
// ui-verification-reminder.sh above: it never shipped bare, but the
|
|
501
528
|
// bare-path form is pruned defensively so a hand-edit or regression can't
|
|
502
529
|
// register it live alongside the real one.
|
|
503
530
|
{ event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/direct-push-guard.sh' },
|
|
531
|
+
// worktree-guard.sh ships only in the `bash …` form, under TWO matchers
|
|
532
|
+
// (PreToolUse/Bash and PreToolUse/Edit|Write) — same defensive reasoning as
|
|
533
|
+
// ui-verification-reminder.sh and direct-push-guard.sh above: it never
|
|
534
|
+
// shipped bare, but pruning the bare-path form guards against a hand-edit or
|
|
535
|
+
// regression registering it live alongside the real ones. One entry covers
|
|
536
|
+
// both matchers: the prune matches on COMMAND alone (never matcher) and
|
|
537
|
+
// scans every event bucket, so this single row de-registers a stray bare
|
|
538
|
+
// entry under either matcher.
|
|
539
|
+
{ event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/worktree-guard.sh' },
|
|
504
540
|
];
|
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
*
|
|
21
21
|
* C1. No registered command is a bare `.sh` path; each names an interpreter
|
|
22
22
|
* and points at its `script`.
|
|
23
|
-
* C2. `script` and `command` stay consistent
|
|
24
|
-
* the
|
|
23
|
+
* C2. `script` and `command` stay consistent, and the command is derived from
|
|
24
|
+
* the path rather than typed twice. A script may carry more than one row
|
|
25
|
+
* (e.g. worktree-guard.sh registers under both PreToolUse/Bash and
|
|
26
|
+
* PreToolUse/Edit|Write) but never two rows on the identical
|
|
27
|
+
* (event, matcher) — that would run it twice per matched call.
|
|
25
28
|
* C3. The pre-fix bare-path form of every shipped script is retired, so a
|
|
26
29
|
* consumer's stale registration is de-registered rather than left live
|
|
27
30
|
* alongside the new one.
|
|
@@ -240,13 +243,17 @@ describe('C2 — script and command are consistent across the table', () => {
|
|
|
240
243
|
}
|
|
241
244
|
});
|
|
242
245
|
|
|
243
|
-
it('C2c:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
it('C2c: no script is registered twice under the identical (event, matcher)', () => {
|
|
247
|
+
// A script CAN need more than one row — worktree-guard.sh legitimately
|
|
248
|
+
// registers under both PreToolUse/Bash and PreToolUse/Edit|Write — but
|
|
249
|
+
// never two rows sharing the SAME (event, matcher): that would run it
|
|
250
|
+
// twice per matched call.
|
|
251
|
+
const keys = DEFAULT_HOOK_SCRIPTS.map((h) => `${h.event}::${h.matcher}::${h.script}`);
|
|
252
|
+
const duplicated = keys.filter((k, i) => keys.indexOf(k) !== i).sort();
|
|
246
253
|
assert.deepEqual(
|
|
247
254
|
duplicated,
|
|
248
255
|
[],
|
|
249
|
-
`a script registered twice runs twice per matched call: ${duplicated.join(', ')}`,
|
|
256
|
+
`a script registered twice under the same event+matcher runs twice per matched call: ${duplicated.join(', ')}`,
|
|
250
257
|
);
|
|
251
258
|
});
|
|
252
259
|
});
|
|
@@ -74,9 +74,25 @@ function collectFiles(baseDir, currentDir, map, prefix) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Every hook script name the CLI's registration table knows about, ONE PER
|
|
79
|
+
* REGISTRATION ROW — so it legitimately repeats a name for a script carrying
|
|
80
|
+
* more than one row (worktree-guard.sh registers under both PreToolUse/Bash
|
|
81
|
+
* and PreToolUse/Edit|Write). Use this where the assertion is about
|
|
82
|
+
* REGISTRATIONS (the wiring line's count).
|
|
83
|
+
*/
|
|
78
84
|
const REGISTERED_HOOK_NAMES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.command));
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The DISTINCT hook script file names — deduped, since a fetched FileMap and
|
|
88
|
+
* an installed `.claude/hooks/` directory each carry one FILE per script
|
|
89
|
+
* regardless of how many registration rows that script has. Use this where the
|
|
90
|
+
* assertion is about INSTALLED SCRIPTS (the "hooks/ installed (N scripts)"
|
|
91
|
+
* line), never REGISTERED_HOOK_NAMES.length directly — that would over-count
|
|
92
|
+
* by the number of scripts carrying more than one row.
|
|
93
|
+
*/
|
|
94
|
+
const INSTALLED_HOOK_FILE_NAMES = [...new Set(REGISTERED_HOOK_NAMES)];
|
|
95
|
+
|
|
80
96
|
function hookBody(name) {
|
|
81
97
|
return `#!/bin/sh\n# managed-by: dev-agents-sync v9.9.9\n# ${name}\nexit 0\n`;
|
|
82
98
|
}
|
|
@@ -229,11 +245,13 @@ describe('AC-8 hooks summary line — count is derived, never a literal', () =>
|
|
|
229
245
|
|
|
230
246
|
// Three shipped sets of DIFFERENT sizes. Any implementation that prints a
|
|
231
247
|
// pinned number fails at least two of these — which is the whole point: this
|
|
232
|
-
// wave already burned three fixes on hard-coded hook counts.
|
|
248
|
+
// wave already burned three fixes on hard-coded hook counts. Deduped
|
|
249
|
+
// (INSTALLED_HOOK_FILE_NAMES), since this is about installed FILES, not
|
|
250
|
+
// registration rows — a script with two rows still installs one file.
|
|
233
251
|
const shippedSets = [
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
252
|
+
INSTALLED_HOOK_FILE_NAMES.slice(0, 2),
|
|
253
|
+
INSTALLED_HOOK_FILE_NAMES.slice(0, 4),
|
|
254
|
+
INSTALLED_HOOK_FILE_NAMES,
|
|
237
255
|
];
|
|
238
256
|
|
|
239
257
|
for (const hookNames of shippedSets) {
|
|
@@ -257,7 +275,7 @@ describe('AC-8 hooks summary line — count is derived, never a literal', () =>
|
|
|
257
275
|
|
|
258
276
|
it('A3b: a hook script the registration table does not know about still counts as installed', async () => {
|
|
259
277
|
// The count comes from what was INSTALLED, not from the registration table.
|
|
260
|
-
const hookNames = [...
|
|
278
|
+
const hookNames = [...INSTALLED_HOOK_FILE_NAMES, 'future-unregistered-hook.sh'];
|
|
261
279
|
const stderr = await captureStderr(() =>
|
|
262
280
|
runInit(consumerDir, {
|
|
263
281
|
targets: ['claude'],
|
|
@@ -365,7 +383,7 @@ describe('AC-8 wiring line', () => {
|
|
|
365
383
|
);
|
|
366
384
|
|
|
367
385
|
// Installed count includes it; registration count does not.
|
|
368
|
-
assert.equal(installedCountFrom(stderr),
|
|
386
|
+
assert.equal(installedCountFrom(stderr), INSTALLED_HOOK_FILE_NAMES.length + 1);
|
|
369
387
|
assert.match(
|
|
370
388
|
stderr,
|
|
371
389
|
new RegExp(
|
|
@@ -425,7 +443,7 @@ describe('AC-8 wiring line', () => {
|
|
|
425
443
|
'--no-hooks-wiring must not create settings.json',
|
|
426
444
|
);
|
|
427
445
|
// The install half still happened and is still reported.
|
|
428
|
-
assert.equal(installedCountFrom(stderr),
|
|
446
|
+
assert.equal(installedCountFrom(stderr), INSTALLED_HOOK_FILE_NAMES.length);
|
|
429
447
|
});
|
|
430
448
|
|
|
431
449
|
it('A7b: update --no-hooks-wiring reports the skip too', async () => {
|
|
@@ -295,9 +295,10 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
295
295
|
}
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
-
it('W2d: all
|
|
299
|
-
// force-push-guard, direct-push-guard,
|
|
300
|
-
//
|
|
298
|
+
it('W2d: all four Bash PreToolUse hooks are wired (a shared event+matcher is not deduped)', async () => {
|
|
299
|
+
// force-push-guard, direct-push-guard, pre-pr-preflight, and worktree-guard
|
|
300
|
+
// (its Bash half — see W6 for its second, Edit|Write, registration) all
|
|
301
|
+
// share PreToolUse/Bash. Merge identity is the COMMAND, so all four must
|
|
301
302
|
// survive — collapsing on event+matcher would silently drop one.
|
|
302
303
|
await runInit(consumerDir, {
|
|
303
304
|
targets: ['claude'],
|
|
@@ -315,8 +316,9 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
315
316
|
expectedCommand('.claude/hooks/direct-push-guard.sh'),
|
|
316
317
|
expectedCommand('.claude/hooks/force-push-guard.sh'),
|
|
317
318
|
expectedCommand('.claude/hooks/pre-pr-preflight.sh'),
|
|
319
|
+
expectedCommand('.claude/hooks/worktree-guard.sh'),
|
|
318
320
|
],
|
|
319
|
-
'all
|
|
321
|
+
'all four PreToolUse/Bash hooks must be wired independently',
|
|
320
322
|
);
|
|
321
323
|
});
|
|
322
324
|
|
|
@@ -367,15 +369,19 @@ describe('W3 — a second update is a no-op (twice == once)', () => {
|
|
|
367
369
|
);
|
|
368
370
|
});
|
|
369
371
|
|
|
370
|
-
it('W3b: no
|
|
372
|
+
it('W3b: no (event, matcher, command) registration is duplicated after two updates', async () => {
|
|
373
|
+
// Keyed on the full (event, matcher, command) triple, not command alone:
|
|
374
|
+
// worktree-guard.sh legitimately registers the SAME command twice, under
|
|
375
|
+
// PreToolUse/Bash and PreToolUse/Edit|Write (see W6) — that is two
|
|
376
|
+
// distinct registrations, not a duplicate.
|
|
371
377
|
setupPreWaveRepo(consumerDir);
|
|
372
378
|
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
373
379
|
|
|
374
380
|
await runUpdate(consumerDir, opts);
|
|
375
381
|
await runUpdate(consumerDir, opts);
|
|
376
382
|
|
|
377
|
-
const
|
|
378
|
-
const duplicated =
|
|
383
|
+
const keys = managedRegistrations(consumerDir).map((w) => `${w.event}::${w.matcher}::${w.command}`);
|
|
384
|
+
const duplicated = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
379
385
|
assert.deepEqual(duplicated, [], `duplicate registrations after two updates: ${duplicated}`);
|
|
380
386
|
});
|
|
381
387
|
|
|
@@ -540,3 +546,86 @@ describe('W5 — check reports drift on a modified hook script', () => {
|
|
|
540
546
|
);
|
|
541
547
|
});
|
|
542
548
|
});
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// W6 — one script, one event, two matchers: docs/specs/260913-deliver-worktree-sync
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
//
|
|
554
|
+
// worktree-guard.sh is the first HOOK_MANIFEST script to need two independent
|
|
555
|
+
// PreToolUse registrations for itself — Bash (blocks a raw git write) and
|
|
556
|
+
// Edit|Write (blocks a direct file edit) — same event, same command, different
|
|
557
|
+
// matcher. `hasManagedCommand` used to dedupe on (event, command) alone, so the
|
|
558
|
+
// merge saw the Bash row wired first and silently treated the Edit|Write row as
|
|
559
|
+
// "already wired," and it never landed in settings.json. These tests pin that
|
|
560
|
+
// both rows survive independently and would fail again if that regressed.
|
|
561
|
+
|
|
562
|
+
describe('W6 — worktree-guard.sh registers independently under two matchers on PreToolUse', () => {
|
|
563
|
+
let consumerDir;
|
|
564
|
+
|
|
565
|
+
beforeEach(() => {
|
|
566
|
+
consumerDir = makeTmpDir();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
afterEach(() => {
|
|
570
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('W6a: init wires both the Bash and Edit|Write registrations for worktree-guard.sh', async () => {
|
|
574
|
+
await runInit(consumerDir, {
|
|
575
|
+
targets: ['claude'],
|
|
576
|
+
fetcher: makeFixtureFetcher(),
|
|
577
|
+
availableTags: AVAILABLE_TAGS,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const command = expectedCommand('.claude/hooks/worktree-guard.sh');
|
|
581
|
+
const matchers = managedRegistrations(consumerDir)
|
|
582
|
+
.filter((w) => w.event === 'PreToolUse' && w.command === command)
|
|
583
|
+
.map((w) => w.matcher)
|
|
584
|
+
.sort();
|
|
585
|
+
assert.deepEqual(
|
|
586
|
+
matchers,
|
|
587
|
+
['Bash', 'Edit|Write'],
|
|
588
|
+
'worktree-guard.sh must be registered under BOTH PreToolUse/Bash and ' +
|
|
589
|
+
'PreToolUse/Edit|Write — same script, same event, two matchers is the ' +
|
|
590
|
+
'one case hasManagedCommand must not collapse',
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('W6b: update wires both worktree-guard.sh registrations onto a pre-wave lockfile', async () => {
|
|
595
|
+
setupPreWaveRepo(consumerDir);
|
|
596
|
+
await runUpdate(consumerDir, {
|
|
597
|
+
fetcher: makeFixtureFetcher(),
|
|
598
|
+
availableTags: AVAILABLE_TAGS,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const command = expectedCommand('.claude/hooks/worktree-guard.sh');
|
|
602
|
+
const matchers = managedRegistrations(consumerDir)
|
|
603
|
+
.filter((w) => w.event === 'PreToolUse' && w.command === command)
|
|
604
|
+
.map((w) => w.matcher)
|
|
605
|
+
.sort();
|
|
606
|
+
assert.deepEqual(
|
|
607
|
+
matchers,
|
|
608
|
+
['Bash', 'Edit|Write'],
|
|
609
|
+
'update must wire both worktree-guard.sh registrations onto an upgrading consumer',
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('W6c: a second update does not duplicate or drop either worktree-guard.sh registration', async () => {
|
|
614
|
+
setupPreWaveRepo(consumerDir);
|
|
615
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
616
|
+
|
|
617
|
+
await runUpdate(consumerDir, opts);
|
|
618
|
+
await runUpdate(consumerDir, opts);
|
|
619
|
+
|
|
620
|
+
const command = expectedCommand('.claude/hooks/worktree-guard.sh');
|
|
621
|
+
const rows = managedRegistrations(consumerDir).filter(
|
|
622
|
+
(w) => w.event === 'PreToolUse' && w.command === command,
|
|
623
|
+
);
|
|
624
|
+
assert.equal(
|
|
625
|
+
rows.length,
|
|
626
|
+
2,
|
|
627
|
+
`expected exactly one row per matcher (2 total) after two updates, got ${rows.length}: ` +
|
|
628
|
+
JSON.stringify(rows),
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
});
|