pi-goal-list-loop-audit 0.31.8 → 0.32.0
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-auditor.ts +4 -0
- package/extensions/goal-settings.ts +2 -0
- package/extensions/loops/goal.ts +58 -10
- package/package.json +1 -1
- package/prompts/goal-loop-continuation.md +1 -0
- package/prompts/goal-loop-forever-metricless.md +2 -0
- package/prompts/goal-loop-forever.md +2 -0
|
@@ -163,6 +163,7 @@ function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | u
|
|
|
163
163
|
"6. When you disapprove, end the report body with a '## Required fixes' section: one line per blocking gap, each an actionable instruction the executor can complete (most critical first). This tail is what the executor sees first — make it self-sufficient.",
|
|
164
164
|
"7. Write the report in English, and never emit <think> blocks or fragments — your reasoning stays private; the report is the verdict plus evidence.",
|
|
165
165
|
"8. End with exactly <approved/> only if the objective is truly complete; <impossible>reason</impossible> if it can never be satisfied as stated; otherwise end with exactly <disapproved/>.",
|
|
166
|
+
"9. Reject-class pattern (v0.31.9, field-observed fork bomb): a test that invokes the project's whole test runner from INSIDE the suite (a test file spawning `bun test`/`npm test`/`pytest`/etc. on a path the runner itself collects) is unbounded recursion — 521 processes, load 28, a full system crash; a `timeout` wrapper kills processes, not recursion depth. Disapprove unless the recursion is provably depth-capped (e.g. an env sentinel checked before spawning).",
|
|
166
167
|
...(goal.verificationContract?.trim()
|
|
167
168
|
? [
|
|
168
169
|
"",
|
|
@@ -337,6 +338,9 @@ export async function runGoalCompletionAuditor(args: {
|
|
|
337
338
|
} finally {
|
|
338
339
|
clearInterval(stallTimer);
|
|
339
340
|
unsub();
|
|
341
|
+
// v0.32.0: dispose the auditor session — each complete_goal leaked one
|
|
342
|
+
// session's subscriptions/stream resources for the parent's lifetime.
|
|
343
|
+
(session as any).dispose?.();
|
|
340
344
|
}
|
|
341
345
|
|
|
342
346
|
if (stalled) {
|
|
@@ -188,6 +188,8 @@ export function loadGlobalSettings(): Settings {
|
|
|
188
188
|
/** Every provenance-tracked key (the /glla headless display + UI). */
|
|
189
189
|
export const SETTINGS_KEYS: Array<keyof Settings> = [
|
|
190
190
|
"auditorModel",
|
|
191
|
+
"auditorModelFallback",
|
|
192
|
+
"auditorSameSessionSwap",
|
|
191
193
|
"auditorThinkingLevel",
|
|
192
194
|
"notifyCmd",
|
|
193
195
|
"tokenLimit",
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -223,6 +223,14 @@ let extensionApi: ExtensionAPI | null = null;
|
|
|
223
223
|
// throws FOREVER in this process — retrying for hours is the hegemon
|
|
224
224
|
// failure shape. Detect the stale signature once and go terminally loud.
|
|
225
225
|
let extensionApiStale = false;
|
|
226
|
+
// v0.32.0: CRITICAL — goStaleTerminal must gate on its OWN flag, not
|
|
227
|
+
// extensionApiStale: probeExtensionApiStale() sets extensionApiStale on
|
|
228
|
+
// detection, so the heartbeat's `probe → goStaleTerminal` sequence always
|
|
229
|
+
// found the flag already true and returned silently — orphan-stale recovery
|
|
230
|
+
// (ledger, loop stop, interruptedAt, warn, AUTO-RELOAD SELF-HEAL) was dead
|
|
231
|
+
// code since v0.29.11. Field proof: hegemon sat stale for days and the
|
|
232
|
+
// wezterm self-heal never fired.
|
|
233
|
+
let staleTerminalDone = false;
|
|
226
234
|
|
|
227
235
|
/** v0.26.7: a stale api is terminal for this process — go loudly with
|
|
228
236
|
* restart guidance instead of retrying sends that can never land.
|
|
@@ -292,16 +300,24 @@ function absorbStaleIfSuperseded(ctx: ExtensionContext): boolean {
|
|
|
292
300
|
extensionApiStale = true; // silence the send paths WITHOUT the terminal theatre
|
|
293
301
|
clearLoopTimer();
|
|
294
302
|
if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; }
|
|
303
|
+
// v0.32.0: the superseded module's heartbeat + UI ticker were IMMORTAL
|
|
304
|
+
// (clearInterval appeared nowhere) — N /reloads = N×2 zombie tickers.
|
|
305
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
306
|
+
if (uiTicker) { clearInterval(uiTicker); uiTicker = null; }
|
|
295
307
|
return true;
|
|
296
308
|
}
|
|
297
309
|
return false;
|
|
298
310
|
}
|
|
299
311
|
|
|
300
312
|
function goStaleTerminal(ctx: ExtensionContext, where: string): void {
|
|
301
|
-
if (
|
|
313
|
+
if (staleTerminalDone) return; // already terminal — don't re-spam
|
|
314
|
+
staleTerminalDone = true;
|
|
302
315
|
extensionApiStale = true;
|
|
303
316
|
appendLedger(ctx.cwd, "extension_api_stale", { where, kind: isLoopActive() ? "loop" : "goal" });
|
|
304
317
|
const guidance = "pi invalidated this session's extension handle (session replacement — the session was disposed and this process's sends can never land). Run /reload — extensions rebuild IN PLACE, no pi restart needed — then /glla resume (autoresume=on resumes for you). Restart pi only if /reload itself fails.";
|
|
318
|
+
// v0.32.0: kill the continuation re-arm too — otherwise an orphaned goal
|
|
319
|
+
// keeps spinning a flat 50ms retry below every watchdog.
|
|
320
|
+
if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; continuationScheduledFor = null; }
|
|
305
321
|
if (isLoopActive()) {
|
|
306
322
|
clearLoopTimer();
|
|
307
323
|
state.loop = { ...state.loop!, active: false, stopReason: `extension api stale: ${guidance}` };
|
|
@@ -400,6 +416,12 @@ function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
|
|
|
400
416
|
// v0.30.0: a successor may already own this session (e.g. /reload
|
|
401
417
|
// re-imported the modules) — the user's command belongs to the fresh
|
|
402
418
|
// instance; say so softly instead of demanding a reload.
|
|
419
|
+
// v0.32.0: the rebind window means a fresh instance is COMING, not here —
|
|
420
|
+
// the old message claimed "handled there" while nothing owned the session.
|
|
421
|
+
if (Date.now() < sessionReplacementUntil) {
|
|
422
|
+
ctx.ui.notify(`glla: this session is rebinding after /reload — ${what} will be handled by the refreshed instance; retry in a moment if it doesn't.`, "info");
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
403
425
|
if (absorbStaleIfSuperseded(ctx)) {
|
|
404
426
|
ctx.ui.notify(`glla: a refreshed instance owns this session — ${what} is handled there; nothing to do.`, "info");
|
|
405
427
|
return true;
|
|
@@ -579,6 +601,8 @@ let carryoverResolved = true;
|
|
|
579
601
|
// set while complete_goal's isolated audit runs, so the heartbeat never
|
|
580
602
|
// refires into an in-flight completion.
|
|
581
603
|
let completionAuditInFlight = false;
|
|
604
|
+
// v0.32.0: consecutive stored-claim quota retries (capped at 5, then hold).
|
|
605
|
+
let quotaRetryStreak = 0;
|
|
582
606
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
583
607
|
|
|
584
608
|
const ZOMBIE_RUN_SILENT_MS = 20 * 60_000;
|
|
@@ -1005,6 +1029,9 @@ function sendContinuation(goalId: string): void {
|
|
|
1005
1029
|
if (!isActionableGoal()) return;
|
|
1006
1030
|
const ctx = freshCtx();
|
|
1007
1031
|
if (!ctx) {
|
|
1032
|
+
// v0.32.0: a stale handle must not spin a flat 50ms re-arm below every
|
|
1033
|
+
// watchdog — the heartbeat's terminal path does the theatre; we just stop.
|
|
1034
|
+
if (probeExtensionApiStale()) return;
|
|
1008
1035
|
// No live ctx — retry shortly; the next session event will refresh it.
|
|
1009
1036
|
continuationScheduledFor = goalId;
|
|
1010
1037
|
continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
|
|
@@ -1251,7 +1278,9 @@ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
|
|
|
1251
1278
|
// Dedupe against the live queue (a re-run must not double-queue a finding
|
|
1252
1279
|
// that's already waiting) — match on the finding text's first 60 chars.
|
|
1253
1280
|
const queuedText = listQueue().map((i) => i.objective).join("\n");
|
|
1254
|
-
|
|
1281
|
+
// v0.32.0: cap one fan-out — a runaway findings file must not enqueue
|
|
1282
|
+
// hundreds of items on a single Confirm.
|
|
1283
|
+
const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60))).slice(0, 50);
|
|
1255
1284
|
const alreadyQueued = open.length - fresh.length;
|
|
1256
1285
|
const decideNote =
|
|
1257
1286
|
decisions.length > 0
|
|
@@ -1440,6 +1469,14 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
|
|
|
1440
1469
|
pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
|
|
1441
1470
|
}, liveCtx);
|
|
1442
1471
|
appendLedger(liveCtx.cwd, "goal_paused", { reason: `auditor quota: retry in ${quota.retryAfterSec}s (stored-claim retry)` });
|
|
1472
|
+
// v0.32.0: terminal cap — a permanently dead auditor key must not spawn
|
|
1473
|
+
// one auditor per hour forever. 5 consecutive quota retries → hold.
|
|
1474
|
+
quotaRetryStreak++;
|
|
1475
|
+
if (quotaRetryStreak >= 5) {
|
|
1476
|
+
appendLedger(liveCtx.cwd, "quota_retry_capped", { streak: quotaRetryStreak });
|
|
1477
|
+
liveCtx.ui.notify(`Auditor quota retry gave up after ${quotaRetryStreak} consecutive attempts — the claim stays stored; /goal resume retries by hand.`, "warning");
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1443
1480
|
liveCtx.ui.notify(`Auditor still quota-limited — next auto-retry in ${retryMin}m (your completion claim is stored; no action needed).`, "warning");
|
|
1444
1481
|
scheduleQuotaRetry(liveCtx, quota.retryAfterSec, result.error, () => {
|
|
1445
1482
|
if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("auditor quota:") && state.goal.pendingCompletion) {
|
|
@@ -1452,6 +1489,7 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
|
|
|
1452
1489
|
// Any other outcome — disapproved, impossible, non-quota infra — belongs
|
|
1453
1490
|
// to the agent: resume and let the continuation drive the next step. The
|
|
1454
1491
|
// verdict is durable in auditHistory + /goal status.
|
|
1492
|
+
quotaRetryStreak = 0;
|
|
1455
1493
|
updateGoal({
|
|
1456
1494
|
status: "active",
|
|
1457
1495
|
auditHistory: history,
|
|
@@ -4265,35 +4303,45 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string, fallbackRef?:
|
|
|
4265
4303
|
return matches[0] ? { model: matches[0] } : { reason: "no available model matching" };
|
|
4266
4304
|
};
|
|
4267
4305
|
const isSession = (m: any) => sessionModel && m.provider === sessionModel.provider && m.id === sessionModel.id;
|
|
4268
|
-
|
|
4306
|
+
// v0.32.0: per-pin source labels — when the primary is unset, pins[0] IS
|
|
4307
|
+
// the fallback and the old i===0→"setting" map mislabeled it.
|
|
4308
|
+
const pins: Array<{ pin: string; src: "setting" | "fallback-pin" }> = [];
|
|
4309
|
+
if (ref?.trim()) pins.push({ pin: ref.trim(), src: "setting" });
|
|
4310
|
+
if (fallbackRef?.trim()) pins.push({ pin: fallbackRef.trim(), src: "fallback-pin" });
|
|
4269
4311
|
for (let i = 0; i < pins.length; i++) {
|
|
4270
|
-
const pin = pins[i]!;
|
|
4312
|
+
const { pin } = pins[i]!;
|
|
4271
4313
|
const r = tryRef(pin);
|
|
4272
4314
|
if (!r.model) {
|
|
4273
4315
|
// Unavailable pin → cascade: next pin, then the session model (LOUD).
|
|
4274
4316
|
appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pin, reason: r.reason });
|
|
4275
|
-
|
|
4317
|
+
// v0.32.0: the last pin no longer pre-announces the session fallback —
|
|
4318
|
+
// the post-loop block does that (it notified twice before).
|
|
4319
|
+
ctx.ui.notify(`Auditor model "${pin}" is unavailable (${r.reason})${i + 1 < pins.length ? " — trying the fallback pin" : ""}. Fix via /glla → Auditor model.`, "warning");
|
|
4276
4320
|
continue;
|
|
4277
4321
|
}
|
|
4278
4322
|
if (sameSessionSwap && isSession(r.model) && i + 1 < pins.length) {
|
|
4279
4323
|
// The pin IS the session model — the verifier would be the executor's
|
|
4280
4324
|
// own model; auto-swap down the chain (the user's move).
|
|
4281
|
-
appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1] });
|
|
4282
|
-
ctx.ui.notify(`Session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — auditor auto-swapped to ${pins[i + 1]} so the verifier differs.`, "info");
|
|
4325
|
+
appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1]!.pin });
|
|
4326
|
+
ctx.ui.notify(`Session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — auditor auto-swapped to ${pins[i + 1]!.pin} so the verifier differs.`, "info");
|
|
4283
4327
|
continue;
|
|
4284
4328
|
}
|
|
4285
|
-
|
|
4329
|
+
// v0.32.0: the nudge must fire when the LAST pin stands on the session
|
|
4330
|
+
// model — the old `!fallbackRef` guard went SILENT when the fallback pin
|
|
4331
|
+
// itself resolved to the session model (verifier == executor, and hop 0's
|
|
4332
|
+
// notify had just claimed "auto-swapped so the verifier differs" — false).
|
|
4333
|
+
if (sameSessionSwap && isSession(r.model) && i + 1 >= pins.length) {
|
|
4286
4334
|
// Last resort reached and it IS the session model, with no fallback
|
|
4287
4335
|
// ever pinned — the model stands (the session IS the last resort);
|
|
4288
4336
|
// one loud nudge so the user can wire the swap.
|
|
4289
4337
|
appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: null });
|
|
4290
4338
|
ctx.ui.notify(`The session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — pin a different /glla → Auditor fallback model so the verifier can differ.`, "warning");
|
|
4291
4339
|
}
|
|
4292
|
-
return { model: r.model, via: i
|
|
4340
|
+
return { model: r.model, via: pins[i]!.src };
|
|
4293
4341
|
}
|
|
4294
4342
|
if (sessionModel) {
|
|
4295
4343
|
if (pins.length > 0) {
|
|
4296
|
-
appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.join(" → "), reason: "all pins exhausted" });
|
|
4344
|
+
appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.map((p) => p.pin).join(" → "), reason: "all pins exhausted" });
|
|
4297
4345
|
ctx.ui.notify("All pinned auditor models are unavailable — falling back to the session model. Fix via /glla → Auditor model.", "warning");
|
|
4298
4346
|
return { model: sessionModel, via: "session-fallback" };
|
|
4299
4347
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. \u2014 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 \u2014 only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|
|
@@ -151,6 +151,7 @@ When the user must CHOOSE between paths, use `pause_goal` with `kind="decision"`
|
|
|
151
151
|
- **Do not modify the objective silently.** The objective is the user's; if it has drifted from what makes sense, use `complete_goal`'s `newObjective` at completion time, or `pause_goal` and propose a `/goal tweak` mid-flight — never just work on something else and claim the original.
|
|
152
152
|
- **Do not pretend completion.** If verification evidence is missing, call `pause_goal` instead of `complete_goal`.
|
|
153
153
|
- **Git discipline: never touch identity or branches.** Commit with the repo's configured identity exactly as-is — no `git config user.*`, no per-commit `git -c user.name=…` overrides, no invented identities like `<task>-agent <…@local>` (field-observed: a phase agent branded itself `phase-e-agent <phase-e@local>` and polluted the history). No creating or switching branches either — commit on the branch you found (usually `main`) and push to its upstream. If git refuses a commit for a missing identity, STOP and ask the user — never invent one.
|
|
154
|
+
- **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.
|
|
154
155
|
- **Do not polish doorknobs.** If you are out of work and the goal is satisfied, call `complete_goal` instead of inventing a side-improvement.
|
|
155
156
|
- **Do not give up early.** If a task is hard, run it down properly. The auditor will catch doorknobs; the agent's job is to do the real work.
|
|
156
157
|
|
|
@@ -54,3 +54,5 @@ ${STRATEGY_NOTE}
|
|
|
54
54
|
- The spec is ALIVE: if the target needs sharpening, call
|
|
55
55
|
propose_loop_refine with your rationale — the user confirms or rejects.
|
|
56
56
|
${BOUNDS_NOTE}
|
|
57
|
+
|
|
58
|
+
- **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.
|
|
@@ -57,3 +57,5 @@ ${STRATEGY_NOTE}
|
|
|
57
57
|
when the metric plateaus. Keep making real improvements.
|
|
58
58
|
- If the measure command itself is broken (errors, no number), fix whatever
|
|
59
59
|
your last change broke — that counts as a stall.
|
|
60
|
+
|
|
61
|
+
- **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.
|