diff --git a/hosts/claude/hooks/question-preference-hook b/hosts/claude/hooks/question-preference-hook new file mode 100755 index 000000000..81b087a28 --- /dev/null +++ b/hosts/claude/hooks/question-preference-hook @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this +# wrapper makes the TypeScript hook executable via bun. Settings.json +# references this file directly. +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +exec bun "$HERE/question-preference-hook.ts" diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts new file mode 100644 index 000000000..1d318b83d --- /dev/null +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -0,0 +1,374 @@ +#!/usr/bin/env bun +/** + * PreToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T6). + * + * Enforces never-ask / always-ask / ask-only-for-one-way preferences + * deterministically — no agent compliance required. + * + * Decision tree (per question in tool_input.questions): + * 1. Extract question_id via marker (). If no marker, + * enforcement is skipped for this question (D18 — hash IDs are + * observed-only, never used as preference keys). + * 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 + 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. + * + * Why deny+reason instead of allow+updatedInput: + * AskUserQuestion's `updatedInput` shape for "pre-resolve this question" + * isn't structurally pinned in Claude Code docs (spike T4 left as open + * question). `deny` with a reason that names the auto-decided option is + * conservative + reliable: the model receives the rejection feedback, + * reads the recommended option from the reason, and proceeds without + * re-firing AUQ. When the spike around input mutation lands, we can + * swap to allow+updatedInput without changing the contract. + * + * Recommended-option extraction (per D2): + * - 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. + * + * Always exits 0. Hook errors land in ~/.gstack/hook-errors.log. + * See docs/spikes/claude-code-hook-mutation.md for the protocol contract. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +interface HookStdin { + session_id?: string; + hook_event_name?: string; + tool_name?: string; + tool_use_id?: string; + tool_input?: { + questions?: Array<{ + question?: string; + options?: Array; + multiSelect?: boolean; + }>; + }; + cwd?: string; +} + +const MARKER_RE = //i; +const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i; + +function stateRoot(): string { + return ( + process.env.GSTACK_STATE_ROOT || + process.env.GSTACK_HOME || + path.join(os.homedir(), '.gstack') + ); +} + +function logHookError(msg: string): void { + try { + const sr = stateRoot(); + fs.mkdirSync(sr, { recursive: true }); + fs.appendFileSync( + path.join(sr, 'hook-errors.log'), + `${new Date().toISOString()} question-preference-hook: ${msg}\n`, + ); + } catch { + // last-resort swallow + } +} + +function readStdin(): Promise { + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => (buf += chunk)); + process.stdin.on('end', () => resolve(buf)); + process.stdin.on('error', () => resolve(buf)); + setTimeout(() => resolve(buf), 2000); + }); +} + +function defer(): void { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'defer', + }, + }), + ); + process.exit(0); +} + +function deny(reason: string): void { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + }), + ); + process.exit(0); +} + +function readJsonSafe(filePath: string): Record | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +interface PreferenceLookup { + preference: string | undefined; + source: 'project' | 'global' | 'none'; +} + +function lookupPreference(slug: string, questionId: string): PreferenceLookup { + const sr = stateRoot(); + const projectFile = path.join(sr, 'projects', slug, 'question-preferences.json'); + const globalFile = path.join(sr, 'global-question-preferences.json'); + + const project = readJsonSafe(projectFile); + if (project && typeof project[questionId] === 'string') { + return { preference: project[questionId] as string, source: 'project' }; + } + const global = readJsonSafe(globalFile); + if (global && typeof global[questionId] === 'string') { + return { preference: global[questionId] as string, source: 'global' }; + } + return { preference: undefined, source: 'none' }; +} + +interface RegistryEntry { + id: string; + door_type?: 'one-way' | 'two-way'; + signal_key?: string; +} + +let registryCache: Record | null = null; + +function loadRegistry(): Record { + if (registryCache) return registryCache; + registryCache = {}; + try { + // Hook lives at hosts/claude/hooks/; registry at scripts/question-registry.ts + const here = path.dirname(new URL(import.meta.url).pathname); + const repoRoot = path.resolve(here, '..', '..', '..'); + const regPath = path.join(repoRoot, 'scripts', 'question-registry.ts'); + if (!fs.existsSync(regPath)) return registryCache; + const src = fs.readFileSync(regPath, 'utf-8'); + // Cheap regex extraction so the hook doesn't need to import the TS file + // (which would require bun resolving the module at hook-invocation time). + // Matches entries like: + // 'ship-test-failure-triage': { + // id: 'ship-test-failure-triage', + // ... + // door_type: 'one-way', + // signal_key: 'test-discipline', + // ... + // }, + const blockRe = + /'([a-z0-9-]+)':\s*\{[^}]*?door_type:\s*'(one-way|two-way)'[^}]*?\}/g; + let m: RegExpExecArray | null; + while ((m = blockRe.exec(src))) { + const [block, id, door_type] = m; + const sk = block.match(/signal_key:\s*'([a-z0-9-]+)'/); + registryCache[id] = { + id, + door_type: door_type as 'one-way' | 'two-way', + signal_key: sk ? sk[1] : undefined, + }; + } + } catch (e) { + logHookError(`registry load failed: ${(e as Error).message}`); + } + return registryCache; +} + +function optionLabels(opts: Array): string[] { + return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || '')); +} + +function extractRecommended( + questionText: string, + opts: string[], +): { recommended: string | undefined; ambiguous: boolean } { + const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o)); + if (labelMatches.length === 1) { + return { recommended: labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim(), ambiguous: false }; + } + if (labelMatches.length > 1) return { recommended: undefined, ambiguous: true }; + + const m = questionText.match(/Recommendation:\s*([^\n]+)/i); + if (!m) return { recommended: undefined, ambiguous: false }; + const recPhrase = m[1].trim(); + const prefixMatches = opts.filter((o) => + o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)), + ); + if (prefixMatches.length === 1) return { recommended: prefixMatches[0], ambiguous: false }; + if (prefixMatches.length > 1) return { recommended: undefined, ambiguous: true }; + return { recommended: undefined, ambiguous: false }; +} + +function slugFromCwd(cwd: string | undefined): string { + // Mirror gstack-slug's basename fallback. The full slug resolver shells out + // to git, which is too expensive on a hot hook path; the basename is close + // enough for preference lookup (preferences are keyed by question_id, slug + // is just the directory bucket). + if (!cwd) return 'unknown'; + return path.basename(cwd); +} + +function markAutoDecided(sessionId: string | undefined, toolUseId: string | undefined): void { + if (!sessionId || !toolUseId) return; + try { + const sr = stateRoot(); + const dir = path.join(sr, 'sessions', sessionId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `.auto-decided-${toolUseId}`), ''); + } catch (e) { + logHookError(`markAutoDecided failed: ${(e as Error).message}`); + } +} + +/** + * Log an auto-decided event directly from PreToolUse, since `deny` prevents + * the tool from running and PostToolUse never fires. Without this, /plan-tune + * Recent auto-decisions would be blind to enforcement hits. + */ +function logAutoDecided( + questionId: string, + questionSummary: string, + recommended: string, + optionsCount: number, + sessionId: string | undefined, + toolUseId: string | undefined, + cwd: string | undefined, +): void { + try { + const here = path.dirname(new URL(import.meta.url).pathname); + const repoRoot = path.resolve(here, '..', '..', '..'); + const bin = path.join(repoRoot, 'bin', 'gstack-question-log'); + const payload: Record = { + skill: 'unknown', + question_id: questionId, + question_summary: questionSummary.slice(0, 200), + options_count: optionsCount, + user_choice: recommended.slice(0, 64), + recommended: recommended.slice(0, 64), + source: 'auto-decided', + session_id: sessionId?.slice(0, 64), + tool_use_id: toolUseId?.slice(0, 128), + }; + spawnSync(bin, [JSON.stringify(payload)], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 3000, + // cwd of the originating tool call so gstack-slug resolves to the + // project the user is actually in, not the hook script's location. + cwd: cwd && fs.existsSync(cwd) ? cwd : undefined, + }); + } catch (e) { + logHookError(`logAutoDecided failed: ${(e as Error).message}`); + } +} + +async function main(): Promise { + const raw = await readStdin(); + if (!raw.trim()) { + defer(); + return; + } + let stdin: HookStdin; + try { + stdin = JSON.parse(raw); + } catch (e) { + logHookError(`stdin parse failed: ${(e as Error).message}`); + defer(); + return; + } + + const toolName = stdin.tool_name || ''; + if ( + toolName !== 'AskUserQuestion' && + !toolName.match(/^mcp__.+__AskUserQuestion$/) + ) { + defer(); + return; + } + + const questions = stdin.tool_input?.questions || []; + if (questions.length === 0) { + defer(); + 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. + const registry = loadRegistry(); + const slug = slugFromCwd(stdin.cwd); + + const autoDecisions: Array<{ id: string; recommended: string }> = []; + for (const q of questions) { + const qText = q.question || ''; + const marker = qText.match(MARKER_RE); + if (!marker) { + defer(); + return; + } + const questionId = marker[1]; + const pref = lookupPreference(slug, questionId); + if (!pref.preference || pref.preference === 'always-ask') { + defer(); + return; + } + + const entry = registry[questionId]; + const doorType = entry?.door_type || 'two-way'; + if (doorType === 'one-way') { + // Safety override — even never-ask doesn't bypass one-way doors. + defer(); + return; + } + + const opts = optionLabels(q.options || []); + const { recommended, ambiguous } = extractRecommended(qText, opts); + if (!recommended || ambiguous) { + // Refuse-on-ambiguous per D2 — fail safe, ask normally. + defer(); + return; + } + autoDecisions.push({ id: questionId, recommended }); + } + + // All questions were eligible for enforcement. + markAutoDecided(stdin.session_id, stdin.tool_use_id); + + // Log each auto-decided question now, since deny prevents PostToolUse from + // firing. /plan-tune Recent auto-decisions reads source=auto-decided events. + for (let i = 0; i < autoDecisions.length; i++) { + const d = autoDecisions[i]; + const q = questions[i]; + const qText = (q.question || '').replace(MARKER_RE, '').trim(); + const opts = optionLabels(q.options || []); + logAutoDecided(d.id, qText, d.recommended, opts.length, stdin.session_id, stdin.tool_use_id, stdin.cwd); + } + + const reasonLines = autoDecisions.map( + (d) => + `[plan-tune auto-decide] ${d.id} → ${d.recommended} (your never-ask preference). Proceed with that option without re-prompting. Change with /plan-tune.`, + ); + deny(reasonLines.join('\n')); +} + +main().catch((e) => { + logHookError(`main crash: ${(e as Error).message}`); + defer(); +}); diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts new file mode 100644 index 000000000..6b06d22f4 --- /dev/null +++ b/test/question-preference-hook.test.ts @@ -0,0 +1,385 @@ +/** + * PreToolUse enforcement hook (plan-tune cathedral T6) — unit tests. + * + * Covers: + * - never-ask + marker + two-way + clean recommendation → deny+reason + * - never-ask + no marker → defer (D18 marker gate) + * - never-ask + one-way → defer (safety override) + * - never-ask + ambiguous recommendation → defer (D2 refuse-on-ambiguous) + * - always-ask → defer + * - no preference → defer + * - project preference wins over global (D8 precedence) + * - global preference applies when no project preference set + * - mcp__*__AskUserQuestion matcher accepted + * - empty stdin → defer (crash safety) + * - auto-decided event logged via gstack-question-log (PostToolUse won't fire) + * - auto-decided marker written to ~/.gstack/sessions//.auto-decided- + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-preference-hook'); + +let stateRoot: string; +let cwdSlug: string; + +let fixtureCwd: string; + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prefhook-')); + cwdSlug = 'fixture-slug'; + fs.mkdirSync(path.join(stateRoot, 'projects', cwdSlug), { recursive: true }); + // Real directory that the hook can chdir() into. gstack-slug derives the + // slug from the basename of this cwd (no .git => basename fallback path). + fixtureCwd = path.join(stateRoot, cwdSlug); + fs.mkdirSync(fixtureCwd, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(stateRoot, { recursive: true, force: true }); +}); + +function writeProjectPref(questionId: string, preference: string): void { + const f = path.join(stateRoot, 'projects', cwdSlug, 'question-preferences.json'); + let prefs: Record = {}; + if (fs.existsSync(f)) prefs = JSON.parse(fs.readFileSync(f, 'utf-8')); + prefs[questionId] = preference; + fs.writeFileSync(f, JSON.stringify(prefs, null, 2)); +} + +function writeGlobalPref(questionId: string, preference: string): void { + const f = path.join(stateRoot, 'global-question-preferences.json'); + let prefs: Record = {}; + if (fs.existsSync(f)) prefs = JSON.parse(fs.readFileSync(f, 'utf-8')); + prefs[questionId] = preference; + fs.writeFileSync(f, JSON.stringify(prefs, null, 2)); +} + +function runHook(stdin: object, cwd?: string): { + stdout: string; + stderr: string; + status: number; + parsed: any; +} { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; + } + env.GSTACK_STATE_ROOT = stateRoot; + delete env.GSTACK_HOME; + env.GSTACK_QUESTION_LOG_NO_DERIVE = '1'; + const res = spawnSync(HOOK, [], { + env, + input: JSON.stringify({ ...stdin, cwd: cwd || fixtureCwd }), + encoding: 'utf-8', + cwd: ROOT, + }); + let parsed: any = null; + try { parsed = JSON.parse(res.stdout || '{}'); } catch {} + return { + stdout: res.stdout ?? '', + stderr: res.stderr ?? '', + status: res.status ?? -1, + parsed, + }; +} + +function autoDecidedEvents(): Array> { + const f = path.join(stateRoot, 'projects', cwdSlug, 'question-log.jsonl'); + if (!fs.existsSync(f)) return []; + return fs + .readFileSync(f, 'utf-8') + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)) + .filter((e) => e.source === 'auto-decided'); +} + +// ---------------------------------------------------------------------- +// Defer paths +// ---------------------------------------------------------------------- + +describe('defers (no enforcement)', () => { + test('no preference set → defer', () => { + const r = runHook({ + session_id: 's1', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-1', + tool_input: { + questions: [ + { question: ' Need approval?', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }); + expect(r.status).toBe(0); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); + + test('marker missing → defer (D18)', () => { + writeProjectPref('test-q', 'never-ask'); + const r = runHook({ + session_id: 's2', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-2', + tool_input: { + questions: [ + { question: 'No marker here', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); + + test('always-ask preference → defer', () => { + writeProjectPref('test-q', 'always-ask'); + const r = runHook({ + session_id: 's3', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-3', + tool_input: { + questions: [ + { question: ' Yes?', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); + + test('empty stdin → defer (crash safety)', () => { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; + } + env.GSTACK_STATE_ROOT = stateRoot; + 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'); + }); + + 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'); + }); +}); + +// ---------------------------------------------------------------------- +// Enforcement paths (deny+reason) +// ---------------------------------------------------------------------- + +describe('enforces never-ask preferences', () => { + test('marker + never-ask + two-way + clean recommendation → deny', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's5', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-5', + tool_input: { + questions: [ + { + question: + ' Pre-landing review flagged issue.', + options: ['A) Fix now (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide'); + expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('Fix now'); + }); + + test('one-way door → defer even with never-ask (safety override)', () => { + writeProjectPref('ship-test-failure-triage', 'never-ask'); + const r = runHook({ + session_id: 's6', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-6', + tool_input: { + questions: [ + { + question: ' Tests failed.', + options: ['A) Fix now (recommended)', 'B) Investigate', 'C) Ack and ship'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); + + test('ambiguous recommendation (two labels) → defer (D2 refuse-on-ambiguous)', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's7', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-7', + tool_input: { + questions: [ + { + question: ' Ambiguous', + options: ['A) Fix now (recommended)', 'B) Skip (recommended)'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); + + test('no recommendation marker AND no prose match → defer', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's8', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-8', + tool_input: { + questions: [ + { + question: ' No rec', + options: ['A) Foo', 'B) Bar'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); +}); + +// ---------------------------------------------------------------------- +// Precedence (D8) +// ---------------------------------------------------------------------- + +describe('precedence: project wins over global (D8)', () => { + test('project never-ask + global always-ask → enforce never-ask', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + writeGlobalPref('ship-pre-landing-review-fix', 'always-ask'); + const r = runHook({ + session_id: 's9', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-9', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test('only global never-ask → enforce (fallback path)', () => { + writeGlobalPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's10', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-10', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test('project always-ask + global never-ask → defer (project wins)', () => { + writeProjectPref('ship-pre-landing-review-fix', 'always-ask'); + writeGlobalPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's11', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-11', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer'); + }); +}); + +// ---------------------------------------------------------------------- +// MCP matcher acceptance +// ---------------------------------------------------------------------- + +describe('MCP variant', () => { + test('mcp__conductor__AskUserQuestion accepted and enforced', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + const r = runHook({ + session_id: 's12', + tool_name: 'mcp__conductor__AskUserQuestion', + tool_use_id: 'tu-12', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); +}); + +// ---------------------------------------------------------------------- +// Auto-decided event logging (since PostToolUse never fires on deny) +// ---------------------------------------------------------------------- + +describe('auto-decided event tagging', () => { + test('logs source=auto-decided event when enforcing', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + runHook({ + session_id: 's13', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-13', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }, fixtureCwd); + const events = autoDecidedEvents(); + expect(events.length).toBe(1); + expect(events[0].question_id).toBe('ship-pre-landing-review-fix'); + expect(events[0].user_choice).toContain('Fix'); + expect(events[0].tool_use_id).toBe('tu-13'); + }); + + test('writes .auto-decided- marker for PostToolUse coordination', () => { + writeProjectPref('ship-pre-landing-review-fix', 'never-ask'); + runHook({ + session_id: 's14', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-14', + tool_input: { + questions: [ + { + question: ' P?', + options: ['A) Fix (recommended)', 'B) Skip'], + }, + ], + }, + }); + const markerPath = path.join(stateRoot, 'sessions', 's14', '.auto-decided-tu-14'); + expect(fs.existsSync(markerPath)).toBe(true); + }); +});