diff --git a/docs/dream-cycle/LEDGER.md b/docs/dream-cycle/LEDGER.md index 8e7ee3d..9b705ba 100644 --- a/docs/dream-cycle/LEDGER.md +++ b/docs/dream-cycle/LEDGER.md @@ -17,3 +17,4 @@ | 2026-08-17 | evaluation-adapters | Given the Darwin evaluator at commit `569285b` configured with `--sandbox mock - | NONE | NONE | yes | ACCEPT | | 7ecbf140d180 | | | 2026-08-17 | evaluation-adapters | Given the Darwin evaluator at commit `569285b` configured with `--sandbox mock - | NONE | NONE | yes | ACCEPT | | 9bf1005b390a | | | 2026-08-28 | ledger-signals | fate reconciliation (agentbox operator audit): judgment-broker queue showed #9/#11/#15 as pending-merge; their changes landed 2026-08-13..15 (#7/#9 consolidated into the fork by human merge, #11/#15 merged in upstream ruvnet/dream-machine whose PR numbering this ledger borrowed) — recording terminal fates so the queue clears | NONE | NONE | no | INCONCLUSIVE | | operator | #7:MERGED #9:MERGED #11:MERGED #15:MERGED | +| 2026-09-01 | ledger-signals | INCONCLUSIVE — see report | NONE | NONE | yes | INCONCLUSIVE | | bdb6735f5b3b | | diff --git a/packages/ledger/src/rowContract.test.ts b/packages/ledger/src/rowContract.test.ts new file mode 100644 index 0000000..0ddcd3d --- /dev/null +++ b/packages/ledger/src/rowContract.test.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseRow, validateLedger, validateRow } from "./rowContract"; + +const ENFORCE_FROM = "2026-09-06"; + +function rulesOf(cells: string[]): string[] { + return validateRow(cells, ENFORCE_FROM).map((v) => v.rule); +} + +const COMPLIANT = [ + "2026-09-06", + "ledger-signals", + "row-contract validator added; prior rows show format drift", + "NONE", + "pending", + "yes", + "ACCEPT", + "guards cross-night memory", + "0123456789ab", + "", +]; + +describe("rowContract unit rules", () => { + it("accepts a compliant row", () => { + expect(rulesOf(COMPLIANT)).toEqual([]); + }); + + it("rejects pointer-only findings", () => { + const cells = [...COMPLIANT]; + cells[2] = "INCONCLUSIVE — see report"; + expect(rulesOf(cells)).toContain("finding-pointer"); + }); + + it("rejects frozen-hypothesis leakage into the finding column", () => { + const cells = [...COMPLIANT]; + cells[2] = "Given the Darwin evaluator at commit `7c30573a2d73c8fa4c67a43042d7c0b204eefa13`"; + expect(rulesOf(cells)).toContain("finding-hypothesis-leak"); + }); + + it("rejects findings longer than 80 chars", () => { + const cells = [...COMPLIANT]; + cells[2] = "x".repeat(81); + expect(rulesOf(cells)).toContain("finding-too-long"); + }); + + it("rejects ACCEPT rows that do not track a PR", () => { + const cells = [...COMPLIANT]; + cells[4] = "NONE"; + expect(rulesOf(cells)).toContain("accept-without-pr"); + }); + + it("rejects prose in the prior-night fates column", () => { + const cells = [...COMPLIANT]; + cells[9] = "merged #7 by human"; + expect(rulesOf(cells)).toContain("fates-grammar"); + }); + + it("accepts well-formed fate tokens", () => { + const cells = [...COMPLIANT]; + cells[9] = "#7:MERGED #8:OPEN #9:STALE"; + expect(rulesOf(cells)).toEqual([]); + }); + + it("grandfathers rows dated before the enforcement cutoff", () => { + const cells = [...COMPLIANT]; + cells[0] = "2026-09-01"; + cells[2] = "INCONCLUSIVE — see report"; + expect(rulesOf(cells)).toEqual([]); + }); + + it("parses header and separator lines without flagging them", () => { + const text = [ + "| date | deep | finding | issue | PR | evaluated? | verdict | effect | witness | prior-night fates |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| " + COMPLIANT.join(" | ") + " |", + ].join("\n"); + expect(validateLedger(text, ENFORCE_FROM)).toEqual([]); + expect(parseRow(text.split("\n")[0] ?? "")).toEqual([ + "date", + "deep", + "finding", + "issue", + "PR", + "evaluated?", + "verdict", + "effect", + "witness", + "prior-night fates", + ]); + }); +}); + +describe("rowContract on the real ledger", () => { + const ledgerPath = findLedger(); + + it.skipIf(ledgerPath === null)("docs/dream-cycle/LEDGER.md complies from the cutoff", () => { + const text = readFileSync(ledgerPath as string, "utf8"); + expect(validateLedger(text, ENFORCE_FROM)).toEqual([]); + }); +}); + +function findLedger(): string | null { + let dir = process.cwd(); + for (let i = 0; i < 6; i += 1) { + const candidate = join(dir, "docs", "dream-cycle", "LEDGER.md"); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} diff --git a/packages/ledger/src/rowContract.ts b/packages/ledger/src/rowContract.ts new file mode 100644 index 0000000..2e1309a --- /dev/null +++ b/packages/ledger/src/rowContract.ts @@ -0,0 +1,74 @@ +// Ledger row contract validator — dream-cycle 2026-09-06, deep: ledger-signals. +// Enforces the machine-readable invariants of docs/dream-cycle/LEDGER.md rows: +// finding is concrete and self-contained (<= 80 chars, no pointers, no +// frozen-hypothesis leakage); prior-night fates are token-only (#N:FATE with +// FATE in MERGED|CLOSED|OPEN|STALE); ACCEPT rows track a PR and carry a witness. +// Rows dated before `enforceFrom` are grandfathered. + +export interface RowViolation { + date: string; + rule: string; + detail: string; +} + +const FATE_TOKEN = /^#\d+:(MERGED|CLOSED|OPEN|STALE)$/; + +/** Parse a markdown table line into its 10 ledger cells, or null if not a row. */ +export function parseRow(line: string): string[] | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) return null; + const cells = trimmed.split("|").map((cell) => cell.trim()); + if (cells.length < 12) return null; + return cells.slice(1, 11); +} + +/** Validate one parsed row (10 cells). Rows dated before enforceFrom are skipped. */ +export function validateRow(cells: string[], enforceFrom: string): RowViolation[] { + const violations: RowViolation[] = []; + const at = (index: number): string => (cells[index] ?? ""); + const date = at(0); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return violations; + if (date < enforceFrom) return violations; + + const flag = (rule: string, detail: string): void => { + violations.push({ date, rule, detail }); + }; + + const finding = at(2); + if (finding.length === 0) flag("finding-empty", "finding column is empty"); + if (finding.length > 80) flag("finding-too-long", `${finding.length} chars > 80`); + if (/\bsee\s+(report|gist)\b/i.test(finding) || /^(see|gist)\b/i.test(finding)) { + flag("finding-pointer", "finding points elsewhere instead of stating the result"); + } + if (/^given\b/i.test(finding)) { + flag("finding-hypothesis-leak", "finding column contains frozen-hypothesis text"); + } + + const verdict = at(6); + if (!/^(ACCEPT|REJECT|INCONCLUSIVE)$/.test(verdict)) { + flag("verdict-vocab", `unrecognised verdict "${verdict}"`); + } + if (verdict === "ACCEPT" && (at(4) === "NONE" || at(4) === "")) { + flag("accept-without-pr", "ACCEPT rows must record PR as pending or #N"); + } + if (verdict === "ACCEPT" && at(8) === "") { + flag("accept-without-witness", "ACCEPT rows must carry a witness"); + } + + const fates = at(9); + if (fates !== "" && !fates.split(/\s+/).every((token) => FATE_TOKEN.test(token))) { + flag("fates-grammar", "prior-night fates must be space-separated #N:FATE tokens only"); + } + + return violations; +} + +/** Validate a whole ledger file body. */ +export function validateLedger(text: string, enforceFrom: string): RowViolation[] { + const violations: RowViolation[] = []; + for (const line of text.split(/\r?\n/)) { + const cells = parseRow(line); + if (cells !== null) violations.push(...validateRow(cells, enforceFrom)); + } + return violations; +}