@hank-warren/pi-loop 0.9.0 → 1.1.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/manager.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Bare-/loop manager and /loop settings, built on Pi's native dialog
3
- * primitives (ui.select / ui.input / ui.confirm). Non-TUI modes get status
4
- * notifications instead of menus.
2
+ * The `/loop` surfaces, wired to the controller: the manager for a live loop,
3
+ * the approval card's actions, and the settings editor.
4
+ *
5
+ * The top-level menus are tui-kit screens (see `loop-manager-menu.ts` and
6
+ * `loop-launch-menu.ts`), so the family renders the same way pi-plan-mode
7
+ * does. The settings editor keeps its `ui.select`/`ui.input` internals: it is
8
+ * a value editor, not a navigation surface, and rewriting it would change
9
+ * nothing a user can see. Non-TUI modes get status notifications instead of
10
+ * menus, exactly as before.
5
11
  */
6
12
 
7
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
14
  import { formatDuration, parseInterval } from "./interval.js";
9
15
  import type { LoopController } from "./loop.js";
10
16
  import {
@@ -17,134 +23,141 @@ import { parseDuration } from "./interval.js";
17
23
  import type { LoopStartArguments } from "./command.js";
18
24
  import { startLoopInFreshSession } from "./fresh-launch.js";
19
25
  import { showLoopApprovalMenu } from "./loop-action-menus.js";
26
+ import { type LoopManagerView, showLoopManagerMenu } from "./loop-manager-menu.js";
27
+ import { showLoopLaunchMenu, showLoopPlanningMenu } from "./loop-launch-menu.js";
20
28
  import type { LoopProposal } from "./planning.js";
29
+ import { loopWidgetLine } from "./widget.js";
21
30
 
22
- export async function showLoopManager(
31
+ /** The launch menu: no loop, no draft, nothing planning. */
32
+ export async function showLoopLaunch(
23
33
  controller: LoopController,
24
34
  ctx: ExtensionCommandContext,
35
+ startPlanning: () => void,
25
36
  ): Promise<void> {
26
37
  if (ctx.mode !== "tui") {
27
- notifyStatus(controller, ctx);
38
+ ctx.ui.notify(
39
+ "No loop in this session. The interactive /loop menu is unavailable in print and JSON modes.",
40
+ "info",
41
+ );
28
42
  return;
29
43
  }
30
- for (;;) {
31
- const loop = controller.state;
32
- const options: string[] = ["Status"];
33
- if (loop?.status === "active") options.push("Pause", "Edit focus", "Edit interval", "Stop");
34
- else if (loop?.status === "paused") options.push("Resume", "Edit focus", "Edit interval", "Stop");
35
- else options.push("Start a loop…");
36
- options.push("Settings");
37
- const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
38
- if (choice === undefined) return;
39
- switch (choice) {
40
- case "Status":
41
- notifyStatus(controller, ctx);
42
- break;
43
- case "Pause":
44
- controller.pauseLoop(ctx);
45
- break;
46
- case "Resume":
47
- controller.resumeLoop(ctx);
48
- break;
49
- case "Stop":
50
- controller.stopLoop(ctx);
51
- break;
52
- case "Start a loop…":
53
- await startFromMenu(controller, ctx);
54
- break;
55
- case "Edit focus":
56
- await editPrompt(controller, ctx);
57
- break;
58
- case "Edit interval":
59
- await editInterval(controller, ctx);
60
- break;
61
- case "Settings":
62
- await showLoopSettings(controller, ctx);
63
- break;
64
- default:
65
- return;
66
- }
67
- }
68
- }
69
-
70
- function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
71
- ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
44
+ await showLoopLaunchMenu(ctx, {
45
+ startPlanning,
46
+ settings: async () => {
47
+ await showLoopSettings(controller, ctx);
48
+ },
49
+ });
72
50
  }
73
51
 
74
- async function startFromMenu(
52
+ /** The planning menu: a drafting conversation is open with no draft yet. */
53
+ export async function showLoopPlanning(
75
54
  controller: LoopController,
76
55
  ctx: ExtensionCommandContext,
56
+ options: { requestProposal: () => void },
77
57
  ): Promise<void> {
78
- const intervalText = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", "5m");
79
- if (intervalText === undefined) return;
80
- const interval = parseInterval(intervalText.trim() || "5m");
81
- if (!interval) {
82
- ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
58
+ if (ctx.mode !== "tui") {
59
+ ctx.ui.notify(
60
+ "Loop planning is open: describe the objective, and the agent will put a loop up for approval.",
61
+ "info",
62
+ );
83
63
  return;
84
64
  }
85
- // The loop owns its objective, so the text is required — asking for it here
86
- // is what replaces the old dead-end refusal.
87
- const promptText = await ctx.ui.input(
88
- "Objective, including how the loop knows it is done",
89
- "e.g. get CI green on main, verified by a passing run",
90
- );
91
- if (promptText === undefined) return;
92
- const prompt = promptText.trim();
93
- if (!prompt) {
94
- ctx.ui.notify("A loop needs an objective, so no loop was started.", "warning");
65
+ await showLoopPlanningMenu(ctx, {
66
+ requestProposal: options.requestProposal,
67
+ cancelPlanning: () => {
68
+ controller.endPlanning();
69
+ ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
70
+ },
71
+ settings: async () => {
72
+ await showLoopSettings(controller, ctx);
73
+ },
74
+ });
75
+ }
76
+
77
+ export async function showLoopManager(
78
+ controller: LoopController,
79
+ ctx: ExtensionCommandContext,
80
+ ): Promise<void> {
81
+ if (ctx.mode !== "tui") {
82
+ notifyStatus(controller, ctx);
95
83
  return;
96
84
  }
97
- const result = controller.startLoop(ctx, {
98
- kind: "start",
99
- requestedMs: interval.requestedMs,
100
- intervalMs: interval.effectiveMs,
101
- clamped: interval.clamped,
102
- ...(prompt ? { prompt } : {}),
85
+ await showLoopManagerMenu(ctx, {
86
+ getView: () => managerView(controller, ctx),
87
+ pause: () => controller.pauseLoop(ctx),
88
+ resume: () => controller.resumeLoop(ctx),
89
+ stop: () => controller.stopLoop(ctx),
90
+ settings: async () => {
91
+ await showLoopSettings(controller, ctx);
92
+ },
93
+ setFocus: (value) => setFocus(controller, ctx, value),
94
+ setCadence: (value) => setCadence(controller, ctx, value),
103
95
  });
104
- if (!result.ok) ctx.ui.notify(result.message, "error");
105
96
  }
106
97
 
107
- async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
98
+ /** The live loop as the manager needs it, or undefined once it has stopped. */
99
+ function managerView(
100
+ controller: LoopController,
101
+ ctx: ExtensionContext,
102
+ ): LoopManagerView | undefined {
108
103
  const loop = controller.state;
109
- if (!loop || loop.status === "stopped") return;
110
- const next = await ctx.ui.input(
111
- "Loop focus (optional, restated on every loop message)",
112
- loop.prompt ?? "",
113
- );
114
- if (next === undefined) return;
115
- const prompt = next.trim();
104
+ if (!loop || loop.status === "stopped") return undefined;
105
+ const view = controller.widgetView();
106
+ return {
107
+ status: loop.status,
108
+ headline: view ? loopWidgetLine(view) : `loop ${loop.status}`,
109
+ statusLines: controller.statusLines(ctx),
110
+ ...(loop.prompt ? { focus: loop.prompt } : {}),
111
+ interval: formatDuration(loop.intervalMs),
112
+ };
113
+ }
114
+
115
+ function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
116
+ ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
117
+ }
118
+
119
+ /** An empty value clears the focus; anything else replaces it. */
120
+ function setFocus(
121
+ controller: LoopController,
122
+ ctx: ExtensionCommandContext,
123
+ value: string,
124
+ ): boolean {
125
+ const loop = controller.state;
126
+ if (!loop || loop.status === "stopped") return false;
127
+ const prompt = value.trim();
116
128
  if (prompt) controller.state = { ...loop, prompt };
117
129
  else {
118
130
  const { prompt: _dropped, ...rest } = loop;
119
131
  controller.state = rest;
120
132
  }
121
133
  controller.persist();
122
- ctx.ui.notify("Loop focus updated.", "info");
134
+ controller.updateWidget();
135
+ ctx.ui.notify(prompt ? "Loop focus updated." : "Loop focus cleared.", "info");
136
+ return true;
123
137
  }
124
138
 
125
- async function editInterval(
139
+ function setCadence(
126
140
  controller: LoopController,
127
141
  ctx: ExtensionCommandContext,
128
- ): Promise<void> {
142
+ value: string,
143
+ ): boolean {
129
144
  const loop = controller.state;
130
- if (!loop || loop.status === "stopped") return;
131
- const next = await ctx.ui.input("Wake interval (e.g. 5m, 2h)", formatDuration(loop.intervalMs));
132
- if (next === undefined) return;
133
- const interval = parseInterval(next.trim());
145
+ if (!loop || loop.status === "stopped") return false;
146
+ const interval = parseInterval(value.trim());
134
147
  if (!interval) {
135
- ctx.ui.notify(`Invalid interval: ${next}. Use <number><unit>, e.g. 5m.`, "error");
136
- return;
148
+ ctx.ui.notify(`Invalid interval: ${value}. Use <number><unit>, e.g. 5m.`, "error");
149
+ return false;
137
150
  }
138
151
  controller.state = { ...loop, intervalMs: interval.effectiveMs };
139
152
  controller.persist();
140
- if (loop.status === "active") {
141
- // Re-arm on the new cadence from now.
142
- controller.resumeAfterEdit();
143
- }
153
+ // Re-arm on the new cadence from now.
154
+ if (loop.status === "active") controller.resumeAfterEdit();
155
+ else controller.updateWidget();
144
156
  ctx.ui.notify(
145
157
  `Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
146
158
  "info",
147
159
  );
160
+ return true;
148
161
  }
149
162
 
150
163
  export async function showLoopSettings(
@@ -169,7 +182,8 @@ export async function showLoopSettings(
169
182
  const next = structuredClone(s);
170
183
  if (index === 0) {
171
184
  // Unlimited is a first-class choice, not a magic word typed into a free
172
- // text box: it is only reachable by discovery otherwise.
185
+ // text box: it is only reachable by discovery otherwise. It is also the
186
+ // default, so this editor is where a user opts *into* a budget.
173
187
  const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
174
188
  if (cap === undefined) continue;
175
189
  next.maxTurns = cap === "unlimited" ? null : cap;
@@ -217,8 +231,7 @@ export async function showLoopSettings(
217
231
  /**
218
232
  * One cap editor for every cap. Unlimited is a first-class choice, not a
219
233
  * magic word typed into a free text box: it is only reachable by discovery
220
- * otherwise. The typed word still works, so the /loop --max vocabulary and
221
- * muscle memory keep working.
234
+ * otherwise. The typed word still works too.
222
235
  */
223
236
  async function editCap(
224
237
  ctx: ExtensionCommandContext,
@@ -275,11 +288,11 @@ function applySettings(
275
288
  * The approval card and its actions.
276
289
  *
277
290
  * This is where a planned loop starts, and the approval is what authorises it.
278
- * `loop_start`'s gate exists because a loop is self-continuing and must never
279
- * begin on model initiative; an explicit choice here, on a card showing the
280
- * objective, the derived criteria, the cadence and the caps, is stronger
281
- * evidence of intent than a typed token, so it starts the loop directly rather
282
- * than routing back through a tool the model could reach on its own.
291
+ * A loop is self-continuing and must never begin on model initiative; an
292
+ * explicit choice here, on a card showing the objective, the derived criteria,
293
+ * the ground rules, the cadence and the caps, is the strongest evidence of
294
+ * intent there is — stronger than any token the model could also emit so it
295
+ * starts the loop directly rather than routing back through a tool.
283
296
  */
284
297
  export async function showLoopApproval(
285
298
  controller: LoopController,
@@ -292,10 +305,7 @@ export async function showLoopApproval(
292
305
  // this only renders one when the user reached the approval some other way.
293
306
  controller.showProposalCard(ctx);
294
307
  if (ctx.mode !== "tui") {
295
- ctx.ui.notify(
296
- "Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
297
- "info",
298
- );
308
+ ctx.ui.notify("Approve it from a TUI session; a loop cannot be started headless.", "info");
299
309
  return;
300
310
  }
301
311
  await showLoopApprovalMenu(ctx, {
@@ -331,6 +341,9 @@ function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
331
341
  maxTurns: proposal.maxTurns,
332
342
  expiresInMs: proposal.expiresInMs,
333
343
  prompt: proposal.objective,
344
+ // The approved constraints cross with the objective; they are part of what
345
+ // the user said yes to.
346
+ ...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
334
347
  };
335
348
  }
336
349
 
@@ -389,6 +402,7 @@ async function changeCadence(
389
402
  intervalMs: interval.effectiveMs,
390
403
  maxTurns: proposal.maxTurns,
391
404
  expiresInMs: proposal.expiresInMs,
405
+ ...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
392
406
  });
393
407
  await showLoopApproval(controller, ctx);
394
408
  }
package/src/messages.ts CHANGED
@@ -20,7 +20,7 @@ export type ContinuationKind = "kickoff" | "continue" | "reanchor";
20
20
  * counter against the delivered-wake cap; that cap is gone, collapsed into
21
21
  * the single loop-turn cap, and pairing a wake number with a turn cap would
22
22
  * have been a number that reads as a budget and is not one. The cap is shown
23
- * to the *user*, in the widget and `/loop status`, which is who it is for.
23
+ * to the *user*, in the widget and the /loop status screen, which is who it is for.
24
24
  */
25
25
  function formatWakeOrdinal(loop: LoopState): string {
26
26
  return `${loop.iteration + 1}`;
package/src/objective.ts CHANGED
@@ -32,6 +32,7 @@ export function buildLoopObjectivePrompt(
32
32
  "<loop_objective>",
33
33
  escapeXmlText(loop.objective),
34
34
  "</loop_objective>",
35
+ ...groundRuleLines(loop),
35
36
  `<loop_id>\n${escapeXmlText(loop.id)}\n</loop_id>`,
36
37
  "This loop_id is only the loop_complete tool's stale-loop guard, not part of the objective.",
37
38
  "",
@@ -73,6 +74,30 @@ export function buildLoopObjectivePrompt(
73
74
  .trimEnd();
74
75
  }
75
76
 
77
+ /**
78
+ * The approved ground rules, as a block the model cannot mistake for the
79
+ * objective.
80
+ *
81
+ * They sit next to the objective rather than inside it because they are a
82
+ * different kind of thing: the objective is what the loop is trying to reach
83
+ * and what `loop_complete` answers for, while these bound how it may get
84
+ * there. Folding them into the objective would make them criteria, and a
85
+ * constraint that has to be "met" is a constraint nobody can satisfy.
86
+ *
87
+ * Approved by the user on the card, so unlike the objective they are not
88
+ * merely task data to consider — they outrank the loop's own judgement about
89
+ * what is expedient at 3am on turn 200.
90
+ */
91
+ function groundRuleLines(loop: LoopState): string[] {
92
+ if (!loop.groundRules || loop.groundRules.length === 0) return [];
93
+ return [
94
+ "",
95
+ "Ground rules (hard constraints, never violate):",
96
+ ...loop.groundRules.map((rule) => `- ${escapeXmlText(rule)}`),
97
+ "The user approved these with the objective. They bound how the work may be done, they are never satisfied or completed, and no amount of progress justifies breaking one. If the only way forward violates a ground rule, stop and call loop_wait.",
98
+ ];
99
+ }
100
+
76
101
  /**
77
102
  * The ledger contract. Stable per loop (the path is derived from the loop
78
103
  * id), so it keeps the append byte-identical across turns.
package/src/planning.ts CHANGED
@@ -7,26 +7,38 @@
7
7
  * it becomes the acceptance gate — and the moment it is decided is a
8
8
  * conversation, not a typed command.
9
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.
10
+ * So `/loop` opens a menu whose first item is a drafting conversation, and the
11
+ * loop starts from an approval card that shows the exact criteria the split
12
+ * will produce. The card is the design language: because the cadence, the
13
+ * caps and the ground rules are on it and editable there, no command grammar
14
+ * has to carry them. A concept removed rather than a knob added.
17
15
  *
18
- * The typed form (`/loop 30m <objective>`) is untouched, as is inline `loop:`
19
- * invocation. Planning is the front door, not the only door.
16
+ * Planning is now the only door. The typed start and the inline token are
17
+ * gone: both authored an acceptance gate in one line, unreviewed.
20
18
  */
21
19
 
22
20
  import { deriveCriteria, type LoopCriterion } from "./ledger.js";
23
21
  import { formatDuration } from "./interval.js";
24
22
 
23
+ /** Bounds on drafted ground rules: enough for real constraints, not a manifesto. */
24
+ export const MAX_GROUND_RULES = 10;
25
+ export const MAX_GROUND_RULE_LENGTH = 500;
26
+
25
27
  /** A drafted loop, put up for approval and not yet started. */
26
28
  export interface LoopProposal {
27
29
  objective: string;
28
30
  /** Exactly what `deriveCriteria` will produce, computed here so the card cannot lie. */
29
31
  criteria: LoopCriterion[];
32
+ /**
33
+ * Hard constraints the loop must never violate.
34
+ *
35
+ * Constraints, not criteria: they never enter `criteria.json` and never
36
+ * gate completion. A loop is unattended, so the useful thing to fix in
37
+ * advance is not only what done looks like but what it must not do on the
38
+ * way there — don't touch prod, don't force-push, don't rewrite the fixture
39
+ * to make the test pass.
40
+ */
41
+ groundRules?: string[];
30
42
  intervalMs: number;
31
43
  maxTurns: number | null;
32
44
  expiresInMs: number;
@@ -50,17 +62,26 @@ export interface LoopPlanningState {
50
62
  cardShownAt?: number;
51
63
  }
52
64
 
65
+ export interface LoopProposalOverrides {
66
+ intervalMs?: number;
67
+ maxTurns?: number | null;
68
+ expiresInMs?: number;
69
+ groundRules?: string[];
70
+ }
71
+
53
72
  export function buildProposal(
54
73
  objective: string,
55
74
  defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
56
75
  now: number,
57
- overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
76
+ overrides: LoopProposalOverrides = {},
58
77
  ): LoopProposal {
78
+ const groundRules = normalizeGroundRules(overrides.groundRules);
59
79
  return {
60
80
  objective: objective.trim(),
61
81
  // Derived, never authored: the card has to show the criteria the engine
62
82
  // will actually freeze, or approving it means approving something else.
63
83
  criteria: deriveCriteria(objective),
84
+ ...(groundRules ? { groundRules } : {}),
64
85
  intervalMs: overrides.intervalMs ?? defaults.intervalMs,
65
86
  maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
66
87
  expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
@@ -68,6 +89,21 @@ export function buildProposal(
68
89
  };
69
90
  }
70
91
 
92
+ /**
93
+ * Trim, drop the empties, and bound a drafted ground-rule list. Returns
94
+ * undefined when nothing survives, so an empty array never becomes an empty
95
+ * section on the card or an empty block in the system append.
96
+ */
97
+ export function normalizeGroundRules(rules: readonly string[] | undefined): string[] | undefined {
98
+ if (!rules) return undefined;
99
+ const cleaned = rules
100
+ .map((rule) => rule.trim())
101
+ .filter((rule) => rule.length > 0)
102
+ .slice(0, MAX_GROUND_RULES)
103
+ .map((rule) => (rule.length > MAX_GROUND_RULE_LENGTH ? rule.slice(0, MAX_GROUND_RULE_LENGTH) : rule));
104
+ return cleaned.length > 0 ? cleaned : undefined;
105
+ }
106
+
71
107
  /** The approval card, as transcript lines. */
72
108
  export function renderProposalCard(proposal: LoopProposal): string[] {
73
109
  return [
@@ -79,6 +115,13 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
79
115
  `**Criteria the gate will hold you to** (${proposal.criteria.length})`,
80
116
  ...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
81
117
  "",
118
+ ...(proposal.groundRules
119
+ ? [
120
+ `**Ground rules the loop must never violate** (${proposal.groundRules.length})`,
121
+ ...proposal.groundRules.map((rule) => `- ${rule}`),
122
+ "",
123
+ ]
124
+ : []),
82
125
  `**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
83
126
  `**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
84
127
  "",
@@ -88,19 +131,20 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
88
131
 
89
132
  export const LOOP_PLANNING_HINT = [
90
133
  "<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.",
134
+ "The user opened loop planning. You are drafting a loop with them; no loop is running and none starts until they approve one on the card.",
135
+ "Read the pi-loop skill before drafting if it is available: it carries the objective, criteria, cadence and evidence craft in depth.",
136
+ "Cover three things in the conversation, then call loop_propose:",
137
+ "- The objective, written as an acceptance test. One requirement per 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. Name the check in the requirement itself ('…, verified by npm test passing'). The two questions that fix most objectives: how will we know it is done, and what command proves it?",
138
+ "- The cadence: how long the loop may run before it expires, and the fallback heartbeat for a session that goes quiet. The loop advances whenever the session settles, so the heartbeat only matters when it is waiting on something.",
139
+ "- The ground rules: hard constraints it must never violate while unattended, such as which systems are off limits, what must never be force-pushed or deleted, and which files may not be edited to make a check pass. Ask for them; a loop runs with nobody watching, so an unstated constraint is one nobody enforces.",
140
+ "Ground rules are constraints, not criteria. They never gate completion — they bound how the work may be done.",
141
+ "When the draft is ready, call loop_propose with the objective and any ground rules. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
142
+ // Without this the model reaches for a prohibition instead. A conversational
143
+ // request for a loop pattern-matches onto 'do not start loops on your own',
144
+ // and the model answers by telling the user to type a command, which is
145
+ // precisely the dead end planning exists to remove. Observed live in a
146
+ // canary session.
147
+ "loop_propose starts nothing, so no rule against starting a loop on your own applies 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 run a command instead — drafting a proposal for them is the whole point of this mode.",
104
148
  "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
149
  "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
150
  "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.",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The approval card, as a framed transcript block.
2
+ * The approval card, as a display-only session entry.
3
3
  *
4
4
  * It used to go out twice and neither copy was a card: `loop_propose`
5
5
  * returned it as tool-result text, and `/loop` re-printed it through
@@ -8,18 +8,59 @@
8
8
  * artifact the whole planning flow exists to produce was the least legible
9
9
  * thing on the screen, and duplicated.
10
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.
11
+ * It is now a custom *entry* with a registered renderer, not a message. That
12
+ * is what buys the property a message could not: Pi maps a `custom` entry to
13
+ * no context messages at all and skips it during compaction, so the card stays
14
+ * visible and restorable in the transcript while never entering model context
15
+ * and never costing a compaction budget. The model is told a proposal exists
16
+ * by the tool result; it never re-reads the rendered card.
17
+ *
18
+ * pi-plan-mode's completed-plan card is the same mechanism for the same
19
+ * reason (`packages/pi-plan-mode/src/presentation.ts`), and `plan_mode_complete`
20
+ * likewise returns a one-line `Plan saved to <path>.` pointer instead of the
21
+ * plan body.
17
22
  */
18
23
 
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ getMarkdownTheme,
26
+ type ExtensionAPI,
27
+ type ExtensionContext,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { Markdown, Text } from "@earendil-works/pi-tui";
20
30
  import { type LoopProposal, renderProposalCard } from "./planning.js";
21
31
 
22
- export const LOOP_PROPOSAL_MESSAGE_TYPE = "loop-proposal";
32
+ /**
33
+ * The card is a custom *entry*, not a custom message, and it carries a new
34
+ * type name to say so. The old `LOOP_PROPOSAL_MESSAGE_TYPE` export is gone
35
+ * rather than aliased: an alias would keep consumers compiling while silently
36
+ * pointing them at a channel proposals no longer travel on, which is worse
37
+ * than the compile error that tells them to look.
38
+ */
39
+ export const LOOP_PROPOSAL_ENTRY_TYPE = "loop-proposal-card";
40
+
41
+ type LoopProposalCardData = { markdown: string; criteria: number; proposedAt: number };
42
+
43
+ /**
44
+ * Persisted entry data is input, not a guarantee.
45
+ *
46
+ * The renderer runs against whatever is on disk, which may predate a field, be
47
+ * truncated by a partial write, or have been hand-edited. Pi contains a
48
+ * renderer throw as an inline `[loop-proposal-card] renderer failed: …` box —
49
+ * survivable, but a needlessly ugly way to say "this card is old".
50
+ */
51
+ function loopProposalCardData(value: unknown): LoopProposalCardData | undefined {
52
+ if (typeof value !== "object" || value === null) return undefined;
53
+ const { markdown } = value as { markdown?: unknown };
54
+ return typeof markdown === "string" ? (value as LoopProposalCardData) : undefined;
55
+ }
56
+
57
+ export function registerLoopProposalRenderer(pi: ExtensionAPI): void {
58
+ pi.registerEntryRenderer(LOOP_PROPOSAL_ENTRY_TYPE, (entry) => {
59
+ const data = loopProposalCardData(entry.data);
60
+ if (!data) return new Text("Loop proposal card unavailable.", 0, 0);
61
+ return new Markdown(data.markdown, 0, 0, getMarkdownTheme());
62
+ });
63
+ }
23
64
 
24
65
  /**
25
66
  * Emit the card. Returns false when Pi refused it, in which case the caller
@@ -32,15 +73,11 @@ export function showLoopProposalCard(
32
73
  proposal: LoopProposal,
33
74
  ): boolean {
34
75
  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
- );
76
+ pi.appendEntry<LoopProposalCardData>(LOOP_PROPOSAL_ENTRY_TYPE, {
77
+ markdown: renderProposalCard(proposal).join("\n"),
78
+ criteria: proposal.criteria.length,
79
+ proposedAt: proposal.proposedAt,
80
+ });
44
81
  return true;
45
82
  } catch (error) {
46
83
  const detail = error instanceof Error ? error.message : String(error);
@@ -87,7 +87,7 @@ export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopContr
87
87
  const loop = controller.state;
88
88
  if (!loop || loop.objective === undefined) {
89
89
  return failure(
90
- "No /loop with an objective is active, so there is no ledger to write. Start one with /loop <interval> <objective>.",
90
+ "No /loop with an objective is active, so there is no ledger to write. Run /loop to plan and approve one.",
91
91
  );
92
92
  }
93
93
  const paths = controller.ledger;