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.
@@ -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). */
@@ -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
- appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges });
327
- 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");
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
- } 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) });
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
- } catch {
1304
- // stale API next agent_end reschedules
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 id = args.trim();
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/, enqueues bug-class fixes to /list, proposes architectural items as /goal. Bypasses the trigger gates (explicit user request).",
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;
@@ -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
- if (reviewerFiredRecently(deps.ledgerEntries, REVIEWER_REFIRE_WINDOW_MS, deps.nowMs)) {
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
- 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";
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 (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++;
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.0",
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",