pi-goal-list-loop-audit 0.28.13 → 0.28.14

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.
@@ -33,6 +33,11 @@ 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.14: what happens to stale carryover (paused goal, waiting list,
37
+ * held loop from before this session) when NEW work activates.
38
+ * pause (default) = leave it + ONE summary; clear = drop it all honestly;
39
+ * resume = legacy silent stacking. */
40
+ carryover?: "resume" | "pause" | "clear";
36
41
  /** v0.24.2: pause the goal after N consecutive auditor disapprovals (0 = unlimited).
37
42
  * Default 5 (raised from 3 in v0.25.0, contract item 7). */
38
43
  auditCap?: number;
@@ -146,6 +151,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
146
151
  "tokenLimit",
147
152
  "wedgeAlertMinutes",
148
153
  "autoResume",
154
+ "carryover",
149
155
  "autoAcceptDrafts",
150
156
  "auditCap",
151
157
  "auditFeedbackChars",
@@ -289,6 +289,56 @@ async function confirmDraft(ctx: ExtensionContext, title: string, body: string):
289
289
  }
290
290
  }
291
291
 
292
+ // v0.28.14: ONE summary + policy application for stale carryover when NEW
293
+ // work activates. pause (default): surface what's waiting, stack nothing
294
+ // silently. clear: drop the queue, dismiss the held loop, archive the
295
+ // paused goal — honestly, with a ledger trail. resume: legacy silent
296
+ // behavior. A new GOAL replacing a paused one archives it in every policy
297
+ // (one-active-thing: state.goal holds exactly one goal).
298
+ function resolveCarryover(ctx: ExtensionContext, trigger: "goal" | "loop"): void {
299
+ if (carryoverResolved || !carryoverSnapshot) return;
300
+ carryoverResolved = true;
301
+ const snap = carryoverSnapshot;
302
+ carryoverSnapshot = null;
303
+ const policy = loadSettings(ctx.cwd).carryover ?? "pause";
304
+ if (policy === "resume") return; // legacy silent stacking
305
+ const done: string[] = [];
306
+ const waiting: string[] = [];
307
+ const pausedGoal = state.goal && state.goal.status === "paused" ? state.goal : null;
308
+ if (pausedGoal && (trigger === "goal" || policy === "clear")) {
309
+ archiveCurrentGoal(ctx, "aborted", trigger === "goal" ? "replaced by new goal (carryover)" : "carryover cleared");
310
+ done.push(`archived paused goal "${(snap.pausedGoal ?? pausedGoal.objective).slice(0, 60)}"`);
311
+ } else if (snap.pausedGoal) {
312
+ waiting.push(`paused goal "${snap.pausedGoal}" (/goal resume)`);
313
+ }
314
+ if (snap.listCount > 0) {
315
+ if (policy === "clear") {
316
+ state = { ...state, list: [] };
317
+ done.push(`dropped ${snap.listCount} waiting list item(s)`);
318
+ } else {
319
+ waiting.push(`${snap.listCount} waiting list item(s) (/list next)`);
320
+ }
321
+ }
322
+ if (snap.heldLoop) {
323
+ if (policy === "clear" && state.loop && !state.loop.active && state.loop.stopReason === HELD_ON_RESTORE) {
324
+ state.loop = { ...state.loop, stopReason: "cleared: carryover" };
325
+ done.push(`dismissed held loop "${snap.heldLoop}"`);
326
+ } else {
327
+ waiting.push(`held loop "${snap.heldLoop}" (/loop to resume)`);
328
+ }
329
+ }
330
+ persistState(ctx);
331
+ appendLedger(ctx.cwd, "carryover_resolved", { policy, trigger, cleared: done.length, waiting: waiting.length });
332
+ const summary = [...done.map((d) => `✂ ${d}`), ...waiting.map((w) => `⏸ ${w}`)].join(" · ");
333
+ if (!summary) return;
334
+ ctx.ui.notify(
335
+ policy === "clear"
336
+ ? `Carryover cleared (${trigger}): ${summary}`
337
+ : `Carryover from before this session: ${summary}${waiting.length > 0 ? " — /glla carryover=clear drops these automatically." : ""}`,
338
+ "info",
339
+ );
340
+ }
341
+
292
342
  // The most recent ExtensionContext seen from any event or command handler.
293
343
  // pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
294
344
  // so timers must never capture a ctx — they read lastCtx at fire time.
@@ -352,6 +402,11 @@ let postRestoreGraceTurns = 0;
352
402
  // refire's own noteActivity, which is what made the hegemon zombie spin
353
403
  // self-sustaining (619 refires / 23.5h / zero turns).
354
404
  let consecutiveStalls = 0;
405
+ // v0.28.14: carryover snapshot — unfinished work loaded from disk at
406
+ // session_start (predates this session). Resolved ONCE per session at the
407
+ // first NEW activation (new goal / new loop) per the carryover setting.
408
+ let carryoverSnapshot: { pausedGoal?: string; listCount: number; heldLoop?: string } | null = null;
409
+ let carryoverResolved = true;
355
410
  // v0.26.6: precise replacement for the removed ship-recency suppression —
356
411
  // set while complete_goal's isolated audit runs, so the heartbeat never
357
412
  // refires into an in-flight completion.
@@ -807,7 +862,13 @@ function notifyPersistenceState(ctx: ExtensionContext): void {
807
862
  }
808
863
 
809
864
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
810
- state = { goal, list: state.list ?? [] }; // preserve the list!
865
+ // v0.28.14: never silently orphan a live goal a paused/active goal
866
+ // being replaced is archived honestly first (the old behavior left it in
867
+ // goals/ but untracked: "older goals lying around leading to confusion").
868
+ if (state.goal && state.goal.id !== goal.id && (state.goal.status === "active" || state.goal.status === "paused")) {
869
+ archiveCurrentGoal(ctx, "aborted", `replaced by goal ${goal.id}`);
870
+ }
871
+ state = { ...state, goal }; // preserve list AND loop (v0.28.14: the bare reconstruction used to nuke a held/active loop whenever a goal was set)
811
872
  const file = writeGoalMd(ctx.cwd, goal);
812
873
  state.goal!.activePath = path.relative(ctx.cwd, file) || file;
813
874
  persistState(ctx);
@@ -838,7 +899,7 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
838
899
  if (archived) {
839
900
  try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
840
901
  }
841
- state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
902
+ state = { ...state, goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason } };
842
903
  appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
843
904
  persistState(ctx);
844
905
  // Loop 2: a list-sourced goal COMPLETED → auto-activate the next item.
@@ -957,6 +1018,13 @@ function listQueue(): NonNullable<State["list"]> {
957
1018
  }
958
1019
 
959
1020
  function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
1021
+ // v0.28.14: one-active-thing choke point — NO call site (session_start,
1022
+ // completion cascade, /list next, list_activate, list-draft auto-activate)
1023
+ // may activate a list item over a live loop, present or future.
1024
+ if (isLoopActive()) {
1025
+ appendLedger(ctx.cwd, "list_activation_blocked_loop", {});
1026
+ return false;
1027
+ }
960
1028
  const queue = listQueue();
961
1029
  const taken = takeAt(queue, n);
962
1030
  if (!taken) return false;
@@ -1110,6 +1178,7 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
1110
1178
  return;
1111
1179
  }
1112
1180
  draftingTarget = null; // explicit objective cancels any drafting session
1181
+ resolveCarryover(ctx, "goal"); // v0.28.14: surface/clear stale leftovers
1113
1182
  const goal = createGoal(raw, ctx);
1114
1183
  setGoal(goal, ctx);
1115
1184
  // Reset counters
@@ -1194,10 +1263,17 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
1194
1263
  }
1195
1264
 
1196
1265
  async function cmdCancel(ctx: ExtensionContext): Promise<void> {
1197
- if (!state.goal) return;
1266
+ if (!state.goal) {
1267
+ // v0.28.14: users reach for /goal cancel to kill a LOOP (no goal
1268
+ // active) — point at the right verb instead of doing nothing silently.
1269
+ if (isLoopActive()) {
1270
+ ctx.ui.notify("No goal to cancel — a LOOP is active: /loop stop (or /loop cancel) ends it.", "info");
1271
+ }
1272
+ return;
1273
+ }
1198
1274
  archiveCurrentGoal(ctx, "aborted", "user cancelled");
1199
1275
  ctx.abort();
1200
- ctx.ui.notify("Goal aborted.", "info");
1276
+ ctx.ui.notify(`Goal aborted.${isLoopActive() ? " A loop is still active — /loop stop ends it." : ""}`, "info");
1201
1277
  }
1202
1278
 
1203
1279
  async function cmdGoals(ctx: ExtensionContext): Promise<void> {
@@ -1464,6 +1540,11 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1464
1540
  ctx.ui.notify(`Usage: /list next [1-${listQueue().length || 1}]`, "info");
1465
1541
  return;
1466
1542
  }
1543
+ // v0.28.14: one-active-thing — /list next must not jump a live loop.
1544
+ if (isLoopActive()) {
1545
+ ctx.ui.notify("A loop is active — /loop stop it before activating a list item.", "warning");
1546
+ return;
1547
+ }
1467
1548
  if (state.goal && state.goal.status === "active") {
1468
1549
  archiveCurrentGoal(ctx, "aborted", `skipped via /list next ${n > 1 ? n : ""}`.trim());
1469
1550
  }
@@ -1912,6 +1993,7 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1912
1993
  );
1913
1994
  return false;
1914
1995
  }
1996
+ resolveCarryover(ctx, "loop"); // v0.28.14: surface/clear stale leftovers
1915
1997
  state = {
1916
1998
  ...state,
1917
1999
  loop: {
@@ -1965,6 +2047,12 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1965
2047
  }
1966
2048
  const stored = state.loop;
1967
2049
  if (stored && !stored.active && stored.stopReason === HELD_ON_RESTORE) {
2050
+ // v0.28.14: one-active-thing — a held loop must not resume over an
2051
+ // active goal/list-item (this was the last unguarded stacking path).
2052
+ if (state.goal && state.goal.status === "active") {
2053
+ ctx.ui.notify("A goal is active — the held loop stays held. /goal pause or /goal cancel it first, then /loop to resume.", "warning");
2054
+ return;
2055
+ }
1968
2056
  state.loop = { ...stored, active: true, stopReason: undefined };
1969
2057
  persistState(ctx);
1970
2058
  scheduleLoopTick(ctx);
@@ -2026,13 +2114,15 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2026
2114
  return;
2027
2115
  }
2028
2116
 
2029
- if (sub === "stop") {
2117
+ // v0.28.14: /loop cancel is a first-class alias — users reached for
2118
+ // /goal cancel to kill loops because "cancel" is the verb they know.
2119
+ if (sub === "stop" || sub === "cancel") {
2030
2120
  if (!state.loop) {
2031
2121
  ctx.ui.notify("No loop to stop.", "info");
2032
2122
  return;
2033
2123
  }
2034
2124
  clearLoopTimer();
2035
- state.loop = { ...state.loop, active: false, stopReason: state.loop.stopReason ?? "stopped by user (/loop stop)" };
2125
+ state.loop = { ...state.loop, active: false, stopReason: state.loop.stopReason ?? `stopped by user (/loop ${sub})` };
2036
2126
  persistState(ctx);
2037
2127
  await finishLoopGit(ctx, state.loop);
2038
2128
  appendLedger(ctx.cwd, "loop_stopped", { reason: "user", iterations: state.loop.iteration, best: state.loop.bestValue });
@@ -2646,6 +2736,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2646
2736
  };
2647
2737
  }
2648
2738
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
2739
+ // v0.28.14: one-active-thing EARLY guard — refuse the whole interview
2740
+ // when a loop is live (the post-confirm backstop below stays: state
2741
+ // can change mid-interview).
2742
+ if (isLoopActive()) {
2743
+ return { content: [{ type: "text", text: "A loop is active — one active thing at a time. The user must /loop stop it before a goal or list item can activate; do not re-propose until then." }], details: {} };
2744
+ }
2649
2745
  // v0.14.0: the interview floor — no Confirm until the user replied.
2650
2746
  // v0.23.8: /glla autoaccept=on skips the floor AND the Confirm —
2651
2747
  // the seed carries the intent (unattended rigs). Default off.
@@ -2745,6 +2841,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2745
2841
  const confirmedTarget = draftingTarget;
2746
2842
  draftingTarget = null;
2747
2843
  const full = p.objective.trim() + (normContract ? `\nDone when:\n${normContract}` : "");
2844
+ // v0.28.14: one-active-thing — no goal/list activation over a live loop.
2845
+ if (isLoopActive()) {
2846
+ return { content: [{ type: "text", text: "A loop is active — one active thing at a time. The user must /loop stop it before a goal or list item can activate; do not re-propose until then." }], details: {} };
2847
+ }
2848
+ resolveCarryover(liveCtx, "goal"); // v0.28.14: surface/clear stale leftovers
2748
2849
  // List drafting: the confirmed contract goes into the QUEUE, not active.
2749
2850
  if (confirmedTarget === "list") {
2750
2851
  const extracted = extractVerificationContract(full);
@@ -2795,6 +2896,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2795
2896
  details: {},
2796
2897
  };
2797
2898
  }
2899
+ // v0.28.14: one-active-thing EARLY guard — refuse before the
2900
+ // interview floor (a live goal blocks any loop proposal).
2901
+ if (state.goal && state.goal.status === "active") {
2902
+ return { content: [{ type: "text", text: "A goal is active — one active thing at a time. The user must /goal pause or /goal cancel it before a loop can start; do not re-propose until then." }], details: {} };
2903
+ }
2798
2904
  // v0.14.0: the interview floor — no Confirm until the user replied.
2799
2905
  if (draftingUserReplies === 0) draftingBlockedProposals++;
2800
2906
  const loopBlock = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
@@ -2810,6 +2916,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2810
2916
  return { content: [{ type: "text", text: 'direction=min|max is required for a measured loop (omit measureCmd or pass "none" for a metricless spec loop).' }], details: {} };
2811
2917
  }
2812
2918
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
2919
+ // v0.28.14: one-active-thing — refuse to even test-run a loop measure
2920
+ // while a goal/list-item is active (the /loop start COMMAND guards
2921
+ // this; the tool path used to skip it and stack a loop over a goal).
2922
+ if (state.goal && state.goal.status === "active") {
2923
+ return { content: [{ type: "text", text: "A goal is active — one active thing at a time. The user must /goal pause or /goal cancel it before a loop can start; do not re-propose until then." }], details: {} };
2924
+ }
2813
2925
  // THE TEST-RUN: orchestrator runs the proposed measure once. The user
2814
2926
  // sees the real number before a single iteration burns tokens.
2815
2927
  // (Metricless loops skip this — there is no measure to test-run.)
@@ -3019,6 +3131,10 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
3019
3131
  return { content: [{ type: "text", text: "n must be a positive integer (1-based position)." }], details: {} };
3020
3132
  }
3021
3133
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
3134
+ // v0.28.14: one-active-thing — a list item must not jump a live loop.
3135
+ if (isLoopActive()) {
3136
+ return { content: [{ type: "text", text: "A loop is active — one active thing at a time. The user must /loop stop it before a list item can activate." }], details: {} };
3137
+ }
3022
3138
  if (state.goal && state.goal.status === "active") {
3023
3139
  archiveCurrentGoal(liveCtx, "aborted", "skipped via list_activate");
3024
3140
  }
@@ -3812,6 +3928,14 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3812
3928
  } else {
3813
3929
  ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
3814
3930
  }
3931
+ } else if (key === "carryover") {
3932
+ if (["resume", "pause", "clear"].includes(value)) {
3933
+ patch.carryover = value as "resume" | "pause" | "clear";
3934
+ changed = true;
3935
+ ctx.ui.notify(`carryover=${value}: ${value === "clear" ? "stale goals/lists/held-loops are dropped when new work activates" : value === "pause" ? "stale carryover is surfaced in one summary when new work activates (default)" : "legacy behavior — carryover stacks silently"}.`, "info");
3936
+ } else {
3937
+ ctx.ui.notify(`carryover must be resume, pause, or clear, got: ${value}`, "warning");
3938
+ }
3815
3939
  } else if (key === "autoaccept") {
3816
3940
  if (["on", "true", "1", "yes"].includes(value)) {
3817
3941
  patch.autoAcceptDrafts = true;
@@ -3935,7 +4059,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3935
4059
  }
3936
4060
  }
3937
4061
  if (!changed) {
3938
- ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
4062
+ ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, carryover, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
3939
4063
  return;
3940
4064
  }
3941
4065
  saveSettings(scope, ctx.cwd, patch);
@@ -4253,6 +4377,15 @@ export default function (pi: ExtensionAPI): void {
4253
4377
  // a foreign session.
4254
4378
  if (isForeignCtx(ctx)) return;
4255
4379
  state = readState(ctx.cwd);
4380
+ // v0.28.14: snapshot carryover BEFORE any restore logic mutates state —
4381
+ // a paused goal, waiting list items, or a loop that was live/held when
4382
+ // the last session ended. Resolved once at the first NEW activation.
4383
+ carryoverSnapshot = {
4384
+ pausedGoal: state.goal && state.goal.status === "paused" ? state.goal.objective.slice(0, 60) : undefined,
4385
+ listCount: listQueue().length,
4386
+ heldLoop: state.loop && (state.loop.active || state.loop.stopReason === HELD_ON_RESTORE) ? state.loop.target.slice(0, 60) : undefined,
4387
+ };
4388
+ carryoverResolved = !(carryoverSnapshot.pausedGoal || carryoverSnapshot.listCount > 0 || carryoverSnapshot.heldLoop);
4256
4389
  if (!registeredCtx) {
4257
4390
  registerAgentTools(pi, ctx);
4258
4391
  registeredCtx = ctx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.13",
3
+ "version": "0.28.14",
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",