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 (different from the original <goal> objective), and the shift is justified — e.g. the original objective was blocked, the agent pivoted to higher-ROI items, and the new items are independently verified — accept the shift as evidence of reasonable engineering judgment. Do NOT rigidly disapprove because the original objective is not literally shipped; audit the shifted work on its merits and say so in the report.",
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
- const recentTools = recentToolResults.slice(-REPETITION.toolResultRepeat);
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
- recentTools.length === REPETITION.toolResultRepeat &&
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 ${REPETITION.toolResultRepeat}× in a row`
199
- : `same ${recentTools[0]!.tool} result ${REPETITION.toolResultRepeat}× in a row (no new information)`;
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;
@@ -93,7 +93,7 @@ import {
93
93
  import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
94
94
  import {
95
95
  REPETITION,
96
- detectLoopStuck,
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 = detectLoopStuck({
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.0",
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",