#!/usr/bin/env bun /** * PostToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T5). * * Reads hook stdin JSON, extracts every AUQ question + user choice from the * tool_input/tool_response, and writes them via gstack-question-log so the * substrate captures fires deterministically — no agent compliance required. * * Triggered by ~/.claude/settings.json: * { * "hooks": { * "PostToolUse": [ * { * "matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)", * "hooks": [ * { "type": "command", * "command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-log-hook", * "timeout": 5 } * ] * } * ] * } * } * * Invariants: * - Always exits 0. A failing hook MUST NOT block the user's session. * Errors land in ~/.gstack/hook-errors.log for postmortem. * - Spawns gstack-question-log as a subprocess; that bin handles * validation, dedup (source+tool_use_id), async derive. * - Marker-first question_id (``), hash fallback * (D18 progressive markers). * * See docs/spikes/claude-code-hook-mutation.md for the protocol contract. */ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { runBin } from './spawn-bin'; 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; }>; }; tool_response?: unknown; cwd?: string; } interface ExtractedQuestion { question_id: string; question_summary: string; options_count: number; user_choice: string; recommended?: string; free_text?: string; category?: string; door_type?: string; } const MARKER_RE = //i; const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i; function logHookError(msg: string): void { try { const stateRoot = process.env.GSTACK_STATE_ROOT || process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); fs.mkdirSync(stateRoot, { recursive: true }); fs.appendFileSync( path.join(stateRoot, 'hook-errors.log'), `${new Date().toISOString()} question-log-hook: ${msg}\n`, ); } catch { // Last-resort: swallow. Hook must not block. } } 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)); // Hard cutoff so we don't hang the user's session waiting for stdin. setTimeout(() => resolve(buf), 2000); }); } function hashQuestionId(skill: string, question: string, options: string[]): string { const sorted = [...options].sort().join('|'); const h = crypto .createHash('sha1') .update(`${skill}::${question}::${sorted}`) .digest('hex'); return `hook-${h.slice(0, 10)}`; } /** * Marker-first id extraction. Returns the marker id (stripped of the * wrapper) when present, else a hash-based hook- id. * Per D18 progressive markers — hash ids are observed-only, never used * as preference keys. */ function extractQuestionId( skill: string, questionText: string, options: string[], ): { id: string; marker_present: boolean; stripped_question: string } { const match = questionText.match(MARKER_RE); if (match) { return { id: match[1], marker_present: true, stripped_question: questionText.replace(MARKER_RE, '').trim(), }; } return { id: hashQuestionId(skill, questionText, options), marker_present: false, stripped_question: questionText, }; } function optionLabels(opts: Array): string[] { return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || '')); } /** * Parse "(recommended)" label-first per D2; fall back to "Recommendation: X" * prose match; refuse (return undefined) if ambiguous. */ function extractRecommended(questionText: string, opts: string[]): string | undefined { const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o)); if (labelMatches.length === 1) return labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim(); if (labelMatches.length > 1) return undefined; // ambiguous const m = questionText.match(/Recommendation:\s*([^\n]+)/i); if (!m) return undefined; const recPhrase = m[1].trim(); const matchByPrefix = opts.find((o) => o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12))); return matchByPrefix; } /** * Best-effort extraction of which option the user picked per question. * AUQ tool_response shape varies by Claude Code variant (native vs MCP), * and the hook stdin docs don't pin a single canonical shape. We handle * the common cases gracefully. * * Shape D is the current native AskUserQuestion result: * { answers: { "": "" }, * annotations?: { "": { notes?, preview? } } } * The map is keyed by the question text exactly as passed in tool_input, * so extraction needs the questions themselves, not just a count. */ function extractUserChoices( response: unknown, questions: Array<{ question?: string; options?: Array }>, diag?: (msg: string) => void, ): Array<{ choice: string; free_text?: string }> { const questionCount = questions.length; const out: Array<{ choice: string; free_text?: string }> = []; if (!response) { diag?.(`answer-extract: empty tool_response (typeof=${typeof response})`); for (let i = 0; i < questionCount; i++) out.push({ choice: '__unknown__' }); return out; } const rec = response as Record; // Shape D: { answers: {questionText: answer}, annotations?: {questionText: {notes}} } if (rec.answers && typeof rec.answers === 'object' && !Array.isArray(rec.answers)) { const answers = rec.answers as Record; const annotations = rec.annotations && typeof rec.annotations === 'object' && !Array.isArray(rec.annotations) ? (rec.annotations as Record>) : {}; const keys = Object.keys(answers); const norm = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase(); for (const q of questions) { const qText = q.question || ''; let key: string | undefined = Object.prototype.hasOwnProperty.call(answers, qText) ? qText : keys.find((k) => norm(k) === norm(qText)); // Single question, single answer: pair them even if the key drifted. if (key === undefined && keys.length === 1 && questionCount === 1) key = keys[0]; if (key === undefined) { diag?.(`answer-extract: no answers key matched question "${qText.slice(0, 60)}"`); out.push({ choice: '__unknown__' }); continue; } const v = answers[key]; const rawChoice = Array.isArray(v) ? v.map(String).join(', ') : String(v ?? '__unknown__'); // The bin compares user_choice === recommended, and recommended is // stored with the "(recommended)" suffix stripped — strip it here too. const choice = rawChoice.replace(RECOMMENDED_LABEL_RE, '').trim() || '__unknown__'; const labels = optionLabels(q.options || []).map((l) => l.replace(RECOMMENDED_LABEL_RE, '').trim().toLowerCase(), ); const notes = annotations[key]?.notes; const isFreeText = !Array.isArray(v) && labels.length > 0 && !labels.includes(choice.toLowerCase()); const freeText = notes !== undefined ? String(notes) : isFreeText ? rawChoice : undefined; out.push(freeText !== undefined ? { choice, free_text: freeText } : { choice }); } return out; } // Shape A: { answers: [{option_label, free_text?}] } if (Array.isArray(rec.answers)) { for (const a of rec.answers as Array>) { const choice = (a.option_label || a.label || a.choice || a.answer || '__unknown__') as string; const freeText = (a.free_text || a.other_text) as string | undefined; out.push(freeText ? { choice, free_text: freeText } : { choice }); } while (out.length < questionCount) out.push({ choice: '__unknown__' }); return out; } // Shape B: { questions: [{user_answer}] } if (Array.isArray(rec.questions)) { for (const q of rec.questions as Array>) { const choice = (q.user_answer || q.answer || q.choice || '__unknown__') as string; out.push({ choice }); } while (out.length < questionCount) out.push({ choice: '__unknown__' }); return out; } // Unrecognized shape: log it for postmortem (never embed it in the record — // that poisons user_choice for every downstream metric). diag?.(`answer-extract: unrecognized tool_response shape: ${JSON.stringify(response).slice(0, 300)}`); for (let i = 0; i < questionCount; i++) { out.push({ choice: '__unknown__' }); } return out; } function detectSkill(cwd: string | undefined): string { // Best-effort: cwd often contains the project slug but rarely the running // skill. Without a session-state mechanism, leave as 'unknown' — the // skill marker () embedded in question text per // future plan-tune work is the durable path. void cwd; return 'unknown'; } function spawnLog(payload: Record, cwd?: string): void { const res = runBin('gstack-question-log', [JSON.stringify(payload)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 3000, // Run from the originating tool call's cwd 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, }); if (res.status !== 0) { logHookError(`gstack-question-log exited ${res.status}: ${res.stderr || res.stdout}`); } } async function main(): Promise { const raw = await readStdin(); if (!raw.trim()) { process.exit(0); } let stdin: HookStdin; try { stdin = JSON.parse(raw); } catch (e) { logHookError(`stdin parse failed: ${(e as Error).message}`); process.exit(0); } const toolName = stdin.tool_name || ''; if ( toolName !== 'AskUserQuestion' && !toolName.match(/^mcp__.+__AskUserQuestion$/) ) { // Matcher should have filtered this out; defensive no-op. process.exit(0); } const questions = stdin.tool_input?.questions || []; if (questions.length === 0) { process.exit(0); } const skill = detectSkill(stdin.cwd); const choices = extractUserChoices(stdin.tool_response, questions, (msg) => logHookError(`${msg} (tool_use_id=${stdin.tool_use_id || 'n/a'})`), ); for (let i = 0; i < questions.length; i++) { const q = questions[i]; const qText = q.question || ''; if (!qText) continue; const opts = optionLabels(q.options || []); const { id, stripped_question } = extractQuestionId(skill, qText, opts); const recommended = extractRecommended(stripped_question, opts); const summary = stripped_question.slice(0, 200); const choice = choices[i] || { choice: '__unknown__' }; const payload: Record = { skill, question_id: id, question_summary: summary, options_count: opts.length, user_choice: String(choice.choice).slice(0, 64), source: choice.free_text ? 'auq-other' : 'hook', session_id: stdin.session_id?.slice(0, 64), tool_use_id: stdin.tool_use_id?.slice(0, 128), }; if (recommended) payload.recommended = recommended.slice(0, 64); if (choice.free_text) payload.free_text = String(choice.free_text); spawnLog(payload, stdin.cwd); } process.exit(0); } main().catch((e) => { logHookError(`main crash: ${(e as Error).message}`); process.exit(0); });