// UserPromptSubmit hook: record a HUMAN_TURN event (human-presence gate). // // On every real human prompt, append a HUMAN_TURN event to the active intent's // audit shard (the state machine's own append-only ledger). The approval / // interview gate (handleApprove / handleAnswer) refuses unless a HUMAN_TURN was // recorded since the last gate resolution, so a model under autopilot cannot // fabricate an approval with no human having acted this turn. // // Presence remains the gate signal, while the prompt payload is also inspected // for an exact protected Plan Approval choice. appendAuditEntry resolves the // active intent from the on-disk cursor. No workflow state on disk means nothing // to gate, so the hook exits without writing (same self-gate as // aidlc-session-start.ts) - otherwise every prompt in a project that carries the // harness shell but never ran the framework would scaffold and grow audit // shards. The gate fails open on an empty ledger, so skipping the mint there is // safe. The mint is fail-open (try/catch, exit 0): a mint failure must never // block the human's turn. // // The same seam also touches the .aidlc-human-turn marker (markHumanTurn). The // ledger event serves the human-presence GATE; the marker serves the Stop hook's // conversational carve-out, which needs a cheap "when was the last human prompt, // relative to the last engine advance?" comparison that works on harnesses // delivering no transcript. Both ride this seam, but AIDLC_UNATTENDED=1 // deliberately withholds only the authority-bearing ledger event while retaining // the conversational marker. See the marker family in aidlc-lib.ts. // // UNATTENDED DRIVING (AIDLC_UNATTENDED=1). The mint is a presence ASSERTION, and // this hook has no evidence for it: UserPromptSubmit carries no signal about who // submitted, and the hook reads no stdin. That is sound while every prompt comes // from a person, but an unattended driver (an overnight runner resuming the // workflow on a schedule, CI, a cron) submits prompts too — so it mints a fresh, // spendable HUMAN_TURN on every cycle and "walking away" stops meaning "no new // human turn". Measured: 10 runner-submitted prompts, zero humans, and // humanActedSinceGate() answered true. // // So a driver that knows it is not a person says so, and the mint is skipped. // This is the same doctrine the engine already applies elsewhere — an unattended // autonomous Construction run "has no human at the gate", which is why // aidlc-utility refuses scope changes and plan re-shapes and aidlc-state refuses // park under it. This closes the one path where an unattended turn still // manufactured a human. // // Fail direction: the flag can only ever WITHHOLD authority. If it leaks into an // interactive shell the human's approvals get refused until it is unset — // annoying, and safe. The inverse mistake (a runner minting presence) is the one // that cannot be undone, because the ledger is append-only. // // The MARKER is deliberately still written. It is not an authority signal, and // suppressing it would change the Stop hook's conversational carve-out, which is // a separate behaviour with its own tests. Reviewers who want the marker // suppressed too should say so — it is a one-line follow-on, not a silent choice. import { existsSync } from "node:fs"; import { consumeSharedDirectiveAsk, humanTurnMintAllowed, markHumanTurn, resolveProjectDirFromHook, stateFilePath, } from "../tools/aidlc-lib.ts"; import { appendAuditEntry } from "../tools/aidlc-audit.ts"; import { recordPlanApprovalHumanResponse, recordPlanApprovalOverrideRequest, } from "../tools/aidlc-testing-posture.ts"; function extractResponseText(value: unknown): string { if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return ""; try { return extractResponseText(JSON.parse(trimmed)); } catch { return trimmed; } } if (Array.isArray(value)) { for (const entry of value) { const text = extractResponseText(entry); if (text) return text; } return ""; } if (value === null || typeof value !== "object") return ""; const record = value as Record; for (const key of [ "answer", "answers", "selected", "selection", "value", "label", "text", ]) { if (!(key in record)) continue; const text = extractResponseText(record[key]); if (text) return text; } for (const entry of Object.values(record)) { const text = extractResponseText(entry); if (text) return text; } return ""; } export async function run(input: string): Promise { try { const projectDir = resolveProjectDirFromHook(import.meta.url); if (existsSync(stateFilePath(projectDir))) { if (humanTurnMintAllowed()) { let sessionId = ""; let humanResponseText = ""; // The break-glass phrase counts only when the human TYPED it: the prompt // text of a UserPromptSubmit payload that names no tool. A picked option // (AskUserQuestion PostToolUse, Codex request_user_input, any adapter's // picker payload) arrives under tool_response and never opens it. let typedPrompt = ""; try { const parsed = JSON.parse(input) as { hook_event_name?: unknown; tool_name?: unknown; session_id?: unknown; prompt?: unknown; user_prompt?: unknown; message?: unknown; tool_response?: unknown; toolResponse?: unknown; }; if (typeof parsed.session_id === "string") sessionId = parsed.session_id.trim(); for (const candidate of [ parsed.prompt, parsed.user_prompt, parsed.message, parsed.tool_response, parsed.toolResponse, ]) { const extracted = extractResponseText(candidate); if (extracted) { humanResponseText = extracted; break; } } if ( parsed.hook_event_name === "UserPromptSubmit" && typeof parsed.tool_name !== "string" ) { typedPrompt = [parsed.prompt, parsed.user_prompt, parsed.message].find( (value): value is string => typeof value === "string" && value.trim().length > 0, ) ?? ""; } } catch { /* presence still records without identity on legacy payloads */ } try { appendAuditEntry("HUMAN_TURN", sessionId ? { Session: sessionId } : {}, projectDir); if (sessionId && humanResponseText) { recordPlanApprovalHumanResponse( projectDir, sessionId, humanResponseText, ); } if (sessionId && typedPrompt) { recordPlanApprovalOverrideRequest(projectDir, sessionId, typedPrompt); } } catch { // Authority bookkeeping remains fail-open for the human's turn. } try { consumeSharedDirectiveAsk(projectDir, humanResponseText); } catch { // Non-authority marker consumption is independently best-effort. } } markHumanTurn(projectDir); } } catch { // Non-fatal — a mint failure must never block the human's turn. } return 0; } if (import.meta.main) { process.exit(await run(await Bun.stdin.text())); }