@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/CHANGELOG.md +26 -0
- package/README.md +35 -32
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +64 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/index.ts +119 -87
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +2 -1
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +120 -25
- package/src/manager.ts +118 -104
- package/src/messages.ts +1 -1
- package/src/objective.ts +25 -0
- package/src/planning.ts +67 -23
- package/src/presentation.ts +55 -18
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +35 -14
- package/src/settings.ts +27 -22
- package/src/state.ts +33 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/manager.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
loop.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
134
|
+
controller.updateWidget();
|
|
135
|
+
ctx.ui.notify(prompt ? "Loop focus updated." : "Loop focus cleared.", "info");
|
|
136
|
+
return true;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
|
|
139
|
+
function setCadence(
|
|
126
140
|
controller: LoopController,
|
|
127
141
|
ctx: ExtensionCommandContext,
|
|
128
|
-
|
|
142
|
+
value: string,
|
|
143
|
+
): boolean {
|
|
129
144
|
const loop = controller.state;
|
|
130
|
-
if (!loop || loop.status === "stopped") return;
|
|
131
|
-
const
|
|
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: ${
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* than routing back through a tool
|
|
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
|
|
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`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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:
|
|
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
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"- Name the check in the requirement itself ('…, verified by npm test passing')
|
|
95
|
-
"- The
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
// request for a loop pattern-matches
|
|
101
|
-
// answers by telling the user to type
|
|
102
|
-
// planning exists to remove. Observed live in a
|
|
103
|
-
|
|
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.",
|
package/src/presentation.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The approval card, as a
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
package/src/progress-tool.ts
CHANGED
|
@@ -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.
|
|
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;
|