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.
705 lines (672 loc) • 71.6 kB
text/typescript
/**
* # session/harness/prompt — the prompt profile + fail-closed turn parser (provider-free)
*
* The PURE half of the BYOK live harness (kestrel-rul, ADR-0013): it turns a frozen, date-blind
* {@link BriefingInput}/{@link ActingFrame} into the model prompt, and turns the model's raw reply
* back into a validated {@link AgentTurn} — with NO provider SDK import, NO wall clock, and NO
* network. `{@link liveAgent}` composes these two functions around an injected {@link LlmClient};
* the concrete AI-SDK client is the only file that touches the wire.
*
* ## Fail-closed by construction (ADR-0013 (a), m9i)
* The reply is parsed into an {@link AgentTurn}, never silently repaired. The three m9i outcomes
* are kept DISTINCT and never collapsed: an *explicit* `standDown` the model authored, an *invalid
* output* (unparseable / malformed), and a *provider failure* (raised by the client). An invalid
* output is turned into a **legitimate pass** (`actions: []`) carrying a JOURNAL note naming the
* failure — never a fabricated `standDown` (which would conflate "the agent chose to de-arm" with
* "the harness could not read the reply"), and never a crash. The standing book keeps riding its
* own TP/EXIT and the platform staleness backstop, so a pass is fail-closed-enough while staying
* honest about what happened. The `standDown` action described in the prompt copy below is the ONE
* agent-path de-arm — its discriminant, predicates, and turn factory live in `src/engine/disarm.ts`
* (kestrel-z473.5), the single home for the de-arm rule.
*
* ## Scrub at capture time (ADR-0013 (e))
* {@link scrubSecrets} is the credential fence the AI-SDK client runs over every byte of wire
* evidence BEFORE it hits any artifact — never a credential in a committed record (an m9i
* acceptance criterion, met structurally). It lives here (provider-free) so the scrub can be
* tested with no network and no provider import.
*/
import type { AgentTurn, Action, AgentConfig, AuthoringReply } from "../agent.ts";
import type { BriefingInput, Kernel, WakeDeltaInput } from "../../frame/types.ts";
import { assertFormatMaterialized, renderBriefing, renderWakeDelta, renderWakeDeltaStreamed, type MaterializedFormat } from "../../frame/render.ts";
import { renderBriefingJson, renderWakeDeltaJson, serializeFrameJson } from "../../frame/render-json.ts";
import { PANE_CATALOG, type ViewSelection } from "../../frame/pane-catalog.ts";
import type { SeatId } from "../../frame/seat.ts";
import { parse } from "../../lang/index.ts";
import { defaultSeriesRegistry, unknownSeriesDiagnostic } from "../../series/index.ts";
// ─────────────────────────────────────────────────────────────────────────────
// The prompt profile — a byte-stable system prompt (AuthorPolicy.prompt_sha256 hashes THIS)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The **baseline prompt profile** (ADR-0013 (d)): the system bytes held constant across every model
* in the controlled division. It teaches the {@link AgentTurn} contract — the reply is ONE JSON
* object `{ journal?, actions[] }`, each action a tagged union member, and the `supersede` document
* is a Kestrel surface statement. MUST be byte-stable: {@link liveAgent} hashes it into the config's
* `promptHash` (`AuthorPolicy.prompt_sha256`), so any edit mints a new grid column. No wall clock,
* no per-run interpolation.
*/
export const BASELINE_SYSTEM_PROMPT = [
"You are Kestrel, a disciplined options-trading agent acting inside a deterministic simulator.",
"At each vantage you are handed a FROZEN, date-blind cockpit frame (relative time only — never a calendar date).",
"You decide what to DO and reply with EXACTLY ONE JSON object and nothing else (no prose, no markdown fences).",
"",
"The JSON object is a turn:",
' { "journal": "<optional one-line pre-hoc thesis + invalidation>", "actions": [ <Action>, ... ] }',
"",
"Each Action is one of these tagged shapes:",
' { "kind": "supersede", "document": "<a Kestrel View/Wake/Plan document>", "note": "<optional>" }',
' - Author or revise your standing book. The FIRST turn (OPEN) supersede is your initial arm.',
' - `document` MUST be valid Kestrel source, e.g. PLAN rider budget 0.5R ttl +60m',
' WHEN phase open DO buy 1 atm C @ lean(bid, fair, 0.5)',
' { "kind": "scheduleWake", "at": { "kind": "inMinutes", "minutes": <n> }, "reason": "<why>" }',
' - or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.',
' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "<sym>", "strike": <n>,',
' "right": "C"|"P", "qty": <n>, "price": "<Kestrel price expr, e.g. @fair or cap fair,0.80>" } }',
' - A single-leg OPTION order. price is a Kestrel expression, never a bare mid. SELL is floored at intrinsic.',
' - To cross EQUITY/SPOT immediately, DROP strike+right (ADR-0017): { "kind": "placeOrder",',
' "order": { "side": "buy"|"sell", "instrument": "<sym>", "qty": <shares>, "price": "@mid" } }.',
' { "kind": "cancelOrder", "ref": "<resting order ref>" }',
' { "kind": "standDown", "reason": "<why>" } - de-arm clean; inventory rides its TP/EXIT. The safe default.',
"",
'To PASS (do nothing this wake — standing plans keep managing), reply with an empty actions list: { "actions": [] }.',
"Bounded risk always: never place a naked sell, never anchor a price on the mid, keep within your R budget.",
"If you are unsure, PASS or standDown rather than guess. Output ONLY the JSON object.",
].join("\n");
/** The stable identity of this profile (a label, not the hash). */
// BYTE-FROZEN — the A/B control (its bytes ARE its `prompt_sha256` grid identity; never pad or "fix" it).
// UNCACHED-CONTROL CAVEAT (kestrel-wa0j.1): baseline-v0 runs predate the cache-TTL axis, so its recorded
// `conversation-cached` economics were measured under the provider-default 5-minute TTL — in any live-paced
// session (wakes > 5 min apart) every wake missed the expired cache and re-wrote the full prefix at the
// cache-write premium. Read those columns as an effectively UNCACHED control, not as cached-policy costs;
// the TTL knob is `AgentConfig.cacheTtl` (documented here, deliberately not retrofitted into this profile).
export const BASELINE_PROMPT_PROFILE = "baseline-v0";
// ─────────────────────────────────────────────────────────────────────────────
// The authoring-taught profile — teaches the Kestrel DSL by worked, copyable example
// (kestrel-rul followup, dry-run-1 fix)
// ─────────────────────────────────────────────────────────────────────────────
//
// WHY THIS PROFILE EXISTS (dry-run-1, docs/results/dry-run-1-live-baseline.md): all three live
// models wasted 30–47% of their turns on the SAME parse escape — *"unexpected `DO` after the WHEN
// clause"* — because they COLLAPSED the multi-line `PLAN … / WHEN … / DO …` document onto one line.
// The old `baseline-v0` profile actually TAUGHT that collapse: its worked example rendered
// `WHEN phase open DO buy …` on one line and never showed the `\n`-escaped JSON string the model
// must author. Meanwhile the external tournament arms authored VALID plans because the tournament
// PROTOCOL.md handed them the `\n `-escaped document verbatim. On the equity (EQ) cell the models
// under-engaged entirely — journalling "no chain legs available" and never reaching for the
// equity-leg grammar (`buy N shares`), which the option-only baseline never showed them.
//
// This profile fixes the teaching, not the grammar (the parser is unchanged — honesty/fail-closed
// guards intact). It shows, VERBATIM and COPYABLE: the multi-line block layout, the exact JSON
// turn bytes (with the `\n ` clause separators spelled out), an options rider with a TP/EXIT
// bracket, an equity-leg plan, a VIEW/pane request, a stand-down, and a pass. Each worked document
// is a member of {@link AUTHORING_EXAMPLE_DOCUMENTS}, and every JSON illustration is
// `JSON.stringify` of one of those documents — so a prompt example can NEVER teach syntax the real
// parser rejects (proven by a test that runs each through `parse()`), and the JSON never drifts
// from the parseable Kestrel it wraps.
/**
* The worked Kestrel documents the authoring profile teaches — each is CANONICAL, PARSEABLE source.
* Exported so a determinism test can run EACH through the real {@link parse} (the prompt can never
* teach invalid syntax) and assert the prompt embeds each verbatim. Keep these BYTE-STABLE: they are
* folded into {@link AUTHORING_SYSTEM_PROMPT}, whose sha256 is the profile's `prompt_sha256`.
*/
export const AUTHORING_EXAMPLE_DOCUMENTS = {
/** Options rider: the multi-line spine — header on line 1, then EACH clause on its OWN indented
* line. `WHEN` and `DO` are NEVER on the same line (that collapse is the #1 dry-run-1 failure).
* A deliberately generic warm-up shape, not any benchmark cell's setup. */
optionsRider: [
"PLAN atm-rider budget 0.5R ttl +60m",
" WHEN spot > vwap",
" DO buy 1 atm C @ lean(bid, fair, 0.5)",
" TP +80% frac 0.5 @ fair",
" EXIT spot < vwap held 60s @ fair",
].join("\n"),
/** Equity-leg plan (ADR-0017): `USING exec <SYM>` + `DO buy N shares` — a spot leg has NO strike
* or right. Shown so a model on a chain-less SPOT tape reaches for the equity grammar instead of
* standing down. A generic FADE shape (price COMES to you): the buy rests at `@ mid` BELOW the
* cross and fills on the pullback — the correct pricing when you fade a cross toward VWAP (NOT the
* EQ cell's breakout thesis — teach grammar, not the setup). */
equityReversion: [
"PLAN eq-revert budget 1R ttl 16:00",
" USING exec SPY",
" WHEN spot crosses below vwap",
" DO buy 100 shares @ mid",
" TP +2%",
" EXIT spot crosses above vwap @ bid",
].join("\n"),
/** Equity-leg plan (ADR-0017), the CHASE mirror of {@link equityReversion}: a breakout you must
* LIFT THE OFFER to catch. Because a resting BUY fills only when the ask crosses strictly BELOW it,
* a continuation UP never fills a `@ mid` buy (the ask climbs away) — so the buy prices `@ ask+2c`,
* STRICTLY THROUGH the ask, to cross now. A generic breakout shape (teach the pricing, not the
* setup). */
equityBreakout: [
"PLAN eq-breakout budget 1R ttl 16:00",
" USING exec QQQ",
" WHEN spot crosses above hod",
" DO buy 100 shares @ ask+2c",
" TP +2%",
" EXIT spot crosses below vwap @ bid",
].join("\n"),
/** Level-break entry with a STATE trigger (ADR-0017 equity leg). The trap this teaches against
* (kestrel-4pm): `WHEN spot crosses below X` is an EDGE — it fires ONLY on a fresh crossing (spot
* must be ABOVE X at arm, then fall through). Armed when spot is ALREADY below X it can NEVER fire
* (no transition to observe), so the plan sits armed forever and never enters. When the level is
* already broken, use the STATE trigger `WHEN spot < X` (fires whenever the condition HOLDS,
* including already-true at arm). A generic already-through shape — teach the trigger, not the trade. */
levelBreakState: [
"PLAN brk-entry budget 1R",
" USING exec SPY",
" WHEN spot < 499.29",
" DO buy 100 shares @ ask+2c",
" TP +2%",
" EXIT spot > 500 @ bid",
].join("\n"),
/** A VIEW request: a name (+ optional `budget`), then one indented pane line each (a pane is a
* catalogued pane id, optionally followed by args the pane understands). Every pane + arg here is one
* the catalog actually SERVES against a product-shaped frame (kestrel-wa0j.19 §6 — the CI fence
* `harness.prompt-profile` resolves + materializes this View so a taught example the arg gate would
* refuse can never ship again): `tape 5m` re-buckets the served 1-minute tape to 5-minute candles
* (the ONE window arg the tape pane takes); `chain` + `levels` are plain, arg-less panes. */
view: ["VIEW cockpit", " tape 5m", " chain", " levels"].join("\n"),
} as const;
/** Every worked document, in the order the prompt presents them — the corpus the profile test parses. */
export const AUTHORING_EXAMPLE_DOCUMENT_LIST: readonly string[] = [
AUTHORING_EXAMPLE_DOCUMENTS.optionsRider,
AUTHORING_EXAMPLE_DOCUMENTS.equityReversion,
AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout,
AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState,
AUTHORING_EXAMPLE_DOCUMENTS.view,
];
/** Render a supersede turn as the EXACT JSON bytes a model must emit — `JSON.stringify` of the same
* verified `document`, so the `\n ` clause separators are shown literally and the illustration can
* never drift from the parseable Kestrel it wraps. */
function supersedeTurnJson(document: string): string {
return JSON.stringify({ actions: [{ kind: "supersede", document }] });
}
/**
* The **authoring-taught prompt profile** (kestrel-rul followup) — the corrected baseline for the
* controlled division and the profile every request-loop teaching extends. Same {@link AgentTurn}
* contract as `baseline-v0`, but it TEACHES the Kestrel authoring grammar by worked, copyable
* example. It is example-driven ON PURPOSE: a live A/B showed a compact grammar-spec-only variant
* regressed authoring (opus 0%→15% invalid; equity engagement lost), so the worked JSON turns stay —
* but the examples are deliberately GENERIC shapes (a warm-up call, a VWAP reversion), NOT any
* benchmark cell's setup, so this teaches SYNTAX, not the trade. MUST be byte-stable: {@link liveAgent}
* hashes it into the config's `promptHash`, so any edit mints a new grid column. No wall clock.
*/
export const AUTHORING_SYSTEM_PROMPT = [
"You are Kestrel, a disciplined trading agent acting inside a deterministic simulator.",
"At each vantage you are handed a FROZEN, date-blind cockpit frame (relative time + an HH:MM ET clock only — never a calendar date).",
"You decide what to DO and reply with EXACTLY ONE JSON object and nothing else (no prose, no markdown fences).",
"",
"═══ THE TURN ENVELOPE ═══",
"Your whole reply is ONE turn object:",
' { "journal": "<optional one-line pre-hoc thesis + invalidation>", "actions": [ <Action>, ... ] }',
'To PASS (do nothing this wake — your standing plans keep managing themselves), reply with an empty list: { "actions": [] }',
"",
"Each Action is exactly one of these tagged shapes:",
' { "kind": "supersede", "document": "<a Kestrel PLAN/VIEW/WAKE document>", "note": "<optional>" }',
" Author or revise your standing book. Your FIRST turn (OPEN) supersede is your initial arm; a later",
" supersede REPLACES the whole document (use it to tighten a stop, add a leg, or re-arm).",
' { "kind": "scheduleWake", "at": { "kind": "inMinutes", "minutes": <n> }, "reason": "<why>" }',
' or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.',
' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "<sym>", "strike": <n>,',
' "right": "C"|"P", "qty": <n>, "price": "<Kestrel price expr, e.g. @fair or cap fair,0.80>" } }',
" A single-leg OPTION order. price is a Kestrel expression, never a bare mid. SELL is floored at intrinsic.",
' To cross EQUITY/SPOT NOW, DROP strike+right (ADR-0017 — a spot leg has neither): { "kind": "placeOrder",',
' "order": { "side": "buy"|"sell", "instrument": "<sym>", "qty": <shares>, "price": "@ask+2c" } }. Same @-anchors as a plan leg.',
" PRICE TO YOUR INTENT: a resting BUY fills only when the ask crosses STRICTLY BELOW it. To cross NOW —",
" a breakout/continuation you must CHASE — LIFT THE OFFER: price STRICTLY THROUGH the ask (a marketable",
" number ABOVE it, e.g. `@ask+2c`; `@ask` alone does NOT cross under the strict `<` floor). `@mid` /",
" `@lean(bid, ask, …)` are RESTING / FADE prices BELOW the ask — they fill only when price PULLS BACK to",
" you, so a `@mid` buy on a rising tape NEVER fills (the ask climbs away). Mirror for a sell (lift = through the bid).",
' { "kind": "cancelOrder", "ref": "<a resting order ref from the frame kernel>" }',
' { "kind": "flatten", "instrument": "<sym>", "note": "<optional>" }',
" CLOSE your whole position in <sym> NOW: cancels the managing plan's resting orders for it AND crosses a",
" COVERED sell to settle — one step, always covered (never naked). Unlike standDown (de-arm, inventory RIDES),",
" flatten LIQUIDATES. A flat book is a clean no-op. After a flatten the position is flat and its 1R is freed.",
' { "kind": "standDown", "reason": "<why>" } — de-arm clean; inventory rides its own TP/EXIT. The safe default.',
"",
"═══ HOW TO WRITE A KESTREL `document` (this is where most mistakes happen) ═══",
"A Kestrel document is MULTI-LINE and INDENTATION-STRUCTURED:",
" • line 1 is the PLAN header: PLAN <name> [budget <n>R] [ttl <+45m|16:00>] [regime {intraday: trend}]",
" Do NOT add a `regime {…}` gate unless the frame shows a regime/predictor feed: with no feed it",
" fail-closes and the plan NEVER arms — it sits `authored (blocked: regime … UNKNOWN)` in the KERNEL",
" (acting) plan states, holding no position. Omit the gate on a plain SPOT/equity tape.",
" • EVERY clause after it is on its OWN line, indented by two spaces: USING, WHEN, DO, TP, EXIT, RELOAD, ALSO.",
" • `WHEN` and `DO` are ALWAYS separate lines. NEVER put two clauses on one line — `WHEN … DO …` is",
" the single most common parse error (\"unexpected `DO` after the WHEN clause\"). One clause per line.",
" • WHEN/DO/TP/EXIT are clauses INSIDE the PLAN block — never top-level statements on their own.",
" • TP on a percentage/multiple KEEPS its sigil: `TP +80%` or `TP 2x` (a bare `TP +2` is rejected).",
"",
"Because the document lives INSIDE a JSON string, each line break is a literal \\n and the two-space",
"indent is two spaces after it. So this plan you want to author:",
"",
AUTHORING_EXAMPLE_DOCUMENTS.optionsRider,
"",
"is written in your JSON turn EXACTLY like this (copy this shape — note the \\n between every clause):",
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.optionsRider),
"",
"═══ WORKED, COPYABLE EXAMPLES (generic shapes — teach the grammar, decide the trade yourself) ═══",
"",
"① Arm an OPTIONS rider (a call with a take-profit + a time-stop bracket). The document + its JSON turn:",
AUTHORING_EXAMPLE_DOCUMENTS.optionsRider,
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.optionsRider),
"",
"② Arm an EQUITY plan — when the frame is a SPOT/equity tape with NO option chain, TRADE THE SHARES.",
" An equity leg is `buy N shares` (no strike, no right) under `USING exec <SYM>`. Do NOT stand down",
" just because there are no option legs — reach for the equity grammar. (a) FADE a cross — price COMES",
" to you, so REST the buy at `@ mid` below the level; it fills on the pullback:",
AUTHORING_EXAMPLE_DOCUMENTS.equityReversion,
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.equityReversion),
" (b) CHASE a breakout — a continuation UP that you must LIFT THE OFFER to catch. A `@ mid` buy would",
" NEVER fill (the ask climbs away), so price `@ ask+2c` — STRICTLY THROUGH the ask — to cross now:",
AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout,
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout),
" (c) ARM ON THE RIGHT SIDE OF THE LEVEL. `spot crosses below|above X` is an EDGE — it fires ONLY on a",
" FRESH crossing, so spot must still be on the ENTRY side when you arm: to catch a BREAK, arm the edge at",
" OPEN, AHEAD of it. If spot has ALREADY crossed X, `crosses` NEVER fires (no transition to observe) — the",
" plan sits armed forever and never enters (the #1 missed-entry bug). For an already-through level use the",
" STATE trigger `WHEN spot < X` (or `> X`): it is true whenever the condition HOLDS, INCLUDING already-true",
" at arm, so it fires the instant you arm below the line:",
AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState,
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState),
" (d) DON'T GATE A MID-SESSION ENTRY ON `phase`. `phase` is a LEVEL state — pre|open|regular|close|post —",
" and `open` is the MOMENTARY opening print (09:30), already elapsed before your first wake: every",
" mid-session wake reads `phase regular`. So `WHEN phase open` armed mid-session is DEAD — it is false",
" at every tick, NEVER fires, and fails SILENT (no reject, no fill — the plan just sits `armed`). To gate",
" a mid-session entry, gate on PRICE: `WHEN spot crosses above <level>` (edge — arm ahead of the break) or",
" `WHEN spot > <level>` (state — fires when already through), as in (a)–(c). Never `phase open`.",
"",
"③ Request a VIEW (name, then one pane per indented line; a pane is a name + plain args, no `:`/`{}`):",
AUTHORING_EXAMPLE_DOCUMENTS.view,
" " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.view),
"",
"④ Stand down (de-arm cleanly — the correct move on an edgeless/untradeable tape):",
' ' + JSON.stringify({ actions: [{ kind: "standDown", reason: "edgeless chop — no defined-risk setup; standing down" }] }),
"",
"⑤ Pass (do nothing this wake; standing plans keep managing):",
' ' + JSON.stringify({ actions: [] }),
"",
"═══ PRICE EXPRESSIONS (the `@ …` and order `price`) ═══",
"Never anchor on a bare mid. Legal anchors: fair, intrinsic, basis, bid, ask, mid, last, join, improve, stub.",
"Combine them: @fair @lean(bid, fair, 0.5) @min(fair, mid) cap fair peg +2c / -1c offsets.",
"FADE vs CHASE decides your price (a resting BUY fills only when the ask crosses STRICTLY BELOW it):",
" • FADE — let price come to you: REST a buy at/below the mid (`@mid`, `@lean(bid, ask, …)`); it fills on the pullback.",
" • CHASE a breakout — LIFT THE OFFER: price a buy STRICTLY THROUGH the ask (`@ask+2c`); it crosses now.",
" A resting `@mid` buy on a rising tape NEVER fills. Mirror for a sell: fade rests above the mid, chase lifts through the bid.",
"",
"═══ DISCIPLINE ═══",
"Bounded risk always: never place a naked sell, never anchor a price on the mid, keep within your R budget.",
"SIZE BY COST BASIS, NOT STOP DISTANCE (ADR-0017): an equity/spot leg's bounded risk is its FULL cost basis",
"`qty × entry_px` (an option's is `qty × premium × mult`), so `qty ≤ 1R_budget / entry_px` — size to the kernel's",
"`sizing: max ~N shares` cue. Sizing by stop-loss distance oversizes and the entry is SILENTLY clamped (never fires).",
"On an edgeless or untradeable tape the correct move is to PASS or standDown for $0 — manufacturing edge is punished.",
"If you are unsure, PASS or standDown rather than guess. Output ONLY the JSON turn object.",
].join("\n");
/** The stable identity of the authoring-taught profile (a label, not the hash). */
export const AUTHORING_PROMPT_PROFILE = "authoring-v1";
// ─────────────────────────────────────────────────────────────────────────────
// The viewshop-v1 profile (ADR-0029 §4) — the OPTIMIZED-division arm that TEACHES the bounded loop
// ─────────────────────────────────────────────────────────────────────────────
//
// This profile EXTENDS the DSL-taught baseline ({@link AUTHORING_SYSTEM_PROMPT}) and adds what the
// controlled baseline deliberately lacks: (a) that `requestView` is a legal move + when to use it,
// (b) the pane MENU interpolated from the ONE {@link PANE_CATALOG} (so it can never advertise a pane
// that does not materialize, and a catalog edit mints a new promptHash ⇒ a new ConfigId), (c) that
// authoring errors are RECOVERABLE (a guided error, fix + resubmit), (d) the Brief/Mandate two-channel
// split + the hard guard (the Brief directs perception, never admission), (e) the T-5m invariant (a
// view costs attention, not time). New promptHash ⇒ new ConfigId ⇒ the measured optimized arm; the
// baseline stays BYTE-FROZEN (this is additive, a distinct registry entry).
/** The pane MENU — generated from the single {@link PANE_CATALOG}, so it is the SAME source `render`
* and the `View` grammar read. Byte-stable given a fixed catalog; a catalog change changes these
* bytes ⇒ the profile's `prompt_sha256` ⇒ a new ConfigId (ADR-0029 §4, ADR-0013 (d)). */
function paneMenuLines(): readonly string[] {
return PANE_CATALOG.map(
(e) => ` • ${e.id} — ${e.title}: ${e.description} [${e.attribution}, ~${e.tokenCostEstimate} tok]`,
);
}
/** The viewshop VIEW-request illustration — `JSON.stringify` of a real, parseable VIEW document (the
* same {@link AUTHORING_EXAMPLE_DOCUMENTS.view} the profile test parses), so the wire shape can never
* drift from the Kestrel it wraps. */
function requestViewJson(view: string, reason: string): string {
return JSON.stringify({ requestView: { view, reason } });
}
/**
* The **viewshop-v1 prompt profile** (ADR-0029 §4). Byte-stable GIVEN a fixed catalog; {@link liveAgent}
* hashes it into `promptHash`, so it is a distinct grid column from `authoring-v1`. No wall clock, no
* per-run interpolation (the pane menu is a pure function of the compile-time catalog).
*/
export const VIEWSHOP_SYSTEM_PROMPT = [
AUTHORING_SYSTEM_PROMPT,
"",
"═══ YOU MAY ASK FOR A DIFFERENT VIEW (the authoring loop) ═══",
"Right now you are at a FROZEN vantage — one moment, which you may look at again. If the current screen",
"does not show what you need to test the thesis your BRIEF points at, do NOT force a Plan you do not",
"believe and do NOT stand down for a look you could have had. Ask for a DIFFERENT lens on the SAME frozen",
"market. Reply with a view request INSTEAD of a turn (never both actions AND requestView):",
' { "requestView": { "view": "<a Kestrel VIEW document>", "reason": "<why you need this lens now>" } }',
"The `view` is a VIEW document exactly like example ③ above (a name + optional budget, then one pane per",
"indented line). For instance:",
" " + requestViewJson(AUTHORING_EXAMPLE_DOCUMENTS.view, "need the near-money chain + levels before I commit"),
"",
"The panes you may select (this menu IS the single source the renderer materializes from — a pane not",
"listed here cannot be shown):",
...paneMenuLines(),
"",
"A view costs ATTENTION, not TIME (the T-5m invariant): it NEVER advances the clock, NEVER consumes a",
"wake, NEVER reveals future data — you are looking again at the SAME moment. View requests are BOUNDED:",
"you get a small number of lenses and a shared attention budget, and when it is spent the session stands",
"down flat. Shop only when a lens would genuinely change your decision; the default View is meant to be",
"enough that shopping is rarely worth its cost.",
"",
"═══ AUTHORING ERRORS ARE RECOVERABLE ═══",
"If your author (a Plan document OR a view request) is malformed, you will be handed a GUIDING ERROR that",
"names the defect. That is NOT a rejection — read it, fix ONLY what it names, and resubmit. A repair",
"counts against the SAME bounded budget as a view request, so fix it in as few attempts as you can.",
"",
"═══ YOUR CONTEXT HAS TWO CHANNELS — MANDATE vs BRIEF (keep them apart) ═══",
" • the MANDATE is HARD and machine-checked (envelope, R budget, never-naked). It is a FENCE and the",
" ONLY input to admission — you may never do what it forbids, and it never tells you what to look for.",
" • the BRIEF is SOFT, directional English (your goal, approach, persona). It directs what you go LOOK",
" for and how you author. It NEVER authorizes anything and NEVER enters an admission check.",
"Decide which View to request FROM YOUR BRIEF: if the current lens will not let you test the thesis your",
"Brief points at, request the lens that would — within budget. Then commit a Plan, stand down, or pass.",
].join("\n");
/** The stable identity of the viewshop profile (a label, not the hash). */
export const VIEWSHOP_PROMPT_PROFILE = "viewshop-v1";
// ─────────────────────────────────────────────────────────────────────────────
// The manage-v1 profile (ADR-0032 §5, owner watcher-v1) — the MANAGE-ONLY watcher tier
// ─────────────────────────────────────────────────────────────────────────────
//
// WHY THIS PROFILE EXISTS (docs/results/author-and-fan/, PR #8): the frozen-plan fan-out ran its
// watcher under `authoring-v1` — the STRATEGIST's authoring prompt — so at its first wake the small
// watcher tried to ARM-NEW-AUTHORITY (a `supersede`, the authoring prompt's headline move). The
// admission Gate correctly refused it at the tier boundary and ESCALATED; on the frozen fan there is
// no captured strategist re-brief, so every watcher collapsed the armed plan and the whole fan
// measured $0. The watcher was mis-cast as an AUTHOR. This profile casts it as a MANAGER: it teaches
// the SAME Kestrel order/price grammar as `authoring-v1` (the watcher still places/cancels/sizes
// orders and takes profit/exits within the armed book) but REMOVES the author's mandate — it may not
// arm new standing authority (`supersede`), and it escalates the few decisions that need one via the
// minimal owner-v1 escalation move. This is the tier `fanWatchers` / a small watcher should default
// to (ADR-0032 §5, owner watcher-v1 = manage-only + minimal escalation). A distinct promptHash ⇒ a
// distinct ConfigId ⇒ a distinct grid column; `authoring-v1` stays BYTE-FROZEN (this is additive).
/**
* The **manage-v1 prompt profile** (ADR-0032 §5, owner watcher-v1). EXTENDS the DSL-taught baseline
* ({@link AUTHORING_SYSTEM_PROMPT} — so the watcher knows the exact order/price grammar it manages
* with) and OVERLAYS the manage-only doctrine: manage WITHIN the strategist's armed Plan and budget
* (reload/exit/adjust/size, place/cancel orders inside the envelope), never arm NEW authority
* (`supersede`), and ESCALATE — do not guess — when the regime breaks. Byte-stable; {@link liveAgent}
* hashes it into `promptHash`, so it is a distinct grid column from `authoring-v1`/`viewshop-v1`. The
* escalation sentinel below is the token {@link import("./cascade.ts").classifyEscalation} matches;
* the `manage-v1` profile test asserts they agree (they are coupled by design).
*/
export const MANAGE_SYSTEM_PROMPT = [
AUTHORING_SYSTEM_PROMPT,
"",
"═══ YOU ARE A MANAGE-ONLY WATCHER (this OVERRIDES the authoring mandate above) ═══",
"A STRATEGIST has ALREADY authored and ARMED the standing Plan+Brief+Mandate you are handed. You are",
"the small, fast WATCHER that MANAGES that armed book at every wake — you are NOT its author. Your job",
"is to keep the armed book healthy WITHIN its budget and Mandate: press a winner toward its target, and",
"CUT a loser whose premise has broken. You manage the POSITION, not just the plan object.",
"",
"═══ READ YOUR BOOK FIRST — P&L, then PREMISE (do this EVERY wake, before you decide) ═══",
"Mark your position to the tape and check the thesis is still alive BEFORE you choose a move:",
" • UNREALIZED P&L: take each open position's `basis` (its entry, on the POSITIONS line) and compare",
" it to the CURRENT price in the market pane. For a long, `(spot − basis) × qty` — a spot BELOW your",
" basis is an OPEN LOSS you are carrying RIGHT NOW, not a paper abstraction.",
" • PREMISE — JUDGE IT FROM THE FRAME, NOT FROM PLAN TEXT YOU CANNOT SEE. You are handed the POSITION and",
" the tape, never the strategist's authored thesis prose — so RECONSTRUCT the premise from what the frame",
" SHOWS you: your direction (the kernel POSITIONS line) plus the LEVEL your entry leans on, read off the",
" `levels` and `tape` panes on your screen. A long implies a bullish premise — a break/level HOLDING —",
" so ask whether that is STILL TRUE against those panes. If price has fallen back THROUGH the level your",
" long leans on — a poke that failed, a breakout that reversed — the setup is INVALIDATED. That is a",
" FAKEOUT: the reason you are long is GONE, and the position is now an un-thesised loser bleeding toward",
" the close.",
"",
"═══ WHEN THE PREMISE IS BROKEN AND YOU ARE LOSING, CUT IT — THIS IS THE EXPECTED MOVE ═══",
"A losing position on a broken premise is the ONE situation a watcher exists to handle. Do NOT ride it,",
"and do NOT merely NARRATE it — a JOURNAL that says \"this is a fakeout, I should exit\" paired with an",
"empty actions list is a NO-OP: the book keeps bleeding to the close. To exit you must CHANGE THE BOOK.",
"The CLEAN way to get flat is ONE action — `flatten`:",
' { "actions": [ { "kind": "flatten", "instrument": "<SYM>", "note": "premise broke — cutting" } ] }',
"It cancels the managing plan's resting protective/TP sell that RESERVES your shares AND crosses a COVERED",
"closing sell to settle, in ONE step — so you never fight the \"never naked\" refusal, and it can never leave",
"you short. Use `flatten` to close your WHOLE position in <SYM> now; after it, you are flat and your 1R is freed.",
"",
"To close only PART of the position (a TRIM, not a full flatten), do the two-step manage turn by hand:",
" 1. CANCEL the resting protective / take-profit SELL that RESERVES your shares (its `ref` is on the",
" RESTING line of the frame kernel) — an armed plan usually rests a TP over the whole lot, and while it",
" reserves the inventory a fresh closing sell alongside it is refused \"never naked\" (already spoken for).",
" 2. PLACE the closing SELL of the size you want gone, priced to CROSS NOW:",
' { "kind": "placeOrder", "order": { "side": "sell", "instrument": "<SYM>", "qty": <part of your qty>, "price": "@bid-2c" } }',
" — an equity leg (drop strike+right), lifted THROUGH the bid (`@bid-2c`, strictly below it) so it fills",
" at once. A resting `@bid`/`@mid` sell can sit unfilled as the tape drops away — to GET OUT, cross.",
"So the trim turn is: { \"actions\": [ { \"kind\": \"cancelOrder\", \"ref\": \"<resting sell ref>\" },",
' { "kind": "placeOrder", "order": { "side": "sell", "instrument": "<SYM>", "qty": <part of your qty>, "price": "@bid-2c" } } ] }',
"Both flatten and the two-step route straight through the admission Gate (closing a long you hold is never a",
"naked sell, floored at intrinsic) — this is what RECLAIMS the loss. Prefer `flatten` for a full exit.",
"",
"TWO MOVES THAT LOOK LIKE AN EXIT BUT ARE NOT — do NOT reach for these to cut a loser:",
" • `standDown` DE-ARMS THE PLAN BUT DOES NOT LIQUIDATE — your inventory keeps riding to settle. On a",
" plan with no protective stop, standing down leaves the losing shares FULLY exposed to the close.",
" standDown is a clean de-arm, NOT a way out of a position — to get flat, `flatten` (or SELL, step 2 above).",
" • `supersede` (arming a new plan, or bolting a new EXIT onto the book) is BEYOND your authority — it is",
" REFUSED at the tier boundary and does NOTHING (your frozen book is HELD, the loss runs on). Reaching",
" for it to \"add a stop\" just wastes the turn. Manage the book you hold with ORDERS, not authorship.",
"",
"YOU MAY (manage WITHIN the armed Plan + budget):",
" • CUT a loser — `flatten` for a full exit, or cancel-then-SELL to trim (as above) — take profit, trim",
" a runner, reload, add, scale or hedge; place / cancel / adjust orders that MANAGE the armed book,",
" priced with the SAME `@…` grammar as above and SIZED within the standing R budget;",
" • `standDown` to de-arm cleanly when the plan is spent AND you are flat (or content to let residual",
" inventory ride its own TP/EXIT) — never as a substitute for SELLING a loser;",
" • `scheduleWake` your next monitoring wake, or PASS ({ \"actions\": [] }). But PASS is correct ONLY when",
" the armed plan is genuinely managing itself — winning toward its target, or its premise still intact",
" with nothing to adjust. PASS is NOT the default, and it is the WRONG move over a losing, premise-broken",
" position: an empty reply there is a no-op that books the full loss.",
"The admission Gate still bounds every order you place (never naked, floored at intrinsic, within the",
"envelope): judgment does not buy authority — an order OUTSIDE the Mandate is refused fail-closed.",
"",
"YOU MAY NOT (this is the strategist's job, not yours):",
" • arm NEW standing authority. DO NOT emit a `supersede` — that AUTHORS/RE-ARMS a whole Plan (a new",
" thesis, a new mandate). It is BEYOND your manage-only authority: a `supersede` from you is REFUSED",
" at the tier boundary and escalated, never admitted. Manage the plan you were handed with ORDERS; do",
" not re-author it — and do not use it as a roundabout way to add the stop you should just SELL into.",
"",
"═══ MINIMAL ESCALATION — when you need a NEW plan, ASK; do not guess ═══",
"Cutting a broken-premise loser is YOUR job — do it with a cancel-then-SELL, do NOT escalate to dodge it.",
"Escalate ONLY when the situation genuinely needs a NEW thesis/mandate beyond managing the armed book (a",
"regime change that calls for a fresh plan, not merely an exit). To escalate, `standDown` with a `reason`",
"— or journal — that OPENS with the token",
' RE-BRIEF: <one line on what broke and why a re-brief is needed>',
"The driver routes a RE-BRIEF: escalation to the strategist for a re-author; your armed book is not torn",
"down by you on a whim. Escalate SPARINGLY (minimal escalation): most wakes need only a manage action or",
"a PASS. Reserve escalation for a real regime break the armed Plan cannot absorb — never as a way to",
"avoid the closing SELL that cutting a loser requires.",
].join("\n");
/** The stable identity of the manage-only watcher profile (a label, not the hash). */
export const MANAGE_PROMPT_PROFILE = "manage-v1";
/**
* The profile `liveAgent` uses when the caller pins none. Defaults to the corrected
* {@link AUTHORING_PROMPT_PROFILE} — the dry-run-1 fix — while `baseline-v0` stays registered for A/B.
*/
export const DEFAULT_PROMPT_PROFILE = AUTHORING_PROMPT_PROFILE;
/** The registry of byte-stable system prompts by profile label. A named profile resolves to its
* EXACT bytes — the graded config axis (`prompt_sha256`) — never a silent fallback. */
const PROMPT_PROFILE_SYSTEM: ReadonlyMap<string, string> = new Map([
[BASELINE_PROMPT_PROFILE, BASELINE_SYSTEM_PROMPT],
[AUTHORING_PROMPT_PROFILE, AUTHORING_SYSTEM_PROMPT],
[VIEWSHOP_PROMPT_PROFILE, VIEWSHOP_SYSTEM_PROMPT],
[MANAGE_PROMPT_PROFILE, MANAGE_SYSTEM_PROMPT],
]);
/** Resolve a profile label to its byte-stable system prompt. Fails closed on an unknown label — a
* named-but-unregistered profile is a caller error (the bytes ARE the config identity), never a
* guess. */
export function systemPromptForProfile(profile: string): string {
const system = PROMPT_PROFILE_SYSTEM.get(profile);
if (system === undefined) {
throw new Error(`unknown prompt profile "${profile}"; known: ${[...PROMPT_PROFILE_SYSTEM.keys()].join(", ")}`);
}
return system;
}
// ─────────────────────────────────────────────────────────────────────────────
// Rendering the frame into the user message
// ─────────────────────────────────────────────────────────────────────────────
/**
* The MATERIALIZED format to render under: the config's `format`, vouched for by the renderer's own
* fail-closed boundary — or a REFUSAL (kestrel-4gl.26).
*
* This used to answer a `json`/`html` config by quietly rendering `ascii` instead. That is a fail-OPEN
* honesty violation with two teeth: the agent is handed a text screen while its config says it asked
* for structured output, and the run's ConfigId/`promptHash` record a format the run never used — so
* the leaderboard column is mislabelled and the result is not recomputable from its own envelope.
* `assertFormatMaterialized` is the single source of that answer (AGENTS.md: "never a silent default"):
* an unmaterialized format is refused, loudly, naming the format. Since ADR-0052 §2 `json` is a
* materialized Rendering, so it PASSES here and the prompt renderers dispatch it to the json adapter
* (never a silent downgrade); `html` is still charter-only and is refused.
*/
function renderFormatOf(config: AgentConfig): MaterializedFormat {
const format = config.format;
assertFormatMaterialized(format);
return format;
}
/** Render the OPEN briefing into the user message (the date-blind keyframe cockpit). Pure. When a
* `view` is supplied (the bounded authoring loop materializing a requested lens, ADR-0029 §2) the
* body panes are SELECTED + ORDERED by it against the one catalog; absent ⇒ the OPEN default View
* (byte-identical to today). The kernel LEADS non-configurably regardless. An unknown pane /
* over-budget View fails closed inside the renderer (the driver catches it → a repair iteration). */
export function renderBriefingPrompt(briefing: BriefingInput, config: AgentConfig, view?: ViewSelection, seat?: SeatId): string {
const format = renderFormatOf(config);
// The materialized `json` Rendering (ADR-0052 §2) is served through its own adapter as byte-stable
// canonical JSON — NEVER downgraded to a text screen (the kestrel-4gl.26 fail-OPEN). A View/seat
// SELECTS + ORDERS text-screen body panes; json is structural (it carries the whole Frame), so those
// text-only knobs do not apply to it.
if (format === "json") return serializeFrameJson(renderBriefingJson(briefing));
// `seat` (ADR-0041 §2 / A1, kestrel-wa0j.26) is threaded into resolveView so a seat with a founder
// seed for OPEN reads it when no `view` was authored (precedence: authored > seat founder > phase
// default). Absent ⇒ byte-identical to before the role axis. The opt-in that supplies it is the
// driver's `AgentConfig.seatViews`.
return renderBriefing(briefing, {
format,
...(view !== undefined ? { view } : {}),
...(seat !== undefined ? { seat } : {}),
});
}
/** The repair re-ask message (ADR-0029 §2, the repair half of the bounded loop): surface the
* repair-guiding error and ask the model to fix + resubmit. Kept terse so it does not re-explain the
* whole contract — the model already has the system profile + its prior attempt in context. Pure. */
export function renderRepairPrompt(error: string): string {
return [
"Your last reply could not be used. The defect was:",
` ${error}`,
"Fix ONLY what this names and resubmit — either a corrected turn ({ actions: [...] }) or a corrected",
"requestView. Emit ONLY the JSON, no explanation. This repair counts against your authoring budget.",
].join("\n");
}
/**
* Render a WAKE delta frame into the user message (the date-blind since-last-vantage cockpit). Pure.
*
* CACHE-MONOTONE kernel delta-encoding (kestrel-312): when the session runs a STREAMING policy
* (`conversation`/`conversation-cached` — the reader provably holds the prior full kernel in cached
* context) AND the caller threads the prior frame's kernel (`prevKernel`), the SAFETY/CONTROL kernel
* is delta-encoded — only the fields that MOVED are re-emitted, the byte-stable skeleton rides the
* cached prefix ({@link renderWakeDeltaStreamed}). Under `stateless-redraw` (no prior context) — or
* when no `prevKernel` is available (e.g. the first wake, or a non-harness caller) — the wake frame
* is a KEYFRAME carrying the COMPLETE kernel ({@link renderWakeDelta}); self-complete frames stay
* self-complete. The composition is fail-closed-verified inside the renderer.
*
* A supplied `view` (kestrel-wa0j.4 — a scheduled wake's stored View, or a config-pinned forced
* View) SELECTS + ORDERS the body panes against the one catalog; absent ⇒ the default WAKE panes,
* byte-identical to today. The kernel LEADS non-configurably regardless, and the View's own token
* budget is guarded at materialization exactly as on OPEN (fail-closed).
*/
export function renderWakePrompt(frame: WakeDeltaInput, config: AgentConfig, prevKernel?: Kernel, view?: ViewSelection, seat?: SeatId): string {
const format = renderFormatOf(config);
// The materialized `json` Rendering (ADR-0052 §2) is served structurally (byte-stable canonical JSON),
// never downgraded to a text screen. json is NOT the KV-cache-optimized text delta path — a consumer
// diffs the structural frame — so the streaming/prevKernel text-delta knobs do not apply to it.
if (format === "json") return serializeFrameJson(renderWakeDeltaJson(frame));
const policy = config.cachePolicy ?? "conversation-cached";
const streaming = policy !== "stateless-redraw" && prevKernel !== undefined;
// `seat` (ADR-0041 §2 / A1, kestrel-wa0j.26): consulted only when no `view` was authored/delivered
// (precedence in resolveView: authored > seat founder > phase default). Absent ⇒ byte-identical.
const viewOpt = {
...(view !== undefined ? { view } : {}),
...(seat !== undefined ? { seat } : {}),
};
return streaming
? renderWakeDeltaStreamed(frame, prevKernel, { format, ...viewOpt })
: renderWakeDelta(frame, { format, ...viewOpt });
}
// ─────────────────────────────────────────────────────────────────────────────
// Parsing the reply into a validated AgentTurn (fail-closed, never repaired)
// ─────────────────────────────────────────────────────────────────────────────
/** The per-action attribution of an ATOMIC turn rejection (kestrel-gxz). Present iff the fail-closed
* cause was an invalid ACTION (not a top-level JSON/shape defect). Names WHICH action index failed
* and its reason, AND the VALID sibling actions that were NOT applied because the turn is
* all-or-nothing — the exact facts the reject-and-reprompt renderer needs so the model can resubmit
* a valid sibling alone (or fix the invalid one) rather than conclude "execution is broken". */
export interface TurnReject {
/** The index of the FIRST invalid action in the turn. */
readonly failedIndex: number;
/** The validator's reason for that action (e.g. `action[1] supersede: document parse escape — …`). */
readonly failedReason: string;
/** The individually-VALID sibling actions, dropped by atomicity — index + a legible one-line summary. */
readonly validSiblings: readonly { readonly index: number; readonly summary: string }[];
}
/** The outcome of parsing one model reply. `ok` distinguishes a validated turn from a fail-closed
* pass; `reason` (present iff `!ok`) is the human-readable failure the pass carries as its JOURNAL —
* the *invalid output* signal, kept distinct from an authored `standDown` and a provider failure.
* `reject` (present iff `!ok` AND the cause was an invalid action) carries the per-action attribution
* the reject-and-reprompt loop surfaces to the model on its NEXT wake. */
export interface TurnParse {
readonly turn: AgentTurn;
readonly ok: boolean;
readonly reason?: string;
readonly reject?: TurnReject;
}
/** The fail-closed pass: an empty turn whose JOURNAL names why the reply was unusable (never a
* fabricated action, never a crash — ADR-0013 (a), m9i "keep the three outcomes distinct"). */
function failClosedPass(reason: string): TurnParse {
return { turn: { actions: [], journal: `harness: ${reason}` }, ok: false, reason };
}
/** Extract the single JSON object from a raw reply: strip a ```json/``` fence if present, else take
* the substring from the first `{` to the last `}`. Returns `null` when no object is present. */
function extractJsonObject(reply: string): string | null {
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(reply);
const body = (fenced !== null ? fenced[1]! : reply).trim();
const start = body.indexOf("{");
const end = body.lastIndexOf("}");
if (start === -1 || end === -1 || end < start) return null;
return body.slice(start, end + 1);
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** Validate ONE action object into a typed {@link Action}, or return an error string naming the
* defect (fail-closed — a supersede document that is not valid Kestrel is a defect, never armed). */
function validateAction(raw: unknown, i: number): Action | string {
if (!isObject(raw)) return `action[${i}] is not an object`;
const kind = raw.kind;
switch (kind) {
case "pass":
// A `pass` action is a convenience for "do nothing" — folded to an empty actions list upstream.
return { kind: "standDown", reason: PASS_SENTINEL_REASON }; // sentinel; filtered by parseTurn (never emitted)
case "supersede": {
if (typeof raw.document !== "string" || raw.document.trim() === "") return `action[${i}] supersede: missing document`;
let node;
try {
node = parse(raw.document); // pre-validate the Kestrel surface — never hand the driver an unparseable doc
} catch (e) {
return `action[${i}] supersede: document parse escape — ${e instanceof Error ? e.message : String(e)}`;
}
// Registry-aware semantic check: a trigger operand that names an UNKNOWN series (a case-clash
// like `VWAP` for `vwap`, or a typo like `vwpa`) parses fine but resolves UNKNOWN — the plan
// arms then silently never fires. Surface it loudly + repair-guiding rather than let it go dark
// (fail-closed legibility; the phonebook never guesses — registry.ts). A legitimate org fact or
// symbolic literal is NOT flagged (only a near-match to the known market vocabulary is).
const unknownSeries = unknownSeriesDiagnostic(node, defaultSeriesRegistry);
if (unknownSeries !== undefined) return `action[${i}] supersede: ${unknownSeries.message}`;
return { kind: "supersede", document: raw.document, ...(typeof raw.note === "string" ? { note: raw.note } : {}) };
}
case "scheduleWake": {
const at = raw.at;
if (!isObject(at)) return `action[${i}] sche