# Kibitzer surface

This directory holds the read-only recall judge surface: the in-process decision engine that vets every tool call boundary against the lexical recall candidates and delivers nudges only through the `nudge` tool. Kibitzer is never an actor — it judges, and only nudges.

The sidecar **must remain read-only**. Every fire is a quick-pinned in-process child; the parent process owns all writes. File I/O inside this surface is for candidacy audit artifacts only (`candidates.json`, `transcript-window.txt`), never for mutable state.

## Anatomy

| Path | Purpose |
|------|---------|
| `events.ts` | Resident sidecar event feed: `createKibitzerEventStream` turns prompt / `tool_call` / `tool_result` hook payloads plus the branch snapshot into `KibitzerEvent` values (branch cursor = `getBranch().length`, newest assistant text emitted once ahead of the hook that revealed it). Bodies are redacted (`redactKibitzerEventText`: memory-core `redactUrl` then the senpi mirror) BEFORE truncation to the caps (tool args 400 / result head 600 / assistant 1500 / prompt 4000), `eval.summary` wins over `eval.code`, the newest 20 events stay verbatim and older ones fold into a one-line digest (<= 1024 chars, keeps first/last cursor). Malformed payloads return `false` and never throw into the hook. `renderKibitzerEventBatch` emits the escaped `<digest>` / `<event>` fragment for wake envelopes. |
| `sensitive-output.ts` | Verbatim mirror of senpi `core/sensitive-output` (`redactSensitiveOutput`, `redactSensitiveTokenValues`); senpi does not export it and omo-senpi imports senpi types only. `events-redaction.test.ts` pins parity against the real dist module. |
| `delivery.ts` | Delivery lifecycle: marks nudges surfaced in the ledger, holds them in memory, writes the pending file (epoch-stamped), enqueues coordinator entries, and steers on the next `tool_result` when conditions permit. Compaction and shutdown drain the held nudges, clear coordinator entries, and delete the pending file. |
| `hooks.ts` | Event registration: binds to `tool_call` (trigger capture snapshot) and `tool_result` (delivery steer gate) with session-id resolution and context capture. |
| `judge-outcome.ts` | RunnerOutcome → judge classification: `completed` / `empty` / `failed` / `dropped`, mapping all terminal states through the settled outcome's disposition (child termination, model response, provider error, timeout). The model that answered after fallback rotation is recorded per run. |
| `sidecar-prompt.ts` | Resident prompt contract: the `<kibitzer-seed>` / `<kibitzer-wake>` / `<kibitzer-reseed>` envelope renderers. Redacts, then caps, then escapes every embedded field; orders events by ascending parent cursor; states the no-memory-write rule and the exactly five read-only tool contracts (`read`, `grep`, `session_entries`, `memory` with `search`/`read` only, `nudge`). The reseed carries rejected paths, delivered paths, the task summary and the last cursor inside a whole-envelope char bound. The matching system prompt is the persona at `packages/memory-core/src/recall/assets/kibitzer-persona.md`. |
| `sidecar.ts` | The resident lifecycle: `createKibitzerSidecar` keeps ONE child per bound main session behind a per-session mutex with states `idle` / `turn_running` / `reseeding` / `backoff` / `disposed`. Hook events only buffer (it owns the session's event stream); `offer` runs the wake policy and routes a wake as `seeded` (lazy child creation, also after backoff or reseed), `followed_up` (idle child revived) or `steered` (running turn, steer-all). Every seed/followUp first takes one machine-wide lease from `wakeSlot` and holds it until the turn settles (a steer joins the turn under the same lease); a bounded wait that finds every slot busy buffers as `slot_busy` with nothing drained and no timer armed - the next hook retries; a lock-domain failure is a `start_failed` wake that backs off. The lease is released on every exit (completed, budget, deadline, failure, shutdown, start failure) and `shutdown` aborts a wait in flight before it queues on the mutex. The child's own subscription counts tool calls (`toolBudget`, from `memory.recall.tool_budget`, aborts as `tool_budget_exceeded`), confirms which steers reached the transcript (unread ones replay by cursor through ONE followUp at settlement), and reads provider usage (`input + cacheRead`, char/4 fallback) for the reseed at 60% of `sidecarMaxTokens` (`memory.recall.sidecar_max_tokens`). The 90 s deadline aborts without losing accepted nudges; settlement re-validates (`validateNudges` over offered ∪ searched − surfaced), hands to `deliver`, and charges the cooldown only when something was delivered. A failed child (or a child that could not start) enters jittered backoff (1 s doubling to 5 min) keeping every buffered event; `shutdown` aborts, disposes and never recreates. Each `KibitzerWakeOutcome` carries `slotWaitMs`. |
| `wake-slot.ts` | Machine-wide admission: `createKibitzerWakeSlot({ locksDirectory, maxConcurrent })` wraps memory-core's `recall-wake` counting lease (FIFO tickets, `max_concurrent_wakes` slots, pid/start-identity stale recovery, never age-based) into `acquire(signal)` → `acquired` (lease + `waitedMs`) / `busy` (the 15 s bounded wait elapsed with every slot live) / `aborted` (the caller's signal). Polls are 200 ms delays, never a spin; nothing retries on its own; filesystem failures propagate. |
| `settings.ts` | The config boundary: `resolveKibitzerSidecarSettings(recall)` maps the agent-resolved `memory.recall` block (`category`, `tool_budget`, `sidecar_max_tokens`, `max_concurrent_wakes`, `event_caps`) onto the values the sidecar, its child starter and the wake slot consume. Defaults live in the schema, not here. |
| `wake-policy.ts` | Pure policy: `decideWake` (a wake needs at least one candidate not offered this lifetime, not in the surfaced ledger, not `system/`; freshness is judged before the cooldown), `createAcceptedNudgeCooldown` (2 accepted wakes per 10 min per main session), `backoffDelayMs` (1 s doubling to 5 min, half-to-full jitter). |
| `sidecar-model.ts` | Which model answers and what the child looks like: `resolveKibitzerSidecarModel` pins `memory.recall.category` (default `quick`) and refuses the beyond-category ladder; `buildKibitzerSidecarSpec` is the normative ChildSpec (allowlist = exactly the five names, bare envelope, turn completion, depth 1); `createKibitzerSidecarChildStarter` is the production `startChild` port (persona → runtime → spec → one in-process child) whose refusals are typed `KibitzerSidecarStartError`s. |
| `sidecar-outcome.ts` | How a wake ended: `classifyWakeEnd` lets a sidecar abort (`tool_budget`, `deadline`, `shutdown`) win over the engine's `cancelled`, otherwise maps through `classifyJudgeTurn`; `KibitzerWakeOutcome` is the closed-value record per wake (status, cause, model, delivered nudges, steered count, tool calls, duration, cursor span, context estimate, `diagnostic`). Only `failed` is diagnostic. |
| `nudge-tool.ts` | The judge's only action: `nudge` tool definition (ToolDefinition) with acceptance accounting (`maxItems` early-exit via `terminate: true` when all accepted) and path forwarding to the ledger. |
| `notice.ts` | Entry renderers and type constants: `GATE_ENTRY_TYPE` (judge result trace), `NUDGED_ENTRY_TYPE` (delivery result trace), gate reason normalization (bounded, sanitized, secrets redacted). |
| `task-runtime.ts` | Memoized task runtime loader (primed at registration, resolved per run, cached keyed by asset name). A throw is persona unavailable; import rejection is reported once as `omo-senpi memory boot asset unavailable`. |
| `tools/` | The resident sidecar's member-scoped, read-only tool closures (`index.ts` registry = exactly `read`, `grep`, `session_entries`, `memory`, `nudge`; no aliases, no builtin passthrough). `read.ts`/`grep.ts` stay inside the workspace realpath (`path-safety.ts` rejects traversal, absolute paths and symlink escapes); `session-read.ts` pages the parent-refreshed branch snapshot after a cursor, omitting `EXCLUDED_CUSTOM_TYPES` plus `omo-kibitzer:nudged`/`omo-kibitzer:gate`; `memory.ts` exposes only `search`/`read` over the committed recall corpus (HEAD only, `system/` hidden, search hits feed the nudge allowed set); `nudge.ts` re-binds the nudge closure to `offered ∪ searched` per call. Every result is redacted (`redactUrl`) before its cap (`caps.ts`) and charges the per-wake `budget.ts` (`result.ts` turns exhaustion into a structured `tool_budget_exceeded` rejection). Tests: `index.test.ts`, `session-read.test.ts`, `memory.test.ts`, `path-safety.test.ts`. |
| `composition.ts` (formerly `wiring-kibitzer.ts`) | Kibitzer composition: assembles `delivery`, `hooks`, and `trigger` (wired from parent) into the `KibitzerComposition` record that the parent's wiring injects. |
| `compat.test.ts` | Kibitzer recall compat: minimal smoke test of the nudge tool's type signature. |
| `delivery.test.ts` | Delivery state machine: acceptance → ledger mark + coordinator enqueue, tool_result steering gate, prompt drain, compaction, shutdown cleanup. |
| `events.test.ts` | Event stream: cursor + assistant capture, exact caps, `eval.summary` preference, 50 ms / same-timestamp survival, 20-event buffer + digest fold and cap, drain continuity, escaped rendering, malformed-payload tolerance. |
| `events-redaction.test.ts` | Redaction ordering: 90KB eval + API key never reach storage/digest/render, redaction-before-truncation straddle proof, memory-core masks, and byte parity with senpi's `core/sensitive-output.js`. |
| `delivery-idle.test.ts` | Delivery idle steer: verifies the delivery steerer skips nudges when the agent is idle (even if not pending). |
| `hooks.test.ts` | Hook registration smoke and session-id resolution. |
| `judge-outcome.test.ts` | Outcome classification: settled turn interpretation (responses, failures, timeouts), model recording, reason normalization. |
| `nudge-tool.test.ts` | Nudge tool: acceptance tracking, termination on maxItems, path forwarding. |
| `sidecar.test.ts` | Lifecycle contract over a fake `ChildHandle`, a `fakeWakeSlot()` and manual timers (`sidecar.test-support.ts`): one child per session, steer vs followUp routing, no-new-candidate silence, settlement-race replay through one followUp, provider 429 backoff with preserved events, accepted-nudge survival across the deadline abort, shutdown during a turn, tool budget as `tool_budget_exceeded` with no diagnostic outcome, cooldown charged only for delivered wakes, reseed carrying delivered/rejected paths, start failure into backoff; governance: a five-minute slot outage buffers as `slot_busy` with no child, timer or background retry and the first admitted seed carries every event (digest + newest 20), shutdown abandons a parked lease wait, a lock-domain failure is a backed-off start failure, configured `tool_budget` / `event_caps` / `sidecar_max_tokens` govern the wake, reseed fires at exactly 60% and not below, and the lease count returns to zero on every exit path. Every wait is an explicit signal with a bounded timeout. |
| `wake-slot.test.ts` | The adapter over the real lock domain in a temp directory: ten sessions on two slots admitted FIFO with at most two live, busy after the bounded wait with no ticket left, abort ends a parked wait, a dead owner reclaimed only on pid/start proof while a live owner (this process) and an unprovable identity are never stolen, double release reports false. |
| `wake-policy.test.ts` | Wake decision reasons and filtering, cooldown window sliding, backoff band bounds and the full 1 s → 5 min ladder under every jitter draw. |
| `sidecar-model.test.ts` | Category pinning (no beyond-category drift), the five-name allowlist and bare/turn spec, the starter's typed refusals. |
| `sidecar-prompt.test.ts` | Envelope contract: attribute/text escaping, redaction before capping, per-field caps, cursor order and range, event window, reseed bound and omitted counts, the five-tool registry, and the packaged persona filename. |
| `observability.test.ts` | Kibitzer observability: end-to-end trace rendering (entry types, nudged records, gate reason strings). |
| `task-runtime.test.ts` | Runtime loader: asset resolution, cache hits, persona-unavailable throws, import-rejection reporting. |
