@hank-warren/pi-loop 0.7.0 → 0.9.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/ledger.ts CHANGED
@@ -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,12 +90,7 @@ 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
96
  return criteriaFromDescriptions(parts);
@@ -90,6 +114,46 @@ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopC
90
114
  }));
91
115
  }
92
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
+
93
157
  function implicitCriterion(objective: string): LoopCriterion {
94
158
  return {
95
159
  id: "c1",
@@ -197,6 +261,119 @@ export function progressTemplate(objective: string): string {
197
261
  ].join("\n");
198
262
  }
199
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
+
200
377
  /** Read the criteria back, fail-open: undefined when absent or unreadable. */
201
378
  export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
202
379
  let contents: string;
@@ -226,11 +403,15 @@ function normalizeCriterion(value: unknown): LoopCriterion | undefined {
226
403
  const id = typeof record.id === "string" ? record.id.trim() : "";
227
404
  const description = typeof record.description === "string" ? record.description.trim() : "";
228
405
  if (!id || !description) return undefined;
406
+ const evidence = typeof record.evidence === "string" ? record.evidence.trim() : "";
407
+ const evidenceAt = record.evidenceAt;
229
408
  return {
230
409
  id,
231
410
  description,
232
411
  check: typeof record.check === "string" ? record.check : "",
233
412
  passes: record.passes === true,
413
+ ...(evidence ? { evidence } : {}),
414
+ ...(typeof evidenceAt === "number" && Number.isSafeInteger(evidenceAt) ? { evidenceAt } : {}),
234
415
  };
235
416
  }
236
417
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The approval card's actions, as a menu.
3
+ *
4
+ * Presentation and choices are separate surfaces on purpose, the way
5
+ * pi-plan-mode splits `presentation.ts` from `plan-action-menus.ts`: the card
6
+ * is a durable artifact in the transcript that the user can scroll back to,
7
+ * and the menu is a transient dialog over it. A plain `ui.select` of label
8
+ * strings could not say what "start in a fresh session" means, and that is
9
+ * exactly the entry that needs explaining.
10
+ *
11
+ * The screen is built by a pure function so a test can assert what the menu
12
+ * offers without a terminal.
13
+ */
14
+
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
17
+ import type { ActionsScreen } from "@narumitw/pi-tui-kit";
18
+ import { formatDuration } from "./interval.js";
19
+ import type { LoopProposal } from "./planning.js";
20
+
21
+ export type LoopApprovalAction =
22
+ | "start-here"
23
+ | "start-fresh"
24
+ | "change-cadence"
25
+ | "keep-editing"
26
+ | "cancel";
27
+
28
+ type Screen = "approval";
29
+
30
+ export interface LoopApprovalMenuOptions {
31
+ proposal: LoopProposal;
32
+ signal?: AbortSignal;
33
+ isCurrent?(): boolean;
34
+ startHere(): void | Promise<void>;
35
+ startFresh(signal: AbortSignal): void | Promise<void>;
36
+ changeCadence(): void | Promise<void>;
37
+ keepEditing(): void;
38
+ cancel(): void;
39
+ }
40
+
41
+ /**
42
+ * The approval screen. Pure and exported for tests: the set of actions on
43
+ * offer is the contract, and it is cheaper to pin here than through a TUI.
44
+ */
45
+ export function loopApprovalScreen(
46
+ proposal: LoopProposal,
47
+ ): ActionsScreen<Screen, LoopApprovalAction> {
48
+ const criteria = `${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}`;
49
+ return {
50
+ kind: "actions",
51
+ title: "Start this loop?",
52
+ lines: [
53
+ `${criteria} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
54
+ "The card above shows exactly what loop_complete will be held to.",
55
+ ],
56
+ items: [
57
+ {
58
+ id: "start-here",
59
+ label: "Start loop here",
60
+ description: "Run it in this session, keeping the planning conversation.",
61
+ action: "start-here",
62
+ },
63
+ {
64
+ id: "start-fresh",
65
+ label: "Start loop in a fresh session",
66
+ description:
67
+ "Open a new session that runs the loop with only the objective — no planning history.",
68
+ action: "start-fresh",
69
+ busyLabel: "Starting the loop in a fresh session…",
70
+ },
71
+ {
72
+ id: "change-cadence",
73
+ label: "Change cadence…",
74
+ description: "Edit the fallback heartbeat before starting.",
75
+ action: "change-cadence",
76
+ },
77
+ {
78
+ id: "keep-editing",
79
+ label: "Keep editing",
80
+ description: "Go back to drafting; tell the agent what to change.",
81
+ action: "keep-editing",
82
+ },
83
+ {
84
+ id: "cancel",
85
+ label: "Cancel",
86
+ description: "Discard the draft. Nothing is started.",
87
+ action: "cancel",
88
+ },
89
+ ],
90
+ hint: "close",
91
+ };
92
+ }
93
+
94
+ export async function showLoopApprovalMenu(
95
+ ctx: ExtensionContext,
96
+ options: LoopApprovalMenuOptions,
97
+ ) {
98
+ const menu = defineMenu<undefined, Screen, LoopApprovalAction, ExtensionContext>({
99
+ start: "approval",
100
+ screens: { approval: () => loopApprovalScreen(options.proposal) },
101
+ actions: {
102
+ "start-here": async () => {
103
+ await options.startHere();
104
+ return { kind: "close" };
105
+ },
106
+ "start-fresh": async ({ signal }) => {
107
+ await options.startFresh(signal);
108
+ return { kind: "close" };
109
+ },
110
+ "change-cadence": async () => {
111
+ await options.changeCadence();
112
+ return { kind: "close" };
113
+ },
114
+ "keep-editing": async () => {
115
+ options.keepEditing();
116
+ return { kind: "close" };
117
+ },
118
+ cancel: async () => {
119
+ options.cancel();
120
+ return { kind: "close" };
121
+ },
122
+ },
123
+ });
124
+ return runMenu(ctx, menu, {
125
+ getState: () => undefined,
126
+ ...(options.signal ? { signal: options.signal } : {}),
127
+ ...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
128
+ });
129
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The loop-active environment contract.
3
+ *
4
+ * pi-loop publishes two variables into its own process environment while a
5
+ * loop is active, and removes them the moment it is not:
6
+ *
7
+ * - `PI_LOOP_ACTIVE=1` — an unattended loop is running in this session.
8
+ * - `PI_LOOP_ID=<id>` — the loop's id, so a reader can tell one loop from the
9
+ * next without asking pi-loop anything.
10
+ *
11
+ * It exists for other extensions, and pi-auto-permissions is the first
12
+ * consumer: a modal permission prompt does not pause a loop, it deadlocks it,
13
+ * so a guardian that would have asked a human needs to know there is no human
14
+ * to ask. The mechanism is deliberately the one `pi-subagents` already
15
+ * established with `PI_SUBAGENT_CHILD=1` and `detectSubagentContext` reads —
16
+ * an environment variable, not a package dependency, not an import, not an
17
+ * RPC. Neither extension needs the other installed, in either direction, and
18
+ * a reader that never sees the variable behaves exactly as it does today.
19
+ *
20
+ * Both variables are set on the process, so they are visible to every
21
+ * extension in the session and inherited by anything it spawns. That is the
22
+ * point: a subagent launched by a looping session is running unattended for
23
+ * the same reason its parent is.
24
+ */
25
+
26
+ import type { LoopState } from "./state.js";
27
+
28
+ export const LOOP_ACTIVE_ENV = "PI_LOOP_ACTIVE";
29
+ export const LOOP_ID_ENV = "PI_LOOP_ID";
30
+
31
+ /**
32
+ * Publish (or withdraw) the loop-active signal for `loop`.
33
+ *
34
+ * Only an `active` loop publishes. A paused loop is not working unattended —
35
+ * the user paused it and is, by construction, present — and a stopped loop is
36
+ * not working at all, so both withdraw the signal rather than leaving a stale
37
+ * one behind for the rest of the session.
38
+ */
39
+ export function publishLoopEnv(
40
+ loop: LoopState | undefined,
41
+ env: Record<string, string | undefined> = process.env,
42
+ ): void {
43
+ if (loop?.status === "active") {
44
+ env[LOOP_ACTIVE_ENV] = "1";
45
+ env[LOOP_ID_ENV] = loop.id;
46
+ return;
47
+ }
48
+ delete env[LOOP_ACTIVE_ENV];
49
+ delete env[LOOP_ID_ENV];
50
+ }