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.

299 lines (277 loc) 15.6 kB
/** * # cli/commands/lang — `parse`/`validate` (+ the arm tier) + `print` (LIGHT, node-runnable) * * Eager imports are only `../../lang/index.ts` (`parse`, `parseMarkdown`, `print`, * `KestrelParseError`) and `node:fs`. No `bun:*`, no `Bun.*`, no chdb — so once bundled to * node-target JS these commands run under **plain node** with no bun and no chdb installed. Parse * failures become a structured USAGE error (exit 2) built from `KestrelParseError` — never a stack * trace. * * ## The arm tier (kestrel-elu9, epic kestrel-bc38): validation matches execution * Parse-green is NOT arm-green: a document naming `VELOCITY` (a case-variant of the WINDOWED metric * `velocity`) parses clean, then REFUSES at arm time — so a bare `validate` answering `valid: true` * taught authors a truth the engine then contradicted. `--arm` (or `--bus`/`--instruments`, which * imply it) additionally drives {@link import("../../engine/validate.ts").validateArm} — the ONE * arm-level surface, which runs the engine's REAL `armDocument` — so the refusal this command prints * is byte-identical to the message a real `run` raises. The engine graph is Worker/node-portable * (no `bun:*` specifiers — the same graph the hosted /validate route runs), reached through a lazy * `import()` so the eager LIGHT graph of this module is unchanged and the arm tier never loads for a * pure-parse invocation (whose output and exit codes stay byte-stable). */ import { readFileSync } from "node:fs"; import { parse, parseMarkdown, print, KestrelParseError, type KestrelNode } from "../../lang/index.ts"; import { canonicalPlansText } from "../../canonical/plans.ts"; // The tape reader (node:fs + the typed event guards — no bun:*, no chdb): light, and statically // imported for the same bundler reason as ./specs.ts below. import { readBus } from "../../bus/read.ts"; import type { InstrumentSpec } from "../../engine/plans.ts"; // The driver's PURE header rules (META → instruments): three tiny functions with type-only imports — // zero runtime dependencies, so they sit on the eager LIGHT graph. Statically imported, deliberately: // a dynamic `import()` here makes the bundler mint them as their own split chunk while the heavy // chunk ALSO re-exports them (via session/sim), and the emitted heavy chunk then carries duplicate // export statements that node rejects at load ("Duplicate export of 'requireMeta'"). import { requireMeta, resolveSpecs, specFromMeta } from "../../session/specs.ts"; import type { OutputCtx } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; const GREEN = "[32m"; const RESET = "[0m"; function usageErr(message: string, hint?: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, ...(hint !== undefined ? { hint } : {}) }); } /** Wrap a `KestrelParseError` (or any parse throw) into a loud, stack-free USAGE error. */ function asParseError(e: unknown): CliError { if (e instanceof KestrelParseError) { return new CliError({ code: "PARSE", exit: EXIT.USAGE, message: e.message, // already carries `(line L, col C)` }); } return usageErr(e instanceof Error ? e.message : String(e)); } /** A loaded plans doc: the parsed documents plus the raw text + markdown-ness the arm tier needs. */ interface LoadedDocuments { readonly path: string; readonly documents: readonly KestrelNode[]; readonly text: string; readonly asMarkdown: boolean; } /** Read + parse a plans doc from already-parsed argv pieces: `.md` (or `--md`) → fenced ```kestrel * blocks; else a single doc. `--single` forces the single-document parser. */ function readDocuments(positionals: readonly string[], bools: ReadonlySet<string>): LoadedDocuments { const path = positionals[0]; if (path === undefined) throw usageErr("expected a <file> path"); let text: string; try { text = readFileSync(path, "utf8"); } catch (e) { throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `cannot read ${JSON.stringify(path)}: ${e instanceof Error ? e.message : String(e)}`, }); } const asMarkdown = bools.has("md") || (!bools.has("single") && path.endsWith(".md")); try { const documents = asMarkdown ? parseMarkdown(text) : [parse(text)]; return { path, documents, text, asMarkdown }; } catch (e) { throw asParseError(e); } } /** Read + parse a plans doc from a raw argv. Mirrors the old `loadDocuments`. */ function loadDocuments(argv: readonly string[]): LoadedDocuments { const { positionals, bools } = parseArgs(argv, new Set(["md", "single"])); return readDocuments(positionals, bools); } /** Count the statements across a document set (a `module` node carries a `statements` array; a * bare statement counts as one). */ function countStatements(documents: readonly KestrelNode[]): number { let n = 0; for (const d of documents) { n += d.kind === "module" ? d.statements.length : 1; } return n; } /** * Derive the arm-context instruments the flags name — REUSING the run driver's own derivations, * never a re-implementation that could drift (AGENTS.md: validation must match execution): * * - `--bus <tape>`: `readBus` → `requireMeta` → `resolveSpecs` — the exact call chain * `runSimSession` opens a session with (`src/session/sim.ts`), so this command validates against * the very instruments a real `run --bus <tape>` would arm with. * - `--instruments <SYM[,SYM…]>`: each symbol through the driver's own META→spec rule * (`specFromMeta`, `src/session/specs.ts`) as an `option-underlier` — the standard listed-option * shape (multiplier 100) every session META fixture declares. A DOCUMENTED default, not a silent * one; pass `--bus` to derive from a real tape instead. * - neither: the EMPTY context — handed to `validateArm`, whose context tier REFUSES it * (fail-closed), surfaced as `ARM_CONTEXT`. Never a silent pass. */ /** The arm context the flags name: the session instruments AND — when `--bus` is given — the tape's * complete regime-scope vocabulary (kestrel-hk9u), so `validate --bus <tape>` gives the EXACT regime * verdict a `run --bus <tape>` raises (a scope the tape writes → no notice; one it never writes → * dead-on-arrival). Without `--bus` the vocabulary is left undefined (validateArm's closed-world `[]` * default), so a regime-gated plan warns that the named context carries no regime stream. */ interface ArmFlags { readonly instruments: readonly InstrumentSpec[]; readonly tapeRegimeScopes?: readonly string[]; } // Exported so the `--instruments` option-underlier spec-shape fixture (kestrel-2zx0) can drive the // EXACT derivation the CLI runs — the derived InstrumentSpec's multiplier is consumed only later (at // fill/settle), so no `validate` verdict can observe it; the fixture asserts the shape on this real // derivation function rather than a re-implementation that could drift. export async function deriveArmInstruments(flags: Map<string, string>): Promise<ArmFlags> { const busPath = flags.get("bus"); const instruments = flags.get("instruments"); if (busPath !== undefined && instruments !== undefined) { throw usageErr( "pass either --bus or --instruments, not both", "--bus derives the session instruments from the tape's META; --instruments names them directly", ); } if (busPath !== undefined) { try { const events = [...readBus(busPath)]; // The tape's regime vocabulary, scanned exactly as `tapeRegimeScopesOf` does (src/session/sim.ts) — // inlined here (a two-line scan) so this LIGHT, node-portable command graph does not pull in the // heavy session module. A `run --bus <tape>` pre-scans the identical set, so the verdicts match. const tapeRegimeScopes = [...new Set(events.filter((e) => e.stream === "REGIME").map((e) => e.scope))]; return { instruments: resolveSpecs(requireMeta(events)), tapeRegimeScopes }; } catch (e) { // A malformed/absent tape or a META-less bus is the CALLER's input being unusable — loud, with // the session layer's own message verbatim (it already names the RUNTIME §8 rule that failed). throw new CliError({ code: "BUS", exit: EXIT.USAGE, message: `--bus ${JSON.stringify(busPath)}: ${e instanceof Error ? e.message : String(e)}`, }); } } if (instruments !== undefined) { const symbols = instruments .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); if (symbols.length === 0) { throw usageErr("--instruments: expected a comma-separated symbol list (e.g. SPY or SPX,SPY)"); } return { instruments: symbols.map((symbol) => specFromMeta({ symbol, assetClass: "option-underlier" })) }; } return { instruments: [] }; } /** * Run the arm tier over every loaded document (fail-closed, first refusal wins). A refusal throws a * `CliError` whose `message` is the {@link import("../../engine/validate.ts").ArmDiagnostic} message * VERBATIM — `validateArm` drives the engine's real `armDocument`, so what this command prints is * byte-identical to the refusal a real run raises (the load-bearing kestrel-bc38 contract; pinned by * `tests/cli/validate-arm.test.ts`). Exit code is `EXIT.USAGE` (2) for every tier — the same bucket * as a parse failure, because for an author the outcome class is identical ("the input is defective, * fix it before running") — with DISTINCT stable codes (`ARM` / `ARM_CONTEXT` / `PARSE`), since * agents match on `code`, never on prose or exit numbers (cli/errors doctrine). */ async function runArmTier(loaded: LoadedDocuments, flags: Map<string, string>): Promise<readonly string[]> { // Lazy, node-portable edge (NOT `loadHeavy`): the engine graph carries no `bun:*` specifier — it is // the Worker-portable graph the hosted /validate route runs — so it loads under plain node; the // lazy import just keeps it off the eager light graph and out of pure-parse invocations. const { validateArm } = await import("../../engine/validate.ts"); const { instruments, tapeRegimeScopes } = await deriveArmInstruments(flags); if (loaded.documents.length === 0) { // A fenced-block-less markdown file has nothing to arm; `armed` on zero documents would be a // verdict about nothing (fail-closed — mirrors validateArm's own empty-context refusal). throw usageErr("--arm: no kestrel documents found — nothing to validate at the arm tier"); } // The single-doc path arms the RAW text; markdown blocks are re-printed canonically (`print`), // which the round-trip invariant makes semantics-preserving (`parse ∘ print` is identity) — the // arm tier evaluates the object model, never the surface bytes. const texts = loaded.asMarkdown ? loaded.documents.map((d) => print(d)) : [loaded.text]; const warnings: string[] = []; for (let i = 0; i < texts.length; i += 1) { const v = validateArm(texts[i]!, { instruments, ...(tapeRegimeScopes !== undefined ? { tapeRegimeScopes } : {}) }); // Non-fatal notices (kestrel-hk9u): a regime gate that can never fire on this context. Collected // and surfaced with the verdict (exit 0) — the document IS valid, the author just needs to know it // will no-op on a regime-less tape. Never demoted to silence, never promoted to a refusal. for (const w of v.warnings) warnings.push(w.message); if (v.armed) continue; const d = v.diagnostics[0]!; if (d.tier === "context") { throw new CliError({ code: "ARM_CONTEXT", exit: EXIT.USAGE, message: d.message, hint: "derive the session context: --bus <tape> (instruments from its META, exactly as `run` resolves them) or --instruments <SYM[,SYM…]>", }); } if (d.tier === "parse") { // Unreachable in practice — `readDocuments` already parsed every document — kept fail-closed // so a future tier reordering can never demote a parse escape to a silent pass. throw new CliError({ code: "PARSE", exit: EXIT.USAGE, message: d.message }); } throw new CliError({ code: "ARM", exit: EXIT.USAGE, message: d.message, hint: texts.length > 1 ? `document ${i + 1} of ${texts.length} parses but refuses to arm — this is the refusal a run would raise` : "the document parses but refuses to arm — this is the refusal a run would raise", }); } return warnings; } /** `parse <file>` / `validate <file>` — parse + validate, report doc/statement counts. With `--arm` * (or `--bus`/`--instruments`, which imply it) also run the engine's arm tier; see the header. */ export async function validateCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { positionals, flags, bools } = parseArgs( argv, new Set(["md", "single", "arm"]), new Set(["bus", "instruments"]), ); const loaded = readDocuments(positionals, bools); const nDocs = loaded.documents.length; const nStmts = countStatements(loaded.documents); // OPT-IN, deliberately: the arm tier needs a session context no bare file supplies, so always-on // would flip every existing pure-parse invocation into a context refusal (and break the stable // exit-code contract). Without these flags the output below stays BYTE-IDENTICAL to before. const armTier = bools.has("arm") || flags.has("bus") || flags.has("instruments"); // Non-fatal arm warnings (kestrel-hk9u) — collected only when the arm tier ran, empty otherwise. const warnings = armTier ? await runArmTier(loaded, flags) : []; if (ctx.mode === "json") { process.stdout.write( JSON.stringify({ schema: "kestrel.validate/v1", valid: true, documents: nDocs, statements: nStmts, // Additive and CONDITIONAL: present only when the arm tier actually ran, so a pure-parse // `valid: true` can never be mistaken for an arm-level verdict (and stays byte-stable). ...(armTier ? { armed: true } : {}), // Non-fatal arm notices — present ONLY when the arm tier ran AND found one, so a clean arm // (and every pure-parse invocation) stays byte-identical to before. ...(warnings.length > 0 ? { warnings } : {}), }) + "\n", ); } else if (ctx.mode === "text") { process.stdout.write(`valid\tdocuments=${nDocs}\tstatements=${nStmts}${armTier ? "\tarmed=true" : ""}\n`); // One tab-framed line per warning (stable, greppable) — after the verdict line, so a consumer // reading only the first line is unaffected. for (const w of warnings) process.stdout.write(`warning\t${w}\n`); } else { const armSuffix = armTier ? (warnings.length > 0 ? " — arms clean, with warnings" : " — arms clean") : ""; const msg = `✓ valid — ${nDocs} document(s), ${nStmts} statement(s)${armSuffix}`; process.stdout.write((ctx.color ? `${GREEN}${msg}${RESET}` : msg) + "\n"); for (const w of warnings) process.stdout.write(`⚠ ${w}\n`); } return 0; } /** `print <file>` — the canonical re-print (the payload IS the text in every mode). */ export function printCommand(argv: readonly string[], ctx: OutputCtx): number { const { documents } = loadDocuments(argv); const text = canonicalPlansText(documents); if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.print/v1", text }) + "\n"); } else { process.stdout.write(text + "\n"); } return 0; }