UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

809 lines 79 kB
/** * # 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 { foldBook, readBus } from "../bus/index.js"; import { renderBriefing, renderWakeDelta } from "../frame/render.js"; import { parse, print } from "../lang/index.js"; import { UNKNOWN } from "../series/index.js"; import { BACKTEST_CONFIG, OPEN_WAKE_KEY, } from "./agent.js"; // 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 } from "./simulate.js"; import { etMinuteOfDay, etWallClockMs } from "./clock.js"; // `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 } from "./sim.js"; import { ARM_BOUND_NOTHING_MARKER } from "../engine/index.js"; const DEFAULT_HOOKS = { 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"), }; // ───────────────────────────────────────────────────────────────────────────── // Wake sequence // ───────────────────────────────────────────────────────────────────────────── /** The structural cadence offsets (minutes before the 16:00 ET close), ordered. */ const STRUCTURAL_OFFSETS_MIN = [120, 60, 30, 15, 10, 5]; // ───────────────────────────────────────────────────────────────────────────── // 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 = { 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, authorCutoff) { 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) { const out = []; 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) { 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) { const wakes = []; const dropped = []; 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) { 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, authored, structural, firstTs, lastTs) { const settleMs = etWallClockMs(sessionDate, 16, 0); const byTs = new Map(); const add = (ts, isStructural) => { 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); } /** * 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, sessionDate) { 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, openTs, sessionDate, sessionOrdinal) { 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 = [ { 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) { 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, body) { 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) { 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, instruction) { 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) { 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) { // 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"; } function numOrNull(v) { 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, kind, n, label, nowTs, instruments, rUsd, tape) { const dump = core.engine.dumpState(); const plans = 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 = []; const fills = []; for (const p of dump.plans) { for (const c of p.children) { const line = { 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, }; } /** * 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, execSym, events, rUsd, cutoffTs, opts = {}) { // 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 = null; let spotTs = null; let chain = null; let book; 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 = null; let spotTs = null; let hod = null; let lod = null; let chain = null; let book; const tape = []; 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 { name = "DayHandshakeError"; code; hint; constructor(o) { super(o.message); this.code = o.code; this.hint = o.hint; } } /** Does this policy refuse promptly on a missing document (vs. ride `--max-wait`)? */ function refusesFast(policy) { 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, dir, firedWakes) { const out = []; 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, firedWakes, ignored) { 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) { 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) { const names = []; const seen = new Set(); 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(hooks, probe, budgetMs, pollMs, beat) { 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, where, budgetMs) { 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, where, policy) { 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, dir, reason, policy) { const rejectPath = join(dir, `reject-${n}.txt`); co