@hank-warren/pi-loop 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/decide.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * handler for a lost continuation or an external wait, not the pacemaker.
10
10
  *
11
11
  * Both share one precedence prefix: loop liveness → expiry → plan mode →
12
- * compaction → busy → wait → caps → act.
12
+ * compaction → busy → wait → the turn cap → act.
13
13
  *
14
14
  * A loop owns its own objective and reads no other extension's state: it ends
15
15
  * only through `loop_complete`, a cap, an expiry, or the user.
@@ -49,14 +49,14 @@ export type TickDecision =
49
49
  | { action: "none"; reason: "loop-not-active" }
50
50
  | { action: "expire"; reason: ExpiryReason }
51
51
  | { action: "skip"; reason: SkipReason }
52
- | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
52
+ | { action: "stop"; reason: "max-turns" }
53
53
  | { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
54
54
 
55
55
  export type ContinuationDecision =
56
56
  | { action: "none"; reason: "loop-not-active" }
57
57
  | { action: "expire"; reason: ExpiryReason }
58
58
  | { action: "skip"; reason: SkipReason }
59
- | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
59
+ | { action: "stop"; reason: "max-turns" }
60
60
  | { action: "continue"; reason: "settled-idle" };
61
61
 
62
62
  /** Shared prefix: everything that holds or ends a loop before caps matter. */
@@ -80,17 +80,14 @@ function decideCommonPrefix(
80
80
  }
81
81
 
82
82
  /**
83
- * Caps are checked in a fixed order so a loop that trips both reports the
84
- * wake cap first it is the one the user configured with `--max`.
83
+ * The one cap, counting every turn the loop caused: continuations and pokes
84
+ * alike. A delivered-wake cap sat next to it until it was collapsed into
85
+ * this one — in a settle-paced loop the wake counter can stay at zero for the
86
+ * loop's whole life, so it was never the ceiling that held.
85
87
  */
86
- function decideCaps(
87
- loop: LoopState,
88
- ): { action: "stop"; reason: "max-iterations" | "max-automatic-turns" } | undefined {
89
- if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
90
- return { action: "stop", reason: "max-iterations" };
91
- }
92
- if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
93
- return { action: "stop", reason: "max-automatic-turns" };
88
+ function decideCap(loop: LoopState): { action: "stop"; reason: "max-turns" } | undefined {
89
+ if (loop.maxTurns !== null && loop.automaticTurns >= loop.maxTurns) {
90
+ return { action: "stop", reason: "max-turns" };
94
91
  }
95
92
  return undefined;
96
93
  }
@@ -103,7 +100,7 @@ function decideCaps(
103
100
  export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
104
101
  const prefix = decideCommonPrefix(loop, env);
105
102
  if (prefix) return prefix;
106
- const capped = decideCaps(loop);
103
+ const capped = decideCap(loop);
107
104
  if (capped) return capped;
108
105
  return { action: "continue", reason: "settled-idle" };
109
106
  }
@@ -111,10 +108,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
111
108
  export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
112
109
  const prefix = decideCommonPrefix(loop, env);
113
110
  if (prefix) return prefix;
114
- const capped = decideCaps(loop);
111
+ const capped = decideCap(loop);
115
112
  if (capped) return capped;
116
113
  // The prefix already let a still-waiting loop skip, so a wait surviving to
117
114
  // here is one whose deadline has come due: this wake is the wake it asked
118
- // for, and it counts against the wake cap like any other.
115
+ // for, and the turn it starts counts against the cap like any other.
119
116
  return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
120
117
  }
package/src/index.ts CHANGED
@@ -11,23 +11,18 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
13
  import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
14
+ import { registerLoopProgressTool } from "./progress-tool.js";
15
+ import { registerLoopProposeTool } from "./propose-tool.js";
16
+ import { LOOP_PLANNING_HINT } from "./planning.js";
14
17
  import { registerLoopStartTool } from "./start-tool.js";
15
18
  import { registerLoopWaitTool } from "./wait-tool.js";
16
19
  import { LoopController, type LoopControllerOptions } from "./loop.js";
17
- import { showLoopManager, showLoopSettings } from "./manager.js";
20
+ import { showLoopApproval, showLoopManager, showLoopSettings } from "./manager.js";
18
21
  import { buildLoopObjectivePrompt } from "./objective.js";
19
22
  import { registerLoopMessageRendering } from "./render.js";
20
- import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
21
- import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
22
- import { describeSchedule } from "./schedule/model.js";
23
- import { Scheduler } from "./schedule/runner.js";
24
23
 
25
24
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
26
25
  const controller = new LoopController(pi, options);
27
- const scheduler = new Scheduler(pi, {
28
- ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
29
- ...(options.now === undefined ? {} : { now: options.now }),
30
- });
31
26
  // Registered unconditionally and never toggled with loop state: tools are
32
27
  // part of the cached request prefix, so mutating the tool set mid-session
33
28
  // would invalidate the whole conversation cache. It refuses when no loop is
@@ -36,6 +31,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
36
31
  // Registered on the same terms and for the same reason: the tool set is
37
32
  // part of the cached prefix, so it never changes with loop state.
38
33
  registerLoopWaitTool(pi, controller);
34
+ registerLoopProgressTool(pi, controller);
35
+ registerLoopProposeTool(pi, controller);
39
36
  // Inline invocation: an `input` handler arms a one-turn system-prompt hint
40
37
  // for a mid-prompt `/loop` token, `before_agent_start` appends it, and
41
38
  // loop_start is the model-invoked start it points at — refused on any turn
@@ -54,9 +51,34 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
54
51
  handler: async (args: string, ctx: ExtensionCommandContext) => {
55
52
  const command = parseLoopCommand(args);
56
53
  switch (command.kind) {
57
- case "show":
58
- await showLoopManager(controller, ctx);
54
+ case "show": {
55
+ // Bare /loop is the front door. With a loop running it is the
56
+ // manager, as before. With a draft awaiting approval it is the
57
+ // card. With neither it opens planning, which is what used to be
58
+ // a one-line "no loop in this session" dead end.
59
+ const running = controller.state && controller.state.status !== "stopped";
60
+ if (running) {
61
+ await showLoopManager(controller, ctx);
62
+ return;
63
+ }
64
+ if (controller.planning.proposal) {
65
+ await showLoopApproval(controller, ctx);
66
+ return;
67
+ }
68
+ if (controller.planning.active) {
69
+ ctx.ui.notify(
70
+ "Still planning: describe the objective, and the agent will put a loop up for approval.",
71
+ "info",
72
+ );
73
+ return;
74
+ }
75
+ controller.beginPlanning();
76
+ ctx.ui.notify(
77
+ "Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
78
+ "info",
79
+ );
59
80
  return;
81
+ }
60
82
  case "status":
61
83
  ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
62
84
  return;
@@ -101,83 +123,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
101
123
  },
102
124
  });
103
125
 
104
- // The scheduler is user-typed only, exactly like /loop: the model gets no
105
- // scheduling tools, because a model that can schedule its own future turns
106
- // can schedule its way around every limit the loop imposes.
107
- pi.registerCommand("schedule", {
108
- description:
109
- 'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
110
- getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
111
- handler: async (args: string, ctx: ExtensionCommandContext) => {
112
- const command = parseScheduleCommand(args, { cwd: ctx.cwd });
113
- switch (command.kind) {
114
- case "show":
115
- await showScheduleManager(scheduler, ctx);
116
- return;
117
- case "list":
118
- ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
119
- return;
120
- case "error":
121
- ctx.ui.notify(command.message, "error");
122
- return;
123
- case "create": {
124
- const { task, warning } = scheduler.create(command);
125
- if (warning) {
126
- ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
127
- }
128
- if (command.clampedFrom !== undefined) {
129
- ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
130
- }
131
- ctx.ui.notify(
132
- [
133
- `Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
134
- task.task.kind === "run"
135
- ? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
136
- : "Injects a prompt into this session at an idle boundary; it dies with the session.",
137
- `Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
138
- ].join("\n"),
139
- "info",
140
- );
141
- return;
142
- }
143
- default: {
144
- const task = scheduler.find(command.id);
145
- if (!task) {
146
- ctx.ui.notify(
147
- `No scheduled task matches ${command.id}. Run /schedule list to see them.`,
148
- "error",
149
- );
150
- return;
151
- }
152
- if (command.kind === "status") {
153
- ctx.ui.notify(describeTask(task).join("\n"), "info");
154
- return;
155
- }
156
- if (command.kind === "pause" || command.kind === "resume") {
157
- const status = command.kind === "pause" ? "paused" : "active";
158
- scheduler.update({ ...task, status });
159
- ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
160
- return;
161
- }
162
- if (command.kind === "run") {
163
- scheduler.fireNow(task);
164
- ctx.ui.notify(`Running "${task.name}" now.`, "info");
165
- return;
166
- }
167
- scheduler.remove(task.id);
168
- ctx.ui.notify(`Deleted "${task.name}".`, "info");
169
- }
170
- }
171
- },
172
- });
173
-
174
126
  pi.on("session_start", async (_event, ctx) => {
175
127
  controller.onSessionStart(ctx);
176
- scheduler.onSessionStart(ctx);
177
128
  });
178
129
  pi.on("session_shutdown", async () => {
179
130
  controller.onSessionShutdown();
180
- scheduler.onSessionShutdown();
181
131
  });
182
132
  // The pacemaker: agent_end records the intent to continue, agent_settled
183
133
  // delivers it once Pi will accept a message.
@@ -189,12 +139,16 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
189
139
  });
190
140
  pi.on("agent_settled", async (_event, ctx) => {
191
141
  controller.onAgentSettled(ctx);
192
- scheduler.onAgentSettled(ctx);
193
142
  });
194
143
  // A loop carries its own objective and injects it as a byte-stable system
195
144
  // append, which is what lets the poke and continuation messages stay
196
145
  // pointer-sized.
197
146
  pi.on("before_agent_start", (event) => {
147
+ // Planning precedes any loop, so its guidance is injected on the same hook
148
+ // and is mutually exclusive with the objective append below.
149
+ if (controller.planning.active) {
150
+ return { systemPrompt: `${event.systemPrompt}\n\n${LOOP_PLANNING_HINT}` };
151
+ }
198
152
  const loop = controller.state;
199
153
  if (!loop || loop.status !== "active") return;
200
154
  const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
package/src/interval.ts CHANGED
@@ -54,6 +54,31 @@ export function formatDuration(ms: number): string {
54
54
  return `${Math.round(ms / 1_000)}s`;
55
55
  }
56
56
 
57
+ /**
58
+ * Render an elapsed span approximately, for display only.
59
+ *
60
+ * `formatDuration` renders the *canonical token* for a configured interval and
61
+ * only ever emits one unit on an exact multiple, so an arbitrary elapsed span
62
+ * falls through it to seconds — 2h12m comes back as "7920s". An age needs the
63
+ * opposite trade: two units at most, truncated, never exact.
64
+ */
65
+ export function formatElapsed(ms: number): string {
66
+ const clamped = Math.max(0, ms);
67
+ if (clamped < UNIT_MS.m) return `${Math.floor(clamped / 1_000)}s`;
68
+ for (const [big, small] of [
69
+ ["d", "h"],
70
+ ["h", "m"],
71
+ ] as const) {
72
+ const bigMs = UNIT_MS[big];
73
+ const smallMs = UNIT_MS[small];
74
+ if (bigMs === undefined || smallMs === undefined || clamped < bigMs) continue;
75
+ const whole = Math.floor(clamped / bigMs);
76
+ const rest = Math.floor((clamped % bigMs) / smallMs);
77
+ return rest > 0 ? `${whole}${big}${rest}${small}` : `${whole}${big}`;
78
+ }
79
+ return `${Math.floor(clamped / UNIT_MS.m)}m`;
80
+ }
81
+
57
82
  /** Render a wall-clock time as HH:MM for the status widget. */
58
83
  export function formatClock(timestamp: number): string {
59
84
  const date = new Date(timestamp);
package/src/ledger.ts CHANGED
@@ -30,9 +30,9 @@ export const LEDGER_DIR_NAME = "loop";
30
30
  export const CRITERIA_FILE = "criteria.json";
31
31
  export const PROGRESS_FILE = "PROGRESS.md";
32
32
 
33
- /** Cap on derived criteria: an objective is a paragraph, not a backlog. */
34
- const MAX_CRITERIA = 12;
35
- const MAX_DESCRIPTION_LENGTH = 500;
33
+ /** Cap on a loop's criteria: an objective is a paragraph, not a backlog. */
34
+ export const MAX_CRITERIA = 12;
35
+ export const MAX_DESCRIPTION_LENGTH = 500;
36
36
 
37
37
  export interface LoopCriterion {
38
38
  id: string;
@@ -44,8 +44,37 @@ export interface LoopCriterion {
44
44
  */
45
45
  check: string;
46
46
  passes: boolean;
47
+ /**
48
+ * The citation given when `passes` was flipped, recorded by the extension
49
+ * at flip time. Absent on a criterion still unmet, and on one flipped by a
50
+ * hand-edit rather than through `loop_progress`.
51
+ */
52
+ evidence?: string;
53
+ /** Epoch ms of the flip that recorded `evidence`. */
54
+ evidenceAt?: number;
47
55
  }
48
56
 
57
+ /**
58
+ * The fixed headings of `PROGRESS.md`. The schema is the point: "update the
59
+ * ledger" means the same thing on every turn only while the sections are the
60
+ * same on every turn, so writes are section-scoped and a section that is not
61
+ * one of these is refused rather than created.
62
+ */
63
+ export const PROGRESS_SECTIONS = [
64
+ "current status",
65
+ "completed",
66
+ "failed approaches and why",
67
+ "next actions",
68
+ ] as const;
69
+ export type ProgressSection = (typeof PROGRESS_SECTIONS)[number];
70
+
71
+ /** Cap on one ledger write: a progress note is a paragraph, not a transcript. */
72
+ export const MAX_PROGRESS_TEXT_LENGTH = 4000;
73
+ export const MAX_EVIDENCE_LENGTH = 4000;
74
+
75
+ /** The template's placeholders, replaced rather than appended to on first write. */
76
+ const PLACEHOLDERS = new Set(["not started.", "- (nothing yet)"]);
77
+
49
78
  export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
50
79
  return join(agentDir, LEDGER_DIR_NAME, loopId);
51
80
  }
@@ -61,15 +90,23 @@ export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string
61
90
  export function deriveCriteria(objective: string): LoopCriterion[] {
62
91
  const trimmed = objective.trim();
63
92
  if (!trimmed) return [implicitCriterion(objective)];
64
- const bullets = trimmed
65
- .split(/\r?\n/)
66
- .map((line) => line.trim())
67
- .filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
68
- .map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
69
- .filter(Boolean);
93
+ const bullets = collectBullets(trimmed);
70
94
  const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
71
95
  if (parts.length < 2) return [implicitCriterion(trimmed)];
72
- return parts.slice(0, MAX_CRITERIA).map((description, index) => ({
96
+ return criteriaFromDescriptions(parts);
97
+ }
98
+
99
+ /**
100
+ * Number a list of descriptions into criteria.
101
+ *
102
+ * Shared by the deterministic split and by the criteria a model may propose
103
+ * at `loop_start`, so nothing downstream — the echo at start, the evidence
104
+ * gate, the immutability rule — can tell the two apart. The extension still
105
+ * writes every field but the description: ids are positional, `check` is
106
+ * empty (audit against authoritative state), and a criterion starts unmet.
107
+ */
108
+ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopCriterion[] {
109
+ return descriptions.slice(0, MAX_CRITERIA).map((description, index) => ({
73
110
  id: `c${index + 1}`,
74
111
  description: truncate(description),
75
112
  check: "",
@@ -77,6 +114,46 @@ export function deriveCriteria(objective: string): LoopCriterion[] {
77
114
  }));
78
115
  }
79
116
 
117
+ const BULLET_MARKER = /^([-*+]|\d+[.)])\s+/;
118
+
119
+ /**
120
+ * Bullets, each folded back together with the lines it wrapped onto.
121
+ *
122
+ * A bullet longer than the terminal width is typed — or pasted — across
123
+ * several lines, and only the first carries the marker. Matching markers and
124
+ * discarding everything else silently truncated such a bullet at its first
125
+ * line, which is worse than mis-splitting it: the criterion still looked
126
+ * well-formed, so a requirement could vanish out of the gate with no signal.
127
+ * A non-blank line that starts no new bullet therefore continues the previous
128
+ * one. Text before the first bullet is still ignored (it is a preamble, not a
129
+ * requirement), and a blank line ends the bullet it follows so a trailing
130
+ * paragraph cannot be glued onto the last item.
131
+ */
132
+ function collectBullets(text: string): string[] {
133
+ const bullets: string[] = [];
134
+ let open = false;
135
+ for (const raw of text.split(/\r?\n/)) {
136
+ const line = raw.trim();
137
+ if (!line) {
138
+ open = false;
139
+ continue;
140
+ }
141
+ if (BULLET_MARKER.test(line)) {
142
+ const body = line.replace(BULLET_MARKER, "").trim();
143
+ if (body) {
144
+ bullets.push(body);
145
+ open = true;
146
+ } else {
147
+ // A bare marker has no body to continue.
148
+ open = false;
149
+ }
150
+ continue;
151
+ }
152
+ if (open) bullets[bullets.length - 1] += ` ${line}`;
153
+ }
154
+ return bullets;
155
+ }
156
+
80
157
  function implicitCriterion(objective: string): LoopCriterion {
81
158
  return {
82
159
  id: "c1",
@@ -184,6 +261,119 @@ export function progressTemplate(objective: string): string {
184
261
  ].join("\n");
185
262
  }
186
263
 
264
+ /**
265
+ * Append to (or replace) one section of `PROGRESS.md`, leaving every other
266
+ * section byte-identical.
267
+ *
268
+ * This exists because the alternative the model reaches for otherwise is a
269
+ * whole-file overwrite, which takes out the objective line and the other
270
+ * three sections along with it. `createLedger` already refuses to overwrite
271
+ * this file for exactly that reason; the agent's write path has to honour the
272
+ * same rule or the protection is decorative.
273
+ *
274
+ * Returns the failure reason, or undefined on success.
275
+ */
276
+ export function writeProgressSection(
277
+ paths: LedgerPaths,
278
+ section: ProgressSection,
279
+ text: string,
280
+ ): string | undefined {
281
+ const entry = text.trim();
282
+ if (!entry) return "the text to record was empty";
283
+ let contents: string;
284
+ try {
285
+ contents = readFileSync(paths.progress, "utf8");
286
+ } catch (error) {
287
+ return formatError(error);
288
+ }
289
+ const lines = contents.split(/\r?\n/);
290
+ const start = lines.findIndex((line) => headingText(line) === section);
291
+ if (start === -1) {
292
+ return `PROGRESS.md has no "## ${section}" section (it was renamed or removed by hand)`;
293
+ }
294
+ let end = start + 1;
295
+ while (end < lines.length && headingText(lines[end]) === undefined) end += 1;
296
+ const body = lines.slice(start + 1, end);
297
+ while (body.length > 0 && !body[0].trim()) body.shift();
298
+ while (body.length > 0 && !body[body.length - 1].trim()) body.pop();
299
+ const placeholder =
300
+ body.length === 1 && PLACEHOLDERS.has(body[0].trim().toLowerCase()) ? true : body.length === 0;
301
+ // Whether a write replaces or extends is a property of the section, not a
302
+ // choice: "current status" is a single current value and the other three are
303
+ // running lists. Deriving it keeps the decision out of the tool schema,
304
+ // where the model could get it wrong on a file nothing else can repair.
305
+ const next =
306
+ section === "current status" || placeholder
307
+ ? entry.split("\n")
308
+ : [...body, "", ...entry.split("\n")];
309
+ const rebuilt = [...lines.slice(0, start + 1), "", ...next, "", ...lines.slice(end)];
310
+ try {
311
+ writeFileSync(paths.progress, `${rebuilt.join("\n").replace(/\n{3,}$/u, "\n")}`, "utf8");
312
+ return undefined;
313
+ } catch (error) {
314
+ return formatError(error);
315
+ }
316
+ }
317
+
318
+ function headingText(line: string | undefined): string | undefined {
319
+ const match = /^##\s+(.+?)\s*$/u.exec(line ?? "");
320
+ return match ? match[1].toLowerCase() : undefined;
321
+ }
322
+
323
+ export interface MarkCriterionResult {
324
+ ok: boolean;
325
+ message: string;
326
+ criteria?: LoopCriterion[];
327
+ }
328
+
329
+ /**
330
+ * Flip one criterion's `passes` and record the citation that justified it.
331
+ *
332
+ * The only mutation `criteria.json` accepts. Descriptions, ids, checks and the
333
+ * set of entries are rewritten by nothing here, so "a model may not rewrite
334
+ * its own acceptance criteria" stops being a rule in a skill file and becomes
335
+ * a property of the only available write path.
336
+ */
337
+ export function markCriterion(
338
+ paths: LedgerPaths,
339
+ id: string,
340
+ evidence: string,
341
+ passes: boolean,
342
+ now: number,
343
+ ): MarkCriterionResult {
344
+ const criteria = readCriteria(paths);
345
+ if (!criteria) return { ok: false, message: "criteria.json is absent or unreadable" };
346
+ const target = criteria.find((criterion) => criterion.id === id);
347
+ if (!target) {
348
+ return {
349
+ ok: false,
350
+ message: `no criterion ${id}; this loop has ${criteria.map((c) => c.id).join(", ")}`,
351
+ };
352
+ }
353
+ const cited = evidence.trim();
354
+ if (passes && !cited) return { ok: false, message: "marking a criterion met requires evidence" };
355
+ const updated = criteria.map((criterion) =>
356
+ criterion.id === id
357
+ ? {
358
+ ...criterion,
359
+ passes,
360
+ ...(passes ? { evidence: cited, evidenceAt: now } : {}),
361
+ }
362
+ : criterion,
363
+ );
364
+ try {
365
+ writeFileSync(paths.criteria, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
366
+ } catch (error) {
367
+ return { ok: false, message: formatError(error) };
368
+ }
369
+ const met = updated.filter((criterion) => criterion.passes).length;
370
+ return {
371
+ ok: true,
372
+ message: `${id} marked ${passes ? "met" : "unmet"} (${met}/${updated.length} now passing)`,
373
+ criteria: updated,
374
+ };
375
+ }
376
+
187
377
  /** Read the criteria back, fail-open: undefined when absent or unreadable. */
188
378
  export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
189
379
  let contents: string;
@@ -213,11 +403,15 @@ function normalizeCriterion(value: unknown): LoopCriterion | undefined {
213
403
  const id = typeof record.id === "string" ? record.id.trim() : "";
214
404
  const description = typeof record.description === "string" ? record.description.trim() : "";
215
405
  if (!id || !description) return undefined;
406
+ const evidence = typeof record.evidence === "string" ? record.evidence.trim() : "";
407
+ const evidenceAt = record.evidenceAt;
216
408
  return {
217
409
  id,
218
410
  description,
219
411
  check: typeof record.check === "string" ? record.check : "",
220
412
  passes: record.passes === true,
413
+ ...(evidence ? { evidence } : {}),
414
+ ...(typeof evidenceAt === "number" && Number.isSafeInteger(evidenceAt) ? { evidenceAt } : {}),
221
415
  };
222
416
  }
223
417