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
text/typescript
/**
* # 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 };