mirror of https://github.com/garrytan/gstack.git
fix(hooks): don't emit permissionDecision 'defer' from question-preference-hook
'defer' is a real permissionDecision value in Claude Code, but it means 'pause this tool call and hand control back' and is honored in print/non-interactive mode (interactive mode warns and ignores it). The hook emitted it as its 'no opinion' outcome, which made every AskUserQuestion call get deferred in non-interactive sessions (e.g. the Claude Code desktop app): the question UI never rendered and AUQ appeared completely broken after gstack setup registered the hook. The neutral outcome for a PreToolUse hook is a silent exit 0 — so defer() now emits nothing, or only additionalContext (without any permissionDecision) when there is plan-tune memory context to inject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
11de390be1
commit
10f30975c3
|
|
@ -93,12 +93,23 @@ 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 }));
|
||||
// "No opinion" must be a SILENT exit 0 (optionally with additionalContext
|
||||
// only), never an explicit `permissionDecision: 'defer'`. `defer` is a real
|
||||
// value in Claude Code, but it means "pause this tool call and hand control
|
||||
// back" and is honored in print/non-interactive mode. Emitting it here made
|
||||
// every AskUserQuestion get deferred in non-interactive sessions (e.g. the
|
||||
// desktop app) — the question UI never rendered. Interactive mode merely
|
||||
// warns and ignores it.
|
||||
if (additionalContext) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue