pi-goal-list-loop-audit 0.38.33 → 0.38.35

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.35 — closed card + smart summary (2026-09-08)
4
+
5
+ ### Fixed
6
+
7
+ - **Card no longer reads cut off (field 2026-09-08 220808):** the head objective now cuts at the last clause boundary inside the budget instead of mid-word (`truncateObjective`; character cut remains the fallback); the steady-state `model: primary … · inherited from session` row is dropped — it restated pi's own status line two rows below; and every card closes — the final `├─`/`│` row becomes `└─` whatever built it (active action row, lone orphan worker row). Provenance still renders on any news (pin, fallbacks, skips, failover); `/goal status` keeps the full chain.
8
+ - **Stale card tests updated deliberately, purpose preserved:** the v0.33.0 slim-card `├─` tail, the v0.38.28 lone-row close, the loop-kind test's incidental model assertion, the narrow-width truncation fixture (now on a pinned row), and the worker-before-footer invariant (detail never lands *after* a footer).
9
+
10
+ ### Verification
11
+
12
+ - Full `release:check`: 2037 pass / 2 skip / 0 fail across 204 files; `tsc --noEmit` clean.
13
+ - Evidence: `audit/UI-CARD-CLOSED-2026-09-08.md`.
14
+
15
+ ## 0.38.34 — completion communication: fair replay rotation + stale-test refresh (2026-09-08)
16
+
17
+ ### Fixed
18
+
19
+ - **Replay starvation (review P2, real bug):** `replayUndeliveredApprovalRenders` always took the oldest five pending entries, so five persistently unconfirmed receipts pinned the head forever and a sixth pending summary was never attempted in that session. Attempted-but-still-pending entries now rotate behind the unattempted in-scope tail (stable within groups; delivered/out-of-scope untouched), so every pending render is attempted within `ceil(n/5)` contacts. No entry dropped, confirmation rules unchanged, rotation alone never acknowledges. Pinned by a six-render regression test.
20
+ - **Five stale release-gate assertions refreshed deliberately:** the approval-chat two-detail cap is lifted (Tests + meaningful Unresolved preserved — the filler filter already keeps chat glanceable); the no-audit briefing test now configures the session file and asserts on the visible session entry instead of the removed toast; continuation-payload byte fixtures updated for the completion-communication guidance (+543 bytes/payload, growth stays exactly linear).
21
+
22
+ ### Verification
23
+
24
+ - Full `release:check`: 2034 pass / 2 skip / 0 fail across 204 files; `tsc --noEmit` clean.
25
+
3
26
  ## 0.38.33 — findings.md indent convention normalized (2026-09-08)
4
27
 
5
28
  ### 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.33 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.35 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
@@ -2,22 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { appendLedger, ensureDirs, nowIso, piGlaDir, runPersistStep } from "./goal-loop-core.js";
4
4
 
5
- /**
6
- * Persisted terminal-approval renders (v0.38.25, post-objective summary).
7
- *
8
- * Field failure 2026-09-07: goal `20260907131550-12ddoy` completed with a
9
- * perfect six-label archive record, but the approval chat lines fired into a
10
- * dead context (auditor verdict landed with no live turn) — record perfect,
11
- * delivery silent. The archive keeps facts; this sidecar keeps the RENDER.
12
- *
13
- * Contract: every terminal approval persists its human-sees lines here at
14
- * archive time. Renders persisted from a live turn are marked delivered at
15
- * once; renders persisted while the host is idle (`ctx.isIdle()`) stay
16
- * undelivered until `replayUndeliveredApprovalRenders` delivers them on the
17
- * next live contact (any user-invoked /goal /glla /review /list /loop
18
- * command) and marks them delivered. Late verdicts follow the same path:
19
- * persist first, deliver-or-replay — never silent.
20
- */
5
+ /** Durable terminal-summary outbox. A toast or a live host is not delivery:
6
+ * only a confirmed visible session message acknowledges a render. */
21
7
 
22
8
  export interface PendingApprovalRender {
23
9
  goalId: string;
@@ -83,24 +69,28 @@ function readRenders(cwd: string): PendingApprovalRender[] {
83
69
  function writeRenders(cwd: string, renders: PendingApprovalRender[]): boolean {
84
70
  const landed = runPersistStep("persistApprovalRender", () => {
85
71
  ensureDirs(cwd);
86
- fs.writeFileSync(approvalRenderStorePath(cwd), JSON.stringify(renders, null, 2), "utf-8");
72
+ const file = approvalRenderStorePath(cwd);
73
+ const tmp = `${file}.tmp`;
74
+ try {
75
+ fs.writeFileSync(tmp, JSON.stringify(renders, null, 2), "utf-8");
76
+ fs.renameSync(tmp, file);
77
+ } finally {
78
+ try { fs.unlinkSync(tmp); } catch { /* renamed or write failed */ }
79
+ }
87
80
  return true;
88
81
  });
89
82
  return landed === true;
90
83
  }
91
84
 
92
- /** Persist the rendered human-sees lines at archive time. `delivered`
93
- * must be true only when the render went out on a provably live turn
94
- * (callers pass `!ctx.isIdle()`); idle-persisted renders stay queued for
95
- * replay. Never throws into orchestrator handlers. */
85
+ /** Enqueue before attempting delivery. Only replay can acknowledge it. */
96
86
  export function persistApprovalRender(cwd: string, render: {
97
87
  goalId: string;
98
88
  objective: string;
99
89
  chatLines: string[];
100
- delivered: boolean;
101
90
  }): boolean {
102
91
  const at = nowIso();
103
92
  const existing = readRenders(cwd);
93
+ if (existing.some((entry) => entry.goalId === render.goalId)) return true;
104
94
  const entry: PendingApprovalRender = {
105
95
  goalId: render.goalId,
106
96
  // v0.38.30 audit: code-point truncation (char slice split surrogate
@@ -108,38 +98,42 @@ export function persistApprovalRender(cwd: string, render: {
108
98
  objective: [...render.objective].slice(0, 300).join(""),
109
99
  chatLines: render.chatLines.slice(0, MAX_RENDER_CHAT_LINES).map((line) => [...line].slice(0, MAX_RENDER_LINE_CHARS).join("")),
110
100
  createdAt: at,
111
- ...(render.delivered ? { deliveredAt: at } : {}),
112
101
  };
113
102
  // The cap trims DELIVERED history only: undelivered renders are never
114
103
  // dropped (each is ~1KB and any user command replays them, so the
115
104
  // undelivered tail is self-draining in practice).
116
105
  const undelivered = existing.filter((e) => !e.deliveredAt);
117
- const delivered = [...existing.filter((e) => e.deliveredAt), ...(render.delivered ? [entry] : [])];
118
- const deliveredBudget = Math.max(0, MAX_STORED_RENDERS - undelivered.length - (render.delivered ? 0 : 1));
119
- const next = [...undelivered, ...(render.delivered ? [] : [entry]), ...delivered.slice(-deliveredBudget)];
106
+ const delivered = existing.filter((e) => e.deliveredAt);
107
+ const deliveredBudget = Math.max(0, MAX_STORED_RENDERS - undelivered.length - 1);
108
+ const next = [...undelivered, entry, ...(deliveredBudget > 0 ? delivered.slice(-deliveredBudget) : [])];
120
109
  if (!writeRenders(cwd, next)) return false;
121
110
  appendLedger(cwd, "terminal_approval_render_persisted", {
122
111
  goalId: render.goalId,
123
- delivered: render.delivered,
112
+ delivered: false,
124
113
  lines: render.chatLines.length,
125
114
  });
126
115
  return true;
127
116
  }
128
117
 
129
- /** Deliver every undelivered render on a live contact, oldest first,
130
- * fire-once fenced via deliveredAt. Callers are user-invoked command
131
- * handlers — live by construction. Returns the replayed count; never
132
- * throws. */
133
- export function replayUndeliveredApprovalRenders(ctx: { cwd: string; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } }): number {
118
+ /** Replay oldest first through the ownership-fenced sender, with bounded
119
+ * fair rotation. A queued, refused, or unconfirmed send stays pending.
120
+ * Session identity deduplicates retries even when writing deliveredAt
121
+ * fails after the message landed. */
122
+ export function replayUndeliveredApprovalRenders(
123
+ ctx: { cwd: string },
124
+ deliver: (entry: PendingApprovalRender) => boolean = () => false,
125
+ onlyGoalId?: string,
126
+ ): number {
134
127
  const renders = readRenders(ctx.cwd);
135
128
  if (renders.length === 0) return 0;
136
- const pending = renders.filter((e) => !e.deliveredAt).slice(0, MAX_REPLAY_PER_CONTACT);
129
+ const inScope = (e: PendingApprovalRender) => !e.deliveredAt && (!onlyGoalId || e.goalId === onlyGoalId);
130
+ const pending = renders.filter(inScope).slice(0, MAX_REPLAY_PER_CONTACT);
137
131
  if (pending.length === 0) return 0;
138
132
  const at = nowIso();
139
133
  let replayed = 0;
140
134
  for (const entry of pending) {
141
135
  try {
142
- ctx.ui.notify(entry.chatLines.join("\n"), "info");
136
+ if (!deliver(entry)) continue;
143
137
  entry.deliveredAt = at;
144
138
  replayed += 1;
145
139
  appendLedger(ctx.cwd, "terminal_approval_render_replayed", {
@@ -152,22 +146,27 @@ export function replayUndeliveredApprovalRenders(ctx: { cwd: string; ui: { notif
152
146
  break;
153
147
  }
154
148
  }
155
- if (replayed > 0) writeRenders(ctx.cwd, renders);
156
- return replayed;
157
- }
158
-
159
- /** Liveness probe for delivery marking. Unknown/throwing ⇒ idle ⇒
160
- * undelivered ⇒ replayed on the next live contact. The safe direction is
161
- * never-silent: a duplicate render on the next command beats a lost one. */
162
- export function isApprovalContextIdle(ctx: { isIdle?: () => boolean }): boolean {
163
- try {
164
- return ctx.isIdle?.() ?? true;
165
- } catch {
166
- return true;
149
+ // Fair rotation: the window above always takes the oldest pending
150
+ // entries, so persistently unconfirmed receipts used to pin the head
151
+ // forever and a later render was never attempted in that session.
152
+ // Attempted-but-still-pending entries rotate behind the unattempted
153
+ // in-scope tail — stable within each group, delivered and out-of-scope
154
+ // entries untouched — so every pending render is attempted within
155
+ // ceil(n/MAX_REPLAY_PER_CONTACT) contacts. No entry is dropped and the
156
+ // confirmation rules are unchanged; rotation alone never acknowledges.
157
+ const stalled = pending.filter((e) => !e.deliveredAt);
158
+ let rotated = false;
159
+ if (stalled.length > 0 && renders.filter(inScope).length > pending.length) {
160
+ const moved = new Set(stalled);
161
+ const rest = renders.filter((e) => !moved.has(e));
162
+ let insertAt = rest.length;
163
+ rest.forEach((e, i) => { if (inScope(e)) insertAt = i + 1; });
164
+ rest.splice(insertAt, 0, ...stalled);
165
+ renders.length = 0;
166
+ renders.push(...rest);
167
+ rotated = true;
167
168
  }
169
+ if (replayed > 0 || rotated) writeRenders(ctx.cwd, renders);
170
+ return replayed;
168
171
  }
169
172
 
170
- /** Test-only reset for store isolation. */
171
- export function __testOnlyResetApprovalRenderStore(): void {
172
- // No module-level cache — isolation is via cwd-scoped files.
173
- }
@@ -169,7 +169,7 @@ export function withoutStaleNext(details: string[] | undefined): string[] {
169
169
  return (details ?? []).filter((d) => !/^\s*Next\s*:/i.test(d));
170
170
  }
171
171
 
172
- /** v0.38.20: the approval chat notify — outcome first, at most two
172
+ /** v0.38.20: the approval chat notify — outcome first, all bounded
173
173
  * informing details (the full record lives in the archive and the
174
174
  * transcript notice), then the approval trailer and the record pointer.
175
175
  * Five 120-char label lines scan as soup, not a summary (field
@@ -185,7 +185,7 @@ export function buildApprovalChatLines(notice: {
185
185
  }): string[] {
186
186
  return [
187
187
  `✓ done — ${notice.outcome}`,
188
- ...withoutStaleNext(notice.details).slice(0, 2),
188
+ ...withoutStaleNext(notice.details),
189
189
  notice.approval,
190
190
  ...(notice.counts ? [notice.counts] : []),
191
191
  notice.record,
@@ -231,7 +231,7 @@ export interface TerminalApprovalRenderInput {
231
231
  }
232
232
 
233
233
  export interface TerminalApprovalRender {
234
- /** The human-sees chat lines: outcome + ≤2 details + approval + counts + record (+ extras). */
234
+ /** The human-sees chat lines: outcome + bounded details + approval + counts + record (+ extras). */
235
235
  chatLines: string[];
236
236
  /** Compact single line for external notifies (pager/sound safe). */
237
237
  recap: string;
@@ -25,6 +25,7 @@
25
25
  // send_rearm_*, queue_stuck_detected, ...).
26
26
  // ============================================================================
27
27
 
28
+ import { deliverTerminalSummary } from "./terminal-summary-delivery.js";
28
29
  import * as fs from "node:fs";
29
30
  import * as path from "node:path";
30
31
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -1144,22 +1145,17 @@ export interface TerminalCompletionNotice {
1144
1145
  goalId: string;
1145
1146
  outcome: string;
1146
1147
  details: string[];
1148
+ /** Canonical visible summary, including record and concrete evidence. */
1149
+ chatLines?: string[];
1147
1150
  /** Audit 2026-09-06: the session generation that produced the verdict.
1148
1151
  * A stale generation (verdict applied after handoff/reload) must not
1149
1152
  * fire a `✓ done` turn into the successor session's unrelated work. */
1150
1153
  generation?: number;
1151
1154
  }
1152
1155
 
1153
- /** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
1154
- * verifier settles asynchronously — the transcript's last word is "the
1155
- * verdict will be applied asynchronously", and a toast is the only
1156
- * closure. A later "how are we looking" then truthfully re-reports the
1157
- * stale transcript as still-waiting even though the goal is archived.
1158
- * This delivers the `✓ done` brief INTO the conversation as a followUp
1159
- * turn so the transcript records the completion. Goal-null-safe (the goal
1160
- * is already archived when this fires), fire-once per goal via a durable
1161
- * ledger fence, and fenced like every other automatic send. Returns true
1162
- * when the notice was dispatched. */
1156
+ /** Deliver a visible, contextual summary without starting an acknowledgement
1157
+ * turn. Confirm the custom session entry, not the void sendMessage return.
1158
+ * The outbox owns retries; session entries fence duplicate delivery. */
1163
1159
  export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: TerminalCompletionNotice): boolean {
1164
1160
  if (supervisorPaused(state)) return false;
1165
1161
  if (mainModelRecoveryActive()) return false;
@@ -1184,27 +1180,12 @@ export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: Term
1184
1180
  });
1185
1181
  return false;
1186
1182
  }
1187
- try {
1188
- const already = readLedgerTail(ctx.cwd, 400, (entry) =>
1189
- entry.type === "terminal_completion_notice_sent" &&
1190
- typeof (entry.value as { goalId?: unknown } | null)?.goalId === "string" &&
1191
- (entry.value as { goalId: string }).goalId === notice.goalId,
1192
- );
1193
- if (already.length > 0) return false;
1194
- } catch {
1195
- return false;
1196
- }
1197
- const content = [
1183
+ const content = (notice.chatLines ?? [
1198
1184
  `✓ done — ${notice.outcome}`,
1199
1185
  ...notice.details,
1200
- "— goal archived; nothing further is owed. Acknowledge briefly; start follow-up work only if asked.",
1201
- ].join("\n");
1186
+ ]).join("\n");
1202
1187
  try {
1203
- flags.extensionApi.sendMessage({
1204
- customType: GOAL_EVENT_ENTRY,
1205
- content,
1206
- display: false,
1207
- }, { triggerTurn: true, deliverAs: "followUp" });
1188
+ if (!deliverTerminalSummary(ctx, flags.extensionApi, GOAL_EVENT_ENTRY, notice.goalId, content)) return false;
1208
1189
  } catch {
1209
1190
  appendLedger(ctx.cwd, "terminal_completion_notice_unsent", { goalId: notice.goalId });
1210
1191
  return false;
@@ -83,6 +83,35 @@ export function truncateCells(s: string, max: number): string {
83
83
  return `${out}…`;
84
84
  }
85
85
 
86
+ /** Clause-aware objective shortening for the card head. A raw character cut
87
+ * ends objectives mid-word ("…restyle the …") which reads as a clipped
88
+ * card rather than a summary (field 2026-09-08 220808). When the text
89
+ * overruns, prefer cutting at the last clause boundary (: ; · — – ( [)
90
+ * inside the budget so the head reads as an intentional summary; fall back
91
+ * to the character cut when no boundary clears the floor. Display-only. */
92
+ export function truncateObjective(s: string, max: number): string {
93
+ const clean = compactDisplayText(s);
94
+ if (tuiVisibleWidth(clean) <= max) return clean;
95
+ if (max <= 1) return truncateCells(clean, max);
96
+ const budget = max - 1;
97
+ const floor = Math.max(16, Math.floor(budget * 0.4));
98
+ let out = "";
99
+ let w = 0;
100
+ let boundaryLen = -1;
101
+ for (const ch of clean) {
102
+ const cw = tuiVisibleWidth(ch);
103
+ if (w + cw > budget) break;
104
+ out += ch;
105
+ w += cw;
106
+ if (/[:;·—–(\[]/.test(ch)) boundaryLen = out.length;
107
+ }
108
+ if (boundaryLen > 0) {
109
+ const cut = [...out].slice(0, boundaryLen).join("").replace(/[:;·—–(\[\s]+$/u, "");
110
+ if (tuiVisibleWidth(cut) >= Math.min(floor, 16)) return `${cut}…`;
111
+ }
112
+ return `${out}…`;
113
+ }
114
+
86
115
  function displayPauseReason(reason: string): string {
87
116
  return compactDisplayText(sanitizeProviderDisplayText(reason));
88
117
  }
@@ -353,26 +382,34 @@ function compactMainModelRecoveryLine(recovery: MainModelRecovery | undefined, c
353
382
 
354
383
  function modelProvenanceLines(provenance: ModelProvenanceDisplay | undefined, width?: number): string[] {
355
384
  if (!provenance) return [];
385
+ const primary = typeof provenance.primary === "string" ? provenance.primary.trim() : "";
386
+ const fallbacks = uniqueModelRefs(provenance.fallbackRefs);
387
+ const skipped = uniqueModelRefs(provenance.skippedForbiddenRefs);
388
+ const handledTurn = typeof provenance.handledTurn === "string" ? provenance.handledTurn.trim() : "";
389
+ const handledTurnNews = !!handledTurn && handledTurn.toLowerCase() !== (primary || "").toLowerCase();
390
+ const handledAudit = typeof provenance.handledAudit === "string" ? provenance.handledAudit.trim() : "";
391
+ // The card sits just above pi's own status line, which already names the
392
+ // session model. A lone `model: primary X · inherited from session` row
393
+ // restates that indicator for zero new information while deepening the
394
+ // tree (field 2026-09-08 220808). Show the block only when it carries
395
+ // news: a pinned selection, fallbacks, skips, or a failover that
396
+ // handled work. `/goal status` keeps the full chain regardless.
397
+ if (primary && provenance.primarySource !== "pinned" && fallbacks.length === 0 && skipped.length === 0 && !handledTurnNews && !handledAudit) return [];
356
398
  const budget = budgetFor(width, 3, 60);
357
399
  const lines: string[] = [];
358
- const primary = typeof provenance.primary === "string" ? provenance.primary.trim() : "";
359
400
  if (primary) {
360
401
  const source = provenance.primarySource === "pinned" ? "pinned" : "inherited from session";
361
402
  lines.push(`model: primary ${truncate(primary, budget)} · ${source}`);
362
403
  }
363
- const fallbacks = uniqueModelRefs(provenance.fallbackRefs);
364
404
  if (fallbacks.length > 0) lines.push(`fallbacks: ${truncate(fallbacks.join(" → "), budget)}`);
365
- const skipped = uniqueModelRefs(provenance.skippedForbiddenRefs);
366
405
  if (skipped.length > 0) lines.push(`skipped forbidden: ${truncate(skipped.join(", "), budget)}`);
367
- const handledTurn = typeof provenance.handledTurn === "string" ? provenance.handledTurn.trim() : "";
368
406
  // A normal turn is handled by the configured primary model. Repeating the
369
407
  // same ref immediately below it adds no information and, when it is the
370
408
  // last row, leaves a misleading continuation glyph in the card. Keep the
371
409
  // row only when a recovery/failover actually handled the turn.
372
- if (handledTurn && handledTurn.toLowerCase() !== primary.toLowerCase()) {
410
+ if (handledTurnNews) {
373
411
  lines.push(`handled turn: ${truncate(handledTurn, budget)}`);
374
412
  }
375
- const handledAudit = typeof provenance.handledAudit === "string" ? provenance.handledAudit.trim() : "";
376
413
  if (handledAudit) {
377
414
  const source = provenance.handledAuditSource?.trim() ? modelSourceLabel(provenance.handledAuditSource) : "";
378
415
  const via = source ? ` · via ${truncate(source, 24)}` : "";
@@ -1450,15 +1487,18 @@ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | nu
1450
1487
  withAgents = orphanHead ? [orphanHead, ...agentLines] : [...agentLines];
1451
1488
  }
1452
1489
  }
1453
- // Keep the final tree row visually closed when the compact judgment block
1454
- // ends without a queue footer. Without this, its last `├─` reads like a
1455
- // missing continuation and makes an already dense card feel unfinished.
1456
- if (withAgents && inner && detailedAgents.length === 0 && extras?.durableDeferRecommendation && withAgents.length > 0) {
1490
+ // The card always reads as a finished block: a final `├─`/`│` row
1491
+ // promises continuation rows that never come (field 2026-09-08 220808 —
1492
+ // the active card ended on its action row, reading as cut off). Close
1493
+ // the tail whatever built it; rows that already close (`└─`, the head,
1494
+ // the spacer) are untouched. Tree prefixes are literal — paint wraps
1495
+ // content, never the glyph — so this holds themed too.
1496
+ if (withAgents && withAgents.length > 0) {
1457
1497
  const tailIndex = withAgents.length - 1;
1458
1498
  const tail = withAgents[tailIndex]!;
1459
- if (tail.startsWith("├─ ") || tail.startsWith("│ ")) {
1460
- withAgents[tailIndex] = `└─ ${tail.slice(3)}`;
1461
- }
1499
+ const m = tail.match(/^(├─ |│ |│ )/);
1500
+ const prefix = m?.[1] ?? "";
1501
+ if (prefix) withAgents[tailIndex] = `└─ ${tail.slice(prefix.length)}`;
1462
1502
  }
1463
1503
  // v0.28.6 (E1): a persistence failure outranks everything — first line,
1464
1504
  // on every render, until a write lands again.
@@ -1663,7 +1703,7 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
1663
1703
  const headIcon = headLive
1664
1704
  ? paint(theme, headLive.band === "fresh" ? "success" : headLive.band === "aging" ? "warning" : "error", headLive.breath)
1665
1705
  : icon;
1666
- const head = `${headIcon} ${truncate(displayObjective(g.objective), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
1706
+ const head = `${headIcon} ${truncateObjective(displayObjective(g.objective), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
1667
1707
  const lines = [head];
1668
1708
  // v0.38.8: durable verdict tally as a first-class card row — the widget
1669
1709
  // is the glance surface, and stored verdicts are the progress evidence
@@ -171,6 +171,7 @@ import {
171
171
  setContinuationRearmSince,
172
172
  resetContinuationDispatchState,
173
173
  noteUserMessageForDispatch,
174
+ sendTerminalCompletionNotice,
174
175
  type ContinuationFlags,
175
176
  type ContinuationDeps,
176
177
  } from "../goal-continuation.js";
@@ -735,6 +736,14 @@ export function __testOnlyClassifyStaleContinuation(content: string, cwd: string
735
736
  return null;
736
737
  }
737
738
 
739
+ /** Commands and lifecycle contacts share the same ownership and delivery gates. */
740
+ function replayApprovalSummariesOnContact(ctx: ExtensionContext): void {
741
+ if (shouldSkipApprovalRenderReplay(ctx)) return;
742
+ replayUndeliveredApprovalRenders(ctx, (entry) => sendTerminalCompletionNotice(ctx, {
743
+ goalId: entry.goalId, outcome: entry.objective, details: [], chatLines: entry.chatLines,
744
+ }));
745
+ }
746
+
738
747
  export function registerGoalRuntime(pi: ExtensionAPI): void {
739
748
  // Factories run before lifecycle events, so observe readiness now; the
740
749
  // admitted session_start below decides which bus/generation may control a
@@ -785,7 +794,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
785
794
  // v0.38.30 audit: skip on stale/worker handles — the wrappers run
786
795
  // before the inner stale fence, and a superseded session used to mark
787
796
  // delivery into a dead session (stale-allowed /loop status included).
788
- if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
797
+ replayApprovalSummariesOnContact(ctx);
789
798
  return cmdGoal(args, ctx);
790
799
  },
791
800
  });
@@ -795,7 +804,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
795
804
  // v0.38.25: live contact — replay any approval render that landed
796
805
  // with no live turn (persist-first, never silent).
797
806
  // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
798
- if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
807
+ replayApprovalSummariesOnContact(ctx);
799
808
  return cmdSettings(args, ctx);
800
809
  };
801
810
  pi.registerCommand("glla", {
@@ -827,7 +836,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
827
836
  // v0.38.25: live contact — replay any approval render that landed
828
837
  // with no live turn (persist-first, never silent).
829
838
  // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
830
- if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
839
+ replayApprovalSummariesOnContact(ctx);
831
840
  return cmdReview(args, ctx);
832
841
  },
833
842
  });
@@ -857,7 +866,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
857
866
  // v0.38.25: live contact — replay any approval render that landed
858
867
  // with no live turn (persist-first, never silent).
859
868
  // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
860
- if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
869
+ replayApprovalSummariesOnContact(ctx);
861
870
  return cmdList(args, ctx);
862
871
  },
863
872
  });
@@ -883,7 +892,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
883
892
  // v0.38.25: live contact — replay any approval render that landed
884
893
  // with no live turn (persist-first, never silent).
885
894
  // v0.38.30 audit: skip on stale/worker handles (see /goal wrapper).
886
- if (!shouldSkipApprovalRenderReplay(ctx)) replayUndeliveredApprovalRenders(ctx);
895
+ replayApprovalSummariesOnContact(ctx);
887
896
  return cmdLoop(args, ctx);
888
897
  },
889
898
  });
@@ -1988,6 +1997,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1988
1997
  appendLedger(ctx.cwd, "load_hold_released", { via: "consenting-reload" });
1989
1998
  }
1990
1999
  refreshUI(ctx, true);
2000
+ replayApprovalSummariesOnContact(ctx);
1991
2001
  });
1992
2002
 
1993
2003
  pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
@@ -2551,6 +2561,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
2551
2561
  pi.on("agent_settled", async (_event: any, ctx: ExtensionContext) => {
2552
2562
  if (tryAbsorbHostSuccessor(ctx, "agent_settled")) return;
2553
2563
  if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || isForeignCtx(ctx)) return;
2564
+ replayApprovalSummariesOnContact(ctx);
2554
2565
  if (!state.mainModelRecovery || state.mainModelRecovery.retryAt || !lastMainModelFailure) return;
2555
2566
  if (!isSupervising()) return;
2556
2567
  lastMainModelFailure = null;
@@ -243,7 +243,7 @@ import {
243
243
  } from "../goal-loop-repetition.js";
244
244
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
245
245
  import { buildTerminalApprovalRender, compactCompletionSummary, isGenericCompletionSummary, missingCompletionSummaryLabels } from "../completion-summary.js";
246
- import { isApprovalContextIdle, persistApprovalRender } from "../approval-render-store.js";
246
+ import { persistApprovalRender, replayUndeliveredApprovalRenders } from "../approval-render-store.js";
247
247
  import {
248
248
  defaultAgentDir,
249
249
  resolveEffectiveSubagentModel,
@@ -1459,38 +1459,23 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1459
1459
  appendLedger(liveCtx.cwd, "goal_archive_failed_after_approval", { goalId, attemptId: claim.attemptId, origin });
1460
1460
  return;
1461
1461
  }
1462
- // v0.38.14: the chat notify is the human briefing — outcome first,
1463
- // filler labels dropped — while the external notify keeps the compact
1464
- // single line (pager/sound safe). v0.38.20: outcome + at most two
1465
- // details + approval + record pointer; the agent's pre-verdict `Next:`
1466
- // is stale the moment the verdict lands and never reaches the chat.
1467
- // PR #43: live inspection — the auditor's pi persisted a resumable
1468
- // session pinned inside the job dir. Point the user at it AFTER the
1469
- // audit (interactive attach only now; while running it was read-only).
1470
- liveCtx.ui.notify(approvalRender.chatLines.join("\n"), "info");
1471
- notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${approvalRender.recap}`);
1472
- persistApprovalRender(liveCtx.cwd, {
1462
+ const persisted = persistApprovalRender(liveCtx.cwd, {
1473
1463
  goalId,
1474
1464
  objective: approvalObjective,
1475
1465
  chatLines: approvalRender.chatLines,
1476
- delivered: !isApprovalContextIdle(liveCtx),
1477
1466
  });
1478
- // v0.38.18 (track 3): the toast above is ephemeral — without a
1479
- // transcript entry the session keeps narrating "waiting on the
1480
- // auditor's verdict" after the archive (junk-runner field). Deliver
1481
- // the brief into the conversation. Skipped for manual /goal verify:
1482
- // that runs inside a turn whose command output already closes the
1483
- // transcript. Fire-once fenced inside the sender.
1484
- if (origin !== "manual") {
1485
- sendTerminalCompletionNotice(liveCtx, {
1486
- goalId,
1467
+ if (persisted) {
1468
+ replayUndeliveredApprovalRenders(liveCtx, (entry) => sendTerminalCompletionNotice(liveCtx, {
1469
+ goalId: entry.goalId,
1487
1470
  generation,
1488
- outcome: approvalRender.outcome,
1489
- // v0.38.20: the transcript keeps the informing details, but the
1490
- // stale pre-verdict `Next:` is stripped here too.
1491
- details: approvalRender.transcriptLines,
1492
- });
1471
+ outcome: entry.objective,
1472
+ details: [],
1473
+ chatLines: entry.chatLines,
1474
+ }), goalId);
1475
+ } else {
1476
+ liveCtx.ui.notify("Goal archived, but its chat summary could not be persisted. Review the archived completion summary.", "warning");
1493
1477
  }
1478
+ notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${approvalRender.recap}`);
1494
1479
  return;
1495
1480
  }
1496
1481
 
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
20
 
21
+ import { sendTerminalCompletionNotice } from "../goal-continuation.js";
21
22
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
22
23
  import { Type } from "typebox";
23
24
 
@@ -254,7 +255,7 @@ import {
254
255
  } from "../goal-loop-repetition.js";
255
256
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
256
257
  import { buildTerminalApprovalRender, compactCompletionSummary, compactTerminalCompletionSummary } from "../completion-summary.js";
257
- import { isApprovalContextIdle, persistApprovalRender } from "../approval-render-store.js";
258
+ import { persistApprovalRender, replayUndeliveredApprovalRenders } from "../approval-render-store.js";
258
259
  import {
259
260
  defaultAgentDir,
260
261
  resolveEffectiveSubagentModel,
@@ -493,7 +494,8 @@ function registerAgentTools(pi: any): void {
493
494
  pi.registerTool(defineTool({
494
495
  name: "complete_goal",
495
496
  label: "Complete goal",
496
- description: "Mark the active goal as complete. Queues a detached auditor worker to verify without holding the main pi turn. Use only when the objective is genuinely satisfied.",
497
+ description: "Submit a completion claim for detached audit; this does NOT complete or approve the goal. Use only when the objective is genuinely satisfied. Return without waiting for the auditor; GLLA delivers the final user summary after verified approval and durable archive.",
498
+ promptGuidelines: ["complete_goal queues a nonterminal claim. Do not say done, complete, approved, or accepted as a completion verdict while audit is pending. Do not poll or wait; if needed give only a brief pending status. GLLA posts the final outcome summary after approval; do not duplicate it."],
497
499
  parameters: Type.Object({
498
500
  // v0.34.136: completionSummary adopts the six-label recap from
499
501
  // audit/COMPLETION-SUMMARY-POLICY-2026-08-19.md (Outcome / Changed
@@ -1241,14 +1243,17 @@ function registerAgentTools(pi: any): void {
1241
1243
  // `Next:` stripped); the chat notify is outcome + approval +
1242
1244
  // record pointer like every other approval path.
1243
1245
  const briefBlock = escRender.transcriptLines.join("\n");
1244
- ctx.ui.notify(escRender.chatLines.join("\n"), "info");
1246
+
1245
1247
  notifyExternal(ctx, `Goal complete without audit (user choice): ${escRender.recap}`);
1246
- persistApprovalRender(ctx.cwd, {
1248
+ const persisted = persistApprovalRender(ctx.cwd, {
1247
1249
  goalId: terminalGoal.id,
1248
1250
  objective: terminalGoal.objective,
1249
1251
  chatLines: escRender.chatLines,
1250
- delivered: !isApprovalContextIdle(ctx),
1251
1252
  });
1253
+ if (persisted) replayUndeliveredApprovalRenders(ctx, (entry) => sendTerminalCompletionNotice(ctx, {
1254
+ goalId: entry.goalId, outcome: entry.objective, details: [], chatLines: entry.chatLines,
1255
+ }), terminalGoal.id);
1256
+ else ctx.ui.notify("Goal archived, but its chat summary could not be persisted. Review the archived completion summary.", "warning");
1252
1257
  return { content: [{ type: "text", text: `Goal marked complete without audit (user choice).\n\n${briefBlock}` }], details: {} };
1253
1258
  }
1254
1259
  scheduleContinuation(ctx, true);
@@ -1305,14 +1310,17 @@ function registerAgentTools(pi: any): void {
1305
1310
  // v0.38.20: same approval voice as the detached path — the stale
1306
1311
  // pre-verdict `Next:` never reaches the chat.
1307
1312
  // PR #43: append the kept inspection-session pointer when present.
1308
- ctx.ui.notify(manualRender.chatLines.join("\n"), "info");
1313
+
1309
1314
  notifyExternal(ctx, `Goal complete (auditor approved): ${manualRender.recap}`);
1310
- persistApprovalRender(ctx.cwd, {
1315
+ const persisted = persistApprovalRender(ctx.cwd, {
1311
1316
  goalId: manualGoalId,
1312
1317
  objective: manualObjective,
1313
1318
  chatLines: manualRender.chatLines,
1314
- delivered: !isApprovalContextIdle(ctx),
1315
1319
  });
1320
+ if (persisted) replayUndeliveredApprovalRenders(ctx, (entry) => sendTerminalCompletionNotice(ctx, {
1321
+ goalId: entry.goalId, outcome: entry.objective, details: [], chatLines: entry.chatLines,
1322
+ }), manualGoalId);
1323
+ else ctx.ui.notify("Goal archived, but its chat summary could not be persisted. Review the archived completion summary.", "warning");
1316
1324
  return { content: [{ type: "text", text: `Goal approved by auditor ${result.model}.` }], details: {} };
1317
1325
  }
1318
1326
 
@@ -1868,8 +1876,10 @@ function registerAgentTools(pi: any): void {
1868
1876
  if (notifyFailure) current.ui.notify(`Completion auditor worker failed to settle (infrastructure, not a verdict). The stored claim is safe; ${activeGoalSurfaceCommand("resume")} retries it.`, "warning");
1869
1877
  });
1870
1878
  return {
1871
- content: [{ type: "text", text: `Completion claim persisted; detached auditor queued (model: ${via ?? "setting"}). The verdict will be applied asynchronously.` }],
1872
- details: {},
1879
+ content: [{ type: "text", text: `AUDIT PENDING — nonterminal. Completion claim persisted; detached auditor queued (model: ${via ?? "setting"}). The goal is not approved or complete. Do not claim completion or give a final success summary. Do not wait or poll: GLLA will post the concrete final summary after verified approval and durable archive; rejection resumes work.` }],
1880
+ details: { status: "audit-pending", terminal: false },
1881
+ // End this tool batch, not the goal; the detached worker owns settlement.
1882
+ terminate: true,
1873
1883
  };
1874
1884
  },
1875
1885
  }));
@@ -0,0 +1,54 @@
1
+ import * as fs from "node:fs";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Pi can mutate the branch before a session write fails, and sendMessage's
5
+ * void API is not a persistence acknowledgement. Read only the bounded tail
6
+ * on delivery/replay; never mutate the host session or poll for a receipt. */
7
+ function persistedEntry(sessionFile: string | undefined, expected: { id: string; customType: string; content: unknown; details?: unknown }): boolean {
8
+ if (!sessionFile) return false;
9
+ let fd: number | undefined;
10
+ try {
11
+ fd = fs.openSync(sessionFile, "r");
12
+ const size = fs.fstatSync(fd).size;
13
+ const start = Math.max(0, size - 256 * 1024);
14
+ const buffer = Buffer.alloc(size - start);
15
+ fs.readSync(fd, buffer, 0, buffer.length, start);
16
+ const lines = buffer.toString("utf8").split("\n");
17
+ if (start > 0) lines.shift();
18
+ // A partial trailing JSONL record is not a durable receipt.
19
+ lines.pop();
20
+ return lines.some((line) => {
21
+ try {
22
+ const entry = JSON.parse(line);
23
+ return entry.type === "custom_message" && entry.id === expected.id &&
24
+ entry.customType === expected.customType && entry.display === true &&
25
+ entry.content === expected.content &&
26
+ entry.details?.terminalApprovalGoalId === (expected.details as { terminalApprovalGoalId?: string })?.terminalApprovalGoalId;
27
+ } catch { return false; }
28
+ });
29
+ } catch { return false; }
30
+ finally { if (fd !== undefined) fs.closeSync(fd); }
31
+ }
32
+
33
+ /** True means a visible contextual summary is persisted, not that a human
34
+ * has read it. An unpersisted branch entry suppresses same-session duplicates
35
+ * but leaves the durable outbox pending for recovery in a fresh session. */
36
+ export function deliverTerminalSummary(
37
+ ctx: Pick<ExtensionContext, "sessionManager">,
38
+ pi: Pick<ExtensionAPI, "sendMessage">,
39
+ customType: string,
40
+ goalId: string,
41
+ content: string,
42
+ ): boolean {
43
+ const find = () => ctx.sessionManager.getBranch().find((entry) =>
44
+ entry.type === "custom_message" && entry.customType === customType && entry.display === true &&
45
+ (entry.details as { terminalApprovalGoalId?: string } | undefined)?.terminalApprovalGoalId === goalId &&
46
+ entry.content === content,
47
+ );
48
+ let entry = find();
49
+ if (!entry) {
50
+ pi.sendMessage({ customType, content, display: true, details: { terminalApprovalGoalId: goalId } }, { triggerTurn: false });
51
+ entry = find();
52
+ }
53
+ return entry?.type === "custom_message" && persistedEntry(ctx.sessionManager.getSessionFile(), entry);
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.33",
3
+ "version": "0.38.35",
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",
@@ -192,3 +192,7 @@ When the user must CHOOSE between paths, use `pause_goal` with `kind="decision"`
192
192
  ## STALLS
193
193
 
194
194
  The orchestrator's backstop is the stall watchdog: three consecutive turns with no tool calls pause the goal. You get an explicit `[STALL WARNING n/3]` continuation first — act on it immediately (complete_goal if done, pause_goal if blocked, a real tool call otherwise); the warning tells you exactly how many unproductive turns remain. If you feel yourself spinning — repeating the same approach, no new evidence — stop early instead: call `pause_goal` with what is blocking and a concrete suggested action, rather than burning the remaining watchdog turns.
195
+
196
+ ### Completion communication
197
+
198
+ `complete_goal` submits a nonterminal claim, not a completion verdict. While the detached audit is pending, do not say the goal is done/complete/approved or present a final success summary. Do not wait or poll. If a response is needed, give one brief pending-status sentence. GLLA posts the concrete outcome-first summary into chat after verified approval and durable archive, without needing another user prompt. Do not duplicate that summary. A rejected claim remains unfinished and repair work continues.