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
text/typescript
/**
* # 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 = "[1m";
const DIM = "[2m";
const RESET = "[0m";
/** 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;
}