@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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Loop planning: authoring an objective with the user before any loop exists.
3
+ *
4
+ * The criteria are frozen the moment a loop starts, and until now the first
5
+ * time anyone saw them was after that point. That is the wrong order. The
6
+ * objective's wording is the single leverage point on a loop's whole life —
7
+ * it becomes the acceptance gate — and the moment it is decided is a
8
+ * conversation, not a typed command.
9
+ *
10
+ * So `/loop` with nothing running opens a drafting conversation instead of an
11
+ * error about a missing interval, and the loop starts from an approval card
12
+ * that shows the exact criteria the split will produce. The card is the design
13
+ * language: because the cadence and the caps are on it and editable there, the
14
+ * command grammar does not have to be natural, and none of the
15
+ * optional-interval, `every`-prefix, adverb or dry-run machinery needs to
16
+ * exist. A concept removed rather than a knob added.
17
+ *
18
+ * The typed form (`/loop 30m <objective>`) is untouched, as is inline `loop:`
19
+ * invocation. Planning is the front door, not the only door.
20
+ */
21
+
22
+ import { deriveCriteria, type LoopCriterion } from "./ledger.js";
23
+ import { formatDuration } from "./interval.js";
24
+
25
+ /** A drafted loop, put up for approval and not yet started. */
26
+ export interface LoopProposal {
27
+ objective: string;
28
+ /** Exactly what `deriveCriteria` will produce, computed here so the card cannot lie. */
29
+ criteria: LoopCriterion[];
30
+ intervalMs: number;
31
+ maxTurns: number | null;
32
+ expiresInMs: number;
33
+ proposedAt: number;
34
+ }
35
+
36
+ export interface LoopPlanningState {
37
+ /** The user opened planning and no loop has started yet. */
38
+ active: boolean;
39
+ /** The current draft awaiting approval, when one has been proposed. */
40
+ proposal?: LoopProposal;
41
+ /**
42
+ * `proposedAt` of the draft whose card has already been rendered.
43
+ *
44
+ * The card is an artifact in the transcript, not a status line, so re-running
45
+ * `/loop` to reopen the menu must not emit a second copy of the same card.
46
+ * A new draft — a reworded objective, a changed cadence — has a new
47
+ * `proposedAt` and does get its own card, because it is a different thing to
48
+ * approve.
49
+ */
50
+ cardShownAt?: number;
51
+ }
52
+
53
+ export function buildProposal(
54
+ objective: string,
55
+ defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
56
+ now: number,
57
+ overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
58
+ ): LoopProposal {
59
+ return {
60
+ objective: objective.trim(),
61
+ // Derived, never authored: the card has to show the criteria the engine
62
+ // will actually freeze, or approving it means approving something else.
63
+ criteria: deriveCriteria(objective),
64
+ intervalMs: overrides.intervalMs ?? defaults.intervalMs,
65
+ maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
66
+ expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
67
+ proposedAt: now,
68
+ };
69
+ }
70
+
71
+ /** The approval card, as transcript lines. */
72
+ export function renderProposalCard(proposal: LoopProposal): string[] {
73
+ return [
74
+ "**◆ Loop ready to start**",
75
+ "",
76
+ "**Objective**",
77
+ ...proposal.objective.split("\n").map((line) => `> ${line}`),
78
+ "",
79
+ `**Criteria the gate will hold you to** (${proposal.criteria.length})`,
80
+ ...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
81
+ "",
82
+ `**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
83
+ `**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
84
+ "",
85
+ "Run `/loop` for the actions: start here, start in a fresh session, change the cadence, keep editing, or cancel.",
86
+ ];
87
+ }
88
+
89
+ export const LOOP_PLANNING_HINT = [
90
+ "<system-reminder>",
91
+ "The user opened loop planning. You are drafting a loop objective with them; no loop is running and none starts until they approve one.",
92
+ "A loop's objective becomes its acceptance gate, so draft it as an acceptance test, not as a prompt:",
93
+ "- One requirement per line, as a bullet. A conjunction inside a sentence does not split, so 'fix the flaky test and update the docs' becomes one criterion whose evidence must cover both halves.",
94
+ "- Name the check in the requirement itself ('…, verified by npm test passing'), so completion is a lookup instead of an argument.",
95
+ "- The two questions that fix most objectives: how will we know it is done, and what command proves it?",
96
+ "When the draft is ready, call loop_propose with it. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
97
+ // Without this the model reaches for the loop_start prohibition instead. It
98
+ // is stated emphatically and repeatedly ('never start a loop without that
99
+ // token, no matter how loop-like the request sounds'), so a conversational
100
+ // request for a loop pattern-matches straight onto it — and the model
101
+ // answers by telling the user to type /loop, which is precisely the dead end
102
+ // planning exists to remove. Observed live in a canary session.
103
+ "loop_propose is not loop_start. It starts nothing, so the inline-token rule does not apply to it: while planning is open, a conversational request for a loop is exactly when to call loop_propose. Do not refuse and tell the user to type /loop instead — drafting a proposal for them is the whole point of this mode.",
104
+ "The user has already opened planning, so their intent to consider a loop is established. What still requires their explicit approval is starting one, and the card is where they give it.",
105
+ "Never restate the objective as a tidier version of what they meant. If they decline to name checks, say plainly what the gate will and will not catch, and let them decide.",
106
+ "If the work is a bad fit for a loop at all — a recurring cadence, open-ended investigation with no end state, or something that finishes this turn — say so in one line and offer the alternative instead of drafting one anyway.",
107
+ "</system-reminder>",
108
+ ].join("\n");
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The approval card, as a framed transcript block.
3
+ *
4
+ * It used to go out twice and neither copy was a card: `loop_propose`
5
+ * returned it as tool-result text, and `/loop` re-printed it through
6
+ * `ctx.ui.notify`. Tool text is rendered as a wall of markdown inside a tool
7
+ * result, and a toast is a transient line that scrolls away — so the one
8
+ * artifact the whole planning flow exists to produce was the least legible
9
+ * thing on the screen, and duplicated.
10
+ *
11
+ * A custom-type message with `display: true` is what Pi frames, and
12
+ * `triggerTurn: false` is what keeps it an artifact rather than a prompt: the
13
+ * card appears, the model is not asked to respond to it, and the user's
14
+ * approval remains the only thing that starts a loop. This is exactly how
15
+ * pi-plan-mode renders a proposed plan (`packages/pi-plan-mode/src/
16
+ * presentation.ts`), for the same reason.
17
+ */
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { type LoopProposal, renderProposalCard } from "./planning.js";
21
+
22
+ export const LOOP_PROPOSAL_MESSAGE_TYPE = "loop-proposal";
23
+
24
+ /**
25
+ * Emit the card. Returns false when Pi refused it, in which case the caller
26
+ * still has a working flow — the menu carries the actions, and the criteria
27
+ * are on disk the moment the loop starts.
28
+ */
29
+ export function showLoopProposalCard(
30
+ pi: ExtensionAPI,
31
+ ctx: ExtensionContext,
32
+ proposal: LoopProposal,
33
+ ): boolean {
34
+ try {
35
+ pi.sendMessage(
36
+ {
37
+ customType: LOOP_PROPOSAL_MESSAGE_TYPE,
38
+ content: renderProposalCard(proposal).join("\n"),
39
+ display: true,
40
+ details: { criteria: proposal.criteria.length, proposedAt: proposal.proposedAt },
41
+ },
42
+ { triggerTurn: false },
43
+ );
44
+ return true;
45
+ } catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ ctx.ui.notify(`Unable to show the loop proposal: ${detail}`, "error");
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `loop_progress`: the only supported write path into the loop ledger.
3
+ *
4
+ * The ledger is the one thing that survives compaction, and until this tool
5
+ * existed the model was told to maintain it with no way to do so — so it
6
+ * reached for `write` or a shell heredoc, and a single `cat > PROGRESS.md`
7
+ * replaced the objective line, the other three sections, and days of
8
+ * failed-approach notes. `createLedger` opens that file with `flag: "wx"`
9
+ * precisely so the *engine* can never do that; leaving the *agent* a path that
10
+ * can made the protection decorative.
11
+ *
12
+ * Two operations, deliberately in one tool: record a note in a named section,
13
+ * and flip a criterion with the citation that justified it. They travel
14
+ * together — "here is what I did, and here is the criterion it proves" is one
15
+ * thought, and one tool call per turn keeps the ledger current without a
16
+ * second round trip.
17
+ *
18
+ * Registered unconditionally like `loop_complete` and `loop_wait`: tools are
19
+ * part of the cached request prefix, so adding one mid-session would
20
+ * invalidate the whole conversation cache. It refuses when no loop is active.
21
+ */
22
+
23
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { StringEnum } from "@earendil-works/pi-ai";
25
+ import { Type } from "typebox";
26
+ import {
27
+ MAX_EVIDENCE_LENGTH,
28
+ MAX_PROGRESS_TEXT_LENGTH,
29
+ markCriterion,
30
+ PROGRESS_SECTIONS,
31
+ type ProgressSection,
32
+ writeProgressSection,
33
+ } from "./ledger.js";
34
+ import type { LoopController } from "./loop.js";
35
+
36
+ export const LOOP_PROGRESS_TOOL = "loop_progress";
37
+
38
+ export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopController) {
39
+ pi.registerTool(
40
+ defineTool({
41
+ name: LOOP_PROGRESS_TOOL,
42
+ label: "Loop Progress",
43
+ description:
44
+ "Record progress in the active /loop's durable ledger: append a note to one PROGRESS.md section, and/or mark a completion criterion met with the evidence that proves it. The only supported way to write to the ledger — never edit PROGRESS.md or criteria.json with file or shell tools.",
45
+ promptSnippet: "Record loop progress and mark criteria met with evidence",
46
+ promptGuidelines: [
47
+ "Use loop_progress to update the loop ledger. Never write PROGRESS.md or criteria.json with the file or shell tools: a whole-file write destroys the objective line and the other sections, and hand-editing criteria.json bypasses the rule that only `passes` may change.",
48
+ "Record a note the same turn you learn something, not at the end. The failed-approaches section carries the most value, because it is the only thing that stops the next continuation from re-running an experiment that already failed.",
49
+ "'current status' replaces what is there (it is one current value); the other three sections append.",
50
+ "Mark a criterion met only with authoritative evidence: the command and what it printed, the file and what it now contains, the URL and its state. The citation is stored next to the criterion and is what loop_complete answers for later.",
51
+ ],
52
+ parameters: Type.Object({
53
+ section: Type.Optional(
54
+ StringEnum([...PROGRESS_SECTIONS], {
55
+ description:
56
+ "Which PROGRESS.md section to write. 'current status' replaces its contents; the others append.",
57
+ }),
58
+ ),
59
+ note: Type.Optional(
60
+ Type.String({
61
+ maxLength: MAX_PROGRESS_TEXT_LENGTH,
62
+ description:
63
+ "Markdown to record in that section. Write list sections as '- ' bullets to match the file.",
64
+ }),
65
+ ),
66
+ criterion: Type.Optional(
67
+ Type.String({
68
+ maxLength: 40,
69
+ description: "Criterion id to mark, e.g. 'c2'. Ids come from the loop's criteria.json.",
70
+ }),
71
+ ),
72
+ evidence: Type.Optional(
73
+ Type.String({
74
+ maxLength: MAX_EVIDENCE_LENGTH,
75
+ description:
76
+ "The citation proving that criterion: the command and its output, the file and its contents, the URL and its state. Required when marking one met.",
77
+ }),
78
+ ),
79
+ met: Type.Optional(
80
+ Type.Boolean({
81
+ description:
82
+ "Whether the criterion is met. Defaults to true; pass false to retract a criterion marked met in error.",
83
+ }),
84
+ ),
85
+ }),
86
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
87
+ const loop = controller.state;
88
+ if (!loop || loop.objective === undefined) {
89
+ return failure(
90
+ "No /loop with an objective is active, so there is no ledger to write. Start one with /loop <interval> <objective>.",
91
+ );
92
+ }
93
+ const paths = controller.ledger;
94
+ if (!paths) {
95
+ return failure(
96
+ "This loop has no ledger (it could not be created), so progress cannot be recorded. Keep the state in your reply instead.",
97
+ );
98
+ }
99
+
100
+ const section = params.section as ProgressSection | undefined;
101
+ const note = params.note?.trim();
102
+ const criterion = params.criterion?.trim();
103
+ // A tool call that writes nothing is a mistake worth naming: the
104
+ // model believed it recorded something and it did not.
105
+ if (!note && !criterion) {
106
+ return failure(
107
+ "Nothing to record. Pass section + note to write a ledger entry, criterion + evidence to mark a criterion, or both.",
108
+ );
109
+ }
110
+ if (note && !section) return failure("A note needs a section to write it to.");
111
+ if (section && !note) return failure("A section needs a note to write into it.");
112
+
113
+ const done: string[] = [];
114
+ if (section && note) {
115
+ const failed = writeProgressSection(paths, section, note);
116
+ if (failed) return failure(`Could not write the ledger: ${failed}`);
117
+ done.push(`Recorded under "${section}".`);
118
+ }
119
+
120
+ let remaining: string | undefined;
121
+ if (criterion) {
122
+ const met = params.met ?? true;
123
+ const result = markCriterion(paths, criterion, params.evidence ?? "", met, Date.now());
124
+ if (!result.ok) {
125
+ // A half-applied call still reports the half that landed, so the
126
+ // model does not record the note twice on the retry.
127
+ return failure(
128
+ [...done, `Could not mark ${criterion}: ${result.message}`].join(" "),
129
+ done.length > 0,
130
+ );
131
+ }
132
+ done.push(result.message);
133
+ const unmet = (result.criteria ?? []).filter((entry) => !entry.passes);
134
+ remaining =
135
+ unmet.length > 0
136
+ ? `Still unmet: ${unmet.map((entry) => entry.id).join(", ")}.`
137
+ : "Every criterion is now marked met; audit them against authoritative current state before calling loop_complete.";
138
+ controller.updateWidget();
139
+ }
140
+
141
+ return {
142
+ content: [
143
+ { type: "text" as const, text: [...done, remaining].filter(Boolean).join(" ") },
144
+ ],
145
+ details: {
146
+ loopId: loop.id,
147
+ ...(section && note ? { section } : {}),
148
+ ...(criterion ? { criterion, met: params.met ?? true } : {}),
149
+ },
150
+ };
151
+ },
152
+ }),
153
+ );
154
+ }
155
+
156
+ function failure(text: string, partial = false) {
157
+ return {
158
+ content: [{ type: "text" as const, text }],
159
+ details: { partial },
160
+ isError: true,
161
+ };
162
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `loop_propose`: put a drafted loop up for the user's approval.
3
+ *
4
+ * It starts nothing. That separation is the point — the model drafts, the user
5
+ * approves, and the approval is what arms the start. `loop_start`'s gate
6
+ * exists because a loop is self-continuing and must never begin on model
7
+ * initiative; an explicit approval on a card showing the objective, the
8
+ * criteria, the cadence and the caps is stronger evidence of intent than a
9
+ * typed token, not weaker, so it arms the same gate rather than bypassing it.
10
+ *
11
+ * Registered unconditionally, like the other loop tools: the tool set is part
12
+ * of the cached request prefix, so it never changes with loop state.
13
+ */
14
+
15
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+ import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
18
+ import type { LoopController } from "./loop.js";
19
+ import { showLoopProposalCard } from "./presentation.js";
20
+
21
+ export const LOOP_PROPOSE_TOOL = "loop_propose";
22
+
23
+ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
24
+ pi.registerTool(
25
+ defineTool({
26
+ name: LOOP_PROPOSE_TOOL,
27
+ label: "Loop Propose",
28
+ description:
29
+ "Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
30
+ promptSnippet: "Propose a drafted loop objective for approval",
31
+ promptGuidelines: [
32
+ "Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
33
+ "Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
34
+ "Do not call loop_start for a planned loop; approving the card is what starts it.",
35
+ "loop_propose starts nothing, so the rule that a loop needs an inline /loop token in the user's message does not apply to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to type /loop instead.",
36
+ ],
37
+ parameters: Type.Object({
38
+ objective: Type.String({
39
+ minLength: 1,
40
+ maxLength: 100_000,
41
+ description:
42
+ "The drafted objective, one requirement per bullet, each naming how it is verified.",
43
+ }),
44
+ interval: Type.Optional(
45
+ Type.String({
46
+ description:
47
+ "Fallback heartbeat as <number><unit> (s, m, h, d), e.g. 30m. Omit to use the configured default.",
48
+ }),
49
+ ),
50
+ max_turns: Type.Optional(
51
+ Type.Number({
52
+ description:
53
+ "Cap on loop-caused turns. Omit for the configured default; the user can change it on the card.",
54
+ }),
55
+ ),
56
+ expires: Type.Optional(
57
+ Type.String({
58
+ description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
59
+ }),
60
+ ),
61
+ }),
62
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
+ if (!controller.planning.active) {
64
+ return failure(
65
+ "Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
66
+ );
67
+ }
68
+ if (controller.state && controller.state.status !== "stopped") {
69
+ return failure(
70
+ "A loop is already running in this session. Stop it with /loop stop before planning another.",
71
+ );
72
+ }
73
+ const objective = params.objective.trim();
74
+ if (!objective) return failure("A proposal needs an objective.");
75
+
76
+ const overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
77
+ {};
78
+ if (params.interval !== undefined) {
79
+ const parsed = parseDuration(params.interval);
80
+ if (parsed === undefined) {
81
+ return failure(
82
+ `Invalid interval: ${params.interval}. Use <number><unit> with unit s, m, h, or d, e.g. 30m.`,
83
+ );
84
+ }
85
+ overrides.intervalMs = Math.min(parsed, MAX_INTERVAL_MS);
86
+ }
87
+ if (params.expires !== undefined) {
88
+ const parsed = parseDuration(params.expires);
89
+ if (parsed === undefined) {
90
+ return failure(
91
+ `Invalid expires: ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
92
+ );
93
+ }
94
+ overrides.expiresInMs = parsed;
95
+ }
96
+ if (params.max_turns !== undefined) {
97
+ if (!Number.isSafeInteger(params.max_turns) || params.max_turns <= 0) {
98
+ return failure("max_turns must be a positive whole number.");
99
+ }
100
+ overrides.maxTurns = params.max_turns;
101
+ }
102
+
103
+ const proposal = controller.propose(objective, overrides);
104
+ // The card goes to the transcript as a framed block, not back through
105
+ // this tool result. Returning it here too would render the same
106
+ // artifact twice, once framed and once as a wall of markdown, and
107
+ // spend the objective's tokens a second time in the model's own
108
+ // context for no reader that does not already have it.
109
+ controller.showProposalCard(ctx);
110
+ return {
111
+ content: [
112
+ {
113
+ type: "text" as const,
114
+ text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
115
+ },
116
+ ],
117
+ details: {
118
+ criteria: proposal.criteria.length,
119
+ intervalMs: proposal.intervalMs,
120
+ maxTurns: proposal.maxTurns,
121
+ },
122
+ };
123
+ },
124
+ }),
125
+ );
126
+ }
127
+
128
+ function failure(text: string) {
129
+ return { content: [{ type: "text" as const, text }], details: {}, isError: true };
130
+ }
package/src/state.ts CHANGED
@@ -63,6 +63,19 @@ export interface LoopState {
63
63
  * while it writes its state down; the next settle stops it.
64
64
  */
65
65
  expiring?: true;
66
+ /**
67
+ * Set on a loop handed to a fresh session and cleared the moment that
68
+ * session restores it.
69
+ *
70
+ * The launching session cannot kick the loop off itself: Pi builds a new
71
+ * extension instance for the new session, so the controller that ran the
72
+ * approval menu is not the controller that ends up holding the loop —
73
+ * observed live, where the loop crossed correctly and then sat idle waiting
74
+ * for its first fallback wake. Carrying the intent in the state instead
75
+ * means whichever instance restores it does the kickoff, which is true for
76
+ * every lifecycle the host might have.
77
+ */
78
+ handoff?: true;
66
79
  }
67
80
 
68
81
  const MAX_PROMPT_LENGTH = 100_000;
@@ -135,6 +148,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
135
148
  const lastFingerprint = optionalText(record.lastFingerprint);
136
149
  if (lastFingerprint === false) return undefined;
137
150
  if (record.expiring !== undefined && record.expiring !== true) return undefined;
151
+ if (record.handoff !== undefined && record.handoff !== true) return undefined;
138
152
  return {
139
153
  id,
140
154
  status: status as LoopStatus,
@@ -154,6 +168,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
154
168
  ...(lastFingerprint === undefined ? {} : { lastFingerprint }),
155
169
  ...(pauseCause === undefined ? {} : { pauseCause }),
156
170
  ...(record.expiring === true ? { expiring: true as const } : {}),
171
+ ...(record.handoff === true ? { handoff: true as const } : {}),
157
172
  };
158
173
  }
159
174
 
package/src/widget.ts CHANGED
@@ -1,15 +1,33 @@
1
1
  /**
2
- * The loop widget: a compact themed line above the editor mirroring the
3
- * footer status (interval · loop turns/cap · next wake), with the loop focus
2
+ * The loop widget: one themed line above the editor, with the loop's focus
4
3
  * dimmed below it when set.
5
4
  *
5
+ * Two rules shape what goes on that line.
6
+ *
7
+ * **Show progress, not consumption.** The line used to lead with the interval
8
+ * and then report turns against the turn cap. The interval is a fallback
9
+ * heartbeat — a settle-paced loop can run its whole life without delivering
10
+ * one — and turns-against-cap is budget burn, which says nothing about how
11
+ * much of the objective is done. Criteria met over criteria total is the
12
+ * progress number, and it leads.
13
+ *
14
+ * **One surface, one story.** The widget and the footer status render the same
15
+ * state, so they render it from the same function. They disagreed before:
16
+ * `setStatus` handled `loop.waiting` and the widget did not, so a loop blocked
17
+ * on CI showed an ordinary "next 17:53" above the editor while the footer said
18
+ * it was waiting.
19
+ *
20
+ * States are ordered by how much they want a human, because the top of that
21
+ * order is the whole reason to glance at the line: paused and blocked and
22
+ * expiring come before the ordinary running line.
23
+ *
6
24
  * Presentation only: every entry point tolerates a host without setWidget
7
25
  * (test fixtures, print mode) and swallows render-side failures, because a
8
26
  * widget must never interrupt loop state transitions.
9
27
  */
10
28
 
11
29
  import { Text } from "@earendil-works/pi-tui";
12
- import { formatClock, formatDuration } from "./interval.js";
30
+ import { formatClock, formatElapsed } from "./interval.js";
13
31
  import type { LoopState } from "./state.js";
14
32
 
15
33
  export const LOOP_WIDGET_KEY = "loop";
@@ -21,27 +39,61 @@ interface WidgetTheme {
21
39
 
22
40
  type WidgetHost = { setWidget?: unknown };
23
41
 
24
- export interface LoopWidgetView {
42
+ /** Criteria progress, absent when the loop has no readable ledger. */
43
+ export interface CriteriaProgress {
44
+ met: number;
45
+ total: number;
46
+ }
47
+
48
+ /** The loop is being drafted with the user and has not started. */
49
+ export interface LoopPlanningView {
50
+ kind: "planning";
51
+ /** Criteria in the proposed draft, once one has been put up for approval. */
52
+ proposedCriteria?: number;
53
+ }
54
+
55
+ export interface LoopRunningView {
56
+ kind: "loop";
25
57
  loop: LoopState;
26
58
  /** A wake is held for the next idle boundary. */
27
59
  wakePending: boolean;
28
60
  /** Epoch ms of the next scheduled tick, when armed. */
29
61
  nextWakeAt: number | undefined;
62
+ criteria?: CriteriaProgress;
63
+ /**
64
+ * How long the session has been busy with no completed turn. Set only past
65
+ * the stall threshold, where a blocking prompt is the likely explanation.
66
+ */
67
+ blockedForMs?: number;
68
+ /** Injected so the elapsed span is deterministic in tests. */
69
+ now?: number;
30
70
  }
31
71
 
72
+ export type LoopWidgetView = LoopPlanningView | LoopRunningView;
73
+
74
+ /** How urgently a line wants a human; picks the colour. */
75
+ type Tone = "normal" | "attention" | "planning";
76
+
32
77
  export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
33
78
  const setWidget = resolveSetWidget(ui);
34
79
  if (!setWidget) return;
35
80
  try {
36
- if (!view || view.loop.status === "stopped") {
81
+ if (!view || (view.kind === "loop" && view.loop.status === "stopped")) {
37
82
  setWidget(LOOP_WIDGET_KEY, undefined);
38
83
  return;
39
84
  }
40
85
  setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
41
86
  const bold = theme.bold ?? identity;
87
+ const paint = (tone: Tone, text: string) => {
88
+ if (tone === "normal") return text;
89
+ return theme.fg?.(tone === "attention" ? "warning" : "accent", text) ?? text;
90
+ };
42
91
  const dim = (text: string) => theme.fg?.("dim", text) ?? text;
43
- const focus = view.loop.prompt ? `\n${dim(` focus: ${view.loop.prompt}`)}` : "";
44
- return new Text(`${bold(loopWidgetLine(view))}${focus}`);
92
+ const focus =
93
+ view.kind === "loop" && view.loop.prompt
94
+ ? `\n${dim(` focus: ${view.loop.prompt}`)}`
95
+ : "";
96
+ return new Text(`${paint(widgetTone(view), bold(loopWidgetLine(view)))}${focus}`);
45
97
  });
46
98
  } catch {
47
99
  // Presentation only; a widget failure must never break a loop transition.
@@ -58,18 +110,56 @@ export function clearLoopWidget(ui: WidgetHost) {
58
110
  }
59
111
  }
60
112
 
61
- export function loopWidgetLine(view: LoopWidgetView) {
113
+ /** Exported for tests: the tone the line renders in. */
114
+ export function widgetTone(view: LoopWidgetView): Tone {
115
+ if (view.kind === "planning") return "planning";
62
116
  const loop = view.loop;
63
- if (loop.status === "paused") return "⏸ loop paused";
117
+ if (loop.status === "paused" || loop.expiring || view.blockedForMs !== undefined) {
118
+ return "attention";
119
+ }
120
+ return "normal";
121
+ }
122
+
123
+ export function loopWidgetLine(view: LoopWidgetView): string {
124
+ if (view.kind === "planning") {
125
+ return view.proposedCriteria === undefined
126
+ ? "◆ loop planning · drafting an objective"
127
+ : `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
128
+ }
129
+ const loop = view.loop;
130
+
131
+ // Ordered by how much the state wants a human. A paused or blocked loop is
132
+ // not making progress, so reporting progress numbers first would bury the
133
+ // only fact that matters.
134
+ if (loop.status === "paused") {
135
+ return `⏸ loop paused${loop.pauseCause ? ` · ${loop.pauseCause}` : ""}`;
136
+ }
137
+ if (loop.expiring) return "⚠ loop expiring · write your state into the ledger";
138
+ if (view.blockedForMs !== undefined) {
139
+ // The engine cannot see the prompt itself: it only knows the session has
140
+ // been busy without completing a turn, which a blocking prompt explains
141
+ // and ordinary long work also explains. Say which one is being reported.
142
+ return `⚠ loop blocked · no turn for ${formatElapsed(view.blockedForMs)} · a prompt may be waiting`;
143
+ }
144
+ if (loop.waiting) {
145
+ const until =
146
+ loop.waiting.resumeAt === undefined
147
+ ? "no deadline"
148
+ : `until ${formatClock(loop.waiting.resumeAt)}`;
149
+ return `⏳ loop waiting · ${loop.waiting.reason} · ${until}`;
150
+ }
151
+
64
152
  const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
65
153
  const next = view.wakePending
66
154
  ? "next on idle"
67
155
  : view.nextWakeAt !== undefined
68
156
  ? `next ${formatClock(view.nextWakeAt)}`
69
157
  : "next unscheduled";
70
- // The turn counter, not the wake counter: the cap counts turns, so a
71
- // progress line against that cap has to count the same thing.
72
- return `⟳ loop every ${formatDuration(loop.intervalMs)} · ${loop.automaticTurns}/${cap} · ${next}`;
158
+ const elapsed = formatElapsed((view.now ?? Date.now()) - loop.startedAt);
159
+ // Progress leads when there is progress to report. Turns are still shown,
160
+ // but as the budget they are, not as the headline.
161
+ const progress = view.criteria ? `${view.criteria.met}/${view.criteria.total} done · ` : "";
162
+ return `⟳ loop ${progress}turn ${loop.automaticTurns}/${cap} · ${elapsed} · ${next}`;
73
163
  }
74
164
 
75
165
  function identity(text: string) {