Skip to content

Commit 207a03f

Browse files
committed
merge: PR garrytan#2318 — question-preference-hook bỏ permissionDecision 'defer'
2 parents a325940 + f5968c4 commit 207a03f

5 files changed

Lines changed: 132 additions & 80 deletions

File tree

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Spike: Claude Code hook mutation for plan-tune cathedral
22

33
**Status:** complete (2026-05-27)
4+
**Revised:** 2026-07-22 — pass-through is empty stdout (not `defer`); restored
5+
decision-precedence facts alongside defer semantics.
46
**Surfaces:** D10 (does PreToolUse allow mutating AUQ input?), D19/Codex (matcher must cover MCP variants)
57
**Downstream consumers:** T3, T5, T6, T8
68

@@ -11,8 +13,9 @@ answer via `updatedInput`? If yes, what's the exact protocol?
1113

1214
## Answer
1315

14-
**Yes.** `updatedInput` is the supported mechanism. Source:
15-
https://code.claude.com/docs/en/hooks (confirmed 2026-04 reference).
16+
**Yes.** `updatedInput` is the supported mechanism (platform capability;
17+
this plan-tune hook does not take that path — see Implementation examples).
18+
Source: https://code.claude.com/docs/en/hooks (confirmed 2026-04 reference).
1619

1720
## Hook stdin schema (PreToolUse + PostToolUse)
1821

@@ -51,12 +54,16 @@ Optional in subagent context: `agent_id`, `agent_type`.
5154
- `"deny"` — block (feedback to Claude, NOT a synthetic answer per Codex
5255
correction in D-prefixed decisions)
5356
- `"ask"` — escalate to user
54-
- `"defer"` — let permission flow continue
57+
- `"defer"` — pause a non-interactive SDK/tool caller so it can resume later
58+
59+
No output (exit 0, empty stdout) is not a `permissionDecision` value. It is
60+
the pass-through path that lets the permission flow continue unchanged.
5561

5662
**`updatedInput` semantics:** shallow merge of fields present in the returned
5763
object onto the original `tool_input`. Only valid with
58-
`permissionDecision: "allow"`. This is what lets us substitute an
59-
auto-decided answer for `never-ask` preferences.
64+
`permissionDecision: "allow"`. This is what would let a hook substitute an
65+
auto-decided answer for `never-ask` preferences; the live plan-tune hook
66+
instead uses `deny` + reason (see Implementation examples).
6067

6168
## Matcher schema
6269

@@ -88,30 +95,36 @@ required for our hook to fire there.
8895
accepting.
8996

9097
**`permissionDecision` precedence (when multiple hooks decide):**
91-
`deny > ask > allow > defer` — most restrictive wins.
98+
`deny > ask > allow` — most restrictive wins.
99+
Claude Code treats `defer` as an explicit pause/resume decision, not as the
100+
normal pass-through path. Hooks that do not need to decide should exit 0 with
101+
no output (empty stdout), not emit `permissionDecision: "defer"`.
92102

93103
## Implementation hookSpecificOutput examples
94104

95105
**Auto-decide (PreToolUse, `never-ask` preference + non-one-way):**
106+
107+
The live hook uses `deny` + reason (not `allow` + `updatedInput`). See the
108+
hook header: AskUserQuestion's pre-resolve `updatedInput` shape is not
109+
structurally pinned, so deny naming the recommended option is the reliable
110+
path; the model reads the reason and proceeds without re-firing AUQ.
96111
```json
97112
{
98113
"hookSpecificOutput": {
99114
"hookEventName": "PreToolUse",
100-
"permissionDecision": "allow",
101-
"permissionDecisionReason": "plan-tune: never-ask preference on ship-test-failure-triage",
102-
"updatedInput": {
103-
"questions": [{ /* same as input, but with auto-selected answer */ }]
104-
}
115+
"permissionDecision": "deny",
116+
"permissionDecisionReason": "[plan-tune auto-decide] ship-pre-landing-review-fix → A) Fix now (recommended) (your never-ask preference). Proceed with that option without re-prompting. Change with /plan-tune."
105117
}
106118
}
107119
```
108120

109121
**Pass-through (no preference, or one-way safety override):**
122+
Exit 0 with no stdout. If the hook has context to add, omit `permissionDecision`:
110123
```json
111124
{
112125
"hookSpecificOutput": {
113126
"hookEventName": "PreToolUse",
114-
"permissionDecision": "defer"
127+
"additionalContext": "optional context for Claude"
115128
}
116129
}
117130
```
@@ -206,12 +219,11 @@ shells into bun.
206219
examples: ship/SKILL.md.tmpl emits options like `"A) Fix now"
207220
(recommended)`.
208221

209-
2. **Auto-decided event tagging.** When hook returns `updatedInput`, the
210-
PostToolUse hook will see the resolved input and log a normal event.
211-
Need an extra field on the PostToolUse payload (e.g.,
212-
`was_auto_decided: true`) that the hook can set via session state
213-
tracking — write a marker file in `~/.gstack/sessions/<id>/.auto-decided-<tool_use_id>`
214-
from PreToolUse, read it from PostToolUse, delete on read.
222+
2. **Auto-decided event tagging.** Auto-decide is `deny` + reason, so
223+
PostToolUse never fires on that tool call. The PreToolUse hook itself
224+
writes a session marker and logs `source=auto-decided` events before
225+
deny (see `markAutoDecided` / `logAutoDecided` in the hook). No
226+
PostToolUse payload field is required for the current path.
215227

216228
3. **Timeout behavior.** Default hook timeout is 60s but the docs are
217229
thin on what happens at timeout. Set explicit `timeout: 5` so the

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

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
1313
* 3. Read preferences with precedence: project-local > global (D8).
1414
* 4. Apply:
15-
* never-ask + one-way → defer (safety override; one-way always asks).
15+
* never-ask + one-way → no decision (safety override; one-way always asks).
1616
* never-ask + two-way + marker → deny with auto-decided recommendation
1717
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
1818
* ask-only-for-one-way + two-way + marker → same as never-ask.
19-
* always-ask, or no preference → defer.
19+
* always-ask, or no preference → no decision.
2020
*
2121
* Why deny+reason instead of allow+updatedInput:
2222
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
@@ -31,7 +31,7 @@
3131
* - First: (recommended) label suffix on an option.
3232
* - Fall back: "Recommendation: X" prose match against option labels.
3333
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
34-
* recommendation): defer instead of silent-wrong.
34+
* recommendation): no decision instead of silent-wrong.
3535
*
3636
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
3737
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
@@ -92,13 +92,24 @@ function readStdin(): Promise<string> {
9292
});
9393
}
9494

95-
function defer(additionalContext?: string): void {
96-
const out: Record<string, unknown> = {
97-
hookEventName: 'PreToolUse',
98-
permissionDecision: 'defer',
99-
};
100-
if (additionalContext) out.additionalContext = additionalContext;
101-
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
95+
function passThrough(additionalContext?: string): void {
96+
// "No opinion" must be a SILENT exit 0 (optionally with additionalContext
97+
// only), never an explicit `permissionDecision: 'defer'`. `defer` is a real
98+
// value in Claude Code, but it means "pause this tool call and hand control
99+
// back" and is honored in print/non-interactive mode. Emitting it here made
100+
// every AskUserQuestion get deferred in non-interactive sessions (e.g. the
101+
// desktop app) — the question UI never rendered. Interactive mode merely
102+
// warns and ignores it.
103+
if (additionalContext) {
104+
process.stdout.write(
105+
JSON.stringify({
106+
hookSpecificOutput: {
107+
hookEventName: 'PreToolUse',
108+
additionalContext,
109+
},
110+
}),
111+
);
112+
}
102113
process.exit(0);
103114
}
104115

@@ -347,15 +358,15 @@ function logAutoDecided(
347358
async function main(): Promise<void> {
348359
const raw = await readStdin();
349360
if (!raw.trim()) {
350-
defer();
361+
passThrough();
351362
return;
352363
}
353364
let stdin: HookStdin;
354365
try {
355366
stdin = JSON.parse(raw);
356367
} catch (e) {
357368
logHookError(`stdin parse failed: ${(e as Error).message}`);
358-
defer();
369+
passThrough();
359370
return;
360371
}
361372

@@ -364,26 +375,26 @@ async function main(): Promise<void> {
364375
toolName !== 'AskUserQuestion' &&
365376
!toolName.match(/^mcp__.+__AskUserQuestion$/)
366377
) {
367-
defer();
378+
passThrough();
368379
return;
369380
}
370381

371382
const questions = stdin.tool_input?.questions || [];
372383
if (questions.length === 0) {
373-
defer();
384+
passThrough();
374385
return;
375386
}
376387

377388
// For multi-question AUQ, enforcement is all-or-nothing per call:
378389
// we deny only if ALL questions have marker + never-ask + safe door type.
379-
// Mixed cases pass through (defer) so the user still gets to answer.
390+
// Mixed cases pass through so the user still gets to answer.
380391
const registry = loadRegistry();
381392
const slug = slugFromCwd(stdin.cwd);
382393
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
383394

384395
// Compute Layer 8 memory context inline: any nuggets matching the
385396
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
386-
// This applies whether we defer OR deny — gives the agent + user the
397+
// This applies whether we pass through OR deny — gives the agent + user the
387398
// relevant prior context either way.
388399
const contextNuggets: string[] = [];
389400
for (const q of questions) {
@@ -402,11 +413,11 @@ async function main(): Promise<void> {
402413
: undefined;
403414

404415
// Determine whether EVERY question is eligible for never-ask auto-decide.
405-
// We deliberately do NOT early-return defer on the first ineligible question:
406-
// a Conductor session still needs the [conductor] prose deny as a fallback,
407-
// so we compute eligibility, then branch. memoryContext is preserved on every
408-
// non-enforcing exit. (All-or-nothing per-call semantics are unchanged: any
409-
// ineligible question makes the whole call not auto-decidable.)
416+
// We deliberately do NOT early-return passThrough on the first ineligible
417+
// question: a Conductor session still needs the [conductor] prose deny as a
418+
// fallback, so we compute eligibility, then branch. memoryContext is preserved
419+
// on every non-enforcing exit. (All-or-nothing per-call semantics are unchanged:
420+
// any ineligible question makes the whole call not auto-decidable.)
410421
const autoDecisions: Array<{ id: string; recommended: string }> = [];
411422
let fullyAutoDecidable = true;
412423
for (const q of questions) {
@@ -471,10 +482,10 @@ async function main(): Promise<void> {
471482
return;
472483
}
473484

474-
defer(memoryContext);
485+
passThrough(memoryContext);
475486
}
476487

477488
main().catch((e) => {
478489
logHookError(`main crash: ${(e as Error).message}`);
479-
defer();
490+
passThrough();
480491
});

test/memory-cache-injection.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ function runHook(stdin: object): { stdout: string; stderr: string; status: numbe
4343
env.GSTACK_STATE_ROOT = stateRoot;
4444
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
4545
delete env.GSTACK_HOME;
46-
// These cases assert the defer-path memoryContext injection. Strip ambient
46+
// These cases assert pass-through memoryContext injection. Strip ambient
4747
// Conductor markers so running inside Conductor (CONDUCTOR_WORKSPACE_PATH/PORT
48-
// set) doesn't flip the hook into the [conductor] prose deny instead of defer.
48+
// set) doesn't flip the hook into the [conductor] prose deny instead of pass-through.
4949
delete env.CONDUCTOR_WORKSPACE_PATH;
5050
delete env.CONDUCTOR_PORT;
5151
const res = spawnSync(HOOK, [], {
@@ -69,7 +69,7 @@ function runHook(stdin: object): { stdout: string; stderr: string; status: numbe
6969
// ----------------------------------------------------------------------
7070

7171
describe('memory injection', () => {
72-
test('injects matching nugget into additionalContext on defer', () => {
72+
test('injects matching nugget into additionalContext while passing through', () => {
7373
writeMemory([
7474
{
7575
nugget: 'User prefers verbose explanations with tradeoffs',
@@ -91,7 +91,8 @@ describe('memory injection', () => {
9191
],
9292
},
9393
});
94-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
94+
expect(r.parsed?.hookSpecificOutput?.hookEventName).toBe('PreToolUse');
95+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
9596
expect(r.parsed?.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
9697
});
9798

@@ -115,7 +116,8 @@ describe('memory injection', () => {
115116
],
116117
},
117118
});
118-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
119+
expect(r.stdout).toBe('');
120+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
119121
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
120122
});
121123

@@ -219,7 +221,8 @@ describe('per-session memory cache', () => {
219221
],
220222
},
221223
});
222-
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
224+
expect(r.stdout).toBe('');
225+
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
223226
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
224227
});
225228
});

0 commit comments

Comments
 (0)