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

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.
@@ -12,7 +12,17 @@
12
12
 
13
13
  import type { Goal, State } from "./goal-loop-core.js";
14
14
  import { isPersistenceDegraded, lastPersistenceFailure } from "./goal-loop-core.js";
15
- import type { LoopState } from "./goal-loop-forever.js";
15
+ import { HELD_ON_RESTORE, type LoopState } from "./goal-loop-forever.js";
16
+
17
+ /** v0.28.17: a loop parked by the session-restore gate (was active when the
18
+ * last session ended). Held loops must stay VISIBLE — before, only
19
+ * state.loop?.active rendered anything and a reload made the loop vanish
20
+ * from the always-on UI (user report 2026-07-29: "loops are the most
21
+ * immature"). Stopped loops (any other stopReason) stay invisible. */
22
+ function heldLoop(state: State): LoopState | undefined {
23
+ const l = state.loop;
24
+ return l && !l.active && l.stopReason === HELD_ON_RESTORE ? l : undefined;
25
+ }
16
26
 
17
27
  // ---- formatters ----
18
28
 
@@ -128,20 +138,26 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
128
138
  return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}${stallSuffix}`;
129
139
  }
130
140
  const g = state.goal;
131
- if (!g) return undefined;
141
+ const held = heldLoop(state);
142
+ // v0.28.17: a held loop rides every goal state as a compact suffix.
143
+ const heldSuffix = held ? paint(theme, "warning", " · loop⏸held") : "";
144
+ if (!g) {
145
+ if (held) return `glla: loop ${paint(theme, "warning", "⏸ held")} · iter ${held.iteration} — /loop to resume`;
146
+ return undefined;
147
+ }
132
148
  if (g.status === "auditing") {
133
149
  const tool = audit?.currentTool ? ` · ${audit.currentTool}` : "";
134
- return `glla: ${paint(theme, "accent", "auditing…")}${tool}`;
150
+ return `glla: ${paint(theme, "accent", "auditing…")}${tool}${heldSuffix}`;
135
151
  }
136
152
  if (g.status === "paused") {
137
153
  const label = `${g.policy} paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
138
- return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}`;
154
+ return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}${heldSuffix}`;
139
155
  }
140
156
  if (g.status === "active") {
141
157
  // v0.28.1 (S1/S2): a stale-handle interrupt keeps the goal ACTIVE (the
142
158
  // next fresh session auto-resumes it) — say so instead of looking healthy.
143
159
  if (g.interruptedAt) {
144
- return `glla: ${g.policy} ${paint(theme, "error", "⚠ interrupted — stale handle · auto-resumes on pi restart")}`;
160
+ return `glla: ${g.policy} ${paint(theme, "error", "⚠ interrupted — stale handle · auto-resumes on pi restart")}${heldSuffix}`;
145
161
  }
146
162
  // v0.24.7: list policy gets its own wording — a queue item is not a goal.
147
163
  // v0.28.11 (U10): goal policy joins it — "list 29" read as a command
@@ -150,9 +166,11 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
150
166
  const n = state.list?.length ?? 0;
151
167
  const queue = n === 0 ? "" : ` · ${n} queued`;
152
168
  const tasks = g.taskList ? ` ${countDone(g)}/${countTotal(g)} tasks ·` : "";
153
- return `glla: ${g.policy} ${paint(theme, "success", "●")}${tasks} ${fmtElapsed(now - Date.parse(g.createdAt))}${queue}`;
169
+ return `glla: ${g.policy} ${paint(theme, "success", "●")}${tasks} ${fmtElapsed(now - Date.parse(g.createdAt))}${queue}${heldSuffix}`;
154
170
  }
155
- return undefined; // complete/aborted → clear
171
+ // complete/aborted → clear — but a held loop still shows.
172
+ if (held) return `glla: loop ${paint(theme, "warning", "⏸ held")} · iter ${held.iteration} — /loop to resume`;
173
+ return undefined;
156
174
  }
157
175
 
158
176
  function countDone(g: Goal): number {
@@ -199,9 +217,27 @@ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | nu
199
217
  function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
200
218
  if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
201
219
  const g = state.goal;
202
- if (!g) return undefined;
203
- if (g.status === "complete" || g.status === "aborted") return undefined;
204
- return goalLines(g, state, audit, now, theme, width);
220
+ const held = heldLoop(state);
221
+ if (!g || g.status === "complete" || g.status === "aborted") {
222
+ // v0.28.17: no visible goal — the held loop gets its own card.
223
+ return held ? heldLoopLines(held, now, theme, width) : undefined;
224
+ }
225
+ const lines = goalLines(g, state, audit, now, theme, width);
226
+ // v0.28.17: a held loop rides the goal card as a trailing line.
227
+ if (held) {
228
+ lines.push(`${paint(theme, "warning", "⏸")} ${truncate(held.target, budgetFor(width, 3, 64))}`);
229
+ lines.push(`└─ ${paint(theme, "dim", `loop held · iter ${held.iteration} — /loop to resume`)}`);
230
+ }
231
+ return lines;
232
+ }
233
+
234
+ /** v0.28.17: standalone card for a restore-held loop (no goal visible). */
235
+ function heldLoopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number): string[] {
236
+ return [
237
+ `${paint(theme, "warning", "⏸")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
238
+ `├─ loop held · iter ${l.iteration} · ${fmtElapsed(now - Date.parse(l.startedAt))} so far`,
239
+ `└─ ${paint(theme, "dim", "held by the session-restore gate — /loop to resume, /loop stop to drop")}`,
240
+ ];
205
241
  }
206
242
 
207
243
  // Branch lines sit flush-left (pi-tasks convention): pi's widget renderer
@@ -31,6 +31,13 @@ export interface LoopRefinement {
31
31
  newMeasureCmd: string;
32
32
  }
33
33
 
34
+ /** v0.28.17: stopReason marking a loop parked by the session-restore gate
35
+ * (it was active when the last session ended; the fresh session holds it
36
+ * until the user resumes with /loop). Exported so the display layer can
37
+ * recognize held loops — they must stay VISIBLE in the status/widget,
38
+ * unlike stopped loops which are genuinely gone. */
39
+ export const HELD_ON_RESTORE = "held: restored in a fresh session";
40
+
34
41
  export interface LoopState {
35
42
  target: string;
36
43
  /** v0.23.0: optional — a metricless "spec loop" (measure=none) has no
@@ -161,6 +161,7 @@ import {
161
161
  LOOP_DEFAULTS,
162
162
  resolveSpecFiles,
163
163
  respecTarget,
164
+ HELD_ON_RESTORE,
164
165
  type LoopState,
165
166
  } from "../goal-loop-forever.js";
166
167
  import {
@@ -184,8 +185,8 @@ import {
184
185
  // =================================================================
185
186
 
186
187
  const GOAL_EVENT_ENTRY = "goal-event";
187
- /** stopReason marker for a loop held (not stopped) by the fresh-session restore gate. */
188
- const HELD_ON_RESTORE = "held: restored in a fresh session";
188
+ // HELD_ON_RESTORE (stopReason marker for a restore-held loop) lives in
189
+ // goal-loop-forever.js since v0.28.17 — the display layer imports it too.
189
190
 
190
191
  // =================================================================
191
192
  // Module-level state (one per session)
@@ -213,12 +213,22 @@ export interface ReviewerOutcome {
213
213
  reportPath?: string;
214
214
  enqueued: number;
215
215
  proposed: number;
216
- /** v0.27.5: the cascade step that actually fired (notify-and-idle | convert-findings-to-list | queue-leftovers | fire-audit-on-clean | propose-goal | aggressive-relaunch | report-only) — surfaces in /goal status and tests. */
216
+ /** v0.27.5: the cascade step that actually fired (notify-and-idle | convert-findings-to-list | queue-leftovers | fire-audit-on-clean | propose-goal | aggressive-relaunch | duplicate-suppressed) — surfaces in /goal status and tests. v0.28.16: "duplicate-suppressed" = the clean completion WAS a regression scan, so the follow-up scan was deduped. */
217
217
  cascadeStep?: string;
218
218
  }
219
219
 
220
220
  export const REVIEWER_REFIRE_WINDOW_MS = 5 * 60_000;
221
221
 
222
+ /** v0.28.16: normalize an objective for duplicate-compare — lowercase,
223
+ * goal-ids (yyyyMMddHHmmss-xxxxxx) become <id>, whitespace collapses. */
224
+ export function normalizeObjective(s: string): string {
225
+ return s
226
+ .toLowerCase()
227
+ .replace(/\b\d{14}-[a-z0-9]{6}\b/g, "<id>")
228
+ .replace(/\s+/g, " ")
229
+ .trim();
230
+ }
231
+
222
232
  /** The reviewer lifecycle (contract item 1). */
223
233
  export function runReviewer(
224
234
  config: ReviewerConfig,
@@ -310,7 +320,16 @@ export function runReviewer(
310
320
  // aggressive mode → relaunch the audit goal directly (no Confirm).
311
321
  if (findings.length === 0 && config.cascade.includes("fire-audit-on-clean")) {
312
322
  const auditObjective = `Post-completion regression scan after ${source.goalId} (${config.auditScope})`;
313
- if (aggressive) {
323
+ // v0.28.16: duplicate-scan dedupe — the reviewer fires ON completion,
324
+ // so source.objective IS the most recent completion. If the goal that
325
+ // just completed was itself this same scan (normalized: goal-ids
326
+ // stripped), the proposal would be a scan-of-a-scan — the cascade loop
327
+ // that fired twice on 2026-07-28. Suppress the proposal/enqueue (the
328
+ // report is still written; the ledger records the reason).
329
+ if (normalizeObjective(source.objective) === normalizeObjective(auditObjective)) {
330
+ deps.ledger("reviewer_suppressed", { reason: "duplicate-scan", goalId: source.goalId, objective: auditObjective });
331
+ cascadeStep = "duplicate-suppressed";
332
+ } else if (aggressive) {
314
333
  if (deps.proposeGoal(auditObjective, "aggressive postaudit: clean completion — relaunching the regression scan as /goal")) {
315
334
  proposed++;
316
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.15",
3
+ "version": "0.28.17",
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",