src / ledger.ts
src / ledger.ts
// The ledger: the state of one project, stored in its own folder.
//
// ledger.json current state, written atomically
// ledger.md readable rendering, regenerated on every write, and editable by hand
// meta.json what throughLine last wrote to ledger.md, and the project's turn count
// events.jsonl append-only log of every change
// versions/ copies of ledger.json from before each write, newest 50 kept
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { appendLine, ensureDir, projectDir, projectsDir, readJson, writeFileAtomic, writeJson } from "./store";
import { slugify } from "./text";
export type StepStatus = "todo" | "doing" | "done" | "blocked";
export type Verification = "verified" | "unverified" | "not_read";
export type Actor = "user" | "model" | "plugin";
export interface Step {
id: number;
text: string;
status: StepStatus;
note: string;
}
export interface Finding {
id: string;
text: string;
source: string;
verification: Verification;
step?: number;
chat?: string;
at: string;
}
export interface Decision {
id: string;
text: string;
reason: string;
by: Actor;
step?: number;
at: string;
}
export interface Question {
id: string;
text: string;
status: "open" | "resolved";
answer?: string;
turn?: number;
at: string;
resolvedAt?: string;
}
export interface Ledger {
format: 1;
name: string;
created: string;
updated: string;
seq: number;
goal: string;
goalSource: "user_message" | "command" | "none";
autoStarted?: boolean;
constraints: string[];
plan: Step[];
findings: Finding[];
decisions: Decision[];
questions: Question[];
counters: { F: number; D: number; Q: number };
chats: string[];
}
export interface LedgerEvent {
seq: number;
ts: string;
actor: Actor;
op: string;
id?: string;
text?: string;
chat?: string;
}
export interface Meta {
renderedHash?: string;
turns: number;
orphanedSince?: string;
undoneTo?: number;
}
export const VERSIONS_KEPT = 50;
export const STEP_STATUSES: StepStatus[] = ["todo", "doing", "done", "blocked"];
const ledgerFile = (slug: string) => path.join(projectDir(slug), "ledger.json");
const eventsFile = (slug: string) => path.join(projectDir(slug), "events.jsonl");
const versionsDir = (slug: string) => path.join(projectDir(slug), "versions");
const metaFile = (slug: string) => path.join(projectDir(slug), "meta.json");
export const markdownFile = (slug: string) => path.join(projectDir(slug), "ledger.md");
export const hash = (text: string) => crypto.createHash("sha256").update(text).digest("hex");
export function loadLedger(slug: string): Ledger | null {
const ledger = readJson<Ledger>(ledgerFile(slug));
return ledger && ledger.format === 1 ? ledger : null;
}
export function loadMeta(slug: string): Meta {
return readJson<Meta>(metaFile(slug)) ?? { turns: 0 };
}
export function saveMeta(slug: string, meta: Meta): void {
writeJson(metaFile(slug), meta);
}
export function listProjects(): string[] {
try {
return fs
.readdirSync(projectsDir(), { withFileTypes: true })
.filter((d) => d.isDirectory() && fs.existsSync(ledgerFile(d.name)))
.map((d) => d.name)
.sort();
} catch {
return [];
}
}
export function uniqueSlug(base: string): string {
const root = slugify(base);
let slug = root;
for (let n = 2; fs.existsSync(projectDir(slug)); n++) slug = `${root}-${n}`;
return slug;
}
export function createLedger(goal: string, goalSource: Ledger["goalSource"], chatId: string, opts: { autoStarted?: boolean } = {}): Ledger {
const now = new Date().toISOString();
const name = uniqueSlug(goal || "project");
const ledger: Ledger = {
format: 1,
name,
created: now,
updated: now,
seq: 0,
goal,
goalSource,
autoStarted: opts.autoStarted || undefined,
constraints: [],
plan: [],
findings: [],
decisions: [],
questions: [],
counters: { F: 0, D: 0, Q: 0 },
chats: [chatId],
};
ensureDir(projectDir(name));
commit(ledger, [{ actor: opts.autoStarted ? "plugin" : "user", op: "project_created", text: goal, chat: chatId }]);
return ledger;
}
// Writes the ledger after a change: keeps the previous version, bumps the sequence number,
// logs each event and regenerates the readable rendering.
export function commit(ledger: Ledger, events: Array<Omit<LedgerEvent, "seq" | "ts">>): void {
const file = ledgerFile(ledger.name);
if (fs.existsSync(file)) {
ensureDir(versionsDir(ledger.name));
fs.copyFileSync(file, path.join(versionsDir(ledger.name), `${String(ledger.seq).padStart(6, "0")}.json`));
pruneVersions(ledger.name);
}
const ts = new Date().toISOString();
for (const e of events) {
ledger.seq++;
appendLine(eventsFile(ledger.name), JSON.stringify({ seq: ledger.seq, ts, ...e }));
}
ledger.updated = ts;
writeJson(file, ledger);
const md = renderMarkdown(ledger);
writeFileAtomic(markdownFile(ledger.name), md);
const meta = loadMeta(ledger.name);
meta.renderedHash = hash(md);
// A new change by the user or the model ends a run of undos.
if (events.some((e) => e.op !== "undo" && e.actor !== "plugin")) delete meta.undoneTo;
saveMeta(ledger.name, meta);
}
function pruneVersions(slug: string): void {
const files = fs.readdirSync(versionsDir(slug)).filter((f) => f.endsWith(".json")).sort();
for (const f of files.slice(0, Math.max(0, files.length - VERSIONS_KEPT))) {
fs.unlinkSync(path.join(versionsDir(slug), f));
}
}
// Earlier states of the ledger, newest first: the copies kept before each write.
export function listVersions(slug: string): Ledger[] {
try {
return fs
.readdirSync(versionsDir(slug))
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
.map((f) => readJson<Ledger>(path.join(versionsDir(slug), f)))
.filter((v): v is Ledger => !!v && v.format === 1);
} catch {
return [];
}
}
export function readEvents(slug: string, afterSeq: number): LedgerEvent[] {
try {
return fs
.readFileSync(eventsFile(slug), "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as LedgerEvent)
.filter((e) => e.seq > afterSeq);
} catch {
return [];
}
}
export function currentStep(ledger: Ledger): Step | undefined {
return ledger.plan.find((s) => s.status === "doing");
}
export function progress(ledger: Ledger): { done: number; total: number; blocked: number } {
return {
done: ledger.plan.filter((s) => s.status === "done").length,
total: ledger.plan.length,
blocked: ledger.plan.filter((s) => s.status === "blocked").length,
};
}
export function stepLabel(s: Step): string {
return `${s.id}. ${s.text} (${s.status})${s.note ? `: ${s.note}` : ""}`;
}
export function isEmpty(l: Ledger): boolean {
return !l.constraints.length && !l.plan.length && !l.findings.length && !l.decisions.length && !l.questions.length;
}
// The readable form. Each entry is one line, with any detail on indented "key: value" lines
// beneath it, so the file can be edited by hand and read back without guessing.
export function renderMarkdown(l: Ledger): string {
const one = (t: string) => t.replace(/\s*\n\s*/g, " ").trim();
const lines: string[] = [`# ${l.name}`, `<!-- throughLine version ${l.seq} -->`, ""];
lines.push("## Goal", "", one(l.goal) || "(not set)", "");
lines.push("## Constraints", "");
lines.push(...(l.constraints.length ? l.constraints.map((c) => `- ${one(c)}`) : ["(none)"]), "");
lines.push("## Plan", "");
if (!l.plan.length) lines.push("(no steps yet)");
for (const s of l.plan) {
lines.push(`${s.id}. [${s.status}] ${one(s.text)}`);
if (s.note) lines.push(` note: ${one(s.note)}`);
}
lines.push("", "## Findings", "");
if (!l.findings.length) lines.push("(none)");
for (const f of l.findings) {
lines.push(`- ${f.id}: ${one(f.text)}`, ` source: ${one(f.source)}`, ` status: ${f.verification.replace("_", " ")}`);
}
lines.push("", "## Decisions", "");
if (!l.decisions.length) lines.push("(none)");
for (const d of l.decisions) {
lines.push(`- ${d.id}: ${one(d.text)}`);
if (d.reason) lines.push(` reason: ${one(d.reason)}`);
}
lines.push("", "## Open questions", "");
const open = l.questions.filter((q) => q.status === "open");
if (!open.length) lines.push("(none)");
for (const q of open) lines.push(`- ${q.id}: ${one(q.text)}`);
const resolved = l.questions.filter((q) => q.status === "resolved");
if (resolved.length) {
lines.push("", "## Resolved questions", "");
for (const q of resolved) lines.push(`- ${q.id}: ${one(q.text)}`, ` answer: ${one(q.answer ?? "")}`);
}
lines.push("");
return lines.join("\n");
}
export class ParseError extends Error {}
export interface ParsedLedger {
version?: number;
goal: string;
constraints: string[];
plan: Step[];
findings: Array<{ id?: string; text: string; source: string }>;
decisions: Array<{ id?: string; text: string; reason: string }>;
open: Array<{ id?: string; text: string }>;
resolved: Array<{ id?: string; text: string; answer: string }>;
}
// Reads a hand-edited ledger.md back. Entries may be added, removed, reordered or reworded;
// an entry without an id gets a new one. Throws ParseError with a reason when a section
// cannot be understood, so the last good ledger stays in force.
export function parseMarkdown(md: string): ParsedLedger {
const sections = new Map<string, string[]>();
let current: string | null = null;
// Editors with Markdown support may escape characters when saving ("\[done]", "\- item").
const unescaped = md.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").replace(/\\([\\`*_{}\[\]()#+\-.!<>|~])/g, "$1");
const marker = /<!--\s*throughLine version (\d+)\s*-->/.exec(unescaped);
for (const raw of unescaped.split("\n")) {
const h = /^##\s+(.+?)\s*$/.exec(raw);
if (h) {
current = h[1].toLowerCase();
sections.set(current, []);
continue;
}
if (current) sections.get(current)!.push(raw);
}
for (const required of ["goal", "plan", "findings"]) {
if (!sections.has(required)) throw new ParseError(`the "${required}" section is missing`);
}
const body = (name: string) => (sections.get(name) ?? []).filter((l) => l.trim() && !/^\((none|not set|no steps yet)\)$/i.test(l.trim()));
const goal = body("goal").map((l) => l.trim()).join(" ");
const constraints = body("constraints").map((l) => l.replace(/^\s*[-*]\s*/, "").trim()).filter(Boolean);
const plan: Step[] = [];
for (const line of body("plan")) {
const step = /^\s*(\d+)[.)]\s*\[(todo|doing|done|blocked)\]\s*(.+)$/i.exec(line);
const note = /^\s*note:\s*(.*)$/i.exec(line);
if (step) plan.push({ id: Number(step[1]), status: step[2].toLowerCase() as StepStatus, text: step[3].trim(), note: "" });
else if (note && plan.length) plan[plan.length - 1].note = note[1].trim();
else throw new ParseError(`a plan line could not be read: "${line.trim().slice(0, 80)}"`);
}
const ids = plan.map((s) => s.id);
if (new Set(ids).size !== ids.length) throw new ParseError("two plan steps have the same number");
type Entry = { id?: string; text: string; sub: Record<string, string> };
const entries = (name: string, prefix: string): Entry[] => {
const out: Entry[] = [];
for (const line of body(name)) {
const item = new RegExp(`^\\s*[-*]\\s*(?:(${prefix}\\d+)\\s*:\\s*)?(.+)$`, "i").exec(line);
// Detail lines are normally indented, but an editor may strip the indentation.
const sub = /^\s*(source|status|reason|answer|note):\s*(.*)$/i.exec(line);
if (sub && out.length && !/^\s*[-*]/.test(line)) out[out.length - 1].sub[sub[1].toLowerCase()] = sub[2].trim();
else if (item) out.push({ id: item[1]?.toUpperCase(), text: item[2].trim(), sub: {} });
else throw new ParseError(`a line in "${name}" could not be read: "${line.trim().slice(0, 80)}"`);
}
return out;
};
return {
version: marker ? Number(marker[1]) : undefined,
goal,
constraints,
plan,
findings: entries("findings", "F").map((e) => ({ id: e.id, text: e.text, source: e.sub.source ?? "none" })),
decisions: entries("decisions", "D").map((e) => ({ id: e.id, text: e.text, reason: e.sub.reason ?? "" })),
open: entries("open questions", "Q").map((e) => ({ id: e.id, text: e.text })),
resolved: entries("resolved questions", "Q").map((e) => ({ id: e.id, text: e.text, answer: e.sub.answer ?? "" })),
};
}
// The ledger: the state of one project, stored in its own folder.
//
// ledger.json current state, written atomically
// ledger.md readable rendering, regenerated on every write, and editable by hand
// meta.json what throughLine last wrote to ledger.md, and the project's turn count
// events.jsonl append-only log of every change
// versions/ copies of ledger.json from before each write, newest 50 kept
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { appendLine, ensureDir, projectDir, projectsDir, readJson, writeFileAtomic, writeJson } from "./store";
import { slugify } from "./text";
export type StepStatus = "todo" | "doing" | "done" | "blocked";
export type Verification = "verified" | "unverified" | "not_read";
export type Actor = "user" | "model" | "plugin";
export interface Step {
id: number;
text: string;
status: StepStatus;
note: string;
}
export interface Finding {
id: string;
text: string;
source: string;
verification: Verification;
step?: number;
chat?: string;
at: string;
}
export interface Decision {
id: string;
text: string;
reason: string;
by: Actor;
step?: number;
at: string;
}
export interface Question {
id: string;
text: string;
status: "open" | "resolved";
answer?: string;
turn?: number;
at: string;
resolvedAt?: string;
}
export interface Ledger {
format: 1;
name: string;
created: string;
updated: string;
seq: number;
goal: string;
goalSource: "user_message" | "command" | "none";
autoStarted?: boolean;
constraints: string[];
plan: Step[];
findings: Finding[];
decisions: Decision[];
questions: Question[];
counters: { F: number; D: number; Q: number };
chats: string[];
}
export interface LedgerEvent {
seq: number;
ts: string;
actor: Actor;
op: string;
id?: string;
text?: string;
chat?: string;
}
export interface Meta {
renderedHash?: string;
turns: number;
orphanedSince?: string;
undoneTo?: number;
}
export const VERSIONS_KEPT = 50;
export const STEP_STATUSES: StepStatus[] = ["todo", "doing", "done", "blocked"];
const ledgerFile = (slug: string) => path.join(projectDir(slug), "ledger.json");
const eventsFile = (slug: string) => path.join(projectDir(slug), "events.jsonl");
const versionsDir = (slug: string) => path.join(projectDir(slug), "versions");
const metaFile = (slug: string) => path.join(projectDir(slug), "meta.json");
export const markdownFile = (slug: string) => path.join(projectDir(slug), "ledger.md");
export const hash = (text: string) => crypto.createHash("sha256").update(text).digest("hex");
export function loadLedger(slug: string): Ledger | null {
const ledger = readJson<Ledger>(ledgerFile(slug));
return ledger && ledger.format === 1 ? ledger : null;
}
export function loadMeta(slug: string): Meta {
return readJson<Meta>(metaFile(slug)) ?? { turns: 0 };
}
export function saveMeta(slug: string, meta: Meta): void {
writeJson(metaFile(slug), meta);
}
export function listProjects(): string[] {
try {
return fs
.readdirSync(projectsDir(), { withFileTypes: true })
.filter((d) => d.isDirectory() && fs.existsSync(ledgerFile(d.name)))
.map((d) => d.name)
.sort();
} catch {
return [];
}
}
export function uniqueSlug(base: string): string {
const root = slugify(base);
let slug = root;
for (let n = 2; fs.existsSync(projectDir(slug)); n++) slug = `${root}-${n}`;
return slug;
}
export function createLedger(goal: string, goalSource: Ledger["goalSource"], chatId: string, opts: { autoStarted?: boolean } = {}): Ledger {
const now = new Date().toISOString();
const name = uniqueSlug(goal || "project");
const ledger: Ledger = {
format: 1,
name,
created: now,
updated: now,
seq: 0,
goal,
goalSource,
autoStarted: opts.autoStarted || undefined,
constraints: [],
plan: [],
findings: [],
decisions: [],
questions: [],
counters: { F: 0, D: 0, Q: 0 },
chats: [chatId],
};
ensureDir(projectDir(name));
commit(ledger, [{ actor: opts.autoStarted ? "plugin" : "user", op: "project_created", text: goal, chat: chatId }]);
return ledger;
}
// Writes the ledger after a change: keeps the previous version, bumps the sequence number,
// logs each event and regenerates the readable rendering.
export function commit(ledger: Ledger, events: Array<Omit<LedgerEvent, "seq" | "ts">>): void {
const file = ledgerFile(ledger.name);
if (fs.existsSync(file)) {
ensureDir(versionsDir(ledger.name));
fs.copyFileSync(file, path.join(versionsDir(ledger.name), `${String(ledger.seq).padStart(6, "0")}.json`));
pruneVersions(ledger.name);
}
const ts = new Date().toISOString();
for (const e of events) {
ledger.seq++;
appendLine(eventsFile(ledger.name), JSON.stringify({ seq: ledger.seq, ts, ...e }));
}
ledger.updated = ts;
writeJson(file, ledger);
const md = renderMarkdown(ledger);
writeFileAtomic(markdownFile(ledger.name), md);
const meta = loadMeta(ledger.name);
meta.renderedHash = hash(md);
// A new change by the user or the model ends a run of undos.
if (events.some((e) => e.op !== "undo" && e.actor !== "plugin")) delete meta.undoneTo;
saveMeta(ledger.name, meta);
}
function pruneVersions(slug: string): void {
const files = fs.readdirSync(versionsDir(slug)).filter((f) => f.endsWith(".json")).sort();
for (const f of files.slice(0, Math.max(0, files.length - VERSIONS_KEPT))) {
fs.unlinkSync(path.join(versionsDir(slug), f));
}
}
// Earlier states of the ledger, newest first: the copies kept before each write.
export function listVersions(slug: string): Ledger[] {
try {
return fs
.readdirSync(versionsDir(slug))
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
.map((f) => readJson<Ledger>(path.join(versionsDir(slug), f)))
.filter((v): v is Ledger => !!v && v.format === 1);
} catch {
return [];
}
}
export function readEvents(slug: string, afterSeq: number): LedgerEvent[] {
try {
return fs
.readFileSync(eventsFile(slug), "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as LedgerEvent)
.filter((e) => e.seq > afterSeq);
} catch {
return [];
}
}
export function currentStep(ledger: Ledger): Step | undefined {
return ledger.plan.find((s) => s.status === "doing");
}
export function progress(ledger: Ledger): { done: number; total: number; blocked: number } {
return {
done: ledger.plan.filter((s) => s.status === "done").length,
total: ledger.plan.length,
blocked: ledger.plan.filter((s) => s.status === "blocked").length,
};
}
export function stepLabel(s: Step): string {
return `${s.id}. ${s.text} (${s.status})${s.note ? `: ${s.note}` : ""}`;
}
export function isEmpty(l: Ledger): boolean {
return !l.constraints.length && !l.plan.length && !l.findings.length && !l.decisions.length && !l.questions.length;
}
// The readable form. Each entry is one line, with any detail on indented "key: value" lines
// beneath it, so the file can be edited by hand and read back without guessing.
export function renderMarkdown(l: Ledger): string {
const one = (t: string) => t.replace(/\s*\n\s*/g, " ").trim();
const lines: string[] = [`# ${l.name}`, `<!-- throughLine version ${l.seq} -->`, ""];
lines.push("## Goal", "", one(l.goal) || "(not set)", "");
lines.push("## Constraints", "");
lines.push(...(l.constraints.length ? l.constraints.map((c) => `- ${one(c)}`) : ["(none)"]), "");
lines.push("## Plan", "");
if (!l.plan.length) lines.push("(no steps yet)");
for (const s of l.plan) {
lines.push(`${s.id}. [${s.status}] ${one(s.text)}`);
if (s.note) lines.push(` note: ${one(s.note)}`);
}
lines.push("", "## Findings", "");
if (!l.findings.length) lines.push("(none)");
for (const f of l.findings) {
lines.push(`- ${f.id}: ${one(f.text)}`, ` source: ${one(f.source)}`, ` status: ${f.verification.replace("_", " ")}`);
}
lines.push("", "## Decisions", "");
if (!l.decisions.length) lines.push("(none)");
for (const d of l.decisions) {
lines.push(`- ${d.id}: ${one(d.text)}`);
if (d.reason) lines.push(` reason: ${one(d.reason)}`);
}
lines.push("", "## Open questions", "");
const open = l.questions.filter((q) => q.status === "open");
if (!open.length) lines.push("(none)");
for (const q of open) lines.push(`- ${q.id}: ${one(q.text)}`);
const resolved = l.questions.filter((q) => q.status === "resolved");
if (resolved.length) {
lines.push("", "## Resolved questions", "");
for (const q of resolved) lines.push(`- ${q.id}: ${one(q.text)}`, ` answer: ${one(q.answer ?? "")}`);
}
lines.push("");
return lines.join("\n");
}
export class ParseError extends Error {}
export interface ParsedLedger {
version?: number;
goal: string;
constraints: string[];
plan: Step[];
findings: Array<{ id?: string; text: string; source: string }>;
decisions: Array<{ id?: string; text: string; reason: string }>;
open: Array<{ id?: string; text: string }>;
resolved: Array<{ id?: string; text: string; answer: string }>;
}
// Reads a hand-edited ledger.md back. Entries may be added, removed, reordered or reworded;
// an entry without an id gets a new one. Throws ParseError with a reason when a section
// cannot be understood, so the last good ledger stays in force.
export function parseMarkdown(md: string): ParsedLedger {
const sections = new Map<string, string[]>();
let current: string | null = null;
// Editors with Markdown support may escape characters when saving ("\[done]", "\- item").
const unescaped = md.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").replace(/\\([\\`*_{}\[\]()#+\-.!<>|~])/g, "$1");
const marker = /<!--\s*throughLine version (\d+)\s*-->/.exec(unescaped);
for (const raw of unescaped.split("\n")) {
const h = /^##\s+(.+?)\s*$/.exec(raw);
if (h) {
current = h[1].toLowerCase();
sections.set(current, []);
continue;
}
if (current) sections.get(current)!.push(raw);
}
for (const required of ["goal", "plan", "findings"]) {
if (!sections.has(required)) throw new ParseError(`the "${required}" section is missing`);
}
const body = (name: string) => (sections.get(name) ?? []).filter((l) => l.trim() && !/^\((none|not set|no steps yet)\)$/i.test(l.trim()));
const goal = body("goal").map((l) => l.trim()).join(" ");
const constraints = body("constraints").map((l) => l.replace(/^\s*[-*]\s*/, "").trim()).filter(Boolean);
const plan: Step[] = [];
for (const line of body("plan")) {
const step = /^\s*(\d+)[.)]\s*\[(todo|doing|done|blocked)\]\s*(.+)$/i.exec(line);
const note = /^\s*note:\s*(.*)$/i.exec(line);
if (step) plan.push({ id: Number(step[1]), status: step[2].toLowerCase() as StepStatus, text: step[3].trim(), note: "" });
else if (note && plan.length) plan[plan.length - 1].note = note[1].trim();
else throw new ParseError(`a plan line could not be read: "${line.trim().slice(0, 80)}"`);
}
const ids = plan.map((s) => s.id);
if (new Set(ids).size !== ids.length) throw new ParseError("two plan steps have the same number");
type Entry = { id?: string; text: string; sub: Record<string, string> };
const entries = (name: string, prefix: string): Entry[] => {
const out: Entry[] = [];
for (const line of body(name)) {
const item = new RegExp(`^\\s*[-*]\\s*(?:(${prefix}\\d+)\\s*:\\s*)?(.+)$`, "i").exec(line);
// Detail lines are normally indented, but an editor may strip the indentation.
const sub = /^\s*(source|status|reason|answer|note):\s*(.*)$/i.exec(line);
if (sub && out.length && !/^\s*[-*]/.test(line)) out[out.length - 1].sub[sub[1].toLowerCase()] = sub[2].trim();
else if (item) out.push({ id: item[1]?.toUpperCase(), text: item[2].trim(), sub: {} });
else throw new ParseError(`a line in "${name}" could not be read: "${line.trim().slice(0, 80)}"`);
}
return out;
};
return {
version: marker ? Number(marker[1]) : undefined,
goal,
constraints,
plan,
findings: entries("findings", "F").map((e) => ({ id: e.id, text: e.text, source: e.sub.source ?? "none" })),
decisions: entries("decisions", "D").map((e) => ({ id: e.id, text: e.text, reason: e.sub.reason ?? "" })),
open: entries("open questions", "Q").map((e) => ({ id: e.id, text: e.text })),
resolved: entries("resolved questions", "Q").map((e) => ({ id: e.id, text: e.text, answer: e.sub.answer ?? "" })),
};
}