@dalzoubi/dev-agents-sync 2.0.28 → 2.2.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 +53 -8
- package/package.json +1 -1
- package/src/commands/init.mjs +26 -2
- package/src/commands/update.mjs +45 -3
- package/src/hooks.mjs +52 -5
- package/src/settings.mjs +17 -0
- 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/update-uptodate-wiring.test.mjs +484 -0
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
|
|
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 |
|
|
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
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
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, {
|
package/src/commands/update.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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[]
|
|
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(
|
|
83
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
132
|
-
// They carry the shell-comment marker (POSIX-sh
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
}
|