pi-goal-list-loop-audit 0.25.0 → 0.25.2

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/>",
@@ -157,6 +157,10 @@ export interface Goal {
157
157
  };
158
158
  createdAt: string;
159
159
  updatedAt: string;
160
+ /** v0.25.2: per-goal telemetry for /glla stats premature-success
161
+ * detection. Bumped live: turns on agent_end, fileWrites/bashCalls on
162
+ * tool_result while the goal is active. */
163
+ telemetry?: { turns: number; fileWrites: number; bashCalls: number };
160
164
  }
161
165
 
162
166
  /**
@@ -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;
@@ -0,0 +1,273 @@
1
+ // pi-goal-list-loop-audit — v0.25.2
2
+ // extensions/goal-loop-stats.ts
3
+ //
4
+ // /glla stats: per-project ledger rollups. Scans .pi-glla/active.jsonl
5
+ // across every project on the rig and produces the cross-project table
6
+ // the spec-driven verifier (v0.25 design) will be hardened against.
7
+ // Pure helpers take strings/paths so tests drive them from tmpdirs —
8
+ // no dependencies beyond node stdlib (contract boundary).
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ export interface LedgerEntry {
15
+ type: string;
16
+ at?: string;
17
+ value?: any;
18
+ }
19
+
20
+ export interface GoalTelemetry {
21
+ turns: number;
22
+ fileWrites: number;
23
+ bashCalls: number;
24
+ }
25
+
26
+ /** Minimal per-goal shape the rollup reasons about (the ledger's `state`
27
+ * snapshots carry the full goal object; archived goals keep their last). */
28
+ export interface GoalRollupSource {
29
+ id: string;
30
+ status?: string;
31
+ createdAt?: string;
32
+ updatedAt?: string;
33
+ usage?: { tokensUsed?: number };
34
+ auditHistory?: Array<{ approved?: boolean; disapproved?: boolean; error?: string }>;
35
+ telemetry?: GoalTelemetry;
36
+ }
37
+
38
+ export interface ProjectRollup {
39
+ project: string;
40
+ goalsCreated: number;
41
+ auditsApproved: number;
42
+ auditsDisapproved: number;
43
+ auditsError: number;
44
+ avgTurns: number;
45
+ avgWrites: number;
46
+ prematureCount: number;
47
+ /** Total token usage across goals (cost in tokens — no price data on
48
+ * this rig; documented in INSTALL.md). */
49
+ totalCost: number;
50
+ lastActive: string;
51
+ }
52
+
53
+ /** Premature-success thresholds (spec-driven verifier design §3): an
54
+ * approved goal with almost no turns, no real editing, and no
55
+ * verification commands is a "claimed done in 12 turns with 0 file
56
+ * writes" pattern the auditor should have caught. */
57
+ export const PREMATURE_THRESHOLDS = {
58
+ maxTurns: 50,
59
+ maxFileWrites: 5,
60
+ maxBashCalls: 8,
61
+ } as const;
62
+
63
+ export function parseLedgerEntries(jsonl: string): LedgerEntry[] {
64
+ const out: LedgerEntry[] = [];
65
+ for (const line of jsonl.split("\n")) {
66
+ const t = line.trim();
67
+ if (!t) continue;
68
+ try {
69
+ const e = JSON.parse(t);
70
+ if (e && typeof e === "object" && typeof e.type === "string") out.push(e as LedgerEntry);
71
+ } catch {
72
+ /* malformed line — skip */
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Flag the "approved too easily" pattern. Goals without telemetry
79
+ * (archived before v0.25.2) are UNKNOWN, not premature — we do not
80
+ * back-convict historical goals on missing data. */
81
+ export function detectPrematureSuccess(goal: GoalRollupSource): boolean {
82
+ const audits = goal.auditHistory ?? [];
83
+ const approved = audits.filter((a) => a.approved).length;
84
+ if (approved === 0) return false;
85
+ const t = goal.telemetry;
86
+ if (!t) return false;
87
+ return (
88
+ t.turns < PREMATURE_THRESHOLDS.maxTurns &&
89
+ t.fileWrites < PREMATURE_THRESHOLDS.maxFileWrites &&
90
+ t.bashCalls < PREMATURE_THRESHOLDS.maxBashCalls
91
+ );
92
+ }
93
+
94
+ /** Roll up one project's ledger. Pure over the parsed entries — the file
95
+ * read happens in rollupProject. */
96
+ export function rollupEntries(project: string, entries: LedgerEntry[]): ProjectRollup {
97
+ let goalsCreated = 0;
98
+ let lastActive = "";
99
+ const finalGoal = new Map<string, GoalRollupSource>();
100
+ for (const e of entries) {
101
+ if (e.at && e.at > lastActive) lastActive = e.at;
102
+ if (e.type === "goal_created") goalsCreated++;
103
+ if (e.type === "state" && e.value?.goal?.id) {
104
+ finalGoal.set(String(e.value.goal.id), e.value.goal as GoalRollupSource);
105
+ }
106
+ }
107
+ let auditsApproved = 0;
108
+ let auditsDisapproved = 0;
109
+ let auditsError = 0;
110
+ let prematureCount = 0;
111
+ let totalCost = 0;
112
+ let turnsSum = 0;
113
+ let turnsN = 0;
114
+ let writesSum = 0;
115
+ let writesN = 0;
116
+ for (const goal of finalGoal.values()) {
117
+ for (const a of goal.auditHistory ?? []) {
118
+ if (a.approved) auditsApproved++;
119
+ else if (a.disapproved) auditsDisapproved++;
120
+ else if (a.error) auditsError++;
121
+ }
122
+ if (detectPrematureSuccess(goal)) prematureCount++;
123
+ totalCost += goal.usage?.tokensUsed ?? 0;
124
+ if (goal.telemetry) {
125
+ turnsSum += goal.telemetry.turns;
126
+ turnsN++;
127
+ writesSum += goal.telemetry.fileWrites;
128
+ writesN++;
129
+ }
130
+ }
131
+ return {
132
+ project,
133
+ goalsCreated,
134
+ auditsApproved,
135
+ auditsDisapproved,
136
+ auditsError,
137
+ avgTurns: turnsN > 0 ? Math.round((turnsSum / turnsN) * 10) / 10 : 0,
138
+ avgWrites: writesN > 0 ? Math.round((writesSum / writesN) * 10) / 10 : 0,
139
+ prematureCount,
140
+ totalCost,
141
+ lastActive,
142
+ };
143
+ }
144
+
145
+ export function rollupProject(projectPath: string): ProjectRollup | undefined {
146
+ const ledger = path.join(projectPath, ".pi-glla", "active.jsonl");
147
+ let raw: string;
148
+ try {
149
+ raw = fs.readFileSync(ledger, "utf-8");
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ return rollupEntries(projectPath, parseLedgerEntries(raw));
154
+ }
155
+
156
+ /** Project discovery (contract item 6). Sources:
157
+ * 1. ~/.pi/agent/sessions/ — session dir names encode their cwd
158
+ * (`--home-dracon-chat-` ≈ /home/dracon/chat); cheap, no file scans.
159
+ * 2. A bounded walk under ~ (maxdepth 6, pruning node_modules/.git and
160
+ * hidden dirs) for .pi-glla/ directories — catches projects no
161
+ * session has visited. (Deviation from the contract's one-level walk:
162
+ * one level would miss the very projects cited — polis is depth 5.)
163
+ * 3. The current cwd.
164
+ * Only roots with a real .pi-glla/active.jsonl survive. Budget-guarded:
165
+ * the walk stops after `budgetMs`. */
166
+ export function discoverGllaProjects(opts: { home?: string; cwd?: string; budgetMs?: number } = {}): string[] {
167
+ const home = opts.home ?? os.homedir();
168
+ const cwd = opts.cwd ?? process.cwd();
169
+ const budgetMs = opts.budgetMs ?? 2000;
170
+ const deadline = Date.now() + budgetMs;
171
+ const found = new Set<string>();
172
+
173
+ const hasLedger = (dir: string): boolean => {
174
+ try {
175
+ fs.accessSync(path.join(dir, ".pi-glla", "active.jsonl"));
176
+ return true;
177
+ } catch {
178
+ return false;
179
+ }
180
+ };
181
+
182
+ // Source 1: session dir names.
183
+ try {
184
+ const sessionsDir = path.join(home, ".pi", "agent", "sessions");
185
+ for (const name of fs.readdirSync(sessionsDir)) {
186
+ // --home-dracon-chat- → /home/dracon/chat (best-effort decode:
187
+ // strip leading/trailing dashes, then replace -- → /- and - → /).
188
+ const decoded = name.replace(/^-+|-+$/g, "").replace(/--/g, "\0").replace(/-/g, "/").replace(/\0/g, "-");
189
+ const candidate = "/" + decoded;
190
+ if (hasLedger(candidate)) found.add(candidate);
191
+ }
192
+ } catch {
193
+ /* no sessions dir */
194
+ }
195
+
196
+ // Source 2: bounded walk — targeted roots FIRST (~/Dev, ~/chat hold the
197
+ // rig's projects; polis sits at depth 5), the general home walk last
198
+ // with whatever budget remains. Deep/wide dirs that never hold projects
199
+ // are pruned.
200
+ const PRUNE = new Set(["node_modules", ".git", ".pi", ".cache", ".npm", ".local", ".config", "Downloads", "Pictures", "Videos", "Music", ".mozilla", ".vscode", "snap", ".steam", ".wine"]);
201
+ const walk = (dir: string, depth: number): void => {
202
+ if (depth > 6 || Date.now() > deadline) return;
203
+ if (hasLedger(dir)) {
204
+ found.add(dir);
205
+ return; // no nested projects below a project root
206
+ }
207
+ let entries: fs.Dirent[];
208
+ try {
209
+ entries = fs.readdirSync(dir, { withFileTypes: true });
210
+ } catch {
211
+ return;
212
+ }
213
+ for (const e of entries) {
214
+ if (Date.now() > deadline) return;
215
+ if (!e.isDirectory()) continue;
216
+ if (PRUNE.has(e.name)) continue;
217
+ walk(path.join(dir, e.name), depth + 1);
218
+ }
219
+ };
220
+ for (const root of [path.join(home, "Dev"), path.join(home, "chat")]) {
221
+ if (Date.now() > deadline) break;
222
+ walk(root, 1);
223
+ }
224
+ walk(home, 0);
225
+
226
+ // Source 3: cwd.
227
+ if (hasLedger(cwd)) found.add(cwd);
228
+
229
+ return [...found].sort();
230
+ }
231
+
232
+ /** Contract item 4: premature filter — only projects with
233
+ * premature_count > 0, sorted by premature ratio descending. */
234
+ export function filterPremature(rollups: ProjectRollup[]): ProjectRollup[] {
235
+ return rollups
236
+ .filter((r) => r.prematureCount > 0)
237
+ .sort((a, b) => b.prematureCount / Math.max(1, b.goalsCreated) - a.prematureCount / Math.max(1, a.goalsCreated));
238
+ }
239
+
240
+ function shortProject(p: string): string {
241
+ const home = os.homedir();
242
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
243
+ }
244
+
245
+ export function formatRollupTable(rollups: ProjectRollup[]): string {
246
+ const header = "| project | goals | approved | disapproved | errors | avg turns | avg writes | premature | tokens | last active |";
247
+ const sep = "|---|---|---|---|---|---|---|---|---|---|";
248
+ const rows = rollups.map(
249
+ (r) =>
250
+ `| ${shortProject(r.project)} | ${r.goalsCreated} | ${r.auditsApproved} | ${r.auditsDisapproved} | ${r.auditsError} | ${r.avgTurns} | ${r.avgWrites} | ${r.prematureCount} | ${r.totalCost.toLocaleString()} | ${r.lastActive ? r.lastActive.slice(0, 10) : "—"} |`,
251
+ );
252
+ return [header, sep, ...rows].join("\n");
253
+ }
254
+
255
+ /** JSON schema matches the table exactly (contract item 2). */
256
+ export function formatRollupJson(rollups: ProjectRollup[]): string {
257
+ return JSON.stringify(
258
+ rollups.map((r) => ({
259
+ project: r.project,
260
+ goals_created: r.goalsCreated,
261
+ audits_approved: r.auditsApproved,
262
+ audits_disapproved: r.auditsDisapproved,
263
+ audits_error: r.auditsError,
264
+ avg_turns: r.avgTurns,
265
+ avg_writes: r.avgWrites,
266
+ premature_count: r.prematureCount,
267
+ total_cost: r.totalCost,
268
+ last_active: r.lastActive || null,
269
+ })),
270
+ null,
271
+ 2,
272
+ );
273
+ }
@@ -90,10 +90,18 @@ import {
90
90
  settingsProvenance,
91
91
  type Settings,
92
92
  } from "../goal-settings.js";
93
+ import {
94
+ discoverGllaProjects,
95
+ filterPremature,
96
+ formatRollupJson,
97
+ formatRollupTable,
98
+ rollupProject,
99
+ type ProjectRollup,
100
+ } from "../goal-loop-stats.js";
93
101
  import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
94
102
  import {
95
103
  REPETITION,
96
- detectLoopStuck,
104
+ isActuallyStuck,
97
105
  loopInterventionDirective,
98
106
  continueVariant,
99
107
  textFingerprint,
@@ -111,6 +119,8 @@ import {
111
119
  applyRefinement,
112
120
  loopBranchName,
113
121
  parseLoopStartArgs,
122
+ loopFinishStopReason,
123
+ isLoopWriteTool,
114
124
  parseMetric,
115
125
  LOOP_DEFAULTS,
116
126
  resolveSpecFiles,
@@ -1204,18 +1214,63 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
1204
1214
  const toolsUsed = loop.toolsThisTurn ?? 0;
1205
1215
  loop.toolsThisTurn = 0;
1206
1216
  loop.toollessStreak = toolsUsed === 0 ? (loop.toollessStreak ?? 0) + 1 : 0;
1217
+ // v0.25.1 multi-signal stuck gate: gather the iteration's progress
1218
+ // signals BEFORE classifying — file writes (tool_result bumps), git
1219
+ // commits since the iteration began (HEAD advance), spec_item_progress
1220
+ // ledger events since the iteration began. ANY positive signal exempts
1221
+ // the iteration: stable verification from a shipping loop is the goal
1222
+ // state of a metricless loop, not the stuck state.
1223
+ const iterStartHead = loop.iterMetrics?.iterationStartHead;
1224
+ const iterStartAt = loop.iterMetrics?.iterationStartAt;
1225
+ const currentHeadRes = await runGit(ctx, ["rev-parse", "HEAD"]);
1226
+ const currentHead = currentHeadRes.ok ? currentHeadRes.stdout : undefined;
1227
+ let gitCommits = 0;
1228
+ if (iterStartHead && currentHead && iterStartHead !== currentHead) {
1229
+ const countRes = await runGit(ctx, ["rev-list", "--count", `${iterStartHead}..HEAD`]);
1230
+ const n = Number.parseInt(countRes.stdout, 10);
1231
+ if (countRes.ok && Number.isFinite(n) && n > 0) gitCommits = n;
1232
+ }
1233
+ let specItemProgress = 0;
1234
+ if (iterStartAt) {
1235
+ try {
1236
+ const ledgerPath = path.join(ctx.cwd, ".pi-glla", "active.jsonl");
1237
+ const lines = fs.readFileSync(ledgerPath, "utf-8").split("\n");
1238
+ for (const line of lines) {
1239
+ if (!line.includes("spec_item_progress")) continue;
1240
+ try {
1241
+ const entry = JSON.parse(line) as { at?: string };
1242
+ if (entry.at && entry.at >= iterStartAt) specItemProgress++;
1243
+ } catch { /* malformed line */ }
1244
+ }
1245
+ } catch { /* no ledger yet */ }
1246
+ }
1247
+ const iterSignals = {
1248
+ fileWrites: loop.iterMetrics?.fileWrites ?? 0,
1249
+ gitCommits,
1250
+ specItemProgress,
1251
+ currentHead,
1252
+ };
1207
1253
  const previousText = loop.recentTexts && loop.recentTexts.length > 0 ? loop.recentTexts[loop.recentTexts.length - 1] : undefined;
1208
1254
  if (lastAssistantText) {
1209
1255
  loop.recentPrints = pushRepetitionCapped(loop.recentPrints ?? [], textFingerprint(lastAssistantText), REPETITION.printWindow);
1210
1256
  loop.recentTexts = pushRepetitionCapped(loop.recentTexts ?? [], lastAssistantText, REPETITION.textWindow);
1211
1257
  }
1212
- const stuckReason = detectLoopStuck({
1258
+ const stuckReason = isActuallyStuck({
1213
1259
  assistantText: lastAssistantText,
1214
1260
  recentPrints: loop.recentPrints ?? [],
1215
1261
  previousText,
1216
1262
  recentToolResults: loop.recentToolResults ?? [],
1217
1263
  toollessStreak: loop.toollessStreak ?? 0,
1218
- });
1264
+ fileWriteCount: iterSignals.fileWrites,
1265
+ gitCommitCount: iterSignals.gitCommits,
1266
+ specItemProgressCount: iterSignals.specItemProgress,
1267
+ }, loop.toolSameRepeat);
1268
+ // Reset the accumulators so the NEXT iteration measures only itself.
1269
+ loop.iterMetrics = {
1270
+ fileWrites: 0,
1271
+ iterationStartHead: iterSignals.currentHead ?? loop.iterMetrics?.iterationStartHead,
1272
+ iterationStartAt: nowIso(),
1273
+ };
1219
1274
  if (stuckReason) {
1220
1275
  loop.consecutiveStuck = (loop.consecutiveStuck ?? 0) + 1;
1221
1276
  loop.lastStuckReason = stuckReason;
@@ -1302,6 +1357,8 @@ interface LoopConfig {
1302
1357
  force?: boolean;
1303
1358
  timeLimitHours?: number;
1304
1359
  tokenBudget?: number;
1360
+ /** v0.25.1: /loop start toolsamerepeat=N (0 = disable legacy check). */
1361
+ toolSameRepeat?: number;
1305
1362
  }
1306
1363
 
1307
1364
  /** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
@@ -1365,6 +1422,8 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1365
1422
  tokensUsed: 0,
1366
1423
  branchName,
1367
1424
  originalBranch,
1425
+ toolSameRepeat: cfg.toolSameRepeat,
1426
+ iterMetrics: { fileWrites: 0, iterationStartAt: nowIso() },
1368
1427
  },
1369
1428
  };
1370
1429
  persistState(ctx);
@@ -1475,6 +1534,27 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1475
1534
  return;
1476
1535
  }
1477
1536
 
1537
+ // v0.25.1: a CLEAN end — "completed: <reason>", distinct from
1538
+ // stuck/plateau/stopped-by-user. Additive: /loop stop is untouched.
1539
+ if (sub === "finish") {
1540
+ if (!state.loop) {
1541
+ ctx.ui.notify("No loop to finish.", "info");
1542
+ return;
1543
+ }
1544
+ clearLoopTimer();
1545
+ const reason = loopFinishStopReason(rest);
1546
+ state.loop = { ...state.loop, active: false, stopReason: reason };
1547
+ persistState(ctx);
1548
+ await finishLoopGit(ctx, state.loop);
1549
+ appendLedger(ctx.cwd, "loop_stopped", { reason, iterations: state.loop.iteration, best: state.loop.bestValue });
1550
+ ctx.ui.notify(
1551
+ `Loop finished (${reason}) after ${state.loop.iteration} iterations. Best: ${state.loop.bestValue ?? "n/a"}.`,
1552
+ "info",
1553
+ );
1554
+ notifyExternal(ctx, `Loop finished: ${reason}`);
1555
+ return;
1556
+ }
1557
+
1478
1558
  if (sub === "respec") {
1479
1559
  // v0.24.3: reconcile the codebase against the root spec, forever.
1480
1560
  // Same auto-start path as /loop start (the user typed the command —
@@ -2595,6 +2675,42 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
2595
2675
  }
2596
2676
  }
2597
2677
 
2678
+ /**
2679
+ * v0.25.2: /glla stats — one command, every project's rollup. Args:
2680
+ * (none) markdown table, all discovered projects
2681
+ * json machine-readable rollup (same schema as the table)
2682
+ * premature only projects with premature_success > 0, ratio-sorted
2683
+ * project=<path> limit the scan to one project
2684
+ */
2685
+ function cmdStats(args: string, ctx: ExtensionContext): void {
2686
+ const asJson = /\bjson\b/.test(args);
2687
+ const prematureOnly = /\bpremature\b/.test(args);
2688
+ const projectMatch = args.match(/project=(\S+)/);
2689
+ let rollups: ProjectRollup[] = [];
2690
+ if (projectMatch) {
2691
+ const p = projectMatch[1]!.replace(/^~/, os.homedir());
2692
+ const r = rollupProject(p);
2693
+ if (!r) {
2694
+ ctx.ui.notify(`/glla stats: no .pi-glla/active.jsonl under ${p}`, "warning");
2695
+ return;
2696
+ }
2697
+ rollups = [r];
2698
+ } else {
2699
+ const projects = discoverGllaProjects({ cwd: ctx.cwd });
2700
+ for (const p of projects) {
2701
+ const r = rollupProject(p);
2702
+ if (r) rollups.push(r);
2703
+ }
2704
+ if (rollups.length === 0) {
2705
+ ctx.ui.notify("/glla stats: no projects with .pi-glla/active.jsonl found on this rig.", "info");
2706
+ return;
2707
+ }
2708
+ }
2709
+ if (prematureOnly) rollups = filterPremature(rollups);
2710
+ const out = asJson ? formatRollupJson(rollups) : formatRollupTable(rollups);
2711
+ ctx.ui.notify(`glla stats — ${rollups.length} project(s)${prematureOnly ? " (premature filter)" : ""}\n${out}`, "info");
2712
+ }
2713
+
2598
2714
  async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2599
2715
  // The plugin's ONE config surface — global by default, rarely opened.
2600
2716
  // /glla show effective values + where each comes from
@@ -2606,7 +2722,13 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2606
2722
  // /glla auditfeedbackchars=800 cap executor-visible disapproval report (0=full, the default)
2607
2723
  // /glla project model=... write to PROJECT override (rare)
2608
2724
  // /glla model=unset remove key (from global; project model=unset for project)
2725
+ // /glla stats [json|premature|project=<path>] per-project ledger rollups (v0.25.2)
2609
2726
  const trimmed = args.trim();
2727
+ // v0.25.2: /glla stats sub-mode — cross-project telemetry rollups.
2728
+ if (/^stats\b/.test(trimmed)) {
2729
+ cmdStats(trimmed.slice("stats".length).trim(), ctx);
2730
+ return;
2731
+ }
2610
2732
  if (!trimmed) {
2611
2733
  if (ctx.hasUI) {
2612
2734
  await openSettingsUI(ctx);
@@ -2903,6 +3025,7 @@ export default function (pi: ExtensionAPI): void {
2903
3025
  ["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
2904
3026
  ["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
2905
3027
  ["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
3028
+ ["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
2906
3029
  ["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
2907
3030
  ["project", "write a project override: /glla project key=value"],
2908
3031
  ]),
@@ -2927,6 +3050,7 @@ export default function (pi: ExtensionAPI): void {
2927
3050
  ["respec", "infinite metricless loop reconciling the codebase against the root SPEC.md"],
2928
3051
  ["status", "show metric, iteration, best/last values, stall count"],
2929
3052
  ["stop", "end the loop (keeps the best state)"],
3053
+ ["finish", "end the loop cleanly: /loop finish [reason] → stopReason 'completed: <reason>'"],
2930
3054
  ]),
2931
3055
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdLoop(args, ctx); },
2932
3056
  });
@@ -2985,6 +3109,23 @@ export default function (pi: ExtensionAPI): void {
2985
3109
  { tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
2986
3110
  REPETITION.toolWindow,
2987
3111
  );
3112
+ // v0.25.1: file-write progress signal for the multi-signal stuck
3113
+ // gate — a loop that is WRITING files is shipping, not stuck.
3114
+ if (isLoopWriteTool(String(event?.toolName ?? ""))) {
3115
+ const metrics = loop.iterMetrics ?? { fileWrites: 0 };
3116
+ metrics.fileWrites++;
3117
+ loop.iterMetrics = metrics;
3118
+ }
3119
+ }
3120
+ // v0.25.2: per-goal tool telemetry (/glla stats premature detection).
3121
+ if (state.goal && state.goal.status === "active") {
3122
+ const toolName = String(event?.toolName ?? "");
3123
+ if (isLoopWriteTool(toolName) || toolName === "bash") {
3124
+ const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
3125
+ if (isLoopWriteTool(toolName)) t.fileWrites++;
3126
+ if (toolName === "bash") t.bashCalls++;
3127
+ state.goal.telemetry = t;
3128
+ }
2988
3129
  }
2989
3130
  if (draftingTarget === null) return;
2990
3131
  if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
@@ -3104,6 +3245,12 @@ export default function (pi: ExtensionAPI): void {
3104
3245
  // continuation loop.
3105
3246
  if (isForeignCtx(ctx)) return;
3106
3247
  noteActivity();
3248
+ // v0.25.2: per-goal turn telemetry (/glla stats).
3249
+ if (state.goal && state.goal.status === "active") {
3250
+ const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
3251
+ t.turns++;
3252
+ state.goal.telemetry = t;
3253
+ }
3107
3254
  if (!registeredCtx) {
3108
3255
  registerAgentTools(pi, ctx);
3109
3256
  registeredCtx = ctx;
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.2",
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",