Skip to content

Commit ade59ab

Browse files
jawadakram20claude
andcommitted
fix(hooks): don't emit invalid permissionDecision 'defer' — it swallows AskUserQuestion
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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a325940 commit ade59ab

5 files changed

Lines changed: 27 additions & 18 deletions

File tree

docs/spikes/claude-code-hook-mutation.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ Optional in subagent context: `agent_id`, `agent_type`.
5151
- `"deny"` — block (feedback to Claude, NOT a synthetic answer per Codex
5252
correction in D-prefixed decisions)
5353
- `"ask"` — escalate to user
54-
- `"defer"` — let permission flow continue
54+
- `"defer"`**NOT a valid value.** The schema accepts only `allow` / `deny` /
55+
`ask`. As of Claude Code 2.1.14 an unrecognized `"defer"` is treated as
56+
"defer the tool call": the call is swallowed and never executes. For
57+
AskUserQuestion this means the question widget never renders and the user
58+
sees nothing at all. To signal "no opinion, let permission flow continue",
59+
OMIT `permissionDecision` entirely (`additionalContext` is still delivered).
5560

5661
**`updatedInput` semantics:** shallow merge of fields present in the returned
5762
object onto the original `tool_input`. Only valid with
@@ -110,8 +115,7 @@ required for our hook to fire there.
110115
```json
111116
{
112117
"hookSpecificOutput": {
113-
"hookEventName": "PreToolUse",
114-
"permissionDecision": "defer"
118+
"hookEventName": "PreToolUse"
115119
}
116120
}
117121
```

hosts/claude/hooks/question-preference-hook.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,14 @@ function readStdin(): Promise<string> {
9393
}
9494

9595
function defer(additionalContext?: string): void {
96+
// NOTE: do NOT emit `permissionDecision: 'defer'`. Claude Code's PreToolUse
97+
// schema only accepts 'allow' | 'deny' | 'ask'; as of 2.1.14 an unrecognized
98+
// 'defer' is interpreted as "defer the tool call", which SWALLOWS the
99+
// AskUserQuestion widget — it never renders and the user sees nothing.
100+
// Omitting permissionDecision entirely is the correct "no opinion" signal:
101+
// normal permission flow continues and additionalContext is still delivered.
96102
const out: Record<string, unknown> = {
97103
hookEventName: 'PreToolUse',
98-
permissionDecision: 'defer',
99104
};
100105
if (additionalContext) out.additionalContext = additionalContext;
101106
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));

test/memory-cache-injection.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('memory injection', () => {
9191
],
9292
},
9393
});
94-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
94+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
9595
expect(r.parsed?.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
9696
});
9797

@@ -115,7 +115,7 @@ describe('memory injection', () => {
115115
],
116116
},
117117
});
118-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
118+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
119119
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
120120
});
121121

@@ -219,7 +219,7 @@ describe('per-session memory cache', () => {
219219
],
220220
},
221221
});
222-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
222+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
223223
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
224224
});
225225
});

test/question-preference-hook.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe('defers (no enforcement)', () => {
126126
},
127127
});
128128
expect(r.status).toBe(0);
129-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
129+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
130130
});
131131

132132
test('marker missing → defer (D18)', () => {
@@ -141,7 +141,7 @@ describe('defers (no enforcement)', () => {
141141
],
142142
},
143143
});
144-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
144+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
145145
});
146146

147147
test('always-ask preference → defer', () => {
@@ -156,7 +156,7 @@ describe('defers (no enforcement)', () => {
156156
],
157157
},
158158
});
159-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
159+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
160160
});
161161

162162
test('empty stdin → defer (crash safety)', () => {
@@ -168,13 +168,13 @@ describe('defers (no enforcement)', () => {
168168
const res = spawnSync(HOOK, [], { env, input: '', encoding: 'utf-8' });
169169
expect(res.status).toBe(0);
170170
const parsed = JSON.parse(res.stdout || '{}');
171-
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
171+
expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined();
172172
});
173173

174174
test('non-AUQ tool_name → defer (defensive)', () => {
175175
writeProjectPref('test-q', 'never-ask');
176176
const r = runHook({ session_id: 's4', tool_name: 'Bash', tool_use_id: 'tu-4', tool_input: {} });
177-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
177+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
178178
});
179179
});
180180

@@ -219,7 +219,7 @@ describe('enforces never-ask preferences', () => {
219219
],
220220
},
221221
});
222-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
222+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
223223
});
224224

225225
test('ambiguous recommendation (two labels) → defer (D2 refuse-on-ambiguous)', () => {
@@ -237,7 +237,7 @@ describe('enforces never-ask preferences', () => {
237237
],
238238
},
239239
});
240-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
240+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
241241
});
242242

243243
test('no recommendation marker AND no prose match → defer', () => {
@@ -255,7 +255,7 @@ describe('enforces never-ask preferences', () => {
255255
],
256256
},
257257
});
258-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
258+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
259259
});
260260
});
261261

@@ -317,7 +317,7 @@ describe('precedence: project wins over global (D8)', () => {
317317
],
318318
},
319319
});
320-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
320+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
321321
});
322322
});
323323

@@ -443,7 +443,7 @@ describe('Conductor prose redirect', () => {
443443
undefined,
444444
CONDUCTOR,
445445
);
446-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
446+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
447447
});
448448
});
449449

test/skill-e2e-plan-tune-cathedral.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ describeIfSelected('PlanTune cathedral E2E: annotation', ['plan-tune-annotation'
296296
});
297297
expect(res.status).toBe(0);
298298
const parsed = JSON.parse(res.stdout || '{}');
299-
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
299+
expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined();
300300
expect(parsed.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
301301
});
302302
});

0 commit comments

Comments
 (0)