pi-goal-list-loop-audit 0.25.6 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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. */
@@ -60,6 +63,9 @@ export interface Settings {
60
63
  * override without the model pin so subagents share the session model and
61
64
  * its quota; "agent-default" restores upstream behavior. Applies to NEW
62
65
  * sessions (pi-subagents registers agents at session start). */
66
+ /** v0.26.0: reviewer (post-completion follow-up enqueuer) config —
67
+ * project-scoped; see extensions/reviewer.ts DEFAULT_REVIEWER_CONFIG. */
68
+ reviewer?: Record<string, unknown>;
63
69
  subagentModelStrategy?: SubagentModelStrategy;
64
70
  /** v0.24.6: per-agent-type model pin, e.g. { "Explore": "minimax/MiniMax-M3" }.
65
71
  * Always wins over subagentModelStrategy — the managed override is written
@@ -124,6 +130,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
124
130
  "aggressiveMode",
125
131
  "quotaRetryMinutes",
126
132
  "stuckMaxInterventions",
133
+ "stallEscalationRefires",
127
134
  ];
128
135
 
129
136
  /** Where each effective setting comes from (for the /glla display). */
@@ -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,
@@ -102,6 +104,13 @@ import {
102
104
  settingsProvenance,
103
105
  type Settings,
104
106
  } from "../goal-settings.js";
107
+ import {
108
+ DEFAULT_REVIEWER_CONFIG,
109
+ resolveReviewerConfig,
110
+ reviewerMenuOptions,
111
+ runReviewer,
112
+ type ReviewerConfig,
113
+ } from "../reviewer.js";
105
114
  import {
106
115
  discoverGllaProjects,
107
116
  parseLedgerEntries,
@@ -224,10 +233,16 @@ const countedLoopTokenMessages = new Set<string>();
224
233
  let lastActivityAt = Date.now();
225
234
  let lastWedgeAlertAt = 0;
226
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;
227
241
  let heartbeatTimer: NodeJS.Timeout | null = null;
228
242
 
229
- function noteActivity(): void {
243
+ function noteActivity(real = false): void {
230
244
  lastActivityAt = Date.now();
245
+ if (real) consecutiveStalls = 0;
231
246
  }
232
247
 
233
248
  function isSupervising(): boolean {
@@ -249,8 +264,8 @@ function refreshUI(ctx: ExtensionContext): void {
249
264
  // Terminal width for truncation budgets: on wide terminals the widget
250
265
  // uses the room instead of cutting at fixed ~60-char floors.
251
266
  const width = process.stdout.columns || 80;
252
- ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme));
253
- 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 }));
254
269
  } catch {
255
270
  // stale ctx — next event refreshes
256
271
  }
@@ -316,8 +331,36 @@ function heartbeatTick(): void {
316
331
  return;
317
332
  }
318
333
  noteActivity();
319
- appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges });
320
- ctx.ui.notify("Heartbeat: supervisor active but session stalled — re-firing continuation.", "info");
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");
321
364
  if (isLoopActive()) {
322
365
  scheduleLoopTick(ctx);
323
366
  } else {
@@ -408,7 +451,9 @@ function sendContinuation(goalId: string): void {
408
451
  content: continuationPrompt(state.goal!),
409
452
  display: false,
410
453
  }, { triggerTurn: true, deliverAs: "followUp" });
411
- } catch {
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) });
412
457
  // API went stale mid-flight; next agent_end/session_start will reschedule.
413
458
  }
414
459
  }
@@ -517,7 +562,73 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
517
562
  // (v0.2.0 bug: bare /list next silently consumed TWO items, found by the
518
563
  // pick-any-item verification in v0.10.0).
519
564
  if (goal.policy === "list" && status === "complete") {
520
- activateNextListItem(ctx);
565
+ const advanced = activateNextListItem(ctx);
566
+ // v0.26.0: the queue just EMPTIED on a completion → list-complete.
567
+ if (!advanced) fireReviewer(ctx, { kind: "list", goalId: goal.id, objective: goal.objective, terminal: "goal-complete" });
568
+ return;
569
+ }
570
+ // v0.26.0: a /goal (non-list) reached a terminal state → maybe fire.
571
+ if (goal.policy !== "list") {
572
+ fireReviewer(ctx, { kind: "goal", goalId: goal.id, objective: goal.objective, terminal: status === "complete" ? "goal-complete" : status === "aborted" ? "goal-aborted" : "goal-paused" });
573
+ }
574
+ }
575
+
576
+ /**
577
+ * v0.26.0: bind the reviewer to the live session. Sources for finding
578
+ * extraction: the archived goal markdown + its audit reports + the
579
+ * durable audit log entries for this goal. List items are enqueued via
580
+ * the ONE enqueue path; /goal proposals go through the agent (which
581
+ * calls propose_goal_draft → the user's Confirm dialog).
582
+ */
583
+ function fireReviewer(
584
+ ctx: ExtensionContext,
585
+ source: { kind: "goal" | "list"; goalId: string; objective: string; terminal: string },
586
+ opts: { manual?: boolean } = {},
587
+ ): void {
588
+ try {
589
+ const settings = loadSettings(ctx.cwd);
590
+ const config = resolveReviewerConfig(settings.reviewer as Partial<ReviewerConfig> | undefined);
591
+ const sources: Array<{ name: string; text: string }> = [];
592
+ try {
593
+ sources.push({ name: "archive", text: fs.readFileSync(archivedGoalPath(ctx.cwd, source.goalId), "utf-8") });
594
+ } catch {
595
+ /* archive md may not exist for manual review of a live goal */
596
+ }
597
+ const auditTexts = readAuditLog(ctx.cwd)
598
+ .filter((e) => e.goalId === source.goalId)
599
+ .map((e) => e.report);
600
+ for (const t of auditTexts) sources.push({ name: "audit", text: t });
601
+ let ledgerEntries: Array<{ type: string; at?: string; value?: any }> = [];
602
+ try {
603
+ ledgerEntries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
604
+ } catch {
605
+ /* no ledger yet */
606
+ }
607
+ const outcome = runReviewer(config, source, {
608
+ cwd: ctx.cwd,
609
+ nowMs: Date.now(),
610
+ manual: opts.manual,
611
+ ledgerEntries,
612
+ sources,
613
+ enqueueListItems: (objectives) => enqueueItems(ctx, objectives, "reviewer"),
614
+ proposeGoal: (objective, reason) => {
615
+ try {
616
+ extensionApi?.sendUserMessage(
617
+ `[REVIEWER FOLLOW-UP — ${reason}. Propose this as a /goal via propose_goal_draft (the user Confirms or rejects): ${objective}]`,
618
+ { deliverAs: ctx.isIdle() ? "followUp" : "steer" },
619
+ );
620
+ } catch {
621
+ /* proposal best-effort */
622
+ }
623
+ },
624
+ notify: (message, level) => ctx.ui.notify(message, level),
625
+ ledger: (type, value) => appendLedger(ctx.cwd, type, value),
626
+ });
627
+ if (!outcome.fired && outcome.suppressedReason && opts.manual) {
628
+ ctx.ui.notify(`Reviewer suppressed: ${outcome.suppressedReason}`, "info");
629
+ }
630
+ } catch (err) {
631
+ ctx.ui.notify(`Reviewer failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, "warning");
521
632
  }
522
633
  }
523
634
 
@@ -1227,8 +1338,13 @@ function sendLoopTurn(): void {
1227
1338
  content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
1228
1339
  display: false,
1229
1340
  }, { triggerTurn: true, deliverAs: "followUp" });
1230
- } catch {
1231
- // stale API next agent_end reschedules
1341
+ // v0.26.1: the send path is ledgered — the hegemon zombie spun 619
1342
+ // refires with zero visibility into whether sends were landing.
1343
+ appendLedger(ctx.cwd, "loop_turn_sent", { iteration: loop.iteration });
1344
+ } catch (err) {
1345
+ // stale API — next agent_end reschedules (but if none comes, the
1346
+ // heartbeat's stall escalation stops the spin — v0.26.1).
1347
+ appendLedger(ctx.cwd, "loop_turn_send_failed", { error: err instanceof Error ? err.message : String(err) });
1232
1348
  }
1233
1349
  }
1234
1350
 
@@ -2766,6 +2882,73 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
2766
2882
  }
2767
2883
  }
2768
2884
 
2885
+ /** v0.26.0: /review <archived-goal-id> — manual reviewer invocation. */
2886
+ async function cmdReview(args: string, ctx: ExtensionContext): Promise<void> {
2887
+ const id = args.trim();
2888
+ if (!id) {
2889
+ ctx.ui.notify("Usage: /review <goal-id> — see /goal archive for ids.", "info");
2890
+ return;
2891
+ }
2892
+ // Resolve the id against the archive (suffix match allowed).
2893
+ let goalId = id;
2894
+ let objective = "(archived goal)";
2895
+ try {
2896
+ const files = fs.readdirSync(archiveDir(ctx.cwd)).filter((f) => f.endsWith(".md"));
2897
+ const match = files.find((f) => f === `${id}.md`) ?? files.find((f) => f.includes(id));
2898
+ if (!match) {
2899
+ ctx.ui.notify(`No archived goal matching "${id}". /goal archive lists them.`, "warning");
2900
+ return;
2901
+ }
2902
+ goalId = match.replace(/\.md$/, "");
2903
+ const md = fs.readFileSync(path.join(archiveDir(ctx.cwd), match), "utf-8");
2904
+ const objMatch = md.match(/## Objective\n\n> ([\s\S]*?)(?:\n\n|$)/);
2905
+ if (objMatch) objective = objMatch[1]!.replace(/\n/g, " ").slice(0, 300);
2906
+ } catch {
2907
+ ctx.ui.notify(`No archive found for ${id}.`, "warning");
2908
+ return;
2909
+ }
2910
+ fireReviewer(ctx, { kind: "goal", goalId, objective, terminal: "goal-complete" }, { manual: true });
2911
+ }
2912
+
2913
+ /** v0.26.0: /glla reviewer — the reviewer config menu (project-scoped). */
2914
+ async function cmdReviewerSettings(ctx: ExtensionContext): Promise<void> {
2915
+ if (!ctx.hasUI) {
2916
+ const cfg = resolveReviewerConfig(loadSettings(ctx.cwd).reviewer as Partial<ReviewerConfig> | undefined);
2917
+ ctx.ui.notify(`reviewer (project): ${JSON.stringify(cfg, null, 2)}`, "info");
2918
+ return;
2919
+ }
2920
+ const load = () => resolveReviewerConfig(loadSettings(ctx.cwd).reviewer as Partial<ReviewerConfig> | undefined);
2921
+ const save = (patch: Partial<ReviewerConfig>) => saveSettings("project", ctx.cwd, { reviewer: { ...load(), ...patch } as Record<string, unknown> });
2922
+ for (;;) {
2923
+ const cfg = load();
2924
+ let choice: string | undefined;
2925
+ try {
2926
+ choice = await ctx.ui.select("Reviewer — post-completion follow-up enqueuer (project settings)", reviewerMenuOptions(cfg));
2927
+ } catch {
2928
+ return;
2929
+ }
2930
+ if (!choice || choice === "Done") return;
2931
+ try {
2932
+ if (choice.startsWith("Enabled")) save({ enabled: !cfg.enabled });
2933
+ else if (choice.startsWith("Leverage mode")) save({ leverageMode: cfg.leverageMode === "fix-without-confirm" ? "confirm-all" : "fix-without-confirm" });
2934
+ 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"] });
2935
+ 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"] });
2936
+ else if (choice.startsWith("Cascade: audit-on-clean")) save({ cascade: cfg.cascade.includes("fire-audit-on-clean") ? cfg.cascade.filter((c) => c !== "fire-audit-on-clean") : [...cfg.cascade, "fire-audit-on-clean"] });
2937
+ else if (choice.startsWith("Max findings")) {
2938
+ const v = await ctx.ui.input("Max findings per review", "1-50");
2939
+ const n = Number(v?.trim());
2940
+ if (Number.isSafeInteger(n) && n >= 1 && n <= 50) save({ maxFindingsPerReview: n });
2941
+ } else if (choice.startsWith("Max reviews")) {
2942
+ const v = await ctx.ui.input("Max reviewer fires per day", "1-100");
2943
+ const n = Number(v?.trim());
2944
+ if (Number.isSafeInteger(n) && n >= 1 && n <= 100) save({ maxReviewsPerDay: n });
2945
+ }
2946
+ } catch {
2947
+ /* individual save failures are non-fatal */
2948
+ }
2949
+ }
2950
+ }
2951
+
2769
2952
  /**
2770
2953
  * v0.25.2: /glla stats — one command, every project's rollup. Args:
2771
2954
  * (none) markdown table, all discovered projects
@@ -2858,6 +3041,10 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2858
3041
  cmdAudits(trimmed.slice("audits".length).trim(), ctx);
2859
3042
  return;
2860
3043
  }
3044
+ if (/^reviewer\b/.test(trimmed)) {
3045
+ await cmdReviewerSettings(ctx);
3046
+ return;
3047
+ }
2861
3048
  if (!trimmed) {
2862
3049
  if (ctx.hasUI) {
2863
3050
  await openSettingsUI(ctx);
@@ -3009,6 +3196,19 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3009
3196
  ctx.ui.notify(`quotaretryminutes must be a positive integer, got: ${value}`, "warning");
3010
3197
  }
3011
3198
  }
3199
+ } else if (key === "stallescalation" || key === "stallescalationrefires") {
3200
+ if (["unset", "default"].includes(value)) {
3201
+ patch.stallEscalationRefires = undefined;
3202
+ changed = true;
3203
+ } else {
3204
+ const n = Number.parseInt(value, 10);
3205
+ if (Number.isInteger(n) && n >= 0) {
3206
+ patch.stallEscalationRefires = n;
3207
+ changed = true;
3208
+ } else {
3209
+ ctx.ui.notify(`stallescalation must be a non-negative integer (0 = never escalate), got: ${value}`, "warning");
3210
+ }
3211
+ }
3012
3212
  } else if (key === "stuckmax" || key === "stuckmaxinterventions") {
3013
3213
  if (["unset", "default"].includes(value)) {
3014
3214
  patch.stuckMaxInterventions = undefined;
@@ -3158,13 +3358,19 @@ export default function (pi: ExtensionAPI): void {
3158
3358
  ["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
3159
3359
  ["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
3160
3360
  ["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
3361
+ ["stallescalation=", "N: heartbeat refires without a turn before goal pauses / loop stops (default 5, 0 = never)"],
3161
3362
  ["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
3162
3363
  ["audits", "audit-log browser: /glla audits [N|full] — recent verdicts from .pi-glla/audits.jsonl"],
3163
3364
  ["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
3365
+ ["reviewer", "reviewer config menu (post-completion follow-up enqueuer)"],
3164
3366
  ["project", "write a project override: /glla project key=value"],
3165
3367
  ]),
3166
3368
  handler: settingsHandler,
3167
3369
  });
3370
+ pi.registerCommand("review", {
3371
+ description: "Manually run the reviewer on an archived goal: /review <goal-id> — extracts findings, writes a report to .pi-glla/reviews/, enqueues bug-class fixes to /list, proposes architectural items as /goal. Bypasses the trigger gates (explicit user request).",
3372
+ handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdReview(args, ctx); },
3373
+ });
3168
3374
  pi.registerCommand("list", {
3169
3375
  description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear | /list cancel",
3170
3376
  getArgumentCompletions: completions([
@@ -3173,6 +3379,7 @@ export default function (pi: ExtensionAPI): void {
3173
3379
  ["next", "activate the next item (or /list next <n> for position n)"],
3174
3380
  ["remove", "remove an item: /list remove <n>"],
3175
3381
  ["clear", "empty the list"],
3382
+ ["depth", "queue depth, oldest item age, average item duration"],
3176
3383
  ["cancel", "stop the whole list: abort the active item + drop all waiting"],
3177
3384
  ]),
3178
3385
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
@@ -3217,6 +3424,30 @@ export default function (pi: ExtensionAPI): void {
3217
3424
  }
3218
3425
  }
3219
3426
 
3427
+ // v0.26.1: compaction ends WITHOUT an agent_end (the compaction turn is
3428
+ // not an agent turn), so the continuation chain can dangle until the
3429
+ // 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
3430
+ pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
3431
+ if (isForeignCtx(ctx)) return;
3432
+ rememberCtx(ctx);
3433
+ if (!isSupervising()) return;
3434
+ appendLedger(ctx.cwd, "session_compact", {});
3435
+ const settle = setTimeout(() => {
3436
+ const c = freshCtx();
3437
+ if (!c) return;
3438
+ try {
3439
+ if (c.isIdle() && !c.hasPendingMessages() && continuationTimer === null && loopTimer === null && isSupervising()) {
3440
+ appendLedger(c.cwd, "compaction_refire", {});
3441
+ if (isLoopActive()) scheduleLoopTick(c);
3442
+ else scheduleContinuation(c, true);
3443
+ }
3444
+ } catch {
3445
+ /* settle race — the 60s heartbeat covers it */
3446
+ }
3447
+ }, 2000);
3448
+ settle.unref?.();
3449
+ });
3450
+
3220
3451
  pi.on("message_start", async (event: any, _ctx: ExtensionContext) => {
3221
3452
  // v0.14.0 drafting floor: count real user replies while drafting. Our
3222
3453
  // own injected draft prompt arrives as a user message — skip that one.
@@ -3399,7 +3630,7 @@ export default function (pi: ExtensionAPI): void {
3399
3630
  // v0.23.8: a subagent finishing must not drive the main session's
3400
3631
  // continuation loop.
3401
3632
  if (isForeignCtx(ctx)) return;
3402
- noteActivity();
3633
+ noteActivity(true);
3403
3634
  // v0.25.2: per-goal turn telemetry (/glla stats).
3404
3635
  if (state.goal && state.goal.status === "active") {
3405
3636
  const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
@@ -3500,7 +3731,7 @@ export default function (pi: ExtensionAPI): void {
3500
3731
 
3501
3732
  pi.on("tool_call", () => {
3502
3733
  toolCallsThisTurn++;
3503
- noteActivity();
3734
+ noteActivity(true);
3504
3735
  // v0.24.0: count loop-iteration tool calls (narration-only detection).
3505
3736
  if (isLoopActive()) {
3506
3737
  state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
@@ -0,0 +1,276 @@
1
+ // pi-goal-list-loop-audit — v0.26.0
2
+ // extensions/reviewer.ts
3
+ //
4
+ // The Reviewer: post-completion follow-up enqueuer. Fires after a /goal
5
+ // completes or a /list queue empties, extracts findings from the archive
6
+ // + ledger, classifies them by leverage, writes a review report, and
7
+ // cascades: bug/refactor findings → /list items (no Confirm, per the
8
+ // leverage principle), architectural findings → /goal proposal (Confirm),
9
+ // clean completions → audit /goal proposal, strategic-only → notify+idle.
10
+ //
11
+ // Deterministic by design (REVIEWER-DESIGN-2026-07-24: "makes NO new tool
12
+ // calls — purely analytical"). All side effects are injected so tests
13
+ // drive it without a pi host. /loop completions never trigger it.
14
+
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+
18
+ export interface ReviewerConfig {
19
+ enabled: boolean;
20
+ fireOn: Array<"goal-complete" | "list-complete">;
21
+ doNotFireOn: string[];
22
+ cascade: Array<"convert-findings-to-list" | "queue-leftovers" | "fire-audit-on-clean" | "notify-and-idle">;
23
+ auditCadence: string;
24
+ auditScope: string;
25
+ leverageMode: "fix-without-confirm" | "confirm-all";
26
+ confirmOn: string[];
27
+ maxFindingsPerReview: number;
28
+ maxReviewsPerDay: number;
29
+ }
30
+
31
+ export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
32
+ enabled: true,
33
+ fireOn: ["goal-complete", "list-complete"],
34
+ doNotFireOn: ["goal-aborted", "goal-paused"],
35
+ cascade: ["convert-findings-to-list", "queue-leftovers", "fire-audit-on-clean", "notify-and-idle"],
36
+ auditCadence: "every-clean-completion",
37
+ auditScope: "regression-scan",
38
+ leverageMode: "fix-without-confirm",
39
+ confirmOn: ["architectural-decision", "new-dependency", "schema-change"],
40
+ maxFindingsPerReview: 10,
41
+ maxReviewsPerDay: 20,
42
+ };
43
+
44
+ /** Merge a partial project-settings block over the defaults. */
45
+ export function resolveReviewerConfig(block?: Partial<ReviewerConfig>): ReviewerConfig {
46
+ return { ...DEFAULT_REVIEWER_CONFIG, ...(block ?? {}) };
47
+ }
48
+
49
+ export type FindingClass = "bug" | "refactor" | "architectural" | "strategic";
50
+
51
+ export interface Finding {
52
+ text: string;
53
+ source: string;
54
+ class: FindingClass;
55
+ }
56
+
57
+ /** Leverage classification (contract item 5). Order matters: strategic
58
+ * and architectural win over bug/refactor — "should we rewrite this
59
+ * broken schema" is a decision, not a fix. */
60
+ const CLASS_PATTERNS: Array<{ class: FindingClass; re: RegExp }> = [
61
+ { class: "strategic", re: /\bshould we\b|\bdeprecat|ship this\??|strategic/i },
62
+ { class: "architectural", re: /\brewrite\b|new dependency|schema change|architectural|redesign/i },
63
+ { 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 },
65
+ ];
66
+
67
+ export function classifyFindingText(line: string): FindingClass | undefined {
68
+ const t = line.trim();
69
+ if (t.length < 8) return undefined;
70
+ for (const { class: cls, re } of CLASS_PATTERNS) {
71
+ if (re.test(t)) return cls;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ /** Scan source texts line-by-line for finding-shaped content. */
77
+ export function extractFindings(sources: Array<{ name: string; text: string }>, max: number): Finding[] {
78
+ const out: Finding[] = [];
79
+ const seen = new Set<string>();
80
+ for (const { name, text } of sources) {
81
+ for (const line of text.split("\n")) {
82
+ const cls = classifyFindingText(line);
83
+ if (!cls) continue;
84
+ const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "").slice(0, 200);
85
+ if (clean.length < 8 || seen.has(clean)) continue;
86
+ seen.add(clean);
87
+ out.push({ text: clean, source: name, class: cls });
88
+ if (out.length >= max) return out;
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** Runaway prevention (contract item 6/9): a reviewer fire in the last
95
+ * `windowMs` suppresses re-firing — reviewer-created work completing
96
+ * immediately must not recursively fire the reviewer. */
97
+ export function reviewerFiredRecently(entries: Array<{ type: string; at?: string }>, windowMs: number, nowMs: number): boolean {
98
+ return entries.some((e) => e.type === "reviewer_fired" && e.at !== undefined && nowMs - Date.parse(e.at) < windowMs);
99
+ }
100
+
101
+ /** Per-day cap (contract item 10). */
102
+ export function reviewsToday(entries: Array<{ type: string; at?: string }>, nowMs: number): number {
103
+ const day = new Date(nowMs).toISOString().slice(0, 10);
104
+ return entries.filter((e) => e.type === "reviewer_fired" && e.at?.startsWith(day)).length;
105
+ }
106
+
107
+ export interface ReviewReport {
108
+ goalId: string;
109
+ kind: "goal" | "list";
110
+ objective: string;
111
+ findings: Finding[];
112
+ cascadeStep: string;
113
+ at: string;
114
+ }
115
+
116
+ export function formatReviewReport(r: ReviewReport): string {
117
+ const byClass = (c: FindingClass) => r.findings.filter((f) => f.class === c);
118
+ const section = (title: string, items: Finding[]) =>
119
+ items.length === 0 ? "" : `\n## ${title}\n\n${items.map((f) => `- ${f.text} _(${f.source})_`).join("\n")}\n`;
120
+ return [
121
+ `# Review — ${r.goalId}`,
122
+ "",
123
+ `**Kind**: ${r.kind} · **At**: ${r.at}`,
124
+ "",
125
+ "## Summary",
126
+ "",
127
+ `Completed: ${r.objective.slice(0, 300)}`,
128
+ "",
129
+ `**Cascade step**: ${r.cascadeStep}`,
130
+ "",
131
+ `## Findings (${r.findings.length})`,
132
+ section("Bug-class (enqueued to /list, no Confirm)", byClass("bug")),
133
+ section("Refactor-class (enqueued to /list, no Confirm)", byClass("refactor")),
134
+ section("Architectural-class (proposed as /goal, Confirm required)", byClass("architectural")),
135
+ section("Strategic-class (notify only)", byClass("strategic")),
136
+ r.findings.length === 0 ? "\n(none — completion looks clean)\n" : "",
137
+ ].join("\n");
138
+ }
139
+
140
+ export function writeReviewReport(cwd: string, report: ReviewReport): string {
141
+ const dir = path.join(cwd, ".pi-glla", "reviews");
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ const ts = report.at.replace(/[:.]/g, "-");
144
+ const file = path.join(dir, `${report.goalId}-${ts}.md`);
145
+ fs.writeFileSync(file, formatReviewReport(report));
146
+ return file;
147
+ }
148
+
149
+ /** Injectable side effects — goal.ts binds these to the live session. */
150
+ export interface ReviewerDeps {
151
+ cwd: string;
152
+ nowMs: number;
153
+ /** /review <id> manual invocation — bypasses fireOn/doNotFireOn gates,
154
+ * the refire window, and the day cap (the user asked explicitly). */
155
+ manual?: boolean;
156
+ ledgerEntries: Array<{ type: string; at?: string; value?: any }>;
157
+ /** Source texts for finding extraction (archive md, audit reports). */
158
+ sources: Array<{ name: string; text: string }>;
159
+ enqueueListItems: (objectives: string[]) => void;
160
+ proposeGoal: (objective: string, reason: string) => void;
161
+ notify: (message: string, level: "info" | "warning") => void;
162
+ ledger: (type: string, value: Record<string, unknown>) => void;
163
+ }
164
+
165
+ export interface ReviewerOutcome {
166
+ fired: boolean;
167
+ suppressedReason?: string;
168
+ report?: ReviewReport;
169
+ reportPath?: string;
170
+ enqueued: number;
171
+ proposed: number;
172
+ }
173
+
174
+ export const REVIEWER_REFIRE_WINDOW_MS = 5 * 60_000;
175
+
176
+ /** The reviewer lifecycle (contract item 1). */
177
+ export function runReviewer(
178
+ config: ReviewerConfig,
179
+ source: { kind: "goal" | "list"; goalId: string; objective: string; terminal: string },
180
+ deps: ReviewerDeps,
181
+ ): ReviewerOutcome {
182
+ const none = (suppressedReason: string): ReviewerOutcome => ({ fired: false, suppressedReason, enqueued: 0, proposed: 0 });
183
+ if (!config.enabled && !deps.manual) return none("reviewer disabled");
184
+ const event = source.kind === "goal" ? `${source.terminal}` : "list-complete";
185
+ if (!deps.manual) {
186
+ if (config.doNotFireOn.includes(event)) return none(`doNotFireOn: ${event}`);
187
+ if (source.kind === "goal" && source.terminal !== "goal-complete") return none(`not a completion: ${source.terminal}`);
188
+ if (!config.fireOn.includes(source.kind === "goal" ? "goal-complete" : "list-complete")) return none("fireOn excludes this event");
189
+ if (reviewerFiredRecently(deps.ledgerEntries, REVIEWER_REFIRE_WINDOW_MS, deps.nowMs)) {
190
+ deps.ledger("reviewer_suppressed", { reason: "refire-window", goalId: source.goalId });
191
+ return none("reviewer fired within the last 5 minutes (runaway prevention)");
192
+ }
193
+ const today = reviewsToday(deps.ledgerEntries, deps.nowMs);
194
+ if (today >= config.maxReviewsPerDay) {
195
+ deps.ledger("reviewer_suppressed", { reason: "day-cap", count: today, cap: config.maxReviewsPerDay, goalId: source.goalId });
196
+ return none(`day cap reached (${today}/${config.maxReviewsPerDay})`);
197
+ }
198
+ }
199
+
200
+ const findings = extractFindings(deps.sources, config.maxFindingsPerReview);
201
+ const bugs = findings.filter((f) => f.class === "bug" || f.class === "refactor");
202
+ const architectural = findings.filter((f) => f.class === "architectural");
203
+ const strategic = findings.filter((f) => f.class === "strategic");
204
+
205
+ let enqueued = 0;
206
+ let proposed = 0;
207
+ let cascadeStep = "notify-and-idle";
208
+
209
+ // Cascade: findings → list items (leverage: fix-without-confirm).
210
+ const convertStep = source.kind === "goal" ? "convert-findings-to-list" : "queue-leftovers";
211
+ if (bugs.length > 0 && config.cascade.includes(convertStep)) {
212
+ deps.enqueueListItems(bugs.map((f) => f.text));
213
+ enqueued = bugs.length;
214
+ cascadeStep = convertStep;
215
+ }
216
+ // Architectural findings → /goal proposal WITH Confirm.
217
+ if (architectural.length > 0) {
218
+ deps.proposeGoal(
219
+ architectural.map((f) => f.text).join("; "),
220
+ `reviewer found ${architectural.length} architectural-class finding(s) — needs your Confirm`,
221
+ );
222
+ proposed += architectural.length;
223
+ cascadeStep = "propose-goal";
224
+ }
225
+ // Clean completion → audit /goal (opt-in cascade step).
226
+ if (findings.length === 0 && config.cascade.includes("fire-audit-on-clean")) {
227
+ deps.proposeGoal(
228
+ `Post-completion regression scan after ${source.goalId} (${config.auditScope})`,
229
+ "reviewer: completion looks clean — firing the audit step",
230
+ );
231
+ proposed++;
232
+ cascadeStep = "fire-audit-on-clean";
233
+ }
234
+
235
+ const report: ReviewReport = {
236
+ goalId: source.goalId,
237
+ kind: source.kind,
238
+ objective: source.objective,
239
+ findings,
240
+ cascadeStep,
241
+ at: new Date(deps.nowMs).toISOString(),
242
+ };
243
+ const reportPath = writeReviewReport(deps.cwd, report);
244
+ deps.ledger("reviewer_fired", {
245
+ goalId: source.goalId,
246
+ kind: source.kind,
247
+ findings: findings.length,
248
+ enqueued,
249
+ proposed,
250
+ cascadeStep,
251
+ report: path.relative(deps.cwd, reportPath),
252
+ });
253
+ deps.notify(
254
+ `Reviewer: ${findings.length} finding(s) — ${enqueued} enqueued to /list, ${proposed} proposed as /goal (${cascadeStep}). Report: ${path.relative(deps.cwd, reportPath)}`,
255
+ "info",
256
+ );
257
+ if (strategic.length > 0) {
258
+ deps.notify(`Reviewer: ${strategic.length} strategic finding(s) need YOUR call — see the report's Strategic section.`, "warning");
259
+ }
260
+ return { fired: true, report, reportPath, enqueued, proposed };
261
+ }
262
+
263
+ /** /glla reviewer menu options, derived from the config — extracted so
264
+ * the menu surface is unit-testable without a pi host. */
265
+ export function reviewerMenuOptions(cfg: ReviewerConfig): string[] {
266
+ return [
267
+ `Enabled — ${cfg.enabled ? "ON" : "OFF"}`,
268
+ `Leverage mode — ${cfg.leverageMode} (bug/refactor findings)`,
269
+ `Fire on goal-complete — ${cfg.fireOn.includes("goal-complete") ? "ON" : "OFF"}`,
270
+ `Fire on list-complete — ${cfg.fireOn.includes("list-complete") ? "ON" : "OFF"}`,
271
+ `Cascade: audit-on-clean — ${cfg.cascade.includes("fire-audit-on-clean") ? "ON" : "OFF"}`,
272
+ `Max findings per review — ${cfg.maxFindingsPerReview}`,
273
+ `Max reviews per day — ${cfg.maxReviewsPerDay}`,
274
+ "Done",
275
+ ];
276
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.25.6",
3
+ "version": "0.26.1",
4
4
  "description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",