Skip to content

fix(hooks): question-preference-hook must not emit permissionDecision 'defer' (merges #2165 + #1924)#2318

Open
Akagilnc wants to merge 4 commits into
garrytan:mainfrom
Akagilnc:fix/auq-hook-neutral-passthrough
Open

fix(hooks): question-preference-hook must not emit permissionDecision 'defer' (merges #2165 + #1924)#2318
Akagilnc wants to merge 4 commits into
garrytan:mainfrom
Akagilnc:fix/auq-hook-neutral-passthrough

Conversation

@Akagilnc

Copy link
Copy Markdown

Picking up the handoff from #2165. @time-attack asked there on 2026-07-15:

Please rewrite this version to include the accurate documentation and strict output coverage from PR 1924. Its implementation is otherwise the best current foundation.

Neither PR has moved since, so this branch does that merge. Credit belongs to @dpnascimento (#2165) and @jacksondc (#1924); every commit here carries both as co-authors. If either of them would rather carry it forward, I am happy to close this in favour of theirs.

The bug

hosts/claude/hooks/question-preference-hook.ts emitted permissionDecision: "defer" on its no-opinion path. defer is a real Claude Code value meaning "pause this tool call and hand control back", and it is honored in print/non-interactive mode. So in any non-interactive session, every AskUserQuestion the hook did not auto-decide got parked and never executed. On my machine one such call was replayed several times across resumes, and no tool_result was ever written for it.

What changed

  • defer() becomes passThrough(): silent exit 0, or hookSpecificOutput.additionalContext alone when plan-tune has context to inject. No permissionDecision on that path.
  • The deny paths (never-ask auto-decide, Conductor prose redirect) are untouched.
  • Spike doc corrections, going a little beyond Fix AskUserQuestion hook pass-through #1924: it restores the deny > ask > allow precedence fact that Fix AskUserQuestion hook pass-through #1924 deleted outright, moves "No output" out of the permissionDecision values: list where it is not a value, and rewrites the Auto-decide example from the never-implemented allow + updatedInput to the deny + reason shape the hook actually emits.
  • Tests: expectNoDecision (from Fix AskUserQuestion hook pass-through #1924) now covers about ten pass-through cases, plus two cases Fix AskUserQuestion hook pass-through #1924 lacked. One is mcp__conductor__AskUserQuestion with no preference, which is the exact shape the bug reports fire on. The other asserts hookEventName === 'PreToolUse' on the additionalContext-only output.

Scope

Fixes the root cause behind #2006, #2035, #2292 and #2310.

#2207 is not fixed here. That one is the Conductor auto-install of the hook, owned by #2208. An early commit body on this branch listed #2207 among the root-cause issues; that wording was too broad, and this PR body is authoritative on claim surface.

Verification

bun test test/question-preference-hook.test.ts test/memory-cache-injection.test.ts
  28 pass / 0 fail / 67 expect()      (upstream/main: 27 pass / 52 expect)

bun run test
  2 failures: spec-template-sync, session-runner observability
  Both also fail on untouched upstream/main. Zero new failures.

bun run slop:diff
  no new findings in 5 changed files

Two things I could not verify

The cathedral e2e assertion never executed locally. test/skill-e2e-plan-tune-cathedral.test.ts is EVALS=1-gated. Forcing it with EVALS_ALL=1 EVALS=1 gives 0 pass / 5 fail, because the fixture scaffold does not copy lib/, so the hook dies with Cannot find module '../../../lib/is-conductor' at expect(res.status).toBe(0), before the pass-through assertion runs. That fixture gap predates this branch and is not fixed here. Gate coverage for this behaviour is the two unit test files above.

The Conductor deny reason carries a premise I could not confirm. In Conductor the hook still denies with a prose redirect whose reason cites the MCP variant being flaky. That premise was tied to the same defer path this PR removes, and I have not re-proven it under current Claude Code. The behaviour is deliberately unchanged, since it is out of scope, but that wording is now a historical claim this PR does not confirm.

I can rebase or reshape this however suits the review.

Akagilnc and others added 4 commits July 22, 2026 15:15
…erence-hook

Silent pass-through (exit 0, optional additionalContext only) instead of
explicit permissionDecision: 'defer'. Claude Code treats defer as a real
pause/resume decision and honors it in print/non-interactive mode, which
swallowed every AskUserQuestion (no tool_result, resume replay loops).
Fixes the root cause behind garrytan#2006/garrytan#2035/garrytan#2207/garrytan#2292/garrytan#2310.

Merges PR garrytan#2165 (print-mode why comment + foundation) with PR garrytan#1924
(passThrough rename, accurate docs, stricter empty-stdout assertions).
deny paths (never-ask auto-decide, Conductor prose redirect) unchanged.

Co-authored-by: dpnascimento <dpnascimento@users.noreply.github.com>
Co-authored-by: jacksondc <jacksondc@users.noreply.github.com>
Round-1 fixer pass against review findings on
fix/auq-hook-neutral-passthrough (4c082806).

Tests:
- empty-stdin case uses expectNoDecision (strict empty stdout; drops
  dead JSON.parse assertions that were always true when stdout was empty)
- add MCP-named AUQ + no preference → expectNoDecision (real failure
  shape for garrytan#2006/garrytan#2035/garrytan#2292/garrytan#2310)

Docs (docs/spikes/claude-code-hook-mutation.md):
- restore multi-hook precedence fact: deny > ask > allow (most
  restrictive wins), keep defer-is-not-pass-through clarification
- move "no output" out of permissionDecision values list
- mark revised date for the pass-through correction

Scope note for PR body: this PR addresses silent pass-through for the
hook itself (garrytan#2006/garrytan#2035/garrytan#2292/garrytan#2310). garrytan#2207 Conductor auto-install
belongs to garrytan#2208 and is not claimed fixed here.

Co-authored-by: dpnascimento <dpnascimento@users.noreply.github.com>
Co-authored-by: jacksondc <jacksondc@users.noreply.github.com>
Round-2 fixer pass on fix/auq-hook-neutral-passthrough.

Tests:
- empty-stdin crash-safety case asserts empty stdout directly (no
  JSON.parse of '' that could never fail after expectNoDecision)
- memory additionalContext-only path also requires hookEventName
  PreToolUse (garrytan#2310 output shape)

Docs (docs/spikes/claude-code-hook-mutation.md):
- Auto-decide example matches live hook: deny + permissionDecisionReason
  (not allow + updatedInput); open-question item updated for deny path

Co-authored-by: dpnascimento <dpnascimento@users.noreply.github.com>
Co-authored-by: jacksondc <jacksondc@users.noreply.github.com>
Round-3 fixer pass on fix/auq-hook-neutral-passthrough.

M1 — empty-stdin crash-safety case now asserts res.stdout directly
(no ?? '' / || '' mask). Matches expectNoDecision, garrytan#1924 original, and
every other empty-stdout assertion in-repo.

Sh1 — spike Answer + updatedInput semantics keep the platform fact but
qualify that plan-tune lives on deny+reason (Implementation examples),
so the headline no longer contradicts the live-hook section.

Co-authored-by: dpnascimento <dpnascimento@users.noreply.github.com>
Co-authored-by: jacksondc <jacksondc@users.noreply.github.com>
@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant