#!/usr/bin/env node /** * PostToolUse(AskUserQuestion) wrapper — schema bridge between Stream E * pure parser (askuser-answer-parser.mjs::toApprovalRecord) and Stream B * approval reader (shell-content-rules.mjs::loadApprovedGitOps). * * For each question/answer pair: if the answer matches a git pattern, * append an approve_git_operation record to * ~/.claude/runtime/askuser-decisions-.jsonl. * * Fail-open observability (never blocks AskUserQuestion). * * Stream H Task 6 — retires the manual approval-write workaround used by * the controller throughout Stream H Tasks 1-5. */ import { appendFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord } from './askuser-answer-parser.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; /** * Pure event processor for test-injection of runtimeDir + nowMs. * * @param {object} event - PostToolUse payload {session_id, tool_input, tool_response} * @param {object} [opts] * @param {string} [opts.runtimeDir] - override default ~/.claude/runtime * @param {number} [opts.nowMs] - override timestamp for test determinism */ export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync } } = {}) { try { const sessionId = event && event.session_id; const toolInput = event && event.tool_input; const toolResponse = event && event.tool_response; if (!sessionId || !toolInput || !toolResponse) return; const questions = toolInput.questions || []; const answers = toolResponse.answers || {}; // M6 FIX-5: резолв ключа подписи один раз на событие (fail-safe — ошибка резолва // → key=null → floor_escape пишется неподписанным, PostToolUse-наблюдаемость цела). let key = null; try { key = keyImpl(); } catch { key = null; } const dir = runtimeDir || join(homedir(), '.claude', 'runtime'); const path = join(dir, `askuser-decisions-${sessionId}.jsonl`); let wroteAny = false; for (const q of questions) { if (!q || !q.question) continue; const ans = answers[q.question]; if (!ans) continue; const rec = toApprovalRecord(ans, { question: q.question, nowMs }); // M6 FIX-5: подписываем ТОЛЬКО floor_escape (§2.2 — approve_git_operation не трогаем). let esc = toFloorEscapeRecord(ans, { nowMs }); if (esc && key) esc = signFloorEscapeRecord(esc, key); for (const out of [rec, esc]) { if (!out) continue; if (!wroteAny) { try { fsImpl.mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ } wroteAny = true; } try { fsImpl.appendFileSync(path, JSON.stringify(out) + '\n'); } catch { /* fail-open */ } } } } catch { // fail-open observability — never throw from PostToolUse handler } } async function main() { let input = ''; for await (const chunk of process.stdin) input += chunk; let payload; try { payload = JSON.parse(input); } catch { return; } processEvent(payload); } if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-askuser-answer-parser.mjs')) { main().catch(() => process.exit(0)); // fail-open observability }