/**
 * # session/day — the STEPPED session (the agent-day of EVALUATION.md Part 1)
 *
 * The supervised, wake-by-wake run of a single session in ONE process (RUNTIME §7 + EVALUATION
 * Part 1). Where {@link runSimSession} drives a bus straight through with a fixed document set,
 * {@link runDaySession} drives the SAME {@link SessionCore} but pauses at wake checkpoints and
 * hands control to an **external author** (an agent in another process) through a **file
 * handshake**: it writes a Frame + a machine summary to the run directory, then BLOCKS for the
 * author's reply (a revised document, or an explicit pass) before continuing. The revised document
 * **supersedes** the standing one (RUNTIME §5, "document supersession").
 *
 * ## The handshake protocol (files under `<dir>`)
 * 1. `briefing.txt` — the OPEN keyframe, written before the first event. The runner then BLOCKS
 *    for `plans-0.kestrel` (the day's authored plan document), parses + arms it.
 * 2. At each wake `n` (authored triggers + the structural cadence into the close), the runner
 *    drives events up to the wake's sim-time, then writes `frame-<n>-<HHMM>.txt` (a delta Frame:
 *    tape since the last vantage, positions, resting, fills, budget, plan states) and
 *    `state-<n>.json` (the machine summary), and BLOCKS for either `revision-<n>.kestrel`
 *    (supersede) OR `pass-<n>` (an empty file = no change).
 * 3. After the last wake it drives to settle, writes `report.json`, and the caller records the run
 *    with the FULL concatenated authored text as `plansText`.
 *
 * ## The injected-time boundary (documented, deliberate) — a LATENCY-BLIND clause
 * The **sim clock is entirely injected** from the bus (`now` = the current event's `ts`, RUNTIME
 * §0) — the graded computation reads no wall clock. The **host wait loop** (polling the filesystem
 * for the author's reply, and the `--max-wait` safety timeout) DOES read the host wall clock: it is
 * an out-of-band author interaction, not part of the graded computation. Determinism is unaffected
 * because the graded output is a pure function of `(bus, wake set, handshake file contents)` — how
 * long the host waited never enters it. With identical revision files pre-staged, a re-run replays
 * byte-identically (the certification test asserts this).
 *
 * **This "host wall-clock wait is off the graded clock" clause is scoped to LATENCY-BLIND sessions**
 * (ADR-0040 / clock-honest-wakes §3, RUNTIME §7 edit). `runDaySession` reaches the driver
 * ({@link runSimulateSession}) with `BACKTEST_CONFIG` — a NO-LLM config that declares no `clockHonest`
 * — so a stepped DAY is always latency-blind today: no deliberation record, the `--max-wait` poll never
 * touches the graded tape, and the frozen `44cf2cbf` reference is preserved byte-for-byte. **There is no
 * transport special-case.** Were an operator to hand the driver a `clockHonest: true` config over an
 * eligible tape (§4 data floor), the SAME uniform driver seam that clocks an in-process `decide`
 * (`clockedTurn` in {@link runSimulateSession}) would clock the file-handshake `await` — the author's
 * wall time (the block-with-retries) becomes `measured_ms`, priced into the tape like any deliberation.
 * A handshake driven by a *human* would then grade terribly clock-honest; operators run those
 * latency-blind. The day driver does not special-case the file transport — the clock-honest eligibility
 * gate (not the handshake) decides admissibility, and this file threads no `now`/`clockHonest` of its
 * own (it inherits the driver's seam unchanged). See docs/design/clock-honest-wakes.md §3.
 *
 * ## Fail-closed (RUNTIME §8)
 * Missing META / no instruments / unknown fill model → refuse (via {@link SessionCore}). An
 * abandoned handshake (no reply within `--max-wait`) exits LOUDLY rather than hanging forever. A
 * malformed `plans-0` / `revision-n` document is a loud parse error (never a silent skip).
 *
 * ## No handshake wait is ever silent (kestrel-4fc9)
 * A blocked day must not look like a hung one. Before EVERY handshake block — the OPEN wait for
 * `plans-0` and each wake's wait for a `revision-<n>`/`pass-<n>` — the runner ANNOUNCES the contract on
 * the host status channel ({@link DayHooks.notify} → stderr): which artifact it just wrote, which file(s)
 * it now wants, and the budget it will actually enforce. While blocked (only ever under `--await-author`)
 * it emits a HEARTBEAT every {@link HEARTBEAT_MS}, so a day waiting on a slow author is observably
 * distinct from a hung process without truncating the real workflow.
 *
 * ## The default TERMINATES; the out-of-band author wait is OPT-IN (kestrel-4fc9, supersedes the 0.4.x default)
 * `kestrel day --dir <empty> </dev/null` (the kestrel-4fc9 repro, and the site's published copy-button) has
 * no author and never will. The runner CANNOT infer author presence/absence from the host — a non-interactive
 * stdin is also exactly what the primary agentic path looks like (an LLM authoring out-of-band from a
 * piped/CI harness), and sniffing `isTTY` would both mis-serve a real caller and put a host property on the
 * control-flow path. So BOTH presence and absence are DECLARED, never detected — symmetric flags:
 *
 * - **default** (neither flag): the day runs from whatever is STAGED in `--dir` and, on a missing document,
 *   refuses PROMPTLY after {@link NO_AUTHOR_GRACE_MS} with a typed {@link DayHandshakeError} (code `NO_AUTHOR`,
 *   exit 6) that names the missing file and both ways forward. A published command therefore always
 *   TERMINATES under plain node — it never rides a 900s poll for a reply the caller never said was coming.
 * - **`--await-author`** ({@link RunDayOptions.awaitAuthor}): the caller DECLARES an out-of-band author will
 *   reply, so each handshake blocks up to `--max-wait` (default 900s), announcing + heartbeating, and
 *   abandons LOUDLY (`HANDSHAKE_ABANDONED`, exit 6) only past that budget. This is the pre-0.4.x default
 *   behaviour, now explicit. Supplying `--max-wait` implies it.
 * - **`--no-author`** ({@link RunDayOptions.noAuthor}): the explicit form of the default — documents are
 *   pre-staged; a miss is the same prompt `NO_AUTHOR` refusal. Kept for self-documenting callers.
 *
 * The 0.4.x contract (a bare `day` rides `--max-wait` 900s for the author) is SUPERSEDED: a shipped,
 * published verb that polls 900s under plain node never terminated inside a caller's budget (the web repo's
 * offline command guard, 120s) — a fail-closed violation on a live surface (RUNTIME §7/§8, this bead's P0
 * re-groom). The real author path is unchanged in substance — it simply says `--await-author` now.
 */

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import type { BusEvent, MetaEvent, OptionQuote } from "../bus/index.ts";
import { foldBook, readBus, type BookState } from "../bus/index.ts";
import { renderBriefing, renderWakeDelta } from "../frame/render.ts";
import type { BriefingInput } from "../frame/types.ts";
import { parse, print, type KestrelNode, type Module } from "../lang/index.ts";
import { UNKNOWN } from "../series/index.ts";
import {
  BACKTEST_CONFIG,
  OPEN_WAKE_KEY,
  type ActingFrame,
  type Agent,
  type AgentTurn,
  type CapturedTurns,
  type WakeAt,
  type WakeKey,
  type WakeSource,
} from "./agent.ts";
import type { PendingWake } from "./carry.ts";
import type { Blotter } from "../blotter/index.ts";
// The ONE shared driver (kestrel-5zl.3). Circular with `./simulate.ts` (which imports this module's pure
// projections — `computeWakes`/`projectAuthorFrame`/`snapshotOf`/…); both sides use the other only INSIDE
// functions (never at module-eval time), so the ESM cycle resolves by call time.
import { runSimulateSession, type DayCompatBridge } from "./simulate.ts";
import { etMinuteOfDay, etWallClockMs } from "./clock.ts";
import type { EpisodeSigmoidParams } from "../fill/index.ts";
// `SessionCore` is a type-only use here (the exported `snapshotOf` signature); the graded event loop it
// drives now lives ENTIRELY behind `runSimulateSession` (kestrel-5zl.3), never re-instantiated in this file.
import { SessionCore, requireMeta, writeReport, type FillModelName, type EpisodeReport } from "./sim.ts";
import { ARM_BOUND_NOTHING_MARKER, type InstrumentSpec } from "../engine/index.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Public surface
// ─────────────────────────────────────────────────────────────────────────────

/** An ET wall-clock time-of-day (a wake vantage). */
export interface TimeOfDay {
  readonly hour: number;
  readonly minute: number;
}

/** The host-interaction seam — the ONLY wall-clock/filesystem/stderr touch points, all off the graded
 * computation (see the file header's injected-time boundary). Defaults use `node:fs` + a sleep +
 * `Date.now` + `process.stderr`; a test injects deterministic hooks so no real wait ever happens.
 *
 * NOTE ON OMISSION: {@link RunDayOptions.hooks} is spread OVER {@link DEFAULT_HOOKS}, so an omitted
 * field inherits the REAL host — an omitted `notify` writes to the process's actual stderr, it is not
 * silenced. A test that must not leak status lines pins `notify` explicitly. */
export interface DayHooks {
  exists(path: string): boolean;
  read(path: string): string;
  write(path: string, content: string): void;
  /** Block the HOST briefly between polls (NOT sim time). */
  sleep(ms: number): void;
  /** HOST wall clock (ms) for the `--max-wait` timeout (NOT sim time). */
  clock(): number;
  /** Announce host status lines — the handshake contract the runner is about to block on, and the
   * heartbeat while it is blocked (kestrel-4fc9). NOT an author artifact: never date-fenced, never
   * graded, never on stdout (the report owns that). Omitted ⇒ the real `process.stderr` (see the
   * interface note); pin it to `() => {}` in a test that must stay quiet. */
  notify(line: string): void;
}

const DEFAULT_HOOKS: DayHooks = {
  exists: (p) => existsSync(p),
  read: (p) => readFileSync(p, "utf8"),
  write: (p, c) => writeFileSync(p, c),
  // `Bun.sleepSync` only under Bun — the published CLI bundle runs the heavy verbs under PLAIN NODE
  // (kestrel-4fc9), where `Bun` is undefined. Fall back to a synchronous host park (Atomics.wait on a
  // throwaway buffer) so the poll loop never busy-spins and never throws on the reference runtime.
  sleep: (ms) => {
    if (typeof Bun !== "undefined") Bun.sleepSync(ms);
    else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
  },
  clock: () => Date.now(),
  notify: (line) => void process.stderr.write(line + "\n"),
};

/** Options for {@link runDaySession}. Exactly one of `busPath`/`events` supplies the bus. */
export interface RunDayOptions {
  readonly busPath?: string;
  readonly events?: readonly BusEvent[];
  /** The run directory the handshake files live in. */
  readonly dir: string;
  /** Authored wake vantages (ET times-of-day). */
  readonly wakes?: readonly TimeOfDay[];
  /** The author acting cutoff (an ET wall-clock time-of-day). The OPEN keyframe is built ONLY from
   * observations watermarked at or before this instant — never a forward peek. Defaults to the
   * canonical benchmark's T-5 (the 09:30 regular open minus 5 min = 09:25 ET). Injected + explicit so
   * no post-cutoff (e.g. 09:30-or-later) print can leak to the author (kestrel-m9i.11, fail-closed). */
  readonly authorCutoff?: TimeOfDay;
  /** Add the structural cadence into the close: T-2h, T-1h, T-30m, T-15m, T-10m, T-5m (RUNTIME
   * §5 / EVALUATION Part 1). */
  readonly structural?: boolean;
  readonly fillModel: FillModelName;
  readonly rUsd: number;
  readonly instruments?: readonly InstrumentSpec[];
  readonly fairTauYears?: (now: number) => number | null;
  readonly makerFairParams?: EpisodeSigmoidParams;
  /** The safety timeout (seconds) an abandoned handshake exits loudly after — the budget the
   * out-of-band author wait rides. Only consulted when the caller has DECLARED it will wait
   * ({@link awaitAuthor}, or supplying this value at all). Default 900. */
  readonly maxWaitSec?: number;
  /** DECLARE that an external author WILL reply out-of-band (`--await-author`, kestrel-4fc9): block at
   * each handshake for the author's document, riding {@link maxWaitSec} (default 900s), announcing +
   * heartbeating while blocked, and abandoning LOUDLY (`HANDSHAKE_ABANDONED`) only past that budget. This
   * is the primary agentic path (an LLM authoring from a piped/CI harness). Supplying {@link maxWaitSec}
   * IMPLIES it — bounding a wait is itself a declaration that you intend to wait. Default false. */
  readonly awaitAuthor?: boolean;
  /** DECLARE that no external author will reply (`--no-author`, kestrel-4fc9): the handshake documents
   * must already be staged in {@link dir}, and any miss is a prompt typed refusal after
   * {@link NO_AUTHOR_GRACE_MS}. This is a caller ASSERTION, never an inference — the runner cannot tell a
   * `</dev/null` repro from a real out-of-band agent harness. Redundant with the default now (which also
   * refuses promptly) but kept as an explicit, self-documenting assertion; it wins over {@link awaitAuthor}
   * if both are somehow set. */
  readonly noAuthor?: boolean;
  /** Host poll interval (ms) between handshake-file checks. Default 50. */
  readonly pollMs?: number;
  /** Write the report to this path too (pretty JSON). */
  readonly out?: string;
  /** Host-interaction hooks (test seam). */
  readonly hooks?: Partial<DayHooks>;
}

/** One wake vantage as it was actually delivered + answered. */
export interface WakeRecord {
  readonly n: number;
  readonly ts: number;
  readonly label: string;
  readonly structural: boolean;
  /** Did the author supersede here (a `revision-<n>` reply), or pass? */
  readonly revised: boolean;
}

/** The result of a stepped day: the graded report, the concatenated authored text (the ledger
 * `plansText`), and the delivered wake sequence (the decision trace scaffold). */
export interface DaySessionResult {
  readonly report: EpisodeReport;
  readonly plansText: string;
  readonly wakes: readonly WakeRecord[];
  /** The turns the run consumed, keyed by wake ordinal (kestrel-5zl.3 re-run — the fileHandshakeAgent
   * refactor over the ONE shared {@link import("./simulate.ts").runSimulateSession} driver). Populated ONLY
   * once `runDaySession` delegates to the driver in DAY-COMPAT MODE: it is the SINGLE driver run's
   * {@link CapturedTurns}, so the day is re-runnable as a Backtest — {@link import("./agent.ts").recordedAgent}
   * replays it through `runSimulateSession` byte-identically, proving there is no forked second progression
   * path. Absent on the pre-refactor stepped path (this field is what makes the TDD-red contract observable). */
  readonly captured?: CapturedTurns;
  /** The graded {@link Blotter} from that SAME single driver run (a pure projection of the graded Bus,
   * ADR-0011) — never a second run stapled on for the Blotter. Present under the driver delegation; absent
   * on the pre-refactor stepped path. */
  readonly blotter?: Blotter;
}

// ─────────────────────────────────────────────────────────────────────────────
// Wake sequence
// ─────────────────────────────────────────────────────────────────────────────

/** The structural cadence offsets (minutes before the 16:00 ET close), ordered. */
const STRUCTURAL_OFFSETS_MIN = [120, 60, 30, 15, 10, 5] as const;

// ─────────────────────────────────────────────────────────────────────────────
// The author acting cutoff (kestrel-m9i.11 — the T-5 information cutoff)
//
// The OPEN keyframe is built ONLY from observations watermarked AT OR BEFORE this instant. It is
// EXPLICIT + INJECTED (an option, defaulted) so no forward scan can leak post-cutoff — e.g.
// 09:30-or-later regular-session — data to the author before plans-0 is authored (a look-ahead that
// contaminates the benchmark). This is a CAUSAL cutoff, orthogonal to date-blinding (which strips
// calendar identity): both hold independently.
// ─────────────────────────────────────────────────────────────────────────────

/** The US regular-session open (ET). The canonical benchmark's acting cutoff is measured back from here. */
export const REGULAR_OPEN: TimeOfDay = { hour: 9, minute: 30 };

/** The canonical benchmark's acting-cutoff LEAD in minutes before the open: T-5. The default author
 * cutoff is `REGULAR_OPEN − AUTHOR_CUTOFF_LEAD_MIN` = 09:25 ET. */
export const AUTHOR_CUTOFF_LEAD_MIN = 5;

/**
 * The acting-cutoff epoch-ms for a session: an explicit `authorCutoff` (an ET wall-clock time-of-day)
 * mapped onto `sessionDate`, or — when unset — the canonical-benchmark default T-5 (the 09:30 regular
 * open minus 5 min = 09:25 ET). Pure ET calendar arithmetic (no wall clock, RUNTIME §0), so replay is
 * byte-stable.
 */
export function authorCutoffTs(sessionDate: string, authorCutoff?: TimeOfDay): number {
  if (authorCutoff !== undefined) return etWallClockMs(sessionDate, authorCutoff.hour, authorCutoff.minute);
  const openMs = etWallClockMs(sessionDate, REGULAR_OPEN.hour, REGULAR_OPEN.minute);
  return openMs - AUTHOR_CUTOFF_LEAD_MIN * 60_000;
}

/** Parse a `--wakes HH:MM,HH:MM,…` string into times-of-day (loud on a malformed token). */
export function parseWakeTimes(spec: string): TimeOfDay[] {
  const out: TimeOfDay[] = [];
  for (const raw of spec.split(",")) {
    const tok = raw.trim();
    if (tok.length === 0) continue;
    const m = /^(\d{1,2}):(\d{2})$/.exec(tok);
    if (m === null) throw new Error(`day: bad --wakes token ${JSON.stringify(tok)} — expected HH:MM`);
    const hour = Number(m[1]);
    const minute = Number(m[2]);
    if (hour > 23 || minute > 59) throw new Error(`day: --wakes time out of range: ${JSON.stringify(tok)}`);
    out.push({ hour, minute });
  }
  return out;
}

/** Parse an `HH:MM` ET clock (an `atClockET` wake field) into a {@link TimeOfDay}, or `null` when it is
 * structurally unresolvable (a malformed token, or hour/minute out of range). The FAIL-CLOSED sibling of
 * {@link parseWakeTimes} (which THROWS on a bad CLI token) — a `null` is dropped-and-logged by the caller,
 * never a silent-default vantage. */
export function parseClockET(clockET: string): TimeOfDay | null {
  const m = /^(\d{1,2}):(\d{2})$/.exec(clockET.trim());
  if (m === null) return null;
  const hour = Number(m[1]);
  const minute = Number(m[2]);
  if (hour > 23 || minute > 59) return null;
  return { hour, minute };
}

/** Re-anchor a carried wake {@link PendingWake} frontier onto the NEXT session (kestrel-5zl.16.9, ADR-0015
 * D3/D1). An `atClockET` pending wake becomes an authored {@link TimeOfDay} vantage: {@link computeWakes}
 * then maps it onto the TARGET session's `session_date`, so "16:00" fires at 16:00 on the NEXT session's
 * date — never the origin's. A structurally-unresolvable clock is DROPPED into `dropped` with its `reason`
 * (fail-closed — never injected as a silent-default vantage).
 *
 * **The fate of an `inMinutes` offset at THIS seam (kestrel-5zl.16.9 mustFix D — a DELIBERATE decision).**
 * This consumer seam re-expresses a pending wake as a dateless {@link TimeOfDay} clock that
 * {@link computeWakes} anchors to the TARGET `session_date`. An `inMinutes` offset is NOT a clock and has
 * NO vantage in the target session to measure from (its origin vantage died with the prior session), so it
 * cannot be honestly re-injected here. Under fail-closed there is no third option: it is DROPPED into
 * `dropped` WITH a reason — SURFACED, never silently swallowed (the pre-fix `continue` vanished it, the
 * review's finding 4). Native `inMinutes` carry (riding an offset across the boundary off a real target
 * vantage) is the b3l Wake-router producer's concern (kestrel-b3l.1), a different mechanism than this
 * `atClockET` re-anchoring; until it lands, an `inMinutes` on the carry frontier fails closed here.
 *
 * Pure ET calendar arithmetic on the caller's side (this returns dateless vantages), so replay is
 * deterministic. */
export function reanchorFrontier(frontier: readonly PendingWake[]): {
  readonly wakes: readonly TimeOfDay[];
  readonly dropped: readonly { readonly reason: string; readonly why: string }[];
} {
  const wakes: TimeOfDay[] = [];
  const dropped: { reason: string; why: string }[] = [];
  for (const w of frontier) {
    if (w.at.kind === "inMinutes") {
      // FAIL-CLOSED, LOUD (mustFix D): an `inMinutes` offset is not re-anchorable at this atClockET seam —
      // drop it WITH a reason (the b3l producer carries it natively when it lands), never a silent skip.
      dropped.push({
        reason: w.reason,
        why: `inMinutes offset +${w.at.minutes}m is not re-anchorable at the carry seam (no target vantage) — deferred to the b3l producer (kestrel-b3l.1)`,
      });
      continue;
    }
    const tod = parseClockET(w.at.clockET);
    if (tod === null) {
      dropped.push({ reason: w.reason, why: `unresolvable atClockET ${JSON.stringify(w.at.clockET)}` });
      continue;
    }
    wakes.push(tod);
  }
  return { wakes, dropped };
}

/** Zero-padded `HHMM` label for a wake ts (ET minute-of-day). */
function labelOf(ts: number): string {
  const mod = etMinuteOfDay(ts);
  const hh = Math.floor(mod / 60);
  const mm = mod % 60;
  return `${String(hh).padStart(2, "0")}${String(mm).padStart(2, "0")}`;
}

/** Compute the ordered, de-duplicated wake sequence for a session, in epoch-ms sim time. Authored
 * wakes + (optionally) the structural cadence, mapped onto `session_date` via the ET calendar,
 * filtered to strictly inside `(firstTs, lastTs)` — a wake before the first event or at/after the
 * settle delivers nothing actionable. Structural provenance is retained (for the decision trace). */
export function computeWakes(
  sessionDate: string,
  authored: readonly TimeOfDay[],
  structural: boolean,
  firstTs: number,
  lastTs: number,
): { ts: number; label: string; structural: boolean }[] {
  const settleMs = etWallClockMs(sessionDate, 16, 0);
  const byTs = new Map<number, { ts: number; label: string; structural: boolean }>();
  const add = (ts: number, isStructural: boolean): void => {
    if (ts <= firstTs || ts >= lastTs) return; // strictly inside the driven window
    const prior = byTs.get(ts);
    // An authored + structural collision keeps authored provenance (structural is the fallback).
    byTs.set(ts, { ts, label: labelOf(ts), structural: prior ? prior.structural && isStructural : isStructural });
  };
  for (const w of authored) add(etWallClockMs(sessionDate, w.hour, w.minute), false);
  if (structural) for (const off of STRUCTURAL_OFFSETS_MIN) add(settleMs - off * 60_000, true);
  return [...byTs.values()].sort((a, b) => a.ts - b.ts);
}

// ─────────────────────────────────────────────────────────────────────────────
// Frame projection + machine-state channel (local)
//
// The author-facing TEXT frames are rendered by THE canonical renderer in `src/frame`
// (`renderBriefing`/`renderWakeDelta` — the swap the pre-landing local renderer promised,
// kestrel-wa0j.2): one renderer, so the day artifacts an operator reads are the SAME screen an
// in-process agent sees. What stays local is the typed {@link DaySnapshot}, its date-blind
// {@link AuthorFrame} projection (the one seam every author channel derives from), and the
// machine-summary JSON channel — projections, not a parallel Rendering.
// ─────────────────────────────────────────────────────────────────────────────

/** A resting/filled child order, projected from the engine's deterministic dump. */
interface OrderLine {
  /** The authoritative Gate ref the fill engine rests this order under (the engine child's `ref`, e.g.
   * `o1`). Threaded so an acting agent can `cancelOrder` a resting order by its REAL ref (kestrel-5zl.5).
   * The canonical kernel PRINTS resting refs (`ref=o1` — deliberate: the ref is what a cancel names), so
   * since the renderer swap (kestrel-wa0j.2) it is visible in the day artifacts too. */
  readonly ref: string;
  readonly plan: string;
  readonly role: string;
  readonly side: string;
  readonly qty: number;
  readonly strike: number;
  readonly right: string;
  readonly px: number;
  readonly filled: boolean;
}

/** A plan's state at a vantage. */
interface PlanLine {
  readonly name: string;
  readonly state: string;
  readonly heldQty: number;
  /** kestrel-50w: WHY an `authored` plan is stuck (an unsatisfiable/UNKNOWN regime gate). Present ⇒ the
   * frame renders `authored (blocked: <reason>)`; absent ⇒ a bare `authored` (a live plan awaiting WHEN). */
  readonly blockedReason?: string;
}

/** The typed bundle a Frame renders — the narrow seam between the day runner and `renderFrame`. */
export interface DaySnapshot {
  readonly kind: "keyframe" | "delta";
  readonly n: number;
  readonly label: string;
  readonly nowTs: number;
  readonly instruments: readonly string[];
  readonly phase: string;
  readonly spot: number | null;
  /** The epoch-ms the SPOT tick behind `spot` printed at (kestrel-rs4) — a REPLAY-side wall epoch,
   * blinded to the relative {@link AuthorFrame.spotAgeMs} at the projection seam. `null`/absent ⇒
   * never observed (`spot` is `null` too, and reads UNKNOWN). */
  readonly spotTs?: number | null;
  readonly priorClose: number | null;
  readonly hod: number | null;
  readonly lod: number | null;
  readonly vwap: number | null;
  /** The frozen opening-range high/low (`CanonicalState.orHigh`/`orLow`) — `null` until the first spot
   * anchors the range. Carried so the `failed-breaks` pane's ORB fake-out level reaches the author on the
   * driver path (kestrel-wa0j.58): the state computed it, but the projection used to drop it, so the pane
   * rendered all-dashes on every corpus frame. */
  readonly orHigh: number | null;
  readonly orLow: number | null;
  readonly chain: { readonly underlierPx: number; readonly expiry?: string; readonly legs: readonly OptionQuote[] } | null;
  /** Delta-only: the underlier tape since the last vantage (`[ts, px]`). */
  readonly tape: readonly { readonly ts: number; readonly px: number }[];
  readonly plans: readonly PlanLine[];
  readonly resting: readonly OrderLine[];
  readonly fills: readonly OrderLine[];
  readonly committedUsd: number;
  readonly rUsd: number;
}

/**
 * The **date-blind AUTHOR projection** of a {@link DaySnapshot} — the ONE shared seam every
 * delivered author artifact (briefing, delta frames, machine JSON, and therefore their filenames)
 * derives from. Calendar identity is stripped HERE, once, so no downstream channel can re-leak it:
 *
 * - every wall-clock epoch (`nowTs`, each `tape[].ts`) becomes **relative ms since session open**
 *   (`sinceOpenMs`) — a small dateless integer — plus an `HHMM` ET time-of-day `label` (a clock,
 *   which pins no calendar date);
 * - an **absolute chain expiry** (`2026-07-17`) becomes a **relative days-to-expiry** offset
 *   (`dte`) measured from the session date (0 for a 0dte) — a relative label pins no date.
 *
 * Replay-only calendar identity (the raw session date, wall epochs) stays OUTSIDE this projection,
 * on the host/replay side. A leak that still reaches the boundary is caught by {@link assertDateBlind}
 * (fail-closed), never silently emitted.
 */
export interface AuthorFrame {
  readonly kind: "keyframe" | "delta";
  readonly n: number;
  /** `HHMM` ET time-of-day of this vantage (a clock — dateless). For a session that is part of a
   * multi-session Instance the label is prefixed with the relative-day ordinal `d{k}` (e.g. `d2 0935`);
   * a standalone session (no ordinal) keeps the bare `HHMM` — byte-identical to today. */
  readonly label: string;
  /** Relative ms since the session's first bus event (dateless). */
  readonly sinceOpenMs: number;
  /** The relative-day coordinate `k` (kestrel-5zl.16.4) — the session's ordinal in its Instance (`d0,
   * d1, …`), pinning no calendar date. Present ONLY for a multi-session run; absent (and unserialized)
   * for a standalone session, so existing single-session frames are byte-identical. Paired with the
   * per-session `sinceOpenMs` (which RESETS at each session's open), this is the date-blind coordinate
   * that lets a horizon span any number of sessions without `sinceOpenMs` ever reaching the 10-digit
   * epoch signature — the ~11.57-day fence limit disappears structurally, the pattern unchanged. */
  readonly sessionOrdinal?: number;
  readonly instruments: readonly string[];
  readonly phase: string;
  readonly spot: number | null;
  /**
   * How long `spot` has been standing without a reprint at this vantage — ms since the SPOT tick
   * that established it (kestrel-rs4). Dateless by construction: a duration, the {@link sinceOpenMs}
   * idiom, never the epoch it was derived from. This is the ONLY channel that tells a genuinely
   * frozen market apart from a feed that stopped printing — the two produce an identical `spot`.
   * Absent (and unserialized) when nothing was ever observed, so a spot-less frame is byte-identical.
   */
  readonly spotAgeMs?: number;
  readonly priorClose: number | null;
  readonly hod: number | null;
  readonly lod: number | null;
  readonly vwap: number | null;
  /** The frozen opening-range high/low, passed through from the {@link DaySnapshot} (kestrel-wa0j.58). A
   * price pins no calendar date, so it rides the date-blind projection unchanged; `null` before the range
   * anchors. Without it every driver-path frame's OR reads `—` and the `failed-breaks` pane is inert. */
  readonly orHigh: number | null;
  readonly orLow: number | null;
  /** The chain, with its absolute expiry replaced by a relative days-to-expiry offset (`dte`). */
  readonly chain: { readonly underlierPx: number; readonly dte: number | null; readonly legs: readonly OptionQuote[] } | null;
  /** The underlier tape since the last vantage — dateless (`HHMM` label + relative ms). */
  readonly tape: readonly { readonly label: string; readonly sinceOpenMs: number; readonly px: number }[];
  readonly plans: readonly PlanLine[];
  readonly resting: readonly OrderLine[];
  readonly fills: readonly OrderLine[];
  readonly committedUsd: number;
  readonly rUsd: number;
}

/**
 * Calendar days from `sessionDate` to an expiry token, or `null`. An **absolute** date expiry
 * (`2026-07-17`) is converted to this RELATIVE offset (it pins no calendar date once the session
 * origin is itself blinded); a bare `Ndte` tag is read for its `N`; any other shape fails closed to
 * `null` (the chain shows no expiry rather than emit an un-relativized token — {@link assertDateBlind}
 * is the backstop). Pure arithmetic (`Date.UTC` is a pure function of its args — no wall clock read,
 * RUNTIME §0), so replay stays byte-stable.
 */
function relativeDte(expiry: string | undefined, sessionDate: string): number | null {
  if (expiry === undefined) return null;
  const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(expiry.trim());
  if (iso !== null) {
    const expMid = Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
    const [sy, sm, sd] = sessionDate.split("-").map(Number);
    const sesMid = Date.UTC(sy ?? 1970, (sm ?? 1) - 1, sd ?? 1);
    return Math.round((expMid - sesMid) / 86_400_000);
  }
  const tag = /^(\d+)\s*dte$/i.exec(expiry.trim());
  return tag !== null ? Number(tag[1]) : null;
}

/** Project a raw {@link DaySnapshot} into the date-blind {@link AuthorFrame} (the single seam).
 * `openTs` is the session's first bus-event `ts` (the injected clock origin, RUNTIME §0). `sessionOrdinal`
 * (kestrel-5zl.16.4), when supplied, is the relative-day coordinate `k`: it prefixes the label with `d{k}`
 * and rides the frame as {@link AuthorFrame.sessionOrdinal}. Absent (every standalone caller) ⇒ today's
 * bare `HHMM` label and no ordinal field — byte-identical. */
export function projectAuthorFrame(
  s: DaySnapshot,
  openTs: number,
  sessionDate: string,
  sessionOrdinal?: number,
): AuthorFrame {
  return {
    kind: s.kind,
    n: s.n,
    label: sessionOrdinal === undefined ? s.label : `d${sessionOrdinal} ${s.label}`,
    sinceOpenMs: s.nowTs - openTs,
    ...(sessionOrdinal !== undefined ? { sessionOrdinal } : {}),
    instruments: [...s.instruments],
    phase: s.phase,
    spot: s.spot,
    // kestrel-rs4: the spot's epoch becomes an AGE here — the one seam calendar identity is stripped
    // at (file header). A duration pins no date, and it is what keeps a dead feed from reaching the
    // author looking live. Never observed ⇒ omitted (byte-identical), and `spot` reads UNKNOWN anyway.
    ...(s.spotTs !== undefined && s.spotTs !== null ? { spotAgeMs: Math.max(0, s.nowTs - s.spotTs) } : {}),
    priorClose: s.priorClose,
    hod: s.hod,
    lod: s.lod,
    vwap: s.vwap,
    // kestrel-wa0j.58: the frozen opening range rides through unchanged (a price pins no date).
    orHigh: s.orHigh,
    orLow: s.orLow,
    chain:
      s.chain === null
        ? null
        : { underlierPx: s.chain.underlierPx, dte: relativeDte(s.chain.expiry, sessionDate), legs: [...s.chain.legs] },
    tape: s.tape.map((t) => ({ label: labelOf(t.ts), sinceOpenMs: t.ts - openTs, px: t.px })),
    plans: [...s.plans],
    resting: [...s.resting],
    fills: [...s.fills],
    committedUsd: s.committedUsd,
    rUsd: s.rUsd,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The date-blind fence (fail-closed guard over the ACTUAL emitted author artifacts)
//
// The projection strips the calendar identity we know about; this guard is the fail-closed backstop
// that scans the FINAL emitted string (and its filename) for any recoverable calendar token — an ISO
// or slash date, a month name, an epoch-looking integer, a compact date fused into a symbol root
// (OCC style), or a weekday name — and REFUSES to emit it (throws loudly, EVALUATION contamination
// fence). A leak in a field the projection did not anticipate is caught here rather than silently
// delivered.
// ─────────────────────────────────────────────────────────────────────────────

/** The calendar-leak patterns the author boundary is fenced against. Shared by the guard below and
 * the CI test so the two never drift. A relative-ms integer (≤ 8 digits for a full session) and an
 * `HHMM` clock (4 digits) sit below the epoch threshold; prices/strikes/qty never reach it.
 *
 * The `compact-date` class is why we CANNOT simply flag any 6/8-digit run: a full-session
 * `sinceOpenMs` legitimately reaches 8 digits (≈2.3e7 ms), so a bare digit-run pattern would
 * false-close on it. Instead we target the recoverable signature it collides with — a YYMMDD/YYYYMMDD
 * run FUSED to a letter root (`SPXW260710`, the standard OCC contract shape). A relative-ms integer
 * is always space/quote/`: `-delimited (never letter-adjacent), so the lookbehind never touches it,
 * while a date-bearing instrument symbol — passed through verbatim at the author boundary — is
 * caught. */
// A month name ONLY counts as a calendar leak when it carries a DATE CONTEXT — a day-number or a
// year ADJACENT to the month word. A BARE month word ("the feed may degrade", "march toward the
// level", "august highs") is an English homograph that legitimately appears in owner-authored
// mandate/Brief prose embedded in the acting frame, NOT a recoverable date, so it must PASS. This
// mirrors compact-date's context-requiring lookbehind: precision comes from demanding the token's
// surrounding date signature, never a bare word. The narrowing is orthogonal to the sibling
// patterns (iso/slash/compact/epoch/weekday), so a NUMERIC date ("2024-05-01") is still caught
// regardless — recall is preserved. Adjacency allows a space/comma/hyphen (and an optional "of",
// for "15th of March"). A DAY is 1–31 with an optional ordinal (st/nd/rd/th) on EITHER side; a YEAR
// is four digits after the month.
const MONTH_WORD =
  "jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?";
const DAY_NUM = "(?:0?[1-9]|[12]\\d|3[01])(?:st|nd|rd|th)?"; // 1–31, optional ordinal suffix
const DATE_SEP = "[\\s,\\-]+(?:of[\\s,\\-]+)?"; // space/comma/hyphen adjacency, optional "of"
const MONTH_NAME_RE = new RegExp(
  // MONTH → day-or-year:  "March 15" | "Mar 15, 2024" | "May 2024"
  `\\b(?:${MONTH_WORD})\\b${DATE_SEP}(?:${DAY_NUM}|\\d{4})\\b` +
    // day → MONTH:        "15 March" | "15th of March" | "1 May"
    `|\\b${DAY_NUM}${DATE_SEP}(?:${MONTH_WORD})\\b`,
  "i",
);

export const DATE_LEAK_PATTERNS: readonly { readonly name: string; readonly re: RegExp }[] = [
  { name: "iso-date", re: /\d{4}-\d{2}-\d{2}/ },
  { name: "slash-date", re: /\d{1,2}\/\d{1,2}\/\d{2,4}/ },
  { name: "month-name", re: MONTH_NAME_RE },
  // A 6-or-more-digit run fused directly onto a letter root — a compact YYMMDD/YYYYMMDD date carried
  // inside an OCC-style instrument symbol (`SPXW260710`). The lookbehind keeps standalone relative-ms
  // integers (always delimited, never letter-adjacent) from tripping it.
  //
  // The lookbehind demands an UPPERCASE root letter (kestrel-z46). OCC instrument symbols are UPPERCASE
  // by spec — the root (`SPXW`) and the C/P right indicator alike — and nothing in the engine ever
  // lowercases a symbol, so this is a PRECISION-ONLY narrowing: every real compact-date leak vector
  // still trips (recall preserved; the SPXW260710 backstop test stays red-on-delete). What it excludes
  // is the false positive it was mis-firing on: a LOWERCASE-hex sha256 hash — a Brief/frame/rendering
  // identity like `sha256:e739bbc12e0d5b78979162ec…` (ADR-0032 tracer-3) — where a `[a-f]`+6-digit slice
  // (`b78979162`) read as an OCC date and blocked an honest, date-blind frame. sha256 hex digests are
  // lowercase, so an uppercase lookbehind can never see one; and because the discriminator is CASE, not
  // "surrounded by hex letters", an all-hex-letter ticker root (`ADBE260710`, `FACE260710`) is STILL
  // caught — the naive hex-context exclusion's blind-spot (the reason-feedback review's noted gap) is
  // not opened here.
  { name: "compact-date", re: /(?<=[A-Z])\d{6,}/ },
  // A weekday name — a recoverable calendar signal even without the numeric date. Full names only,
  // so a short ticker root can never collide with a 3-letter abbreviation.
  { name: "weekday-name", re: /\b(mon|tues|wednes|thurs|fri|satur|sun)day\b/i },
  // 10+ contiguous digits ≈ an epoch (seconds=10, ms=13) — a recoverable raw timestamp.
  { name: "epoch", re: /\b\d{10,}\b/ },
];

/** The first calendar-leak in `body`, or `null` when it is date-blind. Deterministic. */
export function scanDateLeak(body: string): { readonly name: string; readonly match: string } | null {
  for (const { name, re } of DATE_LEAK_PATTERNS) {
    const m = re.exec(body);
    if (m !== null) return { name, match: m[0] };
  }
  return null;
}

/** Fail-closed guard: throw loudly if `body` (an emitted author artifact, or its filename `what`)
 * carries any calendar identity. Called at the author boundary before every write — a leak is caught,
 * never silently emitted (EVALUATION contamination fence; CLAUDE fail-closed). */
export function assertDateBlind(what: string, body: string): void {
  const hit = scanDateLeak(body);
  if (hit !== null) {
    throw new Error(
      `day: date-blind fence tripped — ${what} leaks a ${hit.name} (${JSON.stringify(hit.match)}) at the author boundary (fail-closed, EVALUATION contamination fence)`,
    );
  }
}

/** The session phase as a legible label, or an explicit `"—"` when canonical phase is UNRESOLVED
 * (F3): the renderer invents no value — it never substitutes a plausible `"pre"` for a state the
 * canonical series has not actually resolved (RUNTIME §2/§3, UNKNOWN is never a guess). */
export function phaseLabel(phase: unknown): string {
  return typeof phase === "string" ? phase : "—";
}

/** The DAY-protocol reply instruction appended AFTER a rendered frame. Protocol scaffolding (never
 * frame content — it invents no value), placed as a TRAILER so the canonical kernel still LEADS the
 * artifact ({@link renderBriefing}/{@link renderWakeDelta} enforce the lead guard on their own output).
 * Routed through the same date-blind fence as every author artifact. */
function withDayReplyTrailer(rendered: string, instruction: string): string {
  return `${rendered}\n\n→ ${instruction}\n`;
}

/**
 * The DAY wake-frame body: THE canonical {@link renderWakeDelta} screen over the driver's typed
 * {@link ActingFrame}, rendered under the frame's DELIVERED View (kestrel-wa0j.19 §2 — the day runner
 * threads `frame.view` exactly like the live-agent + file-handshake adapters; the pre-fix omission was a
 * latent screen divergence where the day artifact ignored a View the other adapters honored). Absent
 * view ⇒ the default WAKE panes, BYTE-IDENTICAL to before.
 *
 * FAIL-CLOSED, NEVER CRASH (§1a): a delivered View that resolves but cannot MATERIALIZE (a window arg the
 * frame can't serve, an over-budget View) falls back to the default WAKE panes with the refusal LEADING
 * the artifact — surfaced, never silent, and never a dead day session. A throw with NO View to blame is a
 * genuine render defect and is re-thrown. Pure (no I/O, no clock) — exported so the threading + the
 * fallback are directly testable.
 */
export function dayWakeFrameText(frame: ActingFrame): string {
  try {
    return renderWakeDelta(frame, frame.view !== undefined ? { view: frame.view } : {});
  } catch (e) {
    if (frame.view === undefined) throw e;
    const note = `⚠ handshake: delivered View "${frame.view.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; showing the default WAKE panes (fail-closed, kestrel-wa0j.19).`;
    return `${note}\n\n${renderWakeDelta(frame, {})}`;
  }
}

/**
 * The machine summary (`state-<n>.json`) — the deterministic JSON an agent author reads. It
 * serializes the date-blind {@link AuthorFrame} DIRECTLY: the projection already rewrote every
 * wall-clock epoch to relative `sinceOpenMs` and the absolute chain expiry to a relative `dte`, so
 * the machine channel carries the SAME blinded fields the txt frames do — no channel can re-leak.
 */
export function machineSummary(f: AuthorFrame): string {
  // Round every finite number to a 1e-6 grid — an agent-facing summary needs no 15-significant-digit
  // float tails, and a raw `100.33333333333333` vwap would trip the date-blind EPOCH grep on its
  // 14-digit fractional run (a false date signal). Integers (relative-ms, qty, strike) are untouched.
  return JSON.stringify(f, (_k, v) => (typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e6) / 1e6 : v), 2) + "\n";
}

// ─────────────────────────────────────────────────────────────────────────────
// Snapshot assembly (from the engine's deterministic dump)
// ─────────────────────────────────────────────────────────────────────────────

interface DumpChild {
  readonly ref: string;
  readonly role: string;
  readonly side: string;
  readonly qty: number;
  readonly strike: number;
  readonly right: string;
  readonly px: number;
  readonly filled: boolean;
  readonly cancelled: boolean;
}
interface DumpPlan {
  readonly name: string;
  readonly state: string;
  readonly armBlockReason?: string | null;
  readonly filledBuyQty: number;
  readonly filledSellQty: number;
  readonly children: readonly DumpChild[];
}
interface EngineDump {
  readonly plans: readonly DumpPlan[];
  readonly envelopes: readonly { readonly name: string; readonly committedUsd: number }[];
}

function numOrNull(v: unknown): number | null {
  return typeof v === "number" && Number.isFinite(v) ? v : null;
}

/** Build the frame snapshot from the live core at a vantage. Pure over the core's state. Exported so
 * the Simulate driver (`./simulate.ts`) assembles its acting Frame from the SAME snapshot the stepped
 * day renders — one snapshot shape, one date-blind projection (no forked vantage). */
export function snapshotOf(
  core: SessionCore,
  kind: "keyframe" | "delta",
  n: number,
  label: string,
  nowTs: number,
  instruments: readonly string[],
  rUsd: number,
  tape: readonly { ts: number; px: number }[],
): DaySnapshot {
  const dump = core.engine.dumpState() as EngineDump;
  const plans: PlanLine[] = dump.plans.map((p) => ({
    name: p.name,
    state: p.state,
    heldQty: p.filledBuyQty - p.filledSellQty,
    // kestrel-50w: carry the arm-time gate-block reason onto the projected plan line (present only when
    // the engine latched one — a plan stuck `authored` on an unsatisfiable regime gate).
    ...(p.armBlockReason != null ? { blockedReason: p.armBlockReason } : {}),
  }));
  const resting: OrderLine[] = [];
  const fills: OrderLine[] = [];
  for (const p of dump.plans) {
    for (const c of p.children) {
      const line: OrderLine = {
        ref: c.ref,
        plan: p.name,
        role: c.role,
        side: c.side,
        qty: c.qty,
        strike: c.strike,
        right: c.right,
        px: c.px,
        filled: c.filled,
      };
      if (c.filled) fills.push(line);
      else if (!c.cancelled) resting.push(line);
    }
  }
  const committedUsd = dump.envelopes.reduce((m, e) => Math.max(m, e.committedUsd), 0);
  const execSym = core.execSpec.symbol;
  const book = core.books.get(execSym);
  const st = core.state;
  // kestrel-rs4: canonical state already stamps the tick behind `spot` (`spotStamp`) — carry that ts
  // so the projection can age it. Without it a feed that died hours ago renders as a live level.
  const spotStamp = st.spotStamp;
  return {
    kind,
    n,
    label,
    nowTs,
    instruments: [...instruments],
    phase: phaseLabel(st.phase),
    spot: numOrNull(st.spot),
    spotTs: spotStamp === UNKNOWN ? null : spotStamp.ts,
    priorClose: numOrNull(st.priorClose),
    hod: numOrNull(st.hod),
    lod: numOrNull(st.lod),
    vwap: numOrNull(st.vwap),
    // kestrel-wa0j.58: the CanonicalState froze the opening range (`onSpot`) but the projection dropped it,
    // so `failed-breaks` rendered all-dashes on every corpus frame. `numOrNull` maps UNKNOWN → null (the
    // range reads `—` before the first spot anchors it) — fail-closed, never a fabricated level.
    orHigh: numOrNull(st.orHigh),
    orLow: numOrNull(st.orLow),
    chain:
      book === undefined
        ? null
        : { underlierPx: book.underlier_px, ...(book.expiry !== undefined ? { expiry: book.expiry } : {}), legs: [...book.legs] },
    tape: [...tape],
    plans,
    resting,
    fills,
    committedUsd,
    rUsd,
  };
}

/** Options for {@link briefingSnapshot}. */
export interface BriefingSnapshotOptions {
  /** Present the observed range (an INTRADAY authoring vantage) rather than the pre-open keyframe
   * (kestrel-1vw). When `true`, the OPEN briefing folds every exec observation at/before the cutoff into
   * `spot` (current)/`hod`/`lod`/`tape` — the opening range an ORB cell authors at its OR-close. Absent /
   * `false` ⇒ the pre-open keyframe (first spot/chain only, range UNKNOWN), byte-identical to before. */
  readonly carryRange?: boolean;
}

/**
 * Build the OPEN-briefing snapshot BEFORE the first event — the OPEN keyframe of EVALUATION Part 1.
 *
 * It is bounded by the **author acting cutoff** (`cutoffTs`, kestrel-m9i.11): it admits ONLY the exec
 * SPOT/BOOK observations whose watermark is AT OR BEFORE the cutoff, and NEVER peeks past it — so a
 * regular-session print at/after the 09:30 open (a T-5 benchmark, or a delayed feed) does NOT leak into
 * the author's keyframe (a look-ahead that contaminates the benchmark).
 *
 * By DEFAULT it is the pre-open keyframe: it takes the first exec spot/chain at/before the cutoff and
 * leaves the range (`hod`/`lod`/`tape`) UNKNOWN — the renderer shows the dash + a coverage reason
 * (fail-closed: missing → UNKNOWN). `opts.carryRange` opts into an **intraday authoring vantage**
 * (kestrel-1vw): an ORB cell authoring at its opening-range close (~10:00 ET) folds EVERY exec
 * observation at/before the cutoff, so the OPEN briefing surfaces the range as of the cutoff — `spot`
 * is the current print, `hod`/`lod` are the session high/low (the OR high/low at OR-close), and `tape`
 * is the observed range. The causal fence is identical in both paths (no post-cutoff print is ever
 * admitted); the flag only decides whether the admitted prefix is COLLAPSED to the opening keyframe or
 * PRESENTED as the range. `priorClose`/`vwap` remain UNKNOWN — admitted ONLY through governed context
 * that carries its own as-of/source watermark, never inferred here. Folds nothing into the core (no
 * order can act pre-briefing; the read is informational).
 */
export function briefingSnapshot(
  meta: MetaEvent,
  execSym: string,
  events: readonly BusEvent[],
  rUsd: number,
  cutoffTs: number,
  opts: BriefingSnapshotOptions = {},
): DaySnapshot {
  // Default (pre-open) path — the canonical T-5 briefing: take the FIRST exec spot/chain at/before the
  // cutoff and leave the range (`hod`/`lod`/`tape`) UNKNOWN. Byte-identical to before this fix for every
  // caller that does not explicitly opt into an intraday authoring vantage.
  if (opts.carryRange !== true) {
    let spot: number | null = null;
    let spotTs: number | null = null;
    let chain: DaySnapshot["chain"] = null;
    let book: BookState | undefined;
    for (const ev of events) {
      if (ev.ts > cutoffTs) continue; // fail-closed: never admit a post-cutoff (future) observation
      if (ev.stream !== "TICK") continue;
      if (spot === null && ev.type === "SPOT" && ev.instrument === execSym) {
        spot = ev.px;
        spotTs = ev.ts; // kestrel-rs4: the age this keyframe's spot is presented with rides on THIS ts
      }
      if (ev.type === "BOOK" && ev.instrument === execSym) {
        book = foldBook(ev, book);
        if (chain === null) chain = { underlierPx: ev.underlier_px, ...(ev.expiry !== undefined ? { expiry: ev.expiry } : {}), legs: [...book.legs] };
      }
      if (spot !== null && chain !== null) break; // enough to orient
    }
    return {
      kind: "keyframe",
      n: -1,
      label: "OPEN",
      nowTs: events[0]?.ts ?? 0,
      instruments: meta.instruments.map((i) => i.symbol),
      phase: "pre",
      spot,
      spotTs,
      priorClose: null,
      hod: null,
      lod: null,
      vwap: null,
      // The briefing folds no OR window (it computes no frozen opening range), so the OR reads UNKNOWN
      // here — fail-closed (kestrel-wa0j.58); the frozen OR reaches the author on the driven WAKE path.
      orHigh: null,
      orLow: null,
      chain,
      tape: [],
      plans: [],
      resting: [],
      fills: [],
      committedUsd: 0,
      rUsd,
    };
  }

  // Intraday authoring vantage (kestrel-1vw, `carryRange`) — an ORB cell authoring at its opening-range
  // CLOSE (~10:00 ET). Fold EVERY exec observation at/before the cutoff so the OPEN briefing carries the
  // range the author must break out of: `spot` is the current print, `hod`/`lod` are the session high/low
  // as of the cutoff (the OR high/low at OR-close), and `tape` is the observed range. The SAME m9i.11
  // causal fence still holds — no print watermarked after the cutoff is admitted — so nothing leaks; this
  // path is reached ONLY when a caller sets `carryRange` for a genuinely intraday cutoff.
  let spot: number | null = null;
  let spotTs: number | null = null;
  let hod: number | null = null;
  let lod: number | null = null;
  let chain: DaySnapshot["chain"] = null;
  let book: BookState | undefined;
  const tape: { ts: number; px: number }[] = [];
  for (const ev of events) {
    if (ev.ts > cutoffTs) continue; // fail-closed: never admit a post-cutoff (future) observation
    if (ev.stream !== "TICK") continue;
    if (ev.type === "SPOT" && ev.instrument === execSym) {
      spot = ev.px; // the CURRENT (most-recent) exec spot at/before the cutoff — never a peeked-forward print
      spotTs = ev.ts; // kestrel-rs4: and the ts it printed at, so a far cutoff can age it honestly
      hod = hod === null ? ev.px : Math.max(hod, ev.px);
      lod = lod === null ? ev.px : Math.min(lod, ev.px);
      tape.push({ ts: ev.ts, px: ev.px });
    }
    if (ev.type === "BOOK" && ev.instrument === execSym) {
      book = foldBook(ev, book);
      // The chain is the FOLDED book AS OF THE CUTOFF — refreshed on every admitted BOOK event, not
      // captured once at the first (kestrel-m9i.41). `foldBook` upserts legs keyed by (strike,right),
      // which is exactly how a REAL OPRA tape arrives: ONE leg per BOOK event (src/bus/types.ts). The
      // old `if (chain === null)` froze the chain at the FIRST upsert, so on a real per-leg option tape
      // the author's briefing carried a ONE-LEG chain for the whole session — it could not see the ATM
      // strike or price a straddle, and live models journalled "no chain legs available" and stood down
      // (harness/prompt.ts:94). Synthetic fixtures (choppy-1101) emit a FULL chain snapshot in every
      // BOOK event, so the first capture happened to be complete and the defect stayed invisible there.
      // The m9i.11 causal fence is UNTOUCHED — the `ev.ts > cutoffTs` guard above still admits nothing
      // post-cutoff; this only stops the briefing from discarding the pre-cutoff legs it already folded.
      chain = { underlierPx: ev.underlier_px, ...(ev.expiry !== undefined ? { expiry: ev.expiry } : {}), legs: [...book.legs] };
    }
  }
  return {
    kind: "keyframe",
    n: -1,
    label: "OPEN",
    // The presented CURRENT-TIME is the acting CUTOFF (the real intraday vantage), not the open. On a
    // far-from-open cutoff (e.g. an FOMC 13:45 arm four+ hours past the 09:30 open) pinning `nowTs` to
    // the first event made the briefing read "it's 09:30" while the range was folded to 13:45 — so a
    // live author reasoned "nothing to do but wait" and DEFERRED instead of arming (kestrel far-cutoff
    // clock). This advances only the PRESENTED clock (`nowTs` → the AuthorFrame's `sinceOpenMs`/`clockET`,
    // and time-to-close); the m9i.11 causal fence above is untouched — no post-cutoff print is admitted.
    // Fenced to `≥` the open so a degenerate pre-open cutoff can never yield a negative `sinceOpenMs`.
    nowTs: Math.max(cutoffTs, events[0]?.ts ?? cutoffTs),
    instruments: meta.instruments.map((i) => i.symbol),
    // A genuinely intraday keyframe once the exec tape has opened under the cutoff (an ORB's OR-close).
    phase: tape.length > 0 ? "regular" : "pre",
    spot,
    spotTs,
    priorClose: null,
    hod,
    lod,
    vwap: null,
    // The intraday briefing folds hod/lod as the session range as-of the cutoff — NOT necessarily the
    // 5-min opening range (a far cutoff's hod/lod spans the whole session), so it cannot honestly claim a
    // frozen OR. UNKNOWN here (kestrel-wa0j.58); the driven WAKE path carries the state-computed OR.
    orHigh: null,
    orLow: null,
    chain,
    tape,
    plans: [],
    resting: [],
    fills: [],
    committedUsd: 0,
    rUsd,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The handshake
// ─────────────────────────────────────────────────────────────────────────────

/** A handshake refusal carrying a STABLE code, so the CLI seam can classify it instead of dropping it
 * into the GENERIC "unexpected/uncaught" bucket (kestrel-4fc9). Lives here rather than importing
 * `cli/errors.ts` (which would invert the layering: `session` is below `cli`); the seams map it —
 * {@link import("../cli/commands/grade.ts").dayCommand} and {@link import("./cli.ts")} — onto a
 * `CliError` with the matching code and a dedicated exit.
 *
 * - `NO_AUTHOR` — the caller DECLARED `--no-author` and a required document was not staged.
 * - `HANDSHAKE_ABANDONED` — a real author was expected but did not reply within `--max-wait`. */
export class DayHandshakeError extends Error {
  override readonly name = "DayHandshakeError";
  readonly code: "NO_AUTHOR" | "HANDSHAKE_ABANDONED";
  readonly hint: string;
  constructor(o: { code: "NO_AUTHOR" | "HANDSHAKE_ABANDONED"; message: string; hint: string }) {
    super(o.message);
    this.code = o.code;
    this.hint = o.hint;
  }
}

/** Which wait discipline a handshake block runs under (kestrel-4fc9). Both ends are DECLARED by the
 * caller, never inferred from the host:
 *  - `await`        — `--await-author` (or a supplied `--max-wait`): block up to `--max-wait` for an
 *                     out-of-band author, then abandon loudly (`HANDSHAKE_ABANDONED`).
 *  - `no-author`    — `--no-author`: documents are pre-staged; a miss refuses promptly (`NO_AUTHOR`).
 *  - `default-fast` — neither flag: the shipped default. Runs from staged documents; a miss refuses
 *                     promptly (`NO_AUTHOR`) so a published verb always terminates under plain node. */
type AuthorPolicy = "await" | "no-author" | "default-fast";

/** Does this policy refuse promptly on a missing document (vs. ride `--max-wait`)? */
function refusesFast(policy: AuthorPolicy): boolean {
  return policy !== "await";
}

/** The grace (host ms) a fast-refusing run still gives a document to appear before refusing — it
 * absorbs a staging race (a caller writing `--dir` in the same breath as it launches the day), NOT an
 * author's think time. Consulted only when {@link refusesFast}; the `await` path never touches it, so
 * shortening or lengthening it can never truncate a declared author's wait. */
const NO_AUTHOR_GRACE_MS = 2_000;

/** How often (host ms) a BLOCKED handshake reports that it is still alive (kestrel-4fc9). The bead's
 * defect is a `day` that is indistinguishable from a hung process; the fix is that it keeps talking. */
const HEARTBEAT_MS = 30_000;

/** The bounded scan window for staged-but-unfired handshake replies (kestrel-zox3). Scanned
 * contiguously from the first unfired wake ordinal (author replies are authored contiguously from 0),
 * so this only caps a pathological gap — it never needs to be large. */
const IGNORED_REPLY_SCAN_CAP = 64;

/** The staged handshake replies (`revision-<n>.kestrel` / `pass-<n>`) for wake ordinals that NO wake
 * will deliver (`n ≥ firedWakes`) — the silent adopt-loop footgun (kestrel-zox3). Omitting `--wakes`
 * fires zero wakes, so a staged `revision-0.kestrel` is never read and the whole wake→supersede→adopt→
 * exit loop is skipped, yet the run still settles green. Scanned contiguously from the first unfired
 * ordinal (revision files are authored from 0 upward), bounded so a stray gap can never spin. PURE over
 * the injected host hooks (an `exists` probe only) — off the graded line. */
function stagedIgnoredWakeReplies(hooks: DayHooks, dir: string, firedWakes: number): { readonly n: number; readonly file: string }[] {
  const out: { n: number; file: string }[] = [];
  for (let n = firedWakes; n < firedWakes + IGNORED_REPLY_SCAN_CAP; n++) {
    if (hooks.exists(join(dir, `revision-${n}.kestrel`))) out.push({ n, file: `revision-${n}.kestrel` });
    else if (hooks.exists(join(dir, `pass-${n}`))) out.push({ n, file: `pass-${n}` });
    else break; // contiguous stop — the first ordinal with neither reply ends the staged run
  }
  return out;
}

/** The LOUD no-wake notice (kestrel-zox3): a staged `revision-<n>`/`pass-<n>` that no wake will ever
 * deliver would otherwise be SILENTLY unused while the run reports a green, profitable summary — a
 * fail-closed/honesty violation (the AX wave-6 footgun: trimming `--wakes` from the adopt walkthrough
 * gives a false "it worked" on the exact lesson being taught). Emitted on the HOST status channel
 * ({@link DayHooks.notify} → stderr), never on stdout/the report, never graded. Exported for the fixture. */
export function renderIgnoredRevisionNotice(dir: string, firedWakes: number, ignored: readonly { readonly n: number; readonly file: string }[]): string {
  const names = ignored.map((r) => r.file).join(", ");
  return (
    `day: ⚠ NO WAKE — ${firedWakes} wake(s) will fire, but ${names} is staged in ${JSON.stringify(dir)}. ` +
    `The adopt loop (wake → supersede → adopt → exit) is SKIPPED and the staged ${ignored.length > 1 ? "revisions are" : "revision is"} SILENTLY UNUSED — ` +
    `this run settles green but never performs the adoption it staged. ` +
    `Pass --wakes HH:MM (and/or --structural) so a wake fires and reads it, or remove the staged file(s). (kestrel-zox3)`
  );
}

/** The LOUD adoption-bound-nothing notice on the DEFAULT day terminal (kestrel-gjx5 residual). A valid
 * `ARM … foreach held leg` adopter that bound ZERO held legs (no superseded, inventory-holding sibling to
 * adopt from) ARMS and expires quietly — its exit protection INERT — and grades GREEN. The engine
 * (kestrel-c25w) already writes the teaching reason onto the plan's OWN lifecycle trace, but that lands
 * ONLY in `report.json` (`/plans[N]/lifecycle/…`); a newcomer running plain `day` reads the default
 * terminal (the {@link DayHooks.notify} → stderr status channel), sees only the armed/expired counts, and
 * never opens the JSON. Echo the notice to that channel, NAMING each affected plan, so the lesson reaches
 * the terminal. Off the graded line (never on stdout/the report, never graded), like every other day
 * notice. Exported for the fixture. */
export function renderArmBoundNothingNotice(plans: readonly string[]): string {
  const names = plans.map((p) => JSON.stringify(p)).join(", ");
  const plural = plans.length > 1;
  return (
    `day: ⚠ ${ARM_BOUND_NOTHING_MARKER} — ${plural ? "plans" : "plan"} ${names} armed a valid ` +
    `\`ARM … foreach held leg\` adopter that bound 0 held legs (no superseded, inventory-holding plan to ` +
    `adopt from), so ${plural ? "their" : "its"} exit protection is INERT: ${plural ? "they arm" : "it arms"}, ` +
    `then ${plural ? "expire" : "expires"} without ever covering, and still grades GREEN. Adoption is a one-shot ` +
    `at arm — supersede the inventory-holding plan in the SAME step so its held leg is in place to bind (the ` +
    `day handshake, RUNTIME §4). See /plans[N]/lifecycle in the report for the full reason. (kestrel-c25w)`
  );
}

/** Scan a settled report for plans whose lifecycle carries the {@link ARM_BOUND_NOTHING_MARKER} reason
 * (kestrel-c25w) — a valid adopter that bound zero held legs. Returns each affected plan name ONCE, in
 * first-seen lifecycle order, so {@link renderArmBoundNothingNotice} can name them on the terminal. */
export function armBoundNothingPlans(report: EpisodeReport): string[] {
  const names: string[] = [];
  const seen = new Set<string>();
  for (const trace of report.plans) {
    const bound = trace.lifecycle.some((step) => step.reason?.includes(ARM_BOUND_NOTHING_MARKER) === true);
    if (bound && !seen.has(trace.plan)) {
      seen.add(trace.plan);
      names.push(trace.plan);
    }
  }
  return names;
}

/** Poll `probe` (host time) until it yields non-null, or return null once `budgetMs` is exhausted.
 * The ONE wall-clock touch point (documented boundary) and the ONE poll loop every handshake wait is
 * built from. Signals a miss by RETURNING NULL rather than throwing, so each caller raises its own
 * typed refusal and no caller has to catch-and-reinterpret an exception it did not raise (a bare
 * `catch` here would silently re-label a read fault or a `ReferenceError` as "no author"). Emits a
 * heartbeat through `beat` every {@link HEARTBEAT_MS} it stays blocked. */
function pollFor<T>(hooks: DayHooks, probe: () => T | null, budgetMs: number, pollMs: number, beat: (waitedMs: number) => void): T | null {
  const start = hooks.clock();
  let nextBeat = HEARTBEAT_MS;
  for (;;) {
    const got = probe();
    if (got !== null) return got;
    const waited = hooks.clock() - start;
    if (waited > budgetMs) return null;
    if (waited >= nextBeat) {
      beat(waited);
      nextBeat = waited + HEARTBEAT_MS;
    }
    hooks.sleep(pollMs);
  }
}

/** The abandonment refusal (RUNTIME §7/§8) — a real author was expected and did not reply in time. */
function abandoned(what: string, where: string, budgetMs: number): DayHandshakeError {
  return new DayHandshakeError({
    code: "HANDSHAKE_ABANDONED",
    message: `day: handshake abandoned — no ${what} in ${JSON.stringify(where)} within ${Math.round(budgetMs / 1000)}s (fail-closed, RUNTIME §8)`,
    hint: `the stepped day blocks for an out-of-band author; raise --max-wait <sec>, or pass --no-author with the documents pre-staged in ${JSON.stringify(where)}.`,
  });
}

/** The prompt refusal (code `NO_AUTHOR`, exit 6) for a fast-refusing policy — a missing document is
 * final because the caller never declared an author would supply it. The wording names WHY it is not
 * waiting (an explicit `--no-author`, or the shipped default) and the two ways forward. */
function noAuthor(what: string, where: string, policy: AuthorPolicy): DayHandshakeError {
  const why =
    policy === "no-author"
      ? "--no-author was declared"
      : "no --await-author was declared (the shipped default does not wait for an author it was not told to expect)";
  return new DayHandshakeError({
    code: "NO_AUTHOR",
    message: `day: no author — ${why} and no ${what} was staged in ${JSON.stringify(where)} (grace ${NO_AUTHOR_GRACE_MS / 1000}s elapsed). Refusing promptly rather than waiting for a reply that is never coming (fail-closed, RUNTIME §8)`,
    hint: `pre-stage ${what} in ${JSON.stringify(where)}, or pass --await-author (bounded by --max-wait <sec>) to block for an out-of-band author. (For a straight-through graded run with a fixed document, use \`run\`.)`,
  });
}

/** The refusal for a pre-staged wake reply that was REJECTED at arm and the author never corrected
 * (kestrel-oqui). The staged `revision-<n>` IS on disk — so the generic {@link noAuthor} "no
 * revision-<n> or pass-<n> was staged" would MIS-ATTRIBUTE a real authoring error (an arm-refusal whose
 * reason was already written to `reject-<n>.txt`) to a MISSING file: the exact self-contradiction the AX
 * probe hit ("already staged — proceeding immediately" then, lines later, "no revision was staged"). This
 * names the arm-refusal reason and points at `reject-<n>.txt`. Carries the SAME code the wait discipline
 * would ({@link refusesFast}) so the CLI exit classification (exit 6 for a fast refuse) is unchanged. */
function revisionRejectedAtArm(n: number, dir: string, reason: string, policy: AuthorPolicy): DayHandshakeError {
  const rejectPath = join(dir, `reject-${n}.txt`);
  const tail = refusesFast(policy)
    ? `no corrected revision-${n}.kestrel or pass-${n} was staged (grace ${NO_AUTHOR_GRACE_MS / 1000}s elapsed)`
    : `no corrected revision-${n}.kestrel or pass-${n} arrived within the wait`;
  return new DayHandshakeError({
    code: refusesFast(policy) ? "NO_AUTHOR" : "HANDSHAKE_ABANDONED",
    message: `day: revision-${n} was REFUSED at arm — ${reason}. The standing book is UNCHANGED (this authoring error changed nothing); the staged file IS present, so this is an authoring error, NOT a missing document. ${tail} — read ${JSON.stringify(rejectPath)} for the refusal (fail-closed, RUNTIME §8)`,
    hint: `read ${JSON.stringify(rejectPath)} for the arm-refusal reason, then author a corrected <dir>/revision-${n}.kestrel OR touch <dir>/pass-${n} to keep the standing book.`,
  });
}

/** The budget a handshake block will ACTUALLY enforce — the single source both the announcement and
 * the wait itself read, so an announced wait is never one the runner does not really take. */
function budgetOf(maxWaitMs: number, policy: AuthorPolicy): number {
  return refusesFast(policy) ? NO_AUTHOR_GRACE_MS : maxWaitMs;
}

/** Announce a handshake contract BEFORE blocking on it (kestrel-4fc9): what was just written, which
 * file(s) the runner now wants, in which dir, and for exactly how long it will wait. */
function renderContract(o: { label: string; wrote: string; dir: string; wants: readonly string[]; budgetMs: number; policy: AuthorPolicy; staged: boolean; reply: string }): string {
  const budget = o.staged
    ? "already staged — proceeding immediately, no wait"
    : o.policy === "no-author"
      ? `--no-author declared — refusing in ${o.budgetMs / 1000}s unless it is already staged`
      : o.policy === "default-fast"
        ? `no --await-author — refusing in ${o.budgetMs / 1000}s unless it is already staged (pass --await-author to block for an out-of-band author)`
        : `max-wait ${Math.round(o.budgetMs / 1000)}s`;
  return (
    `day: handshake ${o.label} — wrote ${o.wrote}; blocking for ${o.wants.join(" or ")} in ${JSON.stringify(o.dir)} (${budget}).\n` +
    `day: ${o.reply}`
  );
}

/** The still-blocked heartbeat line. */
function renderHeartbeat(label: string, wants: readonly string[], waitedMs: number, budgetMs: number): string {
  return `day: handshake ${label} — still blocking for ${wants.join(" or ")} (${Math.round(waitedMs / 1000)}s of ${Math.round(budgetMs / 1000)}s elapsed).`;
}

/** Block for ONE authored document (`plans-0.kestrel`, or a `plans-0.retry-<k>.kestrel`) in `dir`.
 * Rides the full `--max-wait` (RUNTIME §7, unchanged) unless the caller DECLARED `--no-author`, in
 * which case a miss past {@link NO_AUTHOR_GRACE_MS} is a prompt typed refusal rather than a 900s ride
 * for a reply that is never coming (kestrel-4fc9). `label` names the block in the heartbeat. */
function awaitDocument(hooks: DayHooks, dir: string, name: string, maxWaitMs: number, pollMs: number, policy: AuthorPolicy, label: string): string {
  const path = join(dir, name);
  const budgetMs = budgetOf(maxWaitMs, policy);
  const got = pollFor(
    hooks,
    () => (hooks.exists(path) ? hooks.read(path) : null),
    budgetMs,
    pollMs,
    (w) => hooks.notify(renderHeartbeat(label, [name], w, budgetMs)),
  );
  if (got === null) throw refusesFast(policy) ? noAuthor(name, dir, policy) : abandoned(name, dir, budgetMs);
  return got;
}

/** Block until EITHER `revision-<n>.kestrel` (returned) OR `pass-<n>` (→ a `passed` result) appears.
 * Revision wins if both are present. `alreadyRejected` (the content of a revision already rejected this
 * wake) is treated as "not yet corrected": the same bytes still sitting there keep the loop BLOCKING for
 * a corrected file or a pass rather than re-returning a doc known to fail — so the reject-and-reprompt
 * loop never busy-spins on a stale file. Rides the full `--max-wait` and then refuses loudly
 * (abandonment fail-closed), or refuses promptly under a declared `--no-author` (kestrel-4fc9).
 *
 * The `pass` sentinel is a tagged result rather than a bare `null` because null is {@link pollFor}'s
 * "keep waiting" signal: a pass must not be mistaken for a miss. */
type WakeReply = { readonly kind: "revision"; readonly text: string } | { readonly kind: "passed" };

function awaitReply(
  hooks: DayHooks,
  dir: string,
  n: number,
  maxWaitMs: number,
  pollMs: number,
  policy: AuthorPolicy,
  alreadyRejected: string | null = null,
): string | null {
  const revPath = join(dir, `revision-${n}.kestrel`);
  const passPath = join(dir, `pass-${n}`);
  const wants = [`revision-${n}.kestrel`, `pass-${n}`];
  const budgetMs = budgetOf(maxWaitMs, policy);
  const got = pollFor<WakeReply>(
    hooks,
    () => {
      if (hooks.exists(revPath)) {
        const text = hooks.read(revPath);
        // The already-rejected bytes still sitting there are "not yet corrected" — keep waiting.
        if (alreadyRejected === null || text !== alreadyRejected) return { kind: "revision", text };
      }
      if (hooks.exists(passPath)) return { kind: "passed" };
      return null;
    },
    budgetMs,
    pollMs,
    (w) => hooks.notify(renderHeartbeat(`WAKE ${n}`, wants, w, budgetMs)),
  );
  if (got === null) throw refusesFast(policy) ? noAuthor(`revision-${n}.kestrel or pass-${n}`, dir, policy) : abandoned(`revision-${n}.kestrel or pass-${n}`, dir, budgetMs);
  return got.kind === "revision" ? got.text : null;
}

/** The author-facing rejection note (`reject-<n>.txt`) written when a `revision-<n>` fails to PARSE
 * or would fail to ARM. It states the reason, affirms the standing book is untouched, and re-requests
 * the wake. Date-blind (routed through the same fence as every author artifact). */
export function renderRejection(n: number, reason: string): string {
  return [
    `=== WAKE ${n} REVISION REJECTED ===`,
    `reason: ${reason}`,
    "",
    "the standing book is UNCHANGED (this authoring error changed nothing).",
    `→ re-author <dir>/revision-${n}.kestrel (corrected) OR touch <dir>/pass-${n} (keep the standing book).`,
    "",
  ].join("\n");
}

/** The author-facing rejection note (`reject-open.txt`) written when `plans-0.kestrel` fails to
 * PARSE or would fail to ARM (bead kestrel-p48s). States the repair-guiding reason, affirms the
 * session has NOT opened, and names the exact retry file the runner now blocks on. Date-blind
 * (routed through the same fence as every author artifact). */
export function renderOpenRejection(attempt: number, reason: string, retryName: string): string {
  return [
    `=== PLANS-0 REJECTED (attempt ${attempt}) ===`,
    `reason: ${reason}`,
    "",
    "the session has NOT opened — nothing is armed (this authoring error changed nothing).",
    `→ author <dir>/${retryName} (corrected). the runner is blocking on that exact filename.`,
    "",
  ].join("\n");
}

/** Parse one authored `.kestrel` document into a normalized module (loud on a parse error). */
function parseDoc(text: string): Module {
  const node: KestrelNode = parse(text);
  return node.kind === "module" ? node : { kind: "module", imports: [], statements: [node] };
}

/** Would the single pre-staged document `name` in `dir` be ACCEPTED — the SAME parse + arm-preview gate
 * the accept path applies (kestrel-oqui)? Accepted iff it exists, PARSES, and the live `previewSupersede`
 * clears. A document that exists but would be REJECTED (bad parse, or an arm refusal like a
 * manages-nothing EXIT-only revision) is NOT "already staged, proceeding immediately" — announcing it as
 * such and then refusing it as "no document was staged" is the self-contradiction the AX probe hit. PURE
 * (an exists/read probe + the pure `previewSupersede`); off the graded line, mirrors an undefined
 * `previewSupersede` exactly (no arm preview ⇒ the accept path would not preview either). */
function stagedDocAccepted(hooks: DayHooks, dir: string, name: string, bridge: DayCompatBridge): boolean {
  const path = join(dir, name);
  if (!hooks.exists(path)) return false;
  try {
    const doc = parseDoc(hooks.read(path));
    return (bridge.previewSupersede?.(doc) ?? null) === null;
  } catch {
    return false; // a parse defect the accept path would also reject
  }
}

/** Will the pre-staged wake reply at ordinal `n` proceed WITHOUT further authoring — the accept-aware
 * flag the WAKE announcement needs so it never claims "already staged, proceeding immediately" for a
 * reply the wait then refuses (kestrel-oqui)? True iff a `revision-<n>` is {@link stagedDocAccepted}, OR
 * a `pass-<n>` is staged (which rides the standing book unchanged — the fallback awaitReply takes once a
 * rejected revision is skipped as already-rejected). A `revision-<n>` that exists but is rejected at
 * parse/arm with no `pass-<n>` behind it is NOT proceeding immediately. PURE; off the graded line. */
function stagedReplyAccepted(hooks: DayHooks, dir: string, n: number, bridge: DayCompatBridge): boolean {
  if (stagedDocAccepted(hooks, dir, `revision-${n}.kestrel`, bridge)) return true;
  return hooks.exists(join(dir, `pass-${n}`));
}

/** Emit an author artifact through the fail-closed date-blind fence: scan the filename AND the body,
 * refuse (throw) on any calendar leak, then write via the injected host hooks. The ONLY author-channel
 * write path — so every delivered artifact (briefing, delta frames, machine JSON, reject notes) and its
 * filename is guarded (EVALUATION contamination fence; CLAUDE fail-closed). Off the graded clock. */
function emitArtifact(hooks: DayHooks, path: string, body: string): void {
  const base = path.slice(path.lastIndexOf("/") + 1);
  assertDateBlind(`filename ${JSON.stringify(base)}`, base);
  assertDateBlind(base, body);
  hooks.write(path, body);
}

/** The bounded repair-retry cap (ADR-0029) for a malformed (unparseable) `revision-<n>`: after this many
 * DISTINCT parse defects the day file agent fails closed to a typed refusal (a pass) + a settled report,
 * rather than looping. A never-CHANGING malformed file hits the SAME fail-closed terminal via the host
 * abandonment timeout (the `awaitReply` block on already-rejected bytes). NON-FATAL either way — the
 * standing book rides, the session settles, the run never crashes. A well-formed-but-COLLIDING revision is
 * NOT a repairable parse defect: it is a semantic authoring choice (rename or pass), so it keeps the loud
 * abandonment (day parity) if the author walks away without deciding. */
const DAY_PARSE_REPAIR_CAP = 3;

// ─────────────────────────────────────────────────────────────────────────────
// The DAY file-handshake Agent (kestrel-5zl.3) — the open/decide seam over the inspectable DAY protocol
//
// runDaySession is NO LONGER a bespoke stepped progression: it delegates to the ONE shared
// runSimulateSession driver (in DAY-COMPAT MODE), handing it THIS Agent. `open` writes `briefing.txt`
// then blocks for `plans-0.kestrel` → a `supersede` (the initial arm); `decide` writes the delta frame +
// machine state then blocks for `revision-<n>` / `pass-<n>` → a validated `supersede` or a pass. The TEXT
// frames render through THE canonical `src/frame` renderer over the driver's typed BriefingInput/
// ActingFrame (kestrel-wa0j.2 — one renderer); the date-blind AuthorFrame riding the shared
// DayCompatBridge names each artifact, feeds the machine-summary channel, and carries the supersede+arm
// collision preview — so the day is re-runnable as a Backtest (recordedAgent replays the captured turns
// through the SAME driver byte-identically), with no forked event loop.
//
// Fail-closed (RUNTIME §8) → NON-FATAL bounded repair-retry then a typed refusal (ADR-0029): a revision
// that fails to PARSE is rejected (reason to `reject-<n>.txt`, date-blind, host-side), the standing book
// left UNTOUCHED, and `revision-<n>` re-requested; a never-corrected MALFORMED revision fails closed to a
// pass (the book rides, the session settles — never a crash). A well-formed but COLLIDING revision (a name
// still owned by a managing plan) is likewise rejected + re-requested, but a never-corrected one keeps the
// loud abandonment (a semantic conflict the author must resolve, not a mechanical parse defect). The
// graded CONTROL supersede is emitted by the driver ONLY on an accepted revision, so a replay with the
// corrected revision pre-staged reproduces byte-identically (RUNTIME §0).
// ─────────────────────────────────────────────────────────────────────────────

interface DayAgentParams {
  readonly hooks: DayHooks;
  readonly dir: string;
  readonly maxWaitMs: number;
  readonly pollMs: number;
  readonly bridge: DayCompatBridge;
  /** The declared wait discipline (kestrel-4fc9). Gates every handshake's budget + prompt-refusal wording. */
  readonly policy: AuthorPolicy;
}

function dayFileHandshakeAgent(p: DayAgentParams): Agent {
  const { hooks, dir, maxWaitMs, pollMs, bridge, policy } = p;
  return {
    // A NO-LLM Backtest config (no envelope-identity fields) — so the driver's core stays config-less and
    // its graded META carries no envelope/config_id, reproducing the frozen day reference byte-for-byte.
    config: BACKTEST_CONFIG,
    open(briefing: BriefingInput): AgentTurn {
      // THE canonical renderer (kestrel-wa0j.2): the OPEN keyframe is the same `renderBriefing` screen an
      // in-process agent perceives — kernel-led, date-blind — plus the DAY reply trailer. The typed
      // BriefingInput arrives from the ONE driver (`briefingInputOf` over the SAME date-blind projection
      // this agent's bridge carries), so there is no second frame assembly here.
      emitArtifact(hooks, join(dir, "briefing.txt"), withDayReplyTrailer(renderBriefing(briefing), "author <dir>/plans-0.kestrel to open the session."));
      // Say what we are about to block on BEFORE blocking (kestrel-4fc9) — a stepped day that is
      // waiting must never be indistinguishable from a hung one.
      hooks.notify(
        renderContract({
          label: "OPEN",
          wrote: join(dir, "briefing.txt"),
          dir,
          wants: ["plans-0.kestrel"],
          budgetMs: budgetOf(maxWaitMs, policy),
          policy,
          staged: stagedDocAccepted(hooks, dir, "plans-0.kestrel", bridge),
          reply: "reply by authoring that file (the day's plan document) — the session does not open until it lands.",
        }),
      );
      let plans0Text = awaitDocument(hooks, dir, "plans-0.kestrel", maxWaitMs, pollMs, policy, "OPEN");
      // Validate the surface AND preview the arm before accepting (bead kestrel-p48s): a plans-0 the
      // engine would refuse at arm (unknown case-variant series, a name collision) previously died
      // SILENTLY — the session opened with "(none armed)" and the author's only signal was a zero
      // grade hours later. Same bounded repair-retry ladder as a wake revision (ADR-0029): write the
      // repair-guiding rejection to reject-open.txt, re-block for plans-0.retry-<k>.kestrel, and
      // abandon LOUDLY once the cap is exhausted (plans-0 is the initial arm — there is no standing
      // book to ride, so exhaustion is fatal, preserving the loud plans-0 contract).
      for (let repairs = 0; ; ) {
        let rejection: string | null = null;
        try {
          const doc = parseDoc(plans0Text);
          rejection = bridge.previewSupersede?.(doc) ?? null;
        } catch (err) {
          rejection = `parse — ${(err as Error).message}`;
        }
        if (rejection === null) return { actions: [{ kind: "supersede", document: plans0Text }] };
        repairs += 1;
        if (repairs >= DAY_PARSE_REPAIR_CAP) {
          throw new Error(
            `day: plans-0 rejected ${repairs}× (last: ${rejection}) — handshake abandoned (fail-closed, RUNTIME §8)`,
          );
        }
        const retryName = `plans-0.retry-${repairs}.kestrel`;
        emitArtifact(hooks, join(dir, "reject-open.txt"), renderOpenRejection(repairs, rejection, retryName));
        // The retry wait is a handshake block too — announce it before riding it (kestrel-4fc9).
        hooks.notify(
          renderContract({
            label: `OPEN retry ${repairs}`,
            wrote: join(dir, "reject-open.txt"),
            dir,
            wants: [retryName],
            budgetMs: budgetOf(maxWaitMs, policy),
            policy,
            staged: stagedDocAccepted(hooks, dir, retryName, bridge),
            reply: `plans-0 was rejected (${rejection}) — author that corrected file; the session still has not opened.`,
          }),
        );
        plans0Text = awaitDocument(hooks, dir, retryName, maxWaitMs, pollMs, policy, `OPEN retry ${repairs}`);
      }
    },
    decide(frame: ActingFrame): AgentTurn {
      const af = bridge.vantage;
      if (af === undefined) throw new Error("day: fileHandshakeAgent.decide — the driver set no wake vantage on the bridge (fail-closed)");
      const n = frame.wakeOrdinal;
      // Deliver the delta Frame + machine state at the wake vantage. The TEXT frame is THE canonical
      // `renderWakeDelta` screen (kestrel-wa0j.2) over the driver's typed ActingFrame — kernel-led,
      // date-blind (`assertFrameDateBlind` already fenced it at the driver seam) — plus the DAY reply
      // trailer. The bridge's AuthorFrame still names the artifact (`n`/`HHMM` label) and feeds the
      // machine-summary JSON channel (the SAME date-blind projection, one seam).
      emitArtifact(
        hooks,
        join(dir, `frame-${af.n}-${af.label}.txt`),
        withDayReplyTrailer(dayWakeFrameText(frame), `author <dir>/revision-${n}.kestrel (supersede) OR touch <dir>/pass-${n} (no change).`),
      );
      emitArtifact(hooks, join(dir, `state-${af.n}.json`), machineSummary(af));

      // The WAKE block gets the same treatment as OPEN (kestrel-4fc9): announce which file(s) it wants,
      // in which dir, for how long — BEFORE riding the wait. This wait was previously silent too.
      hooks.notify(
        renderContract({
          label: `WAKE ${n}`,
          wrote: join(dir, `frame-${af.n}-${af.label}.txt`),
          dir,
          wants: [`revision-${n}.kestrel`, `pass-${n}`],
          budgetMs: budgetOf(maxWaitMs, policy),
          policy,
          staged: stagedReplyAccepted(hooks, dir, n, bridge),
          reply: `reply by authoring that revision (supersede) OR touching pass-${n} (keep the standing book) — the wake does not advance until one lands.`,
        }),
      );

      let rejectedText: string | null = null;
      let lastRejectKind: "parse" | "arm" | null = null;
      let lastRejectReason: string | null = null;
      let parseRepairs = 0;
      /** The NON-FATAL typed refusal (ADR-0029): a JOURNAL-marked pass so the fail-closed is auditable
       * (never silent), the standing book rides, and the session settles — never a crash. */
      const failClosedPass = (): AgentTurn => ({
        actions: [],
        journal: `handshake: revision-${n} malformed — bounded repair-retry exhausted; standing book rides (fail-closed typed refusal, ADR-0029)`,
      });
      for (;;) {
        let reply: string | null;
        try {
          reply = awaitReply(hooks, dir, n, maxWaitMs, pollMs, policy, rejectedText);
        } catch (abandon) {
          // Abandonment (no corrected revision / pass within the host wait). NON-FATAL only for a REPAIRABLE
          // PARSE defect — a malformed author output can never become valid, so ride the standing book. A
          // colliding (arm) reject, or a clean stall (no reply at all), keeps the loud abandonment (day parity).
          if (lastRejectKind === "parse") return failClosedPass();
          // A pre-staged revision we REJECTED at arm and the author never corrected: the file IS on disk, so
          // the generic {@link noAuthor} "no revision was staged" would mis-attribute this authoring error to a
          // MISSING file (the AX self-contradiction, kestrel-oqui). Report the arm-refusal instead.
          if (lastRejectKind === "arm" && lastRejectReason !== null) throw revisionRejectedAtArm(n, dir, lastRejectReason, policy);
          throw abandon;
        }
        if (reply === null) return { actions: [] }; // pass — the standing document rides
        let revDoc: Module;
        try {
          revDoc = parseDoc(reply);
        } catch (err) {
          rejectedText = reply;
          lastRejectKind = "parse";
          emitArtifact(hooks, join(dir, `reject-${n}.txt`), renderRejection(n, `parse — ${(err as Error).message}`));
          if (++parseRepairs >= DAY_PARSE_REPAIR_CAP) return failClosedPass(); // bounded (a changing-garbage stream)
          continue;
        }
        // Preview the supersede-then-arm collision guard against the LIVE core (PURE — no mutation, no emit).
        const armReject = bridge.previewSupersede?.(revDoc) ?? null;
        if (armReject !== null) {
          rejectedText = reply;
          lastRejectKind = "arm";
          lastRejectReason = armReject;
          emitArtifact(hooks, join(dir, `reject-${n}.txt`), renderRejection(n, armReject));
          continue;
        }
        // Accepted — hand the driver the validated supersede. The collision preview guarantees the driver's
        // subsequent supersede+arm cannot reject, so the standing book is never torn down for a doomed arm.
        return { actions: [{ kind: "supersede", document: reply }] };
      }
    },
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// runDaySession
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Drive one STEPPED `sim` day with the wake handshake (RUNTIME §7 / EVALUATION Part 1) — now OVER the ONE
 * shared {@link runSimulateSession} driver (kestrel-5zl.3), NOT a bespoke second progression. Builds the
 * {@link dayFileHandshakeAgent} (open/decide over the DAY file protocol) and runs it through the driver in
 * DAY-COMPAT MODE, which reproduces the stepped runner's cadence + byte stream EXACTLY (the frozen day
 * `determinism_hash` is preserved). Writes the briefing + per-wake frames/state to `dir`, blocking for the
 * author's replies, applies document supersession on each accepted revision, settles, and returns the
 * graded report + the concatenated authored text + the single run's {@link CapturedTurns}/{@link Blotter}
 * (the re-runnable-as-Backtest contract). Fails closed on a missing META / no instruments / an abandoned
 * handshake / a malformed authored document.
 *
 * `async` because the delegation awaits the driver ({@link runSimulateSession} is `async` — the live agent
 * loop is deliberately non-deterministic). The day agent itself resolves synchronously (injected host hooks),
 * so a re-run with the same pre-staged files replays byte-identically (RUNTIME §0).
 */
export async function runDaySession(opts: RunDayOptions): Promise<DaySessionResult> {
  if (opts.busPath === undefined && opts.events === undefined) {
    throw new Error("day: runDaySession needs either `busPath` or `events`");
  }
  const hooks: DayHooks = { ...DEFAULT_HOOKS, ...opts.hooks };
  const dir = opts.dir;
  const maxWaitMs = (opts.maxWaitSec ?? 900) * 1000;
  const pollMs = opts.pollMs ?? 50;

  const events: BusEvent[] = opts.events !== undefined ? [...opts.events] : [...readBus(opts.busPath!)];
  const meta = requireMeta(events);
  const firstTs = events[0]?.ts ?? 0;
  const lastTs = events[events.length - 1]?.ts ?? firstTs;
  const sessionDate = meta.session_date;

  // The DAY-COMPAT bridge (off the graded line): the driver sets `vantage` at each vantage and binds
  // `previewSupersede` to the live core; the agent reads them to render its artifacts + validate revisions.
  const bridge: DayCompatBridge = {};
  // The wait discipline is DECLARED by the caller (kestrel-4fc9), never inferred from the host: a
  // non-interactive stdin is indistinguishable from the primary agentic path, and sniffing it would put a
  // host property on the control-flow path. `--no-author` wins if both are somehow set (absence is final).
  // Supplying `--max-wait` implies `--await-author` — bounding a wait is a declaration you intend to wait.
  // NEITHER flag ⇒ `default-fast`: run from staged documents, refuse PROMPTLY on a miss so a published
  // command always terminates under plain node (supersedes the 0.4.x 900s-default ride).
  const policy: AuthorPolicy =
    opts.noAuthor === true ? "no-author" : opts.awaitAuthor === true || opts.maxWaitSec !== undefined ? "await" : "default-fast";
  const agent = dayFileHandshakeAgent({ hooks, dir, maxWaitMs, pollMs, bridge, policy });

  // ONE shared driver: the day runs OVER runSimulateSession in DAY-COMPAT MODE — the day's own structural
  // wakes are the cadence (NO injected platform staleness → Divergence #1), and the CONTROL supersede
  // carries the day's `{target,note}` bytes (Divergence #2), so the emitted stream is byte-identical to the
  // frozen stepped reference. `dayReport` (via `dayCompat`) surfaces the frozen-basis EpisodeReport.
  const result = await runSimulateSession({
    ...(opts.events !== undefined ? { events } : { busPath: opts.busPath! }),
    agent,
    wakes: opts.wakes ?? [],
    structural: opts.structural ?? false,
    fillModel: opts.fillModel,
    rUsd: opts.rUsd,
    dayCompat: true,
    dayBridge: bridge,
    ...(opts.instruments !== undefined ? { instruments: opts.instruments } : {}),
    ...(opts.fairTauYears !== undefined ? { fairTauYears: opts.fairTauYears } : {}),
    ...(opts.makerFairParams !== undefined ? { makerFairParams: opts.makerFairParams } : {}),
    ...(opts.authorCutoff !== undefined ? { authorCutoff: opts.authorCutoff } : {}),
  });

  // The wake trace + concatenated authored text are a PURE reconstruction of the SINGLE driver run:
  // `computeWakes` gives the delivered cadence (DAY-COMPAT delivers exactly this set), and each captured
  // `supersede` is an accepted revision (a pass / fail-closed refusal carries none). No second run.
  const captured = result.captured;
  const supersedeDocOf = (key: WakeKey): string | undefined => {
    for (const a of captured.get(key)?.turn.actions ?? []) if (a.kind === "supersede") return a.document;
    return undefined;
  };
  const wakeSeq = computeWakes(sessionDate, opts.wakes ?? [], opts.structural ?? false, firstTs, lastTs);
  // FAIL-CLOSED HONESTY (kestrel-zox3): a staged `revision-<n>`/`pass-<n>` for a wake ordinal that no wake
  // will deliver (`n ≥ wakeSeq.length`) is SILENTLY unused today — omitting `--wakes` fires zero wakes, so
  // a staged `revision-0.kestrel` never reaches the decide seam and the entire wake→supersede→adopt→exit
  // loop is skipped, yet the run still settles green + profitable (the AX wave-6 footgun). Surface it
  // LOUDLY on the host status channel (off the graded line, like every other notice) — never a silent
  // green. This reads only the injected host hooks (an `exists` probe); it never touches the graded stream.
  const ignoredReplies = stagedIgnoredWakeReplies(hooks, dir, wakeSeq.length);
  if (ignoredReplies.length > 0) hooks.notify(renderIgnoredRevisionNotice(dir, wakeSeq.length, ignoredReplies));
  // The captured turns are keyed by stable wake identity (kestrel-5zl.19), so rebuild each DAY-COMPAT wake's
  // key from the SAME frontier facts the driver seeded it with: an injected day wake is a `structural`/`seed`
  // source whose trigger is its HHMM label, and its per-source ordinal counts only its OWN source's wakes.
  const perSource = new Map<WakeSource, number>();
  const wakeKeys: WakeKey[] = wakeSeq.map((w) => {
    const source: WakeSource = w.structural ? "structural" : "seed";
    const k = perSource.get(source) ?? 0;
    perSource.set(source, k + 1);
    return `${source}#${k}#${w.label}`;
  });
  const wakeRecords: WakeRecord[] = wakeSeq.map((w, n) => ({
    n,
    ts: w.ts,
    label: w.label,
    structural: w.structural,
    revised: supersedeDocOf(wakeKeys[n]!) !== undefined,
  }));
  // plansText = print(plans-0) ++ "\n\n" ++ print(each accepted revision) — the ledger lineage key, rebuilt
  // from the captured documents (print(parseDoc(...)) is the day's exact canonical form).
  const openDoc = supersedeDocOf(OPEN_WAKE_KEY);
  let plansText = openDoc !== undefined ? print(parseDoc(openDoc)) : "";
  for (const key of wakeKeys) {
    const d = supersedeDocOf(key);
    if (d !== undefined) plansText += "\n\n" + print(parseDoc(d));
  }

  // The graded report is the driver's — built from the SAME `core.emitted` (`determinism_hash =
  // emitted.hash()`, the frozen basis), never re-derived here.
  const report = result.report;
  if (report === undefined) throw new Error("day: driver returned no report under dayCompat (fail-closed, invariant)");
  // ADOPTION-BOUND-NOTHING on the DEFAULT terminal (kestrel-gjx5 residual). The engine already writes the
  // teaching reason onto the plan's own lifecycle trace (kestrel-c25w), but that lands ONLY in the report
  // JSON; the default `day` terminal (the notify → stderr status channel) shows just armed/expired counts, so
  // a newcomer running plain `day` never sees that an adopter armed but bound 0 legs (its exit protection
  // INERT while the run still grades green). Echo it LOUDLY on the plan's line, off the graded stream — same
  // host status channel every other day notice uses. Reads only the settled report; touches nothing graded.
  const boundNothing = armBoundNothingPlans(report);
  if (boundNothing.length > 0) hooks.notify(renderArmBoundNothingNotice(boundNothing));
  if (opts.out !== undefined) writeReport(opts.out, report);
  return { report, plansText, wakes: wakeRecords, captured, blotter: result.blotter };
}
