@hank-warren/pi-loop 0.7.0 → 0.8.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 +49 -0
- package/README.md +1 -37
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +45 -2
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop.ts +163 -38
- package/src/manager.ts +74 -0
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/widget.ts +102 -12
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/loop.ts
CHANGED
|
@@ -34,7 +34,13 @@ import {
|
|
|
34
34
|
type TickDecision,
|
|
35
35
|
type TickEnvironment,
|
|
36
36
|
} from "./decide.js";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
formatClock,
|
|
39
|
+
formatDuration,
|
|
40
|
+
formatElapsed,
|
|
41
|
+
MAX_INTERVAL_MS,
|
|
42
|
+
parseDuration,
|
|
43
|
+
} from "./interval.js";
|
|
38
44
|
import {
|
|
39
45
|
createLedger,
|
|
40
46
|
criteriaFromDescriptions,
|
|
@@ -77,7 +83,17 @@ import {
|
|
|
77
83
|
type ResolvedWaitDelay,
|
|
78
84
|
} from "./wait.js";
|
|
79
85
|
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
80
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
buildProposal,
|
|
88
|
+
type LoopPlanningState,
|
|
89
|
+
type LoopProposal,
|
|
90
|
+
} from "./planning.js";
|
|
91
|
+
import {
|
|
92
|
+
clearLoopWidget,
|
|
93
|
+
loopWidgetLine,
|
|
94
|
+
type LoopWidgetView,
|
|
95
|
+
updateLoopWidget,
|
|
96
|
+
} from "./widget.js";
|
|
81
97
|
|
|
82
98
|
export const LOOP_STATUS_KEY = "loop";
|
|
83
99
|
|
|
@@ -91,6 +107,17 @@ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
|
91
107
|
*/
|
|
92
108
|
export const MAX_FALLBACK_BACKOFF = 4;
|
|
93
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How long a single run may stay open before the widget calls it blocked.
|
|
112
|
+
*
|
|
113
|
+
* Deliberately generous: a long build, a big test suite, or a deep subagent
|
|
114
|
+
* fan-out are all legitimately busy for a while, and crying blocked on honest
|
|
115
|
+
* work would train the signal to be ignored. Fifteen minutes with no completed
|
|
116
|
+
* turn is well past ordinary work and well short of the seven-day expiry that
|
|
117
|
+
* would otherwise be the first sign anything was wrong.
|
|
118
|
+
*/
|
|
119
|
+
export const STALL_ATTENTION_MS = 900_000;
|
|
120
|
+
|
|
94
121
|
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
95
122
|
export const MAX_DEAD_DELIVERIES = 3;
|
|
96
123
|
|
|
@@ -154,6 +181,30 @@ export class LoopController {
|
|
|
154
181
|
private awaitingRun = false;
|
|
155
182
|
/** Set when an interrupted turn needs a compaction before the loop continues. */
|
|
156
183
|
private compactionRequested = false;
|
|
184
|
+
/**
|
|
185
|
+
* When the in-flight run started, or undefined between runs.
|
|
186
|
+
*
|
|
187
|
+
* A session blocked on a modal prompt — a permission approval, a question,
|
|
188
|
+
* anything that waits for a human — is `busy`, and `busy` makes every
|
|
189
|
+
* continuation and every fallback tick skip. No turn completes, so the cap
|
|
190
|
+
* never trips and the no-progress breaker (which counts turns) never fires.
|
|
191
|
+
* Expiry is the only thing left, up to seven days later. The engine cannot
|
|
192
|
+
* see the prompt, but it can see that a run has been open far longer than
|
|
193
|
+
* work usually takes, which is enough to put an attention state on the
|
|
194
|
+
* widget instead of a cheerful next-wake time.
|
|
195
|
+
*/
|
|
196
|
+
private busySince: number | undefined;
|
|
197
|
+
/** How long the current run has been open, once past the stall threshold. */
|
|
198
|
+
private blockedForMs: number | undefined;
|
|
199
|
+
/**
|
|
200
|
+
* Loop planning: the drafting conversation that precedes a loop.
|
|
201
|
+
*
|
|
202
|
+
* In-memory on purpose. A draft is a short interactive flow, and persisting
|
|
203
|
+
* it would mean a half-written objective could outlive the conversation that
|
|
204
|
+
* produced it and be approved later out of context. The loop it becomes is
|
|
205
|
+
* persisted; the draft is not.
|
|
206
|
+
*/
|
|
207
|
+
planning: LoopPlanningState = { active: false };
|
|
157
208
|
|
|
158
209
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
159
210
|
this.pi = pi;
|
|
@@ -175,6 +226,9 @@ export class LoopController {
|
|
|
175
226
|
this.noOpStreak = 0;
|
|
176
227
|
this.deadDeliveries = 0;
|
|
177
228
|
this.awaitingRun = false;
|
|
229
|
+
this.busySince = undefined;
|
|
230
|
+
this.blockedForMs = undefined;
|
|
231
|
+
this.planning = { active: false };
|
|
178
232
|
this.sessionCtx = ctx;
|
|
179
233
|
|
|
180
234
|
const loaded = readLoopSettings(this.settingsPath);
|
|
@@ -224,11 +278,16 @@ export class LoopController {
|
|
|
224
278
|
this.sessionCtx = ctx;
|
|
225
279
|
this.awaitingRun = false;
|
|
226
280
|
this.deadDeliveries = 0;
|
|
281
|
+
this.busySince = this.now();
|
|
282
|
+
this.blockedForMs = undefined;
|
|
227
283
|
}
|
|
228
284
|
|
|
229
285
|
onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
|
|
230
286
|
this.sessionCtx = ctx;
|
|
231
287
|
this.awaitingRun = false;
|
|
288
|
+
// A completed turn is proof the session was not blocked on a human.
|
|
289
|
+
this.busySince = undefined;
|
|
290
|
+
this.blockedForMs = undefined;
|
|
232
291
|
const origin = this.runOrigin;
|
|
233
292
|
this.runOrigin = undefined;
|
|
234
293
|
const loop = this.state;
|
|
@@ -744,6 +803,13 @@ export class LoopController {
|
|
|
744
803
|
// Busy or compacting: coalesce into one pending wake that
|
|
745
804
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
746
805
|
this.wakePending = true;
|
|
806
|
+
if (decision.reason === "agent-busy") this.noteBusyTick(env.now);
|
|
807
|
+
// Keep the heartbeat armed while busy. Coalescing is about
|
|
808
|
+
// deliveries, not about the timer: wakePending is a boolean, so a
|
|
809
|
+
// further tick cannot produce a second wake. Without re-arming,
|
|
810
|
+
// the first busy tick would be the last one ever taken, and a
|
|
811
|
+
// session blocked on a prompt would never be noticed at all.
|
|
812
|
+
this.armFallback();
|
|
747
813
|
}
|
|
748
814
|
this.updateWidget();
|
|
749
815
|
return;
|
|
@@ -937,44 +1003,63 @@ export class LoopController {
|
|
|
937
1003
|
this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
|
|
938
1004
|
}
|
|
939
1005
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1006
|
+
/**
|
|
1007
|
+
* A run has been open past the stall threshold, so the most likely
|
|
1008
|
+
* explanation is a prompt waiting for a human. Reported, never acted on:
|
|
1009
|
+
* approving on the user's behalf is exactly the boundary pi-auto-permissions
|
|
1010
|
+
* exists to hold.
|
|
1011
|
+
*/
|
|
1012
|
+
private noteBusyTick(now: number): void {
|
|
1013
|
+
const since = this.busySince;
|
|
1014
|
+
if (since === undefined) return;
|
|
1015
|
+
const openFor = now - since;
|
|
1016
|
+
this.blockedForMs = openFor >= STALL_ATTENTION_MS ? openFor : undefined;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The view both surfaces render, or undefined when there is nothing to show.
|
|
1021
|
+
*
|
|
1022
|
+
* One function feeds the widget and the footer because two hand-rolled
|
|
1023
|
+
* formatters drifted: the footer handled `loop.waiting` and the widget did
|
|
1024
|
+
* not, so a loop blocked on CI read as an ordinary scheduled loop above the
|
|
1025
|
+
* editor while the footer said it was waiting.
|
|
1026
|
+
*/
|
|
1027
|
+
widgetView(): LoopWidgetView | undefined {
|
|
943
1028
|
const loop = this.state;
|
|
944
|
-
updateLoopWidget(
|
|
945
|
-
ui,
|
|
946
|
-
loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
|
|
947
|
-
);
|
|
948
1029
|
if (!loop || loop.status === "stopped") {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if (loop.status === "paused") {
|
|
953
|
-
ui.setStatus(
|
|
954
|
-
LOOP_STATUS_KEY,
|
|
955
|
-
loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
|
|
956
|
-
);
|
|
957
|
-
return;
|
|
1030
|
+
if (!this.planning.active) return undefined;
|
|
1031
|
+
const proposed = this.planning.proposal?.criteria.length;
|
|
1032
|
+
return { kind: "planning", ...(proposed === undefined ? {} : { proposedCriteria: proposed }) };
|
|
958
1033
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
)
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
);
|
|
1034
|
+
const criteria = this.criteriaProgress();
|
|
1035
|
+
return {
|
|
1036
|
+
kind: "loop",
|
|
1037
|
+
loop,
|
|
1038
|
+
wakePending: this.wakePending,
|
|
1039
|
+
nextWakeAt: this.nextWakeAt,
|
|
1040
|
+
...(criteria ? { criteria } : {}),
|
|
1041
|
+
...(this.blockedForMs === undefined ? {} : { blockedForMs: this.blockedForMs }),
|
|
1042
|
+
now: this.now(),
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Criteria progress for the widget. Re-read rather than cached: this runs on
|
|
1048
|
+
* discrete state transitions, not per frame, and a stale count right after a
|
|
1049
|
+
* criterion is marked would undercut the one number the line exists to show.
|
|
1050
|
+
*/
|
|
1051
|
+
private criteriaProgress(): { met: number; total: number } | undefined {
|
|
1052
|
+
const criteria = this.criteria();
|
|
1053
|
+
if (!criteria || criteria.length === 0) return undefined;
|
|
1054
|
+
return { met: criteria.filter((criterion) => criterion.passes).length, total: criteria.length };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
updateWidget(): void {
|
|
1058
|
+
const ui = this.sessionCtx?.ui;
|
|
1059
|
+
if (!ui) return;
|
|
1060
|
+
const view = this.widgetView();
|
|
1061
|
+
updateLoopWidget(ui, view);
|
|
1062
|
+
ui.setStatus(LOOP_STATUS_KEY, view ? loopWidgetLine(view) : undefined);
|
|
978
1063
|
}
|
|
979
1064
|
|
|
980
1065
|
statusLines(ctx: ExtensionContext): string[] {
|
|
@@ -1023,7 +1108,11 @@ export class LoopController {
|
|
|
1023
1108
|
}
|
|
1024
1109
|
}
|
|
1025
1110
|
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
1026
|
-
|
|
1111
|
+
// A pending wake is delivered at the next settle, whatever the timer says.
|
|
1112
|
+
// The heartbeat stays armed while the agent is busy so a stalled session is
|
|
1113
|
+
// still noticed, which means nextWakeAt can be set here even though no wake
|
|
1114
|
+
// will fire at that time — claiming it would be a stale clock time.
|
|
1115
|
+
if (this.nextWakeAt && !this.wakePending && loop.status === "active") {
|
|
1027
1116
|
lines.push(
|
|
1028
1117
|
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1029
1118
|
this.noOpStreak > 0
|
|
@@ -1043,6 +1132,42 @@ export class LoopController {
|
|
|
1043
1132
|
return lines;
|
|
1044
1133
|
}
|
|
1045
1134
|
|
|
1135
|
+
// --- planning ---
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Open the drafting conversation. Idempotent: running /loop again while
|
|
1139
|
+
* planning shows the current draft rather than restarting the flow.
|
|
1140
|
+
*/
|
|
1141
|
+
beginPlanning(): void {
|
|
1142
|
+
this.planning = { active: true };
|
|
1143
|
+
this.updateWidget();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Record a drafted loop for approval, replacing any previous draft. */
|
|
1147
|
+
propose(
|
|
1148
|
+
objective: string,
|
|
1149
|
+
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
1150
|
+
): LoopProposal {
|
|
1151
|
+
const proposal = buildProposal(
|
|
1152
|
+
objective,
|
|
1153
|
+
{
|
|
1154
|
+
intervalMs: parseDuration(this.settings.defaultInterval) ?? 600_000,
|
|
1155
|
+
maxTurns: this.settings.maxTurns,
|
|
1156
|
+
expiresInMs: parseDuration(this.settings.maxLoopDuration) ?? 604_800_000,
|
|
1157
|
+
},
|
|
1158
|
+
this.now(),
|
|
1159
|
+
overrides,
|
|
1160
|
+
);
|
|
1161
|
+
this.planning = { active: true, proposal };
|
|
1162
|
+
this.updateWidget();
|
|
1163
|
+
return proposal;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
endPlanning(): void {
|
|
1167
|
+
this.planning = { active: false };
|
|
1168
|
+
this.updateWidget();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1046
1171
|
// --- command actions ---
|
|
1047
1172
|
|
|
1048
1173
|
/**
|
package/src/manager.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
saveLoopSettings,
|
|
15
15
|
} from "./settings.js";
|
|
16
16
|
import { parseDuration } from "./interval.js";
|
|
17
|
+
import { renderProposalCard } from "./planning.js";
|
|
17
18
|
|
|
18
19
|
export async function showLoopManager(
|
|
19
20
|
controller: LoopController,
|
|
@@ -266,3 +267,76 @@ function applySettings(
|
|
|
266
267
|
controller.settings = next;
|
|
267
268
|
return true;
|
|
268
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The approval card and its actions.
|
|
273
|
+
*
|
|
274
|
+
* This is where a planned loop starts, and the approval is what authorises it.
|
|
275
|
+
* `loop_start`'s gate exists because a loop is self-continuing and must never
|
|
276
|
+
* begin on model initiative; an explicit choice here, on a card showing the
|
|
277
|
+
* objective, the derived criteria, the cadence and the caps, is stronger
|
|
278
|
+
* evidence of intent than a typed token, so it starts the loop directly rather
|
|
279
|
+
* than routing back through a tool the model could reach on its own.
|
|
280
|
+
*/
|
|
281
|
+
export async function showLoopApproval(
|
|
282
|
+
controller: LoopController,
|
|
283
|
+
ctx: ExtensionCommandContext,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const proposal = controller.planning.proposal;
|
|
286
|
+
if (!proposal) return;
|
|
287
|
+
ctx.ui.notify(renderProposalCard(proposal).join("\n"), "info");
|
|
288
|
+
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
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const choice = await ctx.ui.select("Start this loop?", [
|
|
296
|
+
"Start loop",
|
|
297
|
+
"Change cadence",
|
|
298
|
+
"Keep editing",
|
|
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
|
+
});
|
|
312
|
+
if (result.ok) controller.endPlanning();
|
|
313
|
+
else ctx.ui.notify(result.message, "error");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
case "Change cadence": {
|
|
317
|
+
const text = await ctx.ui.input(
|
|
318
|
+
"Fallback heartbeat (e.g. 30m). The loop advances whenever the session settles.",
|
|
319
|
+
formatDuration(proposal.intervalMs),
|
|
320
|
+
);
|
|
321
|
+
if (text === undefined) return;
|
|
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":
|
|
336
|
+
ctx.ui.notify("Still planning. Tell the agent what to change.", "info");
|
|
337
|
+
return;
|
|
338
|
+
default:
|
|
339
|
+
controller.endPlanning();
|
|
340
|
+
ctx.ui.notify("Loop planning cancelled. Nothing was started.", "info");
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/objective.ts
CHANGED
|
@@ -44,6 +44,16 @@ export function buildLoopObjectivePrompt(
|
|
|
44
44
|
"- Effort exhaustion is not completion. Running long, running out of ideas, or approaching a cap is never a reason to call loop_complete.",
|
|
45
45
|
"- Call loop_complete with this exact loop_id only when every completion criterion is proven, passing one cited piece of evidence per criterion id. It stops the wakeups; it does not assert that unrelated work is finished.",
|
|
46
46
|
"- If the criteria are not met, keep working and expect another wake.",
|
|
47
|
+
// Autonomy. Stated as mechanics rather than as a rule to obey, because the
|
|
48
|
+
// mechanics are the reason: a blocked session is `busy`, and `busy` makes
|
|
49
|
+
// every continuation and every fallback tick skip. Nothing enumerates
|
|
50
|
+
// tools here on purpose — the user's other extensions are unknowable, and
|
|
51
|
+
// a blacklist would go stale the moment one of them ships a new prompt.
|
|
52
|
+
"- 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
|
+
"- 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
|
+
"- 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
|
+
"- Never reshape a command to get past a permission prompt. A blocked command means stop and ask through loop_wait, never find another way around it.",
|
|
56
|
+
"- 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.",
|
|
47
57
|
...(ledger ? ledgerRules(ledger) : []),
|
|
48
58
|
`${focus}`,
|
|
49
59
|
]
|
|
@@ -63,8 +73,9 @@ function ledgerRules(ledger: LedgerPaths): string[] {
|
|
|
63
73
|
return [
|
|
64
74
|
"",
|
|
65
75
|
`Loop ledger (durable state for this loop, at ${ledger.dir}):`,
|
|
66
|
-
`- ${PROGRESS_FILE} is yours to maintain
|
|
67
|
-
`-
|
|
76
|
+
`- ${PROGRESS_FILE} is yours to maintain, keeping its four sections: current status, completed, failed approaches and why, next actions. Record failures and their reasons as you go — nothing else remembers them once the conversation is compacted.`,
|
|
77
|
+
`- Write it with the loop_progress tool and never with the file or shell tools: loop_progress edits one section and leaves every other byte alone, where a whole-file write destroys the objective line and the other three sections along with the one you meant to update.`,
|
|
78
|
+
`- ${CRITERIA_FILE} holds this loop's completion criteria. Mark one met with loop_progress, which stores the citation next to it; only the \`passes\` field ever changes. Never edit an id, description, or check, never add or remove entries, and never write this file by hand.`,
|
|
68
79
|
"- After a compaction, re-read both files before acting. They are the record; a summary is not.",
|
|
69
80
|
];
|
|
70
81
|
}
|
package/src/planning.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
|
|
43
|
+
export function buildProposal(
|
|
44
|
+
objective: string,
|
|
45
|
+
defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
|
|
46
|
+
now: number,
|
|
47
|
+
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
48
|
+
): LoopProposal {
|
|
49
|
+
return {
|
|
50
|
+
objective: objective.trim(),
|
|
51
|
+
// Derived, never authored: the card has to show the criteria the engine
|
|
52
|
+
// will actually freeze, or approving it means approving something else.
|
|
53
|
+
criteria: deriveCriteria(objective),
|
|
54
|
+
intervalMs: overrides.intervalMs ?? defaults.intervalMs,
|
|
55
|
+
maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
|
|
56
|
+
expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
|
|
57
|
+
proposedAt: now,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The approval card, as transcript lines. */
|
|
62
|
+
export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
63
|
+
return [
|
|
64
|
+
"**◆ Loop ready to start**",
|
|
65
|
+
"",
|
|
66
|
+
"**Objective**",
|
|
67
|
+
...proposal.objective.split("\n").map((line) => `> ${line}`),
|
|
68
|
+
"",
|
|
69
|
+
`**Criteria the gate will hold you to** (${proposal.criteria.length})`,
|
|
70
|
+
...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
|
|
71
|
+
"",
|
|
72
|
+
`**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
|
|
73
|
+
`**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
|
|
74
|
+
"",
|
|
75
|
+
"Run `/loop` to approve and start, keep editing, or cancel.",
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const LOOP_PLANNING_HINT = [
|
|
80
|
+
"<system-reminder>",
|
|
81
|
+
"The user opened loop planning. You are drafting a loop objective with them; no loop is running and none starts until they approve one.",
|
|
82
|
+
"A loop's objective becomes its acceptance gate, so draft it as an acceptance test, not as a prompt:",
|
|
83
|
+
"- 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.",
|
|
84
|
+
"- Name the check in the requirement itself ('…, verified by npm test passing'), so completion is a lookup instead of an argument.",
|
|
85
|
+
"- The two questions that fix most objectives: how will we know it is done, and what command proves it?",
|
|
86
|
+
"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.",
|
|
87
|
+
// Without this the model reaches for the loop_start prohibition instead. It
|
|
88
|
+
// is stated emphatically and repeatedly ('never start a loop without that
|
|
89
|
+
// token, no matter how loop-like the request sounds'), so a conversational
|
|
90
|
+
// request for a loop pattern-matches straight onto it — and the model
|
|
91
|
+
// answers by telling the user to type /loop, which is precisely the dead end
|
|
92
|
+
// planning exists to remove. Observed live in a canary session.
|
|
93
|
+
"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.",
|
|
94
|
+
"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.",
|
|
95
|
+
"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.",
|
|
96
|
+
"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.",
|
|
97
|
+
"</system-reminder>",
|
|
98
|
+
].join("\n");
|
|
@@ -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
|
+
}
|