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