@dalzoubi/dev-agents-sync 2.0.28 → 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 CHANGED
@@ -101,7 +101,7 @@ npx --yes @dalzoubi/dev-agents-sync@1 status
101
101
 
102
102
  ## Steering and hooks
103
103
 
104
- For the `claude` target, `init` and `update` set up two extra pieces of project memory and automation.
104
+ For the `claude` target, `init` and `update` set up two extra pieces of project memory and automation: a consumer-owned steering scaffold, and managed hook scripts that are auto-wired into `.claude/settings.json`.
105
105
 
106
106
  ### Steering scaffold (consumer-owned)
107
107
 
@@ -111,19 +111,53 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
111
111
 
112
112
  ### Hook scripts (managed)
113
113
 
114
- `init` and `update` install three hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files:
114
+ `init` and `update` install the toolkit's hook scripts into `.claude/hooks/` as marker-managed, lockfile-tracked, executable files. Most are **warning** hooks: they print one line to stderr and always exit 0. Three are **blocking** hooks that can deny a tool call outright.
115
115
 
116
- | Script | Claude Code event | Purpose |
117
- |---|---|---|
118
- | `steering-injector.sh` | `SessionStart` | Surfaces steering context at the start of a session. |
119
- | `managed-file-guard.sh` | `PostToolUse` | Guards managed files against unexpected edits. |
120
- | `uncommitted-check.sh` | `Stop` | Flags uncommitted work when a session ends. |
116
+ | Script | Event | Matcher | Mode | What it does |
117
+ |---|---|---|---|---|
118
+ | `steering-injector.sh` | `SessionStart` | `.*` | warning | Surfaces steering context at the start of a session. |
119
+ | `managed-file-guard.sh` | `PostToolUse` | `.*` | warning | Guards managed files against unexpected edits. |
120
+ | `uncommitted-check.sh` | `Stop` | `.*` | warning | Flags uncommitted work when a session ends. |
121
+ | `force-push-guard.sh` | `PreToolUse` | `Bash` | **blocking** | Denies `git push` carrying `--force` / `-f` / `--force-with-lease` at `main` or `master` (by explicit refspec, or by the checked-out branch when the push names no ref). Force-pushing a feature branch, and a plain push to `main`, both pass. |
122
+ | `spec-approval-gate.sh` | `PreToolUse` | `Edit\|Write` | **blocking** | Denies an edit that sets a spec to `Status: Approved` while an unresolved clarification marker — a bare bracketed `NEEDS CLARIFICATION` token — remains in prose. Occurrences inside code spans or fenced blocks are documentation and never match. |
123
+ | `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. |
124
+ | `spec-drift-warn.sh` | `PostToolUse` | `Edit\|Write` | warning | After an edit to `spec.md` / `plan.md` / `tasks.md` under `docs/specs/`, warns when that folder's `spec.md` has already moved past `Draft`. Draft folders stay silent. |
125
+ | `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. |
121
126
 
122
127
  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.
123
128
 
129
+ Every hook runs entirely locally: no network calls, no files written, nothing logged anywhere but your own stderr.
130
+
131
+ #### Blocking hooks: deny messages and error codes
132
+
133
+ A blocking hook runs on `PreToolUse`, where exiting 2 denies the tool call and the message is fed back to the model. Each deny is a single stderr line carrying a **stable code** and a next action. The codes are append-only:
134
+
135
+ | Code | Raised by | Example message |
136
+ |---|---|---|
137
+ | `EHOOK_FORCE_PUSH` | `force-push-guard.sh` | `blocked: EHOOK_FORCE_PUSH — force-push to protected branch 'main' — push without a force flag or open a pull request; to override, set DEV_AGENTS_HOOKS_BYPASS=force-push-guard` |
138
+ | `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` |
139
+ | `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` |
140
+
141
+ **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.
142
+
143
+ #### Bypassing a blocking hook
144
+
145
+ Set `DEV_AGENTS_HOOKS_BYPASS` to a comma-separated list of **exact** hook names to stand those blockers down for the run:
146
+
147
+ ```bash
148
+ DEV_AGENTS_HOOKS_BYPASS=force-push-guard,handoff-state-guard claude
149
+ ```
150
+
151
+ - Matching is an exact token match on the name — `force-push-guard-x` or a substring never matches.
152
+ - A bypass is **never silent**: the named hook prints exactly one line, `bypassed: <hook-name>`, to stderr and exits 0.
153
+ - Only the named hooks stand down; every other blocker still blocks.
154
+ - The variable applies to blocking hooks only — warning hooks have nothing to bypass.
155
+
156
+ > **Known gap.** `pre-pr-preflight.sh` matches on Bash command text (`gh pr create`), so a pull request opened through an MCP tool rather than the shell never reaches the hook and gets no reminder. There is no coverage for that path today.
157
+
124
158
  ### settings.json auto-wiring
125
159
 
126
- By default, `init` and `update` also register the three hooks in `.claude/settings.json` so Claude Code runs them. The merge is:
160
+ By default, `init` and `update` also register each installed hook in `.claude/settings.json` so Claude Code runs them. Registrations come from the CLI's own table, so a consumer only ever gets registrations for scripts that actually shipped in the resolved version. The merge is:
127
161
 
128
162
  - **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
129
163
  - **Idempotent** — running `update` twice produces no duplicate entries.
@@ -140,6 +174,17 @@ npx --yes @dalzoubi/dev-agents-sync@1 update --no-hooks-wiring
140
174
 
141
175
  With `--no-hooks-wiring`, the hook scripts are still installed under `.claude/hooks/`, but `settings.json` is neither created nor modified — wire the hooks yourself if you want them active. Note that the flag skips the whole merge, **including the self-cleaning de-registration above**: a registration for a retired hook stays in your `settings.json` until you run without the flag or remove it yourself.
142
176
 
177
+ ### What `init` / `update` report
178
+
179
+ A run that installs hook scripts reports both halves on stderr, alongside the `steering/ …` line:
180
+
181
+ ```text
182
+ info: dev-agents-sync: hooks/ installed (8 scripts)
183
+ info: dev-agents-sync: hooks/ wired into .claude/settings.json (8 registrations)
184
+ ```
185
+
186
+ Both counts are derived from what the **resolved version actually shipped**, not from a fixed number — an older content tag that carries fewer scripts reports fewer. With `--no-hooks-wiring` the second line reads `hooks/ settings.json wiring skipped (--no-hooks-wiring)` instead. `--dry-run` prints neither line: it installs nothing and wires nothing. Neither line affects an exit code.
187
+
143
188
  ## Agents
144
189
 
145
190
  Syncing installs the `define`/`deliver` agent topology: two user-facing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.28",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -17,7 +17,13 @@ import { resolveConsumerPath, writeManagedFile, normalizeFileMap } from '../writ
17
17
  import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
18
18
  import { injectClaudeMd } from '../claudeMd.mjs';
19
19
  import { seedSteering, steeringSummary } from '../steering.mjs';
20
- import { collectHookPaths, hookScriptsForInstalled, wireHookSettings } from '../hooks.mjs';
20
+ import {
21
+ collectHookPaths,
22
+ hookScriptsForInstalled,
23
+ hooksSummary,
24
+ hooksWiringSummary,
25
+ wireHookSettings,
26
+ } from '../hooks.mjs';
21
27
 
22
28
  const _require = createRequire(import.meta.url);
23
29
  const CLI_VERSION = _require('../../package.json').version;
@@ -152,6 +158,7 @@ export async function runInit(consumerCwd, opts = {}) {
152
158
  const plannedPrunedRetired =
153
159
  claudeTargetActive && !noHooksWiring
154
160
  ? wireHookSettings(consumerCwd, hookScriptsForInstalled(plannedKeys), { dryRun: true })
161
+ .prunedRetired
155
162
  : [];
156
163
 
157
164
  return {
@@ -214,11 +221,28 @@ export async function runInit(consumerCwd, opts = {}) {
214
221
  // additive + idempotent and fails loud (ESETTINGS_PARSE) on a malformed file.
215
222
  const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
216
223
  const hookPaths = collectHookPaths(installedHookKeys);
224
+ const hookRegistrations = hookScriptsForInstalled(installedHookKeys);
225
+ // Report the install BEFORE wiring, so the pair reads in the order it happened
226
+ // (scripts on disk, then registrations) with any retired-prune line from
227
+ // wireHookSettings between them. Both lines are suppressed when this run
228
+ // installed no hook scripts at all — there is nothing to report — and they
229
+ // never appear under --dry-run, which returns above (planning is not doing).
230
+ if (hookPaths.length > 0) {
231
+ process.stderr.write(`info: dev-agents-sync: ${hooksSummary(hookPaths)}\n`);
232
+ }
217
233
  let prunedRetired = [];
218
234
  if (claudeTargetActive && !noHooksWiring) {
219
235
  // Unconditional on the spec being non-empty: the prune half needs no spec,
220
236
  // and `init` over an existing .claude/ can meet a pre-retirement settings.json.
221
- prunedRetired = wireHookSettings(consumerCwd, hookScriptsForInstalled(installedHookKeys));
237
+ ({ prunedRetired } = wireHookSettings(consumerCwd, hookRegistrations));
238
+ }
239
+ if (hookPaths.length > 0) {
240
+ process.stderr.write(
241
+ `info: dev-agents-sync: ${hooksWiringSummary({
242
+ registrationCount: hookRegistrations.length,
243
+ skipped: noHooksWiring,
244
+ })}\n`,
245
+ );
222
246
  }
223
247
 
224
248
  writeLockfile(consumerCwd, {
@@ -16,7 +16,13 @@ import { hasMarker, hasMarkerOnFirstLine } from '../marker.mjs';
16
16
  import { injectClaudeMd } from '../claudeMd.mjs';
17
17
  import { seedSteering, steeringSummary } from '../steering.mjs';
18
18
  import { DEFAULT_HOOK_SCRIPTS } from '../settings.mjs';
19
- import { collectHookPaths, hookScriptsForInstalled, wireHookSettings } from '../hooks.mjs';
19
+ import {
20
+ collectHookPaths,
21
+ hookScriptsForInstalled,
22
+ hooksSummary,
23
+ hooksWiringSummary,
24
+ wireHookSettings,
25
+ } from '../hooks.mjs';
20
26
 
21
27
  const _require = createRequire(import.meta.url);
22
28
  const CLI_VERSION = _require('../../package.json').version;
@@ -151,9 +157,29 @@ export async function runUpdate(consumerCwd, opts = {}) {
151
157
  );
152
158
  // Unconditional on hookSpec.length: the prune needs no spec, so a consumer
153
159
  // with no hook scripts on disk still gets retired registrations removed.
154
- prunedRetired = wireHookSettings(consumerCwd, hookSpec, { dryRun });
160
+ const wiring = wireHookSettings(consumerCwd, hookSpec, { dryRun });
161
+ prunedRetired = wiring.prunedRetired;
162
+ // Wiring edits consumer-owned state, so it is never silent (§6) — and for a
163
+ // consumer already pinned to the latest tag who never had hooks wired, the
164
+ // merge above is a genuine FIRST-TIME write to settings.json. Gated on the
165
+ // merge's own "did it write" signal rather than on reaching this branch, so
166
+ // a repeat no-op `update` stays quiet: we report what this run actually did.
167
+ // Not reached under --dry-run (planning is not doing); `merged` there only
168
+ // describes what would change.
169
+ if (!dryRun && wiring.merged) {
170
+ process.stderr.write(
171
+ `info: dev-agents-sync: ${hooksWiringSummary({
172
+ registrationCount: hookSpec.length,
173
+ })}\n`,
174
+ );
175
+ }
155
176
  }
156
177
 
178
+ // No hooks summary line here on purpose: this branch installs nothing, and
179
+ // the summary reports what THIS run installed. Claiming "hooks/ installed
180
+ // (N scripts)" for scripts an earlier run wrote would be a count the run
181
+ // did not earn. Only the wiring half above is reported, and only when the
182
+ // merge actually wrote.
157
183
  return {
158
184
  upToDate: true,
159
185
  resolvedVersion: lock.resolvedVersion,
@@ -199,6 +225,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
199
225
  const plannedPrunedRetired =
200
226
  claudeTargetActive && !noHooksWiring
201
227
  ? wireHookSettings(consumerCwd, hookScriptsForInstalled(plannedKeys), { dryRun: true })
228
+ .prunedRetired
202
229
  : [];
203
230
  return {
204
231
  dryRun: true,
@@ -250,10 +277,25 @@ export async function runUpdate(consumerCwd, opts = {}) {
250
277
  // on a malformed settings.json, leaving it byte-identical.
251
278
  const installedHookKeys = plannedWrites.map(({ relKey }) => relKey);
252
279
  const hookPaths = collectHookPaths(installedHookKeys);
280
+ const hookRegistrations = hookScriptsForInstalled(installedHookKeys);
281
+ // Same pairing as init: install line, then any retired-prune line emitted by
282
+ // wireHookSettings, then the wiring line. Suppressed entirely when this run
283
+ // installed no hook scripts, and never reached under --dry-run (returns above).
284
+ if (hookPaths.length > 0) {
285
+ process.stderr.write(`info: dev-agents-sync: ${hooksSummary(hookPaths)}\n`);
286
+ }
253
287
  let prunedRetired = [];
254
288
  if (claudeTargetActive && !noHooksWiring) {
255
289
  // Unconditional on the spec being non-empty: the prune half needs no spec.
256
- prunedRetired = wireHookSettings(consumerCwd, hookScriptsForInstalled(installedHookKeys));
290
+ ({ prunedRetired } = wireHookSettings(consumerCwd, hookRegistrations));
291
+ }
292
+ if (hookPaths.length > 0) {
293
+ process.stderr.write(
294
+ `info: dev-agents-sync: ${hooksWiringSummary({
295
+ registrationCount: hookRegistrations.length,
296
+ skipped: noHooksWiring,
297
+ })}\n`,
298
+ );
257
299
  }
258
300
 
259
301
  // Prune orphans: marker-bearing managed files no longer shipped. Delete in
package/src/hooks.mjs CHANGED
@@ -73,19 +73,66 @@ export function hookScriptsForInstalled(relKeys) {
73
73
  * command registered under two event buckets is two registrations gone from the
74
74
  * consumer's file; saying "1" there would under-report the edit we just made.
75
75
  *
76
- * @returns {string[]} the DISTINCT retired commands de-registered (or, in
76
+ * @returns {{ prunedRetired: string[], merged: boolean }}
77
+ * `prunedRetired` — the DISTINCT retired commands de-registered (or, in
77
78
  * dry-run, that would be de-registered) by this call. Always an array. Callers
78
79
  * match on it ("was X pruned?"); the user-facing count stays inside this
79
80
  * function, which is the toolkit's single reporting site for the prune.
81
+ * `merged` — mergeHookSettings's own "did this change settings.json?" signal,
82
+ * surfaced rather than dropped so a caller can tell a real write apart from an
83
+ * idempotent no-op and keep the §6 "wiring is never silent" promise without
84
+ * printing a line on every repeat run. In dry-run it reports what WOULD change
85
+ * (nothing is written), so callers must still gate their own output on dryRun.
80
86
  */
81
87
  export function wireHookSettings(consumerCwd, hookScripts, { dryRun = false } = {}) {
82
- const { prunedRetired, prunedRetiredCount } = mergeHookSettings(consumerCwd, hookScripts, {
83
- dryRun,
84
- });
88
+ const { merged, prunedRetired, prunedRetiredCount } = mergeHookSettings(
89
+ consumerCwd,
90
+ hookScripts,
91
+ { dryRun },
92
+ );
85
93
  if (!dryRun && prunedRetiredCount > 0) {
86
94
  process.stderr.write(
87
95
  `info: dev-agents-sync: pruned ${prunedRetiredCount} retired hook registration(s)\n`,
88
96
  );
89
97
  }
90
- return prunedRetired;
98
+ return { prunedRetired, merged };
99
+ }
100
+
101
+ /** `1 script` / `2 scripts` — keeps the summary lines readable at n === 1. */
102
+ function plural(n, word) {
103
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
104
+ }
105
+
106
+ /**
107
+ * Human-readable summary of the hook scripts this run installed, in the same
108
+ * style as steering.mjs's steeringSummary (its sibling on the same output line
109
+ * family).
110
+ *
111
+ * The count is DERIVED from the paths that were actually installed — never a
112
+ * literal. That is load-bearing: a hard-coded number silently lies the moment a
113
+ * hook is added or retired, and it lies loudest for a consumer resolving an
114
+ * older content tag that shipped fewer scripts than the CLI knows about.
115
+ *
116
+ * @param {string[]} hookPaths consumer-relative paths from collectHookPaths.
117
+ */
118
+ export function hooksSummary(hookPaths) {
119
+ return `hooks/ installed (${plural(hookPaths.length, 'script')})`;
120
+ }
121
+
122
+ /**
123
+ * Human-readable summary of the settings.json half of the install: whether the
124
+ * installed scripts were registered, or the merge was skipped by
125
+ * --no-hooks-wiring. Wiring edits consumer-owned state, so it is never silent
126
+ * (§6) — and a consumer who passed --no-hooks-wiring gets told the scripts are
127
+ * on disk but inert.
128
+ *
129
+ * The registration count is derived from the spec hookScriptsForInstalled built
130
+ * for this run, so it can never claim registrations the CLI did not make.
131
+ *
132
+ * @param {{ registrationCount?: number, skipped?: boolean }} state
133
+ */
134
+ export function hooksWiringSummary({ registrationCount = 0, skipped = false } = {}) {
135
+ if (skipped) return 'hooks/ settings.json wiring skipped (--no-hooks-wiring)';
136
+ if (registrationCount === 0) return 'hooks/ nothing to wire into .claude/settings.json';
137
+ return `hooks/ wired into .claude/settings.json (${plural(registrationCount, 'registration')})`;
91
138
  }
package/src/settings.mjs CHANGED
@@ -394,11 +394,28 @@ export function removeHookSettings(consumerCwd, opts = {}) {
394
394
  /**
395
395
  * The default hook registration spec — maps each installed hook script to its
396
396
  * Claude Code event. Kept here so init/update share one source of truth.
397
+ *
398
+ * The matcher is the tool-name filter Claude Code applies before running the
399
+ * hook. Session-scoped events (`SessionStart`, `Stop`) take `.*`; tool-scoped
400
+ * events name the tools they inspect, so a hook that only ever reads a Bash
401
+ * command or a written file path is not spawned on every unrelated tool call.
402
+ *
403
+ * `PreToolUse` hooks can DENY the tool call (exit 2). Two of them share the
404
+ * `Bash` matcher — one blocking, one warning — which the merge handles as two
405
+ * independent registrations keyed by command.
397
406
  */
398
407
  export const DEFAULT_HOOK_SCRIPTS = [
399
408
  { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
400
409
  { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
401
410
  { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
411
+ // Added by docs/specs/260805-hooks-wave-2/ — the toolkit's first blocking
412
+ // hooks plus two warning hooks. Blockers deny only on a positive violation
413
+ // match, fail open otherwise, and honor DEV_AGENTS_HOOKS_BYPASS.
414
+ { event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/force-push-guard.sh' },
415
+ { event: 'PreToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-approval-gate.sh' },
416
+ { event: 'PreToolUse', matcher: 'Write', command: '.claude/hooks/handoff-state-guard.sh' },
417
+ { event: 'PostToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-drift-warn.sh' },
418
+ { event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/pre-pr-preflight.sh' },
402
419
  ];
403
420
 
404
421
  /**
@@ -63,6 +63,7 @@ import { fileURLToPath } from 'node:url';
63
63
  import { runInit } from '../../src/commands/init.mjs';
64
64
  import { runUpdate } from '../../src/commands/update.mjs';
65
65
  import { runCheck } from '../../src/commands/check.mjs';
66
+ import { DEFAULT_HOOK_SCRIPTS } from '../../src/settings.mjs';
66
67
 
67
68
  // ---------------------------------------------------------------------------
68
69
  // Paths
@@ -78,22 +79,27 @@ const README_PATH = path.join(__dirname, '..', '..', 'README.md');
78
79
 
79
80
  const STEERING_FILES = ['decisions.md', 'glossary.md', 'architecture.md'];
80
81
 
81
- const HOOK_FILES = [
82
- 'steering-injector.sh',
83
- 'managed-file-guard.sh',
84
- 'uncommitted-check.sh',
85
- ];
82
+ /**
83
+ * The hook script files this flow expects, DERIVED from the CLI's registration
84
+ * table rather than pinned.
85
+ *
86
+ * The e2e flow's claim is "every hook the toolkit ships installs and wires end
87
+ * to end" — so the expected set has to BE the shipped set. A pinned roster
88
+ * silently shrinks that claim to whichever hooks existed when it was written:
89
+ * the 260805 wave added five hooks that a pinned list of three would not have
90
+ * covered at all. Deriving keeps the flow honest for every future wave.
91
+ */
92
+ const HOOK_FILES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.command));
86
93
 
87
94
  /**
88
95
  * Events paired with the hook script that handles each one. Used to assert
89
96
  * that settings.json wiring is complete without hard-coding the internal
90
97
  * shape (the implement agent owns that).
91
98
  */
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
- ];
99
+ const HOOK_EVENT_PAIRS = DEFAULT_HOOK_SCRIPTS.map((h) => ({
100
+ event: h.event,
101
+ script: path.basename(h.command),
102
+ }));
97
103
 
98
104
  const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
99
105
  const LOCKFILE_NAME = '.dev-agents-sync.json';
@@ -117,7 +123,8 @@ function collectFiles(baseDir, currentDir, map, prefix) {
117
123
 
118
124
  /**
119
125
  * 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.
126
+ * extended with the hook scripts build.mjs would emit — one per registration,
127
+ * so the fetched set and the wiring table cannot drift apart in this flow.
121
128
  * These use the shell-comment marker format for .sh files.
122
129
  */
123
130
  function buildFixtureFileMap(targets = ['claude']) {
@@ -128,14 +135,15 @@ function buildFixtureFileMap(targets = ['claude']) {
128
135
  collectFiles(targetDir, targetDir, map, target);
129
136
  }
130
137
 
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';
138
+ // Hook scripts injected as if emitted by the build pipeline, one per
139
+ // registration. They carry the shell-comment marker (POSIX-sh, not HTML).
140
+ // Bodies are stand-ins: this flow proves install + wiring + drift, never a
141
+ // hook's own behavior (that lives in the content repo's tests/hooks-*.mjs).
142
+ for (const { event, command } of DEFAULT_HOOK_SCRIPTS) {
143
+ const key = command.replace(/^\.claude\//, 'claude/');
144
+ map[key] =
145
+ `#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event}: fixture stand-in for ${path.basename(command)}.\nexit 0\n`;
146
+ }
139
147
 
140
148
  return map;
141
149
  }