@sabaiway/agent-workflow-kit 4.0.0 → 4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ 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
+ ## 4.1.0 — why the gate hook still over-asks, established rather than assumed (AD-079)
8
+
9
+ **Read this if the hook has ever made you approve `grep -rn "=>" src` or a plain read wearing
10
+ `2>/dev/null`.** Those prompts are still here. What changed is that the reason is now measured,
11
+ written down at the point of use, and pinned by tests — and one whole fix direction is formally
12
+ retired instead of being re-proposed every few releases.
13
+
14
+ **One behaviour change.** `>(…)` is now named in the command-substitution class, where it belongs: it
15
+ RUNS a nested command, and it must not depend on the redirection scan to catch it as a side effect.
16
+
17
+ **Three mechanisms were BUILT to stop the over-asking, and all three were REMOVED in review** — each
18
+ on a stop rule declared before the round that met it, each counterexample verified live and kept as
19
+ a regression test. (A fourth direction, deleting the redirection class outright, was rejected at plan
20
+ review before it was built: the velocity profile seeds the allowlist that turns a redirect on `cat`
21
+ into a silent write, so the guard closes a hole the kit itself opens.)
22
+
23
+ 1. **A quote/escape-aware reading of the command**, so a `>` inside a search pattern would stop being
24
+ read as an operator. Defeated by HEREDOCS: their bodies are not shell code, and one quote in each
25
+ of two bodies opens a spurious quoted span and later closes it, with a genuinely active `$(…)`
26
+ sitting between them — the walker ends balanced, so it never falls back, and the guard goes
27
+ silent on a nested command.
28
+ 2. **An fd-duplication exemption** (`2>&1` creates no file). Defeated by a missing token boundary:
29
+ `>&word` duplicates only when the word is a bare number, and `grep x f >&12file` writes the FILE
30
+ `12file`.
31
+ 3. **A null-device exemption** (a redirect into `/dev/null` writes nothing), boundary included.
32
+ Defeated by U+00A0: JavaScript's `\s` counts a no-break space as a word boundary and bash does
33
+ not, so `grep x f >/dev/null` + U+00A0 + `sink` names a FILE and the span was deleted anyway.
34
+
35
+ **The transferable finding, now in the hook's own header:** this hook cannot decide what a
36
+ redirection byte MEANS — not by parsing it, and not even by deleting it — because JavaScript's notion
37
+ of a token boundary and bash's do not agree. On an ASK rung that costs an extra prompt, which is
38
+ safe. It is also exactly why the same scan must never become a DENY.
39
+
40
+ **What did NOT change.** Every guard the hook had, it still has, and every pre-existing acceptance
41
+ test is green and unmodified.
42
+
43
+ **The deny direction is retired.** A rung that REFUSED decorated reads was built for 4.0.0 and
44
+ withdrawn after five shell constructs defeated its proof. It is not coming back.
45
+
46
+ **Re-place the hook to get this.** The placed copy at `.claude/hooks/agent-workflow-gates.mjs` is a
47
+ self-contained snapshot: `rm .claude/hooks/agent-workflow-gates.mjs` then re-run
48
+ `/agent-workflow-kit hook --apply`.
49
+
7
50
  ## 4.0.0 — a review receipt must say HOW the code reached the reviewer (AD-078)
8
51
 
9
52
  **BREAKING, and it costs money to ignore — read the two callouts before upgrading.**
package/README.md CHANGED
@@ -247,7 +247,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
247
247
  | `/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. |
248
248
  | `/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. |
249
249
  | `/agent-workflow-kit agents` | Claude Code · opt-in | **read-only subagents** — places bundled subagent definitions into `.claude/agents/`. **No vehicle gets `Bash`**, and that is the load-bearing property: a read-only fan-out on a full-tool subagent shells out for facts it could have read, and every shelled command is an approval prompt you never needed to see. Three ride a **cheap model** (haiku, low effort) for mechanical work — extraction sweeps, changelog fact-skeletons, gate-failure triage; the fourth, **`review-lens`**, is an ADDITIONAL independent read-only review opinion on code your configured backends have already seen (never a replacement for your review recipe, advisory like every review). Writing code and running gates stay on your main lane, and every vehicle's output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
250
- | `/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. An opt-in **read-lane** (`--read-lane` → `docs/ai/lanes.json`, a **separate** file from `gates.json`) additionally auto-approves *compounds* of seeded read-only commands carrying no shell metaprogramming; `--apply --read-lane` verifies the placed hook is current first (delete-to-reseed otherwise). Preview by default; never writes `settings.local.json`; never commits. |
250
+ | `/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). Detection is a conservative string scan, so it over-asks on a byte that is ordinary text — searching for `=>`, or a read wearing `2>/dev/null`, prompts. That is a documented open limit: 4.1.0 built three mechanisms to narrow it and removed all three in review, each counterexample kept as a test (see Mode: hook). Exact matches only, never patterns; never `deny`; a broken `gates.json` only disables auto-approval, never the guard. An opt-in **read-lane** (`--read-lane` → `docs/ai/lanes.json`, a **separate** file from `gates.json`) additionally auto-approves *compounds* of seeded read-only commands carrying no shell metaprogramming; `--apply --read-lane` verifies the placed hook is current first (delete-to-reseed otherwise). Preview by default; never writes `settings.local.json`; never commits. |
251
251
  | `/agent-workflow-kit state-block-guard` | Claude Code · opt-in | **closing-block detector** — a `Stop` hook that reads the turn's final assistant message and warns when the closing state block is defective: the «what I need from you» slot answering *nothing* (a turn that ENDS always needs a resume, so the answer is false, not merely unhelpful), or a first-person promise of imminent work in a turn that is over. Judged on the slot's first clause, so a real ask followed by a clause break and "nothing else" passes, and a promise gated on something named passes; matching is word-bounded and Unicode-aware, quoted and fenced examples are stripped, and the last STARTED block decides — an incomplete tail counts as no block rather than falling back to an earlier one. A message carrying no block at all is silent unless you pass `--require-block` — this kit does not mandate the block, and a hook that runs every turn must not warn every turn. The judgement is lexical and the mode doc names every residual it leaves. **Detection, never prevention** — a `Stop` hook cannot un-send the message it judges; what it buys is that a silent recurrence becomes a visible one. Warns via `systemMessage` and **exits 0 on every path**: never `deny`, never blocks the stop, never re-enters the model. Reads nothing but the message, makes no network call, approves nothing. This mode is **read-only and has no writer** — it ships the self-contained hook and a paste-ready wiring block you merge into `.claude/settings.json` yourself. |
252
252
  | `/agent-workflow-kit bridge-settings` | opt-in, any time | **host-level bridge settings** — read or change the bridges' `KEY=VALUE` config file (`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`), the **one place a knob survives kit upgrades** (it lives outside every kit tree; a refresh never touches it, and now states loudly if it ever overwrote a local edit). First knobs: the **codex Fast tier** (`CODEX_SERVICE_TIER=priority` — ~1.5× speed at a **2.5× credit rate**, quality-neutral, default off — a consented spend), the codex/agy hard timeouts, the codex review byte cap, and `AGY_REVIEW_MAX_TOTAL_BYTES` (the ceiling on what an oversized agy code review may feed before it refuses pre-spend). `AGY_REVIEW_ALLOW_ADDDIR` is a **retired** compatibility key: still recognized so an existing line never warns as unknown, but it arms nothing — the writer refuses to set it and `--unset` clears it. Allowed keys + value rules come from the bundled bridge manifests; **model/effort are never settable** (the quality guard is untouched). **Previews by default**; refuses an unknown key, an out-of-range value, or a duplicate-carrying file; writes atomically (symlink/TOCTOU-safe). Never commits, never runs a subscription CLI. |
253
253
  | `/agent-workflow-kit autonomy-doctor` | opt-in, any time | **guarded sandbox provisioner "doctor"** — can this machine run the Claude sandbox, and (only with your consent) fix it: macOS Seatbelt built-in / Linux+WSL2 needs `bwrap`+`socat` / native Windows → WSL2. Flagless = **FS-only preview** (the diagnosis, the exact absolute-path command it WOULD run, the exact `--apply <pm>:<pkgs>` consent tuple — runs nothing, never claims ready); `--verify` runs the unprivileged proof (a `bwrap` user-namespace smoke + `socat -V`) — the **only** source of a Linux "ready (verified)" claim; `--apply <pm>:<pkgs>` is the consent-gated privileged install (the tuple must equal the previewed plan; everything executed resolves to absolute paths inside `/usr/bin:/bin:/usr/sbin:/sbin`; the exact command is re-printed immediately before execution), then verifies. Honest loud degrades (unknown PM, untrusted location, nested-sandbox INDETERMINATE, root-unproven) and a stated restart step. Sits **outside every velocity auto-approve tier**; never auto-runs, never writes repo files, never commits. |
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: '4.0.0'
6
+ version: '4.1.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
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": "4.0.0",
6
+ "version": "4.1.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": "4.0.0",
3
+ "version": "4.1.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",
@@ -29,10 +29,13 @@
29
29
  // the command carries a documented runtime residual (output redirection, command
30
30
  // substitution, the bounded `--output` write-flag family) that a settings-level allow
31
31
  // rule cannot see. Most-restrictive-wins: this surfaces a human prompt even where a
32
- // seeded allow rule would have silently approved. Detection is deliberately string-level
33
- // and conservative (no shell parsing in a dependency-free hook): a quoted metacharacter
34
- // may over-ASK, never under-allow. Covers the kit-SEEDED core only, never arbitrary
35
- // user-added rules.
32
+ // seeded allow rule would have silently approved. Covers the kit-SEEDED core only, never
33
+ // arbitrary user-added rules.
34
+ // Detection is deliberately string-level and conservative (no shell parsing in a
35
+ // dependency-free hook): a quoted metacharacter may over-ASK, never under-allow. That
36
+ // over-ask is a KNOWN COST, not an oversight — searching for `=>`, or a plain read wearing
37
+ // `2>/dev/null`, prompts. Three mechanisms to narrow it were built and removed; the note
38
+ // above `detectResidualClasses` records all three with their counterexamples.
36
39
  // (c) read-lane allow (OPT-IN) → allow. Only when docs/ai/lanes.json enables it
37
40
  // (`{ "readLane": true }`, read LIVE per call, fail-closed): a command every separator-split
38
41
  // segment of which is a plain frozen read-only core prefix, with ZERO shell metaprogramming
@@ -42,20 +45,23 @@
42
45
  // (d) everything else → NO decision: exit 0, no output — the normal permission flow proceeds
43
46
  // unchanged. The hook NEVER emits `deny`.
44
47
  //
45
- // WHY THERE IS NO DENY RUNG (kit 4.0.0, three council rounds, AD-078). One was built and REMOVED
46
- // before release. It refused only a seeded read-only command that provably DISCARDS its output
47
- // (`2>/dev/null`), on the argument that such a refusal cannot destroy anything the caller wanted.
48
- // The argument was sound; the byte-level PROOF of "this command discards" was not, and could not be
49
- // made so here. Five constructs defeated it in three rounds — `1<&2` (an fd dup routes stdout back
50
- // out of /dev/null AFTER the approved `>`), a quoted literal `>/dev/null ` in an argument, a
48
+ // WHY THERE IS NO DENY RUNG, AND WHY THE DIRECTION IS RETIRED (kit 4.0.0 built one, AD-078
49
+ // removed it; the class was closed from the other side instead). The withdrawn rung refused a
50
+ // seeded read-only command that provably DISCARDED its output, on the argument that such a refusal
51
+ // cannot destroy anything the caller wanted. The argument was sound; the byte-level PROOF was not.
52
+ // Five constructs defeated it in three rounds — `1<&2` (an fd dup routes stdout back out of
53
+ // /dev/null AFTER the approved `>`), a quoted literal `>/dev/null ` in an argument, a
51
54
  // leading-token-only segment match (`… && npm test`), a bare `&` (backgrounds the read, runs the
52
- // rest), and a `#` comment (bash never executes the redirect at all). Each was a FALSE REFUSAL.
53
- // The lesson, which any future deny rung must start from: on an ASK rung an incomplete scan merely
54
- // over-asks, which is safe; on a DENY rung the SAME incompleteness refuses real work. Deciding
55
- // whether a `>` is an operator or text requires lexing the shell, which this dependency-free hook
56
- // deliberately does not do. A deny rung therefore needs a justification that does not rest on
57
- // parsing command bytes. Design record + all five counterexamples: docs/plans/queue.md,
58
- // BARE-LANE-DENY-RUNG.
55
+ // rest), and a `#` comment (bash never executes the redirect at all). Each was a FALSE REFUSAL,
56
+ // because on an ASK rung an incomplete scan merely over-asks safe while on a DENY rung the
57
+ // SAME incompleteness refuses real work.
58
+ // The direction is retired, not deferred, for a reason bigger than that: the prompts it was meant
59
+ // to prevent were being raised BY THIS HOOK. A deny cannot remove a prompt the hook itself is
60
+ // causing correcting what the guard reports does, and it does it without ever refusing anything.
61
+ // Correcting what the guard REPORTS can, and refuses nothing — but three attempts at that
62
+ // correction were also built and removed (see the note above `detectResidualClasses`). The prompts
63
+ // are still there, and are stated rather than papered over.
64
+ // Design record: docs/plans/queue.md, BARE-LANE-DENY-RUNG.
59
65
  //
60
66
  // Fail-safe invariant, decoupled per function: a DECLARATION anomaly (missing / unreadable /
61
67
  // malformed / schema-invalid gates.json) disables ONLY exact-gate approval (a) — the residual
@@ -129,13 +135,16 @@ export const SEEDED_READONLY_CORE = Object.freeze([
129
135
  // settings-level allow rule cannot see). Drift-guarded — never edit here alone.
130
136
  export const RESIDUAL_FORMS = Object.freeze({
131
137
  writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
132
- // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
133
- // the `>` redirection scan). Bare `<` is input redirection (reads a file read-only commands may
138
+ // `$(…)` + backtick + BOTH process substitutions `<(…)` / `>(…)` RUN a nested command. `>(…)` is
139
+ // named here explicitly because that is the class it is IN: the redirection scan also happens to
140
+ // match its `>`, but a command that RUNS something must be reported as running something, and a
141
+ // classification must not depend on another class's coincidence. Bare `<` is input redirection
142
+ // (reads a file — read-only commands may
134
143
  // already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
135
144
  // (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` also RUN a nested command —
136
145
  // matched as the literal openers `${ ` / `${\t` / `${\n` / `${\r` / `${|` (AD-055 Part II). Ordinary
137
146
  // `${VAR}` has no blank after `${`, so it trips none of these (kept rung-(b)-silent).
138
- commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
147
+ commandSubstitutions: Object.freeze(['$(', '`', '<(', '>(', '${ ', '${\t', '${\n', '${\r', '${|']),
139
148
  // A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
140
149
  // splices the two lines into ONE word, reconstructing a residual token (`--output`, `$(`, `${ …; }`)
141
150
  // a raw substring scan on the pre-splice string misses. Guards a settings-allowed SINGLE (rung c
@@ -264,6 +273,29 @@ export const matchSeededCorePrefix = (command) => {
264
273
  // direction (rung (c) forbids every construction character per segment, so this guards rung (b) singles).
265
274
  const WORD_CONSTRUCTION_CHARS = /["'\\[\]{}*?]/gu;
266
275
 
276
+ // THREE MECHANISMS WERE BUILT TO NARROW THIS SCAN AND ALL THREE WERE REMOVED (AD-079). They are
277
+ // recorded here because each died to a DIFFERENT counterexample, and together they say something
278
+ // the next attempt needs: this hook cannot decide what a redirection byte MEANS — not by parsing
279
+ // it, and not even by deleting it — because JavaScript's idea of a token boundary and bash's do not
280
+ // agree. Each counterexample was verified LIVE, each ships as a test, and each was removed on a
281
+ // stop rule declared to the reviewing bridge BEFORE the round that met it.
282
+ // 1. A quote/escape-aware ACTIVE VIEW, so a `>` inside a search pattern would stop reading as an
283
+ // operator. Defeated by HEREDOCS: their bodies are not shell code, and one quote in each of
284
+ // TWO bodies opens a spurious span and later closes it, with a genuinely active `$(…)` sitting
285
+ // BETWEEN them. The walker ends balanced, so the ambiguity fallback never engages, and the
286
+ // guard goes silent on a nested command. Exact sequence: the test below.
287
+ // 2. An fd-DUPLICATION exemption (`2>&1` writes nothing). Defeated by a missing token boundary:
288
+ // `>&word` duplicates only when the word is a bare number, and `grep x f >&12file` writes the
289
+ // FILE `12file`, whose `>&12` prefix the pattern deleted.
290
+ // 3. A NULL-DEVICE exemption (a redirect into `/dev/null` writes nothing), with a boundary this
291
+ // time. Defeated by U+00A0: JavaScript `\s` counts a no-break space as a boundary and bash does
292
+ // not, so `grep x f >/dev/null<U+00A0>sink` names the FILE `/dev/null<U+00A0>sink` and the span
293
+ // was deleted anyway.
294
+ // What survives is the original scan, unchanged, plus `>(` named in the substitution class where it
295
+ // belongs. The over-asks stay — a `>` that is ordinary text, and an fd duplication — and they are
296
+ // documented as open rather than papered over. AD-078's lesson, now one layer deeper: on an ASK rung
297
+ // an incomplete scan merely over-asks, which is safe; every attempt to make it exact has been a
298
+ // claim about bash that a dependency-free string scanner is not in a position to make.
267
299
  export const detectResidualClasses = (command) => {
268
300
  const deSpliced = command.replace(WORD_CONSTRUCTION_CHARS, '');
269
301
  const scan = (form) => command.includes(form) || deSpliced.includes(form);
@@ -6,9 +6,17 @@
6
6
  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:
7
7
 
8
8
  - **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 `${CLAUDE_SKILL_DIR}/references/modes/gates.md`) — 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`).
9
- - **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.
9
+ - **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. **That over-ask is a KNOWN COST, not an oversight** — see the open-limits section below, which names the three mechanisms built to narrow it and why each was removed.
10
10
  - **Auto-approve a read-only COMPOUND** (opt-in — dark unless enabled): when `docs/ai/lanes.json` sets `{ "readLane": true }` (read **LIVE on every call**), a command whose EVERY separator-split segment is a plain seeded read-only core command, carrying **zero shell metaprogramming** anywhere (no `$`/expansion, quoting, backslash, brace, glob, redirection, substitution, `--output`, env-assignment prefix, or backgrounding), is auto-approved — a conservative **closed-world** allow (any doubt falls through, never a widening). Mode-fenced like gate auto-approval; **cwd-agnostic** (a read is a read from any directory). It runs AFTER the residual ask, so a core command that carries a residual still prompts (most-restrictive-wins). The lane is **bounded by the frozen audited read-only core** (the set velocity seeds) — a **standalone opt-in grant**, never a command OUTSIDE that audited core; enabling it auto-approves compounds (and singles) of that audited core **regardless of which of those commands you seeded** as individual settings rules (that is the trust the opt-in consent covers — not strictly a subset of your current settings).
11
- - **Stay silent otherwise** — the normal permission flow proceeds unchanged. The gate-approval hook **never emits `deny`**; nothing is hard-blocked. A deny rung was built for kit 4.0.0 and REMOVED before release: it refused only a seeded read that provably discarded its output (`2>/dev/null`), and three council rounds found five shell constructs that defeated the byte-level discard PROOF (`1<&2`, a quoted literal `>/dev/null `, leading-token-only segment matching, a bare `&`, a `#` comment) — each one a FALSE REFUSAL. On an ask rung an incomplete scan merely over-asks; on a deny rung the same incompleteness refuses real work, and telling an operator from text needs a shell lexer this hook deliberately does not carry. Design record + all five counterexamples: the family's queue, `BARE-LANE-DENY-RUNG`.
11
+ - **Stay silent otherwise** — the normal permission flow proceeds unchanged. The gate-approval hook **never emits `deny`**; nothing is hard-blocked. A deny rung was built for kit 4.0.0 and REMOVED before release: it refused only a seeded read that provably discarded its output (`2>/dev/null`), and three council rounds found five shell constructs that defeated the byte-level discard PROOF (`1<&2`, a quoted literal `>/dev/null `, leading-token-only segment matching, a bare `&`, a `#` comment) — each one a FALSE REFUSAL. On an ask rung an incomplete scan merely over-asks; on a deny rung the same incompleteness refuses real work. **The direction is now RETIRED, not deferred** — the prompts a deny was meant to prevent were being raised by this hook's own ask, and a deny cannot remove a prompt the hook itself causes. Correcting what the guard reports can, and refuses nothing. Design record: the family's queue, `BARE-LANE-DENY-RUNG`.
12
+
13
+ **THE OVER-ASK IS OPEN, and 4.1.0 is the release that establishes WHY, rather than the one that fixes it.** A plain read wearing `2>/dev/null` or `2>&1`, and a search whose PATTERN merely contains `>` or a backtick, all still prompt. Three mechanisms to narrow that were built for this release and all three were REMOVED in review, each on a stop rule declared before the round that met it, each counterexample verified live and kept as a test:
14
+
15
+ 1. **A quote/escape-aware reading of the command**, so a `>` inside a search pattern would stop reading as an operator. Defeated by HEREDOCS: their bodies are not shell code, and one quote in each of two bodies opens a spurious quoted span and later closes it, with a genuinely active `$(…)` sitting between them — the walker ends balanced, never falls back, and the guard goes silent on a nested command.
16
+ 2. **An fd-duplication exemption** (`2>&1` creates no file). Defeated by a missing token boundary: `>&word` duplicates only when the word is a bare number, and `grep x f >&12file` writes the FILE `12file`.
17
+ 3. **A null-device exemption** (a redirect into `/dev/null` writes nothing), boundary included. Defeated by U+00A0: JavaScript's `\s` counts a no-break space as a word boundary and bash does not, so `grep x f >/dev/null` + U+00A0 + `sink` names a FILE and the span was deleted anyway.
18
+
19
+ **The transferable finding: this hook cannot decide what a redirection byte MEANS — not by parsing it, and not even by deleting it — because JavaScript's notion of a token boundary and bash's do not agree.** On an ASK rung that costs an extra prompt, which is safe; it is why the same scan must never be turned into a DENY. The honest workaround today is a search pattern that avoids the byte, and dropping a decoration your harness does not need.
12
20
 
13
21
  **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.
14
22
 
@@ -254,15 +254,18 @@ export const SHELL_METACHARACTERS = Object.freeze([
254
254
  // test/gate-hook-core-parity.test.mjs alongside UNIVERSAL_READONLY_ALLOWLIST.
255
255
  export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
256
256
  writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
257
- // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
258
- // the `>` redirection scan). Bare `<` is input redirection (reads a file read-only commands may
257
+ // `$(…)` + backtick + BOTH process substitutions `<(…)` / `>(…)` RUN a nested command. `>(…)` is
258
+ // named here explicitly because that is the class it is IN: the redirection scan also happens to
259
+ // match its `>`, but a command that RUNS something must be reported as running something, and a
260
+ // classification must not depend on another class's coincidence. Bare `<` is input redirection
261
+ // (reads a file — read-only commands may
259
262
  // already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
260
263
  // (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` (runs a command, assigns
261
264
  // REPLY) also execute a nested command — matched as the literal openers `${ ` / `${\t` / `${\n` /
262
265
  // `${\r` / `${|` (AD-055 Part II; the `\r` opener guards CRLF payloads — council agy nit). An
263
266
  // ordinary `${VAR}` parameter expansion has NO blank after `${`, so it trips none of these — kept
264
267
  // rung-(b)-silent on a settings-allowed single (rung (c) excludes all `$`).
265
- commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
268
+ commandSubstitutions: Object.freeze(['$(', '`', '<(', '>(', '${ ', '${\t', '${\n', '${\r', '${|']),
266
269
  // A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
267
270
  // splices the two lines into ONE word, which can reconstruct a residual token (`--output`, `$(`,
268
271
  // `${ …; }`) that a raw substring scan on the pre-splice string misses (`--outp\<newline>ut=f` →