From 2faf6ae8bf8e73269cf6f641f85d0156ab270dff Mon Sep 17 00:00:00 2001 From: jawad Akram Date: Wed, 22 Jul 2026 23:33:20 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(hooks):=20don't=20emit=20invalid=20perm?= =?UTF-8?q?issionDecision=20'defer'=20=E2=80=94=20it=20swallows=20AskUserQ?= =?UTF-8?q?uestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PreToolUse question-preference-hook returned `{"permissionDecision":"defer"}` to mean "no opinion, let the permission flow continue". That value is not in Claude Code's PreToolUse schema, which accepts only `allow` / `deny` / `ask`. As of Claude Code 2.1.14 the unrecognized value is interpreted as "defer the tool call": the call is dropped and recorded as a `hook_deferred_tool` attachment. For AskUserQuestion this means the question widget never renders — the user sees nothing at all and the agent is left waiting. Observed in a real transcript: hook_success with `"permissionDecision":"defer"`, then `hook_deferred_tool`, then the user typing "questions are not loading". Because the defer path is the default for every non-auto-decidable question (no preference set, marker missing, always-ask, one-way door safety override), this broke AskUserQuestion for essentially all users, not just those with plan-tune preferences configured. Fix: omit `permissionDecision` entirely in defer(). That is the correct "no opinion" signal — normal permission flow continues and `additionalContext` (the memory-cache injection) is still delivered. The deny paths (never-ask auto-decide, Conductor prose redirect) are untouched and still enforce. Tests updated to assert `permissionDecision` is undefined on the defer path rather than the string 'defer'. 51 pass / 0 fail across question-preference-hook, memory-cache-injection and gstack-question-log. --- docs/spikes/claude-code-hook-mutation.md | 10 +++++++--- .../claude/hooks/question-preference-hook.ts | 7 ++++++- test/memory-cache-injection.test.ts | 6 +++--- test/question-preference-hook.test.ts | 20 +++++++++---------- test/skill-e2e-plan-tune-cathedral.test.ts | 2 +- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/spikes/claude-code-hook-mutation.md b/docs/spikes/claude-code-hook-mutation.md index 9d91e16cd4..9d918daae5 100644 --- a/docs/spikes/claude-code-hook-mutation.md +++ b/docs/spikes/claude-code-hook-mutation.md @@ -51,7 +51,12 @@ 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"` — **NOT a valid value.** The schema accepts only `allow` / `deny` / + `ask`. As of Claude Code 2.1.14 an unrecognized `"defer"` is treated as + "defer the tool call": the call is swallowed and never executes. For + AskUserQuestion this means the question widget never renders and the user + sees nothing at all. To signal "no opinion, let permission flow continue", + OMIT `permissionDecision` entirely (`additionalContext` is still delivered). **`updatedInput` semantics:** shallow merge of fields present in the returned object onto the original `tool_input`. Only valid with @@ -110,8 +115,7 @@ required for our hook to fire there. ```json { "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "defer" + "hookEventName": "PreToolUse" } } ``` diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index 12cbd5ea28..600f80c8d9 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -93,9 +93,14 @@ function readStdin(): Promise { } function defer(additionalContext?: string): void { + // NOTE: do NOT emit `permissionDecision: 'defer'`. Claude Code's PreToolUse + // schema only accepts 'allow' | 'deny' | 'ask'; as of 2.1.14 an unrecognized + // 'defer' is interpreted as "defer the tool call", which SWALLOWS the + // AskUserQuestion widget — it never renders and the user sees nothing. + // Omitting permissionDecision entirely is the correct "no opinion" signal: + // normal permission flow continues and additionalContext is still delivered. const out: Record = { hookEventName: 'PreToolUse', - permissionDecision: 'defer', }; if (additionalContext) out.additionalContext = additionalContext; process.stdout.write(JSON.stringify({ hookSpecificOutput: out })); diff --git a/test/memory-cache-injection.test.ts b/test/memory-cache-injection.test.ts index 3ab6a2144a..4f8a3b68a7 100644 --- a/test/memory-cache-injection.test.ts +++ b/test/memory-cache-injection.test.ts @@ -91,7 +91,7 @@ describe('memory injection', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); expect(r.parsed?.hookSpecificOutput?.additionalContext).toContain('verbose explanations'); }); @@ -115,7 +115,7 @@ describe('memory injection', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined(); }); @@ -219,7 +219,7 @@ describe('per-session memory cache', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined(); }); }); diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts index 39de02f4e8..c104f44940 100644 --- a/test/question-preference-hook.test.ts +++ b/test/question-preference-hook.test.ts @@ -126,7 +126,7 @@ describe('defers (no enforcement)', () => { }, }); expect(r.status).toBe(0); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('marker missing → defer (D18)', () => { @@ -141,7 +141,7 @@ describe('defers (no enforcement)', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('always-ask preference → defer', () => { @@ -156,7 +156,7 @@ describe('defers (no enforcement)', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('empty stdin → defer (crash safety)', () => { @@ -168,13 +168,13 @@ describe('defers (no enforcement)', () => { const res = spawnSync(HOOK, [], { env, input: '', encoding: 'utf-8' }); expect(res.status).toBe(0); const parsed = JSON.parse(res.stdout || '{}'); - expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('non-AUQ tool_name → defer (defensive)', () => { writeProjectPref('test-q', 'never-ask'); const r = runHook({ session_id: 's4', tool_name: 'Bash', tool_use_id: 'tu-4', tool_input: {} }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); @@ -219,7 +219,7 @@ describe('enforces never-ask preferences', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('ambiguous recommendation (two labels) → defer (D2 refuse-on-ambiguous)', () => { @@ -237,7 +237,7 @@ describe('enforces never-ask preferences', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('no recommendation marker AND no prose match → defer', () => { @@ -255,7 +255,7 @@ describe('enforces never-ask preferences', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); @@ -317,7 +317,7 @@ describe('precedence: project wins over global (D8)', () => { ], }, }); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); @@ -443,7 +443,7 @@ describe('Conductor prose redirect', () => { undefined, CONDUCTOR, ); - expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); diff --git a/test/skill-e2e-plan-tune-cathedral.test.ts b/test/skill-e2e-plan-tune-cathedral.test.ts index f9c006914e..e07eb70fa1 100644 --- a/test/skill-e2e-plan-tune-cathedral.test.ts +++ b/test/skill-e2e-plan-tune-cathedral.test.ts @@ -296,7 +296,7 @@ describeIfSelected('PlanTune cathedral E2E: annotation', ['plan-tune-annotation' }); expect(res.status).toBe(0); const parsed = JSON.parse(res.stdout || '{}'); - expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer'); + expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined(); expect(parsed.hookSpecificOutput?.additionalContext).toContain('verbose explanations'); }); }); From ae4f6b9aa3ac5e9027e2c3aca462980bdbb7ddf8 Mon Sep 17 00:00:00 2001 From: jawad Akram Date: Thu, 23 Jul 2026 15:43:22 +0500 Subject: [PATCH 2/2] docs(spike): drop 'defer' from the permissionDecision precedence chain Follow-up to the same file's corrected values list. The multi-hook precedence line still presented 'defer' as a decision value, contradicting the note above it. Precedence is deny > ask > allow; a hook with no opinion omits permissionDecision entirely. --- docs/spikes/claude-code-hook-mutation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/spikes/claude-code-hook-mutation.md b/docs/spikes/claude-code-hook-mutation.md index 9d918daae5..cf857ccf60 100644 --- a/docs/spikes/claude-code-hook-mutation.md +++ b/docs/spikes/claude-code-hook-mutation.md @@ -93,7 +93,9 @@ required for our hook to fire there. accepting. **`permissionDecision` precedence (when multiple hooks decide):** -`deny > ask > allow > defer` — most restrictive wins. +`deny > ask > allow` — most restrictive wins. A hook with no opinion omits +`permissionDecision` entirely rather than emitting a sentinel value; see the +note above on why `"defer"` is not valid here. ## Implementation hookSpecificOutput examples