pi-goal-list-loop-audit 0.35.67 → 0.35.69

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.69 — metricless-loop cadence (2026-08-26)
4
+
5
+ ### Added
6
+ Metricless and measured loops accept an opt-in `cadence=<seconds>` minimum
7
+ interval between successful automatic iterations. The cadence is persisted,
8
+ shown in `/loop status` and loop prompts, and explicit starts/resumes bypass
9
+ it for an urgent wake. The default remains unchanged.
10
+
11
+ ### Tests
12
+ Coverage verifies cadence parsing, delayed automatic re-wakes, urgent
13
+ explicit starts, and the existing unbounded metricless behavior.
14
+
15
+ ## 0.35.68 — bound-stop recovery (2026-08-26)
16
+
17
+ ### Fixed
18
+ Explicit `/loop resume` now recovers time- and token-bound stops as fresh
19
+ supervised windows without discarding iteration, history, or best-value
20
+ state. Recoverable stopped loops can accept a confirmed
21
+ `propose_loop_refine` change while remaining stopped until explicitly
22
+ resumed. Clean max-iteration and finished loops remain terminal; automatic
23
+ startup does not silently reset an explicit budget.
24
+
25
+ ### Tests
26
+ Coverage verifies fresh time windows, token-budget resets, preserved loop
27
+ history, stopped-loop refinement, and the unchanged max-iteration guard.
28
+
3
29
  ## 0.35.67 — in-band provider-result recovery (2026-08-26)
4
30
 
5
31
  ### Fixed
package/INSTALL.md CHANGED
@@ -98,13 +98,15 @@ waiting for a decision.
98
98
 
99
99
  /loop
100
100
  /loop start "reduce flaky tests" measure="..." direction=min
101
- /loop start "keep improving the spec" measure=none max=20
101
+ /loop start "keep improving the spec" measure=none max=20 cadence=900
102
102
  /loop audit
103
103
  ```
104
104
 
105
105
  Use `/goal` for one outcome, `/list` for several independently auditable
106
- outcomes, and `/loop` for an improvement process without one final item. See
107
- the README for the full command semantics.
106
+ outcomes, and `/loop` for an improvement process without one final item. For
107
+ metricless loops that intentionally mature between checks, add optional
108
+ `cadence=<seconds>`; the interval is visible in `/loop status`, while explicit
109
+ starts/resumes remain urgent. See the README for the full command semantics.
108
110
 
109
111
  ## Modes
110
112
 
package/README.md CHANGED
@@ -170,7 +170,7 @@ automatic repeats are fenced. Use `/list resume` for an intentional retry and
170
170
  /loop # interview + Confirm
171
171
  /loop plan # research-first loop design
172
172
  /loop start "reduce flaky tests" measure="..." direction=min
173
- /loop start "keep improving the spec" measure=none max=20
173
+ /loop start "keep improving the spec" measure=none max=20 cadence=900
174
174
  /loop audit # recurring project-audit cadence
175
175
  /loop status
176
176
  /loop stop
@@ -183,7 +183,10 @@ There are three loop styles:
183
183
  before you confirm it and stops on plateau or a configured bound.
184
184
  - **Metricless specification:** no honest number exists, so the loop advances
185
185
  a specification or checklist. It ends at its time/token/iteration bound or
186
- `/loop stop`; it has no fake plateau metric.
186
+ `/loop stop`; it has no fake plateau metric. Add optional
187
+ `cadence=<seconds>` to put a minimum gap between successful automatic
188
+ iterations; explicit starts/resumes remain urgent and `/loop status` shows
189
+ the armed cadence.
187
190
  - **Project audit:** each iteration looks for the next important finding,
188
191
  appends evidence to the audit ledger, and works through the findings.
189
192
 
@@ -54,6 +54,23 @@ export function isLifecycleHeldLoopReason(reason?: string): boolean {
54
54
  || !!reason?.startsWith("send-retry storm:");
55
55
  }
56
56
 
57
+ /** A stopped loop can be respecified without discarding its history when the
58
+ * stop is a recoverable work failure or an explicit time/token window. Max
59
+ * iterations and clean/user stops remain terminal until a fresh `/loop start`.
60
+ */
61
+ export function isRefinableStoppedLoopReason(reason?: string): boolean {
62
+ return !!reason && (
63
+ reason.startsWith("time bound reached")
64
+ || reason.startsWith("token budget exhausted")
65
+ || reason.startsWith("stuck —")
66
+ || reason.startsWith("plateau —")
67
+ || reason.startsWith("metric never moved —")
68
+ || reason.startsWith("measure command broken —")
69
+ || reason.startsWith("provider errors —")
70
+ || reason.startsWith("stalled:")
71
+ );
72
+ }
73
+
57
74
  export interface LoopState {
58
75
  target: string;
59
76
  /** v0.23.0: optional — a metricless "spec loop" (measure=none) has no
@@ -103,6 +120,12 @@ export interface LoopState {
103
120
  tokenBudget?: number;
104
121
  /** v0.15.0: accumulated loop tokens (input+output), orchestrator-counted. */
105
122
  tokensUsed?: number;
123
+ /** v0.35.x: optional minimum gap between successful metricless-loop
124
+ * iterations. Units are milliseconds internally; absent means unchanged
125
+ * immediate cadence. */
126
+ minimumIterationIntervalMs?: number;
127
+ /** v0.35.x: completion timestamp used to arm the next cadence window. */
128
+ lastIterationCompletedAt?: string;
106
129
  /** v0.15.0: living spec — user-confirmed target/measure refinements. */
107
130
  refinements?: LoopRefinement[];
108
131
  /** branch=1 mode: scratch branch holding the loop's commits. */
@@ -382,6 +405,8 @@ export function parseLoopStartArgs(raw: string): {
382
405
  timeLimitHours?: number;
383
406
  tokenBudget?: number;
384
407
  toolSameRepeat?: number;
408
+ /** v0.35.x: optional metricless minimum cadence, supplied in seconds. */
409
+ minimumIterationIntervalMs?: number;
385
410
  } {
386
411
  // Key=value pairs first (measure= and direction= may hold quoted values),
387
412
  // the remaining text is the target. v0.35.4: quoted spans are TARGET
@@ -392,7 +417,7 @@ export function parseLoopStartArgs(raw: string): {
392
417
  let rest = raw.trim();
393
418
  const kv = new Map<string, string>();
394
419
  const kvRe = /(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g;
395
- const KNOWN_KEYS = new Set(["measure", "direction", "window", "max", "branch", "force", "done", "time", "tokens", "toolsamerepeat"]);
420
+ const KNOWN_KEYS = new Set(["measure", "direction", "window", "max", "branch", "force", "done", "time", "tokens", "toolsamerepeat", "cadence"]);
396
421
  const quoteSpans: Array<[number, number]> = [];
397
422
  const quoteRe = /"([^"]*)"|'([^']*)'/g;
398
423
  let qm: RegExpExecArray | null;
@@ -447,6 +472,10 @@ export function parseLoopStartArgs(raw: string): {
447
472
  }
448
473
  const timeRaw = Number.parseFloat(kv.get("time") ?? "");
449
474
  const tokensRaw = Number.parseInt(kv.get("tokens") ?? "", 10);
475
+ const cadenceRaw = Number.parseFloat(kv.get("cadence") ?? "");
476
+ const cadenceMs = Number.isFinite(cadenceRaw) && cadenceRaw > 0
477
+ ? Math.min(Math.round(cadenceRaw * 1_000), 24 * 60 * 60_000)
478
+ : undefined;
450
479
  return {
451
480
  target,
452
481
  measureCmd: metricless ? "" : measureRaw,
@@ -467,6 +496,7 @@ export function parseLoopStartArgs(raw: string): {
467
496
  const n = Number.parseInt(raw, 10);
468
497
  return Number.isInteger(n) && n >= 0 ? n : undefined;
469
498
  })(),
499
+ ...(cadenceMs !== undefined ? { minimumIterationIntervalMs: cadenceMs } : {}),
470
500
  };
471
501
  }
472
502
 
@@ -282,6 +282,17 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
282
282
  }
283
283
 
284
284
  function scheduleLoopTick(ctx: ExtensionContext): void {
285
+ // v0.35.15: `/glla pause` freezes loop re-arms too — the supervisor's
286
+ if (supervisorPaused(state)) return;
287
+ scheduleLoopTickWithUrgency(ctx, false);
288
+ }
289
+
290
+ function scheduleLoopTickUrgent(ctx: ExtensionContext): void {
291
+ if (supervisorPaused(state)) return;
292
+ scheduleLoopTickWithUrgency(ctx, true);
293
+ }
294
+
295
+ function scheduleLoopTickWithUrgency(ctx: ExtensionContext, urgent: boolean): void {
285
296
  // v0.35.15: `/glla pause` freezes loop re-arms too — the supervisor's
286
297
  // automatic machinery includes the metric loop's turn dispatch.
287
298
  if (supervisorPaused(state)) return;
@@ -295,7 +306,18 @@ function scheduleLoopTick(ctx: ExtensionContext): void {
295
306
  } catch {
296
307
  return;
297
308
  }
298
- loopTimer = scheduleSessionTimeout(() => sendLoopTurn(), delay);
309
+ // A cadence is an intentional maturity gap between successful iterations,
310
+ // not a replacement for the busy-send backoff. Explicit starts/resumes are
311
+ // urgent wakes and bypass the gap once; automatic re-arms honor it.
312
+ if (!urgent) {
313
+ const loop = state.loop;
314
+ const intervalMs = loop?.minimumIterationIntervalMs;
315
+ const completedAt = loop?.lastIterationCompletedAt ? Date.parse(loop.lastIterationCompletedAt) : Number.NaN;
316
+ if (intervalMs !== undefined && Number.isFinite(completedAt)) {
317
+ delay = Math.max(delay, completedAt + intervalMs - Date.now());
318
+ }
319
+ }
320
+ loopTimer = scheduleSessionTimeout(() => sendLoopTurn(), Math.max(0, delay));
299
321
  }
300
322
 
301
323
  function sendLoopTurn(): void {
@@ -379,6 +401,9 @@ function sendLoopTurn(): void {
379
401
  } else if (bounds.length) {
380
402
  boundsNote = `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}`;
381
403
  }
404
+ if (loop.minimumIterationIntervalMs !== undefined) {
405
+ boundsNote += `\n- Minimum cadence: wait at least ${Math.ceil(loop.minimumIterationIntervalMs / 1_000)}s after each completed iteration before the next automatic wake; explicit starts/resumes are urgent.`;
406
+ }
382
407
  // v0.24.0: a stuck intervention REPLACES the pep talk — the rotating
383
408
  // directive names why the loop is stuck and what rung of the ladder it's on.
384
409
  // v0.29.19: a plateau reprieve's one-shot shove takes priority over the
@@ -584,7 +609,9 @@ async function runLoopTick(initialCtx: ExtensionContext, event?: any): Promise<v
584
609
  loop.consecutiveStuck = 0;
585
610
  loop.lastStuckReason = undefined;
586
611
  }
587
- let outcome: LoopTickOutcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
612
+ const completedAt = nowIso();
613
+ let outcome: LoopTickOutcome = metricless ? applyMetriclessTick(loop, completedAt) : applyMeasurement(loop, value, completedAt);
614
+ loop.lastIterationCompletedAt = completedAt;
588
615
  // v0.33.2: close the hypothesis feedback loop — the prediction went into
589
616
  // the ledger; now the VERDICT rides the next iteration's prompt.
590
617
  if (loop.lastHypothesis) {
@@ -766,6 +793,8 @@ interface LoopConfig {
766
793
  force?: boolean;
767
794
  timeLimitHours?: number;
768
795
  tokenBudget?: number;
796
+ /** v0.35.x: optional metricless minimum cadence in milliseconds. */
797
+ minimumIterationIntervalMs?: number;
769
798
  /** v0.25.1: /loop start toolsamerepeat=N (0 = disable legacy check). */
770
799
  toolSameRepeat?: number;
771
800
  /** v0.29.10: don't seed bestValue from the pre-work baseline measure —
@@ -871,6 +900,7 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
871
900
  startedAt: nowIso(),
872
901
  timeLimitHours: cfg.timeLimitHours,
873
902
  tokenBudget: cfg.tokenBudget,
903
+ minimumIterationIntervalMs: cfg.minimumIterationIntervalMs,
874
904
  tokensUsed: 0,
875
905
  branchName,
876
906
  originalBranch,
@@ -882,16 +912,18 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
882
912
  },
883
913
  });
884
914
  persistState(ctx);
885
- appendLedger(ctx.cwd, "loop_started", { target: cfg.target, measureCmd: cfg.measureCmd || "none", direction: cfg.direction ?? "none", baseline, branch: branchName, timeLimitHours: cfg.timeLimitHours, tokenBudget: cfg.tokenBudget });
915
+ appendLedger(ctx.cwd, "loop_started", { target: cfg.target, measureCmd: cfg.measureCmd || "none", direction: cfg.direction ?? "none", baseline, branch: branchName, timeLimitHours: cfg.timeLimitHours, tokenBudget: cfg.tokenBudget, minimumIterationIntervalMs: cfg.minimumIterationIntervalMs });
886
916
  ctx.ui.notify(
887
917
  metricless
888
918
  ? `Loop started (metricless spec loop — NO plateau stop): ${displaySlice(cfg.target, 60)}\nEnds only at ${cfg.maxIterations > 0 ? `max ${cfg.maxIterations} iterations` : "no iteration cap"}${cfg.timeLimitHours ? ` · ${cfg.timeLimitHours}h` : ""}${cfg.tokenBudget ? ` · ${cfg.tokenBudget.toLocaleString()} tokens` : ""} · /loop stop. Every iteration must make ONE real, inspectable change — cosmetic churn is the doorknob failure.` +
919
+ (cfg.minimumIterationIntervalMs ? ` · cadence ≥ ${Math.ceil(cfg.minimumIterationIntervalMs / 1_000)}s` : "") +
889
920
  (branchName ? `\nbranch mode: committing each iteration to ${branchName}` : "")
890
921
  : `Loop started: ${displaySlice(cfg.target, 60)}\nBaseline: ${cfg.deferBaseline ? "deferred — the first real measurement seeds it" : (baseline ?? "(forced without a number — first turn must produce one)")} · direction ${cfg.direction} · window ${cfg.plateauWindow} · ${cfg.maxIterations > 0 ? `max ${cfg.maxIterations}` : "no iteration cap"}` +
922
+ (cfg.minimumIterationIntervalMs ? ` · cadence ≥ ${Math.ceil(cfg.minimumIterationIntervalMs / 1_000)}s` : "") +
891
923
  (branchName ? `\nbranch mode: committing improvements to ${branchName}` : ""),
892
924
  "info",
893
925
  );
894
- scheduleLoopTick(ctx);
926
+ scheduleLoopTickUrgent(ctx);
895
927
  return true;
896
928
  }
897
929
 
@@ -920,7 +952,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
920
952
  if (flags.continuationDispatchStoodDown) {
921
953
  releaseContinuationDispatchStandDown();
922
954
  releaseAuditorSurface();
923
- scheduleLoopTick(ctx);
955
+ scheduleLoopTickUrgent(ctx);
924
956
  ctx.ui.notify("Loop dispatch stand-down cleared — retrying one continuation explicitly.", "info");
925
957
  } else {
926
958
  ctx.ui.notify("A loop is already active — /loop status to inspect, /loop stop to end it.", "info");
@@ -939,6 +971,8 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
939
971
  !!r?.startsWith("plateau —") ||
940
972
  !!r?.startsWith("stalled:") ||
941
973
  !!r?.startsWith("stuck —") ||
974
+ !!r?.startsWith("time bound reached") ||
975
+ !!r?.startsWith("token budget exhausted") ||
942
976
  // v0.35.54 (collect-pass HIGH finding): the v0.35.31 "metric never
943
977
  // moved" stop message promises "/loop resume retries or /loop stop",
944
978
  // but this predicate never matched that prefix — the promised command
@@ -983,8 +1017,33 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
983
1017
  // An explicit resume re-arms the counters: fresh stall window,
984
1018
  // cleared dead-turn/stuck streaks, reprieves restored — the user
985
1019
  // saying "push again" wins over the ladder's memory (v0.29.19).
986
- state.loop = { ...stored, active: true, stopReason: undefined, consecutiveErrors: 0, consecutiveStuck: 0, lastStuckReason: undefined, stallCount: 0, auditPlateauReprieves: 0 };
1020
+ // Time and token bounds are per supervised run window: resuming a
1021
+ // bound-stopped loop preserves its iteration/history/best but starts a
1022
+ // fresh elapsed-time window or token budget instead of stopping again
1023
+ // on the same bound.
1024
+ const resetTimeWindow = stored.stopReason?.startsWith("time bound reached") ?? false;
1025
+ const resetTokenBudget = stored.stopReason?.startsWith("token budget exhausted") ?? false;
1026
+ const resumedAt = nowIso();
1027
+ state.loop = {
1028
+ ...stored,
1029
+ active: true,
1030
+ stopReason: undefined,
1031
+ consecutiveErrors: 0,
1032
+ consecutiveStuck: 0,
1033
+ lastStuckReason: undefined,
1034
+ stallCount: 0,
1035
+ auditPlateauReprieves: 0,
1036
+ ...(resetTimeWindow ? { startedAt: resumedAt } : {}),
1037
+ ...(resetTokenBudget ? { tokensUsed: 0 } : {}),
1038
+ };
987
1039
  persistState(ctx);
1040
+ if (resetTimeWindow || resetTokenBudget) {
1041
+ appendLedger(ctx.cwd, "loop_bound_window_reset", {
1042
+ timeWindow: resetTimeWindow,
1043
+ tokenBudget: resetTokenBudget,
1044
+ iteration: stored.iteration,
1045
+ });
1046
+ }
988
1047
  // v0.35.23 (note.md Next #2): an explicit resume is exactly the
989
1048
  // decision a load hold waits for — release it or the tick below
990
1049
  // would be frozen.
@@ -994,9 +1053,14 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
994
1053
  }
995
1054
  releaseContinuationDispatchStandDown();
996
1055
  releaseAuditorSurface();
997
- scheduleLoopTick(ctx);
1056
+ scheduleLoopTickUrgent(ctx);
1057
+ const boundResetNote = resetTimeWindow
1058
+ ? " · fresh time window"
1059
+ : resetTokenBudget
1060
+ ? " · fresh token budget"
1061
+ : "";
998
1062
  ctx.ui.notify(
999
- `Loop resumed: iteration ${stored.iteration}/${stored.maxIterations > 0 ? stored.maxIterations : "∞"} · best ${stored.bestValue ?? "n/a"} — ${displaySlice(stored.target, 60)}`,
1063
+ `Loop resumed: iteration ${stored.iteration}/${stored.maxIterations > 0 ? stored.maxIterations : "∞"} · best ${stored.bestValue ?? "n/a"}${boundResetNote} — ${displaySlice(stored.target, 60)}`,
1000
1064
  "info",
1001
1065
  );
1002
1066
  return;
@@ -1016,7 +1080,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1016
1080
  ctx.ui.notify(formatLoopRecoveryStatus(ctx), "info");
1017
1081
  return;
1018
1082
  }
1019
- ctx.ui.notify("No loop. /loop to draft one, /loop start \"<target>\" for an infinite metricless loop, or add measure=\"<cmd>\" direction=min|max for a metric loop [window=5] [max=50] [time=<hours>] [tokens=<budget>]", "info");
1083
+ ctx.ui.notify("No loop. /loop to draft one, /loop start \"<target>\" for an infinite metricless loop, or add measure=\"<cmd>\" direction=min|max for a metric loop [window=5] [max=50] [time=<hours>] [tokens=<budget>] [cadence=<seconds>]", "info");
1020
1084
  return;
1021
1085
  }
1022
1086
  const lines = [
@@ -1028,6 +1092,11 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1028
1092
  if (loop.timeLimitHours !== undefined) bounds.push(`time ≤ ${loop.timeLimitHours}h`);
1029
1093
  if (loop.tokenBudget !== undefined) bounds.push(`tokens ${(loop.tokensUsed ?? 0).toLocaleString()}/${loop.tokenBudget.toLocaleString()}`);
1030
1094
  if (bounds.length) lines.push(`Bounds: ${bounds.join(" · ")}`);
1095
+ if (loop.minimumIterationIntervalMs !== undefined) {
1096
+ const lastCompleted = loop.lastIterationCompletedAt ? Date.parse(loop.lastIterationCompletedAt) : Number.NaN;
1097
+ const nextDelay = Number.isFinite(lastCompleted) ? Math.max(0, lastCompleted + loop.minimumIterationIntervalMs - Date.now()) : 0;
1098
+ lines.push(`Cadence: ≥ ${Math.ceil(loop.minimumIterationIntervalMs / 1_000)}s between iterations${nextDelay > 0 ? ` · next in ${Math.ceil(nextDelay / 1_000)}s` : " · ready"}`);
1099
+ }
1031
1100
  if (loop.refinements?.length) lines.push(`Spec refined ${loop.refinements.length}× (latest: iteration ${loop.refinements[loop.refinements.length - 1]!.iteration})`);
1032
1101
  if (loop.stopReason) lines.push(`Stopped: ${loop.stopReason}`);
1033
1102
  if (state.mainModelRecovery?.kind === "loop") lines.push(...formatLoopRecoveryStatusLines(ctx));
@@ -752,13 +752,13 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
752
752
  },
753
753
  });
754
754
  pi.registerCommand("loop", {
755
- description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" = infinite metricless loop (no plateau, no cap; ends at time=/tokens= or /loop stop) · /loop respec = infinite metricless reconcile against the root SPEC.md · add measure=\"<cmd>\" direction=min|max [window=5] [max=50] [branch=1] for a metric loop · /loop status · /loop stop (alias /loop cancel). 'Improve until X' is a /goal, not a loop.",
755
+ description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" = infinite metricless loop (no plateau, no cap; ends at time=/tokens= or /loop stop) · /loop respec = infinite metricless reconcile against the root SPEC.md · add measure=\"<cmd>\" direction=min|max [window=5] [max=50] [cadence=<seconds>] [branch=1] for a loop · cadence is opt-in and limits automatic wakes between successful iterations · /loop status · /loop stop (alias /loop cancel). 'Improve until X' is a /goal, not a loop.",
756
756
  getArgumentCompletions: completions([
757
- ["start", "skip drafting: /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50]"],
757
+ ["start", "skip drafting: /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50] [cadence=<seconds>]"],
758
758
  ["respec", "infinite metricless loop reconciling the codebase against the root SPEC.md"],
759
759
  ["plan", "extended loop draft: deep research + multi-round metric design, same Confirm as a regular draft"],
760
760
  ["audit", "project-audit loop: each iteration audits fresh, appends findings, fixes the top ones — plateau stops when the well is dry (v0.29.0)"],
761
- ["status", "show metric, iteration, best/last values, stall count"],
761
+ ["status", "show metric, iteration, best/last values, stall count, and cadence"],
762
762
  ["resume", "resume a held loop (session-restore gate / manual main-model recovery)"],
763
763
  ["refine", "queue an operator respec suggestion into the next iteration's prompt: /loop refine <text>"],
764
764
  ["polish", "alias of /loop refine"],
@@ -318,6 +318,7 @@ import {
318
318
  listAuditFanoutItemText,
319
319
  type LoopTickOutcome,
320
320
  HELD_ON_RESTORE,
321
+ isRefinableStoppedLoopReason,
321
322
  type LoopState,
322
323
  } from "../goal-loop-forever.js";
323
324
  import {
@@ -1804,7 +1805,7 @@ function registerAgentTools(pi: any): void {
1804
1805
  pi.registerTool(defineTool({
1805
1806
  name: "propose_loop_draft",
1806
1807
  label: "Propose loop draft",
1807
- description: "During loop drafting (/loop with no args), propose the loop configuration. The orchestrator test-runs the measure command ONCE and shows the user real output + parsed number in a Confirm dialog. A measure producing no number is auto-rejected. Omit measureCmd (or pass \"none\") for a metricless spec loop — no plateau stop; ends only at bounds or /loop stop.",
1808
+ description: "During loop drafting (/loop with no args), propose the loop configuration. The orchestrator test-runs the measure command ONCE and shows the user real output + parsed number in a Confirm dialog. A measure producing no number is auto-rejected. Omit measureCmd (or pass \"none\") for a metricless spec loop — no plateau stop; ends only at bounds or /loop stop. An optional cadence is the minimum seconds between automatic wakes after successful iterations.",
1808
1809
  parameters: Type.Object({
1809
1810
  target: Type.String({ description: "What to improve, concretely" }),
1810
1811
  measureCmd: Type.Optional(Type.String({ description: 'Shell command that prints ONE number representing progress — or the literal "none" for a metricless spec loop' })),
@@ -1813,12 +1814,13 @@ function registerAgentTools(pi: any): void {
1813
1814
  max: Type.Optional(Type.Number({ description: "Iteration cap (default 50)" })),
1814
1815
  time: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many hours" })),
1815
1816
  tokens: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many tokens (input+output)" })),
1817
+ cadence: Type.Optional(Type.Number({ description: "Minimum seconds between automatic wakes after successful iterations (opt-in; explicit starts/resumes are urgent)" })),
1816
1818
  branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
1817
1819
  }),
1818
1820
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1819
1821
  const foreign3 = foreignToolGuard(execCtx);
1820
1822
  if (foreign3) return { content: [{ type: "text", text: foreign3 }], details: {} };
1821
- const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
1823
+ const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; cadence?: number; branch?: boolean };
1822
1824
  const liveCtx = currentToolContext(execCtx);
1823
1825
  if (!liveCtx) return staleToolResult();
1824
1826
  if (warnIfStaleAtEntry(liveCtx, "loop drafting")) {
@@ -1872,6 +1874,9 @@ function registerAgentTools(pi: any): void {
1872
1874
  };
1873
1875
  }
1874
1876
  const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
1877
+ const cadenceMs = typeof p.cadence === "number" && Number.isFinite(p.cadence) && p.cadence > 0
1878
+ ? Math.min(Math.round(p.cadence * 1_000), 24 * 60 * 60_000)
1879
+ : undefined;
1875
1880
  // v0.23.0: explicit max=0 = truly unbounded (no iteration cap).
1876
1881
  // v0.23.8: metricless + no explicit max = UNBOUNDED here too — the
1877
1882
  // drafter path was still defaulting to 50 after v0.23.6 flipped the
@@ -1889,8 +1894,8 @@ function registerAgentTools(pi: any): void {
1889
1894
  liveCtx,
1890
1895
  "Confirm loop",
1891
1896
  metricless
1892
- ? `Target: ${sanitizeDisplayText(p.target.trim())}\n\nMeasure: NONE — metricless spec loop. There is NO plateau stop: the loop ends only at ${max > 0 ? `${max} iterations` : "NO iteration cap"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""} · /loop stop.${p.branch ? "\nbranch mode: scratch branch, every iteration committed (clean tree required)" : ""}\n\nEvery iteration must make ONE real, inspectable change — cosmetic churn is the known failure mode (doorknob-polishing). Start it?`
1893
- : `Target: ${sanitizeDisplayText(p.target.trim())}\n\nMeasure: ${sanitizeDisplayText(p.measureCmd ?? "")}\nTest-run output: ${sanitizeDisplayText(rawOutput).slice(0, 200)}\nParsed number: ${parsed} (${p.direction === "min" ? "lower is better" : "higher is better"})\n\nPlateau stop: ${window} non-improving iterations · Cap: ${max > 0 ? `${max} iterations` : "none (unbounded)"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""}${p.branch ? "\nbranch mode: scratch branch (clean tree required)" : ""}\n\nThe loop never completes — it runs until one of these bounds, plateau, or /loop stop. Start it?`,
1897
+ ? `Target: ${sanitizeDisplayText(p.target.trim())}\n\nMeasure: NONE — metricless spec loop. There is NO plateau stop: the loop ends only at ${max > 0 ? `${max} iterations` : "NO iteration cap"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""}${cadenceMs ? ` · Cadence: ≥ ${Math.ceil(cadenceMs / 1_000)}s` : ""} · /loop stop.${p.branch ? "\nbranch mode: scratch branch, every iteration committed (clean tree required)" : ""}\n\nEvery iteration must make ONE real, inspectable change — cosmetic churn is the known failure mode (doorknob-polishing). Start it?`
1898
+ : `Target: ${sanitizeDisplayText(p.target.trim())}\n\nMeasure: ${sanitizeDisplayText(p.measureCmd ?? "")}\nTest-run output: ${sanitizeDisplayText(rawOutput).slice(0, 200)}\nParsed number: ${parsed} (${p.direction === "min" ? "lower is better" : "higher is better"})\n\nPlateau stop: ${window} non-improving iterations · Cap: ${max > 0 ? `${max} iterations` : "none (unbounded)"}${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""}${cadenceMs ? ` · Cadence: ≥ ${Math.ceil(cadenceMs / 1_000)}s` : ""}${p.branch ? "\nbranch mode: scratch branch (clean tree required)" : ""}\n\nThe loop never completes — it runs until one of these bounds, plateau, or /loop stop. Start it?`,
1894
1899
  );
1895
1900
  confirmed = c === "yes";
1896
1901
  } catch {
@@ -1913,6 +1918,7 @@ function registerAgentTools(pi: any): void {
1913
1918
  maxIterations: max,
1914
1919
  timeLimitHours: typeof p.time === "number" && Number.isFinite(p.time) && p.time > 0 ? p.time : undefined,
1915
1920
  tokenBudget: typeof p.tokens === "number" && Number.isFinite(p.tokens) && p.tokens > 0 ? Math.floor(p.tokens) : undefined,
1921
+ minimumIterationIntervalMs: cadenceMs,
1916
1922
  branch: p.branch === true,
1917
1923
  });
1918
1924
  if (!started) {
@@ -1928,7 +1934,7 @@ function registerAgentTools(pi: any): void {
1928
1934
  pi.registerTool(defineTool({
1929
1935
  name: "propose_loop_refine",
1930
1936
  label: "Propose loop spec refinement",
1931
- description: "While a loop is ACTIVE, propose refining the loop's spec — sharpen the target and/or change the measure command — when the current spec no longer captures 'better'. The user confirms; on a measure change the orchestrator test-runs the new command and re-baselines. Never edit the measure command or its inputs directly — that is gaming the metric.",
1937
+ description: "While a loop is active or safely stopped by a recoverable bound/failure, propose refining its spec — sharpen the target and/or change the measure command — when the current spec no longer captures 'better'. The user confirms; on a measure change the orchestrator test-runs the new command and re-baselines. Never edit the measure command or its inputs directly — that is gaming the metric.",
1932
1938
  parameters: Type.Object({
1933
1939
  target: Type.Optional(Type.String({ description: "The sharpened target text (omit to keep the current target)" })),
1934
1940
  measureCmd: Type.Optional(Type.String({ description: "The new measure command printing ONE number (omit to keep the current metric)" })),
@@ -1943,9 +1949,11 @@ function registerAgentTools(pi: any): void {
1943
1949
  const liveCtx = currentToolContext(execCtx);
1944
1950
  if (!liveCtx) return staleToolResult();
1945
1951
  const loop = state.loop;
1946
- if (!loop?.active) {
1947
- return { content: [{ type: "text", text: "No active loop to refine. propose_loop_refine is only valid while a loop is running." }], details: {} };
1952
+ const stoppedRefinable = !!loop && !loop.active && isRefinableStoppedLoopReason(loop.stopReason);
1953
+ if (!loop || (!loop.active && !stoppedRefinable)) {
1954
+ return { content: [{ type: "text", text: "No refinable loop is available. propose_loop_refine applies while a loop is running or after a recoverable bound/failure stop; clean max-iteration and user-finished loops require /loop start." }], details: {} };
1948
1955
  }
1956
+ const wasActive = loop.active;
1949
1957
  const newTarget = p.target?.trim() || loop.target;
1950
1958
  const newMeasure = p.measureCmd?.trim() || loop.measureCmd || "";
1951
1959
  // v0.23.0: a metricless loop can't be refined into a measured one
@@ -1989,7 +1997,7 @@ function registerAgentTools(pi: any): void {
1989
1997
  confirmed = (await confirmDraft(
1990
1998
  liveCtx,
1991
1999
  "Confirm loop spec refinement",
1992
- `Rationale: ${sanitizeDisplayText(p.rationale)}\n\nTarget:\n old: ${displaySlice(loop.target, 120)}\n new: ${displaySlice(newTarget, 120)}\n\nMeasure:\n old: ${sanitizeDisplayText(loop.measureCmd ?? "none")}\n new: ${sanitizeDisplayText(newMeasure)}${newMeasure !== loop.measureCmd ? `\n test-run: ${sanitizeDisplayText(testOutput).slice(0, 120)} → ${newBaseline}` : ""}${specChange ? `\n\nSpec file (${sanitizeDisplayText(loop.specFile ?? "")}:\n ${p.specText?.trim() ? `REPLACE with ${p.specText!.trim().length} chars` : ""}${p.specText?.trim() && p.specAppend?.trim() ? " + " : ""}${p.specAppend?.trim() ? `APPEND: ${sanitizeDisplayText(p.specAppend!.trim()).slice(0, 120)}` : ""}` : ""}\n\nThe loop keeps running against the refined spec (iteration ${loop.iteration} so far). Apply?`,
2000
+ `Rationale: ${sanitizeDisplayText(p.rationale)}\n\nTarget:\n old: ${displaySlice(loop.target, 120)}\n new: ${displaySlice(newTarget, 120)}\n\nMeasure:\n old: ${sanitizeDisplayText(loop.measureCmd ?? "none")}\n new: ${sanitizeDisplayText(newMeasure)}${newMeasure !== loop.measureCmd ? `\n test-run: ${sanitizeDisplayText(testOutput).slice(0, 120)} → ${newBaseline}` : ""}${specChange ? `\n\nSpec file (${sanitizeDisplayText(loop.specFile ?? "")}:\n ${p.specText?.trim() ? `REPLACE with ${p.specText!.trim().length} chars` : ""}${p.specText?.trim() && p.specAppend?.trim() ? " + " : ""}${p.specAppend?.trim() ? `APPEND: ${sanitizeDisplayText(p.specAppend!.trim()).slice(0, 120)}` : ""}` : ""}\n\nThe loop ${wasActive ? "keeps running" : "stays stopped until /loop resume"} against the refined spec (iteration ${loop.iteration} so far). Apply?`,
1993
2001
  )) === "yes";
1994
2002
  } catch {
1995
2003
  confirmed = false;
@@ -2026,8 +2034,10 @@ function registerAgentTools(pi: any): void {
2026
2034
  }
2027
2035
  persistState(liveCtx);
2028
2036
  appendLedger(liveCtx.cwd, "loop_refined", { iteration: loop.iteration, newTarget, newMeasureCmd: newMeasure, newBaseline, specChanged: specChange || undefined });
2029
- liveCtx.ui.notify(`Loop spec refined at iteration ${loop.iteration}.${newBaseline !== null ? ` New baseline: ${newBaseline}.` : ""}${specChange ? " Spec file updated." : ""}`, "info");
2030
- return { content: [{ type: "text", text: "Refinement confirmed and applied. Continue improving against the NEW spec — one small change per turn." }], details: {} };
2037
+ liveCtx.ui.notify(`Loop spec refined at iteration ${loop.iteration}.${newBaseline !== null ? ` New baseline: ${newBaseline}.` : ""}${specChange ? " Spec file updated." : ""}${wasActive ? "" : " Run /loop resume to continue with the preserved history."}`, "info");
2038
+ return { content: [{ type: "text", text: wasActive
2039
+ ? "Refinement confirmed and applied. Continue improving against the NEW spec — one small change per turn."
2040
+ : "Refinement confirmed and applied to the stopped loop. Run /loop resume to continue with the preserved history." }], details: {} };
2031
2041
  },
2032
2042
  }));
2033
2043
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.35.67",
3
+ "version": "0.35.69",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",
@@ -65,7 +65,9 @@ stops it.
65
65
  furnace.
66
66
  5. When concrete, call `propose_loop_draft` with `target`, `measureCmd` (or
67
67
  omit/`"none"` for metricless), `direction` (measured only), and optional
68
- `window`/`max`/`time`/`tokens`.
68
+ `window`/`max`/`time`/`tokens`/`cadence`. `cadence` is seconds between
69
+ successful automatic iterations; it is opt-in, shown in `/loop status`, and
70
+ explicit starts/resumes remain urgent.
69
71
  6. **The orchestrator will run your proposed measure command ONCE** and show
70
72
  the user the real output and parsed number in the Confirm dialog. If your
71
73
  command produces no number, the proposal is rejected automatically — fix