pi-goal-list-loop-audit 0.26.0 → 0.26.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.
- package/extensions/goal-loop-core.ts +11 -0
- package/extensions/goal-loop-display.ts +12 -7
- package/extensions/goal-settings.ts +4 -0
- package/extensions/loops/goal.ts +105 -15
- package/extensions/reviewer.ts +49 -19
- package/package.json +1 -1
|
@@ -11,6 +11,17 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
13
|
|
|
14
|
+
/** v0.26.1: consecutive heartbeat refires without a real agent turn
|
|
15
|
+
* before the supervisor gives up (pauses the goal / stops the loop).
|
|
16
|
+
* 0 = never escalate (legacy silent-spin behavior). */
|
|
17
|
+
export const DEFAULT_STALL_ESCALATION_REFIRES = 5;
|
|
18
|
+
|
|
19
|
+
/** v0.26.1: pure gate — has the refire streak hit the escalation
|
|
20
|
+
* threshold? threshold 0 disables escalation entirely. */
|
|
21
|
+
export function shouldEscalateStall(consecutiveStalls: number, threshold: number): boolean {
|
|
22
|
+
return threshold > 0 && consecutiveStalls >= threshold;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
// =================================================================
|
|
15
26
|
// Types
|
|
16
27
|
// =================================================================
|
|
@@ -81,17 +81,20 @@ export interface AuditDisplayProgress {
|
|
|
81
81
|
* One-line status for ctx.ui.setStatus("pi-glla", …).
|
|
82
82
|
* Returns undefined when nothing is being supervised (clears the segment).
|
|
83
83
|
*/
|
|
84
|
-
export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme): string | undefined {
|
|
84
|
+
export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, extras?: { stalls?: number }): string | undefined {
|
|
85
85
|
if (state.loop?.active) {
|
|
86
86
|
const l = state.loop;
|
|
87
|
+
// v0.26.1: surface the refire streak — a spinning supervisor is the
|
|
88
|
+
// zombie signature (hegemon incident: 619 refires, 0 turns).
|
|
89
|
+
const stallSuffix = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
|
|
87
90
|
// v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
|
|
88
91
|
if (!l.measureCmd) {
|
|
89
|
-
return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless`;
|
|
92
|
+
return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless${stallSuffix}`;
|
|
90
93
|
}
|
|
91
94
|
const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
|
|
92
95
|
const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
|
|
93
96
|
const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
|
|
94
|
-
return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}`;
|
|
97
|
+
return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}${stallSuffix}`;
|
|
95
98
|
}
|
|
96
99
|
const g = state.goal;
|
|
97
100
|
if (!g) return undefined;
|
|
@@ -146,8 +149,8 @@ function countTotal(g: Goal): number {
|
|
|
146
149
|
* Widget lines for ctx.ui.setWidget("pi-glla", lines).
|
|
147
150
|
* Returns undefined when nothing is worth showing.
|
|
148
151
|
*/
|
|
149
|
-
export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number): string[] | undefined {
|
|
150
|
-
if (state.loop?.active) return loopLines(state.loop, now, theme, width);
|
|
152
|
+
export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
|
|
153
|
+
if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
|
|
151
154
|
const g = state.goal;
|
|
152
155
|
if (!g) return undefined;
|
|
153
156
|
if (g.status === "complete" || g.status === "aborted") return undefined;
|
|
@@ -202,12 +205,14 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
|
|
|
202
205
|
return lines;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number): string[] {
|
|
208
|
+
function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] {
|
|
209
|
+
// v0.26.1: the refire streak, shown only while nonzero.
|
|
210
|
+
const stallNote = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
|
|
206
211
|
// v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
|
|
207
212
|
if (!l.measureCmd) {
|
|
208
213
|
const lines = [
|
|
209
214
|
`${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
|
|
210
|
-
`├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
|
|
215
|
+
`├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}${stallNote}`,
|
|
211
216
|
`└─ ${paint(theme, "dim", "metricless — work the spec (no plateau)")}`,
|
|
212
217
|
];
|
|
213
218
|
if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
|
|
@@ -49,6 +49,9 @@ export interface Settings {
|
|
|
49
49
|
/** Consecutive stuck interventions before a loop stops (default 5,
|
|
50
50
|
* 10 under aggressiveMode). */
|
|
51
51
|
stuckMaxInterventions?: number;
|
|
52
|
+
/** v0.26.1: consecutive heartbeat refires without a real turn before
|
|
53
|
+
* the goal pauses / loop stops (default 5; 0 = never escalate). */
|
|
54
|
+
stallEscalationRefires?: number;
|
|
52
55
|
/** on → propose_* drafts activate WITHOUT the Confirm dialog and the
|
|
53
56
|
* interview floor is skipped — the seed carries the intent (unattended
|
|
54
57
|
* rigs). Default off: nothing activates before the user confirms. */
|
|
@@ -127,6 +130,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
|
|
|
127
130
|
"aggressiveMode",
|
|
128
131
|
"quotaRetryMinutes",
|
|
129
132
|
"stuckMaxInterventions",
|
|
133
|
+
"stallEscalationRefires",
|
|
130
134
|
];
|
|
131
135
|
|
|
132
136
|
/** Where each effective setting comes from (for the /glla display). */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
auditFeedbackExcerpt,
|
|
34
34
|
DEFAULT_AUDIT_FEEDBACK_CHARS,
|
|
35
35
|
DEFAULT_QUOTA_RETRY_MINUTES,
|
|
36
|
+
DEFAULT_STALL_ESCALATION_REFIRES,
|
|
36
37
|
DEFAULT_TOKEN_LIMIT,
|
|
37
38
|
classifyImpossibleReason,
|
|
38
39
|
extractPendingTasks,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
ledgerPath,
|
|
51
52
|
crossRecommendMode,
|
|
52
53
|
formatListDepth,
|
|
54
|
+
shouldEscalateStall,
|
|
53
55
|
shouldSuppressHeartbeatForRecentShip,
|
|
54
56
|
mergeSettings,
|
|
55
57
|
parseListImport,
|
|
@@ -231,10 +233,16 @@ const countedLoopTokenMessages = new Set<string>();
|
|
|
231
233
|
let lastActivityAt = Date.now();
|
|
232
234
|
let lastWedgeAlertAt = 0;
|
|
233
235
|
let heartbeatNudges = 0;
|
|
236
|
+
// v0.26.1: consecutive heartbeat refires that produced NO real agent turn.
|
|
237
|
+
// Resets only on real activity (agent_end / tool_call) — never on the
|
|
238
|
+
// refire's own noteActivity, which is what made the hegemon zombie spin
|
|
239
|
+
// self-sustaining (619 refires / 23.5h / zero turns).
|
|
240
|
+
let consecutiveStalls = 0;
|
|
234
241
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
235
242
|
|
|
236
|
-
function noteActivity(): void {
|
|
243
|
+
function noteActivity(real = false): void {
|
|
237
244
|
lastActivityAt = Date.now();
|
|
245
|
+
if (real) consecutiveStalls = 0;
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
function isSupervising(): boolean {
|
|
@@ -256,8 +264,8 @@ function refreshUI(ctx: ExtensionContext): void {
|
|
|
256
264
|
// Terminal width for truncation budgets: on wide terminals the widget
|
|
257
265
|
// uses the room instead of cutting at fixed ~60-char floors.
|
|
258
266
|
const width = process.stdout.columns || 80;
|
|
259
|
-
ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme));
|
|
260
|
-
ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width));
|
|
267
|
+
ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme, { stalls: consecutiveStalls }));
|
|
268
|
+
ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width, { stalls: consecutiveStalls }));
|
|
261
269
|
} catch {
|
|
262
270
|
// stale ctx — next event refreshes
|
|
263
271
|
}
|
|
@@ -323,8 +331,36 @@ function heartbeatTick(): void {
|
|
|
323
331
|
return;
|
|
324
332
|
}
|
|
325
333
|
noteActivity();
|
|
326
|
-
|
|
327
|
-
ctx.
|
|
334
|
+
consecutiveStalls++;
|
|
335
|
+
appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges, consecutiveStalls });
|
|
336
|
+
// v0.26.1: a refire streak means the continuation is NOT landing (wedged
|
|
337
|
+
// message queue, stale API handle, dead turn trigger). Nudges can't catch
|
|
338
|
+
// this — they count turns, and a zombie runs none. Escalate to a loud,
|
|
339
|
+
// actionable stop instead of spinning silently forever.
|
|
340
|
+
const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
|
|
341
|
+
if (shouldEscalateStall(consecutiveStalls, stallEscalation)) {
|
|
342
|
+
consecutiveStalls = 0;
|
|
343
|
+
appendLedger(ctx.cwd, "stall_escalated", { threshold: stallEscalation, kind: isLoopActive() ? "loop" : "goal" });
|
|
344
|
+
if (isLoopActive()) {
|
|
345
|
+
clearLoopTimer();
|
|
346
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${stallEscalation} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
|
|
347
|
+
persistState(ctx);
|
|
348
|
+
ctx.ui.notify(`Loop stopped: ${stallEscalation} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
|
|
349
|
+
notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (state.goal && state.goal.status === "active") {
|
|
353
|
+
updateGoal({
|
|
354
|
+
status: "paused",
|
|
355
|
+
pauseReason: `stalled: ${stallEscalation} continuation refires landed no turn`,
|
|
356
|
+
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
|
|
357
|
+
}, ctx);
|
|
358
|
+
ctx.ui.notify(`Goal paused: ${stallEscalation} refires produced no turn. Restart pi, then /goal resume.`, "warning");
|
|
359
|
+
notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
ctx.ui.notify(`Heartbeat: supervisor active but session stalled — re-firing continuation (stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}).`, "info");
|
|
328
364
|
if (isLoopActive()) {
|
|
329
365
|
scheduleLoopTick(ctx);
|
|
330
366
|
} else {
|
|
@@ -415,7 +451,9 @@ function sendContinuation(goalId: string): void {
|
|
|
415
451
|
content: continuationPrompt(state.goal!),
|
|
416
452
|
display: false,
|
|
417
453
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
418
|
-
|
|
454
|
+
appendLedger(ctx.cwd, "goal_continuation_sent", { goalId });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
appendLedger(ctx.cwd, "goal_continuation_send_failed", { goalId, error: err instanceof Error ? err.message : String(err) });
|
|
419
457
|
// API went stale mid-flight; next agent_end/session_start will reschedule.
|
|
420
458
|
}
|
|
421
459
|
}
|
|
@@ -545,11 +583,12 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
|
|
|
545
583
|
function fireReviewer(
|
|
546
584
|
ctx: ExtensionContext,
|
|
547
585
|
source: { kind: "goal" | "list"; goalId: string; objective: string; terminal: string },
|
|
548
|
-
opts: { manual?: boolean } = {},
|
|
586
|
+
opts: { manual?: boolean; mode?: "default" | "auto" | "report" } = {},
|
|
549
587
|
): void {
|
|
550
588
|
try {
|
|
551
589
|
const settings = loadSettings(ctx.cwd);
|
|
552
590
|
const config = resolveReviewerConfig(settings.reviewer as Partial<ReviewerConfig> | undefined);
|
|
591
|
+
if (opts.mode) config.mode = opts.mode;
|
|
553
592
|
const sources: Array<{ name: string; text: string }> = [];
|
|
554
593
|
try {
|
|
555
594
|
sources.push({ name: "archive", text: fs.readFileSync(archivedGoalPath(ctx.cwd, source.goalId), "utf-8") });
|
|
@@ -1300,8 +1339,13 @@ function sendLoopTurn(): void {
|
|
|
1300
1339
|
content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
|
|
1301
1340
|
display: false,
|
|
1302
1341
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1303
|
-
|
|
1304
|
-
//
|
|
1342
|
+
// v0.26.1: the send path is ledgered — the hegemon zombie spun 619
|
|
1343
|
+
// refires with zero visibility into whether sends were landing.
|
|
1344
|
+
appendLedger(ctx.cwd, "loop_turn_sent", { iteration: loop.iteration });
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
// stale API — next agent_end reschedules (but if none comes, the
|
|
1347
|
+
// heartbeat's stall escalation stops the spin — v0.26.1).
|
|
1348
|
+
appendLedger(ctx.cwd, "loop_turn_send_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1305
1349
|
}
|
|
1306
1350
|
}
|
|
1307
1351
|
|
|
@@ -2841,9 +2885,16 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2841
2885
|
|
|
2842
2886
|
/** v0.26.0: /review <archived-goal-id> — manual reviewer invocation. */
|
|
2843
2887
|
async function cmdReview(args: string, ctx: ExtensionContext): Promise<void> {
|
|
2844
|
-
const
|
|
2888
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
2889
|
+
const id = parts[0] ?? "";
|
|
2890
|
+
const modeArg = parts[1];
|
|
2891
|
+
const mode = modeArg === "auto" || modeArg === "report" || modeArg === "default" ? modeArg : undefined;
|
|
2892
|
+
if (modeArg && !mode) {
|
|
2893
|
+
ctx.ui.notify(`Unknown mode "${modeArg}" — use auto | report | default.`, "warning");
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2845
2896
|
if (!id) {
|
|
2846
|
-
ctx.ui.notify("Usage: /review <goal-id> — see /goal archive for ids.", "info");
|
|
2897
|
+
ctx.ui.notify("Usage: /review <goal-id> [auto|report|default] — see /goal archive for ids.", "info");
|
|
2847
2898
|
return;
|
|
2848
2899
|
}
|
|
2849
2900
|
// Resolve the id against the archive (suffix match allowed).
|
|
@@ -2864,7 +2915,7 @@ async function cmdReview(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2864
2915
|
ctx.ui.notify(`No archive found for ${id}.`, "warning");
|
|
2865
2916
|
return;
|
|
2866
2917
|
}
|
|
2867
|
-
fireReviewer(ctx, { kind: "goal", goalId, objective, terminal: "goal-complete" }, { manual: true });
|
|
2918
|
+
fireReviewer(ctx, { kind: "goal", goalId, objective, terminal: "goal-complete" }, { manual: true, mode });
|
|
2868
2919
|
}
|
|
2869
2920
|
|
|
2870
2921
|
/** v0.26.0: /glla reviewer — the reviewer config menu (project-scoped). */
|
|
@@ -2887,6 +2938,7 @@ async function cmdReviewerSettings(ctx: ExtensionContext): Promise<void> {
|
|
|
2887
2938
|
if (!choice || choice === "Done") return;
|
|
2888
2939
|
try {
|
|
2889
2940
|
if (choice.startsWith("Enabled")) save({ enabled: !cfg.enabled });
|
|
2941
|
+
else if (choice.startsWith("Mode")) save({ mode: cfg.mode === "default" ? "auto" : cfg.mode === "auto" ? "report" : "default" });
|
|
2890
2942
|
else if (choice.startsWith("Leverage mode")) save({ leverageMode: cfg.leverageMode === "fix-without-confirm" ? "confirm-all" : "fix-without-confirm" });
|
|
2891
2943
|
else if (choice.startsWith("Fire on goal-complete")) save({ fireOn: cfg.fireOn.includes("goal-complete") ? cfg.fireOn.filter((e) => e !== "goal-complete") : [...cfg.fireOn, "goal-complete"] });
|
|
2892
2944
|
else if (choice.startsWith("Fire on list-complete")) save({ fireOn: cfg.fireOn.includes("list-complete") ? cfg.fireOn.filter((e) => e !== "list-complete") : [...cfg.fireOn, "list-complete"] });
|
|
@@ -3153,6 +3205,19 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
3153
3205
|
ctx.ui.notify(`quotaretryminutes must be a positive integer, got: ${value}`, "warning");
|
|
3154
3206
|
}
|
|
3155
3207
|
}
|
|
3208
|
+
} else if (key === "stallescalation" || key === "stallescalationrefires") {
|
|
3209
|
+
if (["unset", "default"].includes(value)) {
|
|
3210
|
+
patch.stallEscalationRefires = undefined;
|
|
3211
|
+
changed = true;
|
|
3212
|
+
} else {
|
|
3213
|
+
const n = Number.parseInt(value, 10);
|
|
3214
|
+
if (Number.isInteger(n) && n >= 0) {
|
|
3215
|
+
patch.stallEscalationRefires = n;
|
|
3216
|
+
changed = true;
|
|
3217
|
+
} else {
|
|
3218
|
+
ctx.ui.notify(`stallescalation must be a non-negative integer (0 = never escalate), got: ${value}`, "warning");
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3156
3221
|
} else if (key === "stuckmax" || key === "stuckmaxinterventions") {
|
|
3157
3222
|
if (["unset", "default"].includes(value)) {
|
|
3158
3223
|
patch.stuckMaxInterventions = undefined;
|
|
@@ -3302,6 +3367,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3302
3367
|
["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
|
|
3303
3368
|
["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
|
|
3304
3369
|
["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
|
|
3370
|
+
["stallescalation=", "N: heartbeat refires without a turn before goal pauses / loop stops (default 5, 0 = never)"],
|
|
3305
3371
|
["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
|
|
3306
3372
|
["audits", "audit-log browser: /glla audits [N|full] — recent verdicts from .pi-glla/audits.jsonl"],
|
|
3307
3373
|
["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
|
|
@@ -3311,7 +3377,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3311
3377
|
handler: settingsHandler,
|
|
3312
3378
|
});
|
|
3313
3379
|
pi.registerCommand("review", {
|
|
3314
|
-
description: "Manually run the reviewer on an archived goal: /review <goal-id> — extracts findings, writes a report to .pi-glla/reviews/,
|
|
3380
|
+
description: "Manually run the reviewer on an archived goal: /review <goal-id> [auto|report|default] — extracts findings, writes a report to .pi-glla/reviews/, cascades per the mode (auto = auto-loop, no Confirms). Bypasses the trigger gates (explicit user request).",
|
|
3315
3381
|
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdReview(args, ctx); },
|
|
3316
3382
|
});
|
|
3317
3383
|
pi.registerCommand("list", {
|
|
@@ -3367,6 +3433,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3367
3433
|
}
|
|
3368
3434
|
}
|
|
3369
3435
|
|
|
3436
|
+
// v0.26.1: compaction ends WITHOUT an agent_end (the compaction turn is
|
|
3437
|
+
// not an agent turn), so the continuation chain can dangle until the
|
|
3438
|
+
// 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
|
|
3439
|
+
pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
|
|
3440
|
+
if (isForeignCtx(ctx)) return;
|
|
3441
|
+
rememberCtx(ctx);
|
|
3442
|
+
if (!isSupervising()) return;
|
|
3443
|
+
appendLedger(ctx.cwd, "session_compact", {});
|
|
3444
|
+
const settle = setTimeout(() => {
|
|
3445
|
+
const c = freshCtx();
|
|
3446
|
+
if (!c) return;
|
|
3447
|
+
try {
|
|
3448
|
+
if (c.isIdle() && !c.hasPendingMessages() && continuationTimer === null && loopTimer === null && isSupervising()) {
|
|
3449
|
+
appendLedger(c.cwd, "compaction_refire", {});
|
|
3450
|
+
if (isLoopActive()) scheduleLoopTick(c);
|
|
3451
|
+
else scheduleContinuation(c, true);
|
|
3452
|
+
}
|
|
3453
|
+
} catch {
|
|
3454
|
+
/* settle race — the 60s heartbeat covers it */
|
|
3455
|
+
}
|
|
3456
|
+
}, 2000);
|
|
3457
|
+
settle.unref?.();
|
|
3458
|
+
});
|
|
3459
|
+
|
|
3370
3460
|
pi.on("message_start", async (event: any, _ctx: ExtensionContext) => {
|
|
3371
3461
|
// v0.14.0 drafting floor: count real user replies while drafting. Our
|
|
3372
3462
|
// own injected draft prompt arrives as a user message — skip that one.
|
|
@@ -3549,7 +3639,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3549
3639
|
// v0.23.8: a subagent finishing must not drive the main session's
|
|
3550
3640
|
// continuation loop.
|
|
3551
3641
|
if (isForeignCtx(ctx)) return;
|
|
3552
|
-
noteActivity();
|
|
3642
|
+
noteActivity(true);
|
|
3553
3643
|
// v0.25.2: per-goal turn telemetry (/glla stats).
|
|
3554
3644
|
if (state.goal && state.goal.status === "active") {
|
|
3555
3645
|
const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
|
|
@@ -3650,7 +3740,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3650
3740
|
|
|
3651
3741
|
pi.on("tool_call", () => {
|
|
3652
3742
|
toolCallsThisTurn++;
|
|
3653
|
-
noteActivity();
|
|
3743
|
+
noteActivity(true);
|
|
3654
3744
|
// v0.24.0: count loop-iteration tool calls (narration-only detection).
|
|
3655
3745
|
if (isLoopActive()) {
|
|
3656
3746
|
state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
|
package/extensions/reviewer.ts
CHANGED
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
|
|
18
|
+
export type ReviewerMode = "default" | "auto" | "report";
|
|
19
|
+
|
|
18
20
|
export interface ReviewerConfig {
|
|
19
21
|
enabled: boolean;
|
|
22
|
+
/** v0.26.2: default = Confirm-gated cascade; auto = auto-loop — every
|
|
23
|
+
* finding class (incl. architectural) and the clean-completion audit
|
|
24
|
+
* become /list items with zero Confirms (strategic stays notify-only —
|
|
25
|
+
* decisions never auto-fire); report = write the report + notify only. */
|
|
26
|
+
mode: ReviewerMode;
|
|
20
27
|
fireOn: Array<"goal-complete" | "list-complete">;
|
|
21
28
|
doNotFireOn: string[];
|
|
22
29
|
cascade: Array<"convert-findings-to-list" | "queue-leftovers" | "fire-audit-on-clean" | "notify-and-idle">;
|
|
@@ -30,6 +37,7 @@ export interface ReviewerConfig {
|
|
|
30
37
|
|
|
31
38
|
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
|
|
32
39
|
enabled: true,
|
|
40
|
+
mode: "default",
|
|
33
41
|
fireOn: ["goal-complete", "list-complete"],
|
|
34
42
|
doNotFireOn: ["goal-aborted", "goal-paused"],
|
|
35
43
|
cascade: ["convert-findings-to-list", "queue-leftovers", "fire-audit-on-clean", "notify-and-idle"],
|
|
@@ -61,7 +69,7 @@ const CLASS_PATTERNS: Array<{ class: FindingClass; re: RegExp }> = [
|
|
|
61
69
|
{ class: "strategic", re: /\bshould we\b|\bdeprecat|ship this\??|strategic/i },
|
|
62
70
|
{ class: "architectural", re: /\brewrite\b|new dependency|schema change|architectural|redesign/i },
|
|
63
71
|
{ class: "bug", re: /\bTODO\b|\bFIXME\b|\bbug\b|\bissue\b|regression|broken|\bfixme\b/i },
|
|
64
|
-
{ class: "refactor", re: /could be cleaner|consider refactoring|duplicat|refactor|left ?out|follow[\s-]?up|deferred/i },
|
|
72
|
+
{ class: "refactor", re: /could be cleaner|consider refactoring|duplicat|refactor|left ?out|follow[\s-]?up|deferred|could be improved|improvement|enhancement|consider adding|would be nice|nice to have/i },
|
|
65
73
|
];
|
|
66
74
|
|
|
67
75
|
export function classifyFindingText(line: string): FindingClass | undefined {
|
|
@@ -110,6 +118,7 @@ export interface ReviewReport {
|
|
|
110
118
|
objective: string;
|
|
111
119
|
findings: Finding[];
|
|
112
120
|
cascadeStep: string;
|
|
121
|
+
mode: ReviewerMode;
|
|
113
122
|
at: string;
|
|
114
123
|
}
|
|
115
124
|
|
|
@@ -120,7 +129,7 @@ export function formatReviewReport(r: ReviewReport): string {
|
|
|
120
129
|
return [
|
|
121
130
|
`# Review — ${r.goalId}`,
|
|
122
131
|
"",
|
|
123
|
-
`**Kind**: ${r.kind} · **At**: ${r.at}`,
|
|
132
|
+
`**Kind**: ${r.kind} · **At**: ${r.at} · **Mode**: ${r.mode}`,
|
|
124
133
|
"",
|
|
125
134
|
"## Summary",
|
|
126
135
|
"",
|
|
@@ -186,7 +195,11 @@ export function runReviewer(
|
|
|
186
195
|
if (config.doNotFireOn.includes(event)) return none(`doNotFireOn: ${event}`);
|
|
187
196
|
if (source.kind === "goal" && source.terminal !== "goal-complete") return none(`not a completion: ${source.terminal}`);
|
|
188
197
|
if (!config.fireOn.includes(source.kind === "goal" ? "goal-complete" : "list-complete")) return none("fireOn excludes this event");
|
|
189
|
-
|
|
198
|
+
// v0.26.2: in auto mode the queue emptying is the cascade's natural
|
|
199
|
+
// rhythm, not a runaway — the refire window must not strangle it.
|
|
200
|
+
// (The per-day cap below still bounds everything.)
|
|
201
|
+
const refireWindowApplies = !(config.mode === "auto" && source.kind === "list");
|
|
202
|
+
if (refireWindowApplies && reviewerFiredRecently(deps.ledgerEntries, REVIEWER_REFIRE_WINDOW_MS, deps.nowMs)) {
|
|
190
203
|
deps.ledger("reviewer_suppressed", { reason: "refire-window", goalId: source.goalId });
|
|
191
204
|
return none("reviewer fired within the last 5 minutes (runaway prevention)");
|
|
192
205
|
}
|
|
@@ -205,32 +218,47 @@ export function runReviewer(
|
|
|
205
218
|
let enqueued = 0;
|
|
206
219
|
let proposed = 0;
|
|
207
220
|
let cascadeStep = "notify-and-idle";
|
|
221
|
+
const auto = config.mode === "auto";
|
|
222
|
+
const reportOnly = config.mode === "report";
|
|
208
223
|
|
|
209
224
|
// Cascade: findings → list items (leverage: fix-without-confirm).
|
|
210
225
|
const convertStep = source.kind === "goal" ? "convert-findings-to-list" : "queue-leftovers";
|
|
211
|
-
if (bugs.length > 0 && config.cascade.includes(convertStep)) {
|
|
226
|
+
if (bugs.length > 0 && config.cascade.includes(convertStep) && !reportOnly) {
|
|
212
227
|
deps.enqueueListItems(bugs.map((f) => f.text));
|
|
213
228
|
enqueued = bugs.length;
|
|
214
229
|
cascadeStep = convertStep;
|
|
215
230
|
}
|
|
216
|
-
// Architectural findings → /goal proposal WITH Confirm
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
231
|
+
// Architectural findings: default mode → /goal proposal WITH Confirm;
|
|
232
|
+
// auto mode → /list items (the auto-loop rolls straight into them).
|
|
233
|
+
if (architectural.length > 0 && !reportOnly) {
|
|
234
|
+
if (auto) {
|
|
235
|
+
deps.enqueueListItems(architectural.map((f) => f.text));
|
|
236
|
+
enqueued += architectural.length;
|
|
237
|
+
cascadeStep = convertStep;
|
|
238
|
+
} else {
|
|
239
|
+
deps.proposeGoal(
|
|
240
|
+
architectural.map((f) => f.text).join("; "),
|
|
241
|
+
`reviewer found ${architectural.length} architectural-class finding(s) — needs your Confirm`,
|
|
242
|
+
);
|
|
243
|
+
proposed += architectural.length;
|
|
244
|
+
cascadeStep = "propose-goal";
|
|
245
|
+
}
|
|
224
246
|
}
|
|
225
|
-
// Clean completion → audit /goal (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
247
|
+
// Clean completion → audit: default mode proposes a /goal (Confirm);
|
|
248
|
+
// auto mode enqueues the audit as a /list item (no Confirm — the
|
|
249
|
+
// cascade keeps rolling until the findings run dry).
|
|
250
|
+
if (findings.length === 0 && config.cascade.includes("fire-audit-on-clean") && !reportOnly) {
|
|
251
|
+
const auditObjective = `Post-completion regression scan after ${source.goalId} (${config.auditScope})`;
|
|
252
|
+
if (auto) {
|
|
253
|
+
deps.enqueueListItems([auditObjective]);
|
|
254
|
+
enqueued++;
|
|
255
|
+
} else {
|
|
256
|
+
deps.proposeGoal(auditObjective, "reviewer: completion looks clean — firing the audit step");
|
|
257
|
+
proposed++;
|
|
258
|
+
}
|
|
232
259
|
cascadeStep = "fire-audit-on-clean";
|
|
233
260
|
}
|
|
261
|
+
if (reportOnly) cascadeStep = "report-only";
|
|
234
262
|
|
|
235
263
|
const report: ReviewReport = {
|
|
236
264
|
goalId: source.goalId,
|
|
@@ -238,6 +266,7 @@ export function runReviewer(
|
|
|
238
266
|
objective: source.objective,
|
|
239
267
|
findings,
|
|
240
268
|
cascadeStep,
|
|
269
|
+
mode: config.mode,
|
|
241
270
|
at: new Date(deps.nowMs).toISOString(),
|
|
242
271
|
};
|
|
243
272
|
const reportPath = writeReviewReport(deps.cwd, report);
|
|
@@ -265,6 +294,7 @@ export function runReviewer(
|
|
|
265
294
|
export function reviewerMenuOptions(cfg: ReviewerConfig): string[] {
|
|
266
295
|
return [
|
|
267
296
|
`Enabled — ${cfg.enabled ? "ON" : "OFF"}`,
|
|
297
|
+
`Mode — ${cfg.mode} (default = Confirm-gated · auto = auto-loop, no Confirms · report = report only)`,
|
|
268
298
|
`Leverage mode — ${cfg.leverageMode} (bug/refactor findings)`,
|
|
269
299
|
`Fire on goal-complete — ${cfg.fireOn.includes("goal-complete") ? "ON" : "OFF"}`,
|
|
270
300
|
`Fire on list-complete — ${cfg.fireOn.includes("list-complete") ? "ON" : "OFF"}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.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",
|