pi-goal-list-loop-audit 0.22.7 → 0.23.1

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/README.md CHANGED
@@ -61,10 +61,21 @@ matches `/list show`.
61
61
  /loop start "reduce TODOs" measure="grep -c TODO src.txt | head -1" direction=min
62
62
  /loop start "shrink the bundle" measure="..." direction=min time=4 tokens=500000 # arbitrary bounds
63
63
  /loop start "reduce TODOs" measure="..." direction=min branch=1 # scratch-branch mode
64
+ /loop start "keep improving SPEC.md" measure=none max=20 # metricless spec loop (v0.23.0)
64
65
  /loop status # iteration, best, stall, recent values
65
66
  /loop stop # halt with summary
66
67
  ```
67
68
 
69
+ **Metricless loops** (`measure=none`): for genuinely endless work — an
70
+ ever-improving spec, continuous hardening — where no number means "better".
71
+ There is **no plateau stop** (nothing to stall on): the loop ends only at
72
+ its bounds (`max` iterations — `max=0` is truly unbounded — `time` hours,
73
+ `tokens` budget) or `/loop stop`. Every iteration must make one real,
74
+ inspectable change; cosmetic churn is the known failure mode
75
+ (doorknob-polishing). The drafter offers this when you say there is no
76
+ number, and tells you the trade-off before you confirm. Work with a finish
77
+ line is still a `/goal`.
78
+
68
79
  Subcommands match **exactly** — `/goal pause the pipeline` sets an objective
69
80
  about a pipeline; only bare `/goal pause` pauses. (Same rule everywhere, so
70
81
  your objectives can start with any verb.)
@@ -81,10 +81,14 @@ export interface AuditDisplayProgress {
81
81
  export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme): string | undefined {
82
82
  if (state.loop?.active) {
83
83
  const l = state.loop;
84
+ // v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
85
+ if (!l.measureCmd) {
86
+ return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless`;
87
+ }
84
88
  const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
85
89
  const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
86
90
  const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
87
- return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations} · best ${l.bestValue ?? "n/a"} · ${stall}`;
91
+ return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}`;
88
92
  }
89
93
  const g = state.goal;
90
94
  if (!g) return undefined;
@@ -179,13 +183,23 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
179
183
  }
180
184
 
181
185
  function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number): string[] {
186
+ // v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
187
+ if (!l.measureCmd) {
188
+ const lines = [
189
+ `${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
190
+ `├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
191
+ `└─ ${paint(theme, "dim", "metricless — work the spec (no plateau)")}`,
192
+ ];
193
+ if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
194
+ return lines;
195
+ }
182
196
  const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
183
197
  const best = paint(theme, "success", `${l.bestValue ?? "n/a"}`);
184
198
  const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
185
199
  const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
186
200
  const lines = [
187
201
  `${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
188
- `├─ loop ${arrow} iter ${l.iteration}/${l.maxIterations} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
202
+ `├─ loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
189
203
  `├─ best ${best} · last ${l.lastValue ?? "n/a"} · ${stall}`,
190
204
  `└─ ${paint(theme, "dim", truncate(l.measureCmd, budgetFor(width, 3, 56)))}`,
191
205
  ];
@@ -30,9 +30,13 @@ export interface LoopRefinement {
30
30
 
31
31
  export interface LoopState {
32
32
  target: string;
33
- measureCmd: string;
34
- direction: LoopDirection;
33
+ /** v0.23.0: optional — a metricless "spec loop" (measure=none) has no
34
+ * metric, no direction, and NO plateau stop; it ends only at max/time/
35
+ * tokens bounds or /loop stop. */
36
+ measureCmd?: string;
37
+ direction?: LoopDirection;
35
38
  iteration: number;
39
+ /** v0.23.0: 0 = unbounded (no iteration cap). Default 50. */
36
40
  maxIterations: number;
37
41
  plateauWindow: number;
38
42
  stallCount: number;
@@ -124,7 +128,7 @@ export type LoopTickOutcome =
124
128
  */
125
129
  export function applyMeasurement(loop: LoopState, value: number | null, at: string): LoopTickOutcome {
126
130
  loop.iteration++;
127
- const improved = value !== null && isImprovement(loop.direction, value, loop.bestValue);
131
+ const improved = value !== null && loop.direction !== undefined && isImprovement(loop.direction, value, loop.bestValue);
128
132
  if (value === null) {
129
133
  loop.stallCount++;
130
134
  } else if (improved) {
@@ -155,7 +159,7 @@ export function applyMeasurement(loop: LoopState, value: number | null, at: stri
155
159
  loop.stopReason = `plateau — no improvement in ${loop.plateauWindow} consecutive iterations (best: ${loop.bestValue ?? "n/a"})`;
156
160
  return { kind: "stop", reason: loop.stopReason };
157
161
  }
158
- if (loop.iteration >= loop.maxIterations) {
162
+ if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
159
163
  loop.active = false;
160
164
  loop.stopReason = `max iterations reached (${loop.maxIterations}); best: ${loop.bestValue ?? "n/a"}`;
161
165
  return { kind: "stop", reason: loop.stopReason };
@@ -163,11 +167,45 @@ export function applyMeasurement(loop: LoopState, value: number | null, at: stri
163
167
  return { kind: "continue", improved, value };
164
168
  }
165
169
 
170
+ /**
171
+ * One iteration of a METRICLESS loop (v0.23.0, measure=none). There is no
172
+ * number to judge movement, so there is no plateau — the loop ends only at
173
+ * the time/token/iteration bounds or /loop stop. This is the Sisyphus mode:
174
+ * work the spec until the bounds say stop. The doorknob risk is real and
175
+ * accepted by the user explicitly; the iteration prompt demands one real,
176
+ * inspectable change per turn.
177
+ */
178
+ export function applyMetriclessTick(loop: LoopState, at: string): LoopTickOutcome {
179
+ loop.iteration++;
180
+ loop.history.push({ iteration: loop.iteration, value: null, improved: false, at });
181
+ if (loop.history.length > 200) loop.history.splice(0, loop.history.length - 200);
182
+
183
+ if (loop.timeLimitHours !== undefined) {
184
+ const elapsedH = (Date.parse(at) - Date.parse(loop.startedAt)) / 3_600_000;
185
+ if (Number.isFinite(elapsedH) && elapsedH >= loop.timeLimitHours) {
186
+ loop.active = false;
187
+ loop.stopReason = `time bound reached (${loop.timeLimitHours}h) after ${loop.iteration} iterations`;
188
+ return { kind: "stop", reason: loop.stopReason };
189
+ }
190
+ }
191
+ if (loop.tokenBudget !== undefined && (loop.tokensUsed ?? 0) >= loop.tokenBudget) {
192
+ loop.active = false;
193
+ loop.stopReason = `token budget exhausted (${(loop.tokensUsed ?? 0).toLocaleString()} >= ${loop.tokenBudget.toLocaleString()}) after ${loop.iteration} iterations`;
194
+ return { kind: "stop", reason: loop.stopReason };
195
+ }
196
+ if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
197
+ loop.active = false;
198
+ loop.stopReason = `max iterations reached (${loop.maxIterations})`;
199
+ return { kind: "stop", reason: loop.stopReason };
200
+ }
201
+ return { kind: "continue", improved: false, value: null };
202
+ }
203
+
166
204
  /** Parse `/loop start` args into a config. Throws on missing pieces. */
167
205
  export function parseLoopStartArgs(raw: string): {
168
206
  target: string;
169
207
  measureCmd: string;
170
- direction: LoopDirection;
208
+ direction?: LoopDirection;
171
209
  plateauWindow: number;
172
210
  maxIterations: number;
173
211
  branch: boolean;
@@ -196,10 +234,14 @@ export function parseLoopStartArgs(raw: string): {
196
234
  target += rest.slice(cursor);
197
235
  target = target.trim().replace(/^["']|["']$/g, "").trim();
198
236
 
199
- const measureCmd = kv.get("measure") ?? "";
200
- if (!measureCmd) throw new Error('missing measure="<shell command that prints a number>"');
237
+ const measureRaw = (kv.get("measure") ?? "").trim();
238
+ // v0.23.0: measure=none metricless "spec loop" (Sisyphus mode). No
239
+ // metric, no direction, no plateau — bounds and /loop stop only.
240
+ const metricless = measureRaw.toLowerCase() === "none";
241
+ if (!measureRaw) throw new Error('missing measure="<shell command that prints a number>" (or measure=none for a metricless spec loop — no plateau, ends only at bounds or /loop stop)');
201
242
  const dirRaw = (kv.get("direction") ?? "").toLowerCase();
202
- if (dirRaw !== "min" && dirRaw !== "max") throw new Error("missing direction=min|max");
243
+ if (metricless && dirRaw) throw new Error("direction= is meaningless with measure=none — there is no metric to have a direction");
244
+ if (!metricless && dirRaw !== "min" && dirRaw !== "max") throw new Error("missing direction=min|max");
203
245
  if (!target) throw new Error("missing target (what to improve), e.g. /loop start \"reduce test failures\" measure=\"...\" direction=min");
204
246
 
205
247
  const window = Number.parseInt(kv.get("window") ?? "", 10);
@@ -218,10 +260,11 @@ export function parseLoopStartArgs(raw: string): {
218
260
  const tokensRaw = Number.parseInt(kv.get("tokens") ?? "", 10);
219
261
  return {
220
262
  target,
221
- measureCmd,
222
- direction: dirRaw,
263
+ measureCmd: metricless ? "" : measureRaw,
264
+ direction: metricless ? undefined : dirRaw as LoopDirection,
223
265
  plateauWindow: Number.isFinite(window) && window > 0 ? window : LOOP_DEFAULTS.plateauWindow,
224
- maxIterations: Number.isFinite(max) && max > 0 ? max : LOOP_DEFAULTS.maxIterations,
266
+ // v0.23.0: max=0 = truly unbounded (no iteration cap); absent = 50.
267
+ maxIterations: kv.has("max") ? (Number.isFinite(max) && max >= 0 ? max : LOOP_DEFAULTS.maxIterations) : LOOP_DEFAULTS.maxIterations,
225
268
  branch: branchRaw === "1" || branchRaw === "true" || branchRaw === "yes",
226
269
  force: forceRaw === "1" || forceRaw === "true" || forceRaw === "yes",
227
270
  timeLimitHours: Number.isFinite(timeRaw) && timeRaw > 0 ? timeRaw : undefined,
@@ -63,6 +63,7 @@ import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
63
63
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
64
64
  import {
65
65
  applyMeasurement,
66
+ applyMetriclessTick,
66
67
  applyRefinement,
67
68
  loopBranchName,
68
69
  parseLoopStartArgs,
@@ -955,18 +956,23 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
955
956
  }
956
957
 
957
958
  function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
958
- const tmplPath = path.resolve(__dirname, "..", "..", "prompts", "goal-loop-forever.md");
959
+ // v0.23.0: metricless loops get their own prompt — no metric section,
960
+ // anti-doorknob rules instead of anti-gaming rules.
961
+ const metricless = !loop.measureCmd;
962
+ const tmplPath = path.resolve(__dirname, "..", "..", "prompts", metricless ? "goal-loop-forever-metricless.md" : "goal-loop-forever.md");
959
963
  let tmpl: string;
960
964
  try {
961
965
  tmpl = fs.readFileSync(tmplPath, "utf-8");
962
966
  } catch {
963
- tmpl = `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric.`;
967
+ tmpl = metricless
968
+ ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn.`
969
+ : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric.`;
964
970
  }
965
971
  return tmpl
966
972
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
967
973
  .replace(/\$\{TARGET\}/g, loop.target)
968
- .replace(/\$\{MEASURE_CMD\}/g, loop.measureCmd)
969
- .replace(/\$\{DIRECTION\}/g, loop.direction)
974
+ .replace(/\$\{MEASURE_CMD\}/g, loop.measureCmd ?? "none")
975
+ .replace(/\$\{DIRECTION\}/g, loop.direction ?? "none")
970
976
  .replace(/\$\{DIRECTION_WORD\}/g, loop.direction === "min" ? "lower is better" : "higher is better")
971
977
  .replace(/\$\{LAST_VALUE\}/g, loop.lastValue === null ? "(none yet)" : String(loop.lastValue))
972
978
  .replace(/\$\{BEST_VALUE\}/g, loop.bestValue === null ? "(none yet)" : String(loop.bestValue))
@@ -1011,10 +1017,22 @@ function sendLoopTurn(): void {
1011
1017
  ? "**You are one stall from a plateau stop. Small tweaks are not working — try a FUNDAMENTALLY different approach: different file, different technique, or revert and rethink the angle of attack.**"
1012
1018
  : "";
1013
1019
  // v0.15.0: arbitrary bounds (never "completion") — surface what's armed.
1020
+ // v0.23.0: for metricless loops the bounds are the ONLY stop (no
1021
+ // plateau), so the note names that — and an unbounded metricless loop
1022
+ // gets the furnace warning.
1023
+ const metricless = !loop.measureCmd;
1014
1024
  const bounds: string[] = [];
1015
1025
  if (loop.timeLimitHours !== undefined) bounds.push(`${loop.timeLimitHours}h`);
1016
1026
  if (loop.tokenBudget !== undefined) bounds.push(`${loop.tokenBudget.toLocaleString()} tokens (used ${(loop.tokensUsed ?? 0).toLocaleString()})`);
1017
- const boundsNote = bounds.length ? `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}` : "";
1027
+ let boundsNote = "";
1028
+ if (metricless) {
1029
+ if (loop.maxIterations > 0) bounds.unshift(`${loop.maxIterations} iterations`);
1030
+ boundsNote = bounds.length
1031
+ ? `\n- Bounds armed: the loop ends after ${bounds.join(" or ")} — or /loop stop. There is NO plateau stop.`
1032
+ : `\n- NO bounds armed — this loop ends only at /loop stop. Spend each iteration like it costs money; it does.`;
1033
+ } else if (bounds.length) {
1034
+ boundsNote = `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}`;
1035
+ }
1018
1036
  try {
1019
1037
  extensionApi.sendMessage({
1020
1038
  customType: GOAL_EVENT_ENTRY,
@@ -1033,7 +1051,8 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1033
1051
  if (event?.messages) {
1034
1052
  loop.tokensUsed = (loop.tokensUsed ?? 0) + sumNewAssistantTokens(event.messages as unknown[], countedLoopTokenMessages);
1035
1053
  }
1036
- const value = await runMeasure(ctx, loop.measureCmd);
1054
+ const metricless = !loop.measureCmd;
1055
+ const value = metricless ? null : await runMeasure(ctx, loop.measureCmd!);
1037
1056
  // Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
1038
1057
  // for the turn goes into the ledger, making loop history auditable.
1039
1058
  let hypothesis: string | undefined;
@@ -1042,7 +1061,7 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1042
1061
  const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1043
1062
  hypothesis = text.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1044
1063
  }
1045
- const outcome = applyMeasurement(loop, value, nowIso());
1064
+ const outcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
1046
1065
  persistState(ctx);
1047
1066
  appendLedger(ctx.cwd, "loop_measured", {
1048
1067
  iteration: loop.iteration,
@@ -1052,11 +1071,12 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1052
1071
  hypothesis,
1053
1072
  });
1054
1073
  // branch=1 mode: commit improvements, hard-reset regressions — always and
1055
- // only on the scratch branch.
1074
+ // only on the scratch branch. v0.23.0: a metricless loop has no regression
1075
+ // signal, so every iteration stands and is committed.
1056
1076
  if (loop.branchName && outcome.kind === "continue") {
1057
- if (outcome.improved) {
1077
+ if (metricless || outcome.improved) {
1058
1078
  await runGit(ctx, ["add", "-A"]);
1059
- const committed = await runGit(ctx, ["commit", "-m", `pi-glla-loop: iteration ${loop.iteration} (${loop.direction}=${loop.bestValue})`]);
1079
+ const committed = await runGit(ctx, ["commit", "-m", metricless ? `pi-glla-loop: iteration ${loop.iteration}` : `pi-glla-loop: iteration ${loop.iteration} (${loop.direction}=${loop.bestValue})`]);
1060
1080
  appendLedger(ctx.cwd, "loop_git", { action: "commit", iteration: loop.iteration, ok: committed.ok });
1061
1081
  } else {
1062
1082
  const reset = await runGit(ctx, ["reset", "--hard", "HEAD"]);
@@ -1092,8 +1112,9 @@ async function finishLoopGit(ctx: ExtensionContext, loop: LoopState): Promise<vo
1092
1112
 
1093
1113
  interface LoopConfig {
1094
1114
  target: string;
1115
+ /** Empty string = metricless spec loop (v0.23.0). */
1095
1116
  measureCmd: string;
1096
- direction: "min" | "max";
1117
+ direction?: "min" | "max";
1097
1118
  plateauWindow: number;
1098
1119
  maxIterations: number;
1099
1120
  branch: boolean;
@@ -1132,8 +1153,11 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1132
1153
  // produces no number is a footgun: without a baseline the loop burns stall
1133
1154
  // iterations before plateau stops it. Refuse fast (force=1 overrides for
1134
1155
  // measures that only work after the agent builds something first).
1135
- const baseline = await runMeasure(ctx, cfg.measureCmd);
1136
- if (baseline === null && !(cfg as { force?: boolean }).force) {
1156
+ // v0.23.0: metricless loops skip the baseline entirely there is no
1157
+ // measure to run, and no plateau to protect.
1158
+ const metricless = !cfg.measureCmd;
1159
+ const baseline = metricless ? null : await runMeasure(ctx, cfg.measureCmd);
1160
+ if (!metricless && baseline === null && !(cfg as { force?: boolean }).force) {
1137
1161
  ctx.ui.notify(
1138
1162
  `/loop start refused: the measure produced no number.\nCommand: ${cfg.measureCmd}\nFix it so it prints exactly one number, or re-run with force=1 if it only works after the agent builds something first.\n(Non-numeric goal — research, docs, features? Use /goal: the independent auditor verifies semantically. /loop only believes a number.)`,
1139
1163
  "warning",
@@ -1144,7 +1168,7 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1144
1168
  ...state,
1145
1169
  loop: {
1146
1170
  target: cfg.target,
1147
- measureCmd: cfg.measureCmd,
1171
+ measureCmd: cfg.measureCmd || undefined,
1148
1172
  direction: cfg.direction,
1149
1173
  iteration: 0,
1150
1174
  maxIterations: cfg.maxIterations,
@@ -1163,10 +1187,13 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1163
1187
  },
1164
1188
  };
1165
1189
  persistState(ctx);
1166
- appendLedger(ctx.cwd, "loop_started", { target: cfg.target, measureCmd: cfg.measureCmd, direction: cfg.direction, baseline, branch: branchName, timeLimitHours: cfg.timeLimitHours, tokenBudget: cfg.tokenBudget });
1190
+ 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 });
1167
1191
  ctx.ui.notify(
1168
- `Loop started: ${cfg.target.slice(0, 60)}\nBaseline: ${baseline ?? "(forced without a number — first turn must produce one)"} · direction ${cfg.direction} · window ${cfg.plateauWindow} · max ${cfg.maxIterations}` +
1169
- (branchName ? `\nbranch mode: committing improvements to ${branchName}` : ""),
1192
+ metricless
1193
+ ? `Loop started (metricless spec loop — NO plateau stop): ${cfg.target.slice(0, 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.` +
1194
+ (branchName ? `\nbranch mode: committing each iteration to ${branchName}` : "")
1195
+ : `Loop started: ${cfg.target.slice(0, 60)}\nBaseline: ${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"}` +
1196
+ (branchName ? `\nbranch mode: committing improvements to ${branchName}` : ""),
1170
1197
  "info",
1171
1198
  );
1172
1199
  scheduleLoopTick(ctx);
@@ -1192,7 +1219,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1192
1219
  persistState(ctx);
1193
1220
  scheduleLoopTick(ctx);
1194
1221
  ctx.ui.notify(
1195
- `Loop resumed: iteration ${stored.iteration}/${stored.maxIterations} · best ${stored.bestValue ?? "n/a"} — ${stored.target.slice(0, 60)}`,
1222
+ `Loop resumed: iteration ${stored.iteration}/${stored.maxIterations > 0 ? stored.maxIterations : "∞"} · best ${stored.bestValue ?? "n/a"} — ${stored.target.slice(0, 60)}`,
1196
1223
  "info",
1197
1224
  );
1198
1225
  return;
@@ -1209,8 +1236,8 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1209
1236
  }
1210
1237
  const lines = [
1211
1238
  `Loop: ${loop.active ? "active" : "stopped"} — ${loop.target.slice(0, 80)}`,
1212
- `Metric: ${loop.measureCmd} (${loop.direction})`,
1213
- `Iteration ${loop.iteration}/${loop.maxIterations} · best ${loop.bestValue ?? "n/a"} · last ${loop.lastValue ?? "n/a"} · stall ${loop.stallCount}/${loop.plateauWindow}`,
1239
+ `Metric: ${loop.measureCmd ? `${loop.measureCmd} (${loop.direction})` : "none — metricless spec loop (no plateau)"}`,
1240
+ `Iteration ${loop.iteration}/${loop.maxIterations > 0 ? loop.maxIterations : "∞"} · best ${loop.bestValue ?? "n/a"} · last ${loop.lastValue ?? "n/a"} · stall ${loop.stallCount}/${loop.plateauWindow}`,
1214
1241
  ];
1215
1242
  const bounds: string[] = [];
1216
1243
  if (loop.timeLimitHours !== undefined) bounds.push(`time ≤ ${loop.timeLimitHours}h`);
@@ -1634,11 +1661,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1634
1661
  pi.registerTool(defineTool({
1635
1662
  name: "propose_loop_draft",
1636
1663
  label: "Propose loop draft",
1637
- 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.",
1664
+ 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.",
1638
1665
  parameters: Type.Object({
1639
1666
  target: Type.String({ description: "What to improve, concretely" }),
1640
- measureCmd: Type.String({ description: "Shell command that prints ONE number representing progress" }),
1641
- direction: Type.Union([Type.Literal("min"), Type.Literal("max")], { description: "min = lower is better, max = higher is better" }),
1667
+ measureCmd: Type.Optional(Type.String({ description: 'Shell command that prints ONE number representing progress — or the literal "none" for a metricless spec loop' })),
1668
+ direction: Type.Optional(Type.Union([Type.Literal("min"), Type.Literal("max")], { description: "min = lower is better, max = higher is better (omit for a metricless loop)" })),
1642
1669
  window: Type.Optional(Type.Number({ description: "Plateau stop after N non-improving iterations (default 5)" })),
1643
1670
  max: Type.Optional(Type.Number({ description: "Iteration cap (default 50)" })),
1644
1671
  time: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many hours" })),
@@ -1646,7 +1673,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1646
1673
  branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
1647
1674
  }),
1648
1675
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1649
- const p = params as { target: string; measureCmd: string; direction: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
1676
+ const p = params as { target: string; measureCmd?: string; direction?: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
1650
1677
  if (draftingTarget !== "loop") {
1651
1678
  return {
1652
1679
  content: [{ type: "text", text: "Not in loop drafting mode. The user starts loop drafting with /loop (no args), or starts directly with /loop start." }],
@@ -1659,24 +1686,30 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1659
1686
  if (loopBlock) {
1660
1687
  return { content: [{ type: "text", text: loopBlock }], details: {} };
1661
1688
  }
1662
- if (!p.target?.trim() || !p.measureCmd?.trim()) {
1663
- return { content: [{ type: "text", text: "target and measureCmd are both required." }], details: {} };
1689
+ if (!p.target?.trim()) {
1690
+ return { content: [{ type: "text", text: "target is required." }], details: {} };
1691
+ }
1692
+ // v0.23.0: measureCmd omitted or "none" → metricless spec loop.
1693
+ const metricless = !p.measureCmd?.trim() || p.measureCmd.trim().toLowerCase() === "none";
1694
+ if (!metricless && p.direction !== "min" && p.direction !== "max") {
1695
+ return { content: [{ type: "text", text: 'direction=min|max is required for a measured loop (omit measureCmd or pass "none" for a metricless spec loop).' }], details: {} };
1664
1696
  }
1665
1697
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
1666
1698
  // THE TEST-RUN: orchestrator runs the proposed measure once. The user
1667
1699
  // sees the real number before a single iteration burns tokens.
1700
+ // (Metricless loops skip this — there is no measure to test-run.)
1668
1701
  let rawOutput = "";
1669
1702
  let parsed: number | null = null;
1670
- if (extensionApi) {
1703
+ if (!metricless && extensionApi) {
1671
1704
  try {
1672
- const result = await extensionApi.exec("bash", ["-c", p.measureCmd], { cwd: liveCtx.cwd });
1705
+ const result = await extensionApi.exec("bash", ["-c", p.measureCmd!], { cwd: liveCtx.cwd });
1673
1706
  rawOutput = String((result as any)?.stdout ?? "").trim();
1674
1707
  parsed = parseMetric(rawOutput);
1675
1708
  } catch (err) {
1676
1709
  rawOutput = `(measure command failed: ${err instanceof Error ? err.message : String(err)})`;
1677
1710
  }
1678
1711
  }
1679
- if (parsed === null) {
1712
+ if (!metricless && parsed === null) {
1680
1713
  return {
1681
1714
  content: [{
1682
1715
  type: "text",
@@ -1686,12 +1719,15 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1686
1719
  };
1687
1720
  }
1688
1721
  const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
1689
- const max = p.max && p.max > 0 ? Math.floor(p.max) : 50;
1722
+ // v0.23.0: explicit max=0 = truly unbounded (no iteration cap).
1723
+ const max = p.max !== undefined && Number.isFinite(p.max) && p.max >= 0 ? Math.floor(p.max) : 50;
1690
1724
  let confirmed = false;
1691
1725
  try {
1692
1726
  confirmed = await liveCtx.ui.confirm(
1693
1727
  "Confirm loop",
1694
- `Target: ${p.target.trim()}\n\nMeasure: ${p.measureCmd}\nTest-run output: ${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} iterations${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?`,
1728
+ metricless
1729
+ ? `Target: ${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?`
1730
+ : `Target: ${p.target.trim()}\n\nMeasure: ${p.measureCmd}\nTest-run output: ${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?`,
1695
1731
  );
1696
1732
  } catch {
1697
1733
  confirmed = false;
@@ -1705,8 +1741,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1705
1741
  draftingTarget = null;
1706
1742
  const started = await startLoopFromConfig(liveCtx, {
1707
1743
  target: p.target.trim(),
1708
- measureCmd: p.measureCmd,
1709
- direction: p.direction,
1744
+ measureCmd: metricless ? "" : p.measureCmd!.trim(),
1745
+ direction: metricless ? undefined : p.direction,
1710
1746
  plateauWindow: window,
1711
1747
  maxIterations: max,
1712
1748
  timeLimitHours: typeof p.time === "number" && Number.isFinite(p.time) && p.time > 0 ? p.time : undefined,
@@ -1717,7 +1753,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1717
1753
  return { content: [{ type: "text", text: "Loop could not start (see the warning above — likely a git/dirty-tree issue with branch mode)." }], details: {} };
1718
1754
  }
1719
1755
  return {
1720
- content: [{ type: "text", text: `Loop confirmed and started. Baseline ${parsed}. Make ONE small change per turn to move the metric ${p.direction === "min" ? "down" : "up"}.` }],
1756
+ content: [{ type: "text", text: metricless ? "Loop confirmed and started (metricless — no plateau). Make ONE real, inspectable change per turn." : `Loop confirmed and started. Baseline ${parsed}. Make ONE small change per turn to move the metric ${p.direction === "min" ? "down" : "up"}.` }],
1721
1757
  details: {},
1722
1758
  };
1723
1759
  },
@@ -1740,7 +1776,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1740
1776
  return { content: [{ type: "text", text: "No active loop to refine. propose_loop_refine is only valid while a loop is running." }], details: {} };
1741
1777
  }
1742
1778
  const newTarget = p.target?.trim() || loop.target;
1743
- const newMeasure = p.measureCmd?.trim() || loop.measureCmd;
1779
+ const newMeasure = p.measureCmd?.trim() || loop.measureCmd || "";
1780
+ // v0.23.0: a metricless loop can't be refined into a measured one
1781
+ // (no direction, no baseline semantics) — stop and restart instead.
1782
+ if (!loop.measureCmd && p.measureCmd?.trim()) {
1783
+ return { content: [{ type: "text", text: "This loop is metricless — refining it into a measured loop isn't supported. /loop stop, then /loop start with a metric." }], details: {} };
1784
+ }
1744
1785
  if (newTarget === loop.target && newMeasure === loop.measureCmd) {
1745
1786
  return { content: [{ type: "text", text: "Refinement proposed no changes — provide a new target, a new measureCmd, or both." }], details: {} };
1746
1787
  }
@@ -1780,7 +1821,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1780
1821
  iteration: loop.iteration,
1781
1822
  oldTarget: loop.target,
1782
1823
  newTarget,
1783
- oldMeasureCmd: loop.measureCmd,
1824
+ oldMeasureCmd: loop.measureCmd ?? "",
1784
1825
  newMeasureCmd: newMeasure,
1785
1826
  }, newBaseline);
1786
1827
  persistState(liveCtx);
@@ -2337,7 +2378,7 @@ export default function (pi: ExtensionAPI): void {
2337
2378
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
2338
2379
  });
2339
2380
  pi.registerCommand("loop", {
2340
- description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50] [time=<hours>] [tokens=<budget>] [branch=1] skips drafting · /loop status · /loop stop. 'Improve until X' is a /goal, not a loop.",
2381
+ description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50] [time=<hours>] [tokens=<budget>] [branch=1] skips drafting · measure=none = metricless spec loop (no plateau; max=0 = unbounded) · /loop status · /loop stop. 'Improve until X' is a /goal, not a loop.",
2341
2382
  getArgumentCompletions: completions([
2342
2383
  ["start", "skip drafting: /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50]"],
2343
2384
  ["status", "show metric, iteration, best/last values, stall count"],
@@ -2392,7 +2433,7 @@ export default function (pi: ExtensionAPI): void {
2392
2433
  const l = state.loop!;
2393
2434
  if (autoResume) {
2394
2435
  ctx.ui.notify(
2395
- `Resuming loop (iteration ${l.iteration}/${l.maxIterations}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${l.target.slice(0, 60)}`,
2436
+ `Resuming loop (iteration ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${l.target.slice(0, 60)}`,
2396
2437
  "info",
2397
2438
  );
2398
2439
  scheduleLoopTick(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.22.7",
3
+ "version": "0.23.1",
4
4
  "description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",
@@ -45,6 +45,11 @@ If the objective decomposes into milestones and no task list exists yet, call `p
45
45
 
46
46
  When the agent calls any of these, the orchestrator tracks the call and persists state to `.pi-glla/active.jsonl`.
47
47
 
48
+ ## EXECUTION DISCIPLINE
49
+
50
+ - **Delegate independent chunks to subagents.** When the work contains parallel, independent streams — surveying separate subsystems, reading disjoint file sets, running independent verifications — spawn `Agent` subagents (`Explore` for read-only research, `general-purpose` for implementation) instead of serializing everything through your own context. You remain the single writer: synthesize subagent findings and apply edits yourself.
51
+ - **Bound every long command.** Wrap test suites, builds, and dev servers in `timeout <seconds>` (e.g. `timeout 120 bun test src/lib`). An unbounded command that hangs burns an hour; a bounded one burns two minutes and tells you it hung. If a command produces no output for many minutes, treat it as hung: kill it, diagnose why, rerun bounded.
52
+
48
53
  ## TASK WORKFLOW
49
54
 
50
55
  Use tasks as PROGRESS TRACKERS during your work — not as a post-hoc checklist to batch-mark at the end.
@@ -23,16 +23,32 @@ stops it.
23
23
  (`wc -c < bundle.js`), timings (`hyperfine`-style), scores.
24
24
  - Warn the user if the metric is gameable (agent could improve the number
25
25
  without improving the target — e.g. deleting tests to reduce failures).
26
+ - **Offer the metricless alternative explicitly** (v0.23.0): if the user
27
+ says there is no number — "just work the spec", "keep improving it",
28
+ Sisyphus-style — a metricless loop is now legitimate: omit `measureCmd`
29
+ (or pass `"none"`) in the proposal. Tell the user the trade-off in one
30
+ sentence: no metric means NO plateau stop — the loop ends only at its
31
+ bounds (max/time/tokens) or `/loop stop`, and cosmetic churn
32
+ (doorknob-polishing) is the known failure mode. Never pick metricless
33
+ silently; the user chooses it after hearing that.
34
+ - Still redirect to `/goal` when the work HAS a finish line (research,
35
+ write document X, build feature Y): "improve until done" is a goal
36
+ with an auditor, not a loop. Metricless loops are for work that is
37
+ genuinely endless (an ever-improving spec, continuous hardening).
26
38
  3. Clarify the **direction**: is lower better (min) or higher better (max)?
39
+ (Skip this for a metricless loop — there is no direction without a metric.)
27
40
  4. Optional tuning: `window` (plateau stop after N non-improving iterations,
28
- default 5), `max` (iteration cap, default 50). Suggest smaller values for
29
- expensive measures.
30
- 5. When concrete, call `propose_loop_draft` with `target`, `measureCmd`,
31
- `direction`, and optional `window`/`max`.
41
+ default 5 — meaningless for metricless), `max` (iteration cap, default 50;
42
+ `max=0` = truly unbounded). Suggest smaller values for expensive measures.
43
+ For metricless loops, ALWAYS discuss bounds an unbounded metricless loop
44
+ is a deliberate furnace.
45
+ 5. When concrete, call `propose_loop_draft` with `target`, `measureCmd` (or
46
+ omit/`"none"` for metricless), `direction` (measured only), and optional
47
+ `window`/`max`/`time`/`tokens`.
32
48
  6. **The orchestrator will run your proposed measure command ONCE** and show
33
49
  the user the real output and parsed number in the Confirm dialog. If your
34
50
  command produces no number, the proposal is rejected automatically — fix
35
- the command and propose again.
51
+ the command and propose again. (Metricless proposals skip the test-run.)
36
52
  7. If the user rejects, ask what to change, refine, propose again.
37
53
 
38
54
  ## Hard rules
@@ -41,10 +57,7 @@ stops it.
41
57
  - Do not modify the repo while drafting.
42
58
  - Do not propose a measure you have not sanity-checked against the actual
43
59
  repo layout (read files first if unsure what exists).
44
- - **If the user's goal has no honest numeric metric research, writing a
45
- document, building a feature, "understand X" say so plainly and redirect:
46
- `/loop` only believes a number; `/goal` is the right tool because its
47
- independent auditor verifies semantic completeness against a contract.
48
- Offer to hand them a well-structured `/goal` objective instead. Never
49
- invent a fake metric (word count alone, file exists) just to make a loop
50
- fit — a number that doesn't track the real target is worse than no number.
60
+ - Never invent a fake metric (word count alone, file exists) just to make a
61
+ measured loop fit — a number that doesn't track the real target is worse
62
+ than no number. When no honest number exists, the choice is metricless
63
+ loop (endless process) or /goal (has a finish line) — and the user picks.
@@ -0,0 +1,43 @@
1
+ # Forever loop (metricless) — pi-goal-list-loop-audit
2
+
3
+ `[LOOP ITERATION ${ITERATION}]`
4
+
5
+ You are inside an endless work loop with NO metric. The user chose this
6
+ deliberately: there is no number that means "better" for this target, so the
7
+ loop cannot judge movement — and cannot plateau-stop. It ends only at its
8
+ bounds or /loop stop. That freedom is the doorknob-polishing risk made
9
+ flesh; your job is to make every iteration count anyway.
10
+
11
+ ## Target (the spec you are working)
12
+
13
+ <target>
14
+ ${TARGET}
15
+ </target>
16
+
17
+ ## Your job THIS turn
18
+
19
+ Start your reply with exactly one line: `HYPOTHESIS: <what you will change and why it is real progress on the spec>`.
20
+ Then make **ONE** concrete, inspectable change that advances the target.
21
+ Then stop.
22
+
23
+ ${REGRESSION_NOTE}
24
+ ${STRATEGY_NOTE}
25
+
26
+ ## Hard rules
27
+
28
+ - ONE change per turn. Small beats clever — the next iteration gets another turn.
29
+ - REAL work only: a change a reviewer could inspect and call progress
30
+ (a section written, a case handled, a defect fixed, a test added).
31
+ Cosmetic reshuffles, reformatting churn, comment shuffling, and re-wording
32
+ what already reads well are doorknob-polishing — the exact failure this
33
+ loop exists despite.
34
+ - Never repeat yourself: before acting, check what earlier iterations
35
+ already did (git log / the artifacts themselves) and pick the next
36
+ unaddressed piece of the spec.
37
+ - When the spec is genuinely, verifiably exhausted — every section
38
+ addressed, every case handled — say so plainly in your reply instead of
39
+ inventing cosmetic work. The user watches an honest loop gladly; a
40
+ furnace that rewrites the same paragraph forever, never.
41
+ - The spec is ALIVE: if the target needs sharpening, call
42
+ propose_loop_refine with your rationale — the user confirms or rejects.
43
+ ${BOUNDS_NOTE}