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

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,57 @@ 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" | "list"): 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
+ // A new goal OR list item replaces the goal slot; a loop leaves it paused.
309
+ if (pausedGoal && (trigger === "goal" || trigger === "list" || policy === "clear")) {
310
+ archiveCurrentGoal(ctx, "aborted", trigger === "loop" ? "carryover cleared" : `replaced by new ${trigger} (carryover)`);
311
+ done.push(`archived paused goal "${(snap.pausedGoal ?? pausedGoal.objective).slice(0, 60)}"`);
312
+ } else if (snap.pausedGoal) {
313
+ waiting.push(`paused goal "${snap.pausedGoal}" (/goal resume)`);
314
+ }
315
+ if (snap.listCount > 0) {
316
+ if (policy === "clear") {
317
+ state = { ...state, list: [] };
318
+ done.push(`dropped ${snap.listCount} waiting list item(s)`);
319
+ } else {
320
+ waiting.push(`${snap.listCount} waiting list item(s) (/list next)`);
321
+ }
322
+ }
323
+ if (snap.heldLoop) {
324
+ if (policy === "clear" && state.loop && !state.loop.active && state.loop.stopReason === HELD_ON_RESTORE) {
325
+ state.loop = { ...state.loop, stopReason: "cleared: carryover" };
326
+ done.push(`dismissed held loop "${snap.heldLoop}"`);
327
+ } else {
328
+ waiting.push(`held loop "${snap.heldLoop}" (/loop to resume)`);
329
+ }
330
+ }
331
+ persistState(ctx);
332
+ appendLedger(ctx.cwd, "carryover_resolved", { policy, trigger, cleared: done.length, waiting: waiting.length });
333
+ const summary = [...done.map((d) => `✂ ${d}`), ...waiting.map((w) => `⏸ ${w}`)].join(" · ");
334
+ if (!summary) return;
335
+ ctx.ui.notify(
336
+ policy === "clear"
337
+ ? `Carryover cleared (${trigger}): ${summary}`
338
+ : `Carryover from before this session: ${summary}${waiting.length > 0 ? " — /glla carryover=clear drops these automatically." : ""}`,
339
+ "info",
340
+ );
341
+ }
342
+
292
343
  // The most recent ExtensionContext seen from any event or command handler.
293
344
  // pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
294
345
  // so timers must never capture a ctx — they read lastCtx at fire time.
@@ -352,6 +403,11 @@ let postRestoreGraceTurns = 0;
352
403
  // refire's own noteActivity, which is what made the hegemon zombie spin
353
404
  // self-sustaining (619 refires / 23.5h / zero turns).
354
405
  let consecutiveStalls = 0;
406
+ // v0.28.14: carryover snapshot — unfinished work loaded from disk at
407
+ // session_start (predates this session). Resolved ONCE per session at the
408
+ // first NEW activation (new goal / new loop) per the carryover setting.
409
+ let carryoverSnapshot: { pausedGoal?: string; listCount: number; heldLoop?: string } | null = null;
410
+ let carryoverResolved = true;
355
411
  // v0.26.6: precise replacement for the removed ship-recency suppression —
356
412
  // set while complete_goal's isolated audit runs, so the heartbeat never
357
413
  // refires into an in-flight completion.
@@ -807,7 +863,13 @@ function notifyPersistenceState(ctx: ExtensionContext): void {
807
863
  }
808
864
 
809
865
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
810
- state = { goal, list: state.list ?? [] }; // preserve the list!
866
+ // v0.28.14: never silently orphan a live goal a paused/active goal
867
+ // being replaced is archived honestly first (the old behavior left it in
868
+ // goals/ but untracked: "older goals lying around leading to confusion").
869
+ if (state.goal && state.goal.id !== goal.id && (state.goal.status === "active" || state.goal.status === "paused")) {
870
+ archiveCurrentGoal(ctx, "aborted", `replaced by goal ${goal.id}`);
871
+ }
872
+ 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
873
  const file = writeGoalMd(ctx.cwd, goal);
812
874
  state.goal!.activePath = path.relative(ctx.cwd, file) || file;
813
875
  persistState(ctx);
@@ -838,7 +900,7 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
838
900
  if (archived) {
839
901
  try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
840
902
  }
841
- state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
903
+ state = { ...state, goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason } };
842
904
  appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
843
905
  persistState(ctx);
844
906
  // Loop 2: a list-sourced goal COMPLETED → auto-activate the next item.
@@ -957,6 +1019,17 @@ function listQueue(): NonNullable<State["list"]> {
957
1019
  }
958
1020
 
959
1021
  function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
1022
+ // v0.28.14: one-active-thing choke point — NO call site (session_start,
1023
+ // completion cascade, /list next, list_activate, list-draft auto-activate)
1024
+ // may activate a list item over a live loop, present or future.
1025
+ if (isLoopActive()) {
1026
+ appendLedger(ctx.cwd, "list_activation_blocked_loop", {});
1027
+ return false;
1028
+ }
1029
+ // v0.28.14: carryover resolution runs BEFORE the item is taken — under
1030
+ // carryover=clear the stale queue is dropped first and there is nothing
1031
+ // to activate; under pause the ONE summary precedes the activation.
1032
+ resolveCarryover(ctx, "list");
960
1033
  const queue = listQueue();
961
1034
  const taken = takeAt(queue, n);
962
1035
  if (!taken) return false;
@@ -1110,6 +1183,7 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
1110
1183
  return;
1111
1184
  }
1112
1185
  draftingTarget = null; // explicit objective cancels any drafting session
1186
+ resolveCarryover(ctx, "goal"); // v0.28.14: surface/clear stale leftovers
1113
1187
  const goal = createGoal(raw, ctx);
1114
1188
  setGoal(goal, ctx);
1115
1189
  // Reset counters
@@ -1194,10 +1268,17 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
1194
1268
  }
1195
1269
 
1196
1270
  async function cmdCancel(ctx: ExtensionContext): Promise<void> {
1197
- if (!state.goal) return;
1271
+ if (!state.goal) {
1272
+ // v0.28.14: users reach for /goal cancel to kill a LOOP (no goal
1273
+ // active) — point at the right verb instead of doing nothing silently.
1274
+ if (isLoopActive()) {
1275
+ ctx.ui.notify("No goal to cancel — a LOOP is active: /loop stop (or /loop cancel) ends it.", "info");
1276
+ }
1277
+ return;
1278
+ }
1198
1279
  archiveCurrentGoal(ctx, "aborted", "user cancelled");
1199
1280
  ctx.abort();
1200
- ctx.ui.notify("Goal aborted.", "info");
1281
+ ctx.ui.notify(`Goal aborted.${isLoopActive() ? " A loop is still active — /loop stop ends it." : ""}`, "info");
1201
1282
  }
1202
1283
 
1203
1284
  async function cmdGoals(ctx: ExtensionContext): Promise<void> {
@@ -1464,6 +1545,11 @@ async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1464
1545
  ctx.ui.notify(`Usage: /list next [1-${listQueue().length || 1}]`, "info");
1465
1546
  return;
1466
1547
  }
1548
+ // v0.28.14: one-active-thing — /list next must not jump a live loop.
1549
+ if (isLoopActive()) {
1550
+ ctx.ui.notify("A loop is active — /loop stop it before activating a list item.", "warning");
1551
+ return;
1552
+ }
1467
1553
  if (state.goal && state.goal.status === "active") {
1468
1554
  archiveCurrentGoal(ctx, "aborted", `skipped via /list next ${n > 1 ? n : ""}`.trim());
1469
1555
  }
@@ -1912,6 +1998,7 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
1912
1998
  );
1913
1999
  return false;
1914
2000
  }
2001
+ resolveCarryover(ctx, "loop"); // v0.28.14: surface/clear stale leftovers
1915
2002
  state = {
1916
2003
  ...state,
1917
2004
  loop: {
@@ -1965,6 +2052,12 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
1965
2052
  }
1966
2053
  const stored = state.loop;
1967
2054
  if (stored && !stored.active && stored.stopReason === HELD_ON_RESTORE) {
2055
+ // v0.28.14: one-active-thing — a held loop must not resume over an
2056
+ // active goal/list-item (this was the last unguarded stacking path).
2057
+ if (state.goal && state.goal.status === "active") {
2058
+ ctx.ui.notify("A goal is active — the held loop stays held. /goal pause or /goal cancel it first, then /loop to resume.", "warning");
2059
+ return;
2060
+ }
1968
2061
  state.loop = { ...stored, active: true, stopReason: undefined };
1969
2062
  persistState(ctx);
1970
2063
  scheduleLoopTick(ctx);
@@ -2026,13 +2119,15 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2026
2119
  return;
2027
2120
  }
2028
2121
 
2029
- if (sub === "stop") {
2122
+ // v0.28.14: /loop cancel is a first-class alias — users reached for
2123
+ // /goal cancel to kill loops because "cancel" is the verb they know.
2124
+ if (sub === "stop" || sub === "cancel") {
2030
2125
  if (!state.loop) {
2031
2126
  ctx.ui.notify("No loop to stop.", "info");
2032
2127
  return;
2033
2128
  }
2034
2129
  clearLoopTimer();
2035
- state.loop = { ...state.loop, active: false, stopReason: state.loop.stopReason ?? "stopped by user (/loop stop)" };
2130
+ state.loop = { ...state.loop, active: false, stopReason: state.loop.stopReason ?? `stopped by user (/loop ${sub})` };
2036
2131
  persistState(ctx);
2037
2132
  await finishLoopGit(ctx, state.loop);
2038
2133
  appendLedger(ctx.cwd, "loop_stopped", { reason: "user", iterations: state.loop.iteration, best: state.loop.bestValue });
@@ -2646,6 +2741,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2646
2741
  };
2647
2742
  }
2648
2743
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
2744
+ // v0.28.14: one-active-thing EARLY guard — refuse the whole interview
2745
+ // when a loop is live (the post-confirm backstop below stays: state
2746
+ // can change mid-interview).
2747
+ if (isLoopActive()) {
2748
+ 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: {} };
2749
+ }
2649
2750
  // v0.14.0: the interview floor — no Confirm until the user replied.
2650
2751
  // v0.23.8: /glla autoaccept=on skips the floor AND the Confirm —
2651
2752
  // the seed carries the intent (unattended rigs). Default off.
@@ -2745,6 +2846,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2745
2846
  const confirmedTarget = draftingTarget;
2746
2847
  draftingTarget = null;
2747
2848
  const full = p.objective.trim() + (normContract ? `\nDone when:\n${normContract}` : "");
2849
+ // v0.28.14: one-active-thing — no goal/list activation over a live loop.
2850
+ if (isLoopActive()) {
2851
+ 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: {} };
2852
+ }
2853
+ resolveCarryover(liveCtx, "goal"); // v0.28.14: surface/clear stale leftovers
2748
2854
  // List drafting: the confirmed contract goes into the QUEUE, not active.
2749
2855
  if (confirmedTarget === "list") {
2750
2856
  const extracted = extractVerificationContract(full);
@@ -2795,6 +2901,11 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2795
2901
  details: {},
2796
2902
  };
2797
2903
  }
2904
+ // v0.28.14: one-active-thing EARLY guard — refuse before the
2905
+ // interview floor (a live goal blocks any loop proposal).
2906
+ if (state.goal && state.goal.status === "active") {
2907
+ 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: {} };
2908
+ }
2798
2909
  // v0.14.0: the interview floor — no Confirm until the user replied.
2799
2910
  if (draftingUserReplies === 0) draftingBlockedProposals++;
2800
2911
  const loopBlock = draftProposalBlock(draftingUserReplies, draftingBlockedProposals);
@@ -2810,6 +2921,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2810
2921
  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
2922
  }
2812
2923
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
2924
+ // v0.28.14: one-active-thing — refuse to even test-run a loop measure
2925
+ // while a goal/list-item is active (the /loop start COMMAND guards
2926
+ // this; the tool path used to skip it and stack a loop over a goal).
2927
+ if (state.goal && state.goal.status === "active") {
2928
+ 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: {} };
2929
+ }
2813
2930
  // THE TEST-RUN: orchestrator runs the proposed measure once. The user
2814
2931
  // sees the real number before a single iteration burns tokens.
2815
2932
  // (Metricless loops skip this — there is no measure to test-run.)
@@ -3019,6 +3136,10 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
3019
3136
  return { content: [{ type: "text", text: "n must be a positive integer (1-based position)." }], details: {} };
3020
3137
  }
3021
3138
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
3139
+ // v0.28.14: one-active-thing — a list item must not jump a live loop.
3140
+ if (isLoopActive()) {
3141
+ 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: {} };
3142
+ }
3022
3143
  if (state.goal && state.goal.status === "active") {
3023
3144
  archiveCurrentGoal(liveCtx, "aborted", "skipped via list_activate");
3024
3145
  }
@@ -3812,6 +3933,14 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3812
3933
  } else {
3813
3934
  ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
3814
3935
  }
3936
+ } else if (key === "carryover") {
3937
+ if (["resume", "pause", "clear"].includes(value)) {
3938
+ patch.carryover = value as "resume" | "pause" | "clear";
3939
+ changed = true;
3940
+ 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");
3941
+ } else {
3942
+ ctx.ui.notify(`carryover must be resume, pause, or clear, got: ${value}`, "warning");
3943
+ }
3815
3944
  } else if (key === "autoaccept") {
3816
3945
  if (["on", "true", "1", "yes"].includes(value)) {
3817
3946
  patch.autoAcceptDrafts = true;
@@ -3935,7 +4064,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3935
4064
  }
3936
4065
  }
3937
4066
  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");
4067
+ 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
4068
  return;
3940
4069
  }
3941
4070
  saveSettings(scope, ctx.cwd, patch);
@@ -4102,12 +4231,13 @@ export default function (pi: ExtensionAPI): void {
4102
4231
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
4103
4232
  });
4104
4233
  pi.registerCommand("loop", {
4105
- description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" = infinite metricless loop (no plateau, no cap; ends at time=/tokens= or /loop stop) · /loop respec = infinite metricless reconcile against the root SPEC.md · add measure=\"<cmd>\" direction=min|max [window=5] [max=50] [branch=1] for a metric loop · /loop status · /loop stop. 'Improve until X' is a /goal, not a loop.",
4234
+ description: "Loop 3: metric-driven process — it never completes. /loop <target> drafts the metric with you · /loop start \"<target>\" = infinite metricless loop (no plateau, no cap; ends at time=/tokens= or /loop stop) · /loop respec = infinite metricless reconcile against the root SPEC.md · add measure=\"<cmd>\" direction=min|max [window=5] [max=50] [branch=1] for a metric loop · /loop status · /loop stop (alias /loop cancel). 'Improve until X' is a /goal, not a loop.",
4106
4235
  getArgumentCompletions: completions([
4107
4236
  ["start", "skip drafting: /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50]"],
4108
4237
  ["respec", "infinite metricless loop reconciling the codebase against the root SPEC.md"],
4109
4238
  ["status", "show metric, iteration, best/last values, stall count"],
4110
4239
  ["stop", "end the loop (keeps the best state)"],
4240
+ ["cancel", "alias of /loop stop — end the loop"],
4111
4241
  ["finish", "end the loop cleanly: /loop finish [reason] → stopReason 'completed: <reason>'"],
4112
4242
  ]),
4113
4243
  handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdLoop(args, ctx); },
@@ -4253,6 +4383,15 @@ export default function (pi: ExtensionAPI): void {
4253
4383
  // a foreign session.
4254
4384
  if (isForeignCtx(ctx)) return;
4255
4385
  state = readState(ctx.cwd);
4386
+ // v0.28.14: snapshot carryover BEFORE any restore logic mutates state —
4387
+ // a paused goal, waiting list items, or a loop that was live/held when
4388
+ // the last session ended. Resolved once at the first NEW activation.
4389
+ carryoverSnapshot = {
4390
+ pausedGoal: state.goal && state.goal.status === "paused" ? state.goal.objective.slice(0, 60) : undefined,
4391
+ listCount: listQueue().length,
4392
+ heldLoop: state.loop && (state.loop.active || state.loop.stopReason === HELD_ON_RESTORE) ? state.loop.target.slice(0, 60) : undefined,
4393
+ };
4394
+ carryoverResolved = !(carryoverSnapshot.pausedGoal || carryoverSnapshot.listCount > 0 || carryoverSnapshot.heldLoop);
4256
4395
  if (!registeredCtx) {
4257
4396
  registerAgentTools(pi, ctx);
4258
4397
  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.15",
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",