mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 08:29:04 +02:00
fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)
Every AskUserQuestion died with "Tool result missing due to internal error" on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause: the question-preference-hook emitted permissionDecision:'defer' on every pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89 its semantics are "pause this tool call for external resumption" (headless resume) — never "abstain". Interactive sessions have nothing to resume the paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown value, which is why the hook worked when it shipped and broke later. The fix is the two-branch pass-through contract: - no context -> exit 0 with EXACTLY empty stdout - memory nuggets present -> hookSpecificOutput with hookEventName + additionalContext ONLY (the documented shape; plan-tune Layer 8 memory injection ships through this branch and keeps working) defer() is renamed passThrough() so the function says what it does, and docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the hook header) is corrected in the same commit — it taught '"defer" — let permission flow continue' and was the reintroduction vector. Test contract rewritten in the same commit (13 assertions across 3 files, verified fail-first against the unfixed hook): pass-through paths assert exact-empty stdout (a garbage/partial write cannot slip past an optional-chained parse), the nugget path asserts permissionDecision is ABSENT while additionalContext survives, and a new tripwire asserts no non-deny path ever puts the string "permissionDecision" on stdout. The deny (auto-decide) and Conductor prose-redirect paths are unchanged. Deployment: no migration needed — settings.json points at the absolute bash shim which execs the .ts live; /gstack-upgrade delivers the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c4efc2a4f7
commit
3d97863b14
@@ -12,11 +12,15 @@
|
||||
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
|
||||
* 3. Read preferences with precedence: project-local > global (D8).
|
||||
* 4. Apply:
|
||||
* never-ask + one-way → defer (safety override; one-way always asks).
|
||||
* never-ask + one-way → pass through (safety override; one-way always asks).
|
||||
* never-ask + two-way + marker → deny with auto-decided recommendation
|
||||
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
|
||||
* ask-only-for-one-way + two-way + marker → same as never-ask.
|
||||
* always-ask, or no preference → defer.
|
||||
* always-ask, or no preference → pass through.
|
||||
*
|
||||
* Pass-through = exit 0 with empty stdout (or additionalContext-only output
|
||||
* when memory nuggets exist) — NEVER permissionDecision:'defer', whose
|
||||
* CC v2.1.89+ semantics are pause-for-external-resumption (#2035, #2006).
|
||||
*
|
||||
* Why deny+reason instead of allow+updatedInput:
|
||||
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
|
||||
@@ -31,7 +35,7 @@
|
||||
* - First: (recommended) label suffix on an option.
|
||||
* - Fall back: "Recommendation: X" prose match against option labels.
|
||||
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
|
||||
* recommendation): defer instead of silent-wrong.
|
||||
* recommendation): pass through instead of silent-wrong.
|
||||
*
|
||||
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
|
||||
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
|
||||
@@ -92,13 +96,25 @@ function readStdin(): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function defer(additionalContext?: string): void {
|
||||
const out: Record<string, unknown> = {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'defer',
|
||||
};
|
||||
if (additionalContext) out.additionalContext = additionalContext;
|
||||
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
|
||||
function passThrough(additionalContext?: string): void {
|
||||
// Abstain = exit 0 with EMPTY stdout (#2035, #2006). Never emit a
|
||||
// permissionDecision here: 'defer' is a real PreToolUse value, but since
|
||||
// Claude Code v2.1.89 its semantics are "pause this tool call for external
|
||||
// resumption" (a headless-resume feature) — NOT "no opinion". In an
|
||||
// interactive session nothing resumes the paused call, so every
|
||||
// AskUserQuestion died with "Tool result missing due to internal error".
|
||||
// additionalContext-only hookSpecificOutput is the documented shape for
|
||||
// injecting context (plan-tune memory nuggets) without a decision.
|
||||
if (additionalContext) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -347,7 +363,7 @@ function logAutoDecided(
|
||||
async function main(): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) {
|
||||
defer();
|
||||
passThrough();
|
||||
return;
|
||||
}
|
||||
let stdin: HookStdin;
|
||||
@@ -355,7 +371,7 @@ async function main(): Promise<void> {
|
||||
stdin = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||
defer();
|
||||
passThrough();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,26 +380,26 @@ async function main(): Promise<void> {
|
||||
toolName !== 'AskUserQuestion' &&
|
||||
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
||||
) {
|
||||
defer();
|
||||
passThrough();
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = stdin.tool_input?.questions || [];
|
||||
if (questions.length === 0) {
|
||||
defer();
|
||||
passThrough();
|
||||
return;
|
||||
}
|
||||
|
||||
// For multi-question AUQ, enforcement is all-or-nothing per call:
|
||||
// we deny only if ALL questions have marker + never-ask + safe door type.
|
||||
// Mixed cases pass through (defer) so the user still gets to answer.
|
||||
// Mixed cases pass through so the user still gets to answer.
|
||||
const registry = loadRegistry();
|
||||
const slug = slugFromCwd(stdin.cwd);
|
||||
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
|
||||
|
||||
// Compute Layer 8 memory context inline: any nuggets matching the
|
||||
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
|
||||
// This applies whether we defer OR deny — gives the agent + user the
|
||||
// This applies whether we pass through OR deny — gives the agent + user the
|
||||
// relevant prior context either way.
|
||||
const contextNuggets: string[] = [];
|
||||
for (const q of questions) {
|
||||
@@ -402,7 +418,7 @@ async function main(): Promise<void> {
|
||||
: undefined;
|
||||
|
||||
// Determine whether EVERY question is eligible for never-ask auto-decide.
|
||||
// We deliberately do NOT early-return defer on the first ineligible question:
|
||||
// We deliberately do NOT early-return pass-through on the first ineligible question:
|
||||
// a Conductor session still needs the [conductor] prose deny as a fallback,
|
||||
// so we compute eligibility, then branch. memoryContext is preserved on every
|
||||
// non-enforcing exit. (All-or-nothing per-call semantics are unchanged: any
|
||||
@@ -471,10 +487,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
defer(memoryContext);
|
||||
passThrough(memoryContext);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
logHookError(`main crash: ${(e as Error).message}`);
|
||||
defer();
|
||||
passThrough();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user