@sabaiway/agent-workflow-kit 1.27.0 → 1.29.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/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 1.29.0 — Velocity scope C: an opt-in PreToolUse gate-approval hook
8
+
9
+ A **feature** release (ships the bundled bridges unchanged at **2.1.0**). The shipped, probe-proven
10
+ closure of the velocity trust-posture residual ([[AD-021]] scope C, recorded in **AD-037**) — a
11
+ new opt-in `.claude/` writer, the family's third:
12
+
13
+ - **`/agent-workflow-kit hook`** (`tools/gate-hook.mjs`) — places a **self-contained** hook runtime
14
+ (`references/hooks/gate-approve.mjs` → `.claude/hooks/agent-workflow-gates.mjs`; no kit imports, so
15
+ the placed copy survives an uninstall) and wires ONE `PreToolUse` "Bash" entry into
16
+ `.claude/settings.json`. Velocity writer discipline verbatim: `--dry-run` default, deployment-gated
17
+ `--apply`, symlink-safe, refuses unsafe modes in either settings file, merge-don't-clobber,
18
+ idempotent, never `settings.local.json`, never commits. Place-file-FIRST-then-wire; a malformed
19
+ existing `hooks` shape or a diverged-and-unwired target file is a STOP with zero writes (it refuses
20
+ to wire an unknown script as a hook); the target is re-verified no-follow immediately before wiring.
21
+ - **The hook's decision ladder**, read against `docs/ai/gates.json` LIVE per call (one declaration,
22
+ two consumers with *Mode: gates* — editing gates.json never needs re-wiring): **(a)** a command
23
+ BYTE-EXACT to a declared gate `cmd` (trim-only; never a pattern — the rejected AD-021 shape),
24
+ invoked from the project root, under `default`/`acceptEdits` → `allow`; **(b)** a seeded-read-only
25
+ command carrying a documented runtime residual (output redirection, command/process substitution
26
+ `$(…)`/`` ` ``/`<(…)`, or the `--output` write-flag family — matched as a whole-command substring so
27
+ a quoted/escaped form can't hide it) → `ask`, overriding a settings allow rule (**proven live** on
28
+ Claude Code 2.1.185); **(c)** else no decision. Never `deny`. Fail-safe is **decoupled**: a broken
29
+ `gates.json` disables only (a), the guard keeps running; every anomaly exits 0, never 2. Validation
30
+ parity with the runner (`_README` included) — an invalid declaration approves nothing.
31
+ - **Integration.** `/.claude/hooks/` joins the hidden-mode footprint registry; `uninstall` gains the
32
+ hook seam (reports the settings edit + preserves a still-wired or non-bundle file, removes only a
33
+ byte-identical unwired one and cleans an emptied `.claude/hooks/`, all lstat-no-follow + AD-011
34
+ preflight; the wired-probe reads DECODED settings JSON so an escaped `\/` path still counts as
35
+ wired); `status` gains one row (wired / file placed / declaration present) through the full
36
+ surface→view-model→renderers pipeline; the velocity residual notice + SKILL/README point at the
37
+ shipped hook instead of a "deferred" one. Kit-only; the runtime + writer ride the tarball.
38
+ - **Review.** Council at the diff converged over four rounds (codex + agy, grounded): every finding
39
+ closed one obfuscation-of-a-string-scan class (process substitution, quoted/escaped `--output`,
40
+ JSON-escaped `\/`) or a TOCTOU/symlink window — folded by code with red→green regressions; the
41
+ final round was codex **ship** + agy **SHIP**, 0 blockers / 0 majors.
42
+
43
+ ## 1.28.0 — Lens-mirror guards the checked-vs-unchecked plan boundary
44
+
45
+ A **feature** release (template + test only; ships the bundled bridges unchanged at **2.1.0**).
46
+ The kit's half of the §9 sharpening:
47
+
48
+ - **`references/templates/agent_rules.md` (B5)** — the byte-identical sharpened lens bullet (see
49
+ the memory 1.9.0 entry): a plan carries only checked syntax plus literal fixture/schema
50
+ fragments a named test validates; un-run, logic-bearing syntax never lives in plan prose.
51
+ - **`test/lens-mirror.test.mjs`** — Set-1 gains the two new tokens `checked syntax` +
52
+ `logic-bearing`, pinned inside the lens region of all four files (engine planning §9, engine
53
+ procedures, both templates); template byte-identity unchanged; non-vacuity proven by an
54
+ injected red→green (a broken token fails the guard twice over — region + byte-identity).
55
+
7
56
  ## 1.27.0 — Cost-tiered execution: the `gates` runner + the `agents` cheap-lane writer
8
57
 
9
58
  A **feature** release (ships the bundled bridges unchanged at **2.1.0**). Two new modes move
package/README.md CHANGED
@@ -235,8 +235,9 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
235
235
  | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + backend readiness (default Reviewed when a backend is ready, Council on request, slot-aware incl. Delegated) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same). `--override <slot>=<recipe>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
236
236
  | `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<recipe>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend, never commits**; hand-editing stays fully supported. |
237
237
  | `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
238
- | `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a redirection/command-substitution residual remains, closed by a deferred hook); a direct commit/push/publish still asks. `--dry-run` first. |
238
+ | `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
239
239
  | `/agent-workflow-kit agents` | Claude Code · opt-in | **cheap-lane subagents** — places bundled subagent definitions (`.claude/agents/`) pinned to a **cheap model** (haiku, low effort, read-only tools) for mechanical work: extraction sweeps, changelog fact-skeletons, gate-failure triage. Judgment, review, and real code stay on your main lane — these vehicles only extract and draft, and their output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
240
+ | `/agent-workflow-kit hook` | Claude Code · opt-in | **gate-approval hook** — places a self-contained PreToolUse hook (`.claude/hooks/`) and wires it into `.claude/settings.json`: a Bash command **byte-identical** to a gate you declared in `docs/ai/gates.json` (run from the project root) is auto-approved — no prompt, no idle; a seeded read-only command carrying a runtime residual (output redirection, command substitution, `--output` writes) now **asks** even where an allow rule would have silently passed it (proven live — hook `ask` overrides an allow rule). Exact matches only, never patterns; never `deny`; a broken `gates.json` only disables auto-approval, never the guard. Preview by default; never writes `settings.local.json`; never commits. |
240
241
 
241
242
  It **never auto-commits** and **never overwrites** an existing `AGENTS.md` without asking.
242
243
 
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.27.0'
6
+ version: '1.29.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -273,6 +273,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs [--cwd <project>] [--only <id>
273
273
 
274
274
  The declaration is **seeded at bootstrap** (the template loop, step 6) and **ensured-if-missing on upgrade** from THIS kit's own template twin (*Mode: upgrade* step 3) — independent of the installed memory substrate's age; an existing file is always **preserved byte-for-byte**. It is deliberately **not** a delegation-required memory asset: gates are optional, and absence is an honest runner outcome, not a deployment failure.
275
275
 
276
+ Declared gates can also be **auto-approved** (no permission prompt on a byte-exact invocation from the project root) via the opt-in PreToolUse hook — *Mode: hook*: the SAME declaration, a second consumer; editing gates.json needs no re-wiring.
277
+
276
278
  **Invariants:** the runner writes nothing · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets.
277
279
 
278
280
  ### Mode: bootstrap
@@ -311,7 +313,7 @@ The declaration is **seeded at bootstrap** (the template loop, step 6) and **ens
311
313
  independent axes: a packaging-only release bumps the package but leaves the lineage head until a
312
314
  migration actually changes the deployed `docs/ai` structure. A stamp greater than the head →
313
315
  STOP (never downgrade).
314
- 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts above, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat ends on **one** recommended next step — including the optional, opt-in `/agent-workflow-kit velocity` (a read-only allowlist so routine read-only commands stop prompting; *Mode: velocity*) when nothing more pressing applies, never run without a yes. Then **ask before committing** — never auto-commit.
316
+ 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts above, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat ends on **one** recommended next step — including the optional, opt-in `/agent-workflow-kit velocity` (a read-only allowlist so routine read-only commands stop prompting; *Mode: velocity*) or, once gates are declared, `/agent-workflow-kit hook` (auto-approve your own declared gate commands; *Mode: hook*) when nothing more pressing applies, never run without a yes. Then **ask before committing** — never auto-commit.
315
317
 
316
318
  Fill strategy:
317
319
 
@@ -511,7 +513,7 @@ It classifies every surface into four classes and acts accordingly:
511
513
 
512
514
  ### Mode: velocity
513
515
 
514
- The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-only** Claude Code allowlist into `.claude/settings.json` so an agent stops idling on approval prompts for routine read-only commands while the maintainer is away. It is the family's **first programmatic `.claude/settings.json` writer** (attribution stayed an agent-driven prose merge). **In-agent, opt-in, writes only `.claude/settings.json`**, on one hard rule: **it never allowlists `commit`/`push`/`publish`** — so a direct commit/push/publish still ASKs; the only caveat is the trust-posture residual (below), closed by the deferred hook.
516
+ The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-only** Claude Code allowlist into `.claude/settings.json` so an agent stops idling on approval prompts for routine read-only commands while the maintainer is away. It is the family's **first programmatic `.claude/settings.json` writer** (attribution stayed an agent-driven prose merge). **In-agent, opt-in, writes only `.claude/settings.json`**, on one hard rule: **it never allowlists `commit`/`push`/`publish`** — so a direct commit/push/publish still ASKs; the only caveat is the trust-posture residual (below) its closure is **shipped, opt-in: *Mode: hook***.
515
517
 
516
518
  **Version-status routing (like the other writer modes):** read `docs/ai/.workflow-version` first — not-deployed → bootstrap; stamp < `1.3.0` → `upgrade`; stamp > head / unparseable → STOP. The tool enforces this in code too (`--apply` STOPs unless the stamp is the lineage head).
517
519
 
@@ -520,7 +522,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
520
522
  1. **`--dry-run` first, always** (the default — changes nothing). It prints: the fixed read-only core it would add; a **read-only advisory** that lists your `package.json` `scripts` as **unaudited candidates you may add BY HAND** (inspect each first) to `.claude/settings.json` / `settings.local.json` — the tool **never** writes them and flags obviously-mutating names as "do not add"; any **pre-existing non-read-only `Bash(...)` entries** to consider removing by hand; and the honest residual notice (below). It STOPs (zero writes) on a symlinked `.claude` / non-regular `settings.json`, malformed settings JSON, or an unsafe `permissions.defaultMode` — `bypassPermissions` or anything outside `default`/`acceptEdits`/`plan`, present in **either** `settings.json` or `settings.local.json`.
521
523
  2. **Ask the `acceptEdits` opt-in** via **`AskUserQuestion` where supported**, the safe option FIRST:
522
524
  - **"Keep per-edit approval prompts (recommended)"** — seed only the read-only allowlist; file edits still prompt.
523
- - **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — but that same redirection/substitution residual means they are not *fully* closed until the deferred PreToolUse hook (family backlog). Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes only `.claude/settings.json`.
525
+ - **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — and the residual guard for the seeded core ships as the opt-in PreToolUse hook (*Mode: hook*; probe-proven on the `--output` family — current engine builds already intercept redirection/substitution upstream). Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes only `.claude/settings.json`.
524
526
  3. **Only on an explicit yes**, re-run with `--apply` (add `--accept-edits` only if they chose the second option). It merges-don't-clobber (preserves `includeCoAuthoredBy`, every key, and existing allow entries) and writes **only** `.claude/settings.json`.
525
527
  4. **Surface delegation-readiness, read-only.** If they want a step run Delegated, set it with `/agent-workflow-kit set-recipe --set plan-execution.execute=delegated` (*Mode: set-recipe*) or by hand-editing `docs/ai/orchestration.json`; **velocity itself never writes the orchestration config.**
526
528
 
@@ -540,6 +542,31 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs [--dry-run | --apply] [--cw
540
542
 
541
543
  **Invariants:** writer (writes only `.claude/agents/`) · preview by default · a diverged existing file is reported and preserved, never clobbered · never touches settings · never commits · vehicles are pinned to `model: haiku` + `effort: low` + read-only tools (content-tested).
542
544
 
545
+ ### Mode: hook
546
+
547
+ The opt-in **gate-approval PreToolUse hook** — the family's third `.claude/` writer (velocity discipline), and the shipped closure of the velocity trust-posture residual (**AD-021 scope C, probe-proven in AD-037**). It places a **self-contained** hook runtime at `.claude/hooks/agent-workflow-gates.mjs` (no kit imports — it keeps working if the kit is uninstalled) and wires ONE `PreToolUse` `"Bash"` entry into `.claude/settings.json`. Per Bash call the hook then walks a decision ladder, first match wins:
548
+
549
+ - **Auto-approve** a command **byte-identical** (leading/trailing trim only — no whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern matching, ever: patterns are what made AD-021 auto-seeding rejected) to a gate `cmd` declared in `docs/ai/gates.json` — read **LIVE on every call** (editing gates.json never needs re-wiring; one declaration, two consumers with *Mode: gates*) — invoked **from the project root** (gates run from the root by contract; the same bytes from a subdirectory are NOT approved) and under `default`/`acceptEdits` permission mode (an approval never loosens `plan`/`bypassPermissions`).
550
+ - **Ask** on a command whose leading tokens match the velocity **seeded read-only core** when it carries the documented runtime residual — output redirection, command substitution, or the bounded `--output` write-flag family — surfacing a human prompt even where a seeded allow rule would have silently approved (**hook `ask` overrides an allow rule — proven live**: on Claude Code 2.1.185 a seeded `Bash(git log:*)` silently wrote a file via `git log --output=…`; with the hook wired the same call prompts). Detection is string-level and conservative: a quoted metacharacter may over-ask, never under-allow.
551
+ - **Stay silent otherwise** — the normal permission flow proceeds unchanged. The hook **never emits `deny`**; nothing is hard-blocked.
552
+
553
+ **Honest residual status (AD-037):** current engine builds already intercept `>` redirection and `$()` substitution upstream (observed headless on 2.1.185); the **`--output` family was proven open** and is the seam this hook demonstrably closes. The guard still covers all three documented classes (defense-in-depth — engine behavior may vary across surfaces/versions). **Fail-safe, decoupled:** a missing/broken/invalid `gates.json` disables ONLY gate auto-approval — the residual guard keeps running; every anomaly path exits 0 (the hook is never the blocker or the noise — the `gates` runner reports a broken declaration at its own point of use). **Not a sandbox:** it closes the named residual for the seeded core and auto-approves declared gates; it does not police arbitrary commands or user-added rules.
554
+
555
+ **Trust posture (state it plainly when asking consent):** the hook removes the PROMPT only for commands the human already declared in `docs/ai/gates.json` — the same trust boundary as the `gates` runner, which executes them with the caller's privileges. **gates.json thereby becomes a privileged file**: whoever can edit it can get its commands auto-approved. An invalid declaration approves NOTHING (strict parse, exact validation parity with the runner).
556
+
557
+ **Version-status routing** like the other writer modes (stamp head `1.3.0`; `--apply` enforces it in code).
558
+
559
+ Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <dir>]`:
560
+
561
+ 1. **`--dry-run` first, always** (the default — changes nothing): previews the placement and the exact settings entry it would merge. It STOPs (zero writes) on: a symlinked `.claude` / `.claude/hooks` / target file / non-regular `settings.json`; malformed settings JSON; a **malformed existing `hooks` shape** (never a merge-through-clobber); an unsafe `permissions.defaultMode` in **either** settings file; or a target hook file with **different** content while our entry is not wired (**it refuses to wire an unknown script as a PreToolUse hook** — the recovery is named: delete the file to reseed from the bundle).
562
+ 2. **Ask consent** via **`AskUserQuestion` where supported**, the no-change option first: keep prompting for gate commands, or place + wire the hook — presenting the trust posture above in plain language.
563
+ 3. **Only on an explicit yes**, re-run with `--apply`. It places the file FIRST, then wires settings (a wired-but-missing entry would error on every Bash call); merge-don't-clobber (foreign hooks/matchers/keys and existing permissions preserved; re-apply never duplicates); **settings hot-reload — the hook is active for new Bash calls, no session restart needed**. An identical existing file is *already current*; a diverged-but-already-wired file is reported, never clobbered or unwired.
564
+ 4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) — `/.claude/hooks/` is in the known-footprint registry; the apply report reminds you.
565
+
566
+ **Invariants:** writes ONLY `.claude/hooks/agent-workflow-gates.mjs` + `.claude/settings.json` · never `settings.local.json` · never commits · exact-match approval only (no patterns) · never `deny` · never auto-wired by `init`/`upgrade` (placement stays opt-in — the AD-011/AD-034 boundary: init/upgrade may refresh placed things, never place new ones).
567
+
568
+ **Exit codes:** `0` done / dry-run (incl. the reported diverged-but-wired state); `1` a precondition STOP; `2` bad arguments.
569
+
543
570
  ---
544
571
 
545
572
  ## Gotchas
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.27.0",
6
+ "version": "1.29.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -27,6 +27,7 @@ diverge:
27
27
  |---|---|---|---|---|
28
28
  | `/.claude/skills/` | Claude Code | dir | no | local-dev skills; absorbs the AD-013 one-off |
29
29
  | `/.claude/agents/` | Claude Code | dir | no | project subagent definitions (incl. the kit-placed cheap-lane vehicles) |
30
+ | `/.claude/hooks/` | Claude Code | dir | no | project hooks (incl. the kit-placed gate-approval hook) |
30
31
  | `/.cursor/rules/` | Cursor | dir | no | project rule files |
31
32
  | `/.cursorrules` | Cursor (legacy) | file | **yes** | legacy single-file rules |
32
33
  | `/.codeium/` | Codeium/Windsurf | dir | no | home-scoped launchers live under `~/`, out of scope |
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ // gate-approve.mjs — the agent-workflow PreToolUse gate-approval hook. Bundled at
3
+ // references/hooks/gate-approve.mjs; PLACED (copied) by `/agent-workflow-kit hook` to
4
+ // <project>/.claude/hooks/agent-workflow-gates.mjs and wired as a `PreToolUse` "Bash" hook in
5
+ // .claude/settings.json.
6
+ //
7
+ // SELF-CONTAINED by contract: the placed copy must run on machines without the kit — no kit
8
+ // imports, dependency-free, Node >= 18, no side effects on import. Two constants are baked
9
+ // FROZEN COPIES of the velocity-profile.mjs exports (a placed file cannot import the kit):
10
+ // SEEDED_READONLY_CORE ≡ UNIVERSAL_READONLY_ALLOWLIST and RESIDUAL_FORMS ≡
11
+ // RUNTIME_RESIDUAL_FORMS — drift-guarded by the kit's test/gate-hook-core-parity.test.mjs.
12
+ //
13
+ // It reads <project root>/docs/ai/gates.json LIVE on every invocation (AD-035: one declaration,
14
+ // two consumers — editing gates.json never requires re-wiring). The project root resolves from
15
+ // $CLAUDE_PROJECT_DIR (the stdin `cwd` may be a subdirectory), falling back to the stdin `cwd`
16
+ // only when the env is absent.
17
+ //
18
+ // Decision ladder for every Bash PreToolUse call — first match wins:
19
+ // (a) declared-gate EXACT match → allow. Byte-identical (leading/trailing trim only — no
20
+ // whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern
21
+ // matching: patterns are exactly what made AD-021 auto-seeding fragile) to one declared
22
+ // gate `cmd`, AND the stdin `cwd` realpath-resolves to the project root (gates run FROM
23
+ // THE ROOT by contract — the same bytes from a subdirectory resolve relative paths
24
+ // differently), AND permission_mode is `default`/`acceptEdits` (an allow never loosens
25
+ // `plan`/`bypassPermissions`).
26
+ // (b) residual guard → ask. The command's leading tokens match the SEEDED read-only core AND
27
+ // the command carries a documented runtime residual (output redirection, command
28
+ // substitution, the bounded `--output` write-flag family) that a settings-level allow
29
+ // rule cannot see. Most-restrictive-wins: this surfaces a human prompt even where a
30
+ // seeded allow rule would have silently approved. Detection is deliberately string-level
31
+ // and conservative (no shell parsing in a dependency-free hook): a quoted metacharacter
32
+ // may over-ASK, never under-allow. Covers the kit-SEEDED core only, never arbitrary
33
+ // user-added rules.
34
+ // (c) everything else → NO decision: exit 0, no output — the normal permission flow proceeds
35
+ // unchanged. The hook NEVER emits `deny`.
36
+ //
37
+ // Fail-safe invariant, decoupled per function: a DECLARATION anomaly (missing / unreadable /
38
+ // malformed / schema-invalid gates.json) disables ONLY exact-gate approval (a) — the residual
39
+ // guard (b) needs no declaration and keeps running (a broken gates.json must not silently
40
+ // reopen the seeded-allowlist hole). Only an INPUT anomaly (unparseable stdin, non-Bash
41
+ // tool_name) disables the whole hook. EVERY anomaly path exits 0 — never exit 2 (exit 2 is an
42
+ // immediate block; a hook that fires on every Bash call must never become the blocker or the
43
+ // noise — run-gates.mjs already yells about a broken declaration at its own point of use).
44
+ //
45
+ // Trust posture: this hook only removes the PROMPT for commands the human already declared in
46
+ // gates.json — the same trust boundary as run-gates.mjs, which executes those commands with the
47
+ // caller's privileges. gates.json is thereby a privileged file; an invalid declaration approves
48
+ // NOTHING (strict-parse-or-no-decision).
49
+
50
+ import { readFileSync, realpathSync } from 'node:fs';
51
+ import { join } from 'node:path';
52
+ import { pathToFileURL } from 'node:url';
53
+
54
+ export const HOOK_EVENT_NAME = 'PreToolUse';
55
+ export const BASH_TOOL_NAME = 'Bash';
56
+ export const GATES_REL = 'docs/ai/gates.json';
57
+ export const PROJECT_DIR_ENV = 'CLAUDE_PROJECT_DIR';
58
+ export const DECISION_ALLOW = 'allow';
59
+ export const DECISION_ASK = 'ask';
60
+ // The only modes an `allow` may be emitted under — an allowlist, so an unknown future mode is
61
+ // fenced closed by default. The residual guard (b) is NOT mode-fenced: an `ask` never loosens.
62
+ export const ALLOW_PERMISSION_MODES = Object.freeze(['default', 'acceptEdits']);
63
+
64
+ // Baked frozen copy of velocity-profile.mjs UNIVERSAL_READONLY_ALLOWLIST (the audited read-only
65
+ // core velocity seeds as settings allow rules). Drift-guarded — never edit here alone.
66
+ export const SEEDED_READONLY_CORE = Object.freeze([
67
+ 'Bash(git status:*)',
68
+ 'Bash(git diff:*)',
69
+ 'Bash(git log:*)',
70
+ 'Bash(git show:*)',
71
+ 'Bash(git ls-files:*)',
72
+ 'Bash(git check-ignore:*)',
73
+ 'Bash(git branch --list:*)',
74
+ 'Bash(npm view:*)',
75
+ 'Bash(npm ls:*)',
76
+ 'Bash(npm outdated:*)',
77
+ 'Bash(ls:*)',
78
+ 'Bash(cat:*)',
79
+ 'Bash(head:*)',
80
+ 'Bash(tail:*)',
81
+ 'Bash(wc:*)',
82
+ 'Bash(readlink:*)',
83
+ 'Bash(which:*)',
84
+ 'Bash(grep:*)',
85
+ ]);
86
+
87
+ // Baked frozen copy of velocity-profile.mjs RUNTIME_RESIDUAL_FORMS (the documented residual a
88
+ // settings-level allow rule cannot see). Drift-guarded — never edit here alone.
89
+ export const RESIDUAL_FORMS = Object.freeze({
90
+ writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
91
+ // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
92
+ // the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
93
+ // already do that), so it is deliberately NOT here.
94
+ commandSubstitutions: Object.freeze(['$(', '`', '<(']),
95
+ // The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
96
+ // whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
97
+ // `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
98
+ // `--output-indicator` is the safe direction (never under-allow a real write flag through quotes).
99
+ boundedWriteFlags: Object.freeze(['--output']),
100
+ });
101
+
102
+ export const RESIDUAL_CLASS_REDIRECTION = 'output redirection';
103
+ export const RESIDUAL_CLASS_SUBSTITUTION = 'command substitution';
104
+ export const RESIDUAL_CLASS_OUTPUT_FLAG = 'a bounded --output write flag';
105
+
106
+ const EXIT_OK = 0;
107
+ const UTF8 = 'utf8';
108
+ const BASH_ALLOW_PATTERN = /^Bash\((.+):\*\)$/u;
109
+ const WHITESPACE_PATTERN = /\s+/u;
110
+ const GATE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
111
+ const GATE_CMD_NEWLINE_PATTERN = /[\r\n]/u;
112
+ const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
113
+ const README_KEY = '_README';
114
+ const GATES_KEY = 'gates';
115
+
116
+ // ── declaration (validation parity with run-gates.mjs) ────────────────────────────────
117
+
118
+ // Self-contained restatement of run-gates.mjs `validateDeclaration`, at EXACT parity including
119
+ // the optional `_README` string — a declaration the runner accepts is never rejected here (else
120
+ // every template-shaped declaration would approve nothing), and one the runner rejects never
121
+ // approves anything. Returns a boolean shape instead of throwing: anomalies go dark, not loud.
122
+ export const validateDeclarationShape = (parsed) => {
123
+ const invalid = { ok: false };
124
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return invalid;
125
+ for (const key of Object.keys(parsed)) {
126
+ if (key !== README_KEY && key !== GATES_KEY) return invalid;
127
+ }
128
+ if (parsed[README_KEY] !== undefined && typeof parsed[README_KEY] !== 'string') return invalid;
129
+ if (!Array.isArray(parsed[GATES_KEY])) return invalid;
130
+ const seenIds = new Set();
131
+ for (const gate of parsed[GATES_KEY]) {
132
+ if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) return invalid;
133
+ for (const key of Object.keys(gate)) {
134
+ if (!GATE_KEYS.includes(key)) return invalid;
135
+ }
136
+ for (const key of GATE_KEYS) {
137
+ if (typeof gate[key] !== 'string' || gate[key].trim() === '') return invalid;
138
+ }
139
+ if (GATE_CMD_NEWLINE_PATTERN.test(gate.cmd)) return invalid;
140
+ if (!GATE_ID_PATTERN.test(gate.id)) return invalid;
141
+ if (seenIds.has(gate.id)) return invalid;
142
+ seenIds.add(gate.id);
143
+ }
144
+ return { ok: true, gates: parsed[GATES_KEY] };
145
+ };
146
+
147
+ // Read + strictly validate the LIVE declaration. Any declaration anomaly returns null — ladder
148
+ // (a) goes dark while the residual guard (b) keeps running (the decoupled fail-safe invariant).
149
+ export const readDeclarationGates = (projectRoot, deps = {}) => {
150
+ const readFile = deps.readFile ?? readFileSync;
151
+ if (typeof projectRoot !== 'string' || projectRoot === '') return null;
152
+ try {
153
+ const validated = validateDeclarationShape(JSON.parse(readFile(join(projectRoot, GATES_REL), UTF8)));
154
+ return validated.ok ? validated.gates : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ };
159
+
160
+ // ── seeded-core prefix + residual detection (ladder b) ────────────────────────────────
161
+
162
+ const getAllowCommandPrefix = (pattern) => {
163
+ const match = pattern.match(BASH_ALLOW_PATTERN);
164
+ return match ? match[1] : undefined;
165
+ };
166
+
167
+ const tokenizeCommand = (command) => command.trim().split(WHITESPACE_PATTERN).filter(Boolean);
168
+
169
+ const CORE_PREFIX_TOKEN_LISTS = Object.freeze(
170
+ SEEDED_READONLY_CORE.map(getAllowCommandPrefix)
171
+ .filter(Boolean)
172
+ .map((prefix) => Object.freeze(tokenizeCommand(prefix))),
173
+ );
174
+
175
+ // `Bash(git status:*)` approves `git status <anything>` at a word boundary — the guard matches
176
+ // the same way: a core pattern's tokens must be the command's LEADING tokens exactly.
177
+ export const matchSeededCorePrefix = (command) => {
178
+ const tokens = tokenizeCommand(command);
179
+ const matched = CORE_PREFIX_TOKEN_LISTS.find(
180
+ (prefixTokens) =>
181
+ prefixTokens.length <= tokens.length && prefixTokens.every((token, index) => tokens[index] === token),
182
+ );
183
+ return matched ? matched.join(' ') : null;
184
+ };
185
+
186
+ // String-level, conservative: the hook sees the PRE-SHELL command string, so every class is a raw
187
+ // substring scan (never a whitespace-token check — a token check misses `"--output=f"` / `'>' f`
188
+ // where the quotes are still in the string but the shell will strip them). A quoted metacharacter
189
+ // or write flag may over-ASK, never under-allow (no shell parsing in a dependency-free hook).
190
+ export const detectResidualClasses = (command) => {
191
+ const classes = [];
192
+ if (RESIDUAL_FORMS.writeRedirections.some((form) => command.includes(form))) {
193
+ classes.push(RESIDUAL_CLASS_REDIRECTION);
194
+ }
195
+ if (RESIDUAL_FORMS.commandSubstitutions.some((form) => command.includes(form))) {
196
+ classes.push(RESIDUAL_CLASS_SUBSTITUTION);
197
+ }
198
+ if (RESIDUAL_FORMS.boundedWriteFlags.some((flag) => command.includes(flag))) {
199
+ classes.push(RESIDUAL_CLASS_OUTPUT_FLAG);
200
+ }
201
+ return classes;
202
+ };
203
+
204
+ // ── the decision ladder (pure core) ───────────────────────────────────────────────────
205
+
206
+ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gates }) => {
207
+ const trimmed = command.trim();
208
+ // (a) declared-gate exact match — all three invariants (declaration valid, cwd = project
209
+ // root, mode in the allow fence) must hold; otherwise fall through, never error.
210
+ if (Array.isArray(gates) && cwdIsProjectRoot === true && ALLOW_PERMISSION_MODES.includes(permissionMode)) {
211
+ const declaredGate = gates.find((gate) => gate.cmd.trim() === trimmed);
212
+ if (declaredGate !== undefined) {
213
+ return {
214
+ permissionDecision: DECISION_ALLOW,
215
+ permissionDecisionReason: `agent-workflow gates: byte-exact match of declared gate "${declaredGate.id}" (${GATES_REL}), invoked from the project root`,
216
+ };
217
+ }
218
+ }
219
+ // (b) residual guard — no declaration needed; not mode-fenced (an ask never loosens).
220
+ const corePrefix = matchSeededCorePrefix(trimmed);
221
+ if (corePrefix !== null) {
222
+ const residualClasses = detectResidualClasses(trimmed);
223
+ if (residualClasses.length > 0) {
224
+ return {
225
+ permissionDecision: DECISION_ASK,
226
+ permissionDecisionReason: `agent-workflow residual guard: read-only-seeded "${corePrefix}" carries ${residualClasses.join(' + ')} — a settings allow rule cannot see runtime shape; confirm by hand`,
227
+ };
228
+ }
229
+ }
230
+ // (c) no decision — the normal permission flow proceeds unchanged.
231
+ return null;
232
+ };
233
+
234
+ // ── stdin-driven main ─────────────────────────────────────────────────────────────────
235
+
236
+ const resolveRealpath = (maybePath, realpath) => {
237
+ if (typeof maybePath !== 'string' || maybePath === '') return null;
238
+ try {
239
+ return realpath(maybePath);
240
+ } catch {
241
+ return null;
242
+ }
243
+ };
244
+
245
+ // Parse one PreToolUse stdin payload and run the ladder. Returns the decision object or null
246
+ // (null → no output, exit 0). Injectable deps keep the tests hermetic.
247
+ export const runHook = (rawInput, deps = {}) => {
248
+ const env = deps.env ?? process.env;
249
+ const realpath = deps.realpath ?? realpathSync;
250
+ const parseInput = () => {
251
+ try {
252
+ return JSON.parse(rawInput);
253
+ } catch {
254
+ return null;
255
+ }
256
+ };
257
+ const input = parseInput();
258
+ // Input anomaly → the WHOLE hook goes dark (distinct from a declaration anomaly, which only
259
+ // darkens ladder (a)).
260
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return null;
261
+ if (input.tool_name !== BASH_TOOL_NAME) return null;
262
+ const command = input.tool_input === null || typeof input.tool_input !== 'object' ? undefined : input.tool_input.command;
263
+ if (typeof command !== 'string' || command.trim() === '') return null;
264
+
265
+ const envRoot = env[PROJECT_DIR_ENV];
266
+ const projectRoot =
267
+ typeof envRoot === 'string' && envRoot !== '' ? envRoot : typeof input.cwd === 'string' ? input.cwd : null;
268
+ const rootReal = resolveRealpath(projectRoot, realpath);
269
+ const cwdReal = resolveRealpath(input.cwd, realpath);
270
+ return decideBashCall({
271
+ command,
272
+ permissionMode: input.permission_mode,
273
+ cwdIsProjectRoot: rootReal !== null && cwdReal !== null && rootReal === cwdReal,
274
+ gates: readDeclarationGates(projectRoot, deps),
275
+ });
276
+ };
277
+
278
+ export const formatDecision = (decision) =>
279
+ JSON.stringify({ hookSpecificOutput: { hookEventName: HOOK_EVENT_NAME, ...decision } });
280
+
281
+ const readStdin = async () => {
282
+ const chunks = [];
283
+ for await (const chunk of process.stdin) chunks.push(chunk);
284
+ return Buffer.concat(chunks).toString(UTF8);
285
+ };
286
+
287
+ export const main = async () => {
288
+ // Exit 0 on EVERY path — never exit 2 (an immediate block), never a top-level throw: this
289
+ // fires on every Bash call and must never become the blocker or the noise.
290
+ try {
291
+ const decision = runHook(await readStdin());
292
+ if (decision !== null) process.stdout.write(`${formatDecision(decision)}\n`);
293
+ } catch {
294
+ // Deliberately dark: anomalies are the runner's job to report at its own point of use.
295
+ }
296
+ return EXIT_OK;
297
+ };
298
+
299
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
300
+ if (isDirectRun) main().then((code) => process.exit(code));
@@ -65,7 +65,7 @@ Before proposing changes or committing, review against:
65
65
  Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon.)
66
66
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
67
67
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
68
- - **No code-mechanics in the plan.** A Step still carries its exact paths + commands (the plan-structure / self-review canon); the ceiling is on *fold-bred* detail a review fold must not push fine code-mechanics (`cd`, an env default, a flag) into prose. A fold that needs a mechanic is the trigger to write the test instead.
68
+ - **No code-mechanics in the plan.** A Step still carries its exact paths + commands (the plan-structure / self-review canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
69
69
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
70
70
  - **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
71
71
  - **Fold minimally — prose has no checker.** An ephemeral, gitignored plan is prose with no executable checker; fold **minimally, in ONE place** and run a **self-consistency** read across the plan before every re-review — a fold that drifts several prose spots is what turns a 2-round review into churn.
@@ -119,6 +119,13 @@ const CATALOG = [
119
119
  kind: WRITER,
120
120
  oneLine: 'Place bundled cheap-model subagent definitions for mechanical work — sweeps, changelog skeletons, gate triage (Claude Code; opt-in; preview first).',
121
121
  },
122
+ {
123
+ key: 'hook',
124
+ invocation: invocationOf('hook'),
125
+ group: 'Configure',
126
+ kind: WRITER,
127
+ oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
128
+ },
122
129
  {
123
130
  key: 'recipes',
124
131
  invocation: invocationOf('recipes'),
@@ -164,8 +171,8 @@ export const kindOf = (key) => byKey.get(key)?.kind ?? null;
164
171
  // (`upgrade`) OR the full slash form (`/agent-workflow-kit upgrade`); the first word is significant
165
172
  // and trailing args are ignored. Precise semantics:
166
173
  // undefined / null / '' / whitespace-only / the exact bare invocation → 'bootstrap'
167
- // a known first token (upgrade/status/setup/backends/recipes/procedures/velocity/uninstall/help)
168
- // → that mode
174
+ // a known first token (upgrade/status/setup/backends/recipes/procedures/velocity/agents/hook/
175
+ // gates/set-recipe/uninstall/help) → that mode
169
176
  // anything else (unrecognized / ambiguous) → 'help' (read-only — NEVER a writer/guarded mode)
170
177
  export const routeInvocation = (token) => {
171
178
  if (token == null) return BARE_INVOCATION_MODE;
@@ -44,6 +44,10 @@ import {
44
44
  readSettingsFile,
45
45
  resolveEffectiveMode,
46
46
  } from './velocity-profile.mjs';
47
+ // The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
48
+ // implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
49
+ import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
50
+ import { GATES_REL } from './run-gates.mjs';
47
51
  // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
48
52
  // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
49
53
  // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
@@ -438,11 +442,32 @@ export const surveyVelocity = (dir, deps = {}) => {
438
442
  }
439
443
  };
440
444
 
445
+ // gate hook: the opt-in PreToolUse gate-approval hook (Mode: hook) — wired (the settings entry, in
446
+ // EITHER settings file: the hooks contract merges both), the placed hook file, and the gate
447
+ // declaration it consumes. Read-only; the wired-detection is REUSED from the writer (gate-hook.mjs
448
+ // isHookWired — one implementation, never a drifting copy).
449
+ export const surveyGateHook = (dir, deps = {}) => {
450
+ try {
451
+ const d = resolve(dir);
452
+ const exists = deps.exists ?? existsSync;
453
+ const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
454
+ const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
455
+ return {
456
+ wired: isHookWired(project.data) || isHookWired(local.data),
457
+ filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
458
+ declarationPresent: Boolean(exists(join(d, GATES_REL))),
459
+ };
460
+ } catch (err) {
461
+ return { error: localizeError(err) };
462
+ }
463
+ };
464
+
441
465
  // the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
442
466
  export const surveySettings = (dir, deps = {}) => ({
443
467
  recipes: surveyRecipes(dir, deps),
444
468
  attribution: surveyAttribution(dir, deps),
445
469
  velocity: surveyVelocity(dir, deps),
470
+ hook: surveyGateHook(dir, deps),
446
471
  });
447
472
 
448
473
  // bridges: HOST-scoped (no project needed). Wrapper command NAMES come from FAMILY_MEMBERS[].wrapperCmds