Skip to content
Closed
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
23 changes: 17 additions & 6 deletions hosts/claude/hooks/question-preference-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

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