/** * Did a hook's TEXT reach the model? โ€” the observed-output twin of * `noticeDelivery` (which answers the same question for a compiled react * BEFORE anything runs). * * ๐Ÿ”ด THE DEFECT THIS EXISTS FOR, MEASURED 2026-10-11 IN A DOWNSTREAM REPO. Its * main knowledge-base linter ran as a `PostToolUse` hook, wrote every finding to * stderr and exited 0 โ€” for weeks. Probe: exit 0, 0 bytes of stdout, 237 bytes * of stderr. Every `runHook`-based test of it was green, because every one of * them asserted on `r.stderr`, which is exactly the stream nobody reads. The * host's hooks documentation is explicit: * * "Claude never sees stderr from a hook that exits 0. [...] To surface a * warning to Claude from a `PostToolUse` or `PostToolUseFailure` hook, exit 2 * instead so Claude sees the stderr even though the tool already ran." * โ€” code.claude.com/docs/en/hooks ยง Exit code 0, read 2026-10-11 * * "The hook printed it" and "the model received it" are the repo's own * `fired โ‰  landed` split, at the unit tier. A test of a nudge must assert the * second, and the second is a function of FOUR facts โ€” the event, the exit * code, which stream carried the text, and what the harness's capability table * says that event honours. This module is that function. Pure: no process, no * harness literal (the table and the block code are injected), so a test and * the runtime agree about delivery without running a harness. * * WHAT IT DELIBERATELY DOES NOT CLAIM: * - an event with no row in the table is `unknown`, never `undelivered` โ€” the * same fail-open stance as `capabilityOf`. A harness with no table at all * (Codex today) can still be judged on the ONE fact its port carries, the * legacy `injectableEvents`: `additionalContext` there is `delivered`, and * anything else is `unknown`; * - a BLOCK (a veto, or a halt of the turn) is reported as `blocked`, not as * delivery. Where the block reason * goes differs per event (a vetoed prompt shows it to the user only), the * capability table does not record that, and a block is not a nudge โ€” its * assertion is `assertHookBlocked`; * - `systemMessage` is not judged: it is addressed to the user by design. */ import type { EventCapabilityTable } from "./event-capability.js"; /** A route by which hook text reached the model. */ export type DeliveryChannel = /** `hookSpecificOutput.additionalContext` on an event honouring `inject`. */ "additional-context" /** Plain (non-JSON) stdout at exit 0, on an event honouring `stdout-context`. */ | "plain-stdout" /** stderr at the block exit code, on an event honouring `feedback`. */ | "exit-code-feedback" /** JSON `decision: "block"` + `reason`, on an event honouring `feedback`. */ | "decision-reason"; /** Text the hook printed that reached NO channel the model reads. */ export interface StrandedOutput { readonly stream: "stdout" | "stderr"; readonly bytes: number; /** Why it went nowhere โ€” phrased as an instruction, not a verdict. */ readonly why: string; } /** * The verdict. FIVE answers, because "the model saw nothing" has four distinct * causes and a test failure must name the right one (`make-the-distinction-a-type`): * the hook said nothing, it said something on a dead stream, it blocked * instead, or we do not know this event. */ export type HookDelivery = { readonly kind: "delivered"; readonly via: readonly DeliveryChannel[]; /** Output that ALSO went nowhere โ€” informational, not a failure. */ readonly stranded: readonly StrandedOutput[]; } /** The hook vetoed the action. Not a nudge; see the module header. */ | { readonly kind: "blocked"; readonly stranded: readonly StrandedOutput[]; } /** The hook printed text and none of it reached the model. Never empty. */ | { readonly kind: "undelivered"; readonly stranded: readonly StrandedOutput[]; } /** * Nothing addressed to the model: empty streams, or control-only JSON * (`permissionDecision: "allow"`, a user-facing `systemMessage`). */ | { readonly kind: "silent"; } /** The table has no row for this event, so delivery cannot be judged. */ | { readonly kind: "unknown"; readonly event: string; readonly why: string; }; /** What a finished hook process left behind โ€” the observable half. */ export interface HookEmission { readonly event: string; readonly exitCode: number; readonly stdout: string; readonly stderr: string; /** stdout parsed as one JSON object, or null when it is plain text / empty. */ readonly json: Readonly> | null; } /** The harness facts the verdict needs โ€” injected, never a literal. */ export interface DeliveryFacts { /** The dialect's capability table, when the adapter has one. */ readonly table: EventCapabilityTable | undefined; /** The protocol's block exit code (exit 2 on both shipped harnesses). */ readonly blockExitCode: number; /** The protocol's deny decision values (`"block"`, `"deny"`). */ readonly denyDecisionValues: readonly string[]; /** The protocol's halt-the-turn field, if it has one. */ readonly haltsTurnField?: string; /** * The legacy flat list of events honouring `additionalContext` โ€” the fallback * for an adapter with no table. Ignored when `table` is present. */ readonly injectableEvents?: readonly string[]; } /** * Judge one finished hook run. Pure. * * Order matters and is the specification: * 1. nothing printed โ†’ `silent` (true on every event, known or not); * 2. no table row โ†’ `delivered` only on the one fact the legacy list proves, * else `unknown`; * 3. a veto on an event that honours one, or a halt on an event that * honours `halt` โ†’ `blocked`; * 4. otherwise collect every channel that carried text, and every stream that * carried text to no channel; at least one channel โ†’ `delivered`. A JSON * object on stdout is applied on any exit code (the host's exit-code table: * valid JSON is "Success" at exit 0 and at any other code, and its fields * are still read at exit 2); plain text is context only at exit 0. */ export declare function hookOutputDelivery(e: HookEmission, f: DeliveryFacts): HookDelivery; //# sourceMappingURL=hook-delivery.d.ts.map