pi-goal-list-loop-audit 0.25.0 → 0.25.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.
|
@@ -107,7 +107,11 @@ function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | u
|
|
|
107
107
|
"Use read/grep/find/ls/bash as needed to inspect real artifacts. Do not mutate files or run destructive commands.",
|
|
108
108
|
"If the work is only an alpha scaffold, generated template, shallow draft, proxy milestone, or lacks the user-facing value requested, disapprove.",
|
|
109
109
|
"If any explicit requirement is missing, weakly verified, contradicted, or not inspectable with the available evidence, disapprove.",
|
|
110
|
-
"Objective shift: if the executor's <completion_summary> explicitly states that the work has shifted to other items
|
|
110
|
+
"Objective shift: if the executor's <completion_summary> explicitly states that the work has shifted to other items",
|
|
111
|
+
"(different from the original <goal> objective), and the shift is justified — e.g. the original objective was blocked,",
|
|
112
|
+
"the agent pivoted to higher-ROI items, and the new items are independently verified — accept the shift as evidence",
|
|
113
|
+
"of reasonable engineering judgment. Do NOT rigidly disapprove because the original objective is not literally shipped;",
|
|
114
|
+
"audit the shifted work on its merits and say so in the report.",
|
|
111
115
|
"Return a concise audit report. The final line MUST be exactly one of:",
|
|
112
116
|
"<approved/>",
|
|
113
117
|
"<disapproved/>",
|
|
@@ -75,6 +75,33 @@ export interface LoopState {
|
|
|
75
75
|
consecutiveStuck?: number;
|
|
76
76
|
/** v0.24.0: the last stuck reason (for the intervention directive + ledger). */
|
|
77
77
|
lastStuckReason?: string;
|
|
78
|
+
/** v0.25.1: /loop start toolsamerepeat=N — legacy same-tool-same-result
|
|
79
|
+
* check window. 0 disables it (multi-signal detector only). */
|
|
80
|
+
toolSameRepeat?: number;
|
|
81
|
+
/** v0.25.1: per-iteration progress-signal accumulators for the
|
|
82
|
+
* multi-signal stuck gate. fileWrites bumps on write/edit tool results;
|
|
83
|
+
* iterationStartHead/At snapshot when the iteration BEGAN so the tick can
|
|
84
|
+
* count commits and spec_item_progress events produced during it. */
|
|
85
|
+
iterMetrics?: {
|
|
86
|
+
fileWrites: number;
|
|
87
|
+
iterationStartHead?: string;
|
|
88
|
+
iterationStartAt?: string;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** v0.25.1: stop reason for /loop finish — a clean "completed" end,
|
|
93
|
+
* distinct from stuck/plateau/stopped-by-user. */
|
|
94
|
+
export function loopFinishStopReason(reason?: string): string {
|
|
95
|
+
const r = (reason ?? "").trim();
|
|
96
|
+
return `completed: ${r || "finished by user"}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** v0.25.1: tool names that count as file-write progress signals for the
|
|
100
|
+
* multi-signal stuck gate (item 3). */
|
|
101
|
+
export const LOOP_WRITE_TOOLS = ["write", "edit", "multi_edit", "write_file"] as const;
|
|
102
|
+
|
|
103
|
+
export function isLoopWriteTool(toolName: string): boolean {
|
|
104
|
+
return (LOOP_WRITE_TOOLS as readonly string[]).includes(toolName);
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
/** Scratch-branch name for branch=1 mode. Format pinned by tests. */
|
|
@@ -229,6 +256,7 @@ export function parseLoopStartArgs(raw: string): {
|
|
|
229
256
|
force: boolean;
|
|
230
257
|
timeLimitHours?: number;
|
|
231
258
|
tokenBudget?: number;
|
|
259
|
+
toolSameRepeat?: number;
|
|
232
260
|
} {
|
|
233
261
|
// Key=value pairs first (measure= and direction= may hold quoted values),
|
|
234
262
|
// the remaining text is the target.
|
|
@@ -294,6 +322,12 @@ export function parseLoopStartArgs(raw: string): {
|
|
|
294
322
|
force: forceRaw === "1" || forceRaw === "true" || forceRaw === "yes",
|
|
295
323
|
timeLimitHours: Number.isFinite(timeRaw) && timeRaw > 0 ? timeRaw : undefined,
|
|
296
324
|
tokenBudget: Number.isFinite(tokensRaw) && tokensRaw > 0 ? tokensRaw : undefined,
|
|
325
|
+
toolSameRepeat: (() => {
|
|
326
|
+
const raw = (kv.get("toolsamerepeat") ?? "").trim();
|
|
327
|
+
if (!raw) return undefined;
|
|
328
|
+
const n = Number.parseInt(raw, 10);
|
|
329
|
+
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
330
|
+
})(),
|
|
297
331
|
};
|
|
298
332
|
}
|
|
299
333
|
|
|
@@ -142,6 +142,59 @@ export interface LoopStuckInput {
|
|
|
142
142
|
recentToolResults: ToolResultPrint[];
|
|
143
143
|
/** Consecutive iterations with zero tool calls, including this one. */
|
|
144
144
|
toollessStreak: number;
|
|
145
|
+
/** v0.25.1 progress signals (multi-signal stuck model): file writes,
|
|
146
|
+
* git commits, and spec_item_progress events in the FINISHED iteration.
|
|
147
|
+
* Optional for backward compat — absent = 0. */
|
|
148
|
+
fileWriteCount?: number;
|
|
149
|
+
gitCommitCount?: number;
|
|
150
|
+
specItemProgressCount?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* v0.25.1: forward-transition marker — the agent explicitly declares it is
|
|
155
|
+
* moving on ("Next step (iter-222, implement branch)"). Conservative word
|
|
156
|
+
* list: if the agent isn't saying "moving on", it's probably spinning.
|
|
157
|
+
*/
|
|
158
|
+
export function forwardTransitionMarker(text: string): boolean {
|
|
159
|
+
if (
|
|
160
|
+
/\b(next|next step|next phantom|next iter|iter[ -]\d+|pivot|moving on|implement next|switch to|now (do|implement|fix|add)|then (do|implement|fix|add))\b/i.test(
|
|
161
|
+
text,
|
|
162
|
+
)
|
|
163
|
+
) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// Line-start "Next:" / "Next step" — the phrasing the wild-caught
|
|
167
|
+
// transcripts used.
|
|
168
|
+
return /^\s*next\s*(step)?\s*:/im.test(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A forward marker only counts PAIRED with a real write/commit in the
|
|
172
|
+
* same iteration — pure narration ("next: implement X" × N, shipping
|
|
173
|
+
* nothing) is the narrate-but-don't-ship loop and IS stuck. */
|
|
174
|
+
export function forwardTransitionPaired(input: LoopStuckInput): boolean {
|
|
175
|
+
if (!forwardTransitionMarker(input.assistantText)) return false;
|
|
176
|
+
return (input.fileWriteCount ?? 0) > 0 || (input.gitCommitCount ?? 0) > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* v0.25.1 multi-signal stuck gate (progress-signals model): an iteration
|
|
181
|
+
* is stuck ONLY when every progress signal is zero — file writes, git
|
|
182
|
+
* commits, spec_item_progress events, and a PAIRED forward transition —
|
|
183
|
+
* AND the legacy single-signal detector also fires. Stable verification
|
|
184
|
+
* output from a loop that is still shipping is the GOAL state of a
|
|
185
|
+
* metricless loop, not the stuck state (the two wild-caught transcripts
|
|
186
|
+
* that motivated this rework both died in exactly that false positive).
|
|
187
|
+
*
|
|
188
|
+
* `toolSameRepeat` (the /loop start toolsamerepeat=N kwarg) forwards to
|
|
189
|
+
* the legacy check: 0 disables the same-tool-same-result branch entirely
|
|
190
|
+
* (new detector only), undefined = REPETITION.toolResultRepeat.
|
|
191
|
+
*/
|
|
192
|
+
export function isActuallyStuck(input: LoopStuckInput, toolSameRepeat?: number): string | undefined {
|
|
193
|
+
if ((input.fileWriteCount ?? 0) > 0) return undefined;
|
|
194
|
+
if ((input.gitCommitCount ?? 0) > 0) return undefined;
|
|
195
|
+
if ((input.specItemProgressCount ?? 0) > 0) return undefined;
|
|
196
|
+
if (forwardTransitionPaired(input)) return undefined;
|
|
197
|
+
return detectLoopStuck(input, toolSameRepeat);
|
|
145
198
|
}
|
|
146
199
|
|
|
147
200
|
function clip(text: string, n: number): string {
|
|
@@ -154,7 +207,7 @@ function clip(text: string, n: number): string {
|
|
|
154
207
|
* or undefined when it's working normally. Checks run cheapest-and-most-
|
|
155
208
|
* certain first; the first hit wins so the reason stays specific.
|
|
156
209
|
*/
|
|
157
|
-
export function detectLoopStuck(input: LoopStuckInput): string | undefined {
|
|
210
|
+
export function detectLoopStuck(input: LoopStuckInput, toolResultRepeatOverride?: number): string | undefined {
|
|
158
211
|
const { assistantText, recentPrints, previousText, recentToolResults, toollessStreak } = input;
|
|
159
212
|
|
|
160
213
|
// Narration only: iterations that never touch tools produce nothing inspectable.
|
|
@@ -189,14 +242,17 @@ export function detectLoopStuck(input: LoopStuckInput): string | undefined {
|
|
|
189
242
|
}
|
|
190
243
|
|
|
191
244
|
// Same tool, same output, three times running: the loop is re-reading a result it already has.
|
|
192
|
-
|
|
245
|
+
// v0.25.1: toolsamerepeat=0 disables this branch entirely (new detector only).
|
|
246
|
+
const toolResultRepeat = toolResultRepeatOverride ?? REPETITION.toolResultRepeat;
|
|
247
|
+
const recentTools = toolResultRepeat > 0 ? recentToolResults.slice(-toolResultRepeat) : [];
|
|
193
248
|
if (
|
|
194
|
-
|
|
249
|
+
toolResultRepeat > 0 &&
|
|
250
|
+
recentTools.length === toolResultRepeat &&
|
|
195
251
|
recentTools.every((r) => r.tool === recentTools[0]!.tool && r.hash === recentTools[0]!.hash)
|
|
196
252
|
) {
|
|
197
253
|
return recentTools.every((r) => r.isError)
|
|
198
|
-
? `same ${recentTools[0]!.tool} error ${
|
|
199
|
-
: `same ${recentTools[0]!.tool} result ${
|
|
254
|
+
? `same ${recentTools[0]!.tool} error ${toolResultRepeat}× in a row`
|
|
255
|
+
: `same ${recentTools[0]!.tool} result ${toolResultRepeat}× in a row (no new information)`;
|
|
200
256
|
}
|
|
201
257
|
|
|
202
258
|
return undefined;
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -93,7 +93,7 @@ import {
|
|
|
93
93
|
import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
|
|
94
94
|
import {
|
|
95
95
|
REPETITION,
|
|
96
|
-
|
|
96
|
+
isActuallyStuck,
|
|
97
97
|
loopInterventionDirective,
|
|
98
98
|
continueVariant,
|
|
99
99
|
textFingerprint,
|
|
@@ -111,6 +111,8 @@ import {
|
|
|
111
111
|
applyRefinement,
|
|
112
112
|
loopBranchName,
|
|
113
113
|
parseLoopStartArgs,
|
|
114
|
+
loopFinishStopReason,
|
|
115
|
+
isLoopWriteTool,
|
|
114
116
|
parseMetric,
|
|
115
117
|
LOOP_DEFAULTS,
|
|
116
118
|
resolveSpecFiles,
|
|
@@ -1204,18 +1206,63 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
|
|
|
1204
1206
|
const toolsUsed = loop.toolsThisTurn ?? 0;
|
|
1205
1207
|
loop.toolsThisTurn = 0;
|
|
1206
1208
|
loop.toollessStreak = toolsUsed === 0 ? (loop.toollessStreak ?? 0) + 1 : 0;
|
|
1209
|
+
// v0.25.1 multi-signal stuck gate: gather the iteration's progress
|
|
1210
|
+
// signals BEFORE classifying — file writes (tool_result bumps), git
|
|
1211
|
+
// commits since the iteration began (HEAD advance), spec_item_progress
|
|
1212
|
+
// ledger events since the iteration began. ANY positive signal exempts
|
|
1213
|
+
// the iteration: stable verification from a shipping loop is the goal
|
|
1214
|
+
// state of a metricless loop, not the stuck state.
|
|
1215
|
+
const iterStartHead = loop.iterMetrics?.iterationStartHead;
|
|
1216
|
+
const iterStartAt = loop.iterMetrics?.iterationStartAt;
|
|
1217
|
+
const currentHeadRes = await runGit(ctx, ["rev-parse", "HEAD"]);
|
|
1218
|
+
const currentHead = currentHeadRes.ok ? currentHeadRes.stdout : undefined;
|
|
1219
|
+
let gitCommits = 0;
|
|
1220
|
+
if (iterStartHead && currentHead && iterStartHead !== currentHead) {
|
|
1221
|
+
const countRes = await runGit(ctx, ["rev-list", "--count", `${iterStartHead}..HEAD`]);
|
|
1222
|
+
const n = Number.parseInt(countRes.stdout, 10);
|
|
1223
|
+
if (countRes.ok && Number.isFinite(n) && n > 0) gitCommits = n;
|
|
1224
|
+
}
|
|
1225
|
+
let specItemProgress = 0;
|
|
1226
|
+
if (iterStartAt) {
|
|
1227
|
+
try {
|
|
1228
|
+
const ledgerPath = path.join(ctx.cwd, ".pi-glla", "active.jsonl");
|
|
1229
|
+
const lines = fs.readFileSync(ledgerPath, "utf-8").split("\n");
|
|
1230
|
+
for (const line of lines) {
|
|
1231
|
+
if (!line.includes("spec_item_progress")) continue;
|
|
1232
|
+
try {
|
|
1233
|
+
const entry = JSON.parse(line) as { at?: string };
|
|
1234
|
+
if (entry.at && entry.at >= iterStartAt) specItemProgress++;
|
|
1235
|
+
} catch { /* malformed line */ }
|
|
1236
|
+
}
|
|
1237
|
+
} catch { /* no ledger yet */ }
|
|
1238
|
+
}
|
|
1239
|
+
const iterSignals = {
|
|
1240
|
+
fileWrites: loop.iterMetrics?.fileWrites ?? 0,
|
|
1241
|
+
gitCommits,
|
|
1242
|
+
specItemProgress,
|
|
1243
|
+
currentHead,
|
|
1244
|
+
};
|
|
1207
1245
|
const previousText = loop.recentTexts && loop.recentTexts.length > 0 ? loop.recentTexts[loop.recentTexts.length - 1] : undefined;
|
|
1208
1246
|
if (lastAssistantText) {
|
|
1209
1247
|
loop.recentPrints = pushRepetitionCapped(loop.recentPrints ?? [], textFingerprint(lastAssistantText), REPETITION.printWindow);
|
|
1210
1248
|
loop.recentTexts = pushRepetitionCapped(loop.recentTexts ?? [], lastAssistantText, REPETITION.textWindow);
|
|
1211
1249
|
}
|
|
1212
|
-
const stuckReason =
|
|
1250
|
+
const stuckReason = isActuallyStuck({
|
|
1213
1251
|
assistantText: lastAssistantText,
|
|
1214
1252
|
recentPrints: loop.recentPrints ?? [],
|
|
1215
1253
|
previousText,
|
|
1216
1254
|
recentToolResults: loop.recentToolResults ?? [],
|
|
1217
1255
|
toollessStreak: loop.toollessStreak ?? 0,
|
|
1218
|
-
|
|
1256
|
+
fileWriteCount: iterSignals.fileWrites,
|
|
1257
|
+
gitCommitCount: iterSignals.gitCommits,
|
|
1258
|
+
specItemProgressCount: iterSignals.specItemProgress,
|
|
1259
|
+
}, loop.toolSameRepeat);
|
|
1260
|
+
// Reset the accumulators so the NEXT iteration measures only itself.
|
|
1261
|
+
loop.iterMetrics = {
|
|
1262
|
+
fileWrites: 0,
|
|
1263
|
+
iterationStartHead: iterSignals.currentHead ?? loop.iterMetrics?.iterationStartHead,
|
|
1264
|
+
iterationStartAt: nowIso(),
|
|
1265
|
+
};
|
|
1219
1266
|
if (stuckReason) {
|
|
1220
1267
|
loop.consecutiveStuck = (loop.consecutiveStuck ?? 0) + 1;
|
|
1221
1268
|
loop.lastStuckReason = stuckReason;
|
|
@@ -1302,6 +1349,8 @@ interface LoopConfig {
|
|
|
1302
1349
|
force?: boolean;
|
|
1303
1350
|
timeLimitHours?: number;
|
|
1304
1351
|
tokenBudget?: number;
|
|
1352
|
+
/** v0.25.1: /loop start toolsamerepeat=N (0 = disable legacy check). */
|
|
1353
|
+
toolSameRepeat?: number;
|
|
1305
1354
|
}
|
|
1306
1355
|
|
|
1307
1356
|
/** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
|
|
@@ -1365,6 +1414,8 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
|
|
|
1365
1414
|
tokensUsed: 0,
|
|
1366
1415
|
branchName,
|
|
1367
1416
|
originalBranch,
|
|
1417
|
+
toolSameRepeat: cfg.toolSameRepeat,
|
|
1418
|
+
iterMetrics: { fileWrites: 0, iterationStartAt: nowIso() },
|
|
1368
1419
|
},
|
|
1369
1420
|
};
|
|
1370
1421
|
persistState(ctx);
|
|
@@ -1475,6 +1526,27 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
1475
1526
|
return;
|
|
1476
1527
|
}
|
|
1477
1528
|
|
|
1529
|
+
// v0.25.1: a CLEAN end — "completed: <reason>", distinct from
|
|
1530
|
+
// stuck/plateau/stopped-by-user. Additive: /loop stop is untouched.
|
|
1531
|
+
if (sub === "finish") {
|
|
1532
|
+
if (!state.loop) {
|
|
1533
|
+
ctx.ui.notify("No loop to finish.", "info");
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
clearLoopTimer();
|
|
1537
|
+
const reason = loopFinishStopReason(rest);
|
|
1538
|
+
state.loop = { ...state.loop, active: false, stopReason: reason };
|
|
1539
|
+
persistState(ctx);
|
|
1540
|
+
await finishLoopGit(ctx, state.loop);
|
|
1541
|
+
appendLedger(ctx.cwd, "loop_stopped", { reason, iterations: state.loop.iteration, best: state.loop.bestValue });
|
|
1542
|
+
ctx.ui.notify(
|
|
1543
|
+
`Loop finished (${reason}) after ${state.loop.iteration} iterations. Best: ${state.loop.bestValue ?? "n/a"}.`,
|
|
1544
|
+
"info",
|
|
1545
|
+
);
|
|
1546
|
+
notifyExternal(ctx, `Loop finished: ${reason}`);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1478
1550
|
if (sub === "respec") {
|
|
1479
1551
|
// v0.24.3: reconcile the codebase against the root spec, forever.
|
|
1480
1552
|
// Same auto-start path as /loop start (the user typed the command —
|
|
@@ -2927,6 +2999,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2927
2999
|
["respec", "infinite metricless loop reconciling the codebase against the root SPEC.md"],
|
|
2928
3000
|
["status", "show metric, iteration, best/last values, stall count"],
|
|
2929
3001
|
["stop", "end the loop (keeps the best state)"],
|
|
3002
|
+
["finish", "end the loop cleanly: /loop finish [reason] → stopReason 'completed: <reason>'"],
|
|
2930
3003
|
]),
|
|
2931
3004
|
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdLoop(args, ctx); },
|
|
2932
3005
|
});
|
|
@@ -2985,6 +3058,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2985
3058
|
{ tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
|
|
2986
3059
|
REPETITION.toolWindow,
|
|
2987
3060
|
);
|
|
3061
|
+
// v0.25.1: file-write progress signal for the multi-signal stuck
|
|
3062
|
+
// gate — a loop that is WRITING files is shipping, not stuck.
|
|
3063
|
+
if (isLoopWriteTool(String(event?.toolName ?? ""))) {
|
|
3064
|
+
const metrics = loop.iterMetrics ?? { fileWrites: 0 };
|
|
3065
|
+
metrics.fileWrites++;
|
|
3066
|
+
loop.iterMetrics = metrics;
|
|
3067
|
+
}
|
|
2988
3068
|
}
|
|
2989
3069
|
if (draftingTarget === null) return;
|
|
2990
3070
|
if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.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",
|