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