@hank-warren/pi-loop 0.6.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/src/loop.ts CHANGED
@@ -34,12 +34,20 @@ import {
34
34
  type TickDecision,
35
35
  type TickEnvironment,
36
36
  } from "./decide.js";
37
- import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
37
+ import {
38
+ formatClock,
39
+ formatDuration,
40
+ formatElapsed,
41
+ MAX_INTERVAL_MS,
42
+ parseDuration,
43
+ } from "./interval.js";
38
44
  import {
39
45
  createLedger,
46
+ criteriaFromDescriptions,
40
47
  deriveCriteria,
41
48
  type LedgerPaths,
42
49
  ledgerPaths,
50
+ type LoopCriterion,
43
51
  readCriteria,
44
52
  } from "./ledger.js";
45
53
  import {
@@ -75,7 +83,17 @@ import {
75
83
  type ResolvedWaitDelay,
76
84
  } from "./wait.js";
77
85
  import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
78
- import { clearLoopWidget, updateLoopWidget } from "./widget.js";
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";
79
97
 
80
98
  export const LOOP_STATUS_KEY = "loop";
81
99
 
@@ -89,6 +107,17 @@ export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
89
107
  */
90
108
  export const MAX_FALLBACK_BACKOFF = 4;
91
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
+
92
121
  /** Consecutive loop deliveries that produce no run before the loop pauses. */
93
122
  export const MAX_DEAD_DELIVERIES = 3;
94
123
 
@@ -152,6 +181,30 @@ export class LoopController {
152
181
  private awaitingRun = false;
153
182
  /** Set when an interrupted turn needs a compaction before the loop continues. */
154
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 };
155
208
 
156
209
  constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
157
210
  this.pi = pi;
@@ -173,6 +226,9 @@ export class LoopController {
173
226
  this.noOpStreak = 0;
174
227
  this.deadDeliveries = 0;
175
228
  this.awaitingRun = false;
229
+ this.busySince = undefined;
230
+ this.blockedForMs = undefined;
231
+ this.planning = { active: false };
176
232
  this.sessionCtx = ctx;
177
233
 
178
234
  const loaded = readLoopSettings(this.settingsPath);
@@ -222,11 +278,16 @@ export class LoopController {
222
278
  this.sessionCtx = ctx;
223
279
  this.awaitingRun = false;
224
280
  this.deadDeliveries = 0;
281
+ this.busySince = this.now();
282
+ this.blockedForMs = undefined;
225
283
  }
226
284
 
227
285
  onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
228
286
  this.sessionCtx = ctx;
229
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;
230
291
  const origin = this.runOrigin;
231
292
  this.runOrigin = undefined;
232
293
  const loop = this.state;
@@ -570,14 +631,21 @@ export class LoopController {
570
631
  * Create (or adopt) the loop's ledger. Best-effort by design: a loop with
571
632
  * no writable ledger still runs, it just loses the durable record, so the
572
633
  * failure is warned once and never repeated.
634
+ *
635
+ * `criteria` is passed at start: the criteria proposed at `loop_start`, or
636
+ * the deterministic split of the objective. On restore it is omitted, and
637
+ * the criteria already on disk are authoritative — they are the ones the
638
+ * user saw echoed, and re-deriving them would both discard a proposed set
639
+ * and reset whatever `passes` flips the loop has earned.
573
640
  */
574
- private openLedger(loop: LoopState): void {
641
+ private openLedger(loop: LoopState, criteria?: LoopCriterion[]): void {
575
642
  if (loop.objective === undefined) {
576
643
  this.ledger = undefined;
577
644
  return;
578
645
  }
579
646
  const paths = ledgerPaths(loop.id, this.agentDir);
580
- const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
647
+ const contents = criteria ?? readCriteria(paths) ?? deriveCriteria(loop.objective);
648
+ const failure = createLedger(paths, loop.objective, contents);
581
649
  if (failure) {
582
650
  this.ledger = undefined;
583
651
  if (!this.ledgerWarned) {
@@ -735,6 +803,13 @@ export class LoopController {
735
803
  // Busy or compacting: coalesce into one pending wake that
736
804
  // the next agent_settled (or compaction onComplete) delivers.
737
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();
738
813
  }
739
814
  this.updateWidget();
740
815
  return;
@@ -757,12 +832,7 @@ export class LoopController {
757
832
  this.transition("stopped", "loop expired (the expiry was reached)");
758
833
  return;
759
834
  case "stop":
760
- this.transition(
761
- "stopped",
762
- decision.reason === "max-automatic-turns"
763
- ? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
764
- : `the ${loop.maxIterations}-iteration cap was reached`,
765
- );
835
+ this.transition("stopped", `the ${loop.maxTurns}-turn cap was reached`);
766
836
  return;
767
837
  }
768
838
  }
@@ -809,9 +879,9 @@ export class LoopController {
809
879
 
810
880
  /**
811
881
  * Send first, then account. Pi can refuse the delivery (a busy or compacting
812
- * session), and an iteration persisted before the send would burn the
813
- * maxIterations cap on a poke that never arrived; on a throw the loop re-arms
814
- * on the same cadence and retries at the next wake.
882
+ * session), and a turn persisted before the send would burn the cap on a
883
+ * poke that never arrived; on a throw the loop re-arms on the same cadence
884
+ * and retries at the next wake.
815
885
  */
816
886
  private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
817
887
  const loop = this.state;
@@ -933,44 +1003,63 @@ export class LoopController {
933
1003
  this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
934
1004
  }
935
1005
 
936
- updateWidget(): void {
937
- const ui = this.sessionCtx?.ui;
938
- if (!ui) return;
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 {
939
1028
  const loop = this.state;
940
- updateLoopWidget(
941
- ui,
942
- loop ? { loop, wakePending: this.wakePending, nextWakeAt: this.nextWakeAt } : undefined,
943
- );
944
1029
  if (!loop || loop.status === "stopped") {
945
- ui.setStatus(LOOP_STATUS_KEY, undefined);
946
- return;
947
- }
948
- if (loop.status === "paused") {
949
- ui.setStatus(
950
- LOOP_STATUS_KEY,
951
- loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
952
- );
953
- return;
1030
+ if (!this.planning.active) return undefined;
1031
+ const proposed = this.planning.proposal?.criteria.length;
1032
+ return { kind: "planning", ...(proposed === undefined ? {} : { proposedCriteria: proposed }) };
954
1033
  }
955
- if (loop.waiting) {
956
- ui.setStatus(
957
- LOOP_STATUS_KEY,
958
- `loop waiting · ${loop.waiting.reason}${
959
- loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
960
- }`,
961
- );
962
- return;
963
- }
964
- const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
965
- const next = this.wakePending
966
- ? "next on idle"
967
- : this.nextWakeAt
968
- ? `next ${formatClock(this.nextWakeAt)}`
969
- : "next unscheduled";
970
- ui.setStatus(
971
- LOOP_STATUS_KEY,
972
- `loop ${formatDuration(loop.intervalMs)} · ${loop.iteration}/${cap} · ${next}`,
973
- );
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);
974
1063
  }
975
1064
 
976
1065
  statusLines(ctx: ExtensionContext): string[] {
@@ -991,8 +1080,8 @@ export class LoopController {
991
1080
  ? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
992
1081
  : []),
993
1082
  `Interval: every ${formatDuration(loop.intervalMs)}`,
994
- `Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
995
- `Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
1083
+ `Loop turns: ${loop.automaticTurns}${loop.maxTurns === null ? " (unlimited)" : ` of ${loop.maxTurns}`}`,
1084
+ `Fallback wakes delivered: ${loop.iteration}`,
996
1085
  `Started: ${new Date(loop.startedAt).toLocaleString()}`,
997
1086
  `Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
998
1087
  `Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
@@ -1019,7 +1108,11 @@ export class LoopController {
1019
1108
  }
1020
1109
  }
1021
1110
  if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
1022
- if (this.nextWakeAt && loop.status === "active") {
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") {
1023
1116
  lines.push(
1024
1117
  `Next fallback wake: ${formatClock(this.nextWakeAt)}${
1025
1118
  this.noOpStreak > 0
@@ -1039,6 +1132,42 @@ export class LoopController {
1039
1132
  return lines;
1040
1133
  }
1041
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
+
1042
1171
  // --- command actions ---
1043
1172
 
1044
1173
  /**
@@ -1079,9 +1208,7 @@ export class LoopController {
1079
1208
  status: "active",
1080
1209
  objective,
1081
1210
  intervalMs: start.intervalMs,
1082
- maxIterations:
1083
- start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
1084
- maxAutomaticTurns: this.settings.automaticTurns,
1211
+ maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
1085
1212
  compactAt,
1086
1213
  iteration: 0,
1087
1214
  automaticTurns: 0,
@@ -1093,7 +1220,10 @@ export class LoopController {
1093
1220
  this.continuationIntent = undefined;
1094
1221
  this.noOpStreak = 0;
1095
1222
  this.ledgerWarned = false;
1096
- this.openLedger(this.state);
1223
+ this.openLedger(
1224
+ this.state,
1225
+ start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
1226
+ );
1097
1227
  this.persist();
1098
1228
  this.scheduleTick(start.intervalMs);
1099
1229
  this.updateWidget();
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,
@@ -154,8 +155,7 @@ export async function showLoopSettings(
154
155
  for (;;) {
155
156
  const s = controller.settings;
156
157
  const items = [
157
- `Max wakes: ${s.maxIterations === null ? "Unlimited" : s.maxIterations}`,
158
- `Max automatic turns: ${s.automaticTurns === null ? "Unlimited" : s.automaticTurns}`,
158
+ `Max loop turns: ${s.maxTurns === null ? "Unlimited" : s.maxTurns}`,
159
159
  `No-progress breaker: ${s.noProgressTurns === null ? "Off" : `after ${s.noProgressTurns} repeats`}`,
160
160
  `Max loop duration: ${s.maxLoopDuration}`,
161
161
  `Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
@@ -167,14 +167,10 @@ export async function showLoopSettings(
167
167
  if (index === 0) {
168
168
  // Unlimited is a first-class choice, not a magic word typed into a free
169
169
  // text box: it is only reachable by discovery otherwise.
170
- const cap = await editCap(ctx, "Max wakes", "no wake cap", s.maxIterations);
170
+ const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
171
171
  if (cap === undefined) continue;
172
- next.maxIterations = cap === "unlimited" ? null : cap;
172
+ next.maxTurns = cap === "unlimited" ? null : cap;
173
173
  } else if (index === 1) {
174
- const cap = await editCap(ctx, "Max automatic turns", "no turn cap", s.automaticTurns);
175
- if (cap === undefined) continue;
176
- next.automaticTurns = cap === "unlimited" ? null : cap;
177
- } else if (index === 2) {
178
174
  const cap = await editCap(
179
175
  ctx,
180
176
  "No-progress breaker",
@@ -183,7 +179,7 @@ export async function showLoopSettings(
183
179
  );
184
180
  if (cap === undefined) continue;
185
181
  next.noProgressTurns = cap === "unlimited" ? null : cap;
186
- } else if (index === 3) {
182
+ } else if (index === 2) {
187
183
  const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
188
184
  if (value === undefined) continue;
189
185
  if (parseDuration(value.trim()) === undefined) {
@@ -191,7 +187,7 @@ export async function showLoopSettings(
191
187
  continue;
192
188
  }
193
189
  next.maxLoopDuration = value.trim();
194
- } else if (index === 4) {
190
+ } else if (index === 3) {
195
191
  if (s.compaction.enabled) next.compaction.enabled = false;
196
192
  else {
197
193
  const value = await ctx.ui.input(
@@ -216,7 +212,7 @@ export async function showLoopSettings(
216
212
  }
217
213
 
218
214
  /**
219
- * One cap editor for both counters. Unlimited is a first-class choice, not a
215
+ * One cap editor for every cap. Unlimited is a first-class choice, not a
220
216
  * magic word typed into a free text box: it is only reachable by discovery
221
217
  * otherwise. The typed word still works, so the /loop --max vocabulary and
222
218
  * muscle memory keep working.
@@ -271,3 +267,76 @@ function applySettings(
271
267
  controller.settings = next;
272
268
  return true;
273
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/messages.ts CHANGED
@@ -15,23 +15,30 @@ import type { LoopState } from "./state.js";
15
15
  /** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
16
16
  export type ContinuationKind = "kickoff" | "continue" | "reanchor";
17
17
 
18
- function formatIteration(loop: LoopState): string {
19
- const cap = loop.maxIterations === null ? "unlimited" : `${loop.maxIterations}`;
20
- return `${loop.iteration + 1}/${cap}`;
18
+ /**
19
+ * The wake ordinal, and only the ordinal. It used to read `4/25`, the wake
20
+ * counter against the delivered-wake cap; that cap is gone, collapsed into
21
+ * the single loop-turn cap, and pairing a wake number with a turn cap would
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.
24
+ */
25
+ function formatWakeOrdinal(loop: LoopState): string {
26
+ return `${loop.iteration + 1}`;
21
27
  }
22
28
 
23
29
  /**
24
30
  * The poke. Deliberately slim: the loop's own objective injection puts the
25
31
  * objective and loop-mode rules in the system prompt of every turn, so
26
32
  * restating them here would store a duplicate copy on every wake. Only the
27
- * dynamic per-wake state (iteration, reason) belongs in this tail message.
33
+ * dynamic per-wake state (the wake ordinal, the reason) belongs in this tail
34
+ * message.
28
35
  */
29
36
  export function buildObjectivePoke(
30
37
  loop: LoopState,
31
38
  reason: "objective-stalled" | "wait-elapsed" = "objective-stalled",
32
39
  ): string {
33
40
  const lines = [
34
- `Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
41
+ `Scheduled loop wakeup ${formatWakeOrdinal(loop)} (every ${formatDuration(loop.intervalMs)}).`,
35
42
  reason === "wait-elapsed"
36
43
  ? "The wait you asked for has elapsed. Re-check the external state it depended on and continue — the objective and loop-mode rules are in the system prompt."
37
44
  : "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
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. Update it as you work, keeping its four sections: current status, completed, failed approaches and why, next actions. Record failures and their reasons — nothing else remembers them once the conversation is compacted.`,
67
- `- ${CRITERIA_FILE} holds this loop's completion criteria. You may change only the \`passes\` field of an entry, and only when authoritative evidence proves that criterion, citing the evidence in ${PROGRESS_FILE}. Never edit an id, description, or check, and never add or remove entries.`,
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
  }
@@ -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");