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
|
+
}
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -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
|
-
|
|
1089
|
-
hypothesis =
|
|
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.
|
|
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
|
|