pi-goal-list-loop-audit 0.22.6 → 0.23.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/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;
@@ -93,7 +97,7 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
93
97
  return `glla: ${paint(theme, "accent", "auditing…")}${tool}`;
94
98
  }
95
99
  if (g.status === "paused") {
96
- const label = `paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
100
+ const label = `${g.policy} paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
97
101
  return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}`;
98
102
  }
99
103
  if (g.status === "active") {
@@ -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,
@@ -565,6 +566,12 @@ async function cmdStatus(ctx: ExtensionContext): Promise<void> {
565
566
  async function cmdPause(ctx: ExtensionContext): Promise<void> {
566
567
  if (!state.goal) return;
567
568
  updateGoal({ status: "paused" }, ctx);
569
+ // v0.22.7: name WHAT was paused — a list item resumes through /list.
570
+ if (state.goal.policy === "list") {
571
+ const queued = listQueue().length;
572
+ ctx.ui.notify(`List item ${state.goal.id} paused${queued > 0 ? ` (${queued} queued in the list)` : ""}. /list resume to continue.`, "info");
573
+ return;
574
+ }
568
575
  ctx.ui.notify(`Goal ${state.goal.id} paused. /goal resume to continue.`, "info");
569
576
  }
570
577
 
@@ -580,9 +587,13 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
580
587
  updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(usage ? { usage } : {}) }, ctx);
581
588
  // v0.22.5: say what was resumed — with a non-empty list this also resumes
582
589
  // the queue (the active goal IS the list's head item).
590
+ // v0.22.7: name WHAT was resumed — list items resume through /list.
583
591
  const queued = listQueue().length;
592
+ const isListItem = state.goal.policy === "list";
584
593
  ctx.ui.notify(
585
- `Resumed goal [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list — resuming the list's head)` : ""}`,
594
+ isListItem
595
+ ? `Resumed list item [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""}`
596
+ : `Resumed goal [${state.goal.id}]: ${state.goal.objective.replace(/\s+/g, " ").slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list — resuming the list's head)` : ""}`,
586
597
  "info",
587
598
  );
588
599
  scheduleContinuation(ctx, true);
@@ -730,6 +741,22 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
730
741
  const sub = (parts[0] ?? "").toLowerCase();
731
742
  const rest = args.trim().slice(sub.length).trim();
732
743
 
744
+ if (sub === "resume") {
745
+ // Resume the list's head. The head activates AS the active goal, so this
746
+ // is the same motion as /goal resume — named for the surface the user is
747
+ // looking at (v0.22.7: "we would just unpause, and that is next").
748
+ if (!state.goal || state.goal.status !== "paused") {
749
+ ctx.ui.notify("No paused list item to resume. /list show to see the queue.", "info");
750
+ return;
751
+ }
752
+ if (state.goal.policy !== "list") {
753
+ ctx.ui.notify("The paused goal didn't come from the list — /goal resume to continue it.", "info");
754
+ return;
755
+ }
756
+ await cmdResume(ctx);
757
+ return;
758
+ }
759
+
733
760
  if (!sub || sub === "show") {
734
761
  const queue = listQueue();
735
762
  const lines: string[] = [];
@@ -929,18 +956,23 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
929
956
  }
930
957
 
931
958
  function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
932
- 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");
933
963
  let tmpl: string;
934
964
  try {
935
965
  tmpl = fs.readFileSync(tmplPath, "utf-8");
936
966
  } catch {
937
- 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.`;
938
970
  }
939
971
  return tmpl
940
972
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
941
973
  .replace(/\$\{TARGET\}/g, loop.target)
942
- .replace(/\$\{MEASURE_CMD\}/g, loop.measureCmd)
943
- .replace(/\$\{DIRECTION\}/g, loop.direction)
974
+ .replace(/\$\{MEASURE_CMD\}/g, loop.measureCmd ?? "none")
975
+ .replace(/\$\{DIRECTION\}/g, loop.direction ?? "none")
944
976
  .replace(/\$\{DIRECTION_WORD\}/g, loop.direction === "min" ? "lower is better" : "higher is better")
945
977
  .replace(/\$\{LAST_VALUE\}/g, loop.lastValue === null ? "(none yet)" : String(loop.lastValue))
946
978
  .replace(/\$\{BEST_VALUE\}/g, loop.bestValue === null ? "(none yet)" : String(loop.bestValue))
@@ -985,10 +1017,22 @@ function sendLoopTurn(): void {
985
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.**"
986
1018
  : "";
987
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;
988
1024
  const bounds: string[] = [];
989
1025
  if (loop.timeLimitHours !== undefined) bounds.push(`${loop.timeLimitHours}h`);
990
1026
  if (loop.tokenBudget !== undefined) bounds.push(`${loop.tokenBudget.toLocaleString()} tokens (used ${(loop.tokensUsed ?? 0).toLocaleString()})`);
991
- 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
+ }
992
1036
  try {
993
1037
  extensionApi.sendMessage({
994
1038
  customType: GOAL_EVENT_ENTRY,
@@ -1007,7 +1051,8 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1007
1051
  if (event?.messages) {
1008
1052
  loop.tokensUsed = (loop.tokensUsed ?? 0) + sumNewAssistantTokens(event.messages as unknown[], countedLoopTokenMessages);
1009
1053
  }
1010
- const value = await runMeasure(ctx, loop.measureCmd);
1054
+ const metricless = !loop.measureCmd;
1055
+ const value = metricless ? null : await runMeasure(ctx, loop.measureCmd!);
1011
1056
  // Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
1012
1057
  // for the turn goes into the ledger, making loop history auditable.
1013
1058
  let hypothesis: string | undefined;
@@ -1016,7 +1061,7 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1016
1061
  const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1017
1062
  hypothesis = text.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1018
1063
  }
1019
- const outcome = applyMeasurement(loop, value, nowIso());
1064
+ const outcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
1020
1065
  persistState(ctx);
1021
1066
  appendLedger(ctx.cwd, "loop_measured", {
1022
1067
  iteration: loop.iteration,
@@ -1026,11 +1071,12 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1026
1071
  hypothesis,
1027
1072
  });
1028
1073
  // branch=1 mode: commit improvements, hard-reset regressions — always and
1029
- // 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.
1030
1076
  if (loop.branchName && outcome.kind === "continue") {
1031
- if (outcome.improved) {
1077
+ if (metricless || outcome.improved) {
1032
1078
  await runGit(ctx, ["add", "-A"]);
1033
- 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})`]);
1034
1080
  appendLedger(ctx.cwd, "loop_git", { action: "commit", iteration: loop.iteration, ok: committed.ok });
1035
1081
  } else {
1036
1082
  const reset = await runGit(ctx, ["reset", "--hard", "HEAD"]);
@@ -1066,8 +1112,9 @@ async function finishLoopGit(ctx: ExtensionContext, loop: LoopState): Promise<vo
1066
1112
 
1067
1113
  interface LoopConfig {
1068
1114
  target: string;
1115
+ /** Empty string = metricless spec loop (v0.23.0). */
1069
1116
  measureCmd: string;
1070
- direction: "min" | "max";
1117
+ direction?: "min" | "max";
1071
1118
  plateauWindow: number;
1072
1119
  maxIterations: number;
1073
1120
  branch: boolean;
@@ -1106,8 +1153,11 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1106
1153
  // produces no number is a footgun: without a baseline the loop burns stall
1107
1154
  // iterations before plateau stops it. Refuse fast (force=1 overrides for
1108
1155
  // measures that only work after the agent builds something first).
1109
- const baseline = await runMeasure(ctx, cfg.measureCmd);
1110
- 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) {
1111
1161
  ctx.ui.notify(
1112
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.)`,
1113
1163
  "warning",
@@ -1118,7 +1168,7 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1118
1168
  ...state,
1119
1169
  loop: {
1120
1170
  target: cfg.target,
1121
- measureCmd: cfg.measureCmd,
1171
+ measureCmd: cfg.measureCmd || undefined,
1122
1172
  direction: cfg.direction,
1123
1173
  iteration: 0,
1124
1174
  maxIterations: cfg.maxIterations,
@@ -1137,10 +1187,13 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1137
1187
  },
1138
1188
  };
1139
1189
  persistState(ctx);
1140
- 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 });
1141
1191
  ctx.ui.notify(
1142
- `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}` +
1143
- (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}` : ""),
1144
1197
  "info",
1145
1198
  );
1146
1199
  scheduleLoopTick(ctx);
@@ -1166,7 +1219,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1166
1219
  persistState(ctx);
1167
1220
  scheduleLoopTick(ctx);
1168
1221
  ctx.ui.notify(
1169
- `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)}`,
1170
1223
  "info",
1171
1224
  );
1172
1225
  return;
@@ -1183,8 +1236,8 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1183
1236
  }
1184
1237
  const lines = [
1185
1238
  `Loop: ${loop.active ? "active" : "stopped"} — ${loop.target.slice(0, 80)}`,
1186
- `Metric: ${loop.measureCmd} (${loop.direction})`,
1187
- `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}`,
1188
1241
  ];
1189
1242
  const bounds: string[] = [];
1190
1243
  if (loop.timeLimitHours !== undefined) bounds.push(`time ≤ ${loop.timeLimitHours}h`);
@@ -1608,11 +1661,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1608
1661
  pi.registerTool(defineTool({
1609
1662
  name: "propose_loop_draft",
1610
1663
  label: "Propose loop draft",
1611
- 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.",
1612
1665
  parameters: Type.Object({
1613
1666
  target: Type.String({ description: "What to improve, concretely" }),
1614
- measureCmd: Type.String({ description: "Shell command that prints ONE number representing progress" }),
1615
- 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)" })),
1616
1669
  window: Type.Optional(Type.Number({ description: "Plateau stop after N non-improving iterations (default 5)" })),
1617
1670
  max: Type.Optional(Type.Number({ description: "Iteration cap (default 50)" })),
1618
1671
  time: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many hours" })),
@@ -1620,7 +1673,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1620
1673
  branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
1621
1674
  }),
1622
1675
  async execute(_id, params, _signal, _onUpdate, execCtx) {
1623
- 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 };
1624
1677
  if (draftingTarget !== "loop") {
1625
1678
  return {
1626
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." }],
@@ -1633,24 +1686,30 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1633
1686
  if (loopBlock) {
1634
1687
  return { content: [{ type: "text", text: loopBlock }], details: {} };
1635
1688
  }
1636
- if (!p.target?.trim() || !p.measureCmd?.trim()) {
1637
- 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: {} };
1638
1696
  }
1639
1697
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
1640
1698
  // THE TEST-RUN: orchestrator runs the proposed measure once. The user
1641
1699
  // sees the real number before a single iteration burns tokens.
1700
+ // (Metricless loops skip this — there is no measure to test-run.)
1642
1701
  let rawOutput = "";
1643
1702
  let parsed: number | null = null;
1644
- if (extensionApi) {
1703
+ if (!metricless && extensionApi) {
1645
1704
  try {
1646
- 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 });
1647
1706
  rawOutput = String((result as any)?.stdout ?? "").trim();
1648
1707
  parsed = parseMetric(rawOutput);
1649
1708
  } catch (err) {
1650
1709
  rawOutput = `(measure command failed: ${err instanceof Error ? err.message : String(err)})`;
1651
1710
  }
1652
1711
  }
1653
- if (parsed === null) {
1712
+ if (!metricless && parsed === null) {
1654
1713
  return {
1655
1714
  content: [{
1656
1715
  type: "text",
@@ -1660,12 +1719,15 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1660
1719
  };
1661
1720
  }
1662
1721
  const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
1663
- 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;
1664
1724
  let confirmed = false;
1665
1725
  try {
1666
1726
  confirmed = await liveCtx.ui.confirm(
1667
1727
  "Confirm loop",
1668
- `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?`,
1669
1731
  );
1670
1732
  } catch {
1671
1733
  confirmed = false;
@@ -1679,8 +1741,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1679
1741
  draftingTarget = null;
1680
1742
  const started = await startLoopFromConfig(liveCtx, {
1681
1743
  target: p.target.trim(),
1682
- measureCmd: p.measureCmd,
1683
- direction: p.direction,
1744
+ measureCmd: metricless ? "" : p.measureCmd!.trim(),
1745
+ direction: metricless ? undefined : p.direction,
1684
1746
  plateauWindow: window,
1685
1747
  maxIterations: max,
1686
1748
  timeLimitHours: typeof p.time === "number" && Number.isFinite(p.time) && p.time > 0 ? p.time : undefined,
@@ -1691,7 +1753,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1691
1753
  return { content: [{ type: "text", text: "Loop could not start (see the warning above — likely a git/dirty-tree issue with branch mode)." }], details: {} };
1692
1754
  }
1693
1755
  return {
1694
- 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"}.` }],
1695
1757
  details: {},
1696
1758
  };
1697
1759
  },
@@ -1714,7 +1776,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1714
1776
  return { content: [{ type: "text", text: "No active loop to refine. propose_loop_refine is only valid while a loop is running." }], details: {} };
1715
1777
  }
1716
1778
  const newTarget = p.target?.trim() || loop.target;
1717
- 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
+ }
1718
1785
  if (newTarget === loop.target && newMeasure === loop.measureCmd) {
1719
1786
  return { content: [{ type: "text", text: "Refinement proposed no changes — provide a new target, a new measureCmd, or both." }], details: {} };
1720
1787
  }
@@ -1754,7 +1821,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
1754
1821
  iteration: loop.iteration,
1755
1822
  oldTarget: loop.target,
1756
1823
  newTarget,
1757
- oldMeasureCmd: loop.measureCmd,
1824
+ oldMeasureCmd: loop.measureCmd ?? "",
1758
1825
  newMeasureCmd: newMeasure,
1759
1826
  }, newBaseline);
1760
1827
  persistState(liveCtx);
@@ -2300,9 +2367,10 @@ export default function (pi: ExtensionAPI): void {
2300
2367
  handler: settingsHandler,
2301
2368
  });
2302
2369
  pi.registerCommand("list", {
2303
- description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list next [n] | /list remove <n> | /list clear",
2370
+ description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear",
2304
2371
  getArgumentCompletions: completions([
2305
2372
  ["show", "display the queued items"],
2373
+ ["resume", "resume the paused list item (the list's head)"],
2306
2374
  ["next", "activate the next item (or /list next <n> for position n)"],
2307
2375
  ["remove", "remove an item: /list remove <n>"],
2308
2376
  ["clear", "empty the list"],
@@ -2310,7 +2378,7 @@ export default function (pi: ExtensionAPI): void {
2310
2378
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
2311
2379
  });
2312
2380
  pi.registerCommand("loop", {
2313
- 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.",
2314
2382
  getArgumentCompletions: completions([
2315
2383
  ["start", "skip drafting: /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50]"],
2316
2384
  ["status", "show metric, iteration, best/last values, stall count"],
@@ -2365,7 +2433,7 @@ export default function (pi: ExtensionAPI): void {
2365
2433
  const l = state.loop!;
2366
2434
  if (autoResume) {
2367
2435
  ctx.ui.notify(
2368
- `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)}`,
2369
2437
  "info",
2370
2438
  );
2371
2439
  scheduleLoopTick(ctx);
@@ -2380,29 +2448,30 @@ export default function (pi: ExtensionAPI): void {
2380
2448
  } else if (state.goal && state.goal.status === "active" && state.goal.autoContinue) {
2381
2449
  if (autoResume) {
2382
2450
  ctx.ui.notify(
2383
- `Resuming goal [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
2451
+ `Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
2384
2452
  "info",
2385
2453
  );
2386
2454
  scheduleContinuation(ctx, true);
2387
2455
  } else {
2388
2456
  const queued = listQueue().length;
2389
- const resumeHint = queued > 0
2390
- ? `/goal resume to continue (+${queued} queued in the list) · /glla autoresume=on to auto-resume in this project`
2391
- : "/goal resume to continue · /glla autoresume=on to auto-resume in this project";
2457
+ // v0.22.7: name WHAT is held — a list head resumes through /list.
2458
+ const isListItem = state.goal.policy === "list";
2459
+ const resumeCmd = isListItem ? "/list resume" : "/goal resume";
2460
+ const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} queued in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
2392
2461
  updateGoal({
2393
2462
  status: "paused",
2394
2463
  pauseReason: "restored in a fresh session — no work started",
2395
2464
  pauseSuggestedAction: resumeHint,
2396
2465
  }, ctx);
2397
2466
  ctx.ui.notify(
2398
- `Goal held on restore [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""} — /goal resume to continue.`,
2467
+ `${isListItem ? "List item" : "Goal"} held on restore [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} queued in the list)` : ""} — ${resumeCmd} to continue.`,
2399
2468
  "info",
2400
2469
  );
2401
2470
  }
2402
2471
  } else if (state.goal && state.goal.status === "active") {
2403
2472
  // Active but autoContinue off: nothing auto-fires — just surface it.
2404
2473
  ctx.ui.notify(
2405
- `Restored goal [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
2474
+ `Restored ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
2406
2475
  "info",
2407
2476
  );
2408
2477
  } else if ((!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") && listQueue().length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.22.6",
3
+ "version": "0.23.0",
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",
@@ -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}