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.
181 lines (166 loc) • 8.85 kB
text/typescript
/**
* # cli/commands/frame — `frame`/`percept` (LIGHT-ish: src/frame only, no bun/chdb)
*
* Routes the percept through the selected {@link ExecutionBackend}: the LocalBackend renders a Frame
* from a **fixture/snapshot JSON** matching `BriefingInput` or `WakeDeltaInput` (`src/frame/types.ts`)
* — a struct file (a path, or `-`/omitted = stdin), NOT derived from bus state — while the
* RemoteBackend fetches `GET /percepts`. Either way the mode-branch rendering below is unchanged, so
* the local path stays byte-identical to before and node-runnable with no bun/chdb. `--kind` overrides
* the briefing/wake inference (presence of `wakeIndex` ⇒ wake).
*
* ## View addressing (kestrel-wa0j.73.2)
* `--view <name[:panes]>` selects the View the Frame renders under: a bare name resolves against the
* shipped named-View registry ({@link FOUNDER_VIEWS}); `name:pane[ arg…][,pane…]` defines an inline
* View in the language's own pane syntax (e.g. `--view "custom:delta,tape 5m"`). `--seat
* <pm|strategist|watcher>` binds the acting pod seat, so a seat with a founder seed for the frame
* kind reads its founder View; `--seat-views founder` is the explicit opt-in assertion of that same
* behaviour (it requires `--seat`). Resolution precedence is the renderer's own (strict): explicit
* `--view` > seat founder View > phase default. FAIL-CLOSED: an unknown View name, an unknown seat, a
* malformed pane/arg token, or a pane the catalog refuses is a loud typed USAGE error (exit 2) —
* never a silent fall-through to the phase defaults.
*
* Only `text`/`json`/`human` are backed in v1 — a request for any other format (e.g. `--format=html`)
* is rejected loudly by the resolver/router, never silently downgraded. A gated remote percept
* (402/Offer) is rendered as structured data and exits `5` (PAYMENT_REQUIRED).
*/
import type { OutputCtx } from "../context.ts";
import { parseArgs } from "../args.ts";
import { CliError, EXIT } from "../errors.ts";
import { colorizeFrame } from "../render/frame.ts";
import { renderOffer } from "../render/offer.ts";
import { FOUNDER_VIEWS } from "../../frame/founder-views.generated.ts";
import { SEAT_IDS, isSeatId, type SeatId } from "../../frame/seat.ts";
import type { PaneSelectionArg, ViewSelection } from "../../frame/pane-catalog.ts";
import type { ExecutionBackend, PerceptArgs, PerceptKind } from "../backend/index.ts";
function usageErr(message: string): CliError {
return new CliError({ code: "USAGE", exit: EXIT.USAGE, message });
}
/** View + pane names on the CLI surface: an identifier with `-`/`_` (the golden Views' shape). */
const NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
/** The language's time units (`src/lang/ast.ts` TimeUnit) — the window supersort's unit set. */
const WINDOW_RE = /^(\d+)(mo|[smhdwqy])$/;
/**
* One pane-arg token → its **syntactic supersort** (ADR-0041 §1: the five closed surface classes the
* parser assigns with zero catalog knowledge): a window (`5m`), a bare numeral count (`12`), a
* session ordinal (`d-1`), an expiry ordinal (`e-0`), or a bare ident (`vwap`). Anything else is a
* MALFORMED token → loud typed refusal (never dropped, never coerced). Ordinal/expiry are matched
* before ident on purpose — `d-1` is also ident-shaped.
*/
function parsePaneArg(token: string, whole: string): PaneSelectionArg {
const win = WINDOW_RE.exec(token);
if (win !== null) return { kind: "arg-window", window: { value: Number(win[1]), unit: win[2]! } };
if (/^\d+$/.test(token)) return { kind: "arg-count", count: Number(token) };
const ord = /^d-(\d+)$/.exec(token);
if (ord !== null) return { kind: "arg-ordinal", ordinal: Number(ord[1]) };
const exp = /^e-(\d+)$/.exec(token);
if (exp !== null) return { kind: "arg-expiry", expiry: Number(exp[1]) };
if (NAME_RE.test(token)) return { kind: "arg-ident", name: token };
throw usageErr(
`malformed --view ${JSON.stringify(whole)}: pane arg ${JSON.stringify(token)} is not a window (5m), ` +
`count (12), session ordinal (d-1), expiry ordinal (e-0), or ident (vwap)`,
);
}
/** One `pane[ arg…]` entry of an inline View — the language's own pane syntax, comma-separated. */
function parsePaneRef(entry: string, whole: string): ViewSelection["panes"][number] {
const words = entry.trim().split(/\s+/).filter((w) => w !== "");
const name = words[0];
if (name === undefined) {
throw usageErr(`malformed --view ${JSON.stringify(whole)}: empty pane entry (a trailing/doubled comma?)`);
}
if (!NAME_RE.test(name)) {
throw usageErr(`malformed --view ${JSON.stringify(whole)}: bad pane name ${JSON.stringify(name)}`);
}
const args = words.slice(1).map((t) => parsePaneArg(t, whole));
return args.length > 0 ? { name, args } : { name };
}
/**
* `--view <name[:panes]>` → a {@link ViewSelection}, FAIL-CLOSED. A bare `name` must exist in the
* shipped named-View registry ({@link FOUNDER_VIEWS}) or it REFUSES loudly, naming the known Views —
* never a silent fall-through to the phase default. `name:pane[ arg…][,pane…]` builds an inline View
* (pane VALIDITY against the catalog is the renderer's fail-closed job — resolveView refuses unknown
* panes and args on arg-less panes; this parser refuses malformed surface syntax).
*/
function parseViewFlag(value: string): ViewSelection {
const idx = value.indexOf(":");
if (idx === -1) {
const named = FOUNDER_VIEWS[value];
if (named === undefined) {
throw usageErr(
`unknown View ${JSON.stringify(value)} — known named Views: ${Object.keys(FOUNDER_VIEWS).join(", ")}. ` +
`An inline View is --view "<name>:<pane[ arg…][,pane…]>" (e.g. --view "custom:delta,tape 5m")`,
);
}
return named;
}
const name = value.slice(0, idx);
const paneList = value.slice(idx + 1);
if (!NAME_RE.test(name)) {
throw usageErr(`malformed --view ${JSON.stringify(value)}: bad View name ${JSON.stringify(name)}`);
}
if (paneList.trim() === "") {
throw usageErr(`malformed --view ${JSON.stringify(value)}: empty pane list after ":"`);
}
const panes = paneList.split(",").map((entry) => parsePaneRef(entry, value));
return { name, panes };
}
/** `frame <input.json>` / `percept <input.json>` — render a Frame via the backend. */
export async function frameCommand(
argv: readonly string[],
ctx: OutputCtx,
backend: ExecutionBackend,
): Promise<number> {
const { positionals, flags } = parseArgs(argv, new Set(), new Set(["input", "kind", "view", "seat", "seat-views"]));
const path = flags.get("input") ?? positionals[0];
const kindFlag = flags.get("kind");
if (kindFlag !== undefined && kindFlag !== "briefing" && kindFlag !== "wake") {
throw usageErr(`--kind must be briefing|wake, got ${JSON.stringify(kindFlag)}`);
}
// --seat: the closed pod-seat roster (ADR-0041 §2 / A1) — an unknown seat REFUSES, never coerces.
const seatFlag = flags.get("seat");
let seat: SeatId | undefined;
if (seatFlag !== undefined) {
if (!isSeatId(seatFlag)) {
throw usageErr(
`unknown seat ${JSON.stringify(seatFlag)} — the pod-seat roster is closed: ${SEAT_IDS.join("|")}`,
);
}
seat = seatFlag;
}
// --seat-views founder: the explicit founder-View opt-in assertion (mirrors AgentConfig.seatViews).
// On the CLI, supplying --seat IS the opt-in; this flag only asserts it, so it requires --seat and
// accepts only "founder" (any other value is a loud refusal, never a silent policy).
const seatViewsFlag = flags.get("seat-views");
if (seatViewsFlag !== undefined) {
if (seatViewsFlag !== "founder") {
throw usageErr(
`--seat-views must be "founder" (the explicit founder-View opt-in), got ${JSON.stringify(seatViewsFlag)}`,
);
}
if (seat === undefined) {
throw usageErr(`--seat-views founder requires --seat <${SEAT_IDS.join("|")}> — a founder View is keyed by seat`);
}
}
const viewFlag = flags.get("view");
const view = viewFlag !== undefined ? parseViewFlag(viewFlag) : undefined;
const perceptArgs: PerceptArgs = {
...(path !== undefined ? { input: path } : {}),
...(kindFlag !== undefined ? { kind: kindFlag as PerceptKind } : {}),
...(view !== undefined ? { view } : {}),
...(seat !== undefined ? { seat } : {}),
};
const g = await backend.getPercept(perceptArgs);
if (g.gated) {
renderOffer(g.payment, ctx);
return EXIT.PAYMENT_REQUIRED;
}
const { kind, ascii } = g.value;
if (ctx.mode === "json") {
process.stdout.write(JSON.stringify({ schema: "kestrel.frame/v1", kind, ascii }) + "\n");
} else if (ctx.mode === "human") {
process.stdout.write(colorizeFrame(ascii, ctx) + "\n");
} else {
// text / agent / piped — the canonical agent Frame, verbatim.
process.stdout.write(ascii + "\n");
}
return 0;
}