@hank-warren/pi-loop 0.8.0 → 1.0.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 +32 -0
- package/README.md +52 -32
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +73 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +130 -0
- package/src/loop-env.ts +50 -0
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +156 -29
- package/src/manager.ts +213 -147
- package/src/messages.ts +1 -1
- package/src/objective.ts +38 -1
- package/src/planning.ts +78 -24
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +42 -15
- package/src/settings.ts +27 -22
- package/src/state.ts +43 -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 {
|
|
@@ -14,134 +20,144 @@ import {
|
|
|
14
20
|
saveLoopSettings,
|
|
15
21
|
} from "./settings.js";
|
|
16
22
|
import { parseDuration } from "./interval.js";
|
|
17
|
-
import {
|
|
23
|
+
import type { LoopStartArguments } from "./command.js";
|
|
24
|
+
import { startLoopInFreshSession } from "./fresh-launch.js";
|
|
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";
|
|
28
|
+
import type { LoopProposal } from "./planning.js";
|
|
29
|
+
import { loopWidgetLine } from "./widget.js";
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
/** The launch menu: no loop, no draft, nothing planning. */
|
|
32
|
+
export async function showLoopLaunch(
|
|
20
33
|
controller: LoopController,
|
|
21
34
|
ctx: ExtensionCommandContext,
|
|
35
|
+
startPlanning: () => void,
|
|
22
36
|
): Promise<void> {
|
|
23
37
|
if (ctx.mode !== "tui") {
|
|
24
|
-
|
|
38
|
+
ctx.ui.notify(
|
|
39
|
+
"No loop in this session. The interactive /loop menu is unavailable in print and JSON modes.",
|
|
40
|
+
"info",
|
|
41
|
+
);
|
|
25
42
|
return;
|
|
26
43
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
options.push("Settings");
|
|
34
|
-
const choice = await ctx.ui.select(`Pi Loop${loop ? ` · ${loop.status}` : ""}`, options);
|
|
35
|
-
if (choice === undefined) return;
|
|
36
|
-
switch (choice) {
|
|
37
|
-
case "Status":
|
|
38
|
-
notifyStatus(controller, ctx);
|
|
39
|
-
break;
|
|
40
|
-
case "Pause":
|
|
41
|
-
controller.pauseLoop(ctx);
|
|
42
|
-
break;
|
|
43
|
-
case "Resume":
|
|
44
|
-
controller.resumeLoop(ctx);
|
|
45
|
-
break;
|
|
46
|
-
case "Stop":
|
|
47
|
-
controller.stopLoop(ctx);
|
|
48
|
-
break;
|
|
49
|
-
case "Start a loop…":
|
|
50
|
-
await startFromMenu(controller, ctx);
|
|
51
|
-
break;
|
|
52
|
-
case "Edit focus":
|
|
53
|
-
await editPrompt(controller, ctx);
|
|
54
|
-
break;
|
|
55
|
-
case "Edit interval":
|
|
56
|
-
await editInterval(controller, ctx);
|
|
57
|
-
break;
|
|
58
|
-
case "Settings":
|
|
59
|
-
await showLoopSettings(controller, ctx);
|
|
60
|
-
break;
|
|
61
|
-
default:
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function notifyStatus(controller: LoopController, ctx: ExtensionCommandContext): void {
|
|
68
|
-
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
|
+
});
|
|
69
50
|
}
|
|
70
51
|
|
|
71
|
-
|
|
52
|
+
/** The planning menu: a drafting conversation is open with no draft yet. */
|
|
53
|
+
export async function showLoopPlanning(
|
|
72
54
|
controller: LoopController,
|
|
73
55
|
ctx: ExtensionCommandContext,
|
|
56
|
+
options: { requestProposal: () => void },
|
|
74
57
|
): Promise<void> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
);
|
|
80
63
|
return;
|
|
81
64
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
92
83
|
return;
|
|
93
84
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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),
|
|
100
95
|
});
|
|
101
|
-
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
102
96
|
}
|
|
103
97
|
|
|
104
|
-
|
|
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 {
|
|
105
103
|
const loop = controller.state;
|
|
106
|
-
if (!loop || loop.status === "stopped") return;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
loop.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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();
|
|
113
128
|
if (prompt) controller.state = { ...loop, prompt };
|
|
114
129
|
else {
|
|
115
130
|
const { prompt: _dropped, ...rest } = loop;
|
|
116
131
|
controller.state = rest;
|
|
117
132
|
}
|
|
118
133
|
controller.persist();
|
|
119
|
-
|
|
134
|
+
controller.updateWidget();
|
|
135
|
+
ctx.ui.notify(prompt ? "Loop focus updated." : "Loop focus cleared.", "info");
|
|
136
|
+
return true;
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
|
|
139
|
+
function setCadence(
|
|
123
140
|
controller: LoopController,
|
|
124
141
|
ctx: ExtensionCommandContext,
|
|
125
|
-
|
|
142
|
+
value: string,
|
|
143
|
+
): boolean {
|
|
126
144
|
const loop = controller.state;
|
|
127
|
-
if (!loop || loop.status === "stopped") return;
|
|
128
|
-
const
|
|
129
|
-
if (next === undefined) return;
|
|
130
|
-
const interval = parseInterval(next.trim());
|
|
145
|
+
if (!loop || loop.status === "stopped") return false;
|
|
146
|
+
const interval = parseInterval(value.trim());
|
|
131
147
|
if (!interval) {
|
|
132
|
-
ctx.ui.notify(`Invalid interval: ${
|
|
133
|
-
return;
|
|
148
|
+
ctx.ui.notify(`Invalid interval: ${value}. Use <number><unit>, e.g. 5m.`, "error");
|
|
149
|
+
return false;
|
|
134
150
|
}
|
|
135
151
|
controller.state = { ...loop, intervalMs: interval.effectiveMs };
|
|
136
152
|
controller.persist();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
153
|
+
// Re-arm on the new cadence from now.
|
|
154
|
+
if (loop.status === "active") controller.resumeAfterEdit();
|
|
155
|
+
else controller.updateWidget();
|
|
141
156
|
ctx.ui.notify(
|
|
142
157
|
`Loop interval set to ${formatDuration(interval.effectiveMs)}${interval.clamped ? " (clamped to the minimum)" : ""}.`,
|
|
143
158
|
"info",
|
|
144
159
|
);
|
|
160
|
+
return true;
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
export async function showLoopSettings(
|
|
@@ -166,7 +182,8 @@ export async function showLoopSettings(
|
|
|
166
182
|
const next = structuredClone(s);
|
|
167
183
|
if (index === 0) {
|
|
168
184
|
// Unlimited is a first-class choice, not a magic word typed into a free
|
|
169
|
-
// 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.
|
|
170
187
|
const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
|
|
171
188
|
if (cap === undefined) continue;
|
|
172
189
|
next.maxTurns = cap === "unlimited" ? null : cap;
|
|
@@ -214,8 +231,7 @@ export async function showLoopSettings(
|
|
|
214
231
|
/**
|
|
215
232
|
* One cap editor for every cap. Unlimited is a first-class choice, not a
|
|
216
233
|
* magic word typed into a free text box: it is only reachable by discovery
|
|
217
|
-
* otherwise. The typed word still works
|
|
218
|
-
* muscle memory keep working.
|
|
234
|
+
* otherwise. The typed word still works too.
|
|
219
235
|
*/
|
|
220
236
|
async function editCap(
|
|
221
237
|
ctx: ExtensionCommandContext,
|
|
@@ -272,11 +288,11 @@ function applySettings(
|
|
|
272
288
|
* The approval card and its actions.
|
|
273
289
|
*
|
|
274
290
|
* This is where a planned loop starts, and the approval is what authorises it.
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* 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.
|
|
280
296
|
*/
|
|
281
297
|
export async function showLoopApproval(
|
|
282
298
|
controller: LoopController,
|
|
@@ -284,59 +300,109 @@ export async function showLoopApproval(
|
|
|
284
300
|
): Promise<void> {
|
|
285
301
|
const proposal = controller.planning.proposal;
|
|
286
302
|
if (!proposal) return;
|
|
287
|
-
|
|
303
|
+
// The card is an artifact, emitted once per draft; the menu below is the
|
|
304
|
+
// dialog over it. A draft proposed by loop_propose already has its card, so
|
|
305
|
+
// this only renders one when the user reached the approval some other way.
|
|
306
|
+
controller.showProposalCard(ctx);
|
|
288
307
|
if (ctx.mode !== "tui") {
|
|
289
|
-
ctx.ui.notify(
|
|
290
|
-
"Approve it from a TUI session, or start it directly with /loop <interval> <objective>.",
|
|
291
|
-
"info",
|
|
292
|
-
);
|
|
308
|
+
ctx.ui.notify("Approve it from a TUI session; a loop cannot be started headless.", "info");
|
|
293
309
|
return;
|
|
294
310
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"Cancel",
|
|
300
|
-
]);
|
|
301
|
-
switch (choice) {
|
|
302
|
-
case "Start loop": {
|
|
303
|
-
const result = controller.startLoop(ctx, {
|
|
304
|
-
kind: "start",
|
|
305
|
-
requestedMs: proposal.intervalMs,
|
|
306
|
-
intervalMs: proposal.intervalMs,
|
|
307
|
-
clamped: false,
|
|
308
|
-
maxTurns: proposal.maxTurns,
|
|
309
|
-
expiresInMs: proposal.expiresInMs,
|
|
310
|
-
prompt: proposal.objective,
|
|
311
|
-
});
|
|
311
|
+
await showLoopApprovalMenu(ctx, {
|
|
312
|
+
proposal,
|
|
313
|
+
startHere: () => {
|
|
314
|
+
const result = controller.startLoop(ctx, startArgumentsFor(proposal));
|
|
312
315
|
if (result.ok) controller.endPlanning();
|
|
313
316
|
else ctx.ui.notify(result.message, "error");
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const interval = parseInterval(text.trim());
|
|
323
|
-
if (!interval) {
|
|
324
|
-
ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
controller.propose(proposal.objective, {
|
|
328
|
-
intervalMs: interval.effectiveMs,
|
|
329
|
-
maxTurns: proposal.maxTurns,
|
|
330
|
-
expiresInMs: proposal.expiresInMs,
|
|
331
|
-
});
|
|
332
|
-
await showLoopApproval(controller, ctx);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
case "Keep editing":
|
|
317
|
+
},
|
|
318
|
+
startFresh: async () => {
|
|
319
|
+
await startApprovedLoopFresh(controller, ctx, proposal);
|
|
320
|
+
},
|
|
321
|
+
changeCadence: async () => {
|
|
322
|
+
await changeCadence(controller, ctx, proposal);
|
|
323
|
+
},
|
|
324
|
+
keepEditing: () => {
|
|
336
325
|
ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
|
|
337
|
-
|
|
338
|
-
|
|
326
|
+
},
|
|
327
|
+
cancel: () => {
|
|
339
328
|
controller.endPlanning();
|
|
340
329
|
ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The approved draft, as the arguments both start paths take. */
|
|
335
|
+
function startArgumentsFor(proposal: LoopProposal): LoopStartArguments {
|
|
336
|
+
return {
|
|
337
|
+
kind: "start",
|
|
338
|
+
requestedMs: proposal.intervalMs,
|
|
339
|
+
intervalMs: proposal.intervalMs,
|
|
340
|
+
clamped: false,
|
|
341
|
+
maxTurns: proposal.maxTurns,
|
|
342
|
+
expiresInMs: proposal.expiresInMs,
|
|
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 } : {}),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build the loop here, install it over there. The build/install split is what
|
|
352
|
+
* makes this possible at all: the state has to exist before `newSession` so
|
|
353
|
+
* its `setup` can append it, and it must not be installed in this session or
|
|
354
|
+
* the planning session would start working the objective it is handing away.
|
|
355
|
+
*/
|
|
356
|
+
async function startApprovedLoopFresh(
|
|
357
|
+
controller: LoopController,
|
|
358
|
+
ctx: ExtensionCommandContext,
|
|
359
|
+
proposal: LoopProposal,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const built = controller.buildLoop(startArgumentsFor(proposal));
|
|
362
|
+
if (!built.ok) {
|
|
363
|
+
ctx.ui.notify(built.message, "error");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const result = await startLoopInFreshSession(ctx, {
|
|
367
|
+
built: built.built,
|
|
368
|
+
prepareLedger: () => controller.prepareLedgerFor(built.built),
|
|
369
|
+
});
|
|
370
|
+
switch (result.kind) {
|
|
371
|
+
case "started":
|
|
372
|
+
case "partial":
|
|
373
|
+
// The draft has been handed off either way: the planning session must
|
|
374
|
+
// not keep offering to start it a second time.
|
|
375
|
+
controller.endPlanning();
|
|
376
|
+
return;
|
|
377
|
+
case "cancelled":
|
|
378
|
+
return;
|
|
379
|
+
default:
|
|
380
|
+
ctx.ui.notify(result.detail, "error");
|
|
341
381
|
}
|
|
342
382
|
}
|
|
383
|
+
|
|
384
|
+
async function changeCadence(
|
|
385
|
+
controller: LoopController,
|
|
386
|
+
ctx: ExtensionCommandContext,
|
|
387
|
+
proposal: LoopProposal,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
const text = await ctx.ui.input(
|
|
390
|
+
"Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
|
|
391
|
+
formatDuration(proposal.intervalMs),
|
|
392
|
+
);
|
|
393
|
+
if (text === undefined) return;
|
|
394
|
+
const interval = parseInterval(text.trim());
|
|
395
|
+
if (!interval) {
|
|
396
|
+
ctx.ui.notify(`Invalid interval: ${text}. Use <number><unit>, e.g. 30m.`, "error");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// A new draft, so it gets a new card: the cadence on the old one is no
|
|
400
|
+
// longer what would start.
|
|
401
|
+
controller.propose(proposal.objective, {
|
|
402
|
+
intervalMs: interval.effectiveMs,
|
|
403
|
+
maxTurns: proposal.maxTurns,
|
|
404
|
+
expiresInMs: proposal.expiresInMs,
|
|
405
|
+
...(proposal.groundRules ? { groundRules: proposal.groundRules } : {}),
|
|
406
|
+
});
|
|
407
|
+
await showLoopApproval(controller, ctx);
|
|
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
|
"",
|
|
@@ -52,7 +53,19 @@ export function buildLoopObjectivePrompt(
|
|
|
52
53
|
"- You are running unattended. A prompt that blocks on a human — a permission approval, a clarifying question, any tool that waits for an answer — does not pause this loop, it deadlocks it: the session stays busy, so no continuation fires, no wake lands, and no cap trips. Nothing ends the loop until it expires. Plan to work without prompting.",
|
|
53
54
|
"- Decide rather than ask. Take the reversible option, record the decision and the reasoning behind it in the ledger, and keep going. A decision written down is worth more than a question nobody is there to answer.",
|
|
54
55
|
"- When you genuinely need a human, call loop_wait: it is the only way to ask that does not deadlock the session. Put the options in the ledger first, so the answer can be one word.",
|
|
55
|
-
|
|
56
|
+
// The old wording forbade reshaping a blocked command outright, and a
|
|
57
|
+
// permission guardian that blocks with a stated concern depends on exactly
|
|
58
|
+
// that: its block is an instruction to fix the named problem. Both cannot
|
|
59
|
+
// stand, and "never reshape" is the one that was wrong — it made every
|
|
60
|
+
// block terminal, including the ones that named a one-word fix. The line
|
|
61
|
+
// that matters is not whether the command changes but what the change is
|
|
62
|
+
// aimed at: satisfying the concern, or getting around the gate that raised
|
|
63
|
+
// it. So the prohibition is stated against the aim, and the number of
|
|
64
|
+
// attempts is bounded so that "revise to address it" cannot decay into
|
|
65
|
+
// "retry until it passes".
|
|
66
|
+
"- Never reshape a command to get around a permission gate. Splitting it up, obfuscating it, routing it through another tool, or retrying variations until one is allowed are all the same move, and it is forbidden however the loop is going.",
|
|
67
|
+
"- A block that states a concern is different: it names something to fix, and fixing exactly that is legitimate. Revise only to satisfy the stated concern, and only while the block says rounds remain against it. When they run out, the block will say so — stop revising and call loop_wait.",
|
|
68
|
+
"- A block that states no concern, or one you cannot address without widening what the command does, is already final. Do not spend the rounds; call loop_wait.",
|
|
56
69
|
"- Prefer the undoable. Nobody is watching to catch a bad call, so when two paths are close, take the one that is cheap to reverse.",
|
|
57
70
|
...(ledger ? ledgerRules(ledger) : []),
|
|
58
71
|
`${focus}`,
|
|
@@ -61,6 +74,30 @@ export function buildLoopObjectivePrompt(
|
|
|
61
74
|
.trimEnd();
|
|
62
75
|
}
|
|
63
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
|
+
|
|
64
101
|
/**
|
|
65
102
|
* The ledger contract. Stable per loop (the path is derived from the loop
|
|
66
103
|
* id), so it keeps the append byte-identical across turns.
|