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.
- package/extensions/goal-loop-core.ts +11 -0
- package/extensions/goal-loop-display.ts +12 -7
- package/extensions/goal-settings.ts +7 -0
- package/extensions/loops/goal.ts +242 -11
- package/extensions/reviewer.ts +276 -0
- 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. */
|
|
@@ -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). */
|
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,
|
|
@@ -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
|
-
|
|
320
|
-
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");
|
|
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
|
-
|
|
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
|
-
|
|
1231
|
-
//
|
|
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.
|
|
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",
|