pi-goal-list-loop-audit 0.28.22 → 0.28.24
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/README.md +1 -0
- package/extensions/goal-loop-core.ts +3 -3
- package/extensions/goal-settings.ts +5 -0
- package/extensions/loops/goal.ts +130 -12
- package/extensions/reviewer.ts +72 -5
- package/extensions/settings-menu.ts +10 -1
- package/package.json +1 -1
- package/prompts/goal-loop-continuation.md +2 -0
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ Five top-level commands — `/goal`, `/list`, `/loop`, `/glla`, `/review`:
|
|
|
38
38
|
/goal pause # pause
|
|
39
39
|
/goal resume # resume
|
|
40
40
|
/goal cancel # abort
|
|
41
|
+
/goal decide # re-open the decision picker (v0.28.23)
|
|
41
42
|
/goal tweak "<new objective>" # edit in place (Confirm dialog)
|
|
42
43
|
/goal archive # archived goals, newest first
|
|
43
44
|
/glla # settings UI table · /glla key=value · /glla stats · /glla audits [N|full] · /glla postaudit · /glla autoaccept=on
|
|
@@ -203,9 +203,9 @@ export interface Goal {
|
|
|
203
203
|
export type GoalRoute =
|
|
204
204
|
| { kind: "draft" }
|
|
205
205
|
| { kind: "set"; text: string }
|
|
206
|
-
| { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "tweak" | "archive" | "start"; rest: string };
|
|
206
|
+
| { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "decide" | "tweak" | "archive" | "start"; rest: string };
|
|
207
207
|
|
|
208
|
-
const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel"]);
|
|
208
|
+
const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel", "decide"]);
|
|
209
209
|
const GOAL_ARG_SUBS = new Set(["tweak", "archive", "start"]);
|
|
210
210
|
|
|
211
211
|
export function routeGoalArgs(raw: string): GoalRoute {
|
|
@@ -215,7 +215,7 @@ export function routeGoalArgs(raw: string): GoalRoute {
|
|
|
215
215
|
const first = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
|
|
216
216
|
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
217
217
|
if (GOAL_EXACT_SUBS.has(first) && rest === "") {
|
|
218
|
-
return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel", rest: "" };
|
|
218
|
+
return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel" | "decide", rest: "" };
|
|
219
219
|
}
|
|
220
220
|
if (GOAL_ARG_SUBS.has(first)) {
|
|
221
221
|
return { kind: "sub", name: first as "tweak" | "archive" | "start", rest };
|
|
@@ -33,6 +33,10 @@ export interface Settings {
|
|
|
33
33
|
/** on → restored goals/loops/lists auto-resume even in fresh sessions
|
|
34
34
|
* (unattended rigs). Default off: restore holds until /goal resume. */
|
|
35
35
|
autoResume?: boolean;
|
|
36
|
+
/** v0.28.23: off → decision pauses don't pop the select() picker (the
|
|
37
|
+
* widget card still shows the options; /goal decide opens it on demand).
|
|
38
|
+
* Default on; unattended rigs have no UI so this never fires there. */
|
|
39
|
+
decisionPopup?: boolean;
|
|
36
40
|
/** v0.28.14: what happens to stale carryover (paused goal, waiting list,
|
|
37
41
|
* held loop from before this session) when NEW work activates.
|
|
38
42
|
* pause (default) = leave it + ONE summary; clear = drop it all honestly;
|
|
@@ -156,6 +160,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
|
|
|
156
160
|
"tokenLimit",
|
|
157
161
|
"wedgeAlertMinutes",
|
|
158
162
|
"autoResume",
|
|
163
|
+
"decisionPopup",
|
|
159
164
|
"carryover",
|
|
160
165
|
"autoAcceptDrafts",
|
|
161
166
|
"auditCap",
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -463,6 +463,14 @@ function startUITicker(): void {
|
|
|
463
463
|
// and escalated loudly past 5 minutes.
|
|
464
464
|
let continuationRearmStreak = 0;
|
|
465
465
|
let loopRearmStreak = 0;
|
|
466
|
+
// v0.28.24: post-compaction grace — a just-replaced session gets 3 minutes
|
|
467
|
+
// to settle (queue drain, provider recovery) before stall counting resumes.
|
|
468
|
+
// Field-observed in junk-runner: a 196k-token compact finished, then the
|
|
469
|
+
// heartbeat burned all 5 stall refires in the next 5 minutes into a session
|
|
470
|
+
// whose turn trigger was still dead — pausing a resumable goal 4 minutes
|
|
471
|
+
// after the compact instead of giving pi room to recover.
|
|
472
|
+
let compactionGraceUntil = 0;
|
|
473
|
+
const COMPACTION_GRACE_MS = 3 * 60_000;
|
|
466
474
|
const SEND_REARM_LEDGER_EVERY = 600; // 600 × 50ms = 30s
|
|
467
475
|
const SEND_REARM_ESCALATE_AT = 6000; // 6000 × 50ms = 5 minutes
|
|
468
476
|
|
|
@@ -545,6 +553,10 @@ function heartbeatTick(): void {
|
|
|
545
553
|
return;
|
|
546
554
|
}
|
|
547
555
|
const sessionIdle = idle && !pending;
|
|
556
|
+
// v0.28.24: post-compaction grace — the whole stall/refire/watchdog
|
|
557
|
+
// machinery below stays quiet for 3 minutes while the replaced session
|
|
558
|
+
// settles (latch watchdog, wedge alert, refire counting all resume after).
|
|
559
|
+
if (Date.now() < compactionGraceUntil) return;
|
|
548
560
|
// v0.26.5: pending-latch watchdog — a queued continuation whose turn
|
|
549
561
|
// trigger was dropped (field-observed post-compaction: continuation
|
|
550
562
|
// ACCEPTED at compact+0s, then 22 minutes of silence). The stuck latch
|
|
@@ -1138,6 +1150,13 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
1138
1150
|
if (route.name === "pause") return cmdPause(ctx);
|
|
1139
1151
|
if (route.name === "resume") return cmdResume(ctx);
|
|
1140
1152
|
if (route.name === "cancel") return cmdCancel(ctx);
|
|
1153
|
+
// v0.28.23: re-open the decision picker for a decision pause (the
|
|
1154
|
+
// popup auto-opens when the pause lands; this is the on-demand path).
|
|
1155
|
+
if (route.name === "decide") {
|
|
1156
|
+
const shown = await showDecisionPrompt(ctx);
|
|
1157
|
+
if (!shown) ctx.ui.notify("No pending decision — the goal isn't paused on a choice (or no UI).", "info");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1141
1160
|
if (route.name === "tweak") return cmdTweak(route.rest, ctx);
|
|
1142
1161
|
if (route.name === "archive") return cmdGoals(ctx);
|
|
1143
1162
|
// v0.16.0: /goal start <objective> — explicit skip-draft. Activates
|
|
@@ -1198,10 +1217,10 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
|
|
|
1198
1217
|
// v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
|
|
1199
1218
|
// fresh session LOADS it (held by default since v0.28.21), and tell the truth instead of "starting now".
|
|
1200
1219
|
updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
|
|
1201
|
-
ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi, then /goal resume (v0.28.21: session loads no longer auto-start by default)
|
|
1220
|
+
ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi, then /goal resume (v0.28.21: session loads no longer auto-start by default).`, "warning");
|
|
1202
1221
|
return;
|
|
1203
1222
|
}
|
|
1204
|
-
ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion
|
|
1223
|
+
ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion.`, "info");
|
|
1205
1224
|
scheduleContinuation(ctx, true);
|
|
1206
1225
|
}
|
|
1207
1226
|
|
|
@@ -1212,8 +1231,7 @@ async function cmdStatus(ctx: ExtensionContext): Promise<void> {
|
|
|
1212
1231
|
}
|
|
1213
1232
|
const g = state.goal;
|
|
1214
1233
|
const lines = [
|
|
1215
|
-
|
|
1216
|
-
`Objective: ${g.objective}`,
|
|
1234
|
+
`${statusLabel(g.status)}: ${g.objective}`,
|
|
1217
1235
|
// v0.24.7: name WHERE the work came from — a queue item is not a goal.
|
|
1218
1236
|
...(g.policy === "list" ? [`Source: /list queue (${listQueue().length} waiting) — /list to manage`] : []),
|
|
1219
1237
|
`Auto-continue: ${g.autoContinue ? "on" : "off"}`,
|
|
@@ -1233,10 +1251,10 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
|
|
|
1233
1251
|
// v0.22.7: name WHAT was paused — a list item resumes through /list.
|
|
1234
1252
|
if (state.goal.policy === "list") {
|
|
1235
1253
|
const queued = listQueue().length;
|
|
1236
|
-
ctx.ui.notify(`List item ${state.goal.
|
|
1254
|
+
ctx.ui.notify(`List item "${shortObj(state.goal.objective)}" paused${queued > 0 ? ` (${queued} waiting in the list)` : ""}. /list resume to continue.`, "info");
|
|
1237
1255
|
return;
|
|
1238
1256
|
}
|
|
1239
|
-
ctx.ui.notify(`Goal ${state.goal.
|
|
1257
|
+
ctx.ui.notify(`Goal "${shortObj(state.goal.objective)}" paused. /goal resume to continue.`, "info");
|
|
1240
1258
|
}
|
|
1241
1259
|
|
|
1242
1260
|
async function cmdResume(ctx: ExtensionContext): Promise<void> {
|
|
@@ -1291,6 +1309,68 @@ async function cmdCancel(ctx: ExtensionContext): Promise<void> {
|
|
|
1291
1309
|
ctx.ui.notify(`Goal aborted.${isLoopActive() ? " A loop is still active — /loop stop ends it." : ""}`, "info");
|
|
1292
1310
|
}
|
|
1293
1311
|
|
|
1312
|
+
// ---- v0.28.23: decision picker popup ----
|
|
1313
|
+
// A decision pause is ACTIONABLE — the widget card summarizes (and
|
|
1314
|
+
// truncates) it, but picking from a truncated wall was the user's
|
|
1315
|
+
// complaint. Borrow Claude Code / muselinn-Ask: a real select() modal
|
|
1316
|
+
// with the FULL option text, pick → act. Escape leaves the card as the
|
|
1317
|
+
// fallback; /goal decide re-opens the picker at any time.
|
|
1318
|
+
|
|
1319
|
+
let decisionPromptOpen = false;
|
|
1320
|
+
|
|
1321
|
+
/** True when the goal is paused on a user decision with options. */
|
|
1322
|
+
function pendingDecision(): Goal | null {
|
|
1323
|
+
const g = state.goal;
|
|
1324
|
+
return g && g.status === "paused" && g.pauseKind === "decision" && g.pauseOptions && g.pauseOptions.length > 0 ? g : null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Open the decision picker for the current decision pause. Returns true
|
|
1328
|
+
* when a picker was shown (false → caller notifies "no pending decision"). */
|
|
1329
|
+
async function showDecisionPrompt(ctx: ExtensionContext): Promise<boolean> {
|
|
1330
|
+
const g = pendingDecision();
|
|
1331
|
+
if (!g || !ctx.hasUI || decisionPromptOpen) return false;
|
|
1332
|
+
decisionPromptOpen = true;
|
|
1333
|
+
try {
|
|
1334
|
+
const title = `Decision needed — ${g.objective.replace(/\s+/g, " ").slice(0, 72)}${g.pauseReason ? ` · ${g.pauseReason.slice(0, 80)}` : ""}`;
|
|
1335
|
+
const options = g.pauseOptions!.map((o, i) => (g.pauseRecommended === i + 1 ? `${o} (recommended)` : o));
|
|
1336
|
+
const pick = await ctx.ui.select(title, options);
|
|
1337
|
+
if (!pick) return true; // Escape — the widget card remains the fallback
|
|
1338
|
+
const idx = options.indexOf(pick);
|
|
1339
|
+
const label = g.pauseOptions![idx] ?? pick.replace(/ {2}\(recommended\)$/, "");
|
|
1340
|
+
// Executable options — "Label (/goal cancel)" — RUN the command.
|
|
1341
|
+
// Placeholder commands (…/<arg>) fall through to the message path.
|
|
1342
|
+
const cmdMatch = label.match(/\(\/(goal|list|loop) ([a-z]+)\)\s*$/);
|
|
1343
|
+
if (cmdMatch && !label.includes("…") && !label.includes("<")) {
|
|
1344
|
+
const [, group, verb] = cmdMatch;
|
|
1345
|
+
if (group === "goal" && verb === "resume") await cmdResume(ctx);
|
|
1346
|
+
else if (group === "goal" && verb === "cancel") await cmdCancel(ctx);
|
|
1347
|
+
else if (group === "loop" && verb === "stop") await cmdLoop("stop", ctx);
|
|
1348
|
+
else if (group === "loop" && verb === "resume") await cmdLoop("resume", ctx);
|
|
1349
|
+
else {
|
|
1350
|
+
extensionApi?.sendUserMessage(`Decision for the paused goal "${g.objective}": ${label} — continue on this path.`);
|
|
1351
|
+
await cmdResume(ctx);
|
|
1352
|
+
}
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
// Content choice — deliver to the agent, then resume.
|
|
1356
|
+
extensionApi?.sendUserMessage(`Decision for the paused goal "${g.objective}": ${label} — continue on this path.`);
|
|
1357
|
+
await cmdResume(ctx);
|
|
1358
|
+
return true;
|
|
1359
|
+
} finally {
|
|
1360
|
+
decisionPromptOpen = false;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Pop the picker after a decision pause lands — deferred so the current
|
|
1365
|
+
* turn finishes first (pi serializes dialogs). No-ops without a UI, when
|
|
1366
|
+
* disabled (/glla decisionpopup=off), or when one is already open. */
|
|
1367
|
+
function maybeDecisionPopup(ctx: ExtensionContext): void {
|
|
1368
|
+
if (!ctx.hasUI || loadSettings(ctx.cwd).decisionPopup === false) return;
|
|
1369
|
+
setTimeout(() => {
|
|
1370
|
+
void showDecisionPrompt(ctx).catch(() => {});
|
|
1371
|
+
}, 600);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1294
1374
|
async function cmdGoals(ctx: ExtensionContext): Promise<void> {
|
|
1295
1375
|
const dir = archiveDir(ctx.cwd);
|
|
1296
1376
|
if (!fs.existsSync(dir)) {
|
|
@@ -2447,10 +2527,13 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2447
2527
|
status: "paused",
|
|
2448
2528
|
auditHistory: history,
|
|
2449
2529
|
pauseKind: "decision",
|
|
2530
|
+
pauseOptions: ["Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
|
|
2531
|
+
pauseRecommended: 1,
|
|
2450
2532
|
pauseReason: `auditor verdict: IMPOSSIBLE — ${reason}`,
|
|
2451
2533
|
pauseSuggestedAction: "The auditor says this goal can never be satisfied as stated. /goal tweak the objective (or /goal cancel), then /goal resume.",
|
|
2452
2534
|
}, ctx);
|
|
2453
2535
|
ctx.ui.notify(`Auditor: goal IMPOSSIBLE — ${reason}. Goal paused; /goal tweak or /goal cancel, then /goal resume.`, "warning");
|
|
2536
|
+
maybeDecisionPopup(ctx);
|
|
2454
2537
|
appendLedger(ctx.cwd, "goal_paused", { reason: `auditor impossible: ${reason}` });
|
|
2455
2538
|
notifyExternal(ctx, `Goal paused (auditor: impossible): ${reason.slice(0, 120)}`);
|
|
2456
2539
|
return {
|
|
@@ -2623,10 +2706,13 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2623
2706
|
status: "paused",
|
|
2624
2707
|
auditHistory: history,
|
|
2625
2708
|
pauseKind: "decision",
|
|
2709
|
+
pauseOptions: ["Fix the disapproval gap, then continue (/goal resume)", "Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
|
|
2710
|
+
pauseRecommended: 1,
|
|
2626
2711
|
pauseReason: `auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap})`,
|
|
2627
2712
|
pauseSuggestedAction: "Read the audit history (/goal status), fix the actual gap or /goal tweak the objective, then /goal resume. Raise the cap with /glla auditcap=N.",
|
|
2628
2713
|
}, ctx);
|
|
2629
2714
|
ctx.ui.notify(`Goal paused: auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap}). /goal status for the reports; /goal resume to continue.`, "warning");
|
|
2715
|
+
maybeDecisionPopup(ctx);
|
|
2630
2716
|
appendLedger(ctx.cwd, "goal_paused", { reason: `disapproval cap: ${trailingDisapprovals} consecutive (cap ${auditCap})` });
|
|
2631
2717
|
notifyExternal(ctx, `Goal paused: ${trailingDisapprovals} consecutive auditor disapprovals`);
|
|
2632
2718
|
return {
|
|
@@ -2657,7 +2743,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2657
2743
|
pi.registerTool(defineTool({
|
|
2658
2744
|
name: "pause_goal",
|
|
2659
2745
|
label: "Pause goal",
|
|
2660
|
-
description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. When the user must CHOOSE between options, pass kind=\"decision\" with the options list (recommended = 1-based index of the best one) — decision pauses render as a prominent DECISION NEEDED card. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\".",
|
|
2746
|
+
description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. When the user must CHOOSE between options, pass kind=\"decision\" with the options list (recommended = 1-based index of the best one) — decision pauses render as a prominent DECISION NEEDED card. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\". VOCABULARY (v0.28.24): decision options and reasons must reference REAL commands only — /goal resume, /goal cancel, /goal tweak \"<new text>\", /list remove N, /list next, /list resume, /loop stop, /loop resume. These all act on the ACTIVE goal/item: there is NO /goal drop and NO command takes a goal id. Never show goal ids to the user — name the thing ('the active goal', 'list item \"<short name>\"'); ids are internal plumbing the user cannot act on.",
|
|
2661
2747
|
parameters: Type.Object({
|
|
2662
2748
|
reason: Type.String({ description: "Why the work is paused" }),
|
|
2663
2749
|
suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
|
|
@@ -2680,6 +2766,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2680
2766
|
pauseRecommended: p.kind === "decision" && p.recommended && p.recommended >= 1 ? Math.floor(p.recommended) : undefined,
|
|
2681
2767
|
pauseResumeAt: p.kind === "wait" && p.resumeAt ? p.resumeAt : undefined,
|
|
2682
2768
|
}, ctx);
|
|
2769
|
+
if (p.kind === "decision" && p.options && p.options.length > 0) maybeDecisionPopup(ctx);
|
|
2683
2770
|
// v0.27.1: surface the FULL pause contract — reason AND suggested
|
|
2684
2771
|
// action. Before, the action only appeared in /goal status and the
|
|
2685
2772
|
// widget truncated both at ~60 chars, so decision-pauses ("choose a
|
|
@@ -3405,6 +3492,14 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
|
|
|
3405
3492
|
if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
|
|
3406
3493
|
return;
|
|
3407
3494
|
}
|
|
3495
|
+
case "decisionPopup": {
|
|
3496
|
+
const v = await ctx.ui.select("Decision popup (v0.28.23 — decision pauses pop the select() picker)", [
|
|
3497
|
+
"on — a decision pause opens the picker; the widget card is the Escape fallback",
|
|
3498
|
+
"off — widget card only; /goal decide opens the picker on demand",
|
|
3499
|
+
]);
|
|
3500
|
+
if (v) saveSettings("global", ctx.cwd, { decisionPopup: v.startsWith("off") ? false : undefined });
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3408
3503
|
case "aggressiveMode": {
|
|
3409
3504
|
const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going — explicit per-key settings still win)", [
|
|
3410
3505
|
"off — current behavior: pause at the audit cap, wedge alerts on, manual resume",
|
|
@@ -3960,6 +4055,16 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
3960
4055
|
} else {
|
|
3961
4056
|
ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
|
|
3962
4057
|
}
|
|
4058
|
+
} else if (key === "decisionpopup") {
|
|
4059
|
+
if (["on", "true", "1", "yes"].includes(value)) {
|
|
4060
|
+
patch.decisionPopup = true;
|
|
4061
|
+
changed = true;
|
|
4062
|
+
} else if (["off", "false", "0", "no"].includes(value)) {
|
|
4063
|
+
patch.decisionPopup = false;
|
|
4064
|
+
changed = true;
|
|
4065
|
+
} else {
|
|
4066
|
+
ctx.ui.notify(`decisionpopup must be on or off, got: ${value}`, "warning");
|
|
4067
|
+
}
|
|
3963
4068
|
} else if (key === "carryover") {
|
|
3964
4069
|
if (["resume", "pause", "clear"].includes(value)) {
|
|
3965
4070
|
patch.carryover = value as "resume" | "pause" | "clear";
|
|
@@ -4091,7 +4196,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
4091
4196
|
}
|
|
4092
4197
|
}
|
|
4093
4198
|
if (!changed) {
|
|
4094
|
-
ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, carryover, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
|
|
4199
|
+
ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, decisionpopup, carryover, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
|
|
4095
4200
|
return;
|
|
4096
4201
|
}
|
|
4097
4202
|
saveSettings(scope, ctx.cwd, patch);
|
|
@@ -4225,6 +4330,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4225
4330
|
["notify=", "desktop push command: /glla notify='notify-send pi \"$1\"'"],
|
|
4226
4331
|
["tokenlimit=", "per-goal token budget (0 = off): /glla tokenlimit=2000000"],
|
|
4227
4332
|
["autoresume=", "default: hold when a session is loaded, auto-resume on reload/fork; on: always auto-resume; off: never"],
|
|
4333
|
+
["decisionpopup=", "on|off: decision pauses pop the select() picker (default on; the widget card always lists the options, /goal decide reopens the picker)"],
|
|
4228
4334
|
["auditcap=", "N: pause goal after N consecutive auditor disapprovals (default 5, 0 = unlimited)"],
|
|
4229
4335
|
["auditfeedbackchars=", "cap on executor-visible disapproval report chars (0 = full report, the default)"],
|
|
4230
4336
|
["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
|
|
@@ -4325,6 +4431,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4325
4431
|
rememberCtx(ctx);
|
|
4326
4432
|
if (!isSupervising()) return;
|
|
4327
4433
|
appendLedger(ctx.cwd, "session_compact", {});
|
|
4434
|
+
// v0.28.24: a compaction is LEGITIMATE busy time — reset the send-rearm
|
|
4435
|
+
// storm streaks (π-web nearly escalated a "send-retry storm" pause during
|
|
4436
|
+
// a 3.5-minute compact) and open the post-compaction stall grace.
|
|
4437
|
+
continuationRearmStreak = 0;
|
|
4438
|
+
loopRearmStreak = 0;
|
|
4439
|
+
compactionGraceUntil = Date.now() + COMPACTION_GRACE_MS;
|
|
4328
4440
|
const settle = setTimeout(() => {
|
|
4329
4441
|
const c = freshCtx();
|
|
4330
4442
|
if (!c) return;
|
|
@@ -4497,7 +4609,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4497
4609
|
// the auto-resume the marker promised.
|
|
4498
4610
|
if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
|
|
4499
4611
|
ctx.ui.notify(
|
|
4500
|
-
`Resuming ${state.goal.policy === "list" ? "list item" : "goal"}
|
|
4612
|
+
`Resuming ${state.goal.policy === "list" ? "list item" : "goal"}: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}${wasInterrupted ? " — auto-resumed after the stale-handle interrupt" : ""}`,
|
|
4501
4613
|
"info",
|
|
4502
4614
|
);
|
|
4503
4615
|
// v0.28.4 (P3): skip nudge accounting for the first recovery turns.
|
|
@@ -4516,14 +4628,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4516
4628
|
pauseSuggestedAction: resumeHint,
|
|
4517
4629
|
}, ctx);
|
|
4518
4630
|
ctx.ui.notify(
|
|
4519
|
-
`${isListItem ? "List item" : "Goal"} held on restore
|
|
4631
|
+
`${isListItem ? "List item" : "Goal"} held on restore: ${state.goal.objective.slice(0, 70)}${queued > 0 ? ` (+${queued} waiting in the list)` : ""} — ${resumeCmd} to continue.`,
|
|
4520
4632
|
"info",
|
|
4521
4633
|
);
|
|
4522
4634
|
}
|
|
4523
4635
|
} else if (state.goal && state.goal.status === "active") {
|
|
4524
4636
|
// Active but autoContinue off: nothing auto-fires — just surface it.
|
|
4525
4637
|
ctx.ui.notify(
|
|
4526
|
-
`Restored ${state.goal.policy === "list" ? "list item" : "goal"}
|
|
4638
|
+
`Restored ${state.goal.policy === "list" ? "list item" : "goal"}: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
|
|
4527
4639
|
"info",
|
|
4528
4640
|
);
|
|
4529
4641
|
} else if ((!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") && listQueue().length > 0) {
|
|
@@ -4543,13 +4655,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4543
4655
|
updateGoal({
|
|
4544
4656
|
status: "paused",
|
|
4545
4657
|
pauseKind: "decision",
|
|
4658
|
+
pauseOptions: ["Stop the loop, then resume the goal (/loop stop)", "Cancel the goal (/goal cancel) — the loop keeps running"],
|
|
4659
|
+
pauseRecommended: 1,
|
|
4546
4660
|
pauseReason: "held on session load — the loop owns the active slot (one active thing at a time)",
|
|
4547
4661
|
pauseSuggestedAction: "/loop to work the loop, or /loop stop then /goal resume to work the goal",
|
|
4548
4662
|
}, ctx);
|
|
4549
4663
|
ctx.ui.notify(
|
|
4550
|
-
`Goal
|
|
4664
|
+
`Goal held — a loop also exists; one active thing at a time. /loop to resume the loop, or /loop stop then /goal resume.`,
|
|
4551
4665
|
"info",
|
|
4552
4666
|
);
|
|
4667
|
+
maybeDecisionPopup(ctx);
|
|
4553
4668
|
}
|
|
4554
4669
|
// Always paint on session load (v0.22.1): the branches above only reach
|
|
4555
4670
|
// refreshUI via persistState, so a goal that was ALREADY paused (or any
|
|
@@ -4639,10 +4754,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
4639
4754
|
updateGoal({
|
|
4640
4755
|
status: "paused",
|
|
4641
4756
|
pauseKind: "decision",
|
|
4757
|
+
pauseOptions: ["Retry — /goal resume", "Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
|
|
4758
|
+
pauseRecommended: 1,
|
|
4642
4759
|
pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)`,
|
|
4643
4760
|
pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
|
|
4644
4761
|
}, ctx);
|
|
4645
4762
|
ctx.ui.notify(`Goal paused: stalled (${HEARTBEAT_MAX_NUDGES} unproductive turns).`, "warning");
|
|
4763
|
+
maybeDecisionPopup(ctx);
|
|
4646
4764
|
notifyExternal(ctx, "Goal paused: stalled (no tool calls).");
|
|
4647
4765
|
return;
|
|
4648
4766
|
}
|
package/extensions/reviewer.ts
CHANGED
|
@@ -111,17 +111,84 @@ export function stripCodeSpans(text: string): string {
|
|
|
111
111
|
.replace(/`[^`\n]*`/g, " ");
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** v0.28.24: join hard-wrapped lines before classification. Completion
|
|
115
|
+
* summaries and transcripts arrive wrapped at ~70 cols, and line-by-line
|
|
116
|
+
* extraction sliced findings at the wrap point (field-observed in
|
|
117
|
+
* hellhunter: a list item whose ENTIRE objective was "Run a post-completion
|
|
118
|
+
* regression scan on the hellhunter codebase to" — the first visual line of
|
|
119
|
+
* a wrapped paragraph, enqueued by the convert-findings-to-list cascade).
|
|
120
|
+
* A line that doesn't end a sentence continues on the next line unless that
|
|
121
|
+
* line starts a new list item or heading. */
|
|
122
|
+
export function unwrapHardWrappedLines(text: string): string {
|
|
123
|
+
const lines = text.split("\n");
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const prev = out[out.length - 1];
|
|
127
|
+
const startsNewItem = /^\s*([-*•>]|\d+[.)]|#)/.test(line);
|
|
128
|
+
// v0.28.24: join only when the continuation starts LOWERCASE — the
|
|
129
|
+
// mid-sentence signal. Punctuation-less standalone items ("TODO: fix x")
|
|
130
|
+
// start uppercase/keyword and must NOT merge with the next item.
|
|
131
|
+
const continuesSentence = /^[a-z]/.test(line.trimStart());
|
|
132
|
+
if (
|
|
133
|
+
prev !== undefined &&
|
|
134
|
+
prev.trim().length > 0 &&
|
|
135
|
+
line.trim().length > 0 &&
|
|
136
|
+
!startsNewItem &&
|
|
137
|
+
continuesSentence &&
|
|
138
|
+
!/[.!?:;)"'\]]$/.test(prev.trimEnd())
|
|
139
|
+
) {
|
|
140
|
+
out[out.length - 1] = `${prev.trimEnd()} ${line.trimStart()}`;
|
|
141
|
+
} else {
|
|
142
|
+
out.push(line);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** v0.28.24: a candidate ending in a dangling connector is a wrap/parse
|
|
149
|
+
* fragment, not a finding ("…codebase to", "…the settings and"). */
|
|
150
|
+
const DANGLING_END = /\s(to|and|or|but|the|a|an|of|for|with|in|on|at|that|which|into|from|by|is|are|was|were|be|been|so|if|then|than|as|nor|yet|per|via)$/i;
|
|
151
|
+
|
|
152
|
+
/** v0.28.24: cut at a clause boundary, never mid-word — the finding text IS
|
|
153
|
+
* the item's user-facing name once enqueued. */
|
|
154
|
+
export function cutAtClauseBoundary(s: string, max: number): string {
|
|
155
|
+
if (s.length <= max) return s;
|
|
156
|
+
const window = s.slice(0, max);
|
|
157
|
+
const clause = Math.max(
|
|
158
|
+
window.lastIndexOf(". "),
|
|
159
|
+
window.lastIndexOf("! "),
|
|
160
|
+
window.lastIndexOf("? "),
|
|
161
|
+
window.lastIndexOf("; "),
|
|
162
|
+
window.lastIndexOf(", "),
|
|
163
|
+
window.lastIndexOf(" — "),
|
|
164
|
+
window.lastIndexOf(": "),
|
|
165
|
+
);
|
|
166
|
+
if (clause >= Math.floor(max * 0.4)) return window.slice(0, clause + 1).trimEnd();
|
|
167
|
+
const space = window.lastIndexOf(" ");
|
|
168
|
+
return (space > 0 ? window.slice(0, space) : window).trimEnd();
|
|
169
|
+
}
|
|
170
|
+
|
|
114
171
|
/** Scan source texts line-by-line for finding-shaped content. Code
|
|
115
|
-
* spans are stripped first (v0.26.4) — findings live in prose.
|
|
116
|
-
|
|
172
|
+
* spans are stripped first (v0.26.4) — findings live in prose. Hard-wrapped
|
|
173
|
+
* lines are joined (v0.28.24) — findings are sentence-shaped, not
|
|
174
|
+
* visual-line-shaped. `completedObjective` (v0.28.24) dedupes findings that
|
|
175
|
+
* merely restate the just-completed goal (exact-match dedupe at v0.28.16 was
|
|
176
|
+
* too narrow — duplicates arrive as prefixes/paraphrases). */
|
|
177
|
+
export function extractFindings(sources: Array<{ name: string; text: string }>, max: number, completedObjective?: string): Finding[] {
|
|
117
178
|
const out: Finding[] = [];
|
|
118
179
|
const seen = new Set<string>();
|
|
180
|
+
const completedNorm = completedObjective ? normalizeObjective(completedObjective) : "";
|
|
119
181
|
for (const { name, text } of sources) {
|
|
120
|
-
for (const line of stripCodeSpans(text).split("\n")) {
|
|
182
|
+
for (const line of unwrapHardWrappedLines(stripCodeSpans(text)).split("\n")) {
|
|
121
183
|
const cls = classifyFindingText(line);
|
|
122
184
|
if (!cls) continue;
|
|
123
|
-
const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "")
|
|
185
|
+
const clean = cutAtClauseBoundary(line.trim().replace(/^[-*>\s\[\]x]+/, ""), 200);
|
|
124
186
|
if (clean.length < 8 || seen.has(clean)) continue;
|
|
187
|
+
if (DANGLING_END.test(clean) || /[,;:\u2014-]$/.test(clean)) continue; // v0.28.24: wrap/parse fragment
|
|
188
|
+
if (completedNorm) {
|
|
189
|
+
const nf = normalizeObjective(clean);
|
|
190
|
+
if (nf.length >= 24 && (completedNorm.startsWith(nf) || nf.startsWith(completedNorm))) continue; // v0.28.24: restates the completed goal
|
|
191
|
+
}
|
|
125
192
|
seen.add(clean);
|
|
126
193
|
out.push({ text: clean, source: name, class: cls });
|
|
127
194
|
if (out.length >= max) return out;
|
|
@@ -260,7 +327,7 @@ export function runReviewer(
|
|
|
260
327
|
}
|
|
261
328
|
}
|
|
262
329
|
|
|
263
|
-
const findings = extractFindings(deps.sources, config.maxFindingsPerReview);
|
|
330
|
+
const findings = extractFindings(deps.sources, config.maxFindingsPerReview, source.objective);
|
|
264
331
|
const bugs = findings.filter((f) => f.class === "bug" || f.class === "refactor");
|
|
265
332
|
const architectural = findings.filter((f) => f.class === "architectural");
|
|
266
333
|
const strategic = findings.filter((f) => f.class === "strategic");
|
|
@@ -129,7 +129,16 @@ export function buildSettingsRows(
|
|
|
129
129
|
valueText: show("autoResume", "default"),
|
|
130
130
|
sourceText: src("autoResume"),
|
|
131
131
|
description:
|
|
132
|
-
"on: resume on session load too · off: never · default: hold on load
|
|
132
|
+
"on: resume on session load too · off: never · default: hold on EVERY load — explicit resume (v0.28.21)",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "decisionPopup",
|
|
136
|
+
section: "keep-going",
|
|
137
|
+
label: "Decision popup",
|
|
138
|
+
valueText: show("decisionPopup", "on"),
|
|
139
|
+
sourceText: src("decisionPopup"),
|
|
140
|
+
description:
|
|
141
|
+
"on: decision pauses pop the select() picker · off: widget card only — /goal decide reopens the picker (v0.28.23)",
|
|
133
142
|
},
|
|
134
143
|
{
|
|
135
144
|
id: "autoAcceptDrafts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.24",
|
|
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",
|
|
@@ -144,6 +144,8 @@ When the goal is genuinely blocked and you cannot make progress without user inp
|
|
|
144
144
|
pause_goal({reason: "...", suggestedAction: "..."})
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
When the user must CHOOSE between paths, use `pause_goal` with `kind="decision"`, an `options` list, and `recommended` (1-based index) — a prominent decision card renders and the user picks. **Vocabulary rules for reasons and options (v0.28.24):** reference only REAL commands — `/goal resume`, `/goal cancel`, `/goal tweak "<new text>"`, `/list remove N`, `/list next`, `/list resume`, `/loop stop`, `/loop resume` — all act on the ACTIVE goal/item; there is **no `/goal drop`** and **no command takes a goal id**. Never show goal ids (`20260729065635-gbtxsm`) in user-facing text — name the thing instead ("the active goal", "list item 'regression scan'"); ids are internal plumbing the user cannot act on.
|
|
148
|
+
|
|
147
149
|
## HARD RULES
|
|
148
150
|
|
|
149
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.
|