pi-goal-list-loop-audit 0.23.8 → 0.24.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
@@ -77,6 +77,17 @@ inspectable change; cosmetic churn is the known failure mode
77
77
  number, and tells you the trade-off before you confirm. Work with a finish
78
78
  line is still a `/goal`.
79
79
 
80
+ **Anti-repetition** (v0.24.0, both loop flavors): the plateau stop watches
81
+ the *number*; the stuck ladder watches the *work*. Every iteration is
82
+ classified — exact/near-duplicate replies, A-B-A-B alternation, same
83
+ tool-same-result three times, narration-only streaks, degenerate
84
+ single-reply repetition — and a stuck iteration swaps the next prompt for
85
+ a rotating intervention (different approach → different subtask →
86
+ PROGRESS.md → fix one test failure → review your own diff). Three stuck in
87
+ a row escalates to a hard reset (banned openings, tool-call-first); five
88
+ stops the loop with the reason — bounded and surfaced, like plateau.
89
+ Continuation lines also rotate: identical prompts invite identical answers.
90
+
80
91
  Subcommands match **exactly** — `/goal pause the pipeline` sets an objective
81
92
  about a pipeline; only bare `/goal pause` pauses. (Same rule everywhere, so
82
93
  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,
@@ -982,7 +990,7 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
982
990
  }
983
991
  }
984
992
 
985
- function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
993
+ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string, interventionNote = "", variantNote = ""): string {
986
994
  // v0.23.0: metricless loops get their own prompt — no metric section,
987
995
  // anti-doorknob rules instead of anti-gaming rules.
988
996
  const metricless = !loop.measureCmd;
@@ -992,8 +1000,8 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
992
1000
  tmpl = fs.readFileSync(tmplPath, "utf-8");
993
1001
  } catch {
994
1002
  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.`;
1003
+ ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn. ${variantNote} ${interventionNote}`
1004
+ : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric. ${interventionNote}`;
997
1005
  }
998
1006
  return tmpl
999
1007
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
@@ -1007,7 +1015,9 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
1007
1015
  .replace(/\$\{PLATEAU_WINDOW\}/g, String(loop.plateauWindow))
1008
1016
  .replace(/\$\{REGRESSION_NOTE\}/g, regressionNote)
1009
1017
  .replace(/\$\{STRATEGY_NOTE\}/g, strategyNote)
1010
- .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote);
1018
+ .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote)
1019
+ .replace(/\$\{INTERVENTION_NOTE\}/g, interventionNote)
1020
+ .replace(/\$\{VARIANT_NOTE\}/g, variantNote);
1011
1021
  }
1012
1022
 
1013
1023
  function scheduleLoopTick(ctx: ExtensionContext): void {
@@ -1060,10 +1070,18 @@ function sendLoopTurn(): void {
1060
1070
  } else if (bounds.length) {
1061
1071
  boundsNote = `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}`;
1062
1072
  }
1073
+ // v0.24.0: a stuck intervention REPLACES the pep talk — the rotating
1074
+ // directive names why the loop is stuck and what rung of the ladder it's on.
1075
+ const interventionNote = (loop.consecutiveStuck ?? 0) > 0 && loop.lastStuckReason
1076
+ ? loopInterventionDirective(loop.consecutiveStuck!, loop.lastStuckReason, loop.recentTexts ?? [])
1077
+ : "";
1078
+ // v0.24.0: identical prompts invite identical answers — rotate the base
1079
+ // instruction (metricless loops; metric loops already vary via values).
1080
+ const variantNote = metricless ? continueVariant(loop.iteration) : "";
1063
1081
  try {
1064
1082
  extensionApi.sendMessage({
1065
1083
  customType: GOAL_EVENT_ENTRY,
1066
- content: loopPrompt(loop, regressionNote, strategyNote, boundsNote),
1084
+ content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
1067
1085
  display: false,
1068
1086
  }, { triggerTurn: true, deliverAs: "followUp" });
1069
1087
  } catch {
@@ -1083,10 +1101,40 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1083
1101
  // Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
1084
1102
  // for the turn goes into the ledger, making loop history auditable.
1085
1103
  let hypothesis: string | undefined;
1104
+ let lastAssistantText = "";
1086
1105
  if (event) {
1087
1106
  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);
1107
+ lastAssistantText = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1108
+ hypothesis = lastAssistantText.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
1109
+ }
1110
+ // v0.24.0 anti-repetition: roll the behavior windows, then classify. The
1111
+ // plateau stop watches the NUMBER; this watches the WORK — a metricless
1112
+ // loop (no number) has no other defense against doorknob-polishing.
1113
+ const toolsUsed = loop.toolsThisTurn ?? 0;
1114
+ loop.toolsThisTurn = 0;
1115
+ loop.toollessStreak = toolsUsed === 0 ? (loop.toollessStreak ?? 0) + 1 : 0;
1116
+ const previousText = loop.recentTexts && loop.recentTexts.length > 0 ? loop.recentTexts[loop.recentTexts.length - 1] : undefined;
1117
+ if (lastAssistantText) {
1118
+ loop.recentPrints = pushRepetitionCapped(loop.recentPrints ?? [], textFingerprint(lastAssistantText), REPETITION.printWindow);
1119
+ loop.recentTexts = pushRepetitionCapped(loop.recentTexts ?? [], lastAssistantText, REPETITION.textWindow);
1120
+ }
1121
+ const stuckReason = detectLoopStuck({
1122
+ assistantText: lastAssistantText,
1123
+ recentPrints: loop.recentPrints ?? [],
1124
+ previousText,
1125
+ recentToolResults: loop.recentToolResults ?? [],
1126
+ toollessStreak: loop.toollessStreak ?? 0,
1127
+ });
1128
+ if (stuckReason) {
1129
+ loop.consecutiveStuck = (loop.consecutiveStuck ?? 0) + 1;
1130
+ loop.lastStuckReason = stuckReason;
1131
+ appendLedger(ctx.cwd, "loop_stuck", { iteration: loop.iteration, reason: stuckReason, consecutive: loop.consecutiveStuck });
1132
+ if (loop.consecutiveStuck === 1 || loop.consecutiveStuck >= REPETITION.hardResetAfter) {
1133
+ ctx.ui.notify(`Loop stuck (${loop.consecutiveStuck}×): ${stuckReason}`, "warning");
1134
+ }
1135
+ } else {
1136
+ loop.consecutiveStuck = 0;
1137
+ loop.lastStuckReason = undefined;
1090
1138
  }
1091
1139
  const outcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
1092
1140
  persistState(ctx);
@@ -1096,6 +1144,7 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1096
1144
  best: loop.bestValue,
1097
1145
  stall: loop.stallCount,
1098
1146
  hypothesis,
1147
+ stuck: stuckReason,
1099
1148
  });
1100
1149
  // branch=1 mode: commit improvements, hard-reset regressions — always and
1101
1150
  // only on the scratch branch. v0.23.0: a metricless loop has no regression
@@ -1111,6 +1160,18 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1111
1160
  }
1112
1161
  persistState(ctx);
1113
1162
  }
1163
+ // v0.24.0: the top of the stuck ladder — bounded and surfaced, same
1164
+ // philosophy as a plateau stop. The loop ends WITH the reason, not in silence.
1165
+ if (outcome.kind !== "stop" && (loop.consecutiveStuck ?? 0) >= REPETITION.maxInterventions) {
1166
+ loop.active = false;
1167
+ loop.stopReason = `stuck — ${loop.lastStuckReason} (${loop.consecutiveStuck} consecutive interventions)`;
1168
+ persistState(ctx);
1169
+ await finishLoopGit(ctx, loop);
1170
+ ctx.ui.notify(`Loop stopped: ${loop.stopReason}. ${loop.history.length} iterations recorded.`, "warning");
1171
+ appendLedger(ctx.cwd, "loop_stopped", { reason: loop.stopReason, iterations: loop.iteration, best: loop.bestValue });
1172
+ notifyExternal(ctx, `Loop stopped: ${loop.stopReason}`);
1173
+ return;
1174
+ }
1114
1175
  if (outcome.kind === "stop") {
1115
1176
  await finishLoopGit(ctx, loop);
1116
1177
  ctx.ui.notify(`Loop stopped: ${outcome.reason}. ${loop.history.length} iterations recorded.`, "info");
@@ -2528,6 +2589,18 @@ export default function (pi: ExtensionAPI): void {
2528
2589
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
2529
2590
  // messages — count answered (non-cancelled) questionnaires as replies too.
2530
2591
  pi.on("tool_result", async (event: any) => {
2592
+ // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
2593
+ // detection) — recorded for ANY tool result while a loop is active.
2594
+ if (isLoopActive()) {
2595
+ const loop = state.loop!;
2596
+ const out = event?.output ?? event?.result ?? event?.details ?? "";
2597
+ const text = typeof out === "string" ? out : JSON.stringify(out) ?? "";
2598
+ loop.recentToolResults = pushRepetitionCapped(
2599
+ loop.recentToolResults ?? [],
2600
+ { tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
2601
+ REPETITION.toolWindow,
2602
+ );
2603
+ }
2531
2604
  if (draftingTarget === null) return;
2532
2605
  if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
2533
2606
  draftingUserReplies++;
@@ -2715,5 +2788,9 @@ export default function (pi: ExtensionAPI): void {
2715
2788
  pi.on("tool_call", () => {
2716
2789
  toolCallsThisTurn++;
2717
2790
  noteActivity();
2791
+ // v0.24.0: count loop-iteration tool calls (narration-only detection).
2792
+ if (isLoopActive()) {
2793
+ state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
2794
+ }
2718
2795
  });
2719
2796
  }
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.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",
@@ -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