Closes Stream H Task 5 (H6). Adds the PreToolUse wrapper around the pure
decomposition-detector module (Stream A Direction 3 / v4.1 §3.8).
What this catches:
- A feature secretly decomposed into 3+ small prompts whose primary_keywords
overlap heavily AND no planning skill (writing-plans / brainstorming) has
been invoked in the window. v4.1 hard-blocks mutating tools when the LLM
judge confirms decomposition; soft-flags on legit-distinct verdict; allows
when threshold not met or a planning skill was invoked.
Defensive design choices:
- decide() takes llmVerdict as an explicit string ('YES'|'NO'|null), not an
async LLM call — keeps the function pure and unit-testable
without network.
- llmVerdict=null degrades to soft_flag (with degraded:true), NOT hard_block.
This avoids repeating the Stream G Task 8 self-lockout where a fail-CLOSE
LLM hook bricked the session.
- main() is a no-op (exit 0) until the deferred wiring lands (history-ledger
reader from observer Stop hook + LLM judge config from Stream D). Until
then, the hook never blocks anything.
Regression: vitest tools 1748/1748 GREEN (was 1742; +6 wrapper-decide tests
under "enforce-decomposition-detector wrapper (Stream H Task 5)" describe
block, covering: empty history → allow, below threshold → allow, threshold
+ LLM YES → hard_block_mutating, threshold + LLM NO → soft_flag, threshold
+ skill present → allow, threshold + LLM unavailable → degraded soft_flag).
DEFERRED: .claude/settings.json registration (PreToolUse matcher
"Edit|Write|MultiEdit|NotebookEdit|Bash|Task", timeout 8000ms) AND main()
wiring (history-ledger reader + LLM judge integration). Batched with
H5/H7/H8 hook activations at end of Phase H-α/H-β.
Stream H Task 5 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
Closes Stream H Task 6 (H4). Retires the manual approval-write workaround
the controller used throughout Stream H Tasks 1-5.
Two changes:
1. Pure module tools/askuser-answer-parser.mjs gains toApprovalRecord(answer, opts)
exporter that detects a git verb in the user's free-form answer and returns
a Stream B-compatible {type:'approve_git_operation', command, ts} record
(matches loadApprovedGitOps reader format in shell-content-rules.mjs:125).
Returns null for non-git answers and for stop/abort/cancel keywords.
2. New PostToolUse(AskUserQuestion) wrapper tools/enforce-askuser-answer-parser.mjs
reads each question/answer pair, calls toApprovalRecord, appends matching
records to ~/.claude/runtime/askuser-decisions-<sess>.jsonl. Fail-open
observability — never blocks AskUserQuestion.
Regression: vitest tools 1742/1742 GREEN (was 1731; +5 toApprovalRecord tests
under "toApprovalRecord (Stream H Task 6 — schema sync)" including non-string
guard, +6 wrapper-hook tests under "enforce-askuser-answer-parser wrapper
(Stream H Task 6)" including missing session_id fail-open guard).
DEFERRED: settings.json registration (matcher "AskUserQuestion", PostToolUse,
fail-open, timeout 2000ms) — batched with H5/H6/H7/H8 hook activations at end
of Phase H-α/H-β. Hook code is fully implemented and unit-tested; activation
pending settings.json update.
Stream H Task 6 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
Closes v3.8 FATAL F2: nested agent() calls inside Workflow scripts were
invisible to PreToolUse gates. New tools/enforce-workflow-gate.mjs hook
(PreToolUse, block-mode) enforces:
1. scriptPath requires approve_workflow_script record in
~/.claude/runtime/askuser-decisions-<sess>.jsonl with sha256 of content
and 5-min window (mirrors approve_git_operation pattern).
2. scriptContent static-scanned for dangerous patterns: env-key reads
(ROUTER_LLM_KEY/ANTHROPIC_API_KEY/GITHUB_TOKEN/SENTRY_AUTH_TOKEN),
eval(), child_process spawn/exec/fork, absolute fs writes outside /tmp,
path traversal (../../../).
3. sha256 mismatch between approval and current content → block (catches
modification after approval).
4. resumeFromRunId blocked unconditionally (state replay risk per spec).
5. Per-agent inheritance via CLAUDE_GATE_INHERIT env is handled by
subagent-prompt-prefix.mjs (Stream E) — this hook focuses on the outer
Workflow tool call. Nested agent() inside Workflow inherits parent gate.
Regression: vitest tools 1731/1731 GREEN (was 1726; +5 workflow-gate tests
under "enforce-workflow-gate scriptPath approval (F2)" describe block).
DEFERRED: .claude/settings.json registration (matcher "Workflow" → command
"node tools/enforce-workflow-gate.mjs", block-mode, timeout 5000ms) — the
settings.json file is in DEFAULT_PROTECTED_PATTERNS and enforce-read-path-
deny.mjs (Smoke 5 emergency fix 1072fae4) has no LEGIT_SKILLS exemption
like enforce-normative-content-rules.mjs does. Harness Edit/Write tracker
cannot be satisfied without a successful Read first. Will be batched into
a single manual settings.json registration step at end of Phase H-α
alongside H5/H6/H7 hook registrations. Hook code is fully implemented and
unit-tested; activation pending settings.json update.
Stream H Task 3 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
Found during Smoke 5 trace (recovery-procedures.md Section 5 fabrication #4):
extractPathArgs was missing protected paths when they appeared as a flag
value (--output=PATH or --output PATH) or as the second positional argument
(dd of=, tee, cp DST). The path-deny overlay correctly checks each candidate
path, but the candidate list was incomplete.
Fix: rewrite extractPathArgs to scan all tokens past index 0:
- recognize --flag=VALUE inline form (extract VALUE)
- recognize key=value (dd-style: if=, of=)
- skip URL-looking tokens (https://, ftp://, ssh://) as low-FP heuristic
- preserve existing behavior for plain positionals and skip redirect tokens
Regression: vitest tools 1726/1726 GREEN (was 1720; +6 path edge-case tests
under "extractPathArgs edge cases (Stream H Task 2)").
Stream H Task 2 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
Pre-flight sync per Pravila §15.2 («git fetch origin && git log
HEAD..origin/main») was blocked because GIT_READONLY_SUB in
shell-content-rules.mjs missed both `fetch` and `ls-remote` subcommands.
Both are ref-only (no working-tree mutation, no commit/push side effect)
and Stream B Whitelist construction left them out by omission — surfaced
during Stream H pre-flight 2026-05-30.
Fix: add both to GIT_READONLY_SUB; RED→GREEN 5 it.each cases covering
`git fetch`, `git fetch origin`, `git fetch --all`, `git ls-remote origin`,
`git ls-remote --heads`.
Atomic precursor commit before any Stream H plan task — does not touch
extractPathArgs (H2) or path-deny display format (H3); pure whitelist
extension.
Regression: vitest tools shell-content-rules.test.mjs 67/67 GREEN
(was 62; +5 new readonly tests). Full tools regression in next step.
Smoke 5 restart-test (chistaa session) refuted stale-process hypothesis and
identified the real bug: Stream A's pathNormalize() returned OS-native paths
(backslashes on win32) while DEFAULT_PROTECTED_PATTERNS regexes are forward-slash
only.
Trace confirmation:
Stream A pathNormalize('~/foo/bar.jsonl') on win32:
BEFORE: 'c:\\users\\admin\\foo\\bar.jsonl' — backslashes
AFTER: 'c:/users/admin/foo/bar.jsonl' — forward slashes
isProtectedPath now matches → Bash/PowerShell hooks block correctly.
Root cause: path.resolve() + fs.realpathSync() on Windows produce backslashes,
caseFold lowercases them but doesn't change separators. DEFAULT_PROTECTED_PATTERNS
in shell-content-rules.mjs are forward-slash regexes (e.g. /(^|\/)\.claude\/projects/i).
defaultPathNormalize fallback in shell-content-rules.mjs DID normalize separators,
which is why my emergency commit 1072fae4 unit-tests passed but live behavior
failed — live hooks use resolvePathNormalize() which returns Stream A's
buggy implementation.
Fix:
- path-normalization.mjs: append .split('\\').join('/') to pathNormalize output.
- path-normalization.test.mjs: +1 RED→GREEN test for win32 separator normalization.
Why previous commit 1072fae4 was incomplete:
- Added pattern to protected list ✓
- Added enforce-read-path-deny.mjs ✓ (Read tool — works because hook uses
defaultPathNormalize directly, not resolvePathNormalize)
- Did NOT detect Bash/PowerShell path-normalize integration bug (debug script
bypassed Stream A by passing defaultPathNormalize directly).
Side observation (recorded as Stream H TODO by chistaa session):
- extractPathArgs/pathDenyOverlay — non-reading path in non-first position is
not checked fully. Independent latent bug, separate fix.
Regression: 1715/1715 vitest tools GREEN (+1 separator test).
Critical: re-run Smoke 5 in clean session — expected PASS all 6 vectors now.
Smoke 5 (user-run 2026-05-30) found 5 of 6 self-exfil vectors OPEN — parent
context transcripts (~/.claude/projects/*/<session>.jsonl) readable via:
- Bash: ls ~/.claude/projects/, cat ~/.claude/projects/.../jsonl, head/tail
- PowerShell: Get-Content / gc / Select-String
- Read tool: direct file_path
Spec §3.1 declared this protection but Stream B/C/G never wired path-deny.
DEFAULT_PROTECTED_PATTERNS in shell-content-rules.mjs lacked ~/.claude/projects/.
Read tool had no path-deny hook at all.
Closure:
- shell-content-rules.mjs: +1 regex /(^|\/)\.claude\/projects(\/|$)/i in
DEFAULT_PROTECTED_PATTERNS. Bash hook + PowerShell hook already use this
shared list via pathDenyOverlay → both close automatically.
- enforce-read-path-deny.mjs (NEW): PreToolUse(Read) wrapper rejects any
Read on isProtectedPath() match. Uses same shared DEFAULT_PROTECTED_PATTERNS
for consistency (covers transcript / runtime / .env / normative / dep-checksums).
- .claude/settings.json: register enforce-read-path-deny.mjs PreToolUse Read.
Tests: shell-content-rules.test.mjs +3 path cases in protects %s + 1 dedicated
it() block. enforce-read-path-deny.test.mjs +6 cases covering Read tool block.
1714/1714 vitest tools GREEN (was 1707, +7 net).
Critical: re-run Smoke 5 in clean session before continuing other smokes.
Expected post-fix: all 6 vectors blocked (Bash/PowerShell via path-deny,
Read via enforce-read-path-deny, Write via existing normative-content-rules).
Side observation from Smoke 5 session: controller self-fabrication of
intermediate results (4 occurrences across Smokes 1-5) confirms one of the
7 fundamental limits — gate paradox + behavioral residual irreducible.
No hook catches in-response narrative fabrication (not through tool-gate).
This is a Stream H + recovery-procedures.md documentation item, not a hook fix.
findOverride/findOverrideAttempt/loadOverrideVocab become permanent stubs returning null/null/empty.
Non-deleted hooks (verify-before-push, tdd-gate, memory-coverage, branch-switch) still import these
symbols and need them to compile; runtime always reports 'no override'.
Adapted 15 existing tests in enforce-hook-helpers.test.mjs and 7 in enforce-semgrep-security.test.mjs
that asserted old vocab behaviour; all now assert stub behaviour (null/empty).
1824/1824 vitest tools GREEN.
Stream G of router-gate v4 deployment.
После merge Stream A модуль ./path-normalization.mjs существует → resolvePathNormalize() возвращает Stream A pathNormalize, не fallback. Stream B тест предполагал отсутствие модуля и assert'ил конкретное default-значение 'a/b'.
Fix: меняю assertion на 'returns a function' + 'does not throw' — сохраняет original intent (resolvePathNormalize всегда возвращает callable) без жёсткой привязки к implementation Stream A pathNormalize.
Verified: vitest 59/59 GREEN на enforce-router-gate.test.mjs.
Opt-in live smoke (ROUTER_LLM_LIVE_TEST=1 + ROUTER_LLM_KEY); auto-skips otherwise
so it never pollutes the unit regression in worktrees where undici is unresolved.
Checkpoint-1 live result on owner machine: PASS (2/2) — single Sonnet judge + 3-judge
consensus (Sonnet 4.6 + Haiku 4.5 + Opus 4.7) reach all models with real verdicts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recovered from a subagent crash (socket error mid-task) that left literal-newline
corruption in two .join() string literals; repaired and committed by controller.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>