pi-goal-list-loop-audit 0.23.8 → 0.24.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
@@ -57,6 +57,7 @@ matches `/list show`.
57
57
  /list next # skip current, activate next
58
58
  /list remove <n> # drop item n from the list
59
59
  /list clear # empty the list
60
+ /list cancel # stop the whole list: abort the active item + drop all waiting
60
61
  /loop # draft the loop (agent grills; measure is test-run before you confirm)
61
62
  /loop start "keep polishing the UI" # infinite metricless loop (v0.23.6): no plateau, no cap — ends at time=/tokens= or /loop stop
62
63
  /loop start "reduce TODOs" measure="grep -c TODO src.txt | head -1" direction=min
@@ -77,6 +78,17 @@ inspectable change; cosmetic churn is the known failure mode
77
78
  number, and tells you the trade-off before you confirm. Work with a finish
78
79
  line is still a `/goal`.
79
80
 
81
+ **Anti-repetition** (v0.24.0, both loop flavors): the plateau stop watches
82
+ the *number*; the stuck ladder watches the *work*. Every iteration is
83
+ classified — exact/near-duplicate replies, A-B-A-B alternation, same
84
+ tool-same-result three times, narration-only streaks, degenerate
85
+ single-reply repetition — and a stuck iteration swaps the next prompt for
86
+ a rotating intervention (different approach → different subtask →
87
+ PROGRESS.md → fix one test failure → review your own diff). Three stuck in
88
+ a row escalates to a hard reset (banned openings, tool-call-first); five
89
+ stops the loop with the reason — bounded and surfaced, like plateau.
90
+ Continuation lines also rotate: identical prompts invite identical answers.
91
+
80
92
  Subcommands match **exactly** — `/goal pause the pipeline` sets an objective
81
93
  about a pipeline; only bare `/goal pause` pauses. (Same rule everywhere, so
82
94
  your objectives can start with any verb.)
@@ -58,6 +58,20 @@ export interface LoopState {
58
58
  branchName?: string;
59
59
  /** branch=1 mode: the branch to return to on stop. */
60
60
  originalBranch?: string;
61
+ /** v0.24.0 anti-repetition: rolling fingerprints of iteration replies. */
62
+ recentPrints?: string[];
63
+ /** v0.24.0: last few iteration texts (near-duplicate check + banned openings). */
64
+ recentTexts?: string[];
65
+ /** v0.24.0: rolling tool-result fingerprints {tool, hash, isError}. */
66
+ recentToolResults?: { tool: string; hash: string; isError: boolean }[];
67
+ /** v0.24.0: tool calls seen since the last completed iteration. */
68
+ toolsThisTurn?: number;
69
+ /** v0.24.0: consecutive iterations with zero tool calls. */
70
+ toollessStreak?: number;
71
+ /** v0.24.0: consecutive stuck interventions (resets on a clean iteration). */
72
+ consecutiveStuck?: number;
73
+ /** v0.24.0: the last stuck reason (for the intervention directive + ledger). */
74
+ lastStuckReason?: string;
61
75
  }
62
76
 
63
77
  /** Scratch-branch name for branch=1 mode. Format pinned by tests. */
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Loop anti-repetition (v0.24.0) — pure, stateless detectors for the failure
3
+ * mode the plateau stop cannot see: the loop keeps "working" but the work is
4
+ * the SAME work. Plateau watches the number; this watches the behavior.
5
+ *
6
+ * Clean-room implementation of standard techniques (rolling text
7
+ * fingerprints, word-trigram Jaccard similarity, repeated-n-gram detection)
8
+ * for pi-loop-mode's AGPL-licensed repertoire — no code shared.
9
+ *
10
+ * Everything here is a pure function; LoopState holds the rolling windows and
11
+ * runLoopTick (loops/goal.ts) feeds them in. One mechanical predicate per
12
+ * behavior — no fuzzy heuristics.
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+
17
+ export const REPETITION = {
18
+ /** Jaccard similarity at which two consecutive iterations count as a near-duplicate. */
19
+ similarityThreshold: 0.8,
20
+ /** Minimum normalized length before exact/near-duplicate checks apply (short replies repeat innocently). */
21
+ minExactLength: 80,
22
+ minSimilarLength: 60,
23
+ /** Same fingerprint seen this often inside the rolling window = alternating repetition (A-B-A-B). */
24
+ windowRepeat: 3,
25
+ /** Rolling window sizes held on LoopState. */
26
+ printWindow: 12,
27
+ textWindow: 3,
28
+ toolWindow: 6,
29
+ /** Last N identical tool results (same tool, same output) = no new information. */
30
+ toolResultRepeat: 3,
31
+ /** Consecutive iterations with zero tool calls = narration only. */
32
+ toollessIterations: 2,
33
+ /** Degenerate single-response repetition (one sentence/word/phrase looping inside ONE reply). */
34
+ degenerateMinLength: 150,
35
+ degenerateSentenceRepeats: 4,
36
+ degenerateWordRepeats: 16,
37
+ degeneratePhraseRepeats: 8,
38
+ degenerateMaxPhraseWords: 4,
39
+ /** Escalation ladder: hard-reset directive after this many consecutive stuck turns… */
40
+ hardResetAfter: 3,
41
+ /** …and the loop STOPS after this many (bounded, surfaced — same philosophy as plateau). */
42
+ maxInterventions: 5,
43
+ } as const;
44
+
45
+ /** Strip ANSI, collapse whitespace, lowercase — the canonical form all checks use. */
46
+ export function normalizeForPrint(text: string): string {
47
+ return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s+/g, " ").trim().toLowerCase();
48
+ }
49
+
50
+ /** Stable short fingerprint of one reply's canonical form. */
51
+ export function textFingerprint(text: string): string {
52
+ return createHash("sha256").update(normalizeForPrint(text).slice(0, 4000)).digest("hex").slice(0, 16);
53
+ }
54
+
55
+ /** Digits are volatile (counters, timestamps, PIDs) — blank them so "try port 8081" ≈ "try port 8082". */
56
+ function canonical(text: string): string {
57
+ return normalizeForPrint(text).replace(/\d+/g, "#");
58
+ }
59
+
60
+ function wordTrigrams(text: string): Set<string> {
61
+ const words = canonical(text).split(" ").filter(Boolean);
62
+ const out = new Set<string>();
63
+ if (words.length < 3) {
64
+ if (words.length) out.add(words.join(" "));
65
+ return out;
66
+ }
67
+ for (let i = 0; i + 3 <= words.length; i++) out.add(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
68
+ return out;
69
+ }
70
+
71
+ /** Jaccard similarity over word trigrams: 0 = nothing shared, 1 = same shingle set. */
72
+ export function trigramSimilarity(a: string, b: string): number {
73
+ const sa = wordTrigrams(a);
74
+ const sb = wordTrigrams(b);
75
+ if (sa.size === 0 || sb.size === 0) return 0;
76
+ let shared = 0;
77
+ for (const t of sa) if (sb.has(t)) shared++;
78
+ return shared / (sa.size + sb.size - shared);
79
+ }
80
+
81
+ export interface DegenerateRepeat {
82
+ kind: "sentence" | "word" | "phrase";
83
+ unit: string;
84
+ count: number;
85
+ }
86
+
87
+ function tokenRun(text: string): DegenerateRepeat | undefined {
88
+ const tokens = normalizeForPrint(text).match(/[\p{L}\p{N}_'-]+/gu) ?? [];
89
+ for (let width = 1; width <= REPETITION.degenerateMaxPhraseWords; width++) {
90
+ const needed = width === 1 ? REPETITION.degenerateWordRepeats : REPETITION.degeneratePhraseRepeats;
91
+ for (let start = 0; start + width * needed <= tokens.length; start++) {
92
+ let run = 1;
93
+ while (
94
+ start + (run + 1) * width <= tokens.length &&
95
+ tokens.slice(start, start + width).join("") === tokens.slice(start + run * width, start + (run + 1) * width).join("")
96
+ ) {
97
+ run++;
98
+ }
99
+ if (run >= needed) {
100
+ return { kind: width === 1 ? "word" : "phrase", unit: tokens.slice(start, start + width).join(" "), count: run };
101
+ }
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** One sentence/word/phrase looping inside a SINGLE response (degenerate generation). */
108
+ export function findDegenerateRepeat(text: string): DegenerateRepeat | undefined {
109
+ const canon = canonical(text);
110
+ if (canon.length < REPETITION.degenerateMinLength) return undefined;
111
+ const sentences = canon
112
+ .split(/(?<=[.!?])\s+|\n+/)
113
+ .map((s) => s.trim())
114
+ .filter((s) => s.length >= 15);
115
+ if (sentences.length >= REPETITION.degenerateSentenceRepeats) {
116
+ const counts = new Map<string, number>();
117
+ for (const s of sentences) counts.set(s, (counts.get(s) ?? 0) + 1);
118
+ let unit = "";
119
+ let best = 0;
120
+ for (const [s, n] of counts) if (n > best) { unit = s; best = n; }
121
+ if (best >= REPETITION.degenerateSentenceRepeats && best / sentences.length >= 0.5) {
122
+ return { kind: "sentence", unit, count: best };
123
+ }
124
+ }
125
+ return tokenRun(text);
126
+ }
127
+
128
+ export interface ToolResultPrint {
129
+ tool: string;
130
+ hash: string;
131
+ isError: boolean;
132
+ }
133
+
134
+ export interface LoopStuckInput {
135
+ /** Last assistant text of the finished iteration. */
136
+ assistantText: string;
137
+ /** Rolling fingerprints INCLUDING the current iteration's (appended last). */
138
+ recentPrints: string[];
139
+ /** Previous iteration's assistant text (for the near-duplicate check). */
140
+ previousText?: string;
141
+ /** Rolling tool-result prints (most recent last). */
142
+ recentToolResults: ToolResultPrint[];
143
+ /** Consecutive iterations with zero tool calls, including this one. */
144
+ toollessStreak: number;
145
+ }
146
+
147
+ function clip(text: string, n: number): string {
148
+ const flat = text.replace(/\s+/g, " ").trim();
149
+ return flat.length <= n ? flat : `${flat.slice(0, n)}…`;
150
+ }
151
+
152
+ /**
153
+ * The one classifier: given the rolling windows, WHY is the loop stuck —
154
+ * or undefined when it's working normally. Checks run cheapest-and-most-
155
+ * certain first; the first hit wins so the reason stays specific.
156
+ */
157
+ export function detectLoopStuck(input: LoopStuckInput): string | undefined {
158
+ const { assistantText, recentPrints, previousText, recentToolResults, toollessStreak } = input;
159
+
160
+ // Narration only: iterations that never touch tools produce nothing inspectable.
161
+ if (toollessStreak >= REPETITION.toollessIterations) {
162
+ return `no tool calls for ${toollessStreak} iterations (narration only)`;
163
+ }
164
+
165
+ // Degenerate generation inside the current response.
166
+ const degenerate = findDegenerateRepeat(assistantText);
167
+ if (degenerate) {
168
+ return `response degenerated: same ${degenerate.kind} repeated ${degenerate.count}× ("${clip(degenerate.unit, 60)}")`;
169
+ }
170
+
171
+ // Exact repeat of the immediately previous iteration.
172
+ const lastTwo = recentPrints.slice(-2);
173
+ if (lastTwo.length === 2 && lastTwo[0] === lastTwo[1] && normalizeForPrint(assistantText).length > REPETITION.minExactLength) {
174
+ return "repeated the previous response exactly";
175
+ }
176
+
177
+ // Near-duplicate of the previous iteration (slight rephrasing defeats fingerprints).
178
+ if (previousText && normalizeForPrint(assistantText).length > REPETITION.minSimilarLength) {
179
+ const sim = trigramSimilarity(assistantText, previousText);
180
+ if (sim >= REPETITION.similarityThreshold) {
181
+ return `response ~${Math.round(sim * 100)}% similar to the previous iteration`;
182
+ }
183
+ }
184
+
185
+ // Alternating repetition (A-B-A-B): the current fingerprint keeps recurring in the window.
186
+ const current = recentPrints[recentPrints.length - 1];
187
+ if (current && recentPrints.filter((p) => p === current).length >= REPETITION.windowRepeat) {
188
+ return `same response ${REPETITION.windowRepeat}+ times in recent iterations`;
189
+ }
190
+
191
+ // Same tool, same output, three times running: the loop is re-reading a result it already has.
192
+ const recentTools = recentToolResults.slice(-REPETITION.toolResultRepeat);
193
+ if (
194
+ recentTools.length === REPETITION.toolResultRepeat &&
195
+ recentTools.every((r) => r.tool === recentTools[0]!.tool && r.hash === recentTools[0]!.hash)
196
+ ) {
197
+ return recentTools.every((r) => r.isError)
198
+ ? `same ${recentTools[0]!.tool} error ${REPETITION.toolResultRepeat}× in a row`
199
+ : `same ${recentTools[0]!.tool} result ${REPETITION.toolResultRepeat}× in a row (no new information)`;
200
+ }
201
+
202
+ return undefined;
203
+ }
204
+
205
+ /**
206
+ * Rotating stuck interventions — a repeated identical nudge gets filtered as
207
+ * noise, so each consecutive stuck turn gets a DIFFERENT instruction.
208
+ * consecutiveStuck is 1-based (1 = first intervention).
209
+ */
210
+ export function loopInterventionDirective(consecutiveStuck: number, reason: string, recentTexts: string[]): string {
211
+ const strategies = [
212
+ "Abandon the current angle entirely. Pick a genuinely different approach — different file, different technique — and execute it now.",
213
+ "Switch to a part of the target you have NOT touched in recent iterations and make one concrete, inspectable change there.",
214
+ "Write a short PROGRESS.md: current state, what was tried, what keeps failing, the next 3 concrete steps. Then execute step 1.",
215
+ "Run the project's build/tests, pick exactly ONE failure or warning, and fix only that.",
216
+ "Review your recent changes (git diff / git log), find one real problem in them, and fix it.",
217
+ ];
218
+ const strategy = strategies[(consecutiveStuck - 1) % strategies.length]!;
219
+ let escalation = "";
220
+ if (consecutiveStuck >= REPETITION.hardResetAfter) {
221
+ const banned = recentTexts
222
+ .map((t) => clip(normalizeForPrint(t), 40))
223
+ .filter(Boolean)
224
+ .map((t) => `"${t}"`)
225
+ .join(", ");
226
+ escalation =
227
+ ` HARD RESET (stuck intervention #${consecutiveStuck} in a row): forget your previous phrasing entirely.` +
228
+ (banned ? ` Banned openings: ${banned}.` : "") +
229
+ " Your FIRST action this turn must be a tool call that changes a file or produces new information — zero preamble text before it.";
230
+ }
231
+ return `⚠ STUCK — ${reason}.${escalation} ${strategy}`;
232
+ }
233
+
234
+ /**
235
+ * Varied continuation lines for metricless iterations: identical prompts
236
+ * invite identical answers, so the base instruction rotates by iteration.
237
+ */
238
+ export function continueVariant(iteration: number): string {
239
+ const variants = [
240
+ "Advance the target with the next concrete, inspectable change.",
241
+ "Continue: pick the next unit of work from your plan and do it now.",
242
+ "Keep going — one real change that moves the target forward.",
243
+ "Proceed with the next focused step toward the target.",
244
+ "Make the next improvement; something you can point at in a diff.",
245
+ ];
246
+ return variants[iteration % variants.length]!;
247
+ }
248
+
249
+ /** Cap helper for the rolling windows on LoopState. */
250
+ export function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
251
+ const next = [...arr, item];
252
+ return next.length > cap ? next.slice(next.length - cap) : next;
253
+ }
@@ -64,6 +64,14 @@ import {
64
64
  writeGoalMd,
65
65
  } from "../goal-loop-core.js";
66
66
  import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
67
+ import {
68
+ REPETITION,
69
+ detectLoopStuck,
70
+ loopInterventionDirective,
71
+ continueVariant,
72
+ textFingerprint,
73
+ pushCapped as pushRepetitionCapped,
74
+ } from "../goal-loop-repetition.js";
67
75
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
68
76
  import {
69
77
  applyMeasurement,
@@ -841,7 +849,32 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
841
849
  state = { ...state, list: [] };
842
850
  persistState(ctx);
843
851
  appendLedger(ctx.cwd, "list_cleared", {});
844
- ctx.ui.notify("List cleared. Active goal (if any) is untouched — /goal cancel for that.", "info");
852
+ ctx.ui.notify("List cleared. Active goal (if any) is untouched — /goal cancel for that, /list cancel to stop the whole list.", "info");
853
+ return;
854
+ }
855
+
856
+ // v0.24.1: ONE verb for "stop this whole list" — aborts the active item
857
+ // when it's list-sourced AND drops the waiting items. Before this the user
858
+ // had to know to combine /goal cancel + /list clear.
859
+ if (sub === "cancel") {
860
+ const waiting = listQueue().length;
861
+ const activeIsListItem = state.goal?.policy === "list" && (state.goal.status === "active" || state.goal.status === "paused");
862
+ if (waiting === 0 && !activeIsListItem) {
863
+ ctx.ui.notify("No list to cancel — nothing waiting, and the active goal (if any) isn't a list item. /goal cancel aborts a standalone goal.", "info");
864
+ return;
865
+ }
866
+ const dropped = waiting;
867
+ state = { ...state, list: [] };
868
+ persistState(ctx);
869
+ if (activeIsListItem) {
870
+ archiveCurrentGoal(ctx, "aborted", "list cancelled");
871
+ ctx.abort();
872
+ }
873
+ appendLedger(ctx.cwd, "list_cancelled", { abortedActive: activeIsListItem, dropped });
874
+ ctx.ui.notify(
875
+ `List cancelled: ${activeIsListItem ? "active item aborted + " : ""}${dropped} waiting item(s) dropped.${!activeIsListItem && state.goal && state.goal.status === "active" ? " Active goal is not a list item — untouched (/goal cancel for that)." : ""}`,
876
+ "info",
877
+ );
845
878
  return;
846
879
  }
847
880
 
@@ -982,7 +1015,7 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
982
1015
  }
983
1016
  }
984
1017
 
985
- function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
1018
+ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string, interventionNote = "", variantNote = ""): string {
986
1019
  // v0.23.0: metricless loops get their own prompt — no metric section,
987
1020
  // anti-doorknob rules instead of anti-gaming rules.
988
1021
  const metricless = !loop.measureCmd;
@@ -992,8 +1025,8 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
992
1025
  tmpl = fs.readFileSync(tmplPath, "utf-8");
993
1026
  } catch {
994
1027
  tmpl = metricless
995
- ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn.`
996
- : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric.`;
1028
+ ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn. ${variantNote} ${interventionNote}`
1029
+ : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric. ${interventionNote}`;
997
1030
  }
998
1031
  return tmpl
999
1032
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
@@ -1007,7 +1040,9 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
1007
1040
  .replace(/\$\{PLATEAU_WINDOW\}/g, String(loop.plateauWindow))
1008
1041
  .replace(/\$\{REGRESSION_NOTE\}/g, regressionNote)
1009
1042
  .replace(/\$\{STRATEGY_NOTE\}/g, strategyNote)
1010
- .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote);
1043
+ .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote)
1044
+ .replace(/\$\{INTERVENTION_NOTE\}/g, interventionNote)
1045
+ .replace(/\$\{VARIANT_NOTE\}/g, variantNote);
1011
1046
  }
1012
1047
 
1013
1048
  function scheduleLoopTick(ctx: ExtensionContext): void {
@@ -1060,10 +1095,18 @@ function sendLoopTurn(): void {
1060
1095
  } else if (bounds.length) {
1061
1096
  boundsNote = `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}`;
1062
1097
  }
1098
+ // v0.24.0: a stuck intervention REPLACES the pep talk — the rotating
1099
+ // directive names why the loop is stuck and what rung of the ladder it's on.
1100
+ const interventionNote = (loop.consecutiveStuck ?? 0) > 0 && loop.lastStuckReason
1101
+ ? loopInterventionDirective(loop.consecutiveStuck!, loop.lastStuckReason, loop.recentTexts ?? [])
1102
+ : "";
1103
+ // v0.24.0: identical prompts invite identical answers — rotate the base
1104
+ // instruction (metricless loops; metric loops already vary via values).
1105
+ const variantNote = metricless ? continueVariant(loop.iteration) : "";
1063
1106
  try {
1064
1107
  extensionApi.sendMessage({
1065
1108
  customType: GOAL_EVENT_ENTRY,
1066
- content: loopPrompt(loop, regressionNote, strategyNote, boundsNote),
1109
+ content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
1067
1110
  display: false,
1068
1111
  }, { triggerTurn: true, deliverAs: "followUp" });
1069
1112
  } catch {
@@ -1083,10 +1126,40 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1083
1126
  // Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
1084
1127
  // for the turn goes into the ledger, making loop history auditable.
1085
1128
  let hypothesis: string | undefined;
1129
+ let lastAssistantText = "";
1086
1130
  if (event) {
1087
1131
  const last = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
1088
- const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1089
- hypothesis = text.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1132
+ lastAssistantText = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1133
+ hypothesis = lastAssistantText.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1134
+ }
1135
+ // v0.24.0 anti-repetition: roll the behavior windows, then classify. The
1136
+ // plateau stop watches the NUMBER; this watches the WORK — a metricless
1137
+ // loop (no number) has no other defense against doorknob-polishing.
1138
+ const toolsUsed = loop.toolsThisTurn ?? 0;
1139
+ loop.toolsThisTurn = 0;
1140
+ loop.toollessStreak = toolsUsed === 0 ? (loop.toollessStreak ?? 0) + 1 : 0;
1141
+ const previousText = loop.recentTexts && loop.recentTexts.length > 0 ? loop.recentTexts[loop.recentTexts.length - 1] : undefined;
1142
+ if (lastAssistantText) {
1143
+ loop.recentPrints = pushRepetitionCapped(loop.recentPrints ?? [], textFingerprint(lastAssistantText), REPETITION.printWindow);
1144
+ loop.recentTexts = pushRepetitionCapped(loop.recentTexts ?? [], lastAssistantText, REPETITION.textWindow);
1145
+ }
1146
+ const stuckReason = detectLoopStuck({
1147
+ assistantText: lastAssistantText,
1148
+ recentPrints: loop.recentPrints ?? [],
1149
+ previousText,
1150
+ recentToolResults: loop.recentToolResults ?? [],
1151
+ toollessStreak: loop.toollessStreak ?? 0,
1152
+ });
1153
+ if (stuckReason) {
1154
+ loop.consecutiveStuck = (loop.consecutiveStuck ?? 0) + 1;
1155
+ loop.lastStuckReason = stuckReason;
1156
+ appendLedger(ctx.cwd, "loop_stuck", { iteration: loop.iteration, reason: stuckReason, consecutive: loop.consecutiveStuck });
1157
+ if (loop.consecutiveStuck === 1 || loop.consecutiveStuck >= REPETITION.hardResetAfter) {
1158
+ ctx.ui.notify(`Loop stuck (${loop.consecutiveStuck}×): ${stuckReason}`, "warning");
1159
+ }
1160
+ } else {
1161
+ loop.consecutiveStuck = 0;
1162
+ loop.lastStuckReason = undefined;
1090
1163
  }
1091
1164
  const outcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
1092
1165
  persistState(ctx);
@@ -1096,6 +1169,7 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1096
1169
  best: loop.bestValue,
1097
1170
  stall: loop.stallCount,
1098
1171
  hypothesis,
1172
+ stuck: stuckReason,
1099
1173
  });
1100
1174
  // branch=1 mode: commit improvements, hard-reset regressions — always and
1101
1175
  // only on the scratch branch. v0.23.0: a metricless loop has no regression
@@ -1111,6 +1185,18 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1111
1185
  }
1112
1186
  persistState(ctx);
1113
1187
  }
1188
+ // v0.24.0: the top of the stuck ladder — bounded and surfaced, same
1189
+ // philosophy as a plateau stop. The loop ends WITH the reason, not in silence.
1190
+ if (outcome.kind !== "stop" && (loop.consecutiveStuck ?? 0) >= REPETITION.maxInterventions) {
1191
+ loop.active = false;
1192
+ loop.stopReason = `stuck — ${loop.lastStuckReason} (${loop.consecutiveStuck} consecutive interventions)`;
1193
+ persistState(ctx);
1194
+ await finishLoopGit(ctx, loop);
1195
+ ctx.ui.notify(`Loop stopped: ${loop.stopReason}. ${loop.history.length} iterations recorded.`, "warning");
1196
+ appendLedger(ctx.cwd, "loop_stopped", { reason: loop.stopReason, iterations: loop.iteration, best: loop.bestValue });
1197
+ notifyExternal(ctx, `Loop stopped: ${loop.stopReason}`);
1198
+ return;
1199
+ }
1114
1200
  if (outcome.kind === "stop") {
1115
1201
  await finishLoopGit(ctx, loop);
1116
1202
  ctx.ui.notify(`Loop stopped: ${outcome.reason}. ${loop.history.length} iterations recorded.`, "info");
@@ -2488,13 +2574,14 @@ export default function (pi: ExtensionAPI): void {
2488
2574
  handler: settingsHandler,
2489
2575
  });
2490
2576
  pi.registerCommand("list", {
2491
- 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",
2577
+ 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 | /list cancel",
2492
2578
  getArgumentCompletions: completions([
2493
2579
  ["show", "display the waiting items"],
2494
2580
  ["resume", "resume the paused list item (the list's head)"],
2495
2581
  ["next", "activate the next item (or /list next <n> for position n)"],
2496
2582
  ["remove", "remove an item: /list remove <n>"],
2497
2583
  ["clear", "empty the list"],
2584
+ ["cancel", "stop the whole list: abort the active item + drop all waiting"],
2498
2585
  ]),
2499
2586
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
2500
2587
  });
@@ -2528,6 +2615,18 @@ export default function (pi: ExtensionAPI): void {
2528
2615
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
2529
2616
  // messages — count answered (non-cancelled) questionnaires as replies too.
2530
2617
  pi.on("tool_result", async (event: any) => {
2618
+ // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
2619
+ // detection) — recorded for ANY tool result while a loop is active.
2620
+ if (isLoopActive()) {
2621
+ const loop = state.loop!;
2622
+ const out = event?.output ?? event?.result ?? event?.details ?? "";
2623
+ const text = typeof out === "string" ? out : JSON.stringify(out) ?? "";
2624
+ loop.recentToolResults = pushRepetitionCapped(
2625
+ loop.recentToolResults ?? [],
2626
+ { tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
2627
+ REPETITION.toolWindow,
2628
+ );
2629
+ }
2531
2630
  if (draftingTarget === null) return;
2532
2631
  if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
2533
2632
  draftingUserReplies++;
@@ -2715,5 +2814,9 @@ export default function (pi: ExtensionAPI): void {
2715
2814
  pi.on("tool_call", () => {
2716
2815
  toolCallsThisTurn++;
2717
2816
  noteActivity();
2817
+ // v0.24.0: count loop-iteration tool calls (narration-only detection).
2818
+ if (isLoopActive()) {
2819
+ state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
2820
+ }
2718
2821
  });
2719
2822
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.23.8",
3
+ "version": "0.24.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",
@@ -16,10 +16,13 @@ ${TARGET}
16
16
 
17
17
  ## Your job THIS turn
18
18
 
19
+ ${VARIANT_NOTE}
20
+
19
21
  Start your reply with exactly one line: `HYPOTHESIS: <what you will change and why it is real progress on the spec>`.
20
22
  Then make **ONE** concrete, inspectable change that advances the target.
21
23
  Then stop.
22
24
 
25
+ ${INTERVENTION_NOTE}
23
26
  ${REGRESSION_NOTE}
24
27
  ${STRATEGY_NOTE}
25
28
 
@@ -26,6 +26,7 @@ Start your reply with exactly one line: `HYPOTHESIS: <what you will change and w
26
26
  Then make **ONE** small, concrete change that moves the metric in the right
27
27
  direction. Then stop.
28
28
 
29
+ ${INTERVENTION_NOTE}
29
30
  ${REGRESSION_NOTE}
30
31
  ${STRATEGY_NOTE}
31
32