diff --git a/docs/spikes/claude-code-hook-mutation.md b/docs/spikes/claude-code-hook-mutation.md index 9d91e16cd4..b41b7a6ca0 100644 --- a/docs/spikes/claude-code-hook-mutation.md +++ b/docs/spikes/claude-code-hook-mutation.md @@ -107,15 +107,26 @@ required for our hook to fire there. ``` **Pass-through (no preference, or one-way safety override):** + +Emit **no** `permissionDecision` — absence is "no opinion". Either write nothing to +stdout, or emit only `additionalContext`: ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", - "permissionDecision": "defer" + "additionalContext": "…optional…" } } ``` +> ⚠️ **Do not use `permissionDecision: "defer"` here.** (This spike originally +> recommended it; that was wrong and shipped a real bug — see the AskUserQuestion +> note in `~/.claude/CLAUDE.md`.) The documented set is `allow | deny | ask`. +> `defer` is an undocumented print-mode-only value: interactive Claude Code logs +> "defer is print-mode only" and ignores it, but non-interactive sessions (Cowork, +> Agent SDK, `claude -p`) honor it and leave the tool_use **unresolved with no +> result**, which the agent sees as `[Tool result missing due to internal error]`. + **PostToolUse capture (always):** ```json { diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index 12cbd5ea28..78085e3f99 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -92,13 +92,29 @@ function readStdin(): Promise { }); } +/** + * Pass the tool through untouched. Emits NO `permissionDecision` — absence is + * the correct "no opinion" signal, and the only one that is safe in both modes. + * + * Do NOT emit `permissionDecision: 'defer'` here. It is an undocumented + * print-mode-only value (the documented set is allow | deny | ask): interactive + * Claude Code logs "defer is print-mode only" and ignores it, but a + * non-interactive / print session — Cowork, the Agent SDK, `claude -p` — honors + * it and leaves the tool_use unresolved with no result, which surfaces to the + * agent as "[Tool result missing due to internal error]". That made + * AskUserQuestion permanently dead in Cowork while looking fine in the terminal. + */ function defer(additionalContext?: string): void { - const out: Record = { - hookEventName: 'PreToolUse', - permissionDecision: 'defer', - }; - if (additionalContext) out.additionalContext = additionalContext; - process.stdout.write(JSON.stringify({ hookSpecificOutput: out })); + if (additionalContext) { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext, + }, + }), + ); + } process.exit(0); } 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'); }); });