pi-goal-list-loop-audit 0.38.28 → 0.38.30

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.30 — fresh audit pass: nine low-severity fixes (2026-09-08)
4
+
5
+ ### Fixed
6
+ - 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.
7
+ - `countDone` counts all descendants of closed parents, matching the recursive `countTotal` (a closed parent with nested grandchildren no longer under-reads done).
8
+ - 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`.
9
+ - 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.
10
+ - The display-only objective projection additionally strips `#` headers, `>` quotes, `[label](url)` links, and `*em*` emphasis. Stored objectives unchanged.
11
+ - 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.
12
+ - 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.
13
+ - `topOpenAuditFinding` strips aligned/tabbed boxes with the same `[ \t]+` class as its matcher, so the reprieve note never carries checkbox markup.
14
+ - `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).
15
+ - Regression coverage in `tests/audit-2026-09-08.test.ts` (11 pins); `tsc` clean; focused display/loop-forever/approval suites green.
16
+
17
+ ## 0.38.29 — compact active-card recovery and judgment details (2026-09-08)
18
+
19
+ ### Fixed
20
+ - The active goal card now renders main-model recovery as one compact, truthful row instead of repeating the full chain, current model, pending switch, attempts, and skip details. Full recovery diagnostics remain available through `/goal status`; the status footer no longer duplicates that report.
21
+ - Durable-vs-defer plaques cap their glance-card prose while preserving ordering, recommendation, and selected-choice facts. The final judgment row closes the tree cleanly.
22
+ - Markdown emphasis/code wrappers are removed from the display-only objective projection, so `**objective**` and backtick-wrapped commands no longer add visual noise. Stored objectives remain unchanged.
23
+ - Added regression coverage for compact recovery, duplicate suppression, Markdown cleanup, and the closed judgment tail.
24
+
3
25
  ## 0.38.28 — close redundant provenance rows in the active goal card (2026-09-08)
4
26
 
5
27
  ### 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.28 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.30 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
@@ -28,7 +28,7 @@ For shipped docs, the relevant entry points are:
28
28
  packed-artifact release verification, and the 2026-09-07 display/lifecycle/
29
29
  settings audit pass (abort-latch send guards, ownership compare-and-swap,
30
30
  auditor inherit/clear parity) and the v0.38.25 post-objective summary
31
- (canonical approval render, persist + replay, audit-goal counts line), the v0.38.27 selective port of PRs #45/#46 (`/loop pause` soft-hold, widget subtask count), and the v0.38.28 provenance-row closure fix — see CHANGELOG.md for the full trail.
31
+ (canonical approval render, persist + replay, audit-goal counts line), the v0.38.27 selective port of PRs #45/#46 (`/loop pause` soft-hold, widget subtask count), the v0.38.28 provenance-row closure fix, and the v0.38.29 compact active-card recovery/judgment projection — see CHANGELOG.md for the full trail.
32
32
  - `../README.md` — what the plugin is, install, quickstart, and the
33
33
  architectural guarantee (drafting + confirm + detached auditor).
34
34
  - `../INSTALL.md` — source install / local development setup; the recommended
@@ -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
 
@@ -87,6 +87,24 @@ function displayPauseReason(reason: string): string {
87
87
  return compactDisplayText(sanitizeProviderDisplayText(reason));
88
88
  }
89
89
 
90
+ /** Objectives are stored verbatim, but the glance card is a plain-text
91
+ * surface. Remove the common Markdown emphasis/code wrappers that otherwise
92
+ * turn a long objective into noisy `**...**` and backtick litter. This is a
93
+ * display-only projection; prompts, archives, and state keep the original. */
94
+ function displayObjective(objective: string): string {
95
+ return compactDisplayText(objective)
96
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
97
+ .replace(/__([^_]+)__/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");
106
+ }
107
+
90
108
  /** v0.33.1: painted strings measure by their terminal-cell width. */
91
109
  function visibleLen(s: string): number {
92
110
  return tuiVisibleWidth(s);
@@ -250,7 +268,11 @@ export const WORKER_TEXT_SPACER = "\u00A0";
250
268
 
251
269
  function budgetFor(width: number | undefined, prefixCols: number, floor: number): number {
252
270
  if (!width || width <= 0) return floor;
253
- 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);
254
276
  }
255
277
 
256
278
  function uniqueModelRefs(refs: readonly string[] | undefined): string[] {
@@ -280,6 +302,55 @@ function modelSourceLabel(source: string): string {
280
302
  return source.trim();
281
303
  }
282
304
 
305
+ function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, configuredBackups: string[] = [], now = Date.now(), width?: number): string | undefined {
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).
309
+ const primary = recovery.primary.trim();
310
+ const current = (recovery.active ?? primary).trim() || primary;
311
+ const backups = uniqueModelRefs(configuredBackups).slice(0, 10);
312
+ const chain = [primary, ...backups];
313
+ const currentIndex = chain.findIndex((ref) => ref.toLowerCase() === current.toLowerCase());
314
+ const selected = currentIndex === 0
315
+ ? "primary"
316
+ : currentIndex > 0
317
+ ? `backup ${currentIndex}/${backups.length}`
318
+ : "fallback";
319
+ const retryMs = recovery.retryAt ? Date.parse(recovery.retryAt) - now : Number.NaN;
320
+ const probeMs = recovery.primaryProbeAt ? Date.parse(recovery.primaryProbeAt) - now : Number.NaN;
321
+ const phase = recovery.manualResumeRequired
322
+ ? "manual hold"
323
+ : recovery.pendingModelSwitch
324
+ ? `switching → ${truncate(recovery.pendingModelSwitch, budgetFor(width, 20, 48))}`
325
+ : Number.isFinite(retryMs)
326
+ ? retryMs <= 0 ? "retrying now" : `retrying in ${fmtElapsed(retryMs)}`
327
+ : recovery.primaryProbeInFlight
328
+ ? "primary probe pending"
329
+ : Number.isFinite(probeMs)
330
+ ? probeMs <= 0 ? "probing primary now" : `primary probe in ${fmtElapsed(probeMs)}`
331
+ : `${selected} selected`;
332
+ const attempted = recovery.attempted?.length ?? 0;
333
+ const skipped = recovery.skipped?.length ?? 0;
334
+ const hasEpisode = !!recovery.manualResumeRequired
335
+ || !!recovery.pendingModelSwitch
336
+ || Number.isFinite(retryMs)
337
+ || Number.isFinite(probeMs)
338
+ || !!recovery.primaryProbeInFlight
339
+ || recovery.attempts > 0
340
+ || attempted > 1
341
+ || skipped > 0
342
+ || currentIndex !== 0;
343
+ // A recovery object can briefly survive the successful primary selection.
344
+ // Do not label that normal state as an incident, but keep the selected model
345
+ // on the card when no separate provenance row is available.
346
+ if (!hasEpisode) return `model: ${truncate(current, budgetFor(width, 12, 56))} · primary`;
347
+ const history = [
348
+ recovery.attempts > 0 ? `attempts ${recovery.attempts}` : "",
349
+ skipped > 0 ? `skipped ${skipped}` : "",
350
+ ].filter(Boolean);
351
+ return `recovery: ${phase} · ${selected} ${truncate(current, budgetFor(width, 20, 48))}${history.length > 0 ? ` · ${history.join(" · ")}` : ""}`;
352
+ }
353
+
283
354
  function modelProvenanceLines(provenance: ModelProvenanceDisplay | undefined, width?: number): string[] {
284
355
  if (!provenance) return [];
285
356
  const budget = budgetFor(width, 3, 60);
@@ -328,9 +399,14 @@ export function buildDurableDeferDecisionLines(input: DurableDeferRecommendation
328
399
  // Reserve room for the recommendation marker and the outer tree prefix
329
400
  // so a normal 80-column production widget cannot truncate away the
330
401
  // fact that the durable plaque is the selected action.
402
+ // Recommendation bodies are durable facts, but the glance card is not
403
+ // the command's full report. A wide terminal used to turn these plaques
404
+ // into sentence-length banners and crowd out the actual work state;
405
+ // /goal status retains the complete text.
406
+ const bodyCap = plaque.kind === "durable" ? 96 : 72;
331
407
  const bodyBudget = width && width > 0
332
- ? Math.max(8, width - WIDGET_HORIZONTAL_MARGIN - 3 - visibleLen(prefix) - visibleLen(marker))
333
- : budgetFor(width, 3, 60);
408
+ ? Math.max(8, Math.min(bodyCap, width - WIDGET_HORIZONTAL_MARGIN - 3 - visibleLen(prefix) - visibleLen(marker)))
409
+ : Math.min(bodyCap, budgetFor(width, 3, 60));
334
410
  return `${prefix}${truncate(plaque.body, bodyBudget)}${marker}`;
335
411
  }),
336
412
  `selected: ${recommendation.choice}${recommendation.choice === "inline" ? " (durable fix)" : " (reversible workaround)"}`,
@@ -1163,21 +1239,21 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1163
1239
  return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}${pausedStatusSuffix(g, state, extras, now)}${heldSuffix}`;
1164
1240
  }
1165
1241
  if (g.status === "active") {
1166
- const recoverySummary = formatMainModelRecoveryStatus(state.mainModelRecovery, extras?.mainModelFallbacks);
1167
- const withRecovery = (value: string): string => recoverySummary.length > 0
1168
- ? `${value} · ${recoverySummary.join(" · ")}`
1169
- : value;
1242
+ // The footer is a glance/liveness surface. Recovery details belong to the
1243
+ // card (and the full /goal status report), not a second horizontally
1244
+ // concatenated copy here. This keeps exceptions readable without making
1245
+ // a healthy WORKING line look like an incident.
1170
1246
  // v0.28.1 (S1/S2): a stale-handle interrupt keeps the goal ACTIVE.
1171
1247
  // It outranks any older operational note on the same state snapshot.
1172
1248
  if (g.interruptedAt) {
1173
1249
  const label = interruptedForNoStart(g)
1174
1250
  ? "⚠ turn start not observed — automatic retry held"
1175
1251
  : "⚠ interrupted — stale handle · /new (or a fresh session_start) rebinds";
1176
- return withRecovery(`glla: ${paint(theme, "error", label)}${heldSuffix}`);
1252
+ return `glla: ${paint(theme, "error", label)}${heldSuffix}`;
1177
1253
  }
1178
1254
  const attention = activeAttention(g);
1179
1255
  if (attention) {
1180
- return withRecovery(`glla: ${paint(theme, attention.color, `⚠ ${attention.label}`)}${heldSuffix}`);
1256
+ return `glla: ${paint(theme, attention.color, `⚠ ${attention.label}`)}${heldSuffix}`;
1181
1257
  }
1182
1258
  const activity = goalDisplayActivity(g, extras, now);
1183
1259
  // v0.34.97: while the post-compaction grace window is open, surface
@@ -1186,10 +1262,10 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1186
1262
  const compactAgeMs = state.lastCompactionAt ? now - state.lastCompactionAt : Number.POSITIVE_INFINITY;
1187
1263
  const compacting = Number.isFinite(compactAgeMs) && compactAgeMs >= 0 && compactAgeMs < 180_000; // COMPACTION_GRACE_MS = 3 min
1188
1264
  if (compacting) {
1189
- return withRecovery(`glla: ${paint(theme, "warning", `⏳ compacting… (${fmtElapsed(compactAgeMs)} ago)`)}${heldSuffix}`);
1265
+ return `glla: ${paint(theme, "warning", `⏳ compacting… (${fmtElapsed(compactAgeMs)} ago)`)}${heldSuffix}`;
1190
1266
  }
1191
1267
  if (activity === "awaiting-first-turn") {
1192
- return withRecovery(`glla: ${activityStateBadge("AWAITING FIRST TURN", theme, "warning")}${heldSuffix}`);
1268
+ return `glla: ${activityStateBadge("AWAITING FIRST TURN", theme, "warning")}${heldSuffix}`;
1193
1269
  }
1194
1270
  if (activity === "idle") {
1195
1271
  // Audit 2026-09-07 (DECIDED: head owns liveness): the status keeps
@@ -1200,7 +1276,7 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1200
1276
  goalTotalText(g, now),
1201
1277
  (state.list?.length ?? 0) > 0 ? `${state.list!.length} queued` : "",
1202
1278
  ].filter(Boolean);
1203
- return withRecovery(`glla: ${activityStateBadge("IDLE", theme, "warning")}${idleDetails.length > 0 ? ` ${idleDetails.join(" · ")}` : ""}${heldSuffix}`);
1279
+ return `glla: ${activityStateBadge("IDLE", theme, "warning")}${idleDetails.length > 0 ? ` ${idleDetails.join(" · ")}` : ""}${heldSuffix}`;
1204
1280
  }
1205
1281
  // v0.34.39: distinguish durable state from evidence of a live host turn.
1206
1282
  // A spinner is reserved for recent stream/tool evidence; BUSY without
@@ -1212,7 +1288,7 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1212
1288
  g.taskList ? `${countDone(g)}/${countTotal(g)} tasks` : "",
1213
1289
  (state.list?.length ?? 0) > 0 ? `${state.list!.length} queued` : "",
1214
1290
  ].filter(Boolean);
1215
- return withRecovery(`glla: ${activityStateBadge("BUSY", theme, "warning")}${busyDetails.length > 0 ? ` ${busyDetails.join(" · ")}` : ""}${heldSuffix}`);
1291
+ return `glla: ${activityStateBadge("BUSY", theme, "warning")}${busyDetails.length > 0 ? ` ${busyDetails.join(" · ")}` : ""}${heldSuffix}`;
1216
1292
  }
1217
1293
  // v0.34.16: a fresh session_start owns the handoff. A cold boot still
1218
1294
  // follows the global autoResume setting, so the widget names the actual
@@ -1258,7 +1334,7 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1258
1334
  monitoring ? "next check" : "",
1259
1335
  n > 0 ? `${n} queued` : "",
1260
1336
  ].filter(Boolean);
1261
- return withRecovery(`glla: ${marker}${details.length > 0 ? ` ${details.join(" · ")}` : ""}${recoverySuffix}${heldSuffix}`);
1337
+ return `glla: ${marker}${details.length > 0 ? ` ${details.join(" · ")}` : ""}${recoverySuffix}${heldSuffix}`;
1262
1338
  }
1263
1339
  // v0.34.65: a terminal goal names its outcome + wall duration instead of
1264
1340
  // clearing the segment (note.md 2026-08-07: "this seems weak for a complete
@@ -1277,6 +1353,12 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1277
1353
 
1278
1354
  function countDone(g: Goal): number {
1279
1355
  let n = 0;
1356
+ const countAllDescendants = (ts?: any[]): number => {
1357
+ if (!ts) return 0;
1358
+ let c = 0;
1359
+ for (const s of ts) c += 1 + countAllDescendants(s.subtasks);
1360
+ return c;
1361
+ };
1280
1362
  const walk = (ts: Array<{ status: string; subtasks?: any[] }>) => {
1281
1363
  for (const t of ts) {
1282
1364
  if (t.status === "complete") {
@@ -1284,7 +1366,9 @@ function countDone(g: Goal): number {
1284
1366
  // (field: 2 closed parents with pending subtasks displayed "2/24"
1285
1367
  // for 6 real tasks). Mid-flight parents still count done subtasks
1286
1368
  // independently via the walk below.
1287
- n += 1 + (t.subtasks?.length ?? 0);
1369
+ // v0.38.30 audit: count ALL descendants, not just direct children
1370
+ // (a closed parent with nested grandchildren under-read done).
1371
+ n += 1 + countAllDescendants(t.subtasks);
1288
1372
  } else if (t.subtasks) {
1289
1373
  walk(t.subtasks);
1290
1374
  }
@@ -1352,6 +1436,16 @@ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | nu
1352
1436
  withAgents = orphanHead ? [orphanHead, ...agentLines] : [...agentLines];
1353
1437
  }
1354
1438
  }
1439
+ // Keep the final tree row visually closed when the compact judgment block
1440
+ // ends without a queue footer. Without this, its last `├─` reads like a
1441
+ // missing continuation and makes an already dense card feel unfinished.
1442
+ if (withAgents && inner && detailedAgents.length === 0 && extras?.durableDeferRecommendation && withAgents.length > 0) {
1443
+ const tailIndex = withAgents.length - 1;
1444
+ const tail = withAgents[tailIndex]!;
1445
+ if (tail.startsWith("├─ ") || tail.startsWith("│ ")) {
1446
+ withAgents[tailIndex] = `└─ ${tail.slice(3)}`;
1447
+ }
1448
+ }
1355
1449
  // v0.28.6 (E1): a persistence failure outranks everything — first line,
1356
1450
  // on every render, until a write lands again.
1357
1451
  let lines: string[] | undefined = withAgents;
@@ -1373,7 +1467,7 @@ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | nu
1373
1467
  function waitingListStatus(state: State, _now: number, theme?: DisplayTheme, width?: number): string {
1374
1468
  const queue = state.list ?? [];
1375
1469
  const head = queue[0];
1376
- const objective = head?.objective?.trim() ? sanitizeDisplayText(head.objective) : "unnamed queued item";
1470
+ const objective = head?.objective?.trim() ? displayObjective(head.objective) : "unnamed queued item";
1377
1471
  const hold = typeof state.loadHoldAt === "number" ? " · held on restore" : "";
1378
1472
  // Audit 2026-09-06: the width budget truncates the END of the status
1379
1473
  // line — the `/glla resume` action must survive, so the OBJECTIVE takes
@@ -1390,7 +1484,7 @@ function waitingListStatus(state: State, _now: number, theme?: DisplayTheme, wid
1390
1484
  function waitingListLines(state: State, theme?: DisplayTheme, width?: number): string[] {
1391
1485
  const queue = state.list ?? [];
1392
1486
  const head = queue[0];
1393
- const objective = head?.objective?.trim() ? sanitizeDisplayText(head.objective) : "unnamed queued item";
1487
+ const objective = head?.objective?.trim() ? displayObjective(head.objective) : "unnamed queued item";
1394
1488
  const objectiveBudget = budgetFor(width, visibleLen("├─ up next: "), 56);
1395
1489
  const action = typeof state.loadHoldAt === "number"
1396
1490
  ? "held on restore · /glla resume starts the queue · /list next skips/chooses"
@@ -1555,7 +1649,7 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1555
1649
  const headIcon = headLive
1556
1650
  ? paint(theme, headLive.band === "fresh" ? "success" : headLive.band === "aging" ? "warning" : "error", headLive.breath)
1557
1651
  : icon;
1558
- const head = `${headIcon} ${truncate(g.objective.replace(/\s+/g, " "), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
1652
+ const head = `${headIcon} ${truncate(displayObjective(g.objective), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
1559
1653
  const lines = [head];
1560
1654
  // v0.38.8: durable verdict tally as a first-class card row — the widget
1561
1655
  // is the glance surface, and stored verdicts are the progress evidence
@@ -1572,20 +1666,28 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1572
1666
  lines.push(`├─ ${paint(theme, "dim", `Recovery: ${repairStep}`)}`);
1573
1667
  }
1574
1668
  // A model switch crosses an asynchronous boundary while the goal remains
1575
- // active. Keep the complete durable recovery projection on the widget too;
1576
- // otherwise the head says only "active" while pending/attempted/skipped
1577
- // candidates are invisible until the goal is parked.
1578
- if (g.status === "active" && state.mainModelRecovery) {
1579
- const recoverySummary = formatMainModelRecoveryStatus(state.mainModelRecovery, extras?.mainModelFallbacks);
1580
- recoverySummary.forEach((line, i) => {
1581
- lines.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, "dim", line)}`);
1582
- });
1669
+ // active. Keep that fact visible, but do not print the full recovery report
1670
+ // into the glance card; it made the card taller than the space below the
1671
+ // editor. `/goal status` remains the detailed recovery surface.
1672
+ // v0.38.30 audit: goal card owns goal-kind episodes only (a kind:loop
1673
+ // episode beside an active goal used to misattribute loop recovery here
1674
+ // and hide the goal's own provenance; loop kinds keep the parked /
1675
+ // standalone cards). Fall back to provenance when the compact line is
1676
+ // undefined (blank-primary edge left no model fact at all).
1677
+ const goalRecovery = state.mainModelRecovery?.kind === "goal" ? state.mainModelRecovery : undefined;
1678
+ let recoveryLine: string | undefined;
1679
+ if (g.status === "active" && goalRecovery) {
1680
+ recoveryLine = compactMainModelRecoveryLine(goalRecovery, extras?.mainModelFallbacks, now, width);
1681
+ if (recoveryLine) lines.push(`├─ ${paint(theme, "dim", recoveryLine)}`);
1583
1682
  }
1584
1683
  // Model provenance is a card fact, not a notification: keep it visible
1585
- // across active, interrupted, auditing, and paused branches. In
1586
- // particular, never replace a configured forbidden ref with "none" — the
1587
- // skipped line explains why it did not handle the turn.
1588
- const provenance = modelProvenanceLines(extras?.modelProvenance, width);
1684
+ // across active, interrupted, auditing, and paused branches. During an
1685
+ // active recovery the compact recovery row owns the model fact, so do not
1686
+ // print a second primary/current row beside it. The full chain and skipped
1687
+ // refs remain available through `/goal status`.
1688
+ const provenance = recoveryLine
1689
+ ? []
1690
+ : modelProvenanceLines(extras?.modelProvenance, width);
1589
1691
  const provenanceStart = lines.length;
1590
1692
  provenance.forEach((line, i) => {
1591
1693
  lines.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, "dim", line)}`);
@@ -1915,12 +2017,22 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1915
2017
  // no recent action, pending task, or queue footer. Close that block with a
1916
2018
  // terminator instead of leaving `├─`/`│` suggesting missing rows.
1917
2019
  const provenanceEnd = provenanceStart + provenance.length;
1918
- if (provenance.length > 0 && provenanceEnd === lines.length) {
2020
+ const detailedAgentRows = extras?.agents?.lines ?? (extras?.agents?.line ? [extras.agents.line] : []);
2021
+ if (provenance.length > 0 && detailedAgentRows.length === 0 && provenanceEnd === lines.length) {
1919
2022
  const last = lines[provenanceEnd - 1]!;
1920
2023
  if (last.startsWith("├─ ") || last.startsWith("│ ")) {
1921
2024
  lines[provenanceEnd - 1] = `└─ ${last.slice(3)}`;
1922
2025
  }
1923
2026
  }
2027
+ // A compact recovery/model row can be the final detail when no other card
2028
+ // fact follows it. Close that one row without changing the established
2029
+ // open-row shape for recent actions or worker details.
2030
+ if (recoveryLine && detailedAgentRows.length === 0 && lines.length > 0) {
2031
+ const last = lines[lines.length - 1]!;
2032
+ if (last.includes(recoveryLine) && (last.startsWith("├─ ") || last.startsWith("│ "))) {
2033
+ lines[lines.length - 1] = `└─ ${last.slice(3)}`;
2034
+ }
2035
+ }
1924
2036
  return lines;
1925
2037
  }
1926
2038
 
@@ -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.28",
3
+ "version": "0.38.30",
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",