/**
 * # session/agent — the Agent seam (the frozen-frame → Actions boundary of Simulate)
 *
 * Simulate is a **wake-driven, agent-in-loop** run: between wakes standing Plans fire
 * autonomously (fire-then-inform); at a Wake the runtime hands the agent a **frozen,
 * date-blind {@link ActingFrame}** and consumes the {@link AgentTurn} it returns. This module
 * defines the ONE narrow boundary the runtime invokes the author through — `open(briefing) /
 * decide(frame) / close(final)` — and the four adapters that satisfy it. The driver that walks
 * a bus, freezes the vantage, and serializes each Action onto the graded bus
 * (`runSimulateSession`) is the companion module `./simulate.ts` (next phase); it drives the
 * SAME {@link import("./sim.ts").SessionCore} the stepped day already drives, so the two share
 * one event loop and one determinism hash.
 *
 * ## Where the determinism boundary sits (the whole point)
 * The live loop is *deliberately non-deterministic* — judgment is the variable Simulate
 * measures. Everything ABOVE the boundary (Rendering the Frame, the model call, sampling) sits
 * OFF the runtime path. The boundary is the **returned {@link AgentTurn}**: exactly one value
 * crosses, and below it everything is a pure function of Bus bytes (ADR-0011). The driver stamps
 * every Action at the **injected wake ts** (no wall clock, no unseeded RNG on the record path),
 * so `project(gradedBus)` re-derives a byte-identical Blotter, and a captured run re-runs as a
 * Backtest.
 *
 * ## Four adapters, one seam, one driver
 * - {@link liveAgent}          — the model; the sole non-deterministic point, above the line.
 *                                Declared-only here — its concrete body (provider SDK, credential
 *                                injection, Rendering, token/reasoning capture) is the Agent
 *                                Harness epic's, and imports no part of the deterministic core.
 * - {@link recordedAgent}      — replays a prior run's captured turns keyed by stable wake identity
 *                                ({@link wakeKeyOf}): byte-identity replay, no model call, no Rendering.
 * - {@link fixedPlanAgent}     — arm one document at open, pass forever = a Backtest expressed
 *                                through the seam (the case where discretionary and static coincide).
 * - `fileHandshakeAgent`        — the file-handshake protocol (frames/turns as JSON files), now ONE
 *                                adapter rather than the boundary. Promoted to a versioned,
 *                                identity-stamped seam in `./harness/file-handshake.ts` (kestrel-m9i.7).
 *
 * ## Non-negotiables the seam encodes (enforced by the driver next phase)
 * - **Fail closed.** A parse escape on an authored `supersede` document, or an unparseable whole
 *   turn, → a {@link StandDownAction} turn (de-arm clean, inventory rides its TP/EXIT, logged
 *   reason); the agent can never crash the Session. A single unresolvable/past
 *   {@link ScheduleWakeAction} is the *proportionate* refuse-that-action-and-continue (the book
 *   stays armed on the staleness backstop) — NOT a book-wide stand-down. An empty `actions[]` is a
 *   legitimate pass (standing Plans keep managing). A missing {@link AgentConfig} refuses to grade,
 *   never a silent default.
 * - **Bounded risk / never naked.** {@link PlaceOrderAction} / {@link CancelOrderAction} route the
 *   SAME Gate as a fired Plan (SELL floored at intrinsic, budget clamp, never naked); price is a
 *   Kestrel expression the engine resolves, never a bare mid; `standDown` never liquidates.
 * - **Round-trip.** Two substrates, kept apart: the authored `supersede` document round-trips
 *   through Kestrel `parse`/`print` (the `tests/golden/` contract), while the non-surface actions
 *   (`standDown`/`scheduleWake`/`placeOrder`/`cancelOrder`) serialize through the SAME sorted-key /
 *   drop-undefined canonicalizer the Blotter and receipts already use — never Kestrel `print()`.
 *
 * This module is an **extraction** from `./day.ts`, not a greenfield: the frozen-at-wake
 * checkpoint (`events[idx].ts < w.ts`), the date-blind author projection (`projectAuthorFrame`),
 * and supersede+arm already exist there. Simulate's one behavioral change is that the wake set
 * stops being a precomputed static list and becomes agent-driven via {@link ScheduleWakeAction},
 * still floored by the staleness backstop and coalesced/downgraded past the attention budget.
 */

import type { WakeDeltaInput, BriefingInput } from "../frame/types.ts";
import type { ViewSelection } from "../frame/pane-catalog.ts";
import type { SeatViewsPolicy } from "../frame/seat.ts";
import type { CorpusTier, Face, TokenAccounting } from "../bus/types.ts";
import { parse } from "../lang/index.ts";
import { standDownTurn as mkStandDownTurn } from "../engine/disarm.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The comparison axis — AgentConfig (the seam's runtime view of the config descriptor)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The agent **config** — the comparison axis of Simulate (same input bus, many configs),
 * stamped onto the graded bus META as a judge self-description (alongside `fill_model`), so a
 * cell coordinate is a pure read of the record.
 *
 * This IS the a57.14 experimental envelope's **input half** — a single definition, never a second one
 * (the agent-harness ADR (d)). {@link import("./config.ts").canonicalizeConfig} folds it to the canonical descriptor
 * (`ModelIdentity` ⊕ `SamplingConfig` ⊕ `RenderingIdentity` ⊕ `AuthorPolicy` → `ConfigId =
 * sha256(canonical)`, the grid column key), and {@link import("./config.ts").envelopeForConfig} maps it to
 * the {@link import("../bus/types.ts").ExperimentalEnvelope} the Simulate driver stamps — the same value in
 * two shapes, never two truths (ADR-0011).
 *
 * The first six fields are the mechanical core every run carries (a NO-LLM Backtest carries only these —
 * {@link BACKTEST_CONFIG}). The envelope-identity fields below are OPTIONAL: a config that declares any of
 * them is an authoring run whose envelope is stamped; a Backtest declares none, so no envelope is stamped and
 * the Blotter stays `certified` (fail-closed by omission — never a fabricated identity, a57.14).
 */
export interface AgentConfig {
  /** The pinned model identity (family/snapshot). Credentials are NEVER part of the config. */
  readonly model: string;
  /** The Rendering tokenizer identity — the tokenizer token costs are measured under. */
  readonly tokenizer: string;
  /** The Rendering format (the Frame is rendered to tokens INSIDE the live adapter). */
  readonly format: "ascii" | "unicode" | "md" | "json" | "html";
  readonly temperature: number;
  readonly thinkingLevel: "none" | "low" | "medium" | "high" | "max";
  /** The leaderboard / `BY` key (names are data, ADR-0006). */
  readonly label: string;
  /** SamplingConfig — the **fill-realization seed** for the sampled (seeded-Bernoulli) fill channel
   * (ADR-0016 / kestrel-9gu.8.1). Present ⇒ the SimFillEngine draws causal fills off a `runSeed`-keyed
   * mulberry32 substream (`hash(runSeed, model.version, order.ref, episodeIndex)`); **absent ⇒ the sampled
   * channel is OFF** (fail-closed — the run grades floor+expected only, no realized draws). It folds into
   * the {@link import("./config.ts").deriveConfigId ConfigId} (a sampling knob, like `temperature`) but is
   * NOT an envelope-identity field, so two runs differing only in `fillSeed` are REPLICATES under one
   * {@link import("./run-identity.ts").CellKey CellKey} (same tape × fill_model × envelope) yet each yields a
   * distinct {@link import("./run-identity.ts").SimRunId SimRunId} — the ensemble (`baseSeed + i`). */
  readonly fillSeed?: number;

  // ── The a57.14 envelope input half (ModelIdentity ⊕ RenderingIdentity ⊕ AuthorPolicy ⊕ injected
  //    accounting). OPTIONAL — a NO-LLM Backtest carries none ⇒ no envelope ⇒ stays certified. ───────
  /** ModelIdentity — the model provider/family (a generic vendor label; NEVER a credential). */
  readonly provider?: string;
  /** ModelIdentity — the model version/snapshot token. */
  readonly version?: string;
  /** ModelIdentity — the model's training cutoff (the basis for model-relative holdback gating). */
  readonly trainingCutoff?: string;
  /** RenderingIdentity — how the Frame was rendered to tokens (distinct from `format`, which rides the ConfigId). */
  readonly renderingIdentity?: string;
  /** AuthorPolicy — the sha256 HASH of the author prompt/profile (a HASH, NEVER the prompt content). */
  readonly promptHash?: string;
  /** AuthorPolicy — the sha256 HASH of the author's BRIEF (ADR-0026 — the soft, directional English
   * persona/goal). A HASH, sibling to {@link promptHash}, NEVER the brief text: it binds "this
   * performance came from this thesis" into the run/grade provenance (folds into the ConfigId /
   * grid column, so a rebrief mints a new column). The Brief itself is rendered into the frame from
   * the cell/kernel and can NEVER enter admission — only the mandate/envelope do (the hard guard). */
  readonly briefHash?: string;
  /** AuthorPolicy — the tool policy the author ran under (e.g. `no-tools`). */
  readonly toolPolicy?: string;
  /** AuthorPolicy — what woke the author (e.g. `structural-cadence`). */
  readonly wakeSource?: string;
  /** AuthorPolicy — the corpus tier the author draws on — the CLOSED {@link CorpusTier} vocabulary
   * (`public`|`semi-private`|`private`, ADR-0018 three tiers), the practice-vs-holdback wall (kestrel-m9i.26).
   * Closed at capture (mirror {@link face}); the projector re-checks the injected bytes fail-closed. */
  readonly corpusTier?: CorpusTier;
  /** The interface {@link Face} the author reaches the model through (`http`|`sdk`|`cli`|`mcp`) — the
   * m9i.7 tournament axis (m9i.2 owner tweak). Declared by the CALLER (who knows the transport); a
   * harness never fabricates it — an undeclared face fails the envelope closed to PROVISIONAL. */
  readonly face?: Face;
  /** The adapter/harness identity that drives the author (m9i.2 owner tweak — mandatory-for-certified
   * BEFORE the m9i.7 harness tournament). `liveAgent` stamps its own identity when the caller leaves it
   * unset (the harness KNOWS itself — never a fabricated third-party identity). */
  readonly adapter?: string;
  /** The adapter/harness version/revision token (paired with `adapter`). */
  readonly adapterVersion?: string;
  /** The EXTERNALLY-DECLARED brain identity the shared `adapter` (e.g. `file-handshake`) drives — the
   * m9i.7 harness-tournament pivot: `claude-code/2.x` | `codex/x` | `opencode/x` | `first-party-liveagent`
   * (kestrel-m9i.7 stage-0). CFG-only, NOT an a57.14 envelope field (the certified envelope stays exactly
   * 14 fields), yet it folds into {@link import("./config.ts").deriveConfigId ConfigId} — which the grid
   * CellKey keys on — so two runs differing only in `harness` are DISTINCT grid columns. Declared by the
   * CALLER (the harness names ITSELF); a shared adapter never fabricates it. */
  readonly harness?: string;
  /** The `harness` version/revision token (paired with `harness`) — folds into the ConfigId alongside it. */
  readonly harnessVersion?: string;
  /** The prompt-cache / conversation-reuse policy the live harness ran under (kestrel-rul; System Profile
   * v1.1 `cache policy` slot). A **ConfigId axis only** (like `format`/`temperature`), NOT an envelope-identity
   * field: caching is transport-side and OFF the graded path, so two runs differing only in `cachePolicy`
   * grade byte-identically yet land in DISTINCT grid columns — exactly the A/B m9i.5 sweeps (append-only KV
   * reuse vs stateless redraw, cache on/off). `liveAgent` stamps the policy it actually ran under when the
   * caller pins none (the harness knows its own transport). See {@link CachePolicy}. */
  readonly cachePolicy?: CachePolicy;
  /** The provider prompt-cache TTL the `conversation-cached` policy requests on its breakpoints (kestrel-wa0j.1).
   * ABSENT ⇒ the provider default (Anthropic 5-minute ephemeral / Bedrock default cachePoint) — the request
   * bytes and ConfigId are byte-identical to before this axis existed. `"1h"` opts a live-paced session (wakes
   * > 5 min apart) into the 1-hour TTL, so the stable prefix survives between wakes instead of expiring and
   * re-writing at the cache-write premium every wake. Like {@link cachePolicy}, a **ConfigId axis only** —
   * never an envelope field — so a changed TTL mints a distinct grid column. Fail-closed: a `cacheTtl` on a
   * config whose policy requests no cache breakpoints (anything but `conversation-cached`), or on a provider
   * lane with no explicit breakpoint primitive, throws rather than riding silently inert. */
  readonly cacheTtl?: CacheTtl;
  /** The INJECTED token accounting (off the deterministic record path — the owner-away default). */
  readonly tokenAccounting?: TokenAccounting;
  /** The **clock-honest session-semantics switch** (ADR-0040 / kestrel-w7la.1, clock-honest wakes §4):
   * `true` ⇒ deliberation consumes tape time — a Seat's turn is measured at the driver's injectable
   * `now` seam, buffered by {@link latencyBufferMs}, recorded on the Bus as a `deliberation` record,
   * and its control lands at `returnTs = vantageTs + measured + buffer`. Absent ⇒ latency-blind,
   * byte-identical driver behavior to today. A ConfigId axis (it folds into `deriveConfigId`
   * automatically), never an envelope-identity field — a clocked run can NEVER share a grid column
   * with an unclocked one. When the ADR-0032 cascade lands this stays session-scoped (all seats or
   * none); {@link latencyBufferMs} becomes per-seat. */
  readonly clockHonest?: true;
  /** The configured network/infra latency allowance added to EVERY measured Seat turn under
   * {@link clockHonest} (clock-honest wakes §4). Integer milliseconds. **No default (decided)**: a
   * clock-honest session with this absent (or non-integer, or negative) is REFUSED loudly at open —
   * "never a silent default", and any designed default would be a judge-designed weighting (ADR-0030
   * spirit: the clock is measured, not designed). `0` is legal and visible: it declares a colocated
   * seat in the column identity. Governed seasons pin the production value platform-side
   * (PLAT-ADR-0033). A ConfigId axis like {@link clockHonest}. */
  readonly latencyBufferMs?: number;
  /** The **founder-View opt-in** (ADR-0041 §2 / A1, kestrel-wa0j.26). `"founder"` ⇒ the driver passes
   * the acting tier's pod SEAT into the render's `resolveView` (strategist at OPEN, watcher at wakes),
   * so a seat with a founder seed reads its GRADED founder View when it authored none — a distinct
   * experimental subject. Founder Views are SEEDS, never blessed defaults, so this is OFF by default:
   * absent (or `"phase-default"`) ⇒ no seat is consulted ⇒ the frame-kind default panes, byte-identical
   * to before this axis existed. A ConfigId axis (it folds into {@link import("./config.ts").deriveConfigId
   * deriveConfigId} automatically, like {@link clockHonest}/`cachePolicy`), NEVER an a57.14 envelope
   * field — so a founder-View run mints a DISTINCT grid column while the certified envelope stays exactly
   * 14 fields. Absent ⇒ every existing ConfigId is byte-identical (drop-undefined canonicalizer). */
  readonly seatViews?: SeatViewsPolicy;
}

/**
 * How the live harness reuses a session's conversation across wakes — the prompt-cache economics lever
 * (kestrel-rul, the CONTEXT "Streaming Rendering (cache-monotone)" doctrine). A pure {@link AgentConfig}
 * axis: it selects transport behaviour ABOVE the determinism line and never changes the returned
 * {@link AgentTurn}, so a recorded run replays byte-identically regardless of the policy it ran under.
 *
 * - `stateless-redraw` — v0: each wake re-renders its frame as ONE fresh user message with no prior turns
 *   in context and no cache markers (the model has no memory of earlier wakes). The redraw arm for m9i.5.
 * - `conversation` — the session is a GROWING message list (byte-stable system + briefing + each wake's
 *   appended delta frame + the model's prior replies), so the model sees the whole session, but no provider
 *   prompt-cache control is requested.
 * - `conversation-cached` — `conversation` PLUS a provider prompt-cache breakpoint on the byte-stable
 *   prefix (Anthropic `cache_control` / Bedrock cache-point via the AI SDK), so the stable prefix is billed
 *   as a cache-read (~0.1×) instead of full-price on every wake. The default the live harness runs under.
 */
export type CachePolicy = "stateless-redraw" | "conversation" | "conversation-cached";

/**
 * The provider prompt-cache TTL requested on every breakpoint the `conversation-cached` policy marks
 * (kestrel-wa0j.1). The values are the two TTLs the Anthropic-family cache actually serves (`cache_control
 * ttl` on the direct API, `cachePoint.ttl` on Bedrock — verified against the installed AI-SDK providers).
 * The economics: a 5-minute entry costs 1.25× to write; a 1-hour entry costs 2× to write but survives a
 * live-paced session's inter-wake gaps — under the default 5m TTL, wakes > 5 min apart MISS the expired
 * cache and re-write the whole prefix at 1.25× every wake, making `conversation-cached` the most expensive
 * policy exactly where it should be the cheapest. Absent ({@link AgentConfig.cacheTtl} undefined) ⇒ the
 * provider default rides the wire — byte-identical to before this axis existed.
 */
export type CacheTtl = "5m" | "1h";

/**
 * The ONE shared refusal text for a configured {@link CacheTtl} that cannot reach the wire
 * (kestrel-wa0j.1). THREE independent guards refuse for three different reasons — the live-agent
 * policy gate (a {@link CachePolicy} that marks no breakpoints), the AI-SDK breakpoint builder (a
 * provider lane with no breakpoint primitive), and the codex-cli lane (no cache-control flag at
 * all) — but the doctrine is one: a ConfigId column must never claim a cache TTL the provider
 * never saw (a configured knob silently inert is exactly what fail-closed forbids). Each guard
 * supplies its own `blocker` (why THIS layer cannot carry the TTL) and, optionally, a `remedy`.
 */
export function cacheTtlNeverReachesWire(ttl: CacheTtl, blocker: string, remedy?: string): string {
  return (
    `cacheTtl "${ttl}" is configured but ${blocker} — refusing to run with a cache TTL that ` +
    `never reaches the wire (fail-closed, kestrel-wa0j.1).${remedy === undefined ? "" : ` ${remedy}`}`
  );
}

/**
 * Does `provider` expose an explicit per-request prompt-cache breakpoint primitive a {@link CacheTtl}
 * can ride (kestrel-wa0j.19 §4)? TRUE only for the Anthropic-family lanes — `anthropic` (`cacheControl`)
 * and `bedrock` (`cachePoint`) — the two the AI-SDK wire guard ({@link import("./harness/ai-sdk-client.ts").cacheBreakpoint})
 * can attach a `ttl` to. Every other lane (Gemini/OpenAI-compatible/gateway/local/codex-cli/…) caches
 * IMPLICITLY with no breakpoint to carry a TTL, so a configured `cacheTtl` there would never reach the
 * wire. PROVIDER-FREE (a pure string check, imports no provider SDK), so the deterministic core — and
 * {@link import("./harness/live-agent.ts").liveAgent}'s CONSTRUCTION gate — can refuse a misconfigured
 * `cacheTtl × provider` column BEFORE a session of provider-error passes burns, while the wire guard
 * stays as defense in depth. A blank/unknown provider is NOT positively refused here (the caller may
 * pin none — a mock/test); the wire guard is the backstop for what reaches an actual provider.
 */
export function providerSupportsCacheTtl(provider: string): boolean {
  return provider === "anthropic" || provider === "bedrock";
}

// ─────────────────────────────────────────────────────────────────────────────
// The acting-kernel strip layered onto the delta Frame (all date-blind)
//
// These lead-block fields extend the existing delta-frame input with the wake's own oversight
// context. Simulate CONSUMES the acting kernel; it does not fork it. When the richer Frame-of-
// Fields kernel lands, each scalar becomes an attributed, watermarked Field — this seam is the
// interim, date-blind shape the driver populates today.
// ─────────────────────────────────────────────────────────────────────────────

/** How urgent this wake is (authored PRIORITY for author wakes; a platform default otherwise). */
export type WakeSeverity = "routine" | "elevated" | "urgent";

/** WHICH frontier raised a wake — the driver's own provenance for a delivery, and the stable half of the
 * {@link WakeKey} replay identity. Lives on the seam (not in the driver) because it crosses to the agent
 * on every {@link ActingFrame}: `agent` (the author's own {@link ScheduleWakeAction}), `seed` (an authored
 * fixture vantage), `own-fill` (the seeded run's fire-then-inform management wake, ADR-0016), `structural`
 * (the cadence), `staleness` (the platform backstop), `latency-fold` (the clock-honest catch-up delivery —
 * ONE vantage at the Seat's return time carrying every wake that fell inside its open deliberation window,
 * ADR-0040 / clock-honest wakes §2.3; CONTEXT "Latency-fold"). */
export type WakeSource = "agent" | "seed" | "own-fill" | "structural" | "staleness" | "latency-fold";

/** Per-signal data health at the wake vantage — date-blind (a clock, never a date). `stale` /
 * `unavailable` name the instruments/capabilities the runtime could not resolve, so the agent
 * always sees what is UNKNOWN rather than a guessed default (fail-closed). */
export interface DataHealth {
  /** The `HHMM` ET clock this health snapshot was taken at (dateless). */
  readonly asOfClockET: string;
  /** Instruments whose canonical series is stale this vantage. */
  readonly stale: readonly string[];
  /** Capabilities currently UNKNOWN (a de-armed pane / series), each with its reason upstream. */
  readonly unavailable: readonly string[];
}

/** The sim-time horizon around the wake — NOT a think budget (phase 1 is frozen-at-wake, so
 * fast and slow models decide on byte-identical Frames). Any horizon may be `null` ⇒ `—`. */
export interface WakeDeadline {
  readonly minutesToClose: number | null;
  readonly minutesToNextWake: number | null;
}

/**
 * The acting Frame handed to the agent at a Wake — **identical bytes for every Agent** (frozen-
 * at-wake isolates judgment). A superset of the existing {@link WakeDeltaInput} (tape/levels/
 * kernel since the last vantage) plus the acting-kernel strip. All fields are date-blind: relative
 * time and the `HHMM` ET clock only (the contamination fence).
 */
export interface ActingFrame extends WakeDeltaInput {
  /** The 0-based GLOBAL wake ordinal — this delivery's position in the session's wake sequence (distinct
   * from the inherited `wakeIndex` display field). A display/log coordinate, NOT the replay key: it is
   * unstable under vantage insertion (see {@link wakeKeyOf}). */
  readonly wakeOrdinal: number;
  /** WHICH frontier raised this delivery — the stable half of the {@link WakeKey} replay identity
   * (kestrel-5zl.19). */
  readonly wakeSource: WakeSource;
  /** The 0-based MONOTONE PER-SOURCE ordinal — this wake's position among the wakes THIS source RAISED,
   * minted at the raise (kestrel-5zl.20). Unlike {@link wakeOrdinal} it does not move when a *different*
   * source inserts a vantage, NOR when an earlier delivery is folded (spacing/budget/own-fill/probe). */
  readonly wakeSourceOrdinal: number;
  /** The relative-day coordinate `d{k}` (kestrel-5zl.16.4): this session's ordinal in its Instance,
   * pinning no calendar date. Present ONLY for a multi-session run; absent (and unserialized) for a
   * standalone session — so existing single-session acting-frame goldens are byte-identical. Paired with
   * the per-session `sinceOpenMs`/`clockET` (both reset at each session's open), it is how the agent tells
   * the days of a horizon apart without any calendar identity reaching the frame. */
  readonly sessionOrdinal?: number;
  readonly severity: WakeSeverity;
  readonly deadline: WakeDeadline;
  readonly dataHealth: DataHealth;
  /** The View delivered WITH this wake (kestrel-wa0j.4): the scheduled wake's stored View
   * ({@link ScheduleWakeAction.view}), resolved fail-closed at schedule time — the wake decides
   * *when*, the View decides *what* (CONTEXT "Wake delivery"). Absent ⇒ the frame-kind default
   * WAKE panes, byte-identical to a view-less delivery. The kernel stays OUTSIDE this selection,
   * and the View budget guard applies at materialization exactly as on OPEN. */
  readonly view?: ViewSelection;
  /** Attention accounting — the squelch stays visible, never silently dropped. */
  readonly attention: {
    /** Wakes remaining under the attention budget; `null` ⇒ unbounded/unknown. */
    readonly wakesRemaining: number | null;
    /** Same-key wakes folded into this delivery (coalesced past the budget). */
    readonly coalesced: number;
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The Action union — what an agent may DO at a wake
// ─────────────────────────────────────────────────────────────────────────────

/** A dateless sim-time instant the agent schedules its next Wake for. An unresolvable/past value
 * is refused-and-logged (the book rides the staleness backstop), never a book-wide stand-down. */
export type WakeAt =
  | { readonly kind: "inMinutes"; readonly minutes: number }
  | { readonly kind: "atClockET"; readonly clockET: string };

/** A single-leg order intent. `price` is a **Kestrel expression** the engine resolves and gates
 * exactly like a fired Plan's price (never a bare mid; SELL floored at intrinsic).
 *
 * `strike`+`right` follow the ADR-0017 leg convention: BOTH present ⇒ an OPTION order (option-chain
 * fill path); BOTH absent ⇒ an EQUITY/SPOT order (spot-fill path). Exactly one present is a defect the
 * parser fails closed on — never a silent equity fallback. `qty` is contracts for an option, shares for
 * equity. */
export interface OrderIntent {
  readonly side: "buy" | "sell";
  readonly instrument: string;
  /** Present TOGETHER with `right` for an option; BOTH absent for an equity/spot order (ADR-0017). */
  readonly strike?: number;
  readonly right?: "C" | "P";
  readonly qty: number;
  /** A Kestrel price expression (e.g. `@fair`, `cap fair,0.80`) — resolved + gated, never a raw mid. */
  readonly price: string;
}

/** Author or revise the standing document. The first turn's supersede is the initial arm. A parse
 * escape on `document` → a {@link StandDownAction} turn (fail-closed). Only the embedded `document`
 * is a Kestrel surface statement (`print(parse(document))` round-trips). */
export interface SupersedeAction {
  readonly kind: "supersede";
  /** Kestrel source (a View/Wake/Plan document — decision, record, and executable in one object). */
  readonly document: string;
  readonly note?: string;
}

/** Self-defined cadence atop the platform staleness backstop + optional structural cadence. */
export interface ScheduleWakeAction {
  readonly kind: "scheduleWake";
  readonly at: WakeAt;
  readonly reason: string;
  /** An optional View selection to deliver at the scheduled wake. */
  readonly view?: string;
}

/** Place a single-leg order — routed through the SAME Gate as a fired Plan (never naked). */
export interface PlaceOrderAction {
  readonly kind: "placeOrder";
  readonly order: OrderIntent;
  readonly note?: string;
}

/** Cancel a resting order by `ref` (from the Frame kernel's resting list). An unknown ref is
 * refused-and-logged (fail-closed), not a stand-down. */
export interface CancelOrderAction {
  readonly kind: "cancelOrder";
  readonly ref: string;
  readonly note?: string;
}

/** De-arm clean — inventory rides its TP/EXIT. The fail-closed default (never liquidates). */
export interface StandDownAction {
  readonly kind: "standDown";
  readonly reason: string;
}

/** FLATTEN a held position (bd if0 / 75n): the first-class "get me out now". For every managing plan
 * holding `instrument`, the engine cancels its resting orders (neutralizing the rolling TP re-post that
 * defeats a bare `cancelOrder`) AND crosses a COVERED close through the SAME admission Gate a fired Plan
 * uses — so never-naked still holds (a flatten of a HELD long is a covered sell; SELL floored at
 * intrinsic) and it can NEVER create a naked position. After a flatten the position is flat and its R is
 * freed (bd 1a8). A flatten of a flat book is a clean no-op. Unlike `standDown` (de-arm, inventory RIDES),
 * `flatten` actually LIQUIDATES the named position. */
export interface FlattenAction {
  readonly kind: "flatten";
  readonly instrument: string;
  readonly note?: string;
}

/** What the agent may DO at a wake. Each serializes to a CONTROL/ORDER/JOURNAL bus event; the
 * order actions route via the Gate and land as ORDER events (bounded-risk enforced there). */
export type Action =
  | SupersedeAction
  | ScheduleWakeAction
  | PlaceOrderAction
  | CancelOrderAction
  | FlattenAction
  | StandDownAction;

/** One turn = everything decided at one wake. `journal`, when present, becomes a **pre-hoc**
 * JOURNAL `author` event (provably pre-hoc by `seq` — written BEFORE arming). An empty `actions[]`
 * is a legitimate pass (standing Plans keep managing). */
export interface AgentTurn {
  readonly actions: readonly Action[];
  readonly journal?: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// The bounded authoring loop (ADR-0029) — the requestView outcome + the in-window re-entry
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The **view-request outcome** (ADR-0029 §1, Option C) — a DISTINCT top-level authoring reply, NOT
 * an {@link Action}: it is the only agent output that lives ABOVE the determinism line and NEVER
 * becomes a Bus event (folding it into the {@link Action} union would break "every Action is a Bus
 * event"). Its `view` payload is a Kestrel VIEW document (validated to a SINGLE `View` statement, so
 * `print(parse(view))` round-trips and the panes come from the one catalog), and `reason` is the
 * agent's stated why — the emergence-log signal (§5). Honored ONLY under a viewshop-enabled config;
 * under the baseline an emitted `requestView` is an unknown/illegal outcome (fail-closed).
 */
export interface RequestView {
  /** A Kestrel VIEW document — exactly one `VIEW` statement selecting panes from the one catalog. */
  readonly view: string;
  /** Why this lens, now (against the Brief) — logged as evidence, never a graded input. */
  readonly reason: string;
}

/**
 * The classified result of ONE authoring-window model call (ADR-0029 §1/§2). The bounded OPEN loop
 * branches on the three variants; two of them are NON-terminal (they consume the shared dual budget
 * and the driver re-asks):
 *  - `turn`        — a valid TERMINAL {@link AgentTurn} (`supersede`/`standDown`/empty pass);
 *  - `requestView` — a well-formed, non-terminal lens request; the driver materializes it at the SAME
 *                    frozen cutoff and re-asks (the shopping half of the loop);
 *  - `invalid`     — a repairable defect; the driver surfaces `reason` (the repair-guiding error) and
 *                    re-asks the model to fix and resubmit (the repair half — ADR-0029's approved
 *                    sibling, unified here). `reason` becomes the terminal fail-closed pass ONLY once
 *                    the budget is spent.
 */
export type AuthoringReply =
  | { readonly kind: "turn"; readonly turn: AgentTurn }
  | { readonly kind: "requestView"; readonly request: RequestView }
  | { readonly kind: "invalid"; readonly reason: string };

/** How the driver drives one in-window authoring re-entry ({@link Agent.authoringOpen}). Exactly one
 * of these framings applies per call: materialize the briefing under `view` (a lens), OR — when
 * `repairError` is set — re-ask the model to fix its last invalid author (a repair). The very first
 * call carries neither (the incumbent default View, the baseline ask). */
export interface AuthoringOpenOptions {
  /** Materialize the OPEN briefing under this lens (a requested View); absent ⇒ the incumbent default
   * View. The SAME frozen briefing snapshot / cutoff — a lens, never a look-ahead (ADR-0029 §3). */
  readonly view?: ViewSelection;
  /** A repair re-ask: surface this repair-guiding error to the model (do NOT re-render a frame) and
   * ask it to fix and resubmit. Mutually exclusive with a fresh `view` materialization in practice. */
  readonly repairError?: string;
}

/** One in-window authoring call's result: the classified {@link AuthoringReply} plus the model spend.
 * `usage` (input + output + thinking, incl. cache splits) is summed by the driver into the
 * `authoringTokenBudget` AND folds into cumulative session spend — so a model that needs many
 * iterations scores lower on the `ev_per_ktoken` attention axis (ADR-0029 §7). `latencyMs` is audit
 * evidence only, off the graded path. */
export interface AuthoringStep {
  readonly reply: AuthoringReply;
  readonly usage: LlmUsage;
  readonly latencyMs?: number;
}

// ─────────────────────────────────────────────────────────────────────────────
// The WakeHandler seam
// ─────────────────────────────────────────────────────────────────────────────

/**
 * THE WAKEHANDLER SEAM (CONTEXT.md "WakeHandler"). The adapter boundary between the ONE session
 * driver ({@link import("./simulate.ts").runSimulateSession}) and its author: at each wake the driver
 * hands the adapter the delivered Frame ({@link BriefingInput} at OPEN, {@link ActingFrame} at every
 * wake) and the adapter returns the author's response — new/superseding statements (an
 * {@link AgentTurn}), or nothing (an empty pass). Stateful across ONE Session (the live adapter owns
 * its conversation / KV cache; delta Frames arrive append-only, ADR-0008) but it never touches the
 * clock, the bus, or the market.
 *
 * **The adapter is the ONLY place wall time may exist** (an external author deliberating); **the
 * driver owns tape-time accounting** (the Deliberation record, the Latency-fold — ADR-0040)
 * UNIFORMLY, ABOVE this seam. That accounting must NOT live per-adapter: it is minted once at the
 * driver's single `now` seam ({@link import("./simulate.ts").runSimulateSession}'s `clockedTurn`),
 * so day-compat and in-process and (later) the platform DO adapter can never drift a second clock.
 * A response that tries to carry driver-owned tape-time back through this seam is REFUSED fail-closed
 * ({@link assertHandlerResponseTimeless}) — the response channel is authored statements only.
 *
 * **The wake boundary is CHECKPOINT-CAPABLE.** Driver state is serializable while a response is
 * pending, so the platform `SimulationSessionDO` can become a fourth WakeHandler adapter in a later,
 * separate slice (kestrel-3wik notes this; it is deliberately NOT implemented here). The
 * checkpointable driver state at the seam is exactly: the pending wake frontier + its cursor
 * (`frontier`, `idx`, `prevVantageTs`, `prevWakeSeq`), the delivery accounting (`delivered`,
 * `coalesced`, `sourceOrdinals`, `pendingCatchUp`, `fillWatermark`, `deliveredWakeTs`), the armed
 * standing state (`armed`, `authoredWakesPerDay`), and the {@link SessionCore} — every one a pure
 * value/offset off the injected `firstTs`/`session_date` (no wall clock, no RNG), so a resumed
 * driver re-projects byte-identically.
 *
 * The four adapters that satisfy this seam today — {@link liveAgent} (in-process), {@link recordedAgent}
 * (replay), {@link fixedPlanAgent} (autonomous no-op / backtest), and the day file-handshake adapter —
 * are the three WakeHandler modes of the decided design (arch-review B5): the autonomous no-op, the
 * file-handshake external author, and the in-process Agent, over the ONE shared driver.
 *
 * `decide` is `async` because the live loop is deliberately non-deterministic; the recorded and
 * fixed adapters resolve synchronously.
 */
export interface WakeHandler {
  readonly config: AgentConfig;
  /** Open the Session on the OPEN keyframe: arm the initial document, or stand down. */
  open(briefing: BriefingInput): Promise<AgentTurn> | AgentTurn;
  /** Decide at one Wake on the frozen {@link ActingFrame}. */
  decide(frame: ActingFrame): Promise<AgentTurn> | AgentTurn;
  /** Optional end-of-horizon debrief at settle — a post-hoc JOURNAL `debrief`. (Exercising a
   * close *decision* turn that routes end-of-horizon actions through the Gate is a next-phase
   * refinement; this seam keeps close to the debrief note for now.) */
  close?(final: ActingFrame): Promise<string | void> | string | void;
  /**
   * The bounded OPEN authoring loop's in-window re-entry (ADR-0029 §2/§6). Present ONLY on a
   * **viewshop-enabled** adapter — its presence IS the config gate: when the driver sees it, it runs
   * the bounded loop through this instead of {@link open}; when it is absent (baseline, recorded,
   * fixed-plan) the driver runs the single-shot {@link open} and the view-request move does not exist.
   *
   * Each call is ONE model call at the SAME frozen vantage: it materializes the briefing under
   * `opts.view` (or the incumbent default View), or — when `opts.repairError` is set — re-asks the
   * model to fix its last invalid author, and returns the classified {@link AuthoringStep}. The adapter
   * reuses its growing conversation across calls, so the model shops/repairs WITH memory. Only the
   * driver-selected TERMINAL turn ever enters {@link CapturedTurns}, so {@link recordedAgent} (which
   * never implements this) replays byte-identically with zero new graded-path machinery (§5).
   */
  authoringOpen?(briefing: BriefingInput, opts: AuthoringOpenOptions): Promise<AuthoringStep>;
  /**
   * The REPLAY-mode deliberation-cost source (ADR-0040 / clock-honest wakes §7). Present ONLY on a
   * replay adapter ({@link recordedAgent}); its very presence tells the driver this run's cost source
   * is the capture, so the driver NEVER touches its injectable clock (replay never re-times — the
   * poisoned-clock fixture proves it). Three answers, each pinned:
   *  - a number — the recorded `measuredMs` of the Seat-answered turn at `key` (buffered + recorded
   *    exactly like a live measurement);
   *  - `null`   — the capture holds NO turn at `key`: the replay machinery SYNTHESIZED one (a
   *    divergence stand-down / inserted-vantage pass). Engine-minted, not Seat deliberation — it
   *    costs 0, takes no buffer, and records nothing (charging tape for a turn no Seat took would
   *    fabricate cost);
   *  - `undefined` — the capture holds a turn at `key` but no cost: a COSTLESS (latency-blind /
   *    legacy) capture. Under `clockHonest: true` the driver refuses loudly at open — a costless
   *    capture replays only latency-blind (never a silent downgrade).
   */
  recordedCostMs?(key: WakeKey): number | null | undefined;
}

/**
 * The historical name for the {@link WakeHandler} seam — retained as an alias so every existing
 * adapter (`recordedAgent`/`fixedPlanAgent`/`liveAgent`/the day file agent) and their consumers keep
 * their `Agent` type annotations while the canonical CONTEXT.md vocabulary (`WakeHandler`) lands
 * (kestrel-3wik). A follow-up bead does the flat call-site rename; nothing behavioural rides on the
 * name, so the alias keeps this slice byte-identical.
 */
export type Agent = WakeHandler;

/**
 * The tape-time fields the DRIVER alone stamps on the Bus (ADR-0040 / clock-honest wakes §§1, 7) —
 * `measured_ms`/`buffer_ms`/`wake_seq` on the `deliberation` record, the derived `returnTs`, the
 * `CapturedTurn.measuredMs` the driver captures. A {@link WakeHandler} response ({@link AgentTurn})
 * carries the author's STATEMENTS only; it must NEVER carry any of these back through the seam — that
 * would be an adapter OWNING tape-time accounting, the exact per-adapter clock drift the one-driver
 * seam exists to kill. Both the snake_case Bus field and its camelCase driver-local sibling are
 * fenced, so neither spelling can leak.
 */
const RESERVED_TAPE_TIME_FIELDS: readonly string[] = [
  "measuredMs",
  "measured_ms",
  "bufferMs",
  "buffer_ms",
  "costMs",
  "cost_ms",
  "returnTs",
  "return_ts",
  "wakeSeq",
  "wake_seq",
  "deliberation",
];

/**
 * Fail-closed fence over a {@link WakeHandler} response at the driver's consult seam (kestrel-3wik) —
 * the belt that mechanically enforces "the driver owns tape-time accounting UNIFORMLY, above the
 * seam; it must not live per-adapter" (CONTEXT.md "WakeHandler"). Mirrors {@link assertFrameDateBlind}
 * (the date-blind fence at the acting-Frame boundary): a pure inspection that is a NO-OP on a
 * conformant turn (`{ actions, journal? }`) and THROWS on any response smuggling a driver-owned
 * tape-time field back through the seam. Wired through the REAL driver at every consult site (OPEN +
 * every wake, latency-blind AND clocked), so the guard is proven WIRED by a fixture that goes red
 * without it — never a green unit test of an inert function (AGENTS.md non-negotiable).
 */
export function assertHandlerResponseTimeless(turn: AgentTurn): AgentTurn {
  for (const field of RESERVED_TAPE_TIME_FIELDS) {
    if (Object.prototype.hasOwnProperty.call(turn, field)) {
      throw new Error(
        `WakeHandler response carries driver-owned tape-time field ${JSON.stringify(field)} — the adapter is the only place wall time may EXIST, but tape-time accounting (the deliberation record, the latency-fold, ADR-0040) is the DRIVER's, minted once above the seam; a response owning it would drift a second per-adapter clock (refused fail-closed, CONTEXT.md "WakeHandler")`,
      );
    }
  }
  return turn;
}

/**
 * The **stable wake identity** one captured turn is keyed by (kestrel-5zl.19) — `<source>#<k>#<trigger>`:
 * the wake's {@link WakeSource}, its MONOTONE PER-SOURCE ordinal, and its trigger (the semantic
 * `wakeReason`). Never the global wake ordinal: the ordinal is a function of the WHOLE frontier, so any
 * source that inserts a vantage the recording never held (the seeded run's `own-fill` management wake,
 * ADR-0016 / kestrel-9gu.8.3) shifts every later ordinal and silently re-joins the recorded stream one
 * wake off. A per-source ordinal is NARROWER: another source raising a vantage does not, by itself, move
 * it. Carrying the trigger too makes a genuine divergence MISS (fail closed) rather than misattribute a
 * decision the agent made at some other vantage.
 *
 * `k` is minted where the wake is RAISED (kestrel-5zl.20) — on the `FrontierWake` at `pushFrontier` in
 * `./simulate.ts` — and carried through to the {@link ActingFrame}, so it counts RAISES of a source, NOT
 * deliveries. This closes the residual 5zl.19 only narrowed: delivery is a decision of the WHOLE frontier
 * (an inserted `own-fill` delivery advances `prevVantageTs` and the global delivered-budget counter, so it
 * can FOLD — by `minWakeSpacingMin` / `effectiveMaxWakes` — a wake the recording DID deliver), but the
 * folded wake already consumed its `k` at the raise, so the next same-source wake keeps its OWN raise
 * ordinal instead of inheriting the folded wake's. The key therefore no longer depends on whether a
 * delivery survived folding: a survivor never collides with a folded wake's recorded key, and the silent
 * misjoin that class produced is eliminated (not merely narrowed).
 */
export type WakeKey = string;

/**
 * One captured turn PLUS its deliberation cost (ADR-0040 / kestrel-w7la.1, clock-honest wakes §7):
 * ADR-0012 §4's "one value crosses the determinism line: the {@link AgentTurn}" becomes "the
 * `AgentTurn` plus its deliberation cost". `measuredMs` is minted ONCE, live-side, at the driver's
 * injectable `now` seam — integer ms of the Seat's WHOLE awaited turn (bounded authoring loop and
 * repair retries included); a synchronous adapter legitimately measures 0.
 *
 * `measuredMs` is ABSENT exactly when the run never measured — a latency-blind session, or a legacy /
 * externally-captured (run-dir) recording. Such a **costless** capture replays ONLY under
 * latency-blind semantics: replaying it with `clockHonest: true` is REFUSED loudly at open (a silent
 * cost-0 stand-in would let a latency-blind record claim a clock-honest result — the exact silent
 * downgrade §7 forbids). Absent-vs-0 is load-bearing: `0` is a measured colocated turn; absent is
 * "never measured".
 */
export interface CapturedTurn {
  readonly turn: AgentTurn;
  /** Measured wall ms of the Seat's whole turn at this vantage (integer ≥ 0). Absent ⇒ a costless
   * (latency-blind / legacy) capture — see above. */
  readonly measuredMs?: number;
}

/**
 * Captured turns keyed by stable {@link WakeKey} — the deterministic replay input. NOT stored as Kestrel
 * `print()` text: only an embedded `supersede` document is a Kestrel surface statement, so the
 * replay substrate serializes each Action's control/order envelope through the SAME sorted-key /
 * drop-undefined canonicalizer the Blotter and receipts use, while the embedded document keeps its
 * Kestrel `parse`/`print` round-trip. Replaying these re-emits identical Bus events at identical
 * wake ts (byte-identity, not re-computation). Each entry carries the turn AND — on a clocked run —
 * its recorded deliberation cost ({@link CapturedTurn}), so a clock-honest replay re-projects from
 * recorded time and never re-times (ADR-0040 §7).
 */
export type CapturedTurns = ReadonlyMap<WakeKey, CapturedTurn>;

/** The {@link CapturedTurns} replay key for the OPEN turn (`agent.open`) — the one delivery that is not a
 * wake, so it carries no {@link WakeSource} and can never collide with a {@link wakeKeyOf} key. */
export const OPEN_WAKE_KEY: WakeKey = "open";

/** The OPEN keyframe's wake ordinal (`n = -1`, the stepped runner's convention) — a LOG/trace coordinate
 * (the authoring trace's `ordinal`), no longer a {@link CapturedTurns} key. Replay keys on
 * {@link OPEN_WAKE_KEY} / {@link wakeKeyOf}. */
export const OPEN_ORDINAL = -1;

/** Derive a delivery's stable {@link WakeKey} — the ONE join both the Simulate driver (capture) and
 * {@link recordedAgent} (replay) key on, so a recorded turn and its wake can never drift apart. */
export function wakeKeyOf(frame: ActingFrame): WakeKey {
  return `${frame.wakeSource}#${frame.wakeSourceOrdinal}#${frame.wakeReason ?? ""}`;
}

/** The {@link AgentConfig} a NO-LLM deterministic adapter (recorded / fixed-plan) advertises: a Backtest,
 * not a model call. Names are data (ADR-0006) — `label` is the leaderboard key. Credentials are NEVER
 * config; a fixed-plan run pins no tokenizer/format because it renders nothing (frozen-at-wake). */
export const BACKTEST_CONFIG: AgentConfig = {
  model: "fixed-plan",
  tokenizer: "none",
  format: "json",
  temperature: 0,
  thinkingLevel: "none",
  label: "fixed-plan",
};

/**
 * The ENGINE-INSERTED vantage classes (kestrel-5zl.19) — the ONLY {@link WakeSource}s {@link recordedAgent}
 * may answer leniently (a logged empty pass) when the recording holds no wake from them at all. An
 * ALLOWLIST, deliberately, not "any source the recording lacks": leniency must be argued per source, so a
 * WakeSource added later inherits the fail-closed branch by default rather than the lenient one.
 *
 * `own-fill` is here because it is a pure function of the FILL MODEL, not of the market or the agent: a
 * seeded engine raises an own-fill management wake off its own simulated fill, so an unseeded recording
 * cannot hold one and its absence is evidence about the ENGINE, never about the agent or the tape.
 *
 * Every other source is excluded on its own argument, not by omission:
 *  - `agent`     — the agent's own scheduleWake. Its absence means the recorded agent scheduled differently:
 *                  a real divergence.
 *  - `seed` / `structural` — the driver's configured cadence, fixed by config + tape, identical across a
 *                  faithful replay. An absence means the CONFIG diverged, which is exactly what must fail
 *                  closed; it is NOT the own-fill argument (nothing about the fill model inserts one).
 *  - `staleness` — the backstop, the platform's fail-closed safety floor ("there is no Action that removes
 *                  this — the agent can never disable safety", `./simulate.ts`). The agent-path stand-down
 *                  is its ONLY de-arm, so answering an unrecorded staleness wake with a pass would let the
 *                  replay ride a book the backstop fired on. It must stand down.
 */
const INSERTED_VANTAGE_SOURCES: ReadonlySet<WakeSource> = new Set<WakeSource>(["own-fill"]);

/** The fail-closed turn: de-arm clean with a logged reason (inventory rides its TP/EXIT — never
 * liquidates). The recorded/fixed adapters return this rather than crash (RUNTIME §8, CLAUDE fail-closed). */
function standDownTurn(reason: string): AgentTurn {
  return mkStandDownTurn(reason);
}

// ─────────────────────────────────────────────────────────────────────────────
// The provider seam placeholder (owned by the Agent Harness epic)
// ─────────────────────────────────────────────────────────────────────────────

/** Token usage observed at the provider call — the on-record source of the attention-efficiency
 * axis (output + thinking spend, kept distinct from any offline estimate). `inputTokens` is the TOTAL
 * input the provider billed for (fresh + cache-read + cache-write); the two optional cache counters split
 * out the prompt-cache economics (kestrel-rul, consumed by m9i.5). All are provider-reported, off the graded
 * path — a mock/deterministic client omits the cache fields. */
export interface LlmUsage {
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly thinkingTokens: number;
  /** Input tokens served from the provider prompt cache (a cache HIT on the stable prefix) — the cheap
   * (~0.1×) tokens the `conversation-cached` policy trades full-price re-reads for. Absent ⇒ not reported. */
  readonly cacheReadTokens?: number;
  /** Input tokens WRITTEN into the provider prompt cache this call (the one-time cost of caching a newly
   * stable prefix segment, ~1.25× on Anthropic). Absent ⇒ not reported. */
  readonly cacheWriteTokens?: number;
}

/** One completion: the reply text, an optional raw reasoning trace (free evidence, never
 * re-summarized), and the provider-native usage. `wire` is the OPTIONAL scrubbed wire evidence
 * the concrete (AI-SDK) client captured — redacted of all credential material AT capture time
 * (ADR-0013 (e)); it rides ABOVE the determinism line as free audit evidence and never enters the
 * graded bus. A deterministic/mock client omits it. */
export interface LlmCompletion {
  readonly text: string;
  readonly reasoning?: string;
  readonly usage: LlmUsage;
  readonly wire?: string;
}

/** One turn in a session conversation: a `user` delta frame the harness rendered, or an `assistant`
 * reply the model authored on an earlier wake. The growing list of these is how a session's conversation
 * (KV) is reused across wakes — a byte-stable prefix a provider can prompt-cache (kestrel-rul). `cache`
 * marks a provider prompt-cache BREAKPOINT at (and including) this message: the AI-SDK client translates
 * it to the provider-native control (Anthropic `cache_control` ephemeral / Bedrock cache-point); a client
 * without cache support IGNORES it (conversation reuse alone still wins). Off the graded path. */
export interface LlmMessage {
  readonly role: "user" | "assistant";
  readonly content: string;
  readonly cache?: boolean;
}

/**
 * The narrow provider seam {@link liveAgent} calls. The concrete implementations (the multi-provider
 * AI-SDK client, bring-your-own-key credential injection, wire-evidence redaction, provider prompt-cache
 * control) are the Agent Harness epic's and sit strictly ABOVE the determinism line — the deterministic
 * core imports none of that.
 *
 * A `complete` call is stateless per-call; SESSION state lives in {@link liveAgent}, which passes the
 * growing conversation ({@link LlmMessage}[]) each wake so the provider can prompt-cache the byte-stable
 * prefix. `cacheSystem` asks the client to mark the (byte-stable) `system` prefix cacheable too; a client
 * without cache support ignores both cache signals (the conversation is still reused).
 */
export interface LlmClient {
  complete(req: {
    readonly system: string;
    /** Mark the byte-stable `system` prefix as a provider prompt-cache breakpoint (ignored by a client
     * without cache support). Set by the `conversation-cached` policy. */
    readonly cacheSystem?: boolean;
    /** The prompt-cache TTL every breakpoint this request marks should carry ({@link CacheTtl},
     * kestrel-wa0j.1). ABSENT ⇒ the provider default (byte-identical to before this knob existed).
     * UNLIKE the two boolean cache signals — which a cache-less client may ignore (reuse alone still
     * wins) — a client that cannot put this TTL on the wire MUST throw (fail-closed): the ConfigId
     * claims a TTL, and a silently-dropped one would be a config axis that lies (kestrel-gvx). */
    readonly cacheTtl?: CacheTtl;
    readonly messages: readonly LlmMessage[];
    readonly sampling: { readonly temperature: number; readonly thinkingLevel: AgentConfig["thinkingLevel"] };
  }): Promise<LlmCompletion>;
}

// ─────────────────────────────────────────────────────────────────────────────
// The four adapters (one seam)
// ─────────────────────────────────────────────────────────────────────────────

// The live model adapter — {@link liveAgent} — is the sole non-deterministic point, above the
// determinism line. Its concrete body lives in the Agent Harness module `./harness/live-agent.ts`
// (kestrel-rul), NOT here: it is a PURE function of an injected {@link LlmClient} (no provider SDK
// import), so this deterministic-core module — which `./simulate.ts` imports — stays provider-free
// (ADR-0013: "the deterministic core imports no provider SDK"). The BYOK AI-SDK `LlmClient` that
// actually touches the wire is `./harness/ai-sdk-client.ts`, which is the ONLY file importing the
// provider packages and is never pulled in by `./simulate.ts` or `src/index.ts`.

/**
 * Replay a prior run's captured turns byte-identically (no model call, no Rendering). Its contract
 * is a byte-identity claim: re-projecting the recorded run's graded Bus is byte-identical to the
 * original. This is what "re-runnable as a Backtest" concretely means.
 *
 * Deterministic substrate: the replay is keyed ONLY by the stable {@link WakeKey} ({@link OPEN_WAKE_KEY}
 * for `open`, {@link wakeKeyOf} for `decide`) — the rest of the delivered {@link ActingFrame} is IGNORED
 * (rendering is above the determinism line, RUNTIME §0). Synchronous — no model call.
 *
 * Two misses, two proportionate answers (kestrel-5zl.19 — the shakedown-2 finding). A replay may be driven
 * by an engine the recording never ran under (the canonical case: an UNSEEDED recording regraded under a
 * SEEDED engine, which raises `own-fill` management wakes the original session never delivered), so a
 * missing key is not one failure but two:
 *  - **An INSERTED vantage** — the wake's {@link WakeSource} is one of {@link INSERTED_VANTAGE_SOURCES} AND
 *    the recording holds no wake from it at all: that frontier did not exist when the agent decided. The
 *    agent made no decision here and cannot be made to have made one, so replay a LOGGED empty pass (a
 *    legitimate pass — the standing Plans keep managing, exactly as they did between the recorded
 *    vantages). Standing down instead would inject a de-arm the agent never authored and collapse the rest
 *    of the replay — the same trap `frozenStrategist` already holds armed for in `./harness/plan-fixture.ts`.
 *  - **EVERY other miss** — a source OUTSIDE the allowlist (`agent`/`seed`/`structural`/`staleness`), or an
 *    allowlisted source that IS in the recording but not at this wake: the recorded stream genuinely
 *    diverged. That fails closed to a {@link standDownTurn} (never a crash, never a silent pass). A
 *    faithful replay of a driver-captured run never hits it, because the driver captures every turn it
 *    consumed (incl. empty passes) under this same key.
 *
 * The leniency is an ALLOWLIST, never a set-membership miss ({@link INSERTED_VANTAGE_SOURCES}), so a
 * WakeSource added later cannot silently inherit it — most of all `staleness`, whose backstop is the
 * platform's fail-closed safety floor (no Action removes it; the agent-path stand-down is the only de-arm).
 * An unrecorded `staleness` wake is a DIVERGENCE and stands down, never an empty pass.
 *
 * The discriminator needs EVIDENCE, so an OPEN-only recording (no wake turns at all — the frozen-plan
 * fixture of `./harness/plan-fixture.ts`) cannot tell the two apart for any wake, and every wake fails
 * closed to a stand-down exactly as before: ambiguity resolves to the fail-closed branch, never the
 * lenient one.
 */
export function recordedAgent(config: AgentConfig, turns: CapturedTurns): Agent {
  /** The sources the recording actually holds a decision for — the inserted-vs-gap discriminator, read
   * off the keys themselves (no second record to keep in sync). */
  const recordedSources = new Set<string>();
  for (const key of turns.keys()) {
    if (key !== OPEN_WAKE_KEY) recordedSources.add(key.slice(0, key.indexOf("#")));
  }
  return {
    config,
    open(): AgentTurn {
      return (
        turns.get(OPEN_WAKE_KEY)?.turn ?? standDownTurn(`recordedAgent: no captured OPEN turn (key ${OPEN_WAKE_KEY})`)
      );
    },
    decide(frame: ActingFrame): AgentTurn {
      const key = wakeKeyOf(frame);
      const turn = turns.get(key)?.turn;
      if (turn !== undefined) return turn;
      // The lenient branch is scoped to the ENGINE-INSERTED vantage classes and nothing else: an unrecorded
      // wake from any other source (`staleness` above all) is a divergence and falls through to the
      // stand-down below.
      if (
        recordedSources.size > 0 &&
        INSERTED_VANTAGE_SOURCES.has(frame.wakeSource) &&
        !recordedSources.has(frame.wakeSource)
      ) {
        return {
          actions: [],
          journal: `recordedAgent: no recorded '${frame.wakeSource}' vantage — this engine inserted wake ${key}; standing book rides (logged pass, kestrel-5zl.19)`,
        };
      }
      return standDownTurn(`recordedAgent: no captured turn for wake ${key}`);
    },
    // The replay-mode cost source (ADR-0040 §7): recorded cost for a captured Seat turn; `null` for a
    // key the capture never held (the synthesized-turn classes above — cost 0, no buffer, no record);
    // `undefined` for a captured-but-costless entry (a latency-blind/legacy capture — the driver
    // refuses it at open under clockHonest, never a silent cost-0 stand-in).
    recordedCostMs(key: WakeKey): number | null | undefined {
      const entry = turns.get(key);
      if (entry === undefined) return null;
      return entry.measuredMs;
    },
  };
}

/**
 * Arm one document at `open` and pass forever = a Backtest expressed through the seam (the
 * degenerate case where discretionary and static coincide). Synchronous and deterministic — the
 * tracer-bullet stub agent.
 *
 * The `open` turn is a single {@link SupersedeAction} carrying `document` (the driver's initial arm);
 * every subsequent `decide` returns an empty `actions[]` (a legitimate pass — the standing Plans keep
 * managing autonomously between wakes). Fail-closed (RUNTIME §8): the document parse is pre-validated
 * ONCE at construction, and a parse escape turns the `open` turn into a {@link StandDownAction} — the
 * agent can never hand the driver an unparseable document, and can never crash the Session.
 */
export function fixedPlanAgent(document: string, config: AgentConfig = BACKTEST_CONFIG): Agent {
  let parseError: string | null = null;
  try {
    parse(document); // pre-validate the Kestrel surface (`print(parse(text))` round-trips, ADR-0001)
  } catch (e) {
    parseError = e instanceof Error ? e.message : String(e);
  }
  const openTurn: AgentTurn =
    parseError === null
      ? { actions: [{ kind: "supersede", document }] }
      : standDownTurn(`fixedPlanAgent: document parse escape — ${parseError}`);
  return {
    config,
    open(): AgentTurn {
      return openTurn;
    },
    decide(): AgentTurn {
      return { actions: [] }; // pass forever — the standing document is the whole strategy
    },
  };
}

// The file-handshake adapter — the versioned, identity-stamped seam every external CLI harness
// (Claude Code / Codex / OpenCode) drives through — is PROMOTED to `./harness/file-handshake.ts`
// (kestrel-m9i.7 stage-0), NOT here: it imports `node:fs` (frames/turns are files) and stamps a
// harness identity, both of which belong ABOVE the deterministic core this module defines. Import
// `fileHandshakeAgent` from `./harness/file-handshake.ts` (or `../index.ts`). The seam it satisfies
// is the SAME {@link Agent} — `open(briefing)/decide(frame)` — so the driver never learns which
// brain answered; only the returned {@link AgentTurn} crosses the determinism boundary.
