Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/spikes/claude-code-hook-mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,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

Expand All @@ -110,8 +117,7 @@ required for our hook to fire there.
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer"
"hookEventName": "PreToolUse"
}
}
```
Expand Down
7 changes: 6 additions & 1 deletion hosts/claude/hooks/question-preference-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,14 @@ function readStdin(): Promise<string> {
}

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<string, unknown> = {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
};
if (additionalContext) out.additionalContext = additionalContext;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
Expand Down
6 changes: 3 additions & 3 deletions test/memory-cache-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
20 changes: 10 additions & 10 deletions test/question-preference-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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', () => {
Expand All @@ -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)', () => {
Expand All @@ -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();
});
});

Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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', () => {
Expand All @@ -255,7 +255,7 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down Expand Up @@ -317,7 +317,7 @@ describe('precedence: project wins over global (D8)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down Expand Up @@ -443,7 +443,7 @@ describe('Conductor prose redirect', () => {
undefined,
CONDUCTOR,
);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/skill-e2e-plan-tune-cathedral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand Down