pi-goal-list-loop-audit 0.38.29 → 0.38.31

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.31 — overdue wait-pause stops promising imminent resume (2026-09-08)
4
+
5
+ ### Fixed
6
+ - A recovery-timer wait whose retry time passed long ago on a held/idle host no longer claims `next: resuming now` / `auto-retrying · now` forever while also reading `paused` + `safely parked` (field screenshot 20260908_180721). `resuming now` / `resuming…` / `now` are grace-bounded (90s); past that the card and status bar name the timer (`next: recovery timer`, `auto-retrying · overdue — waiting on recovery timer`, `retry overdue`).
7
+ - Recovery-timer waits end at the auto-retry row: the stock provider-failure `pauseSuggestedAction` boilerplate and the generic `awaiting first turn` / `saved` tail are gone (owner + next are already on the rows above); the previous row closes the tree with `└─`. Decision/error/blocked pauses are unchanged.
8
+ - Regression coverage in `tests/paused-overdue-display.test.ts` (6 pins); two v0.34-era pins that encoded the old permanent-`resuming…` wording updated to the grace contract; `tsc` clean.
9
+
10
+ ## 0.38.30 — fresh audit pass: nine low-severity fixes (2026-09-08)
11
+
12
+ ### Fixed
13
+ - Approval-render replay no longer runs before the stale/ownership fence: wrappers skip replay on stale, worker, owner-denied, handoff, and rebind handles (`shouldSkipApprovalRenderReplay`), so a superseded session running stale-allowed `/loop status` cannot mark delivery into a dead session.
14
+ - `countDone` counts all descendants of closed parents, matching the recursive `countTotal` (a closed parent with nested grandchildren no longer under-reads done).
15
+ - Plain-text surfaces use the ANSI-free truncator: `completion-summary.ts` width budgets and the headless settings flat rows route through `truncateCells`; the painted TUI tables keep `truncateToWidth`.
16
+ - Narrow-terminal budgets clamp to the available width instead of the floor, and the compact recovery line takes width-aware inner budgets — provenance/fallback chains truncate indoors instead of hard-cutting mid-token.
17
+ - The display-only objective projection additionally strips `#` headers, `>` quotes, `[label](url)` links, and `*em*` emphasis. Stored objectives unchanged.
18
+ - The approval-render sidecar repairs a corrupt file after ledgering once, truncates objectives by code points, and caps chat lines (60 lines × 1000 chars) behind the 20-entry spillway.
19
+ - The goal card owns goal-kind recovery episodes only: `kind:loop` episodes keep the parked/standalone cards and no longer suppress the goal's provenance; blank-primary recovery falls back to provenance instead of leaving no model fact.
20
+ - `topOpenAuditFinding` strips aligned/tabbed boxes with the same `[ \t]+` class as its matcher, so the reprieve note never carries checkbox markup.
21
+ - `parseLoopStartArgs` consumes known keys only with valid values — junk like unquoted `time=out` stays in the target prose instead of vanishing while defaults silently apply (`done` still always teaches its removal; `measure` accepts any non-empty command).
22
+ - Regression coverage in `tests/audit-2026-09-08.test.ts` (11 pins); `tsc` clean; focused display/loop-forever/approval suites green.
23
+
3
24
  ## 0.38.29 — compact active-card recovery and judgment details (2026-09-08)
4
25
 
5
26
  ### Fixed
package/docs/INDEX.md CHANGED
@@ -18,7 +18,7 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.38.29 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.31 continue through the supervisor freeze (`/glla pause`),
22
22
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
23
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
24
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -31,6 +31,10 @@ export interface PendingApprovalRender {
31
31
  * briefly for inspection; undelivered ones are never dropped by the cap. */
32
32
  const MAX_STORED_RENDERS = 20;
33
33
  const MAX_REPLAY_PER_CONTACT = 5;
34
+ // v0.38.30 audit: bound the sidecar behind the "each is ~1KB" comment — a
35
+ // long approval trailer used to grow the 20-entry file without bound.
36
+ const MAX_RENDER_CHAT_LINES = 60;
37
+ const MAX_RENDER_LINE_CHARS = 1000;
34
38
 
35
39
  export function approvalRenderStorePath(cwd: string): string {
36
40
  return path.join(piGlaDir(cwd), "pending-approval-renders.json");
@@ -63,10 +67,15 @@ function readRenders(cwd: string): PendingApprovalRender[] {
63
67
  dropped: parsed.length - valid.length,
64
68
  kept: valid.length,
65
69
  });
70
+ // v0.38.30 audit: repair the file after ledgering once — otherwise
71
+ // every user command re-appended the same ledger while the file
72
+ // stayed corrupt. Best-effort; a failed rewrite simply ledgers again.
73
+ writeRenders(cwd, valid);
66
74
  }
67
75
  return valid;
68
76
  } catch {
69
77
  appendLedger(cwd, "terminal_approval_render_store_invalid", { dropped: "all", kept: 0 });
78
+ writeRenders(cwd, []);
70
79
  return [];
71
80
  }
72
81
  }
@@ -94,8 +103,10 @@ export function persistApprovalRender(cwd: string, render: {
94
103
  const existing = readRenders(cwd);
95
104
  const entry: PendingApprovalRender = {
96
105
  goalId: render.goalId,
97
- objective: render.objective.slice(0, 300),
98
- chatLines: render.chatLines,
106
+ // v0.38.30 audit: code-point truncation (char slice split surrogate
107
+ // pairs) + bounded chat lines (the 20-entry cap never bound bytes).
108
+ objective: [...render.objective].slice(0, 300).join(""),
109
+ chatLines: render.chatLines.slice(0, MAX_RENDER_CHAT_LINES).map((line) => [...line].slice(0, MAX_RENDER_LINE_CHARS).join("")),
99
110
  createdAt: at,
100
111
  ...(render.delivered ? { deliveredAt: at } : {}),
101
112
  };
@@ -1,5 +1,5 @@
1
1
  import type { Goal, Status } from "./goal-loop-core.js";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { truncateCells } from "./goal-loop-display.js";
3
3
 
4
4
  /**
5
5
  * The durable, user-facing terminal recap contract. Keep this as a small
@@ -303,7 +303,10 @@ export function completionSummaryLines(text: string | undefined, maxValueLength
303
303
  const line = `${name}: ${clipSummaryValue(rawValue || "not recorded", maxValueLength)}`;
304
304
  // Audit 2026-09-06: optional width budget for width-bound surfaces —
305
305
  // the default 240-char values previously had no width-conscious path.
306
- return lineWidth && lineWidth > 0 ? truncateToWidth(line, lineWidth, "…") : line;
306
+ // v0.38.30 audit: plain-text surface — use the ANSI-free truncator
307
+ // (pi-tui truncateToWidth wraps the ellipsis in resets even for plain
308
+ // text, polluting notify/log/pager consumers).
309
+ return lineWidth && lineWidth > 0 ? truncateCells(line, lineWidth) : line;
307
310
  });
308
311
  }
309
312
 
@@ -95,7 +95,14 @@ function displayObjective(objective: string): string {
95
95
  return compactDisplayText(objective)
96
96
  .replace(/\*\*([^*]+)\*\*/g, "$1")
97
97
  .replace(/__([^_]+)__/g, "$1")
98
- .replace(/`([^`\n]*)`/g, "$1");
98
+ .replace(/`([^`\n]*)`/g, "$1")
99
+ // v0.38.30 audit: strip the remaining common single-line wrappers so the
100
+ // glance card keeps its plain-text promise (headers, quotes, links,
101
+ // single-emphasis). Display-only; prompts/archives/state keep verbatim.
102
+ .replace(/^#{1,6}\s+/gm, "")
103
+ .replace(/^>\s?/gm, "")
104
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
105
+ .replace(/\*([^*\n]+)\*/g, "$1");
99
106
  }
100
107
 
101
108
  /** v0.33.1: painted strings measure by their terminal-cell width. */
@@ -261,7 +268,11 @@ export const WORKER_TEXT_SPACER = "\u00A0";
261
268
 
262
269
  function budgetFor(width: number | undefined, prefixCols: number, floor: number): number {
263
270
  if (!width || width <= 0) return floor;
264
- return Math.max(floor, width - WIDGET_HORIZONTAL_MARGIN - prefixCols);
271
+ // v0.38.30 audit: clamp to the available width (narrow terminals used to
272
+ // get the floor, so the inner truncate no-opped and the outer hard-cut
273
+ // sliced mid-token). Floor applies only when width is unknown; otherwise
274
+ // the available width wins with a small absolute minimum to avoid junk.
275
+ return Math.max(10, width - WIDGET_HORIZONTAL_MARGIN - prefixCols);
265
276
  }
266
277
 
267
278
  function uniqueModelRefs(refs: readonly string[] | undefined): string[] {
@@ -291,8 +302,10 @@ function modelSourceLabel(source: string): string {
291
302
  return source.trim();
292
303
  }
293
304
 
294
- function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, configuredBackups: string[] = [], now = Date.now()): string | undefined {
305
+ function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, configuredBackups: string[] = [], now = Date.now(), width?: number): string | undefined {
295
306
  if (!recovery || !recovery.primary.trim()) return undefined;
307
+ // v0.38.30 audit: width-aware inner budgets (fixed 48/56 silently dropped
308
+ // the attempts/skipped tail at narrow widths when the outer hard-cut ran).
296
309
  const primary = recovery.primary.trim();
297
310
  const current = (recovery.active ?? primary).trim() || primary;
298
311
  const backups = uniqueModelRefs(configuredBackups).slice(0, 10);
@@ -308,7 +321,7 @@ function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, c
308
321
  const phase = recovery.manualResumeRequired
309
322
  ? "manual hold"
310
323
  : recovery.pendingModelSwitch
311
- ? `switching → ${truncate(recovery.pendingModelSwitch, 48)}`
324
+ ? `switching → ${truncate(recovery.pendingModelSwitch, budgetFor(width, 20, 48))}`
312
325
  : Number.isFinite(retryMs)
313
326
  ? retryMs <= 0 ? "retrying now" : `retrying in ${fmtElapsed(retryMs)}`
314
327
  : recovery.primaryProbeInFlight
@@ -330,12 +343,12 @@ function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, c
330
343
  // A recovery object can briefly survive the successful primary selection.
331
344
  // Do not label that normal state as an incident, but keep the selected model
332
345
  // on the card when no separate provenance row is available.
333
- if (!hasEpisode) return `model: ${truncate(current, 56)} · primary`;
346
+ if (!hasEpisode) return `model: ${truncate(current, budgetFor(width, 12, 56))} · primary`;
334
347
  const history = [
335
348
  recovery.attempts > 0 ? `attempts ${recovery.attempts}` : "",
336
349
  skipped > 0 ? `skipped ${skipped}` : "",
337
350
  ].filter(Boolean);
338
- return `recovery: ${phase} · ${selected} ${truncate(current, 48)}${history.length > 0 ? ` · ${history.join(" · ")}` : ""}`;
351
+ return `recovery: ${phase} · ${selected} ${truncate(current, budgetFor(width, 20, 48))}${history.length > 0 ? ` · ${history.join(" · ")}` : ""}`;
339
352
  }
340
353
 
341
354
  function modelProvenanceLines(provenance: ModelProvenanceDisplay | undefined, width?: number): string[] {
@@ -998,6 +1011,14 @@ function goalTotalText(g: Goal, now: number): string {
998
1011
  return Number.isFinite(startedAt) ? `total ${fmtElapsed(bucketSilentMs(Math.max(0, now - startedAt)))}` : "";
999
1012
  }
1000
1013
 
1014
+ /** v0.38.31 (field 2026-09-08 180721): "resuming now" is a transient
1015
+ * truth, not a state. A wait-pause whose retry time passed an hour ago on
1016
+ * a held/idle host is overdue — claiming an imminent resume forever while
1017
+ * the card also says "safely parked" is the same contradiction class as
1018
+ * f8d1c2f. Past the grace window the transition falls through to the
1019
+ * pause-kind label ("recovery timer" for waits) instead. */
1020
+ export const PAUSED_RESUME_GRACE_MS = 90_000;
1021
+
1001
1022
  function pausedNextTransition(g: Goal, state: State, now: number): string {
1002
1023
  const resume = g.policy === "list" ? "/list resume" : "/goal resume";
1003
1024
  const retryAt = state.mainModelRecovery?.retryAt ? Date.parse(state.mainModelRecovery.retryAt) : Number.NaN;
@@ -1013,7 +1034,11 @@ function pausedNextTransition(g: Goal, state: State, now: number): string {
1013
1034
  }
1014
1035
  const resumeAt = g.pauseResumeAt ? Date.parse(g.pauseResumeAt) : Number.NaN;
1015
1036
  if (Number.isFinite(resumeAt)) {
1016
- return resumeAt <= now ? "resuming now" : `auto-retry in ${fmtElapsed(resumeAt - now)}`;
1037
+ if (resumeAt > now) return `auto-retry in ${fmtElapsed(resumeAt - now)}`;
1038
+ // Inside the grace window the retry is genuinely imminent. Past it the
1039
+ // timer never fired (held host, idle session) — fall through to the
1040
+ // kind label below instead of promising "resuming now" forever.
1041
+ if (now - resumeAt < PAUSED_RESUME_GRACE_MS) return "resuming now";
1017
1042
  }
1018
1043
  if (isCompletionAuditNoVerdict(g)) {
1019
1044
  const retryAt = g.pendingCompletion?.recoveryRetryAt
@@ -1194,14 +1219,16 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1194
1219
  if (kind === "wait" || kind === "blocked") {
1195
1220
  // v0.34.12: live countdown (the UI ticker keeps rendering through a
1196
1221
  // timed wait) — "auto-retry in 23m" beats a static clock time, and a
1197
- // passed resumeAt says "resuming…" instead of lying about the past.
1222
+ // freshly-passed resumeAt says "resuming…" instead of lying about the
1223
+ // past. v0.38.31: "resuming…" shares the transition grace window — a
1224
+ // retry time long past with no dispatch reads "retry overdue".
1198
1225
  // Every retry-class pause renders the same ⏳ auto-retrying… line +
1199
1226
  // countdown; blocked pauses without a recovery timer render as
1200
1227
  // ⏸ action needed. A main-model manual hold names its recovery owner.
1201
1228
  const rms = g.pauseResumeAt ? Date.parse(g.pauseResumeAt) - now : Number.NaN;
1202
- const when = Number.isFinite(rms)
1203
- ? rms <= 0 ? " · resuming…" : ` · auto-retry in ${fmtElapsed(rms)}`
1204
- : "";
1229
+ const when = !Number.isFinite(rms) ? ""
1230
+ : rms > 0 ? ` · auto-retry in ${fmtElapsed(rms)}`
1231
+ : -rms >= PAUSED_RESUME_GRACE_MS ? " · retry overdue" : " · resuming…";
1205
1232
  if (kind === "blocked") {
1206
1233
  const label = state.mainModelRecovery?.manualResumeRequired === true
1207
1234
  ? "⏸ manual recovery hold"
@@ -1340,6 +1367,12 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1340
1367
 
1341
1368
  function countDone(g: Goal): number {
1342
1369
  let n = 0;
1370
+ const countAllDescendants = (ts?: any[]): number => {
1371
+ if (!ts) return 0;
1372
+ let c = 0;
1373
+ for (const s of ts) c += 1 + countAllDescendants(s.subtasks);
1374
+ return c;
1375
+ };
1343
1376
  const walk = (ts: Array<{ status: string; subtasks?: any[] }>) => {
1344
1377
  for (const t of ts) {
1345
1378
  if (t.status === "complete") {
@@ -1347,7 +1380,9 @@ function countDone(g: Goal): number {
1347
1380
  // (field: 2 closed parents with pending subtasks displayed "2/24"
1348
1381
  // for 6 real tasks). Mid-flight parents still count done subtasks
1349
1382
  // independently via the walk below.
1350
- n += 1 + (t.subtasks?.length ?? 0);
1383
+ // v0.38.30 audit: count ALL descendants, not just direct children
1384
+ // (a closed parent with nested grandchildren under-read done).
1385
+ n += 1 + countAllDescendants(t.subtasks);
1351
1386
  } else if (t.subtasks) {
1352
1387
  walk(t.subtasks);
1353
1388
  }
@@ -1648,9 +1683,15 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1648
1683
  // active. Keep that fact visible, but do not print the full recovery report
1649
1684
  // into the glance card; it made the card taller than the space below the
1650
1685
  // editor. `/goal status` remains the detailed recovery surface.
1686
+ // v0.38.30 audit: goal card owns goal-kind episodes only (a kind:loop
1687
+ // episode beside an active goal used to misattribute loop recovery here
1688
+ // and hide the goal's own provenance; loop kinds keep the parked /
1689
+ // standalone cards). Fall back to provenance when the compact line is
1690
+ // undefined (blank-primary edge left no model fact at all).
1691
+ const goalRecovery = state.mainModelRecovery?.kind === "goal" ? state.mainModelRecovery : undefined;
1651
1692
  let recoveryLine: string | undefined;
1652
- if (g.status === "active" && state.mainModelRecovery) {
1653
- recoveryLine = compactMainModelRecoveryLine(state.mainModelRecovery, extras?.mainModelFallbacks, now);
1693
+ if (g.status === "active" && goalRecovery) {
1694
+ recoveryLine = compactMainModelRecoveryLine(goalRecovery, extras?.mainModelFallbacks, now, width);
1654
1695
  if (recoveryLine) lines.push(`├─ ${paint(theme, "dim", recoveryLine)}`);
1655
1696
  }
1656
1697
  // Model provenance is a card fact, not a notification: keep it visible
@@ -1658,7 +1699,7 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1658
1699
  // active recovery the compact recovery row owns the model fact, so do not
1659
1700
  // print a second primary/current row beside it. The full chain and skipped
1660
1701
  // refs remain available through `/goal status`.
1661
- const provenance = state.mainModelRecovery
1702
+ const provenance = recoveryLine
1662
1703
  ? []
1663
1704
  : modelProvenanceLines(extras?.modelProvenance, width);
1664
1705
  const provenanceStart = lines.length;
@@ -1860,7 +1901,12 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1860
1901
  recoverySummary.forEach((line) => lines.push(`│ ${paint(theme, "dim", line.replace(/^Main-model recovery: /, ""))}`));
1861
1902
  }
1862
1903
  else if (Number.isFinite(retryMs)) {
1863
- const when = retryMs <= 0 ? "now" : `next probe in ${fmtElapsed(retryMs)}`;
1904
+ // v0.38.31: "now" shares the transition grace window — a retry time
1905
+ // that passed long ago with no dispatch is overdue, not imminent.
1906
+ const overdue = -retryMs >= PAUSED_RESUME_GRACE_MS;
1907
+ const when = retryMs > 0 ? `next probe in ${fmtElapsed(retryMs)}`
1908
+ : overdue ? "overdue — waiting on recovery timer"
1909
+ : "now";
1864
1910
  lines.push(`├─ ${paint(theme, "dim", `auto-retrying · ${when}`)}`);
1865
1911
  } else if (kind === "blocked" && state.mainModelRecovery?.manualResumeRequired === true) {
1866
1912
  lines.push(`├─ ${paint(theme, "warning", "manual recovery hold — automatic probes stopped")}`);
@@ -1901,6 +1947,16 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1901
1947
  // before the first turn), render "awaiting first turn" instead of "saved"
1902
1948
  // — the latter was misleading because no work was ever "saved" before the
1903
1949
  // session ended.
1950
+ if (kind === "wait") {
1951
+ // v0.38.31 (field 2026-09-08 180721): a recovery-timer wait carries no
1952
+ // objective-specific tail — owner + next are already on the lifecycle /
1953
+ // transition / auto-retry rows, and the parked suggestedAction is stock
1954
+ // provider-failure boilerplate identical on every recovery wait. End
1955
+ // the card here, closing the previous row as the footer.
1956
+ const tail = lines[lines.length - 1];
1957
+ if (tail !== undefined) lines[lines.length - 1] = tail.replace(/^├─|^│\s*/, "└─ ");
1958
+ return lines;
1959
+ }
1904
1960
  const spent: string[] = [];
1905
1961
  const tokUsed = g.usage?.tokensUsed ?? 0;
1906
1962
  const audits = g.auditHistory?.length ?? 0;
@@ -431,8 +431,23 @@ export function parseLoopStartArgs(raw: string): {
431
431
  const spans: Array<[number, number]> = [];
432
432
  while ((m = kvRe.exec(rest)) !== null) {
433
433
  if (insideQuote(m.index)) continue; // key-looking text inside the target's quotes — leave it
434
- if (!KNOWN_KEYS.has(m[1]!.toLowerCase())) continue; // unknown key — leave it in the target
435
- kv.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
434
+ const key = m[1]!.toLowerCase();
435
+ if (!KNOWN_KEYS.has(key)) continue; // unknown key — leave it in the target
436
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
437
+ // v0.38.30 audit: only consume known keys with VALID values — junk like
438
+ // unquoted `time=out` used to be stripped from the target while silently
439
+ // falling back to defaults. Invalid values stay in the target prose.
440
+ // `done` always consumes (any use teaches the v0.15.0 removal); `measure`
441
+ // accepts any non-empty command string.
442
+ let valid = true;
443
+ if (key === "direction") valid = value.toLowerCase() === "min" || value.toLowerCase() === "max";
444
+ else if (key === "window") { const n = Number.parseInt(value, 10); valid = Number.isFinite(n) && n > 0; }
445
+ else if (key === "max") { const n = Number.parseInt(value, 10); valid = Number.isFinite(n) && n >= 0; }
446
+ else if (key === "branch" || key === "force") valid = ["1", "true", "yes", "0", "false", "no"].includes(value.toLowerCase());
447
+ else if (key === "time" || key === "cadence") { const n = Number.parseFloat(value); valid = Number.isFinite(n) && n > 0; }
448
+ else if (key === "tokens" || key === "toolsamerepeat") { const n = Number.parseInt(value, 10); valid = Number.isInteger(n) && n >= 0; }
449
+ if (!valid) continue; // junk value — leave the span in the target
450
+ kv.set(key, value);
436
451
  spans.push([m.index, m.index + m[0].length]);
437
452
  }
438
453
  // Remove kv spans from the target text.
@@ -601,7 +616,9 @@ export function topOpenAuditFinding(cwd: string): string | null {
601
616
  const p = join(piGlaDir(cwd), "audit-loop/findings.md");
602
617
  if (!existsSync(p)) return null;
603
618
  const line = readFileSync(p, "utf-8").split("\n").find((l) => /^- \[[ \t]+\]/.test(l));
604
- return line ? line.replace(/^- \[ \]\s*/, "").trim().slice(0, 120) : null;
619
+ // v0.38.30 audit: strip with the same [ \t]+ class as the matcher
620
+ // (an aligned `- [ ]` box used to leak markup into the reprieve note).
621
+ return line ? line.replace(/^- \[[ \t]+\]\s*/, "").trim().slice(0, 120) : null;
605
622
  } catch {
606
623
  return null;
607
624
  }
@@ -227,6 +227,7 @@ import {
227
227
  type ProjectRollup,
228
228
  } from "../goal-loop-stats.js";
229
229
  import { releaseAuditorSurface, suppressAuditorSurfaceAfterColdRestore } from "./goal-auditor-surface.js";
230
+ import { shouldSkipApprovalRenderReplay } from "./goal-session.js";
230
231
  import {
231
232
  cancelDetachedGoalCompletionAuditor,
232
233
  newDetachedAuditJobAttemptId,
@@ -781,7 +782,10 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
781
782
  if (refuseForeignCommand(ctx)) return Promise.resolve();
782
783
  // v0.38.25: live contact — replay any approval render that landed
783
784
  // with no live turn (persist-first, never silent).
784
- replayUndeliveredApprovalRenders(ctx);
785
+ // v0.38.30 audit: skip on stale/worker handles — the wrappers run
786
+ // before the inner stale fence, and a superseded session used to mark
787
+ // delivery into a dead session (stale-allowed /loop status included).
788
+ if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
785
789
  return cmdGoal(args, ctx);
786
790
  },
787
791
  });
@@ -790,7 +794,8 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
790
794
  if (refuseForeignCommand(ctx)) return Promise.resolve();
791
795
  // v0.38.25: live contact — replay any approval render that landed
792
796
  // with no live turn (persist-first, never silent).
793
- replayUndeliveredApprovalRenders(ctx);
797
+ // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
798
+ if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
794
799
  return cmdSettings(args, ctx);
795
800
  };
796
801
  pi.registerCommand("glla", {
@@ -821,7 +826,8 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
821
826
  if (refuseForeignCommand(ctx)) return Promise.resolve();
822
827
  // v0.38.25: live contact — replay any approval render that landed
823
828
  // with no live turn (persist-first, never silent).
824
- replayUndeliveredApprovalRenders(ctx);
829
+ // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
830
+ if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
825
831
  return cmdReview(args, ctx);
826
832
  },
827
833
  });
@@ -850,7 +856,8 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
850
856
  if (refuseForeignCommand(ctx)) return Promise.resolve();
851
857
  // v0.38.25: live contact — replay any approval render that landed
852
858
  // with no live turn (persist-first, never silent).
853
- replayUndeliveredApprovalRenders(ctx);
859
+ // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
860
+ if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
854
861
  return cmdList(args, ctx);
855
862
  },
856
863
  });
@@ -875,7 +882,8 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
875
882
  if (refuseForeignCommand(ctx)) return Promise.resolve();
876
883
  // v0.38.25: live contact — replay any approval render that landed
877
884
  // with no live turn (persist-first, never silent).
878
- replayUndeliveredApprovalRenders(ctx);
885
+ // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
886
+ if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
879
887
  return cmdLoop(args, ctx);
880
888
  },
881
889
  });
@@ -1315,6 +1315,38 @@ function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
1315
1315
  return true;
1316
1316
  }
1317
1317
 
1318
+ /** v0.38.30 audit: non-notifying stale probe for the approval-render replay.
1319
+ * The command wrappers replay undelivered renders on every live contact, but
1320
+ * they run BEFORE the inner warnIfStaleAtEntry fence — a superseded session
1321
+ * running stale-allowed `/loop status` (or any refused command) used to
1322
+ * rewrite pending-approval-renders.json + ledger and notify into a dead
1323
+ * session, marking delivery the user never saw. Workers are never live
1324
+ * contact either. Passive flags only (no absorb side effects); fail closed. */
1325
+ export function shouldSkipApprovalRenderReplay(ctx: ExtensionContext): boolean {
1326
+ try {
1327
+ if (isWorkerSessionCtx(ctx)) return true;
1328
+ } catch {
1329
+ return true;
1330
+ }
1331
+ try {
1332
+ if (processOwnerDeniedCwd === ctx.cwd) return true;
1333
+ } catch {
1334
+ return true;
1335
+ }
1336
+ if (sessionHandoffPending) return true;
1337
+ try {
1338
+ if (Date.now() < sessionReplacementUntil) return true;
1339
+ } catch {
1340
+ return true;
1341
+ }
1342
+ try {
1343
+ if (probeExtensionApiStale()) return true;
1344
+ } catch {
1345
+ return true;
1346
+ }
1347
+ return false;
1348
+ }
1349
+
1318
1350
  /** v0.28.12: draft-class confirm with the auto-accept escape hatch SURFACED.
1319
1351
  * The polis incident: a user sat through a 14-item batch Confirm having
1320
1352
  * already reviewed every item during drafting, never knowing /glla
@@ -23,6 +23,7 @@ import * as path from "node:path";
23
23
 
24
24
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
25
25
  import { truncateToWidth } from "@earendil-works/pi-tui";
26
+ import { truncateCells } from "../goal-loop-display.js";
26
27
  import { Type } from "typebox";
27
28
 
28
29
  // v0.34.109 (decomposition step 1): the state singleton and the persistence
@@ -742,7 +743,9 @@ async function promptSettingsMenu(
742
743
  // Audit 2026-09-06: bound the flat option strings — full VALUE +
743
744
  // DESCRIPTION previously rendered unbounded. The resolution prefix
744
745
  // (`[section] label —`) sits at the head, so tail truncation is safe.
745
- const flat = rows.map((r) => truncateToWidth(`[${r.section}] ${r.label} — ${r.valueText} [${r.sourceText.replace(/^\[|\]$/g, "")}] — ${r.description}`, 120, "…"));
746
+ // v0.38.30 audit: headless select values are plain-text — use the
747
+ // ANSI-free truncator (truncateToWidth stays for the painted TUI table).
748
+ const flat = rows.map((r) => truncateCells(`[${r.section}] ${r.label} — ${r.valueText} [${r.sourceText.replace(/^\[|\]$/g, "")}] — ${r.description}`, 120));
746
749
  flat.push("Done");
747
750
  const v = await ctx.ui.select(title, flat);
748
751
  if (!v || v === "Done") return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.29",
3
+ "version": "0.38.31",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",