pi-goal-list-loop-audit 0.25.1 → 0.25.3
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.
|
@@ -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
|
/**
|
|
@@ -918,3 +922,125 @@ export function lastShippedAtMs(cwd: string): number | null {
|
|
|
918
922
|
}
|
|
919
923
|
return best;
|
|
920
924
|
}
|
|
925
|
+
|
|
926
|
+
// =================================================================
|
|
927
|
+
// v0.25.3: list-philosophy rework — cross-mode recommendation +
|
|
928
|
+
// /list depth rollups
|
|
929
|
+
// =================================================================
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Detect a mode mismatch between what the user described and the mode
|
|
933
|
+
* they invoked. Returns a recommendation string for the drafting
|
|
934
|
+
* injection, or undefined when the seed fits the mode.
|
|
935
|
+
*
|
|
936
|
+
* The canonical failure this prevents (real incidents 2026-07-24):
|
|
937
|
+
* "close 76 weak points, one commit each" folded into ONE wrapper goal
|
|
938
|
+
* with an aggregate "≥ 76 commits" contract → auto-committer squash →
|
|
939
|
+
* literal count fails → auditor correctly disapproves finished work.
|
|
940
|
+
*/
|
|
941
|
+
export function crossRecommendMode(seed: string, mode: "goal" | "list"): string | undefined {
|
|
942
|
+
const s = seed.trim();
|
|
943
|
+
if (!s) return undefined;
|
|
944
|
+
// Aggregate seed: "N items/findings/weak points/screens/todos/fixes"
|
|
945
|
+
// (+ "each" / "one commit" flavor) — the wrapper-goal anti-pattern.
|
|
946
|
+
const aggregate = s.match(/(\d+)\s*(?:items?|findings?|weak[\s-]points?|screens?|todos?|fix(?:es)?|tasks?|issues?)/i);
|
|
947
|
+
const n = aggregate ? Number(aggregate[1]) : 0;
|
|
948
|
+
if (n >= 5) {
|
|
949
|
+
return (
|
|
950
|
+
`[MODE CHECK — this seed names ${n} discrete items${/each|one commit|as a tasklist/i.test(s) ? ' ("each"/"tasklist" phrasing)' : ""}. ` +
|
|
951
|
+
`Do NOT fold them into ONE wrapper ${mode === "list" ? "list item" : "goal"} with an aggregate contract ("≥ ${n} commits") — ` +
|
|
952
|
+
`the auto-committer squashes commits and the literal count fails even when the work is done (the 2026-07-24 76-weak-points incident). ` +
|
|
953
|
+
`Propose ${n} SHORT /list items via propose_goal_draft items[] — each item closes exactly ONE finding with its own per-item contract. ` +
|
|
954
|
+
`Any aggregate re-audit becomes the FINAL /goal, not the first.]`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (mode === "list") {
|
|
958
|
+
if (/\b(?:take|takes|taking)\s+(?:a\s+)?(?:few|several|\d+)\s+hours?\b/i.test(s) || /\b(?:multi-hour|deep (?:audit|research|dive)|all day|over the weekend)\b/i.test(s)) {
|
|
959
|
+
return (
|
|
960
|
+
`[MODE CHECK — this seed sounds like multi-hour work. /list items are SHORT (minutes, one focused change). ` +
|
|
961
|
+
`Either break it into ≤ 30-minute items via items[], or tell the user this fits /goal better — one big task, ` +
|
|
962
|
+
`ends on auditor approval. If the user overrides ("as a list item anyway"), comply.]`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
} else {
|
|
966
|
+
if (/^(?:fix|typo|rename|bump|remove|delete|clean ?up|tweak)\b/i.test(s) && s.length < 80 && !/\bhours?\b|\ball\b|\bevery\b|\beach\b/i.test(s)) {
|
|
967
|
+
return (
|
|
968
|
+
`[MODE CHECK — this seed sounds like a five-minute cleanup. A full audited /goal may be overkill; ` +
|
|
969
|
+
`suggest /list (queue of short items) or the tasklist plugin. If the user wants the audit anyway, comply.]`
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** /list depth rollup: how deep is the queue, how stale is the head,
|
|
977
|
+
* how long do items actually take (from archived list-policy goals). */
|
|
978
|
+
export interface ListDepthStats {
|
|
979
|
+
queueDepth: number;
|
|
980
|
+
oldestItemId?: string;
|
|
981
|
+
oldestAgeMs?: number;
|
|
982
|
+
avgDurationMs?: number;
|
|
983
|
+
durationSamples: number;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function computeListDepth(
|
|
987
|
+
queue: Array<{ id: string; addedAt: string }>,
|
|
988
|
+
ledgerEntries: Array<{ type: string; value?: any }>,
|
|
989
|
+
nowMs: number,
|
|
990
|
+
): ListDepthStats {
|
|
991
|
+
let oldestItemId: string | undefined;
|
|
992
|
+
let oldestAgeMs: number | undefined;
|
|
993
|
+
for (const item of queue) {
|
|
994
|
+
const added = Date.parse(item.addedAt);
|
|
995
|
+
if (Number.isNaN(added)) continue;
|
|
996
|
+
const age = nowMs - added;
|
|
997
|
+
if (oldestAgeMs === undefined || age > oldestAgeMs) {
|
|
998
|
+
oldestAgeMs = age;
|
|
999
|
+
oldestItemId = item.id;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
// Average item duration from the ledger's list-policy goals (most
|
|
1003
|
+
// recent 10 with both timestamps).
|
|
1004
|
+
const finals = new Map<string, { createdAt?: string; updatedAt?: string; policy?: string; status?: string }>();
|
|
1005
|
+
for (const e of ledgerEntries) {
|
|
1006
|
+
if (e.type === "state" && e.value?.goal?.id) {
|
|
1007
|
+
finals.set(String(e.value.goal.id), e.value.goal);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const durations: number[] = [];
|
|
1011
|
+
for (const g of finals.values()) {
|
|
1012
|
+
if (g.policy !== "list") continue;
|
|
1013
|
+
if (g.status !== "complete" && g.status !== "archived") continue;
|
|
1014
|
+
const c = Date.parse(g.createdAt ?? "");
|
|
1015
|
+
const u = Date.parse(g.updatedAt ?? "");
|
|
1016
|
+
if (Number.isNaN(c) || Number.isNaN(u) || u < c) continue;
|
|
1017
|
+
durations.push(u - c);
|
|
1018
|
+
}
|
|
1019
|
+
const recent = durations.slice(-10);
|
|
1020
|
+
const avgDurationMs = recent.length > 0 ? Math.round(recent.reduce((a, b) => a + b, 0) / recent.length) : undefined;
|
|
1021
|
+
return {
|
|
1022
|
+
queueDepth: queue.length,
|
|
1023
|
+
oldestItemId,
|
|
1024
|
+
oldestAgeMs,
|
|
1025
|
+
avgDurationMs,
|
|
1026
|
+
durationSamples: recent.length,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function fmtAge(ms: number): string {
|
|
1031
|
+
const mins = Math.round(ms / 60000);
|
|
1032
|
+
if (mins < 60) return `${mins}m`;
|
|
1033
|
+
const hours = Math.round(mins / 60);
|
|
1034
|
+
if (hours < 24) return `${hours}h`;
|
|
1035
|
+
return `${Math.floor(hours / 24)}d ${Math.round(hours % 24)}h`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Contract item 7's exact headline format, then detail lines. */
|
|
1039
|
+
export function formatListDepth(stats: ListDepthStats): string {
|
|
1040
|
+
const oldest = stats.oldestAgeMs !== undefined ? fmtAge(stats.oldestAgeMs) : "—";
|
|
1041
|
+
const avg = stats.avgDurationMs !== undefined ? fmtAge(stats.avgDurationMs) : "—";
|
|
1042
|
+
const lines = [`queue depth: ${stats.queueDepth} · oldest: ${oldest} · avg duration: ${avg}`];
|
|
1043
|
+
if (stats.oldestItemId) lines.push(`oldest item: ${fmtAge(stats.oldestAgeMs!)} (id ${stats.oldestItemId})`);
|
|
1044
|
+
if (stats.durationSamples > 0) lines.push(`avg item duration: ${fmtAge(stats.avgDurationMs!)} (from last ${stats.durationSamples} archived)`);
|
|
1045
|
+
return lines.join("\n");
|
|
1046
|
+
}
|
|
@@ -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
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
isFullAuditObjective,
|
|
40
40
|
lastShippedAtMs,
|
|
41
41
|
resolveEffectiveAggressiveSettings,
|
|
42
|
+
computeListDepth,
|
|
43
|
+
ledgerPath,
|
|
44
|
+
crossRecommendMode,
|
|
45
|
+
formatListDepth,
|
|
42
46
|
shouldSuppressHeartbeatForRecentShip,
|
|
43
47
|
mergeSettings,
|
|
44
48
|
parseListImport,
|
|
@@ -90,6 +94,15 @@ import {
|
|
|
90
94
|
settingsProvenance,
|
|
91
95
|
type Settings,
|
|
92
96
|
} from "../goal-settings.js";
|
|
97
|
+
import {
|
|
98
|
+
discoverGllaProjects,
|
|
99
|
+
parseLedgerEntries,
|
|
100
|
+
filterPremature,
|
|
101
|
+
formatRollupJson,
|
|
102
|
+
formatRollupTable,
|
|
103
|
+
rollupProject,
|
|
104
|
+
type ProjectRollup,
|
|
105
|
+
} from "../goal-loop-stats.js";
|
|
93
106
|
import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
|
|
94
107
|
import {
|
|
95
108
|
REPETITION,
|
|
@@ -554,7 +567,15 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
|
|
|
554
567
|
if (target === "list") {
|
|
555
568
|
tmpl = tmpl.replace(
|
|
556
569
|
"[GOAL DRAFTING]",
|
|
557
|
-
"[
|
|
570
|
+
"[LIST DRAFTING — the confirmed item goes into the /list LIST, it does not activate immediately. " +
|
|
571
|
+
"/list items are SHORT tasks, not multi-hour objectives: each item should fit comfortably in a single agent run " +
|
|
572
|
+
"(minutes of work, a single focused change). The list's long-running property is QUEUE DEPTH — hundreds of short " +
|
|
573
|
+
"items activated one at a time over days/weeks — never any single item's scope. " +
|
|
574
|
+
"If the user describes work that would take hours, propose breaking it into multiple /list items, or suggest /goal " +
|
|
575
|
+
"for the big version. When the user has many items to enqueue at once ('queue these 50 audits'), propose them ALL AT " +
|
|
576
|
+
"ONCE with the items[] parameter — one Confirm for the whole batch, never 50 separate proposals. Each items[] entry " +
|
|
577
|
+
"is still a SHORT task — never an aggregate wrapper ('land all N findings' with a '≥N commits' contract is the " +
|
|
578
|
+
"canonical anti-pattern: the auto-committer squashes, the count fails, the auditor disapproves finished work).]",
|
|
558
579
|
);
|
|
559
580
|
}
|
|
560
581
|
} catch {
|
|
@@ -565,6 +586,12 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
|
|
|
565
586
|
// is blocked until the user has replied at least once (see message_start).
|
|
566
587
|
if (seed) {
|
|
567
588
|
tmpl = buildSeedGrillMessage(tmpl, seed, tool);
|
|
589
|
+
// v0.25.3: cross-mode recommendation — catch wrapper-goal seeds and
|
|
590
|
+
// mode mismatches BEFORE the draft crystallizes.
|
|
591
|
+
if (target === "goal" || target === "list") {
|
|
592
|
+
const xr = crossRecommendMode(seed, target);
|
|
593
|
+
if (xr) tmpl += `\n\n${xr}`;
|
|
594
|
+
}
|
|
568
595
|
}
|
|
569
596
|
try {
|
|
570
597
|
extensionApi?.sendUserMessage(tmpl, { deliverAs: ctx.isIdle() ? "followUp" : "steer" });
|
|
@@ -844,6 +871,20 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
844
871
|
const sub = (parts[0] ?? "").toLowerCase();
|
|
845
872
|
const rest = args.trim().slice(sub.length).trim();
|
|
846
873
|
|
|
874
|
+
if (sub === "depth") {
|
|
875
|
+
// v0.25.3: long-running state at a glance — queue depth, oldest item
|
|
876
|
+
// age, average item duration from archived list-policy goals.
|
|
877
|
+
let entries: Array<{ type: string; value?: any }> = [];
|
|
878
|
+
try {
|
|
879
|
+
entries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
|
|
880
|
+
} catch {
|
|
881
|
+
/* no ledger yet */
|
|
882
|
+
}
|
|
883
|
+
const stats = computeListDepth(listQueue(), entries, Date.now());
|
|
884
|
+
ctx.ui.notify(`/list depth: ${formatListDepth(stats)}`, "info");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
847
888
|
if (sub === "resume") {
|
|
848
889
|
// Resume the list's head. The head activates AS the active goal, so this
|
|
849
890
|
// is the same motion as /goal resume — named for the surface the user is
|
|
@@ -2667,6 +2708,42 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2667
2708
|
}
|
|
2668
2709
|
}
|
|
2669
2710
|
|
|
2711
|
+
/**
|
|
2712
|
+
* v0.25.2: /glla stats — one command, every project's rollup. Args:
|
|
2713
|
+
* (none) markdown table, all discovered projects
|
|
2714
|
+
* json machine-readable rollup (same schema as the table)
|
|
2715
|
+
* premature only projects with premature_success > 0, ratio-sorted
|
|
2716
|
+
* project=<path> limit the scan to one project
|
|
2717
|
+
*/
|
|
2718
|
+
function cmdStats(args: string, ctx: ExtensionContext): void {
|
|
2719
|
+
const asJson = /\bjson\b/.test(args);
|
|
2720
|
+
const prematureOnly = /\bpremature\b/.test(args);
|
|
2721
|
+
const projectMatch = args.match(/project=(\S+)/);
|
|
2722
|
+
let rollups: ProjectRollup[] = [];
|
|
2723
|
+
if (projectMatch) {
|
|
2724
|
+
const p = projectMatch[1]!.replace(/^~/, os.homedir());
|
|
2725
|
+
const r = rollupProject(p);
|
|
2726
|
+
if (!r) {
|
|
2727
|
+
ctx.ui.notify(`/glla stats: no .pi-glla/active.jsonl under ${p}`, "warning");
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
rollups = [r];
|
|
2731
|
+
} else {
|
|
2732
|
+
const projects = discoverGllaProjects({ cwd: ctx.cwd });
|
|
2733
|
+
for (const p of projects) {
|
|
2734
|
+
const r = rollupProject(p);
|
|
2735
|
+
if (r) rollups.push(r);
|
|
2736
|
+
}
|
|
2737
|
+
if (rollups.length === 0) {
|
|
2738
|
+
ctx.ui.notify("/glla stats: no projects with .pi-glla/active.jsonl found on this rig.", "info");
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (prematureOnly) rollups = filterPremature(rollups);
|
|
2743
|
+
const out = asJson ? formatRollupJson(rollups) : formatRollupTable(rollups);
|
|
2744
|
+
ctx.ui.notify(`glla stats — ${rollups.length} project(s)${prematureOnly ? " (premature filter)" : ""}\n${out}`, "info");
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2670
2747
|
async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
2671
2748
|
// The plugin's ONE config surface — global by default, rarely opened.
|
|
2672
2749
|
// /glla show effective values + where each comes from
|
|
@@ -2678,7 +2755,13 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2678
2755
|
// /glla auditfeedbackchars=800 cap executor-visible disapproval report (0=full, the default)
|
|
2679
2756
|
// /glla project model=... write to PROJECT override (rare)
|
|
2680
2757
|
// /glla model=unset remove key (from global; project model=unset for project)
|
|
2758
|
+
// /glla stats [json|premature|project=<path>] per-project ledger rollups (v0.25.2)
|
|
2681
2759
|
const trimmed = args.trim();
|
|
2760
|
+
// v0.25.2: /glla stats sub-mode — cross-project telemetry rollups.
|
|
2761
|
+
if (/^stats\b/.test(trimmed)) {
|
|
2762
|
+
cmdStats(trimmed.slice("stats".length).trim(), ctx);
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2682
2765
|
if (!trimmed) {
|
|
2683
2766
|
if (ctx.hasUI) {
|
|
2684
2767
|
await openSettingsUI(ctx);
|
|
@@ -2975,6 +3058,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2975
3058
|
["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
|
|
2976
3059
|
["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
|
|
2977
3060
|
["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
|
|
3061
|
+
["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
|
|
2978
3062
|
["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
|
|
2979
3063
|
["project", "write a project override: /glla project key=value"],
|
|
2980
3064
|
]),
|
|
@@ -3066,6 +3150,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3066
3150
|
loop.iterMetrics = metrics;
|
|
3067
3151
|
}
|
|
3068
3152
|
}
|
|
3153
|
+
// v0.25.2: per-goal tool telemetry (/glla stats premature detection).
|
|
3154
|
+
if (state.goal && state.goal.status === "active") {
|
|
3155
|
+
const toolName = String(event?.toolName ?? "");
|
|
3156
|
+
if (isLoopWriteTool(toolName) || toolName === "bash") {
|
|
3157
|
+
const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
|
|
3158
|
+
if (isLoopWriteTool(toolName)) t.fileWrites++;
|
|
3159
|
+
if (toolName === "bash") t.bashCalls++;
|
|
3160
|
+
state.goal.telemetry = t;
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3069
3163
|
if (draftingTarget === null) return;
|
|
3070
3164
|
if (askUserQuestionAnswered(String(event?.toolName ?? ""), event?.details)) {
|
|
3071
3165
|
draftingUserReplies++;
|
|
@@ -3184,6 +3278,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3184
3278
|
// continuation loop.
|
|
3185
3279
|
if (isForeignCtx(ctx)) return;
|
|
3186
3280
|
noteActivity();
|
|
3281
|
+
// v0.25.2: per-goal turn telemetry (/glla stats).
|
|
3282
|
+
if (state.goal && state.goal.status === "active") {
|
|
3283
|
+
const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
|
|
3284
|
+
t.turns++;
|
|
3285
|
+
state.goal.telemetry = t;
|
|
3286
|
+
}
|
|
3187
3287
|
if (!registeredCtx) {
|
|
3188
3288
|
registerAgentTools(pi, ctx);
|
|
3189
3289
|
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.3",
|
|
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",
|
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Goal drafting — pi-goal-list-loop-audit
|
|
2
2
|
|
|
3
|
+
# Long-running philosophy
|
|
4
|
+
|
|
5
|
+
The three modes are NOT redundant — each has a distinct source of
|
|
6
|
+
long-running-ness:
|
|
7
|
+
|
|
8
|
+
| Mode | Item size | Long-running by | Typical lifetime |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| `/goal` | ONE big multi-hour task | Scope | Hours |
|
|
11
|
+
| `/list` | N items × short (minutes each) | Queue depth | Hours → days → weeks |
|
|
12
|
+
| `/loop` | 1 metric × infinite polish | Bounds | Until plateau/stop/finish |
|
|
13
|
+
|
|
14
|
+
Pick the mode by where the long-running property lives, then draft for
|
|
15
|
+
THAT mode:
|
|
16
|
+
|
|
17
|
+
- **`/goal` is the multi-hour mode.** Its long-running property is SCOPE:
|
|
18
|
+
one big task that spans multiple agent runs, requires deep research, or
|
|
19
|
+
would take hours end-to-end. It ends only when the auditor approves the
|
|
20
|
+
verification contract. If the work is short enough to fit in a single
|
|
21
|
+
agent run (a focused change, a single audit, a small refactor), prefer
|
|
22
|
+
`/list` instead.
|
|
23
|
+
- **`/list` items are short tasks, not multi-hour objectives.** Each item
|
|
24
|
+
should fit comfortably in a single agent run — minutes of work, a
|
|
25
|
+
single focused change. The list's long-running property is QUEUE DEPTH:
|
|
26
|
+
hundreds of short items, activated one at a time, pushed over days or
|
|
27
|
+
weeks. If the user describes work that would take hours, break it up
|
|
28
|
+
into multiple `/list` items — or suggest `/goal` for the big version.
|
|
29
|
+
- **`/loop` is metric-driven infinite polish** — its long-running
|
|
30
|
+
property is open bounds; it ends on plateau, bounds, `/loop stop`, or
|
|
31
|
+
`/loop finish`.
|
|
32
|
+
|
|
33
|
+
## Cross-recommend `/goal` ↔ `/list`
|
|
34
|
+
|
|
35
|
+
While drafting, watch the seed's shape and recommend the right mode:
|
|
36
|
+
|
|
37
|
+
- **Aggregate seeds belong in `/list` as N items, never as ONE wrapper
|
|
38
|
+
goal.** The canonical failure (real incidents, 2026-07-24): "close
|
|
39
|
+
every weak point in X.md (76 items, one commit each)" or "land all 40
|
|
40
|
+
findings as a tasklist" got folded into ONE goal with an aggregate
|
|
41
|
+
contract ("≥ 76 commits") — the auto-committer squashed commits, the
|
|
42
|
+
literal count failed, the auditor correctly disapproved finished work.
|
|
43
|
+
When the seed contains "N items/findings/weak points/screens" + "each"
|
|
44
|
+
+ "one commit", propose N SHORT items via `propose_goal_draft`
|
|
45
|
+
`items[]`, each with its OWN per-item contract ("close IMP-AUD3-68:
|
|
46
|
+
Map.svelte:1528 missing role" — impossible to squash), and let any
|
|
47
|
+
re-audit pass be the FINAL `/goal`, not the first.
|
|
48
|
+
- **Multi-hour seeds in `/list`** ("this will take hours", "deep audit",
|
|
49
|
+
"research all 22 screens"): suggest `/goal` — or break the work into
|
|
50
|
+
≤ 30-minute items.
|
|
51
|
+
- **Five-minute seeds in `/goal`** ("fix typo in X", "bump version"):
|
|
52
|
+
suggest `/list` or the tasklist plugin instead — a full audited goal is
|
|
53
|
+
overkill.
|
|
54
|
+
- The user can always override ("no, as a list item anyway") — comply.
|
|
55
|
+
|
|
3
56
|
`[GOAL DRAFTING]`
|
|
4
57
|
|
|
5
58
|
The user invoked `/goal` with no objective. Your job is to turn their vague
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Loop drafting — pi-goal-list-loop-audit
|
|
2
2
|
|
|
3
|
+
# Long-running philosophy
|
|
4
|
+
|
|
5
|
+
`/loop` is the metric-driven infinite-polish mode. Its long-running
|
|
6
|
+
property is BOUNDS: one metric, polished forever, ending only on plateau,
|
|
7
|
+
bounds, `/loop stop`, or `/loop finish`. The other modes long-run
|
|
8
|
+
differently — `/goal` by scope (one big multi-hour task), `/list` by
|
|
9
|
+
queue depth (hundreds of short items, minutes each). If the user's work
|
|
10
|
+
is a single big task with a done state, that's `/goal`; if it's many
|
|
11
|
+
small tasks, that's `/list`; only true open-ended metric improvement
|
|
12
|
+
belongs here.
|
|
13
|
+
|
|
3
14
|
`[LOOP DRAFTING]`
|
|
4
15
|
|
|
5
16
|
The user invoked `/loop` with no arguments. Your job is to turn their rough
|