fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)

Every AskUserQuestion died with "Tool result missing due to internal error"
on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause:
the question-preference-hook emitted permissionDecision:'defer' on every
pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89
its semantics are "pause this tool call for external resumption" (headless
resume) — never "abstain". Interactive sessions have nothing to resume the
paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown
value, which is why the hook worked when it shipped and broke later.

The fix is the two-branch pass-through contract:
- no context -> exit 0 with EXACTLY empty stdout
- memory nuggets present -> hookSpecificOutput with hookEventName +
  additionalContext ONLY (the documented shape; plan-tune Layer 8 memory
  injection ships through this branch and keeps working)

defer() is renamed passThrough() so the function says what it does, and
docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the
hook header) is corrected in the same commit — it taught '"defer" — let
permission flow continue' and was the reintroduction vector.

Test contract rewritten in the same commit (13 assertions across 3 files,
verified fail-first against the unfixed hook): pass-through paths assert
exact-empty stdout (a garbage/partial write cannot slip past an
optional-chained parse), the nugget path asserts permissionDecision is
ABSENT while additionalContext survives, and a new tripwire asserts no
non-deny path ever puts the string "permissionDecision" on stdout. The
deny (auto-decide) and Conductor prose-redirect paths are unchanged.

Deployment: no migration needed — settings.json points at the absolute
bash shim which execs the .ts live; /gstack-upgrade delivers the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-09 19:08:49 -07:00
co-authored by Claude Fable 5
parent c4efc2a4f7
commit 3d97863b14
5 changed files with 137 additions and 61 deletions
+14 -3
View File
@@ -51,7 +51,13 @@ Optional in subagent context: `agent_id`, `agent_type`.
- `"deny"` — block (feedback to Claude, NOT a synthetic answer per Codex
correction in D-prefixed decisions)
- `"ask"` — escalate to user
- `"defer"`let permission flow continue
- `"defer"`pause the tool call for EXTERNAL resumption (Claude Code
v2.1.89+, a headless feature: resume with `-p --resume` to re-evaluate).
NEVER emit this to mean "no opinion" — in an interactive session nothing
resumes the paused call and the tool dies with "Tool result missing due to
internal error" (#2035, #2006). To abstain, exit 0 with EMPTY stdout
(optionally `hookSpecificOutput` with `additionalContext` only, no
`permissionDecision`).
**`updatedInput` semantics:** shallow merge of fields present in the returned
object onto the original `tool_input`. Only valid with
@@ -106,15 +112,20 @@ required for our hook to fire there.
}
```
**Pass-through (no preference, or one-way safety override):**
**Pass-through (no preference, or one-way safety override):** exit 0 with
EMPTY stdout. When there is context to inject (plan-tune memory nuggets),
emit `additionalContext` WITHOUT a `permissionDecision`:
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer"
"additionalContext": "[plan-tune memory] Past answers suggest: ..."
}
}
```
(Historical note: this example originally emitted `permissionDecision:
"defer"`, which broke every AskUserQuestion once CC v2.1.89 gave 'defer'
pause-for-resume semantics — #2035.)
**PostToolUse capture (always):**
```json