/**
 * # session/harness/live-agent — the BYOK live {@link Agent} adapter (provider-free, kestrel-rul)
 *
 * The concrete body of `liveAgent` (declared, provider-free, in `../agent.ts`). It satisfies the
 * ONE {@link Agent} seam — `open(briefing) / decide(frame)` — by composing the pure prompt profile
 * and the fail-closed turn parser (`./prompt.ts`) around an INJECTED {@link LlmClient}. It imports
 * NO provider SDK: the wire lives only inside whatever `LlmClient` the caller supplies (the AI-SDK
 * client in `./ai-sdk-client.ts`, or a mock in tests), so the deterministic core — which
 * `../simulate.ts` imports — stays provider-free (ADR-0013 (c), the invariant across both drivers).
 *
 * ## Determinism boundary (ADR-0013, RUNTIME §0)
 * The live model call is the sole non-deterministic act, ABOVE the determinism line. Below it, only
 * the returned {@link AgentTurn} crosses into the graded path — and because the driver captures each
 * returned turn into {@link CapturedTurns}, {@link recordedAgent} replays the SAME turns and
 * re-derives a byte-identical graded Bus (dual-harness fidelity). The per-turn wire evidence
 * (model id, usage, reasoning, latency, scrubbed bytes) is captured OFF that path into an optional
 * {@link TurnCapture} sink — free audit evidence for the leaderboard's attention axis, never a graded input.
 *
 * ## Session conversation (KV) reuse (kestrel-rul, the cache-monotone doctrine)
 * Under the conversation policies ({@link CachePolicy}, default `conversation-cached`) the session is a
 * GROWING message list — byte-stable `system` + the OPEN briefing + each wake's appended delta frame + the
 * model's prior replies — passed whole to the {@link LlmClient} each wake, so a provider can prompt-cache the
 * stable prefix (Anthropic `cache_control` / Bedrock cache-point via the AI SDK) and bill the re-read prefix
 * at ~0.1× instead of full price on every wake. This all sits ABOVE the determinism line: only the returned
 * {@link AgentTurn} crosses into the graded path, so {@link recordedAgent} replays byte-identically no matter
 * the policy. `stateless-redraw` keeps the v0 arm (each wake one fresh message, no memory) for the m9i.5 A/B.
 *
 * ## Config envelope (ADR-0013 (d))
 * `liveAgent` derives `AuthorPolicy.prompt_sha256` from the EXACT system-prompt bytes and stamps it
 * onto the config it advertises, so the run lands in the grid under a `ConfigId` that folds the
 * prompt profile — any prompt edit mints a new column. The active {@link CachePolicy} is stamped too (a
 * ConfigId axis only, never an envelope field). Credentials are NEVER part of the config.
 */

import {
  OPEN_ORDINAL,
  cacheTtlNeverReachesWire,
  type ActingFrame,
  type Agent,
  type AgentConfig,
  type AgentTurn,
  type AuthoringOpenOptions,
  type AuthoringStep,
  type CachePolicy,
  type LlmClient,
  type LlmCompletion,
  type LlmMessage,
  type LlmUsage,
  providerSupportsCacheTtl,
} from "../agent.ts";
import type { BriefingInput, Kernel } from "../../frame/types.ts";
import type { ViewSelection } from "../../frame/pane-catalog.ts";
import type { SeatId } from "../../frame/seat.ts";
import { sha256 } from "../../crypto/sha256.ts";
import { checkBoundedNumerics } from "./bounded-numerics.ts";
import {
  DEFAULT_PROMPT_PROFILE,
  VIEWSHOP_PROMPT_PROFILE,
  systemPromptForProfile,
  parseTurn,
  parseAuthoringReply,
  renderBriefingPrompt,
  renderRepairPrompt,
  renderReprompt,
  renderWakePrompt,
  REPROMPT_WITHHELD,
  type TurnReject,
} from "./prompt.ts";
import { scanDateLeak } from "../day.ts";

/** The classification of one turn's outcome — the m9i three-way distinction, kept explicit on the
 * capture record so a leaderboard never conflates an authored stand-down, an invalid reply, and a
 * provider failure (ADR-0013 (a)). `authored` covers any validated turn (incl. an explicit standDown). */
export type TurnOutcome = "authored" | "invalid" | "provider-error";

/** Per-turn wire evidence captured ABOVE the determinism line (never a graded input): the model
 * identity, provider-native usage, the raw reasoning trace (free evidence), the authored journal,
 * measured latency, and the SCRUBBED wire bytes. Recorded so a run's cost/attention axis and audit
 * trail survive without re-touching the wire; the graded replay rides {@link CapturedTurns} instead. */
export interface TurnCapture {
  /** {@link OPEN_ORDINAL} for the open turn, else the wake ordinal. */
  readonly ordinal: number;
  readonly model: string;
  readonly promptProfile: string;
  readonly usage: LlmUsage;
  readonly reasoning?: string;
  readonly journal?: string;
  /** Wall-clock latency of the model call in ms — audit evidence only (OFF the record path). */
  readonly latencyMs: number;
  /** The scrubbed wire evidence the client captured (`undefined` for a mock without wire). */
  readonly wire?: string;
  readonly outcome: TurnOutcome;
  /** The KINDS of actions this turn EMITTED (`action.kind[]`, e.g. `["placeOrder"]`; `[]` for a PASS or a
   * fail-closed/provider-error turn). Recorded so a leaderboard can tell a genuine PASS (empty actions)
   * apart from an admitted-but-unfilled order or a held supersede WITHOUT inferring participation from P&L
   * alone (kestrel harness-observability, Bug 2). Above the determinism line — audit evidence, never graded. */
  readonly actionKinds: readonly string[];
  /** The count of emitted actions (`= actionKinds.length`) — a convenience aggregate for run summaries. */
  readonly actionCount: number;
  /** Present when the reply was invalid — the fail-closed reason journalled onto the pass. */
  readonly invalidReason?: string;
  /** The EXACT user-message the model saw this turn (the effective wake/briefing message, incl. any
   * reject-reprompt prefix). Populated on the decide path so a distillation harvest can reconstruct the
   * (prompt → gold action) SFT pair faithfully, without re-deriving the frame. OFF the graded path — audit
   * evidence only, never a graded input (the graded replay rides {@link CapturedTurns}). */
  readonly prompt?: string;
  /** The RAW model reply text (`completion.text`) this turn — the frontier watcher's verbatim emission, the
   * distillation TARGET for SFT. OFF the graded path — audit evidence only. */
  readonly reply?: string;
  /** The {@link CachePolicy} this turn ran under — the axis m9i.5's prompt-cache economics are indexed by
   * (so a sweep can compare `cacheReadTokens`/`cacheWriteTokens` across policies on identical Frames). */
  readonly cachePolicy: CachePolicy;
  /** CACHE-LIVENESS LOST (kestrel-wa0j.1): under `conversation-cached` with a reused prefix, the provider
   * reported `cacheReadTokens === 0` on TWO OR MORE consecutive reused turns ending at this one — the stable
   * prefix is provably NOT being served from cache (a TTL expiry between wakes, or a silent prefix
   * invalidator), so every wake is re-writing the whole prefix at the cache-write premium. Recorded loudly
   * on the capture (and warned once per session) — never a silent economics regression. Absent on healthy
   * turns, on the first (cold) miss, and when the client reports no cache counters (no evidence ≠ a miss). */
  readonly cacheLivenessLost?: true;
}

/** Options for {@link liveAgent}. `capture` is a sink array the adapter APPENDS one record per turn
 * to (open + each decide); `promptProfile` labels the active variant set (baseline vs a model
 * variant) for the leaderboard, and `now` is an injectable clock for latency capture (kept OFF the
 * graded path — defaults to `Date.now`, which is legitimate here because latency is wire evidence,
 * never a graded input). */
export interface LiveAgentOptions {
  readonly capture?: TurnCapture[];
  readonly promptProfile?: string;
  readonly now?: () => number;
  /** A FORCED View (kestrel-4gl.13.13) — SELECTS + ORDERS the body panes rendered to the model at
   * every OPEN + WAKE frame, overriding the frame-kind default. Supplied for the **perception arm** of
   * a View A/B (e.g. `{ name:"perception", panes:[…,{name:"gex"},{name:"iv-skew"}] }`) so the agent is
   * SHOWN the options-analytics panes without having to author a View itself. Absent ⇒ the frame-kind
   * default panes (the minimal arm), byte-identical to before. Distinct from the viewshop authoring
   * loop (where the MODEL requests the View); this pins it by config. */
  readonly forcedView?: ViewSelection;
}

function usageOf(completion: LlmCompletion): LlmUsage {
  return completion.usage;
}

/** The adapter/harness identity `liveAgent` advertises when the caller pins none (m9i.2 owner tweak —
 * adapter identity is mandatory-for-certified before the m9i.7 harness tournament). This is the harness
 * naming ITSELF — the one identity it genuinely knows — never a fabricated third-party harness. */
export const LIVE_AGENT_ADAPTER = "kestrel-live-agent";

/** The adapter/harness revision token paired with {@link LIVE_AGENT_ADAPTER}. Bump on any behavioral
 * change to this adapter (prompt loop, parsing, capture) — it folds into the ConfigId, so a bump mints a
 * new grid column rather than contaminating an old one (ADR-0013 (d)). `v2` (kestrel-wa0j.19 §5): the
 * percept-v5 merge changed BOTH the prompt loop (a scheduled/forced View is now delivered on the WAKE
 * frame, not only OPEN) AND the capture schema (the `cacheLivenessLost` flag), and the delivery render is
 * now fail-closed to the default WAKE panes on a materialization refusal — behavioral changes to what the
 * model perceives, so they must mint a NEW grid column rather than contaminate the v1 column (the
 * file-handshake precedent in the same merge bumped v1→v2 for exactly this class of change). */
export const LIVE_AGENT_ADAPTER_VERSION = "v2";

/** The default {@link CachePolicy} the live harness runs under when the caller pins none: reuse the
 * session conversation AND request provider prompt-cache control on the byte-stable prefix (kestrel-rul —
 * the cost lever for benchmark sweeps). A caller can pin `stateless-redraw` (the v0 arm) or `conversation`
 * (reuse, no provider cache) to sweep the economics (m9i.5). */
export const DEFAULT_CACHE_POLICY: CachePolicy = "conversation-cached";

/**
 * Build the BYOK live {@link Agent} over an injected {@link LlmClient}. Provider-free: the wire is
 * entirely the client's concern. The returned agent's `config` carries the derived
 * `promptHash = sha256(system bytes)` (unless the caller pinned one) and this harness's own
 * adapter identity ({@link LIVE_AGENT_ADAPTER}/{@link LIVE_AGENT_ADAPTER_VERSION}, unless the caller
 * pinned one — m9i.2), so the Simulate driver stamps a complete-enough a57.14 envelope and the run lands
 * in the grid under a prompt-profile- and adapter-aware `ConfigId`. The interface `face` is deliberately
 * NOT defaulted here: only the caller knows the transport its injected client rides (`sdk` for the
 * AI-SDK client), and a fabricated face would be a fabricated identity — an undeclared face fails the
 * envelope closed to PROVISIONAL (a57.14).
 */
export function liveAgent(config: AgentConfig, llm: LlmClient, opts: LiveAgentOptions = {}): Agent {
  const now = opts.now ?? Date.now;
  // The active profile selects the byte-stable system prompt: the caller may pin `baseline-v0` (the
  // A/B control) or `authoring-v1` (the dry-run-1 authoring fix, the default). The profile label is
  // BOTH the selector and the capture label — the bytes it names are the graded config identity
  // (`prompt_sha256`), so an unknown label fails closed rather than silently using a default.
  const promptProfile = opts.promptProfile ?? DEFAULT_PROMPT_PROFILE;
  const system = systemPromptForProfile(promptProfile);
  const promptHash = config.promptHash ?? sha256(system);
  const adapter = config.adapter ?? LIVE_AGENT_ADAPTER;
  const adapterVersion = config.adapterVersion ?? LIVE_AGENT_ADAPTER_VERSION;
  const cachePolicy = config.cachePolicy ?? DEFAULT_CACHE_POLICY;
  // Stamp the prompt hash + this harness's own adapter identity + the cache policy onto the advertised
  // config so envelopeForConfig() emits AuthorPolicy.prompt_sha256 / adapter / adapter_version and the
  // ConfigId folds the prompt profile, the harness, AND the cache policy (ADR-0013 (d); m9i.2 owner tweak;
  // kestrel-rul). `cachePolicy` is a ConfigId axis only — NOT an envelope field (caching is transport-side,
  // off the graded path), so two policies grade identically yet land in distinct columns. Credentials are
  // NEVER part of the config.
  const fullConfig: AgentConfig = { ...config, promptHash, adapter, adapterVersion, cachePolicy };

  const sampling = { temperature: config.temperature, thinkingLevel: config.thinkingLevel } as const;

  // ── Session conversation (KV) reuse ────────────────────────────────────────────────────────────────
  // In the conversation policies the session is a GROWING message list (byte-stable system + briefing +
  // each wake's appended delta frame + the model's prior replies), so a provider can prompt-cache the stable
  // prefix (kestrel-rul, the cache-monotone doctrine). This lives ABOVE the determinism line — only the
  // returned AgentTurn crosses into the graded path, so recordedAgent replays byte-identically regardless of
  // caching. `stateless-redraw` keeps the v0 behaviour (each wake is one fresh message, no memory). The
  // transcript is committed ONLY on a successful completion, so a provider error leaves it alternating +
  // consistent (the next wake appends cleanly, never two user turns in a row).
  const reuse = cachePolicy !== "stateless-redraw";
  const marksCache = cachePolicy === "conversation-cached";
  // FAIL-CLOSED (kestrel-wa0j.1): a cacheTtl only has meaning on the policy that marks cache breakpoints.
  // On any other policy it would ride the ConfigId as a distinct column while never reaching the wire — an
  // authored knob silently inert, exactly what the fail-closed doctrine forbids. Refuse at construction.
  if (config.cacheTtl !== undefined && !marksCache) {
    throw new Error(
      cacheTtlNeverReachesWire(
        config.cacheTtl,
        `cachePolicy "${cachePolicy}" requests no provider cache breakpoints`,
        `Use cachePolicy "conversation-cached" or drop cacheTtl.`,
      ),
    );
  }
  // FAIL-CLOSED at CONSTRUCTION (kestrel-wa0j.19 §4): a cacheTtl only reaches the wire on a provider whose
  // lane exposes an explicit prompt-cache breakpoint (anthropic/bedrock). The lane KNOWS its provider
  // (`config.provider`), so a `cacheTtl × non-cache-provider` column is refused HERE — before a whole
  // session of provider-error passes burns on a TTL the wire cannot honor. The AI-SDK wire guard
  // (`cacheBreakpoint`) keeps the same refusal as defense in depth (the two can never drift). A blank/
  // unset provider is NOT refused here (a mock/test may pin none) — the wire guard is its backstop.
  if (config.cacheTtl !== undefined && config.provider !== undefined && config.provider !== "" && !providerSupportsCacheTtl(config.provider)) {
    throw new Error(
      `cacheTtl "${config.cacheTtl}" is configured but provider "${config.provider}" has no explicit ` +
        `prompt-cache breakpoint to carry a TTL — refusing to construct a live agent whose configured TTL ` +
        `can never reach the wire (fail-closed at construction, kestrel-wa0j.19 §4). Use provider ` +
        `"anthropic" or "bedrock", or drop cacheTtl.`,
    );
  }
  // ── Cache-liveness surfacing (kestrel-wa0j.1 / kestrel-wa0j.19 §3) ──────────────────────────────────
  // Under `conversation-cached` every reused turn's stable prefix SHOULD come back as cache-read tokens.
  // `cacheReadTokens === 0` on two consecutive reused turns means the prefix is provably NOT served from
  // cache — but WHY it is dead is diagnosed from the WRITE counter, so the operator is told the RIGHT fix:
  //   • reads===0 with WRITES>0 (reported) — the prefix is being CACHED but never re-read: the entry
  //     expired between wakes (a live-paced session outrunning the 5m TTL) and is re-written every wake at
  //     the cache-write premium. Actionable: `cacheTtl "1h"` where the lane supports it.
  //   • reads===0 AND WRITES===0 (both reported) — the prefix is NEVER cached at all (below the model's
  //     minimum cacheable prefix, or a lane that does not prompt-cache): a `1h` TTL would NOT help (and on
  //     a non-cache lane would THROW at construction, §4), so the warning names the cause WITHOUT the 1h
  //     recommendation.
  // A client that reports NO read counter (`cacheReadTokens` undefined) is no evidence either way — the
  // streak is NOT advanced and nothing is flagged (a no-counter lane can never false-flag, §3). Recorded
  // loudly: a `cacheLivenessLost` flag on the TurnCapture from the second consecutive miss on, plus ONE
  // console.warn per session (the visible-degrade idiom of src/render/tokens.ts).
  let cacheColdStreak = 0;
  let cacheLivenessWarned = false;
  const cacheLiveness = (prefixReused: boolean, usage: LlmUsage): { readonly cacheLivenessLost: true } | Record<never, never> => {
    if (!marksCache || !prefixReused) return {};
    const read = usage.cacheReadTokens;
    if (read === undefined) return {}; // read counter not reported — never flag off missing evidence (§3)
    cacheColdStreak = read === 0 ? cacheColdStreak + 1 : 0;
    if (cacheColdStreak < 2) return {};
    if (!cacheLivenessWarned) {
      cacheLivenessWarned = true;
      const write = usage.cacheWriteTokens;
      // NEVER-CACHED only when the write counter is REPORTED as 0 (an unreported write is ambiguous — do
      // not claim never-cached off missing evidence; treat it as the actionable TTL-expiry diagnosis).
      const neverCached = write === 0;
      console.warn(
        neverCached
          ? `prompt-cache liveness lost: cacheReadTokens=0 AND cacheWriteTokens=0 on ${cacheColdStreak} ` +
              `consecutive reused turns under "${cachePolicy}" — the stable prefix is NEVER being cached ` +
              `(below the model's minimum cacheable prefix, or this lane does not prompt-cache), so every ` +
              `wake pays full input price. A cacheTtl "1h" would NOT help here (kestrel-wa0j.19 §3).`
          : `prompt-cache liveness lost: cacheReadTokens=0 on ${cacheColdStreak} consecutive reused turns ` +
              `under "${cachePolicy}" — the stable prefix is being re-written every turn but never re-read: ` +
              `the cache entry expired between wakes (the 5m TTL outrun by a live-paced session), so every ` +
              `wake re-writes the whole prefix at the cache-write premium. Consider cacheTtl "1h" where the ` +
              `lane supports it (kestrel-wa0j.1).`,
      );
    }
    return { cacheLivenessLost: true };
  };
  // The bounded authoring loop (ADR-0029) is CONFIG-GATED on the prompt profile: only the viewshop-v1
  // arm advertises the requestView move + the in-window re-entry. The baseline profiles genuinely lack
  // it (an emitted requestView parses to the fail-closed `invalid` outcome), so the controlled division
  // cannot stumble into the move. Presence of `authoringOpen` in the returned Agent IS the driver's gate.
  const viewShop = promptProfile === VIEWSHOP_PROMPT_PROFILE;
  // ── Founder-View opt-in (ADR-0041 §2 / A1, kestrel-wa0j.26) ──────────────────────────────────────
  // The ACTING TIER's pod seat is a function of the vantage — the ADR-0032 cascade the open/decide
  // split already embodies: `open()` is the STRATEGIST (the frontier framer authoring at the open),
  // `decide()` is the WATCHER (the fast reflex managing at each wake). Under the `founder` opt-in the
  // driver passes that seat into the render so a seat with a founder seed reads its GRADED founder View
  // when it authored none (precedence in resolveView: authored View > seat founder View > phase default).
  // OFF by default: `phase-default`/absent ⇒ NO seat is passed ⇒ byte-identical to before this axis
  // existed (the no-churn law). Founder Views are seeds, never blessed defaults — the opt-in is the gate.
  const founderSeats = fullConfig.seatViews === "founder";
  const openSeat: SeatId | undefined = founderSeats ? "strategist" : undefined;
  const wakeSeat: SeatId | undefined = founderSeats ? "watcher" : undefined;
  const transcript: LlmMessage[] = [];
  // The prior frame's kernel — the one the reader provably holds in cached context under a streaming
  // (reuse) policy. Threaded into renderWakePrompt so the SAFETY/CONTROL kernel can be DELTA-encoded
  // (kestrel-312): only the fields that MOVED since this kernel are re-emitted; the byte-stable
  // skeleton rides the cached prefix. Set on OPEN (the briefing kernel) and after each wake. It is a
  // pure RENDERING/transport concern above the determinism line — the graded Bus is unchanged, and
  // recordedAgent replays byte-identically regardless of how the wake prompt was framed.
  let priorKernel: Kernel | undefined;

  // ── Reject-and-reprompt state (kestrel-gxz) ──────────────────────────────────────────────────────
  // When an authored turn fails closed (ATOMIC — a valid sibling never arms alone), we carry its
  // per-action reason forward and PREFIX the NEXT wake's user message with it, so the model self-
  // corrects rather than concluding "execution is broken in this kernel". Set iff the LAST parsed turn
  // failed closed; CONSUMED only on a SUCCESSFUL parse — a provider error (where the model never saw
  // the frame) leaves it intact for the wake after. This lives ABOVE the determinism line: it changes
  // only the model's INPUT, never the returned AgentTurn, so recordedAgent replays byte-identically.
  let pendingReject: { readonly reason: string; readonly reject?: TurnReject } | undefined;

  // ── Shared request assembly + capture builders (kestrel-wa0j review cleanup) ────────────────────
  /** Assemble ONE wire call's request from the (possibly reused) transcript + this turn's user
   * message — the SINGLE source for both the decide path ({@link runTurn}) and the authoring path
   * ({@link runAuthoringCall}). Conversation policies prepend the growing transcript; the newest
   * (user) block carries the moving cache breakpoint so the whole stable prefix is a cache-read
   * next wake. The request object's key set and insertion order are PINNED by the request-shape
   * tests (`cacheTtl` is ABSENT — not `undefined` — when unconfigured), so it is built here once,
   * byte-identically for both paths. `prior` is the transcript view the once-per-completion
   * cache-liveness accounting reads (non-empty ⇒ a reused prefix). */
  const buildRequest = (
    userMsg: LlmMessage,
  ): { readonly prior: readonly LlmMessage[]; readonly request: Parameters<LlmClient["complete"]>[0] } => {
    const prior = reuse ? transcript : [];
    const messages: LlmMessage[] = marksCache ? [...prior, { ...userMsg, cache: true }] : [...prior, userMsg];
    return {
      prior,
      request: {
        system,
        cacheSystem: marksCache,
        ...(config.cacheTtl !== undefined ? { cacheTtl: config.cacheTtl } : {}),
        messages,
        sampling,
      },
    };
  };

  /** The identity + wire-evidence head every post-completion {@link TurnCapture} shares: who ran
   * (ordinal, model, prompt profile), the provider-native usage, the optional reasoning/journal
   * evidence, measured latency, and the scrubbed wire bytes. Spread FIRST at each capture site —
   * the per-site outcome fields follow, so the record's key order is unchanged. Optional fields
   * stay ABSENT (never `undefined`) exactly as the inline spreads behaved. */
  const completionEvidence = (
    ordinal: number,
    completion: LlmCompletion,
    journal: string | undefined,
    latencyMs: number,
  ) => ({
    ordinal,
    model: config.model,
    promptProfile,
    usage: usageOf(completion),
    ...(completion.reasoning !== undefined ? { reasoning: completion.reasoning } : {}),
    ...(journal !== undefined ? { journal } : {}),
    latencyMs,
    ...(completion.wire !== undefined ? { wire: completion.wire } : {}),
  });

  /** The fail-closed provider-error capture (the THIRD m9i outcome) — identical on the decide and
   * authoring paths but for the ordinal: zero usage (the wire never answered), the harness journal,
   * and never a fabricated action. A provider ERROR means the model never answered — the cache
   * counters are OMITTED (absent), NEVER fabricated as 0 (kestrel-wa0j.19 §3): a reported 0 is
   * evidence of a miss, absent is "not reported", and the liveness detector must not read a
   * fabricated miss off a call that never reached a cache at all. */
  const providerErrorCapture = (ordinal: number, reason: string, journal: string, latencyMs: number): TurnCapture => ({
    ordinal,
    model: config.model,
    promptProfile,
    usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
    journal,
    latencyMs,
    outcome: "provider-error",
    actionKinds: [],
    actionCount: 0,
    invalidReason: reason,
    cachePolicy,
  });

  const runTurn = async (ordinal: number, userMessage: string): Promise<AgentTurn> => {
    const t0 = now();
    // If the prior authored turn failed closed, PREFIX its per-action reason onto this wake's frame.
    // The rendered reason is re-fenced through the SAME date-blind guard as the author boundary
    // (scanDateLeak): a leaking model-authored token (e.g. an OCC symbol carrying a compact date) makes
    // the whole reprompt collapse to the byte-stable REPROMPT_WITHHELD fallback — fail-closed by
    // redaction, never a crash. (The reject is CONSUMED below only on a successful parse.)
    let effectiveMessage = userMessage;
    if (pendingReject !== undefined) {
      const body = renderReprompt(pendingReject.reason, pendingReject.reject);
      const safe = scanDateLeak(body) === null ? body : REPROMPT_WITHHELD;
      effectiveMessage = `${safe}\n\n${userMessage}`;
    }
    // Assemble THIS call's message list + request through the ONE shared builder (`buildRequest`).
    const userMsg: LlmMessage = { role: "user", content: effectiveMessage };
    const { prior, request } = buildRequest(userMsg);

    let completion: LlmCompletion;
    try {
      completion = await llm.complete(request);
    } catch (e) {
      // Provider failure — fail-closed to a pass carrying the reason, DISTINCT from an invalid reply
      // and from an authored standDown (m9i). Never crash the session, never fabricate an action. The
      // transcript is left UNTOUCHED (the unanswered delta is not committed, so roles stay alternating).
      const reason = `provider error — ${e instanceof Error ? e.message : String(e)}`;
      const journal = `harness: ${reason}`;
      const turn: AgentTurn = { actions: [], journal };
      opts.capture?.push(providerErrorCapture(ordinal, reason, journal, now() - t0));
      return turn;
    }
    const latencyMs = now() - t0;
    // Advance the cache-liveness streak exactly ONCE per successful completion (a reused prefix is one
    // whose transcript was non-empty at request assembly — the OPEN/cold turn never counts).
    const liveness = cacheLiveness(prior.length > 0, usageOf(completion));
    // EMPTY COMPLETION (kestrel-sgwd / kestrel-ubjc — Bug 1 corrected). A model can wake and author NO text —
    // a whitespace-only / content-free completion (Sonnet under conversation-cached fanning does this at its
    // first wake). This path separates the TWO concerns the original hygiene fix conflated:
    //   (a) DON'T POISON THE REUSED TRANSCRIPT — an empty assistant block makes EVERY later wake fail closed at
    //       Bedrock (HTTP 400 "The content field in the Message object … is empty"), a DETERMINISTIC validation
    //       error backoff will not retry, so ONE empty completion poisons the whole rest of the session. We
    //       leave the transcript UNTOUCHED (exactly like the provider-error path above), so roles stay
    //       alternating and no empty block ever rides the cached prefix. This is CORRECT and is KEPT.
    //   (b) DON'T SCORE A FAILURE AS A DECISION — a zero-token / whitespace-only reply is a GENERATION FAILURE,
    //       NOT a judicious stand-down. It classifies INVALID (fail-closed), NEVER a scored PASS, mirroring the
    //       hosted adapter's `invalid-empty` (scripts/bench/hosted/hosted-adapter.ts, classifyEmission) so the
    //       local + hosted conventions match. It flows into the SAME reject-and-reprompt path a parse-invalid
    //       turn does (pendingReject is SET, not cleared), so the NEXT wake reprompts rather than silently
    //       banking a stand-down. The returned turn stays fail-closed (empty actions — never a fabricated
    //       action). Off the graded path — the returned empty turn is what CapturedTurns replays.
    if (completion.text.trim() === "") {
      const reason = "empty/zero-token model completion — classified INVALID, never a scored PASS";
      const journal = `harness: ${reason}`;
      pendingReject = { reason };
      const turn: AgentTurn = { actions: [], journal };
      opts.capture?.push({
        ...completionEvidence(ordinal, completion, journal, latencyMs),
        outcome: "invalid",
        actionKinds: [],
        actionCount: 0,
        invalidReason: reason,
        cachePolicy,
        ...liveness,
      });
      return turn;
    }
    // BOUNDED-NUMERICS (kestrel gateway-cache-fix — ported from the hosted adapter's `invalid-degenerate`,
    // scripts/bench/hosted/hosted-adapter.ts `checkBoundedNumerics`). A strict schema / constrained-decoding
    // grammar can force a syntactically-VALID but DEGENERATE number (a json_schema `qty:integer` probe looped
    // to an 8,173-digit integer: valid JSON, garbage). Such an emission is a GENERATION FAILURE — it
    // classifies INVALID (fail-closed), NEVER a scored PASS — the SAME "reject unknown input, never default
    // it" rule the empty→INVALID guard above enforces, so the local + hosted conventions match. It runs
    // BEFORE parseTurn so a digit-loop can never slip through as a FINITE-but-absurd float (`Number.isFinite`
    // is true for a 15-digit run coerced by JSON.parse) and be scored as a real order. It flows into the SAME
    // reject-and-reprompt path a parse-invalid turn does (pendingReject SET), returns a fail-closed
    // empty-actions turn (never a fabricated action), and — the reply being NON-empty — commits the raw reply
    // to the reused transcript exactly as the parse-invalid path below does (so the cached prefix still
    // matches what the provider saw; unlike the empty guard, which must leave it untouched).
    const numeric = checkBoundedNumerics(completion.text);
    if (!numeric.ok) {
      const reason = numeric.reason ?? "degenerate numeric emission — classified INVALID, never a scored PASS";
      const journal = `harness: ${reason}`;
      pendingReject = { reason };
      if (reuse) transcript.push(userMsg, { role: "assistant", content: completion.text });
      const turn: AgentTurn = { actions: [], journal };
      opts.capture?.push({
        ...completionEvidence(ordinal, completion, journal, latencyMs),
        prompt: effectiveMessage,
        reply: completion.text,
        outcome: "invalid",
        actionKinds: [],
        actionCount: 0,
        invalidReason: reason,
        cachePolicy,
        ...liveness,
      });
      return turn;
    }
    const parsed = parseTurn(completion.text);
    // Reject-and-reprompt bookkeeping (kestrel-gxz): a SUCCESSFUL parse CONSUMES any pending reject —
    // if THIS turn also failed closed we carry its OWN per-action reason forward, else we clear it. The
    // provider-error path (the catch above) returns early WITHOUT touching pendingReject, so a correction
    // the model never saw survives to the next wake. Only the model's next INPUT changes — the returned
    // AgentTurn (the graded output) is untouched, so recordedAgent replays byte-identically.
    pendingReject = parsed.ok
      ? undefined
      : { reason: parsed.reason ?? "invalid turn", ...(parsed.reject !== undefined ? { reject: parsed.reject } : {}) };
    // Commit this exchange to the session transcript — the user delta + the RAW reply the model authored
    // (even when unparseable, so the reused prefix matches what the provider actually saw). Off the graded
    // path; the graded replay rides CapturedTurns, never this transcript.
    if (reuse) {
      transcript.push(userMsg, { role: "assistant", content: completion.text });
    }
    opts.capture?.push({
      ...completionEvidence(ordinal, completion, parsed.turn.journal, latencyMs),
      // SFT-harvest evidence (OFF the graded path): the exact prompt the model saw + its verbatim reply.
      prompt: effectiveMessage,
      reply: completion.text,
      outcome: parsed.ok ? "authored" : "invalid",
      actionKinds: parsed.turn.actions.map((a) => a.kind),
      actionCount: parsed.turn.actions.length,
      ...(parsed.ok ? {} : { invalidReason: parsed.reason }),
      cachePolicy,
      ...liveness,
    });
    return parsed.turn;
  };

  /**
   * ONE in-window authoring call (ADR-0029 §2/§6 — the viewshop arm only). Same conversation-reuse /
   * cache / capture machinery as {@link runTurn}, but it classifies the reply through
   * {@link parseAuthoringReply} (so a `requestView` is recognized) and returns an {@link AuthoringStep}
   * the DRIVER routes on. Every call is a captured turn (usage folds into cumulative session spend —
   * the `ev_per_ktoken` attention axis, §7). Only the driver-selected terminal turn enters
   * {@link CapturedTurns}; the iterations are evidence, not replay inputs.
   */
  const runAuthoringCall = async (userMessage: string): Promise<AuthoringStep> => {
    const t0 = now();
    const userMsg: LlmMessage = { role: "user", content: userMessage };
    const { prior, request } = buildRequest(userMsg);

    let completion: LlmCompletion;
    try {
      completion = await llm.complete(request);
    } catch (e) {
      // Provider failure mid-loop — the THIRD m9i outcome, kept distinct on the capture. The driver
      // treats the returned terminal standDown as fail-closed (pre-open there is no book to ride —
      // ADR-0029 §6). Transcript left UNTOUCHED (roles stay alternating for a later retry).
      const reason = `provider error — ${e instanceof Error ? e.message : String(e)}`;
      const journal = `harness: ${reason}`;
      opts.capture?.push(providerErrorCapture(OPEN_ORDINAL, reason, journal, now() - t0));
      return {
        reply: { kind: "turn", turn: { actions: [{ kind: "standDown", reason: journal }] } },
        usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
        latencyMs: now() - t0,
      };
    }
    const latencyMs = now() - t0;
    // Same once-per-successful-completion cache-liveness accounting as runTurn (the loop's iterations
    // are reused turns too — a dead cache inflates the authoring budget the same way it inflates wakes).
    const liveness = cacheLiveness(prior.length > 0, usageOf(completion));
    // EMPTY COMPLETION (kestrel-sgwd / kestrel-ubjc — Bug 1 corrected) — same as runTurn: a zero-token /
    // whitespace-only authoring reply is a GENERATION FAILURE, classified INVALID (fail-closed), NEVER a
    // scored PASS (mirrors the hosted adapter's `invalid-empty`). It returns a `kind:"invalid"` reply — the
    // SAME repairable/fail-closed AuthoringStep the driver already routes for a materialization failure — so
    // the driver re-asks rather than banking a stand-down. Concern (a) is unchanged: the empty assistant
    // message is NOT appended to the reused transcript (it would poison every later wake with a Bedrock 400).
    if (completion.text.trim() === "") {
      const reason = "empty/zero-token model completion — classified INVALID, never a scored PASS";
      opts.capture?.push({
        ...completionEvidence(OPEN_ORDINAL, completion, `harness: ${reason}`, latencyMs),
        outcome: "invalid",
        actionKinds: [],
        actionCount: 0,
        invalidReason: reason,
        cachePolicy,
        ...liveness,
      });
      return { reply: { kind: "invalid", reason }, usage: usageOf(completion), latencyMs };
    }
    const reply = parseAuthoringReply(completion.text, { allowRequestView: viewShop });
    if (reuse) transcript.push(userMsg, { role: "assistant", content: completion.text });
    const journal = reply.kind === "turn" ? reply.turn.journal : undefined;
    opts.capture?.push({
      ...completionEvidence(OPEN_ORDINAL, completion, journal, latencyMs),
      // A `requestView` is a legal AUTHORED move (a non-terminal control sub-outcome), so it is
      // `authored`, not `invalid`; the requestView specifics ride the driver-owned emergence log (§5).
      outcome: reply.kind === "invalid" ? "invalid" : "authored",
      actionKinds: reply.kind === "turn" ? reply.turn.actions.map((a) => a.kind) : [],
      actionCount: reply.kind === "turn" ? reply.turn.actions.length : 0,
      ...(reply.kind === "invalid" ? { invalidReason: reply.reason } : {}),
      cachePolicy,
      ...liveness,
    });
    return { reply, usage: usageOf(completion), latencyMs };
  };

  return {
    config: fullConfig,
    open(briefing: BriefingInput): Promise<AgentTurn> {
      // OPEN = the STRATEGIST tier (ADR-0032). Under the founder opt-in the strategist reads its founder
      // View when no forcedView was pinned (authored/forced always wins in resolveView).
      const turn = runTurn(OPEN_ORDINAL, renderBriefingPrompt(briefing, fullConfig, opts.forcedView, openSeat));
      // The OPEN keyframe (full kernel) is now the reader's cached kernel — the first wake delta
      // composes against it (under a streaming policy).
      priorKernel = briefing.kernel;
      return turn;
    },
    decide(frame: ActingFrame): Promise<AgentTurn> {
      // Only stream a kernel delta when the conversation is REUSED (the reader holds the prior full
      // kernel in cached context). Under `stateless-redraw` — reuse=false — the wake stays a
      // self-complete full-kernel keyframe (prevKernel omitted).
      const streamPrev = reuse ? priorKernel : undefined;
      // The View delivered WITH the wake (kestrel-wa0j.4) — the scheduled wake's stored View, already
      // resolved fail-closed by the driver — WINS over the config-pinned `forcedView` for THIS frame
      // only: the wake decides *when*, its named View decides *what* (an authored delivery is more
      // specific than the config lens). Absent both ⇒ the default WAKE panes, byte-identical to today.
      // Cache-monotone by construction: the View changes only THIS wake's appended user message —
      // every earlier transcript byte rides the cached prefix untouched.
      const view = frame.view ?? opts.forcedView;
      // WAKE = the WATCHER tier (ADR-0032). Under the founder opt-in the watcher reads its founder View
      // (delta leads) when no View was delivered/forced (authored/delivered always wins in resolveView).
      let userMessage: string;
      try {
        userMessage = renderWakePrompt(frame, fullConfig, streamPrev, view, wakeSeat);
      } catch (e) {
        // FAIL-CLOSED, NEVER CRASH (kestrel-wa0j.19 §1 — the BLOCKER). A delivered View that RESOLVES but
        // cannot MATERIALIZE (a window arg the frame context can't serve, or an over-budget View measured
        // under the real tokenizer) must never kill the session at the delivery seam. Fall back to the
        // DEFAULT WAKE panes for THIS frame and SURFACE the refusal as a harness annotation on the rendered
        // prompt — matching how the OPEN authoring loop already catches-and-repairs a bad requested View.
        // This sits ABOVE the determinism line (it changes only the model's input message, never the
        // returned AgentTurn), so recordedAgent replays byte-identically. A throw with NO View to blame is a
        // genuine render defect (not agent-authored input) — re-thrown, never swallowed.
        if (view === undefined) throw e;
        const raw = `delivered View "${view.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; rendering the default WAKE panes (fail-closed, kestrel-wa0j.19)`;
        // Date-fence the annotation exactly like the reject-reprompt prefix: an agent-authored View name (or
        // an echoed arg) that leaks a calendar token collapses to a byte-stable, date-blind fallback note.
        const note = scanDateLeak(raw) === null ? raw : "delivered View could not be materialized — rendering the default WAKE panes (fail-closed, kestrel-wa0j.19)";
        userMessage = `harness: ${note}\n\n${renderWakePrompt(frame, fullConfig, streamPrev, undefined)}`;
      }
      const turn = runTurn(frame.wakeOrdinal, userMessage);
      priorKernel = frame.kernel;
      return turn;
    },
    // The bounded authoring loop's in-window re-entry — present ONLY on the viewshop arm (the driver's
    // config gate, ADR-0029 §2/§6). A repair re-ask surfaces the guiding error (no re-render); otherwise
    // the briefing is materialized under the driver-supplied lens (or the default View). The briefing
    // kernel becomes the reader's cached kernel for the first later `decide` delta, exactly as `open` sets it.
    ...(viewShop
      ? {
          authoringOpen(briefing: BriefingInput, o: AuthoringOpenOptions): Promise<AuthoringStep> {
            // The briefing kernel LEADS every materialization and becomes the reader's cached kernel for
            // the first later `decide` delta, exactly as `open` sets it (regardless of the loop outcome).
            priorKernel = briefing.kernel;
            let userMessage: string;
            try {
              userMessage =
                o.repairError !== undefined
                  ? renderRepairPrompt(o.repairError)
                  : renderBriefingPrompt(briefing, fullConfig, o.view, openSeat);
            } catch (e) {
              // A requested View that fails closed at materialization (over its own token budget, or an
              // unknown pane that slipped the driver's resolveView guard) is a REPAIRABLE defect, never a
              // crash: return the guiding error with zero spend, and the driver re-asks (a repair iteration).
              return Promise.resolve({
                reply: {
                  kind: "invalid",
                  reason: `requested View could not be materialized — ${e instanceof Error ? e.message : String(e)}`,
                },
                usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
              });
            }
            return runAuthoringCall(userMessage);
          },
        }
      : {}),
  };
}
