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.
1,288 lines (1,197 loc) • 81.1 kB
text/typescript
/**
* # parse — recursive-descent parser (canonical text -> the typed object model)
*
* The inverse projection of {@link ./print.ts} (ADR-0004): `parse(print(x))` deep-equals
* `x`, and `print(parse(text))` is byte-stable. The parser reads the {@link LineNode} tree
* the {@link ./lex.ts} lexer produces — a statement's header is one line, its clauses are
* the lines nested beneath, and a continued clause folds in via {@link flatten}.
*
* Design commitments:
* - **Fail closed** (ARCHITECTURE §6): every escape is a {@link KestrelParseError} with
* `line`/`col` and an instructive message (the text is read by LLM agents — it names
* what was expected and lists the legal alternatives). No silent clause-dropping.
* - **Registry-open names** (CONTEXT: Registry): any well-formed identifier parses as a
* series reference; resolution happens at arm time, never at parse time.
* - **`atomic` is refused, not faked** (ADR-0005): the keyword parses to a loud rejection.
* - **USING defaults thread through** (ARCHITECTURE §2): a statement inside a module with
* an ambient `USING` is fully qualified by merging the ambient with any explicit clause,
* so the elided form round-trips.
*/
import type {
AlsoTicket,
Anchor,
AnchorName,
Arbitration,
ArmClause,
Baseline,
BookStatement,
Budget,
CancelIfClause,
Citation,
CommentLayer,
LineComment,
Comparison,
Counterfactual,
Coverage,
CrossEvent,
DoTicket,
Duration,
EscStage,
ExitClause,
ExpirySelector,
FillEvent,
GradeDimension,
GradeStatement,
GradeSubject,
HeldQuantifier,
ImportDecl,
Instrument,
InvalidateClause,
KestrelNode,
Leg,
Module,
Operand,
OrderPolicy,
PaneArg,
PaneRef,
PathSegment,
PlanClause,
PlanStatement,
PodStatement,
PriceExpr,
Provenance,
ProvenanceTier,
Quantity,
RegimeGate,
RegimeTagBinding,
ReloadClause,
RiskLine,
SeriesRef,
Standing,
Statement,
StrikeSpec,
TimeOfDay,
TimeUnit,
TpClause,
TpTarget,
Trigger,
Ttl,
Using,
ViewStatement,
WakeBudget,
WakeStatement,
Window,
} from "./ast.ts";
import { flatten, KestrelParseError, lex, type LineNode, type Token } from "./lex.ts";
import {
ANCHOR_NAMES,
ANCHORS,
CITATION_ALGOS,
CLOCK_STOP_KEYWORD,
CMP_FROM_PUNCT,
FILL_EVENTS,
GRADE_WHATS,
EXPIRY_PREFIX,
HELD_STOP_KEYWORD,
ORDINAL_PREFIX,
ORDINALS,
PROV_RANK,
PROV_TIERS,
STANDINGS,
TIME_UNITS,
TIME_UNITS_LIST,
} from "./vocab.ts";
import {
ATOMIC_MESSAGE,
bandMessage,
budgetMessage,
citationBadHashMessage,
CITATION_INLINE_BODY_MESSAGE,
citationOnMessage,
elevatesProvenance,
findMarkInTrigger,
HELD_CROSS_MESSAGE,
isCitationHash,
isSimpleSeries,
markMessage,
provenanceMessage,
} from "./validate.ts";
export { KestrelParseError } from "./lex.ts";
// The closed vocabularies (anchors, time units, fill events, standings, provenance tiers,
// grade subjects, comparison ops, ordinals) live in ./vocab.ts, derived from the ast.ts
// unions; the six doctrine invariants live in ./validate.ts. This file reads both — it keeps
// no private copy — so parser and printer can never drift from the types or from each other.
const CLAUSE_KEYWORDS = "WHEN, USING, DO, ALSO, RELOAD, TP, EXIT, INVALIDATE, CANCEL-IF, or ARM";
/**
* The clause keywords that live INSIDE a PLAN block (indented beneath the `PLAN <name>` header).
* Used ONLY to make diagnostics repair-guiding when a live author (a) collapses two clauses onto one
* line (`WHEN … DO …`) or (b) hoists a clause to the top level (a bare `WHEN …` statement) — the two
* dominant live-model authoring mistakes (dry-run-1, docs/results/dry-run-1-live-baseline.md, 45 of
* ~54 escapes). PURELY DIAGNOSTIC: what the parser accepts/rejects is unchanged (a trailing/top-level
* clause keyword was always an error) — only the MESSAGE on rejection names the fix. No grammar
* contract change, no honesty/fail-closed-guard change.
*/
const PLAN_CLAUSE_KEYWORD_SET: ReadonlySet<string> = new Set([
"WHEN",
"USING",
"DO",
"ALSO",
"RELOAD",
"TP",
"EXIT",
"INVALIDATE",
"CANCEL-IF",
"ARM",
]);
/**
* Natural-language trigger verbs a strong model reaches for in the infix `<operand> <verb> …`
* position where Kestrel's ONLY verbs are `crosses`/`touches` (or a `<`/`>` comparison). Observed
* live (finding #1): `spot breaks below …`, `spot dips to …`. PURELY DIAGNOSTIC — none of these is
* an accepted token; the parser still rejects them. The MESSAGE names the bad verb and steers to the
* canonical crossing form, mirroring the WHEN/DO-collapse hint. No grammar-contract change.
*/
const TRIGGER_VERB_SYNONYMS: ReadonlySet<string> = new Set([
"breaks",
"break",
"dips",
"dip",
"rises",
"rise",
"falls",
"fall",
"moves",
"move",
"drops",
"drop",
"climbs",
"climb",
"jumps",
"jump",
"hits",
"hit",
"reaches",
"reach",
"exceeds",
"exceed",
]);
/** Repair hint for a natural-language trigger verb (diagnostic only — `verb` stays rejected). */
function triggerVerbSynonymMessage(verb: string): string {
return (
`unexpected \`${verb}\` in a trigger — Kestrel has no \`${verb}\` trigger verb. ` +
`Write a threshold event as \`crosses above|below\` (a strict crossing) or ` +
`\`touches above|below\` (at-or-touch), e.g. \`spot crosses below 499.15\` — ` +
`or a plain comparison with \`<\`/\`>\`, e.g. \`spot < 499.15\`.`
);
}
/**
* Natural-language price-anchor tokens a model reaches for to express a bounded price where Kestrel's
* bounding constructors are `min`/`max`/`lean`. PURELY DIAGNOSTIC — none is an accepted anchor; the
* MESSAGE names the bad token and steers to the canonical constructor.
*
* `cap` USED to live here but has since GRADUATED to an accepted synonym (ADR-0030 measured-grammar
* relaxation, kestrel-hvgd): `cap A, B` normalizes to `min(A, B)` in {@link parsePriceAtom}. The
* remaining tokens stay rejected-with-a-steer — the cluster for them has not cleared teaching.
*/
const PRICE_ANCHOR_SYNONYMS: ReadonlySet<string> = new Set(["ceiling", "floor", "limit"]);
/** Repair hint for a natural-language price anchor (diagnostic only — `tok` stays rejected). */
function priceAnchorSynonymMessage(tok: string): string {
return (
`unexpected \`${tok}\` — Kestrel has no \`${tok}\` price anchor. ` +
`To bound a price use \`min(...)\` to cap it or \`max(...)\` to floor it, ` +
`or \`lean(a,b,x)\` to lean a fraction between two anchors — ` +
`e.g. \`min(fair, 0.95)\` caps the price at 0.95.`
);
}
function describe(t: Token | undefined): string {
if (t === undefined) return "end of line";
if (t.type === "string") return "a string";
if (t.type === "number") return `number ${t.text}`;
return `\`${t.text}\``;
}
// ─────────────────────────────────────────────────────────────────────────────
// Comment trivia — carry the byte-stable "why" from the line tree into the AST (ADR-0033)
// ─────────────────────────────────────────────────────────────────────────────
/** Build a {@link LineComment} for one printed line from its source {@link LineNode} trivia,
* or undefined when the line carries no comments. */
function lineCommentOf(node: LineNode, at: number): LineComment | undefined {
const leading = node.leading;
const trailing = node.trailing;
const hasLeading = leading !== undefined && leading.length > 0;
if (!hasLeading && trailing === undefined) return undefined;
return {
at,
...(hasLeading ? { leading } : {}),
...(trailing !== undefined ? { trailing } : {}),
};
}
/**
* Assemble a block's comment sidecar from its header line (ordinal 0), its direct interior
* lines in canonical print order (ordinals 1..N), and the header node's `tail`. Child blocks
* (a pod's books/sub-pods) own their own trivia and are NOT passed as interior lines. Returns
* undefined when nothing is present, so a comment-free statement carries no `comments` field
* and stays deep-equal to its builder output.
*/
function commentLayer(header: LineNode, interior: readonly LineNode[]): CommentLayer | undefined {
const lines: LineComment[] = [];
const h = lineCommentOf(header, 0);
if (h !== undefined) lines.push(h);
interior.forEach((n, i) => {
const lc = lineCommentOf(n, i + 1);
if (lc !== undefined) lines.push(lc);
});
const tail = header.tail;
const hasTail = tail !== undefined && tail.length > 0;
if (lines.length === 0 && !hasTail) return undefined;
return {
...(lines.length > 0 ? { lines } : {}),
...(hasTail ? { tail } : {}),
};
}
/** Spread helper: `{ ...withComments(layer) }` adds a `comments` field only when present. */
function withComments(layer: CommentLayer | undefined): { comments?: CommentLayer } {
return layer === undefined ? {} : { comments: layer };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cursor — a positioned token stream over one logical line
// ─────────────────────────────────────────────────────────────────────────────
class Cursor {
private i = 0;
constructor(
private readonly toks: readonly Token[],
private readonly fallbackLine: number,
) {}
peek(k = 0): Token | undefined {
return this.toks[this.i + k];
}
/** Text of the token at `k` iff it is a word, else undefined (for keyword dispatch). */
wordAt(k = 0): string | undefined {
const t = this.toks[this.i + k];
return t !== undefined && t.type === "word" ? t.text : undefined;
}
atEnd(): boolean {
return this.i >= this.toks.length;
}
private here(): { line: number; col: number } {
const t = this.toks[this.i];
if (t !== undefined) return { line: t.line, col: t.col };
const last = this.toks[this.toks.length - 1];
return last !== undefined ? { line: last.line, col: last.end } : { line: this.fallbackLine, col: 1 };
}
fail(message: string): never {
const { line, col } = this.here();
throw new KestrelParseError(message, line, col);
}
advance(): Token {
const t = this.toks[this.i];
if (t === undefined) this.fail("unexpected end of line");
this.i++;
return t;
}
matchWord(text: string): boolean {
return this.wordAt() === text;
}
optWord(text: string): boolean {
if (this.matchWord(text)) {
this.i++;
return true;
}
return false;
}
expectWord(text: string, ctx: string): void {
if (!this.matchWord(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
this.i++;
}
matchPunct(text: string): boolean {
const t = this.peek();
return t !== undefined && t.type === "punct" && t.text === text;
}
optPunct(text: string): boolean {
if (this.matchPunct(text)) {
this.i++;
return true;
}
return false;
}
expectPunct(text: string, ctx: string): void {
if (!this.matchPunct(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
this.i++;
}
expectString(ctx: string): string {
const t = this.peek();
if (t === undefined || t.type !== "string") this.fail(`expected a quoted string ${ctx}, got ${describe(t)}`);
this.i++;
return t.text;
}
expectNumber(ctx: string): number {
const t = this.peek();
if (t === undefined || t.type !== "number") this.fail(`expected a number ${ctx}, got ${describe(t)}`);
this.i++;
return Number(t.text);
}
expectEnd(ctx: string): void {
if (!this.atEnd()) this.fail(`unexpected ${describe(this.peek())} after ${ctx}`);
}
/**
* Read an identifier, reassembling the tight hyphen/digit forms the lexer split
* (`zoom-1d`, `chase-urgent`). Extends across a maximal run of adjacent word/number
* tokens joined by `-`/`_`, stopping at the first gap or non-name token.
*/
readName(what: string): string {
const first = this.peek();
if (first === undefined || first.type !== "word") this.fail(`expected ${what}, got ${describe(first)}`);
let text = first.text;
let end = first.end;
this.i++;
for (;;) {
const t = this.peek();
if (t === undefined || t.col !== end) break;
if (t.type === "punct" && (t.text === "-" || t.text === "_")) {
const after = this.peek(1);
if (after !== undefined && after.col === t.end && (after.type === "word" || after.type === "number")) {
text += t.text + after.text;
end = after.end;
this.i += 2;
continue;
}
break;
}
if (t.type === "word" || t.type === "number") {
text += t.text;
end = t.end;
this.i++;
continue;
}
break;
}
return text;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Repair-guiding clause-end (diagnostic-only — accept/reject set unchanged)
// ─────────────────────────────────────────────────────────────────────────────
/**
* End a PLAN clause line. If a SIBLING clause keyword trails on the same line, fail with a
* repair-guiding message naming the fix instead of the bare `unexpected \`DO\` after …`. The
* WHEN/DO-on-one-line collapse was the single dominant live-model authoring error (dry-run-1: 35 of
* ~54 escapes). Diagnostic only — a trailing clause keyword was always an error; this just teaches
* the reader (a model or a human reading the journalled error) how to repair it.
*/
function expectClauseEnd(c: Cursor, clauseCtx: string): void {
const t = c.peek();
if (t !== undefined && t.type === "word" && PLAN_CLAUSE_KEYWORD_SET.has(t.text)) {
c.fail(
`unexpected \`${t.text}\` after ${clauseCtx} — this looks like two clauses on one line. ` +
`Each PLAN clause goes on its OWN line, indented two spaces beneath the \`PLAN <name>\` header: ` +
`write \`WHEN …\` and \`DO …\` as SEPARATE lines, never \`WHEN … DO …\` together.`,
);
}
c.expectEnd(clauseCtx);
}
// ─────────────────────────────────────────────────────────────────────────────
// Lexical readers
// ─────────────────────────────────────────────────────────────────────────────
function readTimeUnit(c: Cursor, ctx: string): TimeUnit {
const units = TIME_UNITS_LIST.join(", ");
const u = c.readName(`a time unit (${units}) ${ctx}`);
if (!TIME_UNITS.has(u)) c.fail(`unknown time unit \`${u}\`; expected one of ${units}`);
return u as TimeUnit;
}
function readWindow(c: Cursor): Window {
const value = c.expectNumber("for a window size");
const unit = readTimeUnit(c, "for a window");
return { kind: "window", value, unit };
}
function readDuration(c: Cursor, ctx: string): Duration {
const value = c.expectNumber(`for a duration ${ctx}`);
const unit = readTimeUnit(c, ctx);
return { kind: "duration", value, unit };
}
function readTimeOfDay(c: Cursor): TimeOfDay {
const hour = c.expectNumber("for the hour of a clock time");
c.expectPunct(":", "in a clock time (HH:MM)");
const minute = c.expectNumber("for the minute of a clock time");
if (!Number.isInteger(hour) || hour < 0 || hour > 23) c.fail(`bad clock hour ${hour}; expected 0..23`);
if (!Number.isInteger(minute) || minute < 0 || minute > 59) c.fail(`bad clock minute ${minute}; expected 0..59`);
return { kind: "time-of-day", hour, minute };
}
/** A quantity: an optional sign, a number, an optional unit (`R`, `c`, `%`, `bp`). */
function readQuantity(c: Cursor, ctx: string): Quantity {
let sign = 1;
if (c.matchPunct("+")) c.advance();
else if (c.matchPunct("-")) {
c.advance();
sign = -1;
}
const value = sign * c.expectNumber(ctx);
const unit = readOptUnit(c);
return unit === undefined ? { kind: "quantity", value } : { kind: "quantity", value, unit };
}
/** A cross re-arm band width: a positive quantity (the band>0 doctrine invariant lives in
* ./validate.ts; here it fails closed on the value with position). */
function readBand(c: Cursor): Quantity {
const q = readQuantity(c, "for a cross re-arm band width (`band 5c`)");
if (!(q.value > 0)) c.fail(bandMessage(quantityText(q)));
return q;
}
/** A human-readable rendering of a quantity for error messages (parse-only; does not need
* to be canonical). */
function quantityText(q: Quantity): string {
return q.unit === undefined ? String(q.value) : `${q.value}${q.unit}`;
}
function readOptUnit(c: Cursor): Quantity["unit"] | undefined {
if (c.matchPunct("%")) {
c.advance();
return "%";
}
const w = c.wordAt();
if (w === "R" || w === "c" || w === "bp") {
c.advance();
return w;
}
return undefined;
}
// ─────────────────────────────────────────────────────────────────────────────
// Series references & operands
// ─────────────────────────────────────────────────────────────────────────────
function parseSeries(c: Cursor): SeriesRef {
const segments: PathSegment[] = [];
for (;;) {
const name = c.readName("a series name");
// A selector is `(word...)`; a window is `(number...)` and belongs to the whole series.
if (c.matchPunct("(") && c.peek(1)?.type === "word") {
c.advance(); // (
const q = c.wordAt();
let selector: PathSegment["selector"];
if (q === "any" || q === "all") {
c.advance();
selector = { kind: "sel-quant", q };
} else {
selector = { kind: "sel-name", name: c.readName("a selector name inside `(...)`") };
}
c.expectPunct(")", "to close a series selector");
segments.push({ name, selector });
} else {
segments.push({ name });
}
if (c.optPunct(".")) continue;
break;
}
let window: Window | undefined;
if (c.matchPunct("(") && c.peek(1)?.type === "number") {
c.advance(); // (
window = readWindow(c);
c.expectPunct(")", "to close a series window");
}
return window === undefined ? { kind: "series", segments } : { kind: "series", segments, window };
}
/** An operand of a comparison/cross: a number/quantity, a baseline, or a series. */
function parseOperand(c: Cursor): Operand {
const t = c.peek();
if (t !== undefined && (t.type === "number" || (t.type === "punct" && (t.text === "+" || t.text === "-")))) {
// signed number: sigma baseline (`3sigma`), a quantity, or a bare number
let sign = 1;
if (c.matchPunct("+")) c.advance();
else if (c.matchPunct("-")) {
c.advance();
sign = -1;
}
const value = c.expectNumber("for an operand");
if (c.matchWord("sigma")) {
c.advance();
return { kind: "baseline", stat: "sigma", n: value };
}
const unit = readOptUnit(c);
return unit === undefined ? { kind: "quantity", value: sign * value } : { kind: "quantity", value: sign * value, unit };
}
const w = c.wordAt();
if (w !== undefined && /^p\d+$/.test(w)) {
c.advance();
return { kind: "baseline", stat: "p", n: Number(w.slice(1)) };
}
if (w !== undefined) return parseSeries(c);
c.fail(`expected an operand (a series name, a number, or a baseline like p99/3sigma), got ${describe(t)}`);
}
// `isSimpleSeries` and the EXIT-may-not-condition-on-a-mark walk (`findMarkInTrigger`) live in
// ./validate.ts, shared with the builder/printer so a mark-conditioned EXIT is refused on every
// surface; this file imports them.
// ─────────────────────────────────────────────────────────────────────────────
// Trigger algebra (OR < AND < NOT < postfix < atom) — mirrors print.ts precedence
// ─────────────────────────────────────────────────────────────────────────────
function parseTrigger(c: Cursor): Trigger {
return parseOr(c);
}
function parseOr(c: Cursor): Trigger {
const terms = [parseAnd(c)];
while (c.optWord("OR")) terms.push(parseAnd(c));
return terms.length === 1 ? terms[0]! : { kind: "or", terms };
}
function parseAnd(c: Cursor): Trigger {
const terms = [parseNot(c)];
while (c.optWord("AND")) terms.push(parseNot(c));
return terms.length === 1 ? terms[0]! : { kind: "and", terms };
}
function parseNot(c: Cursor): Trigger {
if (c.optWord("NOT")) return { kind: "not", term: parseNot(c) };
return parsePostfix(c);
}
function parsePostfix(c: Cursor): Trigger {
let inner = parseAtom(c);
for (;;) {
if (c.matchWord("held")) {
// Fail closed at the `held` token: holding a cross (a bare edge) is the sfg8 anti-pattern.
if (inner.kind === "cross") c.fail(HELD_CROSS_MESSAGE);
c.advance();
inner = { kind: "break-hold", inner, dur: readDuration(c, "after `held`") };
} else if (c.matchWord("within")) {
c.advance();
inner = { kind: "within", inner, dur: readDuration(c, "after `within`") };
} else if (c.matchWord("until")) {
// The thesis temporal envelope (kestrel-rtf): `until`/`at` are postfix combinators over
// the SAME predicate surface as `within` — siblings, not a second predicate language —
// each taking a wall-clock time via the existing `readTimeOfDay`.
c.advance();
inner = { kind: "until", inner, at: readTimeOfDay(c) };
} else if (c.matchWord("at")) {
c.advance();
inner = { kind: "at", inner, at: readTimeOfDay(c) };
} else break;
}
return inner;
}
function ordinalValue(c: Cursor): number | undefined {
const w = c.wordAt();
if (w !== undefined) {
const idx = ORDINALS.indexOf(w);
if (idx >= 1) return idx;
}
// numeric ordinal: `<n>th`
const t = c.peek();
if (t !== undefined && t.type === "number" && c.wordAt(1) === "th") return Number(t.text);
return undefined;
}
function parseAtom(c: Cursor): Trigger {
if (c.optPunct("(")) {
const t = parseTrigger(c);
c.expectPunct(")", "to close a parenthesized trigger");
return t;
}
const ord = ordinalValue(c);
if (ord !== undefined) {
// consume the ordinal (a word, or `<number> th`)
if (c.wordAt() !== undefined) c.advance();
else {
c.advance(); // number
c.advance(); // th
}
return { kind: "nth", ordinal: ord, event: parseAtom(c) };
}
if (c.matchWord("phase")) {
c.advance();
return { kind: "phase", phase: c.readName("a phase name after `phase`") };
}
if (c.matchWord("time")) {
c.advance();
return parseTimeWindow(c);
}
// 0DTE time-exit stops (docs/results/fomc-options-axis) — a bare, LEADING time-clock, distinct
// from the `<inner> held <dur>` break-hold postfix (which always has an inner atom before `held`,
// so it never reaches this leading position). `held <dur>` = a TimeHeldStop; `clockET <clock>` = a
// wall-clock ClockStop. A leading `held` is intercepted ONLY when a duration number follows, so a
// bare `held` identifier keeps its old reading (a structural event named `held`) — strict superset.
if (c.matchWord(HELD_STOP_KEYWORD) && c.peek(1)?.type === "number") {
c.advance();
return { kind: "held-stop", dur: readDuration(c, "after `held` (a 0DTE time-held stop, `held 90m`)") };
}
if (c.matchWord(CLOCK_STOP_KEYWORD)) {
c.advance();
return { kind: "clock-stop", at: readTimeOfDay(c) };
}
const w = c.wordAt();
if (w !== undefined && FILL_EVENTS.has(w)) {
c.advance();
const event = w as FillEvent["event"];
if (c.optWord("leg")) {
const leg = c.expectNumber("for a fill-event leg index");
return { kind: "fill", event, leg };
}
return { kind: "fill", event };
}
return parseOperandLed(c);
}
function parseTimeWindow(c: Cursor): Trigger {
if (c.optWord("after")) return { kind: "time-window", from: readTimeOfDay(c) };
if (c.optWord("before")) return { kind: "time-window", to: readTimeOfDay(c) };
const from = readTimeOfDay(c);
c.expectPunct("..", "in a time window (`time HH:MM..HH:MM`)");
const to = readTimeOfDay(c);
return { kind: "time-window", from, to };
}
function parseOperandLed(c: Cursor): Trigger {
const left = parseOperand(c);
if (c.matchWord("crosses") || c.matchWord("touches")) {
// `touches` is the at-or-touch predicate (>=/<=); `crosses` is a strict crossing (>/<).
const touch = c.advance().text === "touches";
const kw = touch ? "touches" : "crosses";
// `left crosses|touches outside LO-HI` — a RANGE-BREAKOUT (ADR-0030 measured-grammar widening,
// kestrel-hvgd): fire on a breakout past EITHER edge of the band. It DESUGARS to the OR of the
// two hardened single-edge crosses — `left <verb> above HI OR left <verb> below LO` — so it adds
// NO new runtime node (the engine already evaluates OR + cross) and inherits the sfg8 hardening.
// `touches outside` keeps the at-or-touch predicate on both edges; `crosses outside` is strict.
if (c.optWord("outside")) {
const lo = parseOperand(c);
c.expectPunct("-", "between the low and high of a breakout range (`crosses outside LO-HI`)");
const hi = parseOperand(c);
const touchOpt = touch ? { touch: true as const } : {};
const above: CrossEvent = { kind: "cross", left, dir: "above", right: hi, ...touchOpt };
const below: CrossEvent = { kind: "cross", left, dir: "below", right: lo, ...touchOpt };
return { kind: "or", terms: [above, below] };
}
let dir: CrossEvent["dir"];
if (c.optWord("above")) dir = "above";
else if (c.optWord("below")) dir = "below";
else c.fail(`expected \`above\`, \`below\`, or \`outside LO-HI\` (a breakout) after \`${kw}\`, got ${describe(c.peek())}`);
const right = parseOperand(c);
// Optional re-arm band: `... band 5c`. The band must be a positive width (a zero/negative
// band can never re-arm) — fail closed on it rather than arm a cross that can't re-arm.
let band: Quantity | undefined;
if (c.optWord("band")) band = readBand(c);
return {
kind: "cross",
left,
dir,
right,
...(touch ? { touch } : {}),
...(band !== undefined ? { band } : {}),
} satisfies CrossEvent;
}
const opTok = c.peek();
if (opTok !== undefined && opTok.type === "punct" && opTok.text in CMP_FROM_PUNCT) {
c.advance();
const op = CMP_FROM_PUNCT[opTok.text]!;
return { kind: "cmp", op, left, right: parseOperand(c) } satisfies Comparison;
}
// A natural-language trigger verb in the infix position (where only crosses/touches/`<`/`>` are
// legal) — name it and steer to the canonical crossing form rather than swallowing the operand as a
// bare event and failing downstream with a bare `unexpected \`breaks\` …`. Diagnostic only.
const infix = c.wordAt();
if (infix !== undefined && TRIGGER_VERB_SYNONYMS.has(infix)) c.fail(triggerVerbSynonymMessage(infix));
// otherwise this is a structural event named by a bare identifier
if (isSimpleSeries(left)) {
const name = left.segments[0]!.name;
if (c.optWord("of")) return { kind: "event", name, of: parseOperand(c) };
return { kind: "event", name };
}
c.fail(
`expected a comparison (>, >=, <, <=, ==, !=), \`crosses above/below\`, or \`of\` ` +
`after this operand, got ${describe(c.peek())}`,
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Price expressions
// ─────────────────────────────────────────────────────────────────────────────
function parsePriceAtom(c: Cursor): PriceExpr {
const t = c.peek();
if (t !== undefined && t.type === "word") {
if (ANCHORS.has(t.text)) {
c.advance();
return { kind: "anchor", name: t.text as AnchorName } satisfies Anchor;
}
if (t.text === "min" || t.text === "max") {
c.advance();
c.expectPunct("(", `after \`${t.text}\``);
const args: PriceExpr[] = [parsePrice(c)];
while (c.optPunct(",")) args.push(parsePrice(c));
c.expectPunct(")", `to close \`${t.text}(...)\``);
return { kind: "price-fn", fn: t.text, args };
}
// `cap A, B` — a measured-grammar synonym (ADR-0030, kestrel-hvgd) for `min(A, B)`. Models
// author `@ cap fair, 0.95` to CAP a price at a ceiling — exactly what `min` does. Parenless,
// comma-separated, ≥2 operands; it NORMALIZES to the canonical `min(...)` price-fn so the
// printer emits only `min` (one canonical form) and round-trip stays byte-stable (ADR-0004).
// A strict superset: `cap` in a PRICE position was rejected-with-a-steer before, never accepted,
// and the order-policy `cap <price>` list keyword is consumed one rung up (parseOrderPolicy)
// before this atom is ever reached, so no previously-parsing form changes meaning.
if (t.text === "cap") {
c.advance();
const args: PriceExpr[] = [parsePrice(c)];
while (c.optPunct(",")) args.push(parsePrice(c));
if (args.length < 2) {
c.fail(
"`cap` needs a ceiling — write `cap <price>, <ceiling>` (it normalizes to " +
"`min(<price>, <ceiling>)`), e.g. `cap fair, 0.95`",
);
}
return { kind: "price-fn", fn: "min", args };
}
if (t.text === "lean") {
c.advance();
c.expectPunct("(", "after `lean`");
const a = parsePrice(c);
c.expectPunct(",", "between lean arguments");
const b = parsePrice(c);
c.expectPunct(",", "between lean arguments");
const x = c.expectNumber("for the lean fraction");
c.expectPunct(")", "to close `lean(...)`");
return { kind: "lean", a, b, x };
}
}
if (t !== undefined && t.type === "number") {
c.advance();
return { kind: "price-abs", value: Number(t.text) };
}
// A natural-language price-anchor token (`ceiling`/`floor`/`limit`) — name it and steer to the
// canonical bounding constructor rather than the bare `got \`ceiling\``. Diagnostic only. (`cap`
// is no longer here: it graduated to an accepted `min` synonym above — kestrel-hvgd.)
if (t !== undefined && t.type === "word" && PRICE_ANCHOR_SYNONYMS.has(t.text)) {
c.fail(priceAnchorSynonymMessage(t.text));
}
c.fail(
`expected a price: an anchor (${ANCHOR_NAMES.join(", ")}), a number, or min/max/lean(...), ` +
`got ${describe(t)}`,
);
}
function parsePrice(c: Cursor): PriceExpr {
let base = parsePriceAtom(c);
for (;;) {
const t = c.peek();
const next = c.peek(1);
if (t !== undefined && t.type === "punct" && (t.text === "+" || t.text === "-") && next?.type === "number") {
c.advance(); // sign
const amount = c.expectNumber("for a price offset");
let unit: "c" | "%";
if (c.optPunct("%")) unit = "%";
else if (c.matchWord("c")) {
c.advance();
unit = "c";
} else {
c.fail(`expected \`c\` or \`%\` after a price offset amount, got ${describe(c.peek())}`);
}
base = { kind: "price-offset", base, sign: t.text as "+" | "-", amount, unit };
continue;
}
break;
}
return base;
}
// ─────────────────────────────────────────────────────────────────────────────
// Legs, order policy, held quantifiers
// ─────────────────────────────────────────────────────────────────────────────
function parseStrike(c: Cursor): StrikeSpec {
if (c.optWord("atm")) return { kind: "strike-atm" };
if (c.matchPunct("+") || c.matchPunct("-")) {
const sign = c.advance().text === "-" ? -1 : 1;
return { kind: "strike-rel", steps: sign * c.expectNumber("for a relative strike") };
}
const t = c.peek();
if (t !== undefined && t.type === "number") {
c.advance();
if (c.matchWord("d")) {
c.advance();
return { kind: "strike-delta", delta: Number(t.text) };
}
return { kind: "strike-abs", strike: Number(t.text) };
}
// Name BOTH continuations after a leg quantity: a strike (the option leg) OR `shares` (the equity
// leg, ADR-0017). Every mistake must price in one frame — an author who wrote `buy 100 @ mid` needs
// to see that `buy 100 shares @ mid` is the equity form, not just that a strike was expected.
c.fail(
`expected a strike (+N/-N relative, N absolute, atm, or Nd delta) for an option leg, ` +
`or \`shares\` for an equity leg (\`buy N shares\`, ADR-0017), got ${describe(t)}`,
);
}
function parseLeg(c: Cursor): Leg {
let side: "buy" | "sell";
if (c.optWord("buy")) side = "buy";
else if (c.optWord("sell")) side = "sell";
else c.fail(`expected \`buy\` or \`sell\` to start a leg, got ${describe(c.peek())}`);
const qty = c.expectNumber("for a leg quantity");
// An equity/spot leg (`buy 100 shares`, ADR-0017): the `shares` marker replaces strike+right;
// the symbol is the ambient `USING exec`. Anything else is an option leg (`buy 2 +1 C`).
if (c.optWord("shares")) {
// Fail-closed: a spot instrument has no strike or right, so `shares` is a COMPLETE leg (ADR-0017).
// A strike/right token trailing it is the equity↔option confusion — refuse loudly, never silently
// reinterpret. (A `,` next leg, an `@` price, or end-of-ticket are the only legal continuations.)
const t = c.peek();
const w = c.wordAt();
if ((t !== undefined && t.type === "number") || w === "C" || w === "P" || w === "atm" || c.matchPunct("+") || c.matchPunct("-")) {
c.fail(`an equity/spot leg carries no strike or right — \`${qty} shares\` is complete (ADR-0017); drop the strike/right`);
}
return { kind: "equity-leg", side, qty };
}
const strike = parseStrike(c);
let right: "C" | "P";
if (c.optWord("C")) right = "C";
else if (c.optWord("P")) right = "P";
else c.fail(`expected \`C\` or \`P\` (option right) after a strike, got ${describe(c.peek())}`);
// An optional PER-LEG expiry (`buy 2 +1 C exp 0dte`, kestrel-ih5h seam 1). The selector vocabulary is
// the SAME `parseExpiry` the execution instrument uses (`USING exec SPY 0dte`) — `Ndte`, a date, or a
// tag — so an author learns one expiry language, not two. Unlike the instrument form (positional, held
// apart by a stop-set) a leg's expiry needs the `exp` MARKER: a leg is followed by `,` or `@`, and a
// bare tag selector reads names, so a positional tag would silently swallow whatever came next. Absent
// `exp`, the leg leaves `expiry` undefined and inherits the ambient `USING exec` tenor (additive:
// every plan written before this syntax prints byte-identically, ADR-0004).
let expiry: ExpirySelector | undefined;
if (c.optWord("exp")) {
// Fail closed: `exp` with nothing usable after it is a parse escape, NEVER a silent undefined — an
// undefined expiry means "inherit the exec tenor", so accepting a bare `exp` would quietly read back
// as the ambient (often 0dte) expiry the author was trying to override. No selector can begin with
// punctuation, so `exp @ mid` / `exp ,` are caught here rather than misread as a tag.
const t = c.peek();
if (t === undefined || t.type === "punct") {
c.fail(`\`exp\` needs an expiry selector after it — \`0dte\`, a date (\`2026-07-17\`), or a tag (\`weekly\`) — got ${describe(t)}`);
}
expiry = parseExpiry(c);
if (expiry === undefined) c.fail("`exp` needs an expiry selector after it (`0dte`, a date like `2026-07-17`, or a tag like `weekly`)");
}
return { kind: "leg", side, qty, strike, right, ...(expiry !== undefined ? { expiry } : {}) };
}
function parseLegs(c: Cursor): Leg[] {
const legs = [parseLeg(c)];
while (c.optPunct(",")) legs.push(parseLeg(c));
return legs;
}
function parseHeldQuantifier(c: Cursor): HeldQuantifier | undefined {
if (c.matchWord("foreach")) {
c.advance();
c.expectWord("held", "in a held-leg quantifier (`foreach held leg`)");
c.expectWord("leg", "in a held-leg quantifier (`foreach held leg`)");
return { kind: "held-foreach" };
}
if (c.matchWord("any")) {
c.advance();
c.expectWord("held", "in a held-leg quantifier (`any held leg`)");
c.expectWord("leg", "in a held-leg quantifier (`any held leg`)");
return { kind: "held-any" };
}
// A bare `held leg` with no `foreach`/`any` head is a MIS-TYPED quantifier — the author reached for
// the adoption idiom and dropped the quantifier keyword (`ARM held leg` instead of `ARM foreach held
// leg`). Without this, the leading `held` falls through to a generic `expectClauseEnd` "unexpected
// `held`" that flags the wrong token and never teaches the fix — unlike the `foreach`/`any` siblings,
// which echo the full form. Detect `held leg` specifically (a bare `held <duration>` is a legitimate
// TimeHeldStop inside a trigger and never reaches here) and echo BOTH correct forms (kestrel-b4wx).
if (c.wordAt() === "held" && c.wordAt(1) === "leg") {
c.fail(
"a held-leg quantifier needs a `foreach` or `any` head — `held leg` alone is not a quantifier. " +
"Did you mean `foreach held leg` (every held leg) or `any held leg`?",
);
}
return undefined;
}
function parseOrderPolicy(c: Cursor): OrderPolicy | undefined {
let pricing: "peg" | "fix" | undefined;
const esc: EscStage[] = [];
const caps: PriceExpr[] = [];
const floors: PriceExpr[] = [];
let cancelIf: Trigger | undefined;
let gtc = false;
for (;;) {
if (c.matchWord("peg") || c.matchWord("fix")) {
if (pricing !== undefined) c.fail("a ticket may declare `peg` or `fix` only once");
pricing = c.advance().text as "peg" | "fix";
} else if (c.optWord("esc")) {
const to = parsePrice(c);
esc.push({ kind: "esc-stage", to, after: readDuration(c, "for an esc stage") });
} else if (c.optWord("cap")) {
caps.push(parsePrice(c));
} else if (c.optWord("floor")) {
floors.push(parsePrice(c));
} else if (c.optWord("cancel-if")) {
cancelIf = parseTrigger(c);
} else if (c.optWord("gtc")) {
gtc = true;
} else break;
}
if (pricing === undefined && esc.length === 0 && caps.length === 0 && floors.length === 0 && cancelIf === undefined && !gtc) {
return undefined;
}
return {
kind: "order-policy",
...(pricing !== undefined ? { pricing } : {}),
...(esc.length > 0 ? { esc } : {}),
...(caps.length > 0 ? { caps } : {}),
...(floors.length > 0 ? { floors } : {}),
...(cancelIf !== undefined ? { cancelIf } : {}),
...(gtc ? { gtc } : {}),
};
}
function rejectAtomicIfPresent(c: Cursor): void {
if (c.matchWord("atomic")) c.fail(ATOMIC_MESSAGE);
}
// ─────────────────────────────────────────────────────────────────────────────
// Plan clauses
// ─────────────────────────────────────────────────────────────────────────────
function parseTpTarget(c: Cursor): TpTarget {
if (c.matchPunct("+") || c.matchPunct("-")) {
const sign = c.advance().text === "-" ? -1 : 1;
const pct = sign * c.expectNumber("for a TP percentage");
c.expectPunct("%", "after a TP percentage (`+100%`)");
return { kind: "tp-pct", pct };
}
const t = c.peek();
if (t !== undefined && t.type === "number") {
if (c.peek(1)?.type === "punct" && c.peek(1)!.text === "%") {
c.advance();
c.advance();
return { kind: "tp-pct", pct: Number(t.text) };
}
if (c.wordAt(1) === "x") {
c.advance();
c.advance();
return { kind: "tp-mult", mult: Number(t.text) };
}
}
return { kind: "tp-price", price: parsePrice(c) };
}
function parsePlanClause(c: Cursor): PlanClause {
const kw = c.readName(`a clause keyword (${CLAUSE_KEYWORDS})`);
switch (kw) {
case "DO":
case "ALSO": {
rejectAtomicIfPresent(c);
const legs = parseLegs(c);
c.expectPunct("@", "before the ticket price");
const price = parsePrice(c);
const policy = parseOrderPolicy(c);
expectClauseEnd(c, `the ${kw} ticket`);
const base = { legs, price, ...(policy !== undefined ? { policy } : {}) };
return kw === "DO" ? ({ kind: "do", ...base } satisfies DoTicket) : ({ kind: "also", ...base } satisfies AlsoTicket);
}
case "RELOAD": {
let when: Trigger | undefined;
if (c.optWord("WHEN")) when = parseTrigger(c);
rejectAtomicIfPresent(c);
const legs = parseLegs(c);
c.expectPunct("@", "before the RELOAD price");
const price = parsePrice(c);
const policy = parseOrderPolicy(c);
expectClauseEnd(c, "the RELOAD ticket");
return {
kind: "reload",
...(when !== undefined ? { when } : {}),
legs,
price,
...(policy !== undefined ? { policy } : {}),
} satisfies ReloadClause;
}
case "TP": {
const target = parseTpTarget(c);
let frac: number | undefined;
if (c.optWord("frac")) frac = c.expectNumber("for a TP fraction");
const over = parseHeldQuantifier(c);
let price: PriceExpr | undefined;
if (c.optPunct("@")) price = parsePrice(c);
const policy = parseOrderPolicy(c);
expectClauseEnd(c, "the TP clause");
return {
kind: "tp",
target,
...(frac !== undefined ? { frac } : {}),
...(over !== undefined ? { over } : {}),
...(price !== undefined ? { price } : {}),
...(policy !== undefined ? { policy } : {}),
} satisfies TpClause;
}
case "EXIT": {
const when = parseTrigger(c);
const mark = findMarkInTrigger(when);
if (mark !== undefined) c.fail(markMessage(mark));
const over = parseHeldQuantifier(c);
let price: PriceExpr | undefined;
if (c.optPunct("@")) price = parsePrice(c);
const policy = parseOrderPolicy(c);
expectClauseEnd(c, "the EXIT clause");
return {
kind: "exit",
when,
...(over !== undefined ? { over } : {}),
...(price !== undefined ? { price } : {}),
...(policy !== undefined ? { policy } : {}),
} satisfies ExitClause;
}
case "INVALIDATE": {
const when = parseTrigger(c);
expectClauseEnd(c, "the INVALIDATE clause");
return { kind: "invalidate", when } satisfies InvalidateClause;
}
case "CANCEL-IF": {
const when = parseTrigger(c);
expectClauseEnd(c, "the CANCEL-IF clause");
return { kind: "cancel-if", when } satisfies CancelIfClause;
}
case "ARM": {
let when: Trigger | undefined;
if (c.optWord("WHEN")) when = parseTrigger(c);
let basis: PriceExpr | undefined;
if (c.optWord("basis")) basis = parsePrice(c);
const over = parseHeldQuantifier(c);
// An ARM clause must DO something: adopt a held leg (`… foreach held leg`) OR chain on another
// plan's state (`ARM WHEN …`). A clause carrying NEITHER a held quantifier NOR a WHEN trigger
// binds nothing — bare `ARM` and `ARM basis <px>` parse valid today and then silently adopt
// NOTHING (no de-arm reason, no frame notice), re-locking the r866 "a fill is a one-way door"
// trap one dropped keyword away (kestrel-b4wx). Refuse it AT PARSE — the earliest, loudest signal,
// naming the two real idioms — rather than let an author ship a plan that arms into the void.
// A `basis` needs a held leg to anchor onto (RUNTIME §4: `basis` is unresolvable with no held
// position), but a `basis` alongside a WHEN is legitimate (a chain-armed manage plan carries its
// cost basis; see the `manage-inventory` golden), so the refusal keys ONLY on "neither when nor
// held quantifier", never on basis alone.
if (when === undefined && over === undefined) {
c.fail(
basis === undefined
? "bare `ARM` binds nothing — an ARM clause must adopt a held leg or chain a plan. " +
"Did you mean `ARM foreach held leg` (adopt a superseded plan's held leg — the exit " +
"escape hatch) or `ARM WHEN <trigger>` (chain plans on another's state)?"
: "`ARM basis <px>` carries an adoption basis but binds no leg — a basis only anchors an " +
"ADOPTED held leg (RUNTIME §4). Did you mean `ARM basis <px> foreach held leg` (adopt " +
"the superseded plan's held leg at that basis)?",
);
}
expectClauseEnd(c, "the ARM clause");
return {
kind: "arm",
...(when !== undefined ? { when } : {}),
...(basis !== undefined ? { basis } : {}),
...(over !== undefined ? { over } : {}),
} satisfies ArmClause;
}
default:
c.fail(`unknown clause \`${kw}\`; expected one of ${CLAUSE_KEYWORDS}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Instruments, USING, provenance
// ─────────────────────────────────────────────────────────────────────────────
function parseExpiry(c: Cursor): ExpirySelector | undefined {
const t = c.peek();
if (t === undefined) return undefined;
if (t.type === "number") {
c.advance();
if (c.matchWord("dte")) {
c.advance();
return { kind: "expiry-dte", dte: Number(t.text) };
}
// date token: N(-N)+ — preserve the RAW digit text (zero-padded `07` stays `07`);
// Number() here would drop leading zeros and break the round-trip.
if (c.matchPunct("-")) {
let s = t.text;
while (c.optPunct("-")) {
const seg = c.peek();
if (seg === undefined || seg.type !== "number") c.fail(`expected a number after \`-\` in an expiry date, got ${describe(seg)}`);
s += "-" + seg.text;
c.advance();
}
return { kind: "expiry-date", date: s };
}
c.fail(`expected \`dte\` or a date after ${t.text} in an expiry, got ${describe(c.peek())}`);
}
return { kind: "expiry-tag", tag: c.readName("an expiry tag") };
}
function parseInstrument(c: Cursor, opts: { expiry: boolean; stop: ReadonlySet<string> }): Instrument {
const symbol = c.readName("an instrument symbol");
if (!opts.expiry) return { kind: "instrument", symbol };
const w = c.wordAt();
const nextIsStop = w !== undefined && opts.stop.has(w);
const hasExpiry = !c.atEnd() && !nextIsStop;
if (!hasExpiry) return { kind: "instrument", symbol };
const expiry = parseExpiry(c);
return expiry === undefined ? { kind: "instrument", symbol } : { kind: "instrument", symbol, expiry };
}
const USING_STOP: ReadonlySet<string> = new Set(["signal", "exec"]);
function parseUsingBody(c: Cursor): Using {
let signal: Instrument | undefined;
let exec: Instrument | undefined;
for (;;) {
if (c.optWord("signal")) signal = parseInstrument(c, { expiry: true, stop: USING_STOP });
else if (c.optWord("exec")) exec = parseInstrument(c, { expiry: true, stop: USING_STOP });
else break;
}
if (signal === undefined && exec === undefined) {
c.fail("`USING` needs at least a `signal` or an `exec` instrument");
}
return {
kind: "using",
...(signal !== undefined ? { signal } : {}),
...(exec !== undefined ? { exec } : {}),
};
}
function parseProvenanceBrace(c: Cursor): Provenance {
c.expectPunct("{", "to open a provenance map");
let tier: ProvenanceTier | undefined;
let author: string | undefined;
let origin: string | undefined;
let replay: string | undefined;
if (!c.matchPunct("}")) {
for (;;) {
const key = c.readName("a provenance field (tier, author, origin, or replay)");
c.expectPunct(":", "after a provenance field name");
switch (key) {
case "tier": {
const v = c.readName("a provenance tier (vetted, candidate, or unvetted)");
if (!PROV_TIERS.has(v)) c.fail(`unknown provenance tier \`${v}\`; expected vetted, candidate, or unvetted`);
tier = v as ProvenanceTier;
break;
}
case "author":
author = c.expectString("for a provenance author");
break;
case "origin":
origin = c.expectString("for a provenance origin");
break;
case "replay":
replay = c.expectString("for a provenance replay record");
break;
default:
c.fail(`unknown provenance field \`${key}\`; expected tier, author, origin, or replay`);
}
if (!c.optPunct(",")) break;
}
}
c.expectPunct("}", "to close a provenance map");
return {
kind: "provenance",
...(tier !== undefined ? { tier } : {}),
...(author !== undefined ? { author } : {}),
...(origin !== undefined ? { origin } : {}),
...(replay !== undefined ? { replay } : {}),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// The `because` pre-registration citation (kestrel-rtf)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reassemble a content-hash digest from the adjacent tokens the lexer split it into. There is
* no lexed hash token in Kestrel, so a hex digest lexes as a maximal run of adjacent word/number
* tokens: a letter-leading digest (`af3c…`) is one word, but a **digit-leading** digest (`82f6…`)
* splits into a `number` (`82`) then a `word` (`f6…`) — so we join by adjacency exactly like
* {@link Cursor.readName} / {@link readCorpusToken}, stopping at the first gap or non-hex token.
*/
function readContentHash(c: Cursor): string {
const first = c.peek();
if (first === undefined || (first.type !== "word" && first.type !== "number")) {
c.fail(citationBadHashMessage(describe(first)));
}
let text = first.text;
let end = first.end;
c.advance();
for (;;) {
const t = c.peek();
if (t === undefined || t.col !== end) break; // a gap (space) ends the digest
if (t.type !== "word" && t.type !== "number") break;
text += t.text;
end = t.end;
c.advance();
}
return text;
}
/**
* Parse a `because` citation body — the `BECAUSE` keyword is already consumed by the caller.
* The fixed shape is `<algo>:<64 hex>` (only `sha256` today; {@link CITATION_ALGOS}). Fails
* closed on a bad/absent hash and on an inline body — the clause carries a content hash ONLY
* (the thesis prose lives platform-side).
*/
function parseBecause(c: Cursor): Citation {
const algo = c.readName("a content-hash algorithm in a `because` citation (`sha256:<64 hex>`)");
if (!CITATION_ALGOS.has(algo)) c.fail(citationBadHashMessage(`${algo}:…`));
c.expectPunct(":", "between `sha256` and its digest in a `because` citation (`sha256:<64 hex>`)");
const hash = readContentHash(c);
if (!isCitationHash(hash)) c.fail(citationBadHashMessage(hash));
// Content-hash ONLY: any trailing token (a quoted body, more prose) is the refu