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.

366 lines (331 loc) 18.7 kB
/** * # cli/commands/orient — the bare-npx ORIENTATION (ADR-0035 §c, PRD §0.2, bead kestrel-jvr4.3) * * `npx kestrel.markets` with NO args renders neither the generic help nor an alphabetical command * dump. It renders an **orientation**: (1) a **spectator Frame** — a default watchlist + rectangular * panes of scalar cells — and (2) the CLI's **commands grouped by task** (*see the market · write a * plan · test it · review · go live*). `kestrel help` / `--help` still print the full usage (help is * NOT the orientation); the orientation points AT them. * * ## Same content, two Renderings (ADR-0035 §c) * An agent / CI / pipe gets the orientation as static `text` and moves on (exit 0, no stdin read). A * **confident human** gets the *same* orientation as the **opening view** of the interactive session. * The full interactive session is a later phase of the epic; for now the human Rendering IS the static * orientation, presented as that opening view (it only adds a one-line "opening view" banner) — so * this bead ships the orientation both ways without pretending the TUI exists yet. * * ## Data honesty (PRD §7 acceptance: "nothing phoned home") * The spectator panes come from data shipped IN the package — never a network call, never an invented * number. The **provenance-honest** source is `artifacts/sdk/catalog-records.json` (a package `files` * entry: the public-baseline recorded sessions, each carrying its own `recordedAt` date). Its rows are * rendered with that recorded date, so a recorded tape is never passed off as live. The **levels** * pane is honestly all-`UNKNOWN` — no live market feed ships in-package, and a bare invocation makes no * request. `SpectatorFrame.asof` is the ONE permitted wall clock in the whole contract (invariant 4): * it has nothing at stake, and the DATA rows state their recorded date so the wall clock never launders * a stale number into a live one. If the shipped catalog is somehow absent, every pane degrades to * all-`UNKNOWN` with the watchlist + a provenance note — still honest, still an orientation. * * Node-light (built-ins only: `fs`/`path`/`url`) — no `bun:*`, no network — so it runs under plain node * and never reads stdin. `null` cells render as `UNKNOWN` (never blank); the contract object carries * only scalar cells, never pre-rendered ascii. */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { GlobalFlags, OutputCtx } from "../context.ts"; import type { Caller } from "../caller.ts"; import { VERSION } from "./meta.ts"; // ───────────────────────────────────────────────────────────────────────────── // The spectator contract shapes (oversight contract §3.4). Mirrored here — the canonical home is // `src/protocol/oversight.ts` (a NEW leaf that lands with the full oversight contract bead); this // bead needs only the spectator subset and does not fork the shape. // ───────────────────────────────────────────────────────────────────────────── /** A cell is a scalar. `null` is UNKNOWN and renders as such — never blank, never invented. */ export type SpectatorCell = string | number | null; /** A RECTANGULAR pane of scalar cells — so any Rendering can draw it without inventing a value. * NOT pre-rendered ascii (oversight contract §3.4). */ export interface SpectatorPane { readonly paneId: string; readonly columns: readonly string[]; readonly rows: readonly (readonly SpectatorCell[])[]; } /** Spectator context: NOTHING AT STAKE (the bare-invocation orientation). Off the deterministic path, * so `asof` (a wall clock) is permitted HERE and nowhere else (contract invariant 4). */ export interface SpectatorFrame { readonly watchlist: readonly string[]; readonly asof: string; readonly panes: readonly SpectatorPane[]; } /** How each pane's rows were sourced — Rendering-level provenance, kept OFF the scalar contract object * (the frame stays pure). `recorded` names the shipped recorded date; `unknown` = no live source ships. */ export interface OrientationProvenance { readonly paneId: string; readonly note: string; } /** The default watchlist — generic illustrative instruments only (ARCHITECTURE §7). */ export const DEFAULT_WATCHLIST = ["SPX", "SPY", "QQQ"] as const; // ───────────────────────────────────────────────────────────────────────────── // Task-grouped commands (ADR-0035 §c) — never an alphabetical dump. Each verb is a REAL shipped verb. // ───────────────────────────────────────────────────────────────────────────── export interface TaskGroup { readonly task: string; readonly steps: readonly { readonly cmd: string; readonly purpose: string }[]; } export const TASK_GROUPS: readonly TaskGroup[] = [ { task: "see the market", steps: [ { cmd: "kestrel sim", purpose: "run a curated hosted scenario, free (bare: the menu)" }, { cmd: "kestrel prove", purpose: "the zero-credential proof — no key, no config, no prompt" }, ], }, { task: "write a plan", steps: [ { cmd: "kestrel card first-plan", purpose: "author your first document (offline walkthrough)" }, { cmd: "kestrel parse <file>", purpose: "parse + validate a plan document" }, ], }, { task: "test it", steps: [ { cmd: "kestrel run --bus <p> --plans <p> --fill <m> --r-usd <n>", purpose: "grade a session against a recorded bus" }, { cmd: "kestrel day --bus <p> --dir <d> --fill <m> --r-usd <n>", purpose: "a stepped/wake session" }, ], }, { task: "review", steps: [ { cmd: "kestrel runs list", purpose: "query recorded runs" }, { cmd: "kestrel runs show <id>", purpose: "show one run + its plans (or a hosted proof URL)" }, { cmd: "kestrel leaderboard", purpose: "the ranked leaderboard over recorded runs" }, ], }, { task: "go live", steps: [ { cmd: "kestrel register", purpose: "self-register as an autonomous agent" }, { cmd: "kestrel paper --instrument <s> …", purpose: "a PAPER session on a live IB Gateway feed (never live money)" }, ], }, ]; // ───────────────────────────────────────────────────────────────────────────── // Shipped data source — artifacts/sdk/catalog-records.json (a package `files` entry). // ───────────────────────────────────────────────────────────────────────────── interface CatalogEntry { readonly id: string; readonly permittedFillModel?: string; readonly provenance?: { readonly recordedAt?: string }; } interface CatalogRecord { readonly blotter?: { readonly settlement?: { readonly realized?: number; readonly asset?: string } }; } interface CatalogFile { readonly catalogEntries?: readonly CatalogEntry[]; readonly records?: Readonly<Record<string, CatalogRecord>>; } /** * Resolve the shipped `artifacts/sdk/catalog-records.json` by ascending from THIS module's own * location until it is found — handles both the repo checkout (`src/cli/commands/`, three up) and the * bundled install (`dist/cli.js`, one up) without a hard-coded depth, exactly like `card.ts`. Returns * `null` (never throws) if it is somehow absent — the orientation then degrades to all-UNKNOWN. */ function resolveCatalogPath(): string | null { let dir = dirname(fileURLToPath(import.meta.url)); const rel = join("artifacts", "sdk", "catalog-records.json"); for (let i = 0; i < 8; i++) { const candidate = join(dir, rel); if (existsSync(candidate)) return candidate; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** Load the shipped catalog, or `null` if absent / unreadable / malformed (fail-soft: honesty over a crash). */ export function loadCatalog(): CatalogFile | null { const path = resolveCatalogPath(); if (path === null) return null; try { return JSON.parse(readFileSync(path, "utf8")) as CatalogFile; } catch { return null; } } // ───────────────────────────────────────────────────────────────────────────── // Build the spectator frame (pure, given the catalog + a wall-clock ISO). // ───────────────────────────────────────────────────────────────────────────── /** The recorded date (YYYY-MM-DD) of a catalog entry, or `null` if unrecorded. */ function recordedDate(e: CatalogEntry): string | null { const at = e.provenance?.recordedAt; return typeof at === "string" && at.length >= 10 ? at.slice(0, 10) : null; } /** * Build the {@link SpectatorFrame} + its Rendering-level provenance. Two panes: * - **levels** — the watchlist with all-`null` (UNKNOWN) live cells: no live feed ships in-package, * and a bare invocation makes no request. This is the honest all-UNKNOWN case, in-product. * - **public-baseline** — the shipped recorded sessions (id · fill model · settled usd · recorded * date). Real scalar values, each stamped with its own recorded date. Omitted only if the catalog * is absent, in which case the frame is watchlist + the levels pane alone — still an orientation. */ export function buildSpectatorFrame(nowIso: string, catalog: CatalogFile | null): { frame: SpectatorFrame; provenance: readonly OrientationProvenance[] } { const watchlist = [...DEFAULT_WATCHLIST]; const levels: SpectatorPane = { paneId: "levels", columns: ["symbol", "last", "chg", "status"], // Every live cell is UNKNOWN (null): no bundled live feed. `symbol` is the only known scalar. rows: watchlist.map((sym) => [sym, null, null, null] as const), }; const panes: SpectatorPane[] = [levels]; const provenance: OrientationProvenance[] = [ { paneId: "levels", note: "no live market feed ships in this package — levels UNKNOWN; no request is made." }, ]; const entries = catalog?.catalogEntries ?? []; const records = catalog?.records ?? {}; if (entries.length > 0) { const rows = entries.map((e): readonly SpectatorCell[] => { const settled = records[e.id]?.blotter?.settlement?.realized; return [ e.id, e.permittedFillModel ?? null, typeof settled === "number" ? settled : null, // absent settlement ⇒ UNKNOWN, never a silent 0 recordedDate(e), // the row's OWN recorded date — never the wall clock ]; }); panes.push({ paneId: "public-baseline", columns: ["session", "fill model", "settled (usd)", "recorded"], rows }); provenance.push({ paneId: "public-baseline", note: "recorded public-baseline catalog, shipped in this package (artifacts/sdk/catalog-records.json) — not live.", }); } return { frame: Object.freeze({ watchlist, asof: nowIso, panes }), provenance }; } // ───────────────────────────────────────────────────────────────────────────── // Rendering — glyphs/layout only, invents no value. `null` → "UNKNOWN". // ───────────────────────────────────────────────────────────────────────────── const BOLD = ""; const DIM = ""; const RESET = ""; /** The visible glyph for a scalar cell: `UNKNOWN` for null (never blank), else the value's string. */ export function cellGlyph(c: SpectatorCell): string { return c === null ? "UNKNOWN" : String(c); } function padEnd(s: string, w: number): string { return s.length >= w ? s : s + " ".repeat(w - s.length); } /** Render one pane as a fixed-width table (header + rows), null → UNKNOWN. */ function renderPane(pane: SpectatorPane, color: boolean): string { const header = (s: string): string => (color ? `${BOLD}${s}${RESET}` : s); const body = pane.rows.map((r) => r.map(cellGlyph)); const widths = pane.columns.map((h, c) => Math.max(h.length, ...body.map((row) => (row[c] ?? "").length), 0)); const line = (cells: readonly string[]): string => cells.map((cell, c) => padEnd(cell, widths[c]!)).join(" "); const lines: string[] = []; lines.push(" " + header(line(pane.columns))); for (const row of body) lines.push(" " + line(row)); return lines.join("\n"); } /** * The machine JSON Rendering of the same orientation (an explicit `--json` on the bare invocation — * ladder rung 1: the flag wins). The spectator frame rides as DATA — scalar cells, `null` = UNKNOWN — * which is exactly the contract §3.4 shape both Renderings consume; nothing is pre-rendered. */ export function renderOrientationJson( frame: SpectatorFrame, provenance: readonly OrientationProvenance[], caller: Caller, ): string { return ( JSON.stringify({ schema: "kestrel.orient/v1", version: VERSION, caller: { kind: caller.kind, detectedBy: caller.detectedBy, interactive: caller.interactive }, spectator: frame, provenance, tasks: TASK_GROUPS, see: ["kestrel help", "kestrel card"], }) + "\n" ); } /** * Render the full orientation to a string. `text`/agent = plain; `human` = bold headers when * `ctx.color`. When `caller.interactive` (a confident human), an "opening view" banner is prepended — * the human Rendering of the same content as the session's opening view. */ export function renderOrientation( frame: SpectatorFrame, provenance: readonly OrientationProvenance[], ctx: OutputCtx, caller: Caller, ): string { const color = ctx.mode === "human" && ctx.color; const header = (s: string): string => (color ? `${BOLD}${s}${RESET}` : s); const dim = (s: string): string => (color ? `${DIM}${s}${RESET}` : s); const lines: string[] = []; lines.push(`kestrel ${VERSION} — orient`); if (caller.interactive) { // The opening view of the interactive session (a local paper Pod). The full session is a later // phase; for now this banner is the only difference between the two Renderings. lines.push(dim("opening view — interactive session (local paper Pod) is coming; showing your orientation")); } lines.push(""); // ── the spectator frame ── lines.push(header("the market")); lines.push(` watchlist: ${frame.watchlist.join(" ")}`); lines.push(` ${dim(`as of ${frame.asof} (wall clock — spectator only, nothing at stake)`)}`); const provByPane = new Map(provenance.map((p) => [p.paneId, p.note])); for (const pane of frame.panes) { lines.push(""); lines.push(` ${header(pane.paneId)}`); lines.push(renderPane(pane, color)); const note = provByPane.get(pane.paneId); if (note !== undefined) lines.push(` ${dim(note)}`); } lines.push(""); // ── task-grouped commands ── lines.push(header("next moves")); for (const group of TASK_GROUPS) { lines.push(""); lines.push(` ${header(group.task)}`); const w = Math.max(...group.steps.map((s) => s.cmd.length)); for (const step of group.steps) lines.push(` ${padEnd(step.cmd, w)} ${dim(step.purpose)}`); } lines.push(""); lines.push(dim("full command list: `kestrel help` · learn the language (offline): `kestrel card`")); return lines.join("\n") + "\n"; } /** * The bare-invocation orientation command. Builds the spectator frame from shipped data (no network, * no stdin read), renders it for the resolved Caller, writes it to stdout, and returns exit 0. * * The single wall clock (`SpectatorFrame.asof`) is read HERE — the one place the whole contract permits * it (invariant 4), because the spectator frame has nothing at stake. */ export function orientCommand(ctx: OutputCtx, caller: Caller, globals: GlobalFlags = {}): number { // Diagnostic (stderr only — stdout stays a pure orientation channel): under KESTREL_DEBUG, emit how // the Caller was resolved, so the detection ladder is observable THROUGH the real driver (a spawned // binary) as well as in the pure unit matrix — the env-beats-TTY rule is asserted both ways. const dbg = process.env.KESTREL_DEBUG; if (dbg !== undefined && dbg !== "" && dbg !== "0") { process.stderr.write( `caller\tkind=${caller.kind}\tharness=${caller.harness ?? ""}\tdetectedBy=${caller.detectedBy}\tinteractive=${caller.interactive}\n`, ); } const { frame, provenance } = buildSpectatorFrame(new Date().toISOString(), loadCatalog()); if (ctx.mode === "json") { // An explicit `--json` on the bare invocation (ladder rung 1: the flag wins) — machine JSON. process.stdout.write(renderOrientationJson(frame, provenance, caller)); return 0; } // Ladder rung 2 (ADR-0035 §b): a DETECTED agent/CI gets machine `text` even while holding a PTY — // `resolveOutputCtx` probes only the TTY + its own agent vars, so a PTY-holding agent would resolve // `human` there; for the BARE invocation the Caller (env-first) governs the skin. An explicit // `--format` still wins (rung 1) — only the un-flagged agent/CI case is downgraded. One-shot verbs // are untouched: this adjustment lives inside the orientation only. const explicitFormat = globals.json === true || globals.format !== undefined; const effCtx: OutputCtx = !explicitFormat && caller.kind === "agent" && ctx.mode === "human" ? { mode: "text", color: false, width: Infinity, interactive: false, stream: false } : ctx; process.stdout.write(renderOrientation(frame, provenance, effCtx, caller)); return 0; }