@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent workflows into Claude Code, Cursor, Copilot, and Codex projects.",
6
6
  "license": "UNLICENSED",
@@ -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
- * installed command path, so re-running is a no-op.
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
- /** True if `event` already has a CLI-managed entry whose command targets `command`. */
127
- function hasManagedCommand(eventEntries, command) {
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((entry) => managedCommandsIn(entry).includes(command));
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 registration (§9).
246
- if (hasManagedCommand(existing, command)) continue;
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: one row per shipped script, and
24
- * the command is derived from the path rather than typed twice.
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: each shipped script has exactly one row', () => {
244
- const scripts = DEFAULT_HOOK_SCRIPTS.map((h) => h.script);
245
- const duplicated = scripts.filter((s, i) => scripts.indexOf(s) !== i).sort();
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
- /** Every hook script name the CLI's registration table knows about. */
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
- REGISTERED_HOOK_NAMES.slice(0, 2),
235
- REGISTERED_HOOK_NAMES.slice(0, 4),
236
- REGISTERED_HOOK_NAMES,
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 = [...REGISTERED_HOOK_NAMES, 'future-unregistered-hook.sh'];
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), REGISTERED_HOOK_NAMES.length + 1);
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), REGISTERED_HOOK_NAMES.length);
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 three Bash PreToolUse hooks are wired (a shared event+matcher is not deduped)', async () => {
299
- // force-push-guard, direct-push-guard, and pre-pr-preflight all share
300
- // PreToolUse/Bash. Merge identity is the COMMAND, so all three must
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 three PreToolUse/Bash hooks must be wired independently',
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 hook command is registered twice after two updates', async () => {
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 commands = managedRegistrations(consumerDir).map((w) => w.command);
378
- const duplicated = commands.filter((c, i) => commands.indexOf(c) !== i);
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
+ });