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.

766 lines (717 loc) 41.9 kB
/** * # cli/commands/sim — `sim` (LIGHT, node-runnable): the one-command funnel (kestrel-585 / kestrel-vcn). * * The site's hero command: `npx kestrel.markets sim wsb-gme-meme-short-squeeze`. LIGHT by * construction — it imports only `../../lang` (the real parser, for local --plans * validation) and `../../canonical` (its `canonicalPlansText`, itself lang-only), the * fetch-only {@link ../backend/hosted.ts HostedFunnel}, and the pure `../render/sim.ts` * renderer. No `bun:*`/chdb, so it runs under plain node for every human and agent who * copies the snippet. * * Shape: * - bare `sim` → the MENU (GET /catalog, derived scenario slugs + aliases). * - `sim <lowercase-slug>` → resolve the slug → mint trial → POST /simulate → render the * completed Operation's story + the shareable proof URL. * - `sim <TICKER…>` / mixed-case / a `<when>` selector → this PR ships only the curated-slug * funnel, so these RESERVED forms refuse LOUDLY (exit 2 USAGE), * naming what they will become — the case rule is locked in now * (kestrel-vcn selector grammar; ticker/date selectors are kestrel-do4). * * Backend routing inverts `run`'s local-default for this ONE verb (design §4 rule 1): a * sim positional NAMES platform data, so `sim` always routes to the hosted funnel — the * default base, `--api`/`KESTREL_API` overriding — even with no `--api`. */ import { readFileSync } from "node:fs"; import { appendHostedRun, defaultHostedStorePath, type HostedRunRow } from "../../ledger/hosted.ts"; import { parse, parseMarkdown, KestrelParseError, type KestrelNode } from "../../lang/index.ts"; import { canonicalPlansText } from "../../canonical/plans.ts"; import { sha256 } from "../../crypto/sha256.ts"; import type { OutputCtx } from "../context.ts"; import type { GlobalFlags } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT, asConnectionError } from "../errors.ts"; import { renderOffer, renderSettlementRequired } from "../render/offer.ts"; import { HostedFunnel, DEFAULT_API, resolveScenarioSlug, nearMissSlugs, summarizeReceipts, gradedMetrics, neverFiredReasonOf, gradeArtifactOf, gradeReceiptOf, proofWebUrl, slugifyTitle, SCENARIO_ALIASES, type HostedCatalogEntry, type HostedOperation, type HostedProof, type Spend, } from "../backend/hosted.ts"; import { renderSimStory, renderSimMenu, renderDemoLesson, renderStandDownLesson, renderPaidBoundaryNudge, neverFiredLine, demoLessonObject, standDownLessonObject, paidBoundaryNudgeObject, type SimStory, type SimMenu, type DemoLesson, type StandDownLesson, type PaidBoundaryNudge } from "../render/sim.ts"; import { renderResidencySnippet, renderResidencyOneLine, residencyObject } from "../render/residency.ts"; import type { ResolvedScenario } from "../backend/hosted.ts"; type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /** * The default strategy: a clearly-labeled stand-down demo document (author-no-strategy * holds — the DEFAULT is the demo baseline, not a hidden strategy). Its own comment is * its label. Supply `--plans <file>` to author a real strategy. */ export const STAND_DOWN_DOC = "# demo: stand-down baseline — supply --plans <file> to author a strategy\n"; /** * A minimal, replaceable EXAMPLE plan the plan-less stand-down lesson (v6et) shows the reader — * three lines they can save to a file and run with `--plans` to make the NEXT `sim` run actually * trade. Deliberately naive (rest a limit at the open, hold to the risk-envelope ttl) and clearly * a starting point, never dressed up as alpha. Kept in sync in shape with `prove`'s bundled sampler * starter (prove.ts SAMPLER_STARTER_DOC), but owned HERE so `sim` never imports `prove` (which * imports `sim` — the dependency runs one way only). */ export const STAND_DOWN_EXAMPLE_PLAN = "PLAN starter budget 1R ttl +24h\n WHEN spot > 0\n DO buy 1 shares @ spot"; function usageErr(message: string, hint?: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, ...(hint !== undefined ? { hint } : {}) }); } /* ─────────────────────── selector case-rule (kestrel-vcn) ───────────────── */ /** The case-decided kind of a `what` positional (design §4 grammar). */ export type SelectorKind = "slug" | "tickers" | "mixed"; /** * Classify a `what` token by CASE (the design's fail-closed case rule): all-lowercase = * a catalog scenario slug; all-UPPERCASE = ticker selector(s); a mix of cases = ambiguous * (a loud USAGE error names both interpretations). A token with neither letter (a bare * date/number) is treated as a slug shape here and rejected downstream as a `<when>`. */ export function classifySelector(token: string): SelectorKind { const hasUpper = /[A-Z]/.test(token); const hasLower = /[a-z]/.test(token); if (hasUpper && hasLower) return "mixed"; if (hasUpper) return "tickers"; return "slug"; } const DATE_RE = /^\d{4}-\d{2}(-\d{2})?$/; const RANGE_RE = /^\d{4}-\d{2}(-\d{2})?\.\.\d{4}-\d{2}(-\d{2})?$/; const REL_WHEN = new Set(["yesterday", "last-week", "last-month"]); /** True when a token is a `<when>` (date/range/relative), not a scenario slug. */ function looksLikeWhen(token: string): boolean { return DATE_RE.test(token) || RANGE_RE.test(token) || REL_WHEN.has(token); } /** * Parse the `sim` positionals into a scenario slug, or refuse LOUDLY (exit 2 USAGE) for a * reserved form — naming what each will become so the case rule is locked in now: * - a second positional (a `<when>`) → date/window selectors are reserved (kestrel-do4). * - an UPPERCASE `what` → ticker selectors are reserved (kestrel-do4). * - a mixed-case `what` → ambiguous slug/ticker; refuse naming both. * - a bare `<when>` in the `what` slot → needs a scenario or ticker; reserved. */ export function parseSimSelector(positionals: readonly string[]): { scenarioSlug: string } { const first = positionals[0]!; if (positionals.length > 1) { const when = positionals[1]!; throw usageErr( `date-window selectors are reserved — \`kestrel sim ${first} ${when}\` is not available yet`, "this PR ships curated scenario slugs only; ticker × <when> selectors are coming (kestrel-do4). Run `kestrel sim` for the menu.", ); } const kind = classifySelector(first); if (kind === "mixed") { throw usageErr( `"${first}" is ambiguous: a lowercase scenario slug or an UPPERCASE ticker selector? — mixed case is reserved`, "use an all-lowercase scenario slug (e.g. meme-stock-short-squeeze) or, once shipped, all-UPPERCASE tickers (e.g. SPY). Run `kestrel sim` for the menu.", ); } if (kind === "tickers") { throw usageErr( `ticker selectors are reserved — \`kestrel sim ${first} <when> --plans <file>\` is not available yet`, "this PR ships curated lowercase scenario slugs only; UPPERCASE ticker × <when> selectors are coming (kestrel-do4). Run `kestrel sim` for the menu.", ); } if (looksLikeWhen(first)) { throw usageErr( `"${first}" is a <when> with no scenario or ticker — reserved`, "name a scenario slug (kestrel sim <slug>); a standalone market window is coming (kestrel-do4). Run `kestrel sim` for the menu.", ); } return { scenarioSlug: first }; } /* ─────────────────────────── strategy loading ──────────────────────────── */ export interface Strategy { readonly source: string; readonly label: string; /** * The deterministic CONTENT identity of the strategy — `sha256` of the canonical plan text * ({@link source}), 64-char lowercase hex (kestrel-etyu). This is what distinguishes two runs of * the SAME `--plans` path edited IN PLACE (the returning-user workflow): the {@link label} (the * path) is unchanged across an edit, but the digest moves with the content. No wall clock, no RNG. */ readonly digest: string; } /** Wrap a parse throw as a loud, stack-free USAGE(PARSE) error (mirrors commands/lang.ts). */ function asParseError(e: unknown): CliError { if (e instanceof KestrelParseError) return new CliError({ code: "PARSE", exit: EXIT.USAGE, message: e.message }); return usageErr(e instanceof Error ? e.message : String(e)); } /** * A BUNDLED example document a verb may default to when the caller supplies no `--plans`. * It is exactly parallel to what `--plans` uploads — a labeled, replaceable example document, * never a hidden strategy (author-provenance stays customer/explicitly-supplied server-side). * `sim` keeps the {@link STAND_DOWN_DOC} default (the raw funnel); `prove` — the value- * demonstration front door — passes a firing sampler starter so its bare run actually trades. */ export interface BundledDefaultDoc { /** Raw Kestrel source, parsed + canonicalized locally (must parse — validated like the default). */ readonly source: string; /** The strategy label printed in the story — self-describing as a replaceable bundled example. */ readonly label: string; } /** * The strategy for the hosted run, validated LOCALLY with the REAL parser first (a bad * document fails at exit 2 before any capability is minted or dataset is touched): * - no `--plans` → the `bundledDefault` (a labeled example), else the stand-down baseline. * - `--plans <p>` → the author's document — `.md` (fenced ```kestrel) / `.kestrel` / `-` (stdin). * * `bundledDefault` is consulted ONLY when no `--plans` is given; an author-supplied document * always wins, so the caller's own strategy path is unchanged. */ export function loadStrategy(plansPath: string | undefined, bundledDefault?: BundledDefaultDoc): Strategy { if (plansPath === undefined) { const doc = bundledDefault ?? { source: STAND_DOWN_DOC, label: "stand-down baseline (demo) — supply --plans <file> to author a strategy", }; const documents = [parse(doc.source)]; // validate the default too — it must parse const source = canonicalPlansText(documents); return { source, label: doc.label, digest: sha256(source) }; } let text: string; try { text = plansPath === "-" ? readFileSync(0, "utf8") : readFileSync(plansPath, "utf8"); } catch (e) { throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `cannot read --plans ${JSON.stringify(plansPath)}: ${e instanceof Error ? e.message : String(e)}`, }); } let documents: readonly KestrelNode[]; try { documents = plansPath.endsWith(".md") ? parseMarkdown(text) : [parse(text)]; } catch (e) { throw asParseError(e); } const source = canonicalPlansText(documents); // The digest is the CONTENT identity (kestrel-etyu): two runs of this SAME `plansPath` edited in // place carry the same label (the path) but a DIFFERENT digest, so they are distinguishable. return { source, label: plansPath, digest: sha256(source) }; } /* ──────────────────────────── base URL routing ─────────────────────────── */ interface Env { readonly [k: string]: string | undefined; } /** The hosted funnel base: `--api <url>` > `KESTREL_API` > the default; empty/`default` → default. */ export function resolveSimBase(globals: GlobalFlags, env: Env): string { const raw = globals.api ?? env.KESTREL_API; if (raw === undefined || raw === "" || raw === "default") return DEFAULT_API; return raw; } /* ──────────────────────────────── the verb ─────────────────────────────── */ export interface SimDeps { /** Injected fetch (tests pass an in-process funnel face); defaults to global fetch. */ readonly fetch?: FetchLike; /** Injected env (tests); defaults to process.env. */ readonly env?: Env; /** Where the hosted-run receipt is appended; defaults to the identity-bound home store * `~/.kestrel/hosted-runs.jsonl` ({@link defaultHostedStorePath}, kestrel-pvlc / kestrel-fq78). */ readonly hostedStorePath?: string; } /** * Read the opaque copy-token: `--copy-token <tok>` > `KESTREL_COPY_TOKEN`. The CLI never * interprets it — it is forwarded verbatim as one header at mint time (see {@link runHostedProof}). * An empty/absent value returns `undefined` (the header is omitted; behaviour unchanged). */ export function resolveCopyToken(flags: Map<string, string>, env: Env): string | undefined { const raw = flags.get("copy-token") ?? env.KESTREL_COPY_TOKEN; return raw === undefined || raw === "" ? undefined : raw; } /** * Parse `--budget <amount>` into the {@link Spend} cap forwarded on `POST /simulate` as * `spend.budget` (bead kestrel-4dz5) — the CLI's ONLY affordance to reach the paid boundary * (exit 5 PAYMENT_REQUIRED). The declared budget is a HONORED CAP the caller opts into (the CLI * never auto-pays): a tiny budget on a metered dataset pauses at the Spend boundary and returns * the settleable Offer as DATA. Absent → `undefined` (the free default path, unchanged). A * malformed value — non-numeric, negative, or non-finite (NaN/±Infinity) — is a loud USAGE error * (exit 2), never a silently-dropped or widened cap. */ export function resolveSpend(flags: Map<string, string>): Spend | undefined { const raw = flags.get("budget"); if (raw === undefined) return undefined; const budget = Number(raw); if (raw.trim() === "" || !Number.isFinite(budget) || budget < 0) throw usageErr( `--budget must be a finite, non-negative number (got ${JSON.stringify(raw)})`, "declare a spend ceiling in USD, e.g. `--budget 0.50`; use `--budget 0` to pause at the first metered draw", ); return { budget }; } /** `sim` — the curated-scenario funnel. Bare = the menu; a slug = mint → simulate → render. */ export async function simCommand( argv: readonly string[], ctx: OutputCtx, globals: GlobalFlags, deps: SimDeps = {}, ): Promise<number> { const { positionals, flags } = parseArgs(argv, new Set(), new Set(["plans", "copy-token", "budget"])); const env = deps.env ?? (process.env as Env); const base = resolveSimBase(globals, env); const client = new HostedFunnel({ baseUrl: base, ...(deps.fetch !== undefined ? { fetch: deps.fetch } : {}) }); // A raw `fetch` transport failure (network down / DNS miss / refused socket) throws an untyped // `TypeError`, not a `CliError` — left alone it falls through the router as an unclear GENERIC // exit 1 on the launch's paper/sim entry path. Type it fail-closed as NETWORK_UNAVAILABLE (exit // 4) carrying the base + a network/API hint. This wraps the whole hosted drive — catalog, mint, // simulate, proof (via `runHostedProof`) — so any leg's connection fault is typed identically. // Already-typed throws (USAGE from the selector, NOT_FOUND, an HTTP status) pass through // untouched: `asConnectionError` returns null for anything that is not a transport fault // (PR #68 review 6a, kestrel-a3v9). try { return await runSimFunnel(client, base, positionals, flags, ctx, deps, env); } catch (e) { throw asConnectionError(e, base) ?? e; } } /** The hosted-funnel drive for {@link simCommand}, wrapped by its connection-error mapping. */ async function runSimFunnel( client: HostedFunnel, base: string, positionals: readonly string[], flags: Map<string, string>, ctx: OutputCtx, deps: SimDeps, env: Env, ): Promise<number> { // ── bare `sim` → the MENU ── if (positionals.length === 0) { const { entries } = await client.catalog(); return renderMenu(entries, ctx); } // ── the selector case rule (reserved forms refuse here, exit 2) ── const { scenarioSlug } = parseSimSelector(positionals); // ── resolve the slug against the catalog ── const { entries } = await client.catalog(); const resolved = resolveScenarioSlug(scenarioSlug, entries); if (resolved === null) { const near = nearMissSlugs(scenarioSlug, entries); throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `unknown scenario slug ${JSON.stringify(scenarioSlug)}`, hint: near.length > 0 ? `did you mean: ${near.join(", ")}? — run \`kestrel sim\` for the full menu` : "run `kestrel sim` for the menu", }); } // ── strategy: validated LOCALLY first (bad doc → exit 2 before any mint) ── const plansPath = flags.get("plans"); const strategy = loadStrategy(plansPath); const copyToken = resolveCopyToken(flags, env); // The self-declared Spend cap (`--budget`) — validated before any mint (a bad value → exit 2). const spend = resolveSpend(flags); return runHostedProof({ client, resolved, strategy, ctx, hostedStorePath: deps.hostedStorePath ?? defaultHostedStorePath(env), // sim prints the one-line residency guidance (kestrel-qupe): the proof URL is the shareable handle, // and — since kestrel-fq78 — the receipt is recorded to the identity-bound home ledger so `runs // list` finds it from any directory (unlike the fuller `prove` snippet). residencyOneLine: true, // Whether the strategy was authored (`--plans`) vs the stand-down default — governs the // `--copy-token` continuation notice (kestrel-gu9h). authoredPlan: plansPath !== undefined, // The plan-less stand-down lesson (v6et): rides ONLY the no-`--plans` path — the class of every // bare `sim <slug>` run, not one slug — so a menu-browser (and the crypto door) is told the empty // grade is a by-design stand-down and shown the two next steps. An author-supplied `--plans` run // arms real orders and needs no such nudge, so it carries none. ...(plansPath === undefined ? { standDownLesson: { scenarioSlug: resolved.canonicalSlug, examplePlanSource: STAND_DOWN_EXAMPLE_PLAN, swapInCli: `npx kestrel.markets sim ${resolved.canonicalSlug} --plans <your-plan.kestrel>`, proveCli: "npx kestrel.markets prove", }, } : {}), // The free-path paid-boundary nudge (kestrel-yk9n, mirror of kestrel-markets-f6ly): rides ONLY the // free path — a run that did NOT opt into the paid boundary via `--budget` (spend === undefined) — so // a persona following the advertised free CLI path is told, legibly, that this run cost nothing AND // how to cross into the paid boundary (a signed, settleable Offer). A `--budget` run has already // engaged the boundary (it either paused with the Offer, exit 5, or completed within its cap), so it // needs no nudge and carries none. Set here by the real command so deleting the render call site — // not editing render/sim.ts — is what a wiring guard catches. ...(spend === undefined ? { paidBoundaryNudge: { scenarioSlug: resolved.canonicalSlug } as PaidBoundaryNudge } : {}), ...(copyToken !== undefined ? { copyToken } : {}), ...(spend !== undefined ? { spend } : {}), }); } /** The shared inputs to {@link runHostedProof}: a resolved scenario + a validated strategy. */ export interface HostedProofRun { readonly client: HostedFunnel; readonly resolved: ResolvedScenario; readonly strategy: Strategy; readonly ctx: OutputCtx; readonly hostedStorePath: string; /** Opaque copy-token forwarded verbatim to the trial mint (see {@link HostedFunnel.mintTrial}). */ readonly copyToken?: string; /** * The caller's self-declared Spend cap (`--budget`, bead kestrel-4dz5), forwarded on * `POST /simulate` as `spend.budget` — the ONE trigger that lets the CLI reach the paid boundary * (a settleable Offer → exit 5). Absent → the free default drive, request byte-identical to before. */ readonly spend?: Spend; /** When true (the `prove` front door), append the full three-part residency snippet after the proof. */ readonly emitResidency?: boolean; /** * When true (the `sim` verb, kestrel-qupe), append the ONE-LINE residency guidance after the proof: * the proof URL is the durable handle because `runs list` is CWD-local. `sim` sets this; `prove` * uses the fuller {@link emitResidency} snippet instead. Rendered HERE by the real command (delete * this call — not the pure renderer — to catch the wiring). */ readonly residencyOneLine?: boolean; /** * Whether the caller authored the strategy via `--plans` (true) or the run fell back to a bundled/ * stand-down default (false/undefined). Governs the `--copy-token` continuation notice (kestrel-gu9h): * a copy-token claims to "reproduce or extend this proof", but the CLI can NEVER recover the origin * plan from the opaque token — so on a NON-authored copy-token run it emits a LOUD stderr notice that * the origin plan is not being carried (supply `--plans`), instead of silently running the baseline. */ readonly authoredPlan?: boolean; /** * The one-frame lesson `prove` prints when its bare run used the BUNDLED example plan (no * `--plans`): what the example is + how to swap in your own. Set only by the `prove` front * door on its bundled-default path; absent whenever the caller supplied `--plans`. Rendered * HERE by the real command (delete this call — not the pure renderer — to catch the wiring). */ readonly demoLesson?: DemoLesson; /** * The one-frame lesson `sim <slug>` prints when its run fell back to the STAND-DOWN default (no * `--plans`) — the honest counterpart to `prove`'s {@link demoLesson} (v6et). Bug: a plan-less * `sim <slug>` certifies an EMPTY, $0 session and said nothing, so a cold menu-browser (and the * crypto door, which routes straight to `sim <slug>`) reads the all-zeros grade as "broken". This * frame states plainly that the empty grade is a plan-less stand-down BY DESIGN and points at the * two next steps (author `--plans`, or `prove` for a bundled example that trades). Set ONLY by * `sim` on its no-`--plans` path; absent whenever the caller supplied `--plans` (and never on * `prove`, which passes {@link demoLesson} on its bundled-that-trades path instead — the two are * mutually exclusive). Rendered HERE by the real command (delete this call — not the pure * renderer — to catch the wiring). */ readonly standDownLesson?: StandDownLesson; /** * The free-path paid-boundary nudge (kestrel-yk9n, mirror of kestrel-markets-f6ly). Set ONLY by * `sim` on the FREE path — a run that did not opt into the paid boundary via `--budget` * (`spend === undefined`). Bug f6ly: the free happy CLI path completes, prints its grade + proof * URL, and only ever offers MORE free stuff — the 402 value boundary (the signed, settleable Offer * that certifies a run against real spend) is invisible, so the persona never learns they could * "pay to certify MY book" and never converts. This nudge states plainly that the run was free and * HOW to cross into the paid boundary (`--budget`), inventing no price (the Offer is signed * server-side). Absent whenever `--budget` was declared (the boundary was already engaged — a paused * run prints the Offer itself) and on `prove`. Rendered HERE by the real command (delete this call — * not the pure renderer — to catch the wiring). */ readonly paidBoundaryNudge?: PaidBoundaryNudge; /** * Suppress this function's OWN stdout render (still mints, records, and fires {@link onProof}). * `replay` uses it to drive a fresh run for its reproducibility check while owning the report. */ readonly quiet?: boolean; /** * Fired on the SUCCESS path with the fresh proof's signature envelope + URL (before render). * `replay` uses it to assert the re-run is structurally reproducible and its signature * re-verifies. Never fired on a gated/settlement outcome. */ readonly onProof?: (outcome: HostedProofOutcome) => void; } /** The fresh proof's verifiable envelope + graded shape, handed to {@link HostedProofRun.onProof}. */ export interface HostedProofOutcome { readonly proofUrl: string; readonly proofId: string; readonly root: string; readonly signature: string; readonly kid: string; readonly epoch: number; readonly metrics: { readonly orderCount: number; readonly fillCount: number; readonly realizedPnl: number }; } /** * The SHARED hosted-proof core reused by `sim` and `prove`: mint an anonymous trial → * POST /simulate → read + validate the signed Grade proof → append the local receipt → * render. Its ONLY behavioral delta between the two verbs is `emitResidency` (prove appends * the persist-me snippet; sim does not). The selector case-rule, slug resolution, and * strategy loading stay in each verb's own body — this function starts at the mint. */ export async function runHostedProof(run: HostedProofRun): Promise<number> { const { client, resolved, strategy, ctx } = run; // ── mint an anonymous trial (carrying the opaque copy-token, if any), then drive the sim ── const trial = await client.mintTrial(run.copyToken !== undefined ? { copyToken: run.copyToken } : {}); const g = await client.simulate( { source: strategy.source, artifactId: resolved.entry.artifact_id, ...(run.spend !== undefined ? { spend: run.spend } : {}) }, trial.capability, ); // A Spend-boundary suspension is surfaced as its itemized Offer + resume affordance (exit 5), // never the misleading "operation is not completed" 502 (kestrel-e8e7). if ("settlementRequired" in g) { renderSettlementRequired(g.settlementRequired, ctx); return EXIT.PAYMENT_REQUIRED; } if (g.gated) { renderOffer(g.payment, ctx); return EXIT.PAYMENT_REQUIRED; } const op = g.value; // ── read the grade proof for the metrics + the shareable URL ── const gradeArtifact = gradeArtifactOf(op); const gradeReceipt = gradeReceiptOf(op); const proofArtifactId = gradeArtifact.artifact_id; const proofUrl = proofWebUrl(proofArtifactId); // A COMPLETED, metered Operation is the caller's paid-for result. A transient proof 404 is only // R2 write lag — the signed grade is still SETTLING — so it must DEGRADE, never discard the run: // render the full story with the metrics + proof URL annotated "settling — retry shortly" and // exit 0 (kestrel-fzum). A proof that IS readable but fails verification is a real integrity // fault (HTTP_502, not a 404) and never reaches this catch — it stays fail-closed. let proof: HostedProof | null = null; try { proof = await client.proof(gradeArtifact, gradeReceipt, op.operation_id, trial.capability); } catch (e) { if (!isProofSettling(e)) throw e; process.stderr.write( `warning: the grade proof for ${op.operation_id} is not yet readable (settling — retry shortly); ` + `metrics are pending and this run is not yet in \`kestrel runs list --hosted\`: ${e.message}\n`, ); } const metrics = proof === null ? null : gradedMetrics(proof.result.metrics); // The grader's never-fired reason (kestrel-kglw) — surfaced ONLY on a 0-order run, the highest-value // teaching moment that was otherwise silent on the default face. Gated on `orderCount === 0` so a run // that traded never carries it, and read straight off the signed proof (surface, never recompute). const neverFiredReason = metrics !== null && metrics.orderCount === 0 ? neverFiredReasonOf(proof) : undefined; // Hand the fresh proof's verifiable envelope to any observer (replay's reproducibility check). // A still-settling proof has no envelope to verify, so the observer is NOT fired — the caller // (replay) surfaces that honestly rather than fabricating an unverifiable outcome. if (proof !== null && metrics !== null) { run.onProof?.({ proofUrl, proofId: proofArtifactId, root: proof.root, signature: proof.signature, kid: proof.verification.kid, epoch: proof.verification.epoch, metrics, }); } // ── append the LOCAL hosted-run receipt so this run is re-listable (`runs list`, kestrel-pvlc) ── // Best-effort + zero network: the remote sim already succeeded and printed its proof URL, so a // failed local append DEGRADES the ledger but must not fail-report the sim (exit stays 0). It is // still LEGIBLE — the failure is logged loudly to stderr, never a silent drop. A still-settling // proof carries no honest metrics/timestamp, so the receipt is skipped rather than faked. if (proof !== null && metrics !== null) { recordHostedRun(run.hostedStorePath, { source: "hosted", slug: resolved.canonicalSlug, requested_slug: resolved.requestedSlug, operation_id: op.operation_id, grade_artifact_id: proofArtifactId, bus_artifact_id: op.evidence.busRef.artifact_id, manifest_artifact_id: op.evidence.manifestRef.artifact_id, order_count: metrics.orderCount, fill_count: metrics.fillCount, realized_pnl: metrics.realizedPnl, proof_url: proofUrl, // The server's signed-proof timestamp (never a local wall clock) — reproducible (RUNTIME §0). issued_at: proof.issuedAt ?? "", // The plan/strategy that produced this run (kestrel-etyu). `strategy` is the human LABEL (the // `--plans <file>` path, or the stand-down/bundled default's label); `strategy_digest` is the // deterministic CONTENT identity (sha256 of the canonical plan text) — the field that // distinguishes two runs of the SAME path edited IN PLACE (same label, different digest). strategy: strategy.label, strategy_digest: strategy.digest, // Record the opaque copy-token VERBATIM so a `--copy-token` continuation threads back to its // origin (kestrel-gu9h) — the CLI never interprets it (repo boundary), it just keeps the link. ...(run.copyToken !== undefined ? { copy_token: run.copyToken } : {}), // Record the grader's never-fired reason (kestrel-kglw) so a returning user who `runs show`s this // SAME 0-order run re-reads WHY nothing fired — the reason `renderSimStory` printed once on stdout // is otherwise lost, leaving a bare `order_count=0` one surface downstream. `neverFiredReason` is // already gated on `orderCount === 0` above (a traded run carries none), so this stores it only on // the run whose highest-value moment it explains — VERBATIM (the store never recomputes a verdict). ...(neverFiredReason !== undefined ? { never_fired_reason: neverFiredReason } : {}), }); } // The `--copy-token` continuation notice (kestrel-gu9h). A copy-token claims to "reproduce or // extend this proof", but the origin plan lives in the origin run, NOT in the opaque token — the // CLI cannot recover it (repo boundary). So when a copy-token run did NOT author its own strategy // (`--plans`), say LOUDLY (to stderr, the diagnostics channel) that the origin plan is not carried // and this run used the bundled/stand-down baseline — never silently degrade. The lineage back to // the origin IS recorded (the copy_token on the receipt above). Suppressed for `replay` (quiet: it // owns its own report and reproduces from the no-leak baseline by design). if (!run.quiet && run.copyToken !== undefined && run.authoredPlan !== true) { process.stderr.write( `notice: --copy-token continues a proof, but the origin plan is NOT recoverable from the opaque token — ` + `this run used the bundled/stand-down baseline (${strategy.label}), not the strategy you are continuing. ` + `Supply \`--plans <your-plan>\` to author the continuation; the lineage back to the origin is recorded on the receipt.\n`, ); } // `replay` drives the run for its own report — record + onProof happened, render is suppressed. if (run.quiet) return 0; if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.sim/v1", scenario: resolved.canonicalSlug, requested: resolved.requestedSlug, operation: op, metrics: proof === null ? null : proof.result.metrics, // The grader's never-fired reason under a stable top-level key (kestrel-kglw) — the twin of // the human `why` line. Present ONLY on a 0-order run carrying a reason (never on a run that // traded), so a machine caller reads the SAME diagnostic the default face now states. ...(neverFiredReason !== undefined ? { never_fired_reason: neverFiredReason } : {}), proof_url: proofUrl, // The signed grade is still settling (transient R2 write lag) — surface it as data. ...(proof === null ? { proof_settling: true } : {}), // The residency ask rides a structured field in --json (never the printed snippet). ...(run.emitResidency ? { residency: residencyObject(proofUrl) } : {}), // sim's one-line residency guidance rides a structured field in --json (kestrel-qupe), so a // machine caller is warned its folder-bound history is not durable — the proof URL is (never // the printed line). Only when NOT already carrying the fuller `residency` snippet (prove). ...(run.residencyOneLine && !run.emitResidency ? { residency_guidance: renderResidencyOneLine(proofUrl) } : {}), // The one-frame demo lesson rides a structured field in --json (never the printed block). ...(run.demoLesson !== undefined ? { demo: demoLessonObject(run.demoLesson) } : {}), // The plan-less stand-down lesson rides a structured `stand_down` field in --json (v6et), so a // machine caller reads the SAME by-design-not-broken diagnostic the default face now prints — // never the printed block. Present ONLY on a `sim` run that used the stand-down default AND // whose readable grade is the empty shape (metrics present, orderCount 0) — never against a // still-settling grade, mirroring the human gate below. ...(run.standDownLesson !== undefined && metrics !== null && metrics.orderCount === 0 ? { stand_down: standDownLessonObject(run.standDownLesson) } : {}), // The free-path paid-boundary nudge rides a structured `paid_nudge` field in --json (kestrel-yk9n), // so a machine caller reads the SAME "this run was free — reach the paid boundary via --budget" // affordance the default face prints — never the printed block. Present ONLY on the free path // (`paidBoundaryNudge` set), mirroring the human gate below. ...(run.paidBoundaryNudge !== undefined && metrics !== null ? { paid_nudge: paidBoundaryNudgeObject(run.paidBoundaryNudge) } : {}), }) + "\n", ); return 0; } const story: SimStory = { canonicalSlug: resolved.canonicalSlug, requestedSlug: resolved.requestedSlug, viaAlias: resolved.viaAlias, title: resolved.entry.title, instrument: resolved.entry.instrument, period: resolved.entry.period, frameCount: resolved.entry.frame_count, strategyLabel: strategy.label, operation: { id: op.operation_id, status: op.status }, receipts: summarizeReceipts(op.receipts), evidence: op.evidence, metrics, proofUrl, }; renderSimStory(story, ctx); // The never-fired reason line (kestrel-kglw): a 0-order run is the highest-value teaching moment, // and it was SILENT on the default face until now. Driven HERE by the real command so removing THIS // call — not editing render/sim.ts — is what a wiring guard catches. Emitted only when the run placed // no orders AND the signed grade carried a reason (never on a run that traded). if (neverFiredReason !== undefined) process.stdout.write(neverFiredLine(neverFiredReason) + "\n"); // The one-frame lesson for a bare `prove` running its BUNDLED example plan: what the example // did + how to swap in your own (the one-frame-lesson doctrine). Driven HERE by the real // command so removing THIS call — not editing render/sim.ts — is what a wiring guard catches. if (run.demoLesson !== undefined) process.stdout.write(renderDemoLesson(run.demoLesson) + "\n"); // The plan-less stand-down lesson (v6et): a bare `sim <slug>` used the stand-down default and // certified an empty, $0 session — the honest counterpart to `prove`'s demo lesson. Print the // by-design-not-broken framing + the two next steps so the menu-browser/crypto-door path does not // dead-end reading the all-zeros grade as "broken". Driven HERE by the real command so removing // THIS call — not editing render/sim.ts — is what a wiring guard catches. Gated on the no-`--plans` // path (an authored run carries no `standDownLesson`) AND a readable, empty grade (metrics present, // orderCount 0): the lesson's "$0 / no-orders result" framing would be dishonest against a still- // SETTLING grade or a run that armed orders. Mirrors the `neverFiredLine` orderCount gate above. if (run.standDownLesson !== undefined && metrics !== null && metrics.orderCount === 0) process.stdout.write(renderStandDownLesson(run.standDownLesson) + "\n"); // The free-path paid-boundary nudge (kestrel-yk9n, mirror of kestrel-markets-f6ly): a persona following // the advertised FREE CLI path completes, reads its grade + proof URL, and is only ever offered more // free stuff — the 402 value boundary (the signed, settleable Offer) is invisible, so it never converts. // Print, on the free path only, that this run cost nothing AND how to cross into the paid boundary // (`--budget`). Driven HERE by the real command so removing THIS call — not editing render/sim.ts — is // what a wiring guard catches. Gated on `paidBoundaryNudge` (set ONLY when no `--budget` opted into the // boundary; a paused `--budget` run prints the Offer itself, exit 5, and never reaches here) AND a // readable grade (`metrics !== null`): a still-SETTLING grade is a transient degraded state whose // attention is on "retry shortly", not the free happy path this conversion nudge belongs on — so it is // suppressed there, mirroring the `standDownLesson` metrics gate above. if (run.paidBoundaryNudge !== undefined && metrics !== null) process.stdout.write(renderPaidBoundaryNudge(run.paidBoundaryNudge) + "\n"); // The persist-me snippet is the `prove` front door's success exhaust (n04e.1 / ADR-0038 §4). // Driven HERE by the real command (not the pure renderer) so removing this call — not editing // render/residency.ts — is what a wiring guard catches. if (run.emitResidency) process.stdout.write(renderResidencySnippet(proofUrl) + "\n"); // sim's one-line residency guidance (kestrel-qupe): the proof URL is the shareable handle, and the // receipt is recorded to the identity-bound home ledger so `runs list` finds it from any directory // (kestrel-fq78). Driven HERE by the real command so removing THIS call — not editing // render/residency.ts — is what a wiring guard catches. Suppressed when the fuller `prove` // snippet already carried the same message (never both). if (run.residencyOneLine && !run.emitResidency) process.stdout.write(renderResidencyOneLine(proofUrl) + "\n"); return 0; } /** * A proof-fetch failure that is only transient R2 write lag — the signed grade is still SETTLING: * a 404 on `GET /proof`. A completed, metered run degrades past this (the story renders with the * proof URL annotated) rather than being discarded (kestrel-fzum). Every OTHER proof failure — * verification refused (HTTP_502), a transport 5xx — is a real fault and stays fail-closed. */ function isProofSettling(e: unknown): e is CliError { return e instanceof CliError && e.code === "HTTP_404"; } /** * Append the hosted-run receipt, best-effort. A local-ledger write must never turn a remotely * successful sim into a failure, so any fs error is logged LOUDLY to stderr (legible, not silent) * and swallowed — the run's exit code reflects the remote outcome, not the local receipt. */ function recordHostedRun(storePath: string, row: HostedRunRow): void { try { appendHostedRun(storePath, row); } catch (e) { process.stderr.write( `warning: could not append the hosted-run receipt to ${JSON.stringify(storePath)} — ` + `\`kestrel runs list --hosted\` will not show this run: ${e instanceof Error ? e.message : String(e)}\n`, ); } } /** Build + render the bare-`sim` menu from the catalog entries. */ function renderMenu(entries: readonly HostedCatalogEntry[], ctx: OutputCtx): number { const scenarios = entries.map((e) => ({ slug: slugifyTitle(e.title), title: e.title, instrument: e.instrument, artifactId: e.artifact_id, free: e.free, })); const menu: SimMenu = { scenarios, aliases: SCENARIO_ALIASES }; if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.sim.menu/v1", scenarios: scenarios.map((s) => ({ slug: s.slug, title: s.title, instrument: s.instrument, artifact_id: s.artifactId, free: s.free, })), aliases: SCENARIO_ALIASES, usage: "kestrel sim <scenario-slug> [--plans <file>]", }) + "\n", ); return 0; } renderSimMenu(menu, ctx); return 0; } // re-export for the router + tests export type { HostedOperation };