src / checks.ts
src / checks.ts
// The mechanical checks M2 adds: source verification against the chat's history, hand edits
// of ledger.md, the health line, and staleness reminders.
import * as fs from "fs";
import * as path from "path";
import { readUrls, Turn, urlInSource, workCalls } from "./history";
import {
commit, Finding, hash, Ledger, LedgerEvent, listVersions, loadMeta, markdownFile, Meta, ParsedLedger, ParseError, parseMarkdown, readEvents, renderMarkdown, saveMeta, Verification,
} from "./ledger";
import { initialVerification } from "./ops";
import { projectDir, writeFileAtomic } from "./store";
// ---------- Source verification ----------
// Checks this chat's findings against the pages read in this chat. A finding recorded in
// another chat is left alone, since its reads are in that chat's history.
export function verifySources(l: Ledger, chatId: string, turns: Turn[]): string[] {
const read = readUrls(turns);
const userTurns = turns.length;
let origin: Map<string, string> | null = null;
const chatOf = (f: Finding): string | undefined => {
if (f.chat) return f.chat;
origin ??= new Map(readEvents(l.name, 0).filter((e) => e.op === "finding_added" && e.id).map((e) => [e.id!, e.chat ?? ""]));
return origin.get(f.id);
};
const changed: string[] = [];
for (const f of l.findings) {
if (f.verification === "verified" || chatOf(f) !== chatId) continue;
let next: Verification | null = null;
const url = urlInSource(f.source);
const turn = /^turn\s+(\d+)$/i.exec(f.source.trim());
if (url) next = read.has(url) ? "verified" : "not_read";
else if (turn) next = Number(turn[1]) >= 1 && Number(turn[1]) <= userTurns + 1 ? "verified" : "not_read";
if (next && next !== f.verification) {
f.verification = next;
changed.push(`${f.id} ${next.replace("_", " ")}`);
}
}
if (changed.length) commit(l, [{ actor: "plugin", op: "sources_checked", text: changed.join(", "), chat: chatId }]);
return changed;
}
// ---------- Hand edits ----------
export type HandEdit = { kind: "none" } | { kind: "applied"; summary: string } | { kind: "failed"; reason: string; savedAs: string };
export function applyHandEdit(l: Ledger): HandEdit {
const file = markdownFile(l.name);
let md: string;
try {
md = fs.readFileSync(file, "utf8");
} catch {
return { kind: "none" };
}
const meta: Meta = loadMeta(l.name);
if (!meta.renderedHash) {
// Written by an earlier version, or never recorded: refresh it and start tracking.
const fresh = renderMarkdown(l);
writeFileAtomic(file, fresh);
meta.renderedHash = hash(fresh);
saveMeta(l.name, meta);
return { kind: "none" };
}
if (hash(md) === meta.renderedHash) return { kind: "none" };
let parsed;
try {
parsed = parseMarkdown(md);
} catch (e) {
const savedAs = `ledger.edit-not-applied-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
fs.copyFileSync(file, path.join(projectDir(l.name), savedAs));
const fresh = renderMarkdown(l);
writeFileAtomic(file, fresh);
meta.renderedHash = hash(fresh);
saveMeta(l.name, meta);
return { kind: "failed", reason: e instanceof ParseError ? e.message : "the file could not be read", savedAs };
}
const base = findBase(l, parsed);
const summary = mergeEdit(l, base, parsed);
if (!summary) {
// Only whitespace or formatting changed. Keep the file as the user left it.
meta.renderedHash = hash(md);
saveMeta(l.name, meta);
return { kind: "none" };
}
commit(l, [{ actor: "user", op: "hand_edit", text: summary }]);
return { kind: "applied", summary };
}
const norm = (t: string | undefined) => (t ?? "").replace(/\s+/g, " ").trim();
// The saved state the user's copy started from. ledger.md carries the version it was written
// from; without that marker, the version sharing the most entries with the edit is used.
// The user may have opened the file before the model's latest changes, so the current ledger
// is not assumed to be the starting point.
function findBase(current: Ledger, parsed: ParsedLedger): Ledger {
// ledger.md records the version it was written from; that is the starting point when present.
if (parsed.version !== undefined) {
if (parsed.version === current.seq) return current;
const exact = listVersions(current.name).find((v) => v.seq === parsed.version);
if (exact) return exact;
}
const keys = (l: Ledger) => new Set([
...l.plan.map((s) => `S${s.id}|${norm(s.text)}`),
...l.findings.map((f) => `${f.id}|${norm(f.text)}`),
...l.decisions.map((d) => `${d.id}|${norm(d.text)}`),
...l.questions.map((q) => `${q.id}|${norm(q.text)}`),
]);
const edited = [
...parsed.plan.map((s) => `S${s.id}|${norm(s.text)}`),
...parsed.findings.map((f) => `${f.id}|${norm(f.text)}`),
...parsed.decisions.map((d) => `${d.id}|${norm(d.text)}`),
...parsed.open.map((q) => `${q.id}|${norm(q.text)}`),
...parsed.resolved.map((q) => `${q.id}|${norm(q.text)}`),
];
let best = current;
let bestScore = -1;
for (const candidate of [current, ...listVersions(current.name)]) {
const k = keys(candidate);
// Entries the edit shares with this version, minus entries this version has that the
// edit lacks: an older version missing the model's later work scores higher when the
// edit also lacks that work.
const shared = edited.filter((e) => k.has(e)).length;
const extra = [...k].filter((e) => !edited.includes(e)).length;
const score = shared * 2 - extra + (norm(candidate.goal) === norm(parsed.goal) ? 1 : 0);
if (score > bestScore) { best = candidate; bestScore = score; }
}
return best;
}
// Three-way merge by id: what the user changed relative to the starting version is applied to
// the current ledger; entries the model added since then are kept. Returns a summary of what
// changed, or "" when nothing did.
function mergeEdit(l: Ledger, base: Ledger, parsed: ParsedLedger): string {
const changed = new Set<string>();
const now = new Date().toISOString();
if (norm(parsed.goal) !== norm(base.goal) && norm(parsed.goal) !== norm(l.goal)) { l.goal = parsed.goal; l.goalSource = "command"; changed.add("goal"); }
if (JSON.stringify(parsed.constraints.map(norm)) !== JSON.stringify(base.constraints.map(norm)) && JSON.stringify(parsed.constraints.map(norm)) !== JSON.stringify(l.constraints.map(norm))) {
l.constraints = parsed.constraints;
changed.add("constraints");
}
function merge<C extends { id: string | number }, U extends { id?: string | number }>(
section: string, cur: C[], baseItems: C[], user: U[],
sameAsBase: (u: U, b: C) => boolean, apply: (c: C, u: U) => C, create: (u: U) => C,
): C[] {
const curById = new Map(cur.map((c) => [String(c.id), c]));
const baseById = new Map(baseItems.map((b) => [String(b.id), b]));
const seen = new Set<string>();
const out: C[] = [];
for (const u of user) {
const id = u.id === undefined ? undefined : String(u.id);
const c = id ? curById.get(id) : undefined;
const b = id ? baseById.get(id) : undefined;
if (c && !seen.has(id!)) {
seen.add(id!);
if (b && sameAsBase(u, b)) out.push(c);
else if (!b && !sameAsBase(u, c)) { out.push(c); out.push(create(u)); changed.add(section); }
else { const next = apply(c, u); if (JSON.stringify(next) !== JSON.stringify(c)) changed.add(section); out.push(next); }
} else {
out.push(create(u));
changed.add(section);
}
}
for (const c of cur) {
if (seen.has(String(c.id))) continue;
if (baseById.has(String(c.id))) changed.add(section); // removed by the user
else out.push(c); // added after the user's copy was taken
}
return out;
}
let nextStep = Math.max(0, ...l.plan.map((s) => s.id), ...parsed.plan.map((s) => s.id)) + 1;
l.plan = merge("plan", l.plan, base.plan, parsed.plan,
(u, b) => norm(u.text) === norm(b.text) && u.status === b.status && norm(u.note) === norm(b.note),
(c, u) => ({ ...c, text: u.text, status: u.status, note: u.note }),
(u) => {
// A step the user numbered keeps its number unless the model has used it since.
const free = !l.plan.some((s) => s.id === u.id);
return { id: free ? u.id : nextStep++, text: u.text, status: u.status, note: u.note };
});
l.findings = merge("findings", l.findings, base.findings, parsed.findings,
(u, b) => norm(u.text) === norm(b.text) && norm(u.source) === norm(b.source),
(c, u) => ({ ...c, text: u.text, source: u.source, verification: norm(c.source) === norm(u.source) ? c.verification : initialVerification(u.source) }),
(u) => ({ id: `F${++l.counters.F}`, text: u.text, source: u.source, verification: initialVerification(u.source), at: now } as Finding));
l.decisions = merge("decisions", l.decisions, base.decisions, parsed.decisions,
(u, b) => norm(u.text) === norm(b.text) && norm(u.reason) === norm(b.reason),
(c, u) => ({ ...c, text: u.text, reason: u.reason }),
(u) => ({ id: `D${++l.counters.D}`, text: u.text, reason: u.reason, by: "user" as const, at: now }));
const userQuestions = [
...parsed.open.map((q) => ({ ...q, status: "open" as const, answer: "" })),
...parsed.resolved.map((q) => ({ ...q, status: "resolved" as const })),
];
l.questions = merge("questions", l.questions, base.questions, userQuestions,
(u, b) => norm(u.text) === norm(b.text) && u.status === b.status && norm(u.answer) === norm(b.answer),
(c, u) => ({ ...c, text: u.text, status: u.status, answer: u.status === "resolved" ? u.answer : undefined, resolvedAt: u.status === "resolved" ? c.resolvedAt ?? now : undefined }),
(u) => ({ id: `Q${++l.counters.Q}`, text: u.text, status: u.status, answer: u.status === "resolved" ? u.answer : undefined, at: now, resolvedAt: u.status === "resolved" ? now : undefined }));
return [...changed].join(", ");
}
// Merges an edit the user saved since throughLine last wrote ledger.md, before anything else
// writes the ledger and rewrites the file. Used by commands and tools, which run between the
// messages where the preprocessor normally picks edits up.
export function absorbPendingEdit(l: Ledger): HandEdit {
try {
return applyHandEdit(l);
} catch {
return { kind: "none" };
}
}
// ---------- Health ----------
const QUESTION_STALE_TURNS = 10;
export function healthIssues(l: Ledger, meta: Meta, sinceLast: LedgerEvent[]): string[] {
const issues: string[] = [];
if (!l.goal.trim()) issues.push("no goal set (ask the user to set one with /tl goal)");
const notRead = l.findings.filter((f) => f.verification === "not_read").map((f) => f.id);
if (notRead.length) issues.push(`${notRead.join(", ")} cite${notRead.length === 1 ? "s" : ""} a page or turn not found in this chat (read it, or correct the source)`);
const noSource = l.findings.filter((f) => f.source === "none").map((f) => f.id);
if (noSource.length) issues.push(`${noSource.join(", ")} ha${noSource.length === 1 ? "s" : "ve"} no source`);
const doing = l.plan.filter((s) => s.status === "doing");
if (doing.length > 1) issues.push(`steps ${doing.map((s) => s.id).join(" and ")} are all marked doing (keep one)`);
if (!doing.length && l.plan.some((s) => s.status === "todo")) issues.push("no step is marked doing");
const staleQ = l.questions.filter((q) => q.status === "open" && q.turn !== undefined && meta.turns - q.turn >= QUESTION_STALE_TURNS).map((q) => q.id);
if (staleQ.length) issues.push(`${staleQ.join(", ")} open for ${QUESTION_STALE_TURNS} or more turns`);
// Reported once: steps finished since the last message with nothing recorded during them.
const finished = new Set(sinceLast.filter((e) => e.op === "step_updated" && /now done/.test(e.text ?? "")).map((e) => Number(e.id)));
for (const id of finished) {
const recorded = l.findings.some((f) => f.step === id) || l.decisions.some((d) => d.step === id);
if (!recorded) issues.push(`step ${id} was marked done with nothing recorded during it (record what it established)`);
}
return issues;
}
// ---------- Staleness ----------
export const REMINDER_EVERY_TURNS = 3;
export interface Staleness {
staleTurns: number;
remind: boolean;
}
export function staleness(prev: { staleTurns?: number; lastReminderTurn?: number }, turn: number, lastTurn: Turn | undefined, modelChanged: boolean, staleAfter: number): Staleness {
let staleTurns = prev.staleTurns ?? 0;
if (modelChanged) staleTurns = 0;
else if (lastTurn && workCalls(lastTurn) > 0) staleTurns++;
const due = prev.lastReminderTurn === undefined || turn - prev.lastReminderTurn >= REMINDER_EVERY_TURNS;
return { staleTurns, remind: staleTurns >= staleAfter && due };
}
export function reminderLine(staleTurns: number): string {
return `Reminder: the last ${staleTurns} turns used tools but the ledger was not updated. Before continuing, record what was established with ledger_record and set step status with ledger_plan.`;
}
// The mechanical checks M2 adds: source verification against the chat's history, hand edits
// of ledger.md, the health line, and staleness reminders.
import * as fs from "fs";
import * as path from "path";
import { readUrls, Turn, urlInSource, workCalls } from "./history";
import {
commit, Finding, hash, Ledger, LedgerEvent, listVersions, loadMeta, markdownFile, Meta, ParsedLedger, ParseError, parseMarkdown, readEvents, renderMarkdown, saveMeta, Verification,
} from "./ledger";
import { initialVerification } from "./ops";
import { projectDir, writeFileAtomic } from "./store";
// ---------- Source verification ----------
// Checks this chat's findings against the pages read in this chat. A finding recorded in
// another chat is left alone, since its reads are in that chat's history.
export function verifySources(l: Ledger, chatId: string, turns: Turn[]): string[] {
const read = readUrls(turns);
const userTurns = turns.length;
let origin: Map<string, string> | null = null;
const chatOf = (f: Finding): string | undefined => {
if (f.chat) return f.chat;
origin ??= new Map(readEvents(l.name, 0).filter((e) => e.op === "finding_added" && e.id).map((e) => [e.id!, e.chat ?? ""]));
return origin.get(f.id);
};
const changed: string[] = [];
for (const f of l.findings) {
if (f.verification === "verified" || chatOf(f) !== chatId) continue;
let next: Verification | null = null;
const url = urlInSource(f.source);
const turn = /^turn\s+(\d+)$/i.exec(f.source.trim());
if (url) next = read.has(url) ? "verified" : "not_read";
else if (turn) next = Number(turn[1]) >= 1 && Number(turn[1]) <= userTurns + 1 ? "verified" : "not_read";
if (next && next !== f.verification) {
f.verification = next;
changed.push(`${f.id} ${next.replace("_", " ")}`);
}
}
if (changed.length) commit(l, [{ actor: "plugin", op: "sources_checked", text: changed.join(", "), chat: chatId }]);
return changed;
}
// ---------- Hand edits ----------
export type HandEdit = { kind: "none" } | { kind: "applied"; summary: string } | { kind: "failed"; reason: string; savedAs: string };
export function applyHandEdit(l: Ledger): HandEdit {
const file = markdownFile(l.name);
let md: string;
try {
md = fs.readFileSync(file, "utf8");
} catch {
return { kind: "none" };
}
const meta: Meta = loadMeta(l.name);
if (!meta.renderedHash) {
// Written by an earlier version, or never recorded: refresh it and start tracking.
const fresh = renderMarkdown(l);
writeFileAtomic(file, fresh);
meta.renderedHash = hash(fresh);
saveMeta(l.name, meta);
return { kind: "none" };
}
if (hash(md) === meta.renderedHash) return { kind: "none" };
let parsed;
try {
parsed = parseMarkdown(md);
} catch (e) {
const savedAs = `ledger.edit-not-applied-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
fs.copyFileSync(file, path.join(projectDir(l.name), savedAs));
const fresh = renderMarkdown(l);
writeFileAtomic(file, fresh);
meta.renderedHash = hash(fresh);
saveMeta(l.name, meta);
return { kind: "failed", reason: e instanceof ParseError ? e.message : "the file could not be read", savedAs };
}
const base = findBase(l, parsed);
const summary = mergeEdit(l, base, parsed);
if (!summary) {
// Only whitespace or formatting changed. Keep the file as the user left it.
meta.renderedHash = hash(md);
saveMeta(l.name, meta);
return { kind: "none" };
}
commit(l, [{ actor: "user", op: "hand_edit", text: summary }]);
return { kind: "applied", summary };
}
const norm = (t: string | undefined) => (t ?? "").replace(/\s+/g, " ").trim();
// The saved state the user's copy started from. ledger.md carries the version it was written
// from; without that marker, the version sharing the most entries with the edit is used.
// The user may have opened the file before the model's latest changes, so the current ledger
// is not assumed to be the starting point.
function findBase(current: Ledger, parsed: ParsedLedger): Ledger {
// ledger.md records the version it was written from; that is the starting point when present.
if (parsed.version !== undefined) {
if (parsed.version === current.seq) return current;
const exact = listVersions(current.name).find((v) => v.seq === parsed.version);
if (exact) return exact;
}
const keys = (l: Ledger) => new Set([
...l.plan.map((s) => `S${s.id}|${norm(s.text)}`),
...l.findings.map((f) => `${f.id}|${norm(f.text)}`),
...l.decisions.map((d) => `${d.id}|${norm(d.text)}`),
...l.questions.map((q) => `${q.id}|${norm(q.text)}`),
]);
const edited = [
...parsed.plan.map((s) => `S${s.id}|${norm(s.text)}`),
...parsed.findings.map((f) => `${f.id}|${norm(f.text)}`),
...parsed.decisions.map((d) => `${d.id}|${norm(d.text)}`),
...parsed.open.map((q) => `${q.id}|${norm(q.text)}`),
...parsed.resolved.map((q) => `${q.id}|${norm(q.text)}`),
];
let best = current;
let bestScore = -1;
for (const candidate of [current, ...listVersions(current.name)]) {
const k = keys(candidate);
// Entries the edit shares with this version, minus entries this version has that the
// edit lacks: an older version missing the model's later work scores higher when the
// edit also lacks that work.
const shared = edited.filter((e) => k.has(e)).length;
const extra = [...k].filter((e) => !edited.includes(e)).length;
const score = shared * 2 - extra + (norm(candidate.goal) === norm(parsed.goal) ? 1 : 0);
if (score > bestScore) { best = candidate; bestScore = score; }
}
return best;
}
// Three-way merge by id: what the user changed relative to the starting version is applied to
// the current ledger; entries the model added since then are kept. Returns a summary of what
// changed, or "" when nothing did.
function mergeEdit(l: Ledger, base: Ledger, parsed: ParsedLedger): string {
const changed = new Set<string>();
const now = new Date().toISOString();
if (norm(parsed.goal) !== norm(base.goal) && norm(parsed.goal) !== norm(l.goal)) { l.goal = parsed.goal; l.goalSource = "command"; changed.add("goal"); }
if (JSON.stringify(parsed.constraints.map(norm)) !== JSON.stringify(base.constraints.map(norm)) && JSON.stringify(parsed.constraints.map(norm)) !== JSON.stringify(l.constraints.map(norm))) {
l.constraints = parsed.constraints;
changed.add("constraints");
}
function merge<C extends { id: string | number }, U extends { id?: string | number }>(
section: string, cur: C[], baseItems: C[], user: U[],
sameAsBase: (u: U, b: C) => boolean, apply: (c: C, u: U) => C, create: (u: U) => C,
): C[] {
const curById = new Map(cur.map((c) => [String(c.id), c]));
const baseById = new Map(baseItems.map((b) => [String(b.id), b]));
const seen = new Set<string>();
const out: C[] = [];
for (const u of user) {
const id = u.id === undefined ? undefined : String(u.id);
const c = id ? curById.get(id) : undefined;
const b = id ? baseById.get(id) : undefined;
if (c && !seen.has(id!)) {
seen.add(id!);
if (b && sameAsBase(u, b)) out.push(c);
else if (!b && !sameAsBase(u, c)) { out.push(c); out.push(create(u)); changed.add(section); }
else { const next = apply(c, u); if (JSON.stringify(next) !== JSON.stringify(c)) changed.add(section); out.push(next); }
} else {
out.push(create(u));
changed.add(section);
}
}
for (const c of cur) {
if (seen.has(String(c.id))) continue;
if (baseById.has(String(c.id))) changed.add(section); // removed by the user
else out.push(c); // added after the user's copy was taken
}
return out;
}
let nextStep = Math.max(0, ...l.plan.map((s) => s.id), ...parsed.plan.map((s) => s.id)) + 1;
l.plan = merge("plan", l.plan, base.plan, parsed.plan,
(u, b) => norm(u.text) === norm(b.text) && u.status === b.status && norm(u.note) === norm(b.note),
(c, u) => ({ ...c, text: u.text, status: u.status, note: u.note }),
(u) => {
// A step the user numbered keeps its number unless the model has used it since.
const free = !l.plan.some((s) => s.id === u.id);
return { id: free ? u.id : nextStep++, text: u.text, status: u.status, note: u.note };
});
l.findings = merge("findings", l.findings, base.findings, parsed.findings,
(u, b) => norm(u.text) === norm(b.text) && norm(u.source) === norm(b.source),
(c, u) => ({ ...c, text: u.text, source: u.source, verification: norm(c.source) === norm(u.source) ? c.verification : initialVerification(u.source) }),
(u) => ({ id: `F${++l.counters.F}`, text: u.text, source: u.source, verification: initialVerification(u.source), at: now } as Finding));
l.decisions = merge("decisions", l.decisions, base.decisions, parsed.decisions,
(u, b) => norm(u.text) === norm(b.text) && norm(u.reason) === norm(b.reason),
(c, u) => ({ ...c, text: u.text, reason: u.reason }),
(u) => ({ id: `D${++l.counters.D}`, text: u.text, reason: u.reason, by: "user" as const, at: now }));
const userQuestions = [
...parsed.open.map((q) => ({ ...q, status: "open" as const, answer: "" })),
...parsed.resolved.map((q) => ({ ...q, status: "resolved" as const })),
];
l.questions = merge("questions", l.questions, base.questions, userQuestions,
(u, b) => norm(u.text) === norm(b.text) && u.status === b.status && norm(u.answer) === norm(b.answer),
(c, u) => ({ ...c, text: u.text, status: u.status, answer: u.status === "resolved" ? u.answer : undefined, resolvedAt: u.status === "resolved" ? c.resolvedAt ?? now : undefined }),
(u) => ({ id: `Q${++l.counters.Q}`, text: u.text, status: u.status, answer: u.status === "resolved" ? u.answer : undefined, at: now, resolvedAt: u.status === "resolved" ? now : undefined }));
return [...changed].join(", ");
}
// Merges an edit the user saved since throughLine last wrote ledger.md, before anything else
// writes the ledger and rewrites the file. Used by commands and tools, which run between the
// messages where the preprocessor normally picks edits up.
export function absorbPendingEdit(l: Ledger): HandEdit {
try {
return applyHandEdit(l);
} catch {
return { kind: "none" };
}
}
// ---------- Health ----------
const QUESTION_STALE_TURNS = 10;
export function healthIssues(l: Ledger, meta: Meta, sinceLast: LedgerEvent[]): string[] {
const issues: string[] = [];
if (!l.goal.trim()) issues.push("no goal set (ask the user to set one with /tl goal)");
const notRead = l.findings.filter((f) => f.verification === "not_read").map((f) => f.id);
if (notRead.length) issues.push(`${notRead.join(", ")} cite${notRead.length === 1 ? "s" : ""} a page or turn not found in this chat (read it, or correct the source)`);
const noSource = l.findings.filter((f) => f.source === "none").map((f) => f.id);
if (noSource.length) issues.push(`${noSource.join(", ")} ha${noSource.length === 1 ? "s" : "ve"} no source`);
const doing = l.plan.filter((s) => s.status === "doing");
if (doing.length > 1) issues.push(`steps ${doing.map((s) => s.id).join(" and ")} are all marked doing (keep one)`);
if (!doing.length && l.plan.some((s) => s.status === "todo")) issues.push("no step is marked doing");
const staleQ = l.questions.filter((q) => q.status === "open" && q.turn !== undefined && meta.turns - q.turn >= QUESTION_STALE_TURNS).map((q) => q.id);
if (staleQ.length) issues.push(`${staleQ.join(", ")} open for ${QUESTION_STALE_TURNS} or more turns`);
// Reported once: steps finished since the last message with nothing recorded during them.
const finished = new Set(sinceLast.filter((e) => e.op === "step_updated" && /now done/.test(e.text ?? "")).map((e) => Number(e.id)));
for (const id of finished) {
const recorded = l.findings.some((f) => f.step === id) || l.decisions.some((d) => d.step === id);
if (!recorded) issues.push(`step ${id} was marked done with nothing recorded during it (record what it established)`);
}
return issues;
}
// ---------- Staleness ----------
export const REMINDER_EVERY_TURNS = 3;
export interface Staleness {
staleTurns: number;
remind: boolean;
}
export function staleness(prev: { staleTurns?: number; lastReminderTurn?: number }, turn: number, lastTurn: Turn | undefined, modelChanged: boolean, staleAfter: number): Staleness {
let staleTurns = prev.staleTurns ?? 0;
if (modelChanged) staleTurns = 0;
else if (lastTurn && workCalls(lastTurn) > 0) staleTurns++;
const due = prev.lastReminderTurn === undefined || turn - prev.lastReminderTurn >= REMINDER_EVERY_TURNS;
return { staleTurns, remind: staleTurns >= staleAfter && due };
}
export function reminderLine(staleTurns: number): string {
return `Reminder: the last ${staleTurns} turns used tools but the ledger was not updated. Before continuing, record what was established with ledger_record and set step status with ledger_plan.`;
}