git:20260911.43c4486 to git:20260911.9250954
8 added, 5 removed. Audit A to A.
# Kibitzer surface
This directory holds the read-only recall judge surface: the resident in-process sidecar (`index.ts` is the live composition, one sidecar per bound main session) that is fed every prompt / tool_call / tool_result of its main session, wakes only for a memory candidate it has not judged, and delivers nudges only through the `nudge` tool. Kibitzer is never an actor — it judges, and only nudges. This is the only Kibitzer engine: the one-shot judge (per-launch child, candidate-set fingerprint, launch ceiling, trailing slot, process-wide `globalThis` judge slots, per-run `recall/runs/<id>` directories and the compaction-epoch stamp on the pending file) is gone, and none of it may come back under another name - there is no dual mode, and `memory.recall.enabled=false` is the only off switch.
- The sidecar **must remain read-only**. Every child is quick-pinned and in-process; the parent process owns all writes. The only file I/O this surface performs on its own behalf is the child's own session JSONL under `recall/sidecars/<encoded-session>/` and the machine-wide wake lease under the identity's `runtime/locks`, never mutable memory state.
+ The sidecar **must remain read-only**. Every child is quick-pinned and in-process; the parent process owns all writes. The only file I/O this surface performs on its own behalf is under `recall/sidecars/<encoded-session>/` (the child's own session JSONL and the parent-written `wakes.ndjson` audit lines), the machine-wide wake lease and the per-session directory owner lock under the identity's `runtime/locks`, and the seven-day sweep of aged sidecar directories - never mutable memory 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 at accept time, holds them in memory, writes the pending file (`{ version, sessionId, writtenAt, nudges }`; no compaction epoch - a parent compaction never invalidates the sidecar, it only retracts what delivery still holds), 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` | The four hook registrations behind a `KibitzerHookSink`: `before_agent_start` (delivery `markRunning`, then the prompt capture), `tool_call` (capture), `tool_result` (capture FIRST, then the delivery steer gate) and `agent_settled` (delivery `markSettled` plus the sink's bookkeeping refresh; never a wake). Every sink call is synchronous inside the handler - the host disposes the ctx when the handler returns - and the memory-child sentinels (`SENPI_MEMORY_REFLECTION`/`SENPI_MEMORY_FACTS`) keep every capture out of worker children. |
| `judge-outcome.ts` | RunnerOutcome → turn classification (`completed` / `empty` / `failed`+{`child_failed`, `child_failed_upstream`} / `dropped`+{`cancelled`}) that `sidecar-outcome.ts` builds on. A settled `"Model returned an empty response twice"` (senpi's empty-assistant recovery settling a second silent stop) is `completed` when nudges were accepted and `empty` when none were - the sidecar may legitimately end a wake without prose, so that message is the floor under the nudge tool's `terminate` hint, not a failure (#7963). `child_failed_upstream` is derived from the SETTLED failure message (`503`, `auth_unavailable`, `overloaded`) because the child settles its own turn once its retry budget and fallback chain are exhausted - there is no event-stream fail-fast, which would abort the engine's rotation in progress. The settled outcome's `model` (the rung that actually answered after a rotation) is what the wake outcome records. `normalizeGateReason` produces the bounded, sanitized reason (`redacted` on secret-like text). |
| `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. |
+ | `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, slot wait, cursor span, context estimate, provider `usage` summed over the turn's assistant messages, `diagnostic`). Only `failed` is diagnostic. |
+ | `observe.ts` | Durable, bounded observability and retention. `kibitzerSidecarSessionDir` (unpadded URL-safe base64 of the parent session id: injective, path-safe, `decodeKibitzerSidecarDirName` round-trips it) names `recall/sidecars/<encoded-session>/`; `createKibitzerObservability` appends one `wakes.ndjson` line per settled wake there (`kibitzerWakeRecord`: status, cause, model, cursor span, tool calls, duration, slot wait, usage, delivered paths; the reason is cut before any stack frame, masked by memory-core's and senpi's secret patterns and bounded to `GATE_REASON_MAX_CHARS`, paths and model are capped, the whole line is bounded at `KIBITZER_WAKE_RECORD_MAX_CHARS`), serialized per session, and keeps the diagnostic streak: an isolated failure is silent, the third consecutive diagnostic failure of one main session appends exactly ONE `omo-kibitzer:gate` notice (`consecutiveFailures`, additive `wake`), and a normal completion or the session's shutdown resets it. `own` takes the session's directory owner lock (`locks/recall-sidecar.<encoded-session>.lock`, `kibitzerSidecarOwnerLockPath`) until `onSessionShutdown` releases it. `pruneKibitzerSidecars` removes a sidecar directory idle for `KIBITZER_SIDECAR_RETENTION_MS` (7 days) - never one this process owns, never one whose owner lock is held by a live process (a dead owner is recovered only on pid/start proof), re-checking idleness under the lock - by rename to a `.prune-*` tombstone then removal; leftover tombstones are cleared first. The sweep runs once per identity when its first sidecar comes alive and at every session shutdown. |
| `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). |
+ | `notice.ts` | Entry renderers and type constants: `GATE_ENTRY_TYPE` (the persistent-failure notice; `KibitzerGateRecord` keeps the one-shot fields, `runId` included, and gains the additive `wake`), `NUDGED_ENTRY_TYPE` (delivery result trace), gate reason normalization (bounded, sanitized, secrets redacted). The gate renderer draws only records carrying `consecutiveFailures`, so an isolated failure never reaches the screen. |
| `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`. |
- | `index.ts` (formerly `composition.ts`) | The live composition: `createKibitzerComposition` owns ONE `KibitzerSidecar` per bound main session (created lazily on the session's first hook when the agent-resolved `memory.recall.enabled` is on - the only off switch - and never per identity), built from the identity paths (`recall/sidecars/<encoded-session>/` via `kibitzerSidecarSessionDir` = unpadded URL-safe base64 of the session id, the repo for the read-only `memory` tool, `runtime/locks` for the wake slot), `resolveKibitzerSidecarSettings` (category into the child starter, tool budget, max tokens, event caps, `max_concurrent_wakes` into `createKibitzerWakeSlot`) and the shared recall corpus cache. Its hook sink reads the ctx synchronously - branch snapshot (event cursor and the `session_entries` page), model registry, payload - captures the event into the sidecar's stream, and only then detaches the lexical collection over the plain snapshot (prompt text or the `ToolArgWindow` texts as planner hints) into `sidecar.offer`; `tool_result` only completes the rich event. Delivery is the unchanged `delivery.ts` (`deliver` = `accept` at settlement: ledger mark, hold, steer/prompt drain); compaction only drains delivery; shutdown disposes the sidecar (lease released) and drains delivery; `whenIdle` waits for detached collections and running turns; `activeSessions` lists live sidecars. `childStarter` exposes the starter's QA seams (`createRunner`, `createSession`, `loadPersona`, `loadTaskRuntime`) and `onWake` is the observability seam. |
+ | `index.ts` (formerly `composition.ts`) | The live composition: `createKibitzerComposition` owns ONE `KibitzerSidecar` per bound main session (created lazily on the session's first hook when the agent-resolved `memory.recall.enabled` is on - the only off switch - and never per identity), built from the identity paths (`recall/sidecars/<encoded-session>/` via `kibitzerSidecarSessionDir` = unpadded URL-safe base64 of the session id, the repo for the read-only `memory` tool, `runtime/locks` for the wake slot), `resolveKibitzerSidecarSettings` (category into the child starter, tool budget, max tokens, event caps, `max_concurrent_wakes` into `createKibitzerWakeSlot`) and the shared recall corpus cache. Its hook sink reads the ctx synchronously - branch snapshot (event cursor and the `session_entries` page), model registry, payload - captures the event into the sidecar's stream, and only then detaches the lexical collection over the plain snapshot (prompt text or the `ToolArgWindow` texts as planner hints) into `sidecar.offer`; `tool_result` only completes the rich event. Delivery is the unchanged `delivery.ts` (`deliver` = `accept` at settlement: ledger mark, hold, steer/prompt drain); every settled wake goes to `observe.ts` (the `wakes.ndjson` line and the gate streak) and the sidecar directory is owned from creation (`observe.own`) to shutdown; compaction only drains delivery; shutdown disposes the sidecar (lease released), releases the directory (`observe.onSessionShutdown`, which also sweeps aged directories) and drains delivery; `whenIdle` waits for detached collections, running turns and pending wake records; `activeSessions` lists live sidecars. `childStarter` exposes the starter's QA seams (`createRunner`, `createSession`, `loadPersona`, `loadTaskRuntime`) and `onWake` is a QA seam fed after the record is written. |
| `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 contract: every capture runs synchronously on the live ctx and before delivery, prompt-shape gating, memory-child sentinels, unbound-session capture without delivery, and warn-and-continue on a throwing sink, a rejecting delivery or a stale session resolver. |
- | `index.test.ts` | Composition contract over a scripted collector, a `createRunner` seam and the REAL wake-lock domain in a temp identity: exactly one sidecar per bound session (two sessions of one identity, three hooks on one session), none when recall is disabled or the session is unbound, every event captured before the ctx is disposed (seed carries prompt/assistant/tool_call/tool_result with cursors, caps and planner hints), an accepted nudge reaching delivery with the ledger mark and steer plus the two-per-window cooldown, and shutdown aborting/disposing the child, releasing the machine slot, and starting nothing for a racing or late offer. Every wait is an explicit signal with a bounded timeout. |
+ | `index.test.ts` | Composition contract over a scripted collector, a `createRunner` seam and the REAL wake-lock domain in a temp identity: exactly one sidecar per bound session (two sessions of one identity, three hooks on one session), none when recall is disabled or the session is unbound, every event captured before the ctx is disposed (seed carries prompt/assistant/tool_call/tool_result with cursors, caps and planner hints), an accepted nudge reaching delivery with the ledger mark and steer plus the two-per-window cooldown, one `wakes.ndjson` line per settled wake in the child transcript's directory with the owner lock held while the sidecar lives, and shutdown aborting/disposing the child, releasing the machine slot and the directory lock (the cancelled wake recorded before shutdown resolves), and starting nothing for a racing or late offer. Every wait is an explicit signal with a bounded timeout. |
| `judge-outcome.test.ts` | Outcome classification: settled turn interpretation (responses, failures, timeouts), model recording, reason normalization. |
+ | `notice.test.ts` | Entry renderers: the gate notice draws only at the streak threshold (a dropped or below-threshold record draws nothing), names the recalled-memory candidates and the actionable settings hint, renders the resident record (`wake`, no `runId`) without a run line and a stored one-shot record with its `run <id>` line; the nudged recollection keeps the single fixed Kibitzer title whatever the stored opener or provenance. |
| `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` | Delivery provenance: one accepted nudge delivered by steer, by an idle-coordinator flush and by the prompt drain records `via: "steer"` / `"wake"` / `"prompt"` on its `omo-kibitzer:nudged` entry. |
+ | `observe.test.ts` | Observability and retention over a temp identity: distinct session ids that differ only by base64url-significant characters get distinct, decodable directories, wake files and owner locks; a real sidecar wake (fake child with provider usage and a nudge) lands as exactly one `wakes.ndjson` record with cursors, model, tool calls, duration and summed usage; credentials, a stack frame, a local path and oversized fields are redacted and bounded with no secret pattern left in the file; three isolated failures append no gate notice while every wake is recorded; three consecutive diagnostic failures append exactly one notice (a fourth adds nothing, a normal completion or shutdown starts a fresh streak, streaks are per session); the seven-day sweep removes only the idle unowned directory, never one owned by this process or locked by a live owner, clears a leftover tombstone, and the directory goes only after its session's shutdown. Every wait is an explicit signal with a bounded timeout. |
| `task-runtime.test.ts` | Runtime loader: asset resolution, cache hits, persona-unavailable throws, import-rejection reporting. |