pi-goal-list-loop-audit 0.38.24 → 0.38.25

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.25 — post-objective summary: canonical approval render, persist + replay, audit-goal counts (2026-09-07)
4
+
5
+ ### Fixed
6
+ - Field failure: goal `20260907131550-12ddoy` completed with a perfect six-label archive record, but the approval chat lines fired into a dead context (auditor verdict landed with no live turn) — record perfect, delivery silent. Cross-harness survey (`audit/POST-OBJECTIVE-SUMMARY-2026-09-07.md`): Codex CLI 0.147.0 and Claude Code both leave the final message to the agent and standardize only usage/session-persistence/resume; pi-goal-x (in-repo v0.26.1 source) fixes the report order (verdict → complete → task summary → detail) — validating the outcome-first shape. Nobody persists the render; GLLA now does.
7
+ - ONE canonical builder: `buildTerminalApprovalRender` (outcome + ≤2 details + approval + counts + record) feeds chat, transcript, external, and the persisted render on all three approval paths (detached auditor, manual verify, Esc-without-audit). `buildApprovalChatLines` gains an optional `counts` slot (backward compatible); the stale pre-verdict `Next:` stays stripped everywhere.
8
+ - Audit-goal counts line rides every render, built from durable state only: `— run: 42 turns · 17 file writes · 23 bash calls · auditor approved (1 verdict).` Absent facts are named as absent; the no-audit path says so honestly.
9
+ - Persist + replay: renders persist to `.pi-glla/pending-approval-renders.json` at archive time with `delivered: !isIdle()` (the probe fails toward undelivered — a duplicate beats a loss). All five command handlers (`/goal /glla /review /list /loop`) replay undelivered renders on live contact, fire-once fenced via `deliveredAt`, ledgered (`terminal_approval_render_persisted/_replayed`), corrupt-store safe. Coverage: 7 new behavioral pins + updated source-grep contract.
10
+
3
11
  ## 0.38.24 — audit pass: abort-latch send guards, ownership compare-and-swap, auditor inherit parity (2026-09-07)
4
12
 
5
13
  ### 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.24 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.25 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
@@ -27,7 +27,8 @@ For shipped docs, the relevant entry points are:
27
27
  extensions, bounded zero-stream retry containment, crash-safe persistence,
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
- auditor inherit/clear parity) — see CHANGELOG.md for the full trail.
30
+ auditor inherit/clear parity) and the v0.38.25 post-objective summary
31
+ (canonical approval render, persist + replay, audit-goal counts line) — see CHANGELOG.md for the full trail.
31
32
  - `../README.md` — what the plugin is, install, quickstart, and the
32
33
  architectural guarantee (drafting + confirm + detached auditor).
33
34
  - `../INSTALL.md` — source install / local development setup; the recommended
@@ -0,0 +1,159 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { appendLedger, ensureDirs, nowIso, piGlaDir, runPersistStep } from "./goal-loop-core.js";
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
+ */
21
+
22
+ export interface PendingApprovalRender {
23
+ goalId: string;
24
+ objective: string;
25
+ chatLines: string[];
26
+ createdAt: string;
27
+ deliveredAt?: string;
28
+ }
29
+
30
+ /** Cap: the sidecar is a spillway, not a log. Delivered renders are kept
31
+ * briefly for inspection; undelivered ones are never dropped by the cap. */
32
+ const MAX_STORED_RENDERS = 20;
33
+ const MAX_REPLAY_PER_CONTACT = 5;
34
+
35
+ export function approvalRenderStorePath(cwd: string): string {
36
+ return path.join(piGlaDir(cwd), "pending-approval-renders.json");
37
+ }
38
+
39
+ function isValidRender(entry: unknown): entry is PendingApprovalRender {
40
+ if (typeof entry !== "object" || entry === null) return false;
41
+ const e = entry as Record<string, unknown>;
42
+ return typeof e.goalId === "string" && e.goalId.length > 0
43
+ && typeof e.objective === "string"
44
+ && Array.isArray(e.chatLines)
45
+ && e.chatLines.every((line) => typeof line === "string")
46
+ && typeof e.createdAt === "string"
47
+ && (e.deliveredAt === undefined || typeof e.deliveredAt === "string");
48
+ }
49
+
50
+ function readRenders(cwd: string): PendingApprovalRender[] | undefined {
51
+ let raw: string;
52
+ try {
53
+ raw = fs.readFileSync(approvalRenderStorePath(cwd), "utf-8");
54
+ } catch {
55
+ return [];
56
+ }
57
+ try {
58
+ const parsed: unknown = JSON.parse(raw);
59
+ if (!Array.isArray(parsed)) throw new Error("not an array");
60
+ const valid = parsed.filter(isValidRender);
61
+ if (valid.length !== parsed.length) {
62
+ appendLedger(cwd, "terminal_approval_render_store_invalid", {
63
+ dropped: parsed.length - valid.length,
64
+ kept: valid.length,
65
+ });
66
+ }
67
+ return valid;
68
+ } catch {
69
+ appendLedger(cwd, "terminal_approval_render_store_invalid", { dropped: "all", kept: 0 });
70
+ return [];
71
+ }
72
+ }
73
+
74
+ function writeRenders(cwd: string, renders: PendingApprovalRender[]): boolean {
75
+ const landed = runPersistStep("persistApprovalRender", () => {
76
+ ensureDirs(cwd);
77
+ fs.writeFileSync(approvalRenderStorePath(cwd), JSON.stringify(renders, null, 2), "utf-8");
78
+ return true;
79
+ });
80
+ return landed === true;
81
+ }
82
+
83
+ /** Persist the rendered human-sees lines at archive time. `delivered`
84
+ * must be true only when the render went out on a provably live turn
85
+ * (callers pass `!ctx.isIdle()`); idle-persisted renders stay queued for
86
+ * replay. Never throws into orchestrator handlers. */
87
+ export function persistApprovalRender(cwd: string, render: {
88
+ goalId: string;
89
+ objective: string;
90
+ chatLines: string[];
91
+ delivered: boolean;
92
+ }): boolean {
93
+ const at = nowIso();
94
+ const existing = readRenders(cwd) ?? [];
95
+ const entry: PendingApprovalRender = {
96
+ goalId: render.goalId,
97
+ objective: render.objective.slice(0, 300),
98
+ chatLines: render.chatLines,
99
+ createdAt: at,
100
+ ...(render.delivered ? { deliveredAt: at } : {}),
101
+ };
102
+ const undelivered = existing.filter((e) => !e.deliveredAt);
103
+ const delivered = [...existing.filter((e) => e.deliveredAt), ...(render.delivered ? [entry] : [])]
104
+ .slice(-(MAX_STORED_RENDERS - undelivered.length - (render.delivered ? 0 : 1)));
105
+ const next = [...undelivered, ...(render.delivered ? [] : [entry]), ...delivered];
106
+ if (!writeRenders(cwd, next.slice(-MAX_STORED_RENDERS))) return false;
107
+ appendLedger(cwd, "terminal_approval_render_persisted", {
108
+ goalId: render.goalId,
109
+ delivered: render.delivered,
110
+ lines: render.chatLines.length,
111
+ });
112
+ return true;
113
+ }
114
+
115
+ /** Deliver every undelivered render on a live contact, oldest first,
116
+ * fire-once fenced via deliveredAt. Callers are user-invoked command
117
+ * handlers — live by construction. Returns the replayed count; never
118
+ * throws. */
119
+ export function replayUndeliveredApprovalRenders(ctx: { cwd: string; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } }): number {
120
+ const renders = readRenders(ctx.cwd);
121
+ if (!renders || renders.length === 0) return 0;
122
+ const pending = renders.filter((e) => !e.deliveredAt).slice(0, MAX_REPLAY_PER_CONTACT);
123
+ if (pending.length === 0) return 0;
124
+ const at = nowIso();
125
+ let replayed = 0;
126
+ for (const entry of pending) {
127
+ try {
128
+ ctx.ui.notify(entry.chatLines.join("\n"), "info");
129
+ entry.deliveredAt = at;
130
+ replayed += 1;
131
+ appendLedger(ctx.cwd, "terminal_approval_render_replayed", {
132
+ goalId: entry.goalId,
133
+ createdAt: entry.createdAt,
134
+ });
135
+ } catch {
136
+ // Leave undelivered for the next live contact; never break the
137
+ // command that triggered the replay.
138
+ break;
139
+ }
140
+ }
141
+ if (replayed > 0) writeRenders(ctx.cwd, renders);
142
+ return replayed;
143
+ }
144
+
145
+ /** Liveness probe for delivery marking. Unknown/throwing ⇒ idle ⇒
146
+ * undelivered ⇒ replayed on the next live contact. The safe direction is
147
+ * never-silent: a duplicate render on the next command beats a lost one. */
148
+ export function isApprovalContextIdle(ctx: { isIdle?: () => boolean }): boolean {
149
+ try {
150
+ return ctx.isIdle?.() ?? true;
151
+ } catch {
152
+ return true;
153
+ }
154
+ }
155
+
156
+ /** Test-only reset for store isolation. */
157
+ export function __testOnlyResetApprovalRenderStore(): void {
158
+ // No module-level cache — isolation is via cwd-scoped files.
159
+ }
@@ -173,21 +173,113 @@ export function withoutStaleNext(details: string[] | undefined): string[] {
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
176
- * 2026-09-04); the stale Next never reaches the chat. */
176
+ * 2026-09-04); the stale Next never reaches the chat.
177
+ * v0.38.25: optional `counts` audit-goal counts line rides between the
178
+ * approval trailer and the record pointer (the pointer stays last). */
177
179
  export function buildApprovalChatLines(notice: {
178
180
  outcome: string;
179
181
  details: string[] | undefined;
180
182
  approval: string;
181
183
  record: string;
184
+ counts?: string;
182
185
  }): string[] {
183
186
  return [
184
187
  `✓ done — ${notice.outcome}`,
185
188
  ...withoutStaleNext(notice.details).slice(0, 2),
186
189
  notice.approval,
190
+ ...(notice.counts ? [notice.counts] : []),
187
191
  notice.record,
188
192
  ];
189
193
  }
190
194
 
195
+ /** v0.38.25: the audit-goal counts line. Compact execution + audit proof
196
+ * built ONLY from durable goal state — turns/file-writes/bash from
197
+ * telemetry, verdict counts from auditHistory. Absent facts are named as
198
+ * absent, never invented (the recorded-facts honesty rule). */
199
+ export function buildAuditCountsLine(goal: Goal, auditNote?: string): string {
200
+ const telemetry = goal.telemetry;
201
+ const run = telemetry
202
+ ? `${telemetry.turns} turns · ${telemetry.fileWrites} file writes · ${telemetry.bashCalls} bash calls`
203
+ : "no execution telemetry was recorded";
204
+ const history = goal.auditHistory ?? [];
205
+ const latest = history.length > 0 ? history[history.length - 1] : undefined;
206
+ const audit = auditNote ?? (latest === undefined
207
+ ? "no auditor verdict was recorded"
208
+ : (() => {
209
+ const verdict = latest.approved ? "approved" : latest.impossible ? "impossible" : latest.disapproved ? "disapproved" : "no verdict";
210
+ return `auditor ${verdict} (${history.length} verdict${history.length === 1 ? "" : "s"})`;
211
+ })());
212
+ return `— run: ${run} · ${audit}.`;
213
+ }
214
+
215
+ export interface TerminalApprovalRenderInput {
216
+ goal: Goal;
217
+ status: Status;
218
+ stopReason?: string;
219
+ archivePath?: string;
220
+ completionSummary?: string;
221
+ /** Path-specific voice, e.g. `— auditor X approved …` or `— completed without audit (your choice).` */
222
+ approval: string;
223
+ /** Path-specific record pointer, e.g. `— record: <path>`. */
224
+ record: string;
225
+ /** Extra trailing lines (inspection-session pointer, …). */
226
+ extras?: string[];
227
+ /** Override for the counts line (e.g. the no-audit path). */
228
+ countsLine?: string;
229
+ /** Override for the audit half of the counts line. */
230
+ auditNote?: string;
231
+ }
232
+
233
+ export interface TerminalApprovalRender {
234
+ /** The human-sees chat lines: outcome + ≤2 details + approval + counts + record (+ extras). */
235
+ chatLines: string[];
236
+ /** Compact single line for external notifies (pager/sound safe). */
237
+ recap: string;
238
+ /** Transcript-notice details: informing details (stale Next stripped) + approval. */
239
+ transcriptLines: string[];
240
+ /** The counts line riding the render. */
241
+ countsLine: string;
242
+ /** Brief outcome (chat line 1 without the `✓ done — ` prefix). */
243
+ outcome: string;
244
+ /** Approval trailer line (shared by chat + transcript surfaces). */
245
+ approval: string;
246
+ }
247
+
248
+ /** v0.38.25: ONE canonical builder for every terminal approval surface —
249
+ * chat notify, transcript notice, external notify, and the persisted
250
+ * archive render. All approval paths (detached auditor, manual verify,
251
+ * Esc-without-audit) build the same voice from the same resolved facts so
252
+ * the surfaces cannot drift and a render can be persisted + replayed. */
253
+ export function buildTerminalApprovalRender(input: TerminalApprovalRenderInput): TerminalApprovalRender {
254
+ const facts: CompletionSummaryFacts = {
255
+ goal: input.goal,
256
+ status: input.status,
257
+ stopReason: input.stopReason,
258
+ archivePath: input.archivePath,
259
+ };
260
+ const candidate = input.completionSummary ?? input.goal.completionSummary;
261
+ const recap = compactTerminalCompletionSummary(facts, candidate);
262
+ const brief = terminalHumanBrief(facts, candidate);
263
+ const countsLine = input.countsLine ?? buildAuditCountsLine(input.goal, input.auditNote);
264
+ return {
265
+ chatLines: [
266
+ ...buildApprovalChatLines({
267
+ outcome: brief.outcome,
268
+ details: brief.details,
269
+ approval: input.approval,
270
+ record: input.record,
271
+ counts: countsLine,
272
+ }),
273
+ ...(input.extras ?? []),
274
+ ],
275
+ recap,
276
+ transcriptLines: [...withoutStaleNext(brief.details), input.approval],
277
+ countsLine,
278
+ outcome: brief.outcome,
279
+ approval: input.approval,
280
+ };
281
+ }
282
+
191
283
  /** Multi-line projection: one `Label: value` line per label with generous
192
284
  * word-bounded values. This is the user-facing `✓ done` block — six short
193
285
  * facts that stay scannable in chat. The single-line projection remains
@@ -124,6 +124,7 @@ isGoalRevisionCurrent,
124
124
  type ModelSwitchRecord,
125
125
  type ListItem,
126
126
  } from "../goal-loop-core.js";
127
+ import { replayUndeliveredApprovalRenders } from "../approval-render-store.js";
127
128
  import {
128
129
  createContinuationDispatch,
129
130
  dispatchMatchesOwner,
@@ -778,12 +779,18 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
778
779
  handler: (args: string, ctx: ExtensionContext) => {
779
780
  rememberCtx(ctx);
780
781
  if (refuseForeignCommand(ctx)) return Promise.resolve();
782
+ // v0.38.25: live contact — replay any approval render that landed
783
+ // with no live turn (persist-first, never silent).
784
+ replayUndeliveredApprovalRenders(ctx);
781
785
  return cmdGoal(args, ctx);
782
786
  },
783
787
  });
784
788
  const settingsHandler = (args: string, ctx: ExtensionContext) => {
785
789
  rememberCtx(ctx);
786
790
  if (refuseForeignCommand(ctx)) return Promise.resolve();
791
+ // v0.38.25: live contact — replay any approval render that landed
792
+ // with no live turn (persist-first, never silent).
793
+ replayUndeliveredApprovalRenders(ctx);
787
794
  return cmdSettings(args, ctx);
788
795
  };
789
796
  pi.registerCommand("glla", {
@@ -812,6 +819,9 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
812
819
  handler: (args: string, ctx: ExtensionContext) => {
813
820
  rememberCtx(ctx);
814
821
  if (refuseForeignCommand(ctx)) return Promise.resolve();
822
+ // v0.38.25: live contact — replay any approval render that landed
823
+ // with no live turn (persist-first, never silent).
824
+ replayUndeliveredApprovalRenders(ctx);
815
825
  return cmdReview(args, ctx);
816
826
  },
817
827
  });
@@ -838,6 +848,9 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
838
848
  handler: (args: string, ctx: ExtensionContext) => {
839
849
  rememberCtx(ctx);
840
850
  if (refuseForeignCommand(ctx)) return Promise.resolve();
851
+ // v0.38.25: live contact — replay any approval render that landed
852
+ // with no live turn (persist-first, never silent).
853
+ replayUndeliveredApprovalRenders(ctx);
841
854
  return cmdList(args, ctx);
842
855
  },
843
856
  });
@@ -859,6 +872,9 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
859
872
  handler: (args: string, ctx: ExtensionContext) => {
860
873
  rememberCtx(ctx);
861
874
  if (refuseForeignCommand(ctx)) return Promise.resolve();
875
+ // v0.38.25: live contact — replay any approval render that landed
876
+ // with no live turn (persist-first, never silent).
877
+ replayUndeliveredApprovalRenders(ctx);
862
878
  return cmdLoop(args, ctx);
863
879
  },
864
880
  });
@@ -242,7 +242,8 @@ import {
242
242
  pushCapped as pushRepetitionCapped,
243
243
  } from "../goal-loop-repetition.js";
244
244
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
245
- import { buildApprovalChatLines, compactCompletionSummary, compactTerminalCompletionSummary, isGenericCompletionSummary, missingCompletionSummaryLabels, terminalHumanBrief, withoutStaleNext } from "../completion-summary.js";
245
+ import { buildTerminalApprovalRender, compactCompletionSummary, isGenericCompletionSummary, missingCompletionSummaryLabels } from "../completion-summary.js";
246
+ import { isApprovalContextIdle, persistApprovalRender } from "../approval-render-store.js";
246
247
  import {
247
248
  defaultAgentDir,
248
249
  resolveEffectiveSubagentModel,
@@ -1420,23 +1421,29 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1420
1421
  // is the agent's completionSummary when captured; the objective is the
1421
1422
  // fallback for legacy/aborted goals.
1422
1423
  const terminalReason = `auditor ${result.model} approved (${origin})`;
1423
- const recap = compactTerminalCompletionSummary({
1424
- goal: state.goal,
1425
- status: "complete",
1426
- stopReason: terminalReason,
1427
- archivePath: path.relative(liveCtx.cwd, archivedGoalPath(liveCtx.cwd, state.goal.id)) || archivedGoalPath(liveCtx.cwd, state.goal.id),
1428
- }, state.goal.completionSummary);
1429
- // Computed pre-archive: archiveCurrentGoal clears state.goal.
1430
- const brief = terminalHumanBrief({
1431
- goal: state.goal,
1432
- status: "complete",
1433
- stopReason: terminalReason,
1434
- archivePath: path.relative(liveCtx.cwd, archivedGoalPath(liveCtx.cwd, state.goal.id)) || archivedGoalPath(liveCtx.cwd, state.goal.id),
1435
- }, state.goal.completionSummary);
1436
1424
  const approvalVia = `${origin === "manual" ? " on /goal verify" : origin === "session-recovery" ? " after session recovery" : " on the provider retry"}${fallbackUsed ? " after an auditor-model fallback" : ""}`;
1437
1425
  // v0.38.20: the chat record pointer. Computed pre-archive like the
1438
- // recap/brief above (archiveCurrentGoal clears state.goal).
1426
+ // render below (archiveCurrentGoal clears state.goal).
1439
1427
  const approvalRecord = `— record: ${path.relative(liveCtx.cwd, archivedGoalPath(liveCtx.cwd, state.goal.id)) || archivedGoalPath(liveCtx.cwd, state.goal.id)}`;
1428
+ const approvalArchivePath = path.relative(liveCtx.cwd, archivedGoalPath(liveCtx.cwd, state.goal.id)) || archivedGoalPath(liveCtx.cwd, state.goal.id);
1429
+ // v0.38.25: ONE canonical render for every approval surface (chat,
1430
+ // transcript, external, persisted). Computed pre-archive — the fence
1431
+ // clears state.goal — and persisted after the archive lands so a
1432
+ // verdict that lands with no live turn is replayed on the next live
1433
+ // contact instead of going silent (field 2026-09-07).
1434
+ const approvalRender = buildTerminalApprovalRender({
1435
+ goal: state.goal,
1436
+ status: "complete",
1437
+ stopReason: terminalReason,
1438
+ archivePath: approvalArchivePath,
1439
+ completionSummary: state.goal.completionSummary,
1440
+ approval: `— auditor ${result.model} approved${approvalVia}.`,
1441
+ record: approvalRecord,
1442
+ extras: inspectionSessionPath
1443
+ ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1444
+ : [],
1445
+ });
1446
+ const approvalObjective = state.goal.objective;
1440
1447
  const archived = archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (${origin})`);
1441
1448
  if (!archived) {
1442
1449
  // archiveCurrentGoal already preserved the live record and warned the
@@ -1460,17 +1467,14 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1460
1467
  // PR #43: live inspection — the auditor's pi persisted a resumable
1461
1468
  // session pinned inside the job dir. Point the user at it AFTER the
1462
1469
  // audit (interactive attach only now; while running it was read-only).
1463
- liveCtx.ui.notify([...buildApprovalChatLines({
1464
- outcome: brief.outcome,
1465
- details: brief.details,
1466
- approval: `— auditor ${result.model} approved${approvalVia}.`,
1467
- record: approvalRecord,
1468
- }),
1469
- ...(inspectionSessionPath
1470
- ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1471
- : []),
1472
- ].join("\n"), "info");
1473
- notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${recap}`);
1470
+ liveCtx.ui.notify(approvalRender.chatLines.join("\n"), "info");
1471
+ notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${approvalRender.recap}`);
1472
+ persistApprovalRender(liveCtx.cwd, {
1473
+ goalId,
1474
+ objective: approvalObjective,
1475
+ chatLines: approvalRender.chatLines,
1476
+ delivered: !isApprovalContextIdle(liveCtx),
1477
+ });
1474
1478
  // v0.38.18 (track 3): the toast above is ephemeral — without a
1475
1479
  // transcript entry the session keeps narrating "waiting on the
1476
1480
  // auditor's verdict" after the archive (junk-runner field). Deliver
@@ -1481,10 +1485,10 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1481
1485
  sendTerminalCompletionNotice(liveCtx, {
1482
1486
  goalId,
1483
1487
  generation,
1484
- outcome: brief.outcome,
1488
+ outcome: approvalRender.outcome,
1485
1489
  // v0.38.20: the transcript keeps the informing details, but the
1486
1490
  // stale pre-verdict `Next:` is stripped here too.
1487
- details: [...withoutStaleNext(brief.details), `— auditor ${result.model} approved${approvalVia}.`],
1491
+ details: approvalRender.transcriptLines,
1488
1492
  });
1489
1493
  }
1490
1494
  return;
@@ -253,7 +253,8 @@ import {
253
253
  pushCapped as pushRepetitionCapped,
254
254
  } from "../goal-loop-repetition.js";
255
255
  import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
256
- import { buildApprovalChatLines, compactCompletionSummary, compactTerminalCompletionSummary, resolveCompletionSummary, terminalHumanBrief, withoutStaleNext } from "../completion-summary.js";
256
+ import { buildTerminalApprovalRender, compactCompletionSummary, compactTerminalCompletionSummary } from "../completion-summary.js";
257
+ import { isApprovalContextIdle, persistApprovalRender } from "../approval-render-store.js";
257
258
  import {
258
259
  defaultAgentDir,
259
260
  resolveEffectiveSubagentModel,
@@ -1217,11 +1218,18 @@ function registerAgentTools(pi: any): void {
1217
1218
  const terminalGoal = state.goal;
1218
1219
  if (!terminalGoal) return staleToolResult();
1219
1220
  const terminalReason = "completed without audit (user choice after Esc)";
1220
- const recap = compactTerminalCompletionSummary({
1221
+ const escArchivePath = path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, terminalGoal.id)) || archivedGoalPath(ctx.cwd, terminalGoal.id);
1222
+ // v0.38.25: canonical render (same voice as every approval path).
1223
+ // Built from the terminalGoal local, which survives the archive
1224
+ // fence clearing state.goal.
1225
+ const escRender = buildTerminalApprovalRender({
1221
1226
  goal: terminalGoal,
1222
1227
  status: "complete",
1223
1228
  stopReason: terminalReason,
1224
- archivePath: path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, terminalGoal.id)) || archivedGoalPath(ctx.cwd, terminalGoal.id),
1229
+ archivePath: escArchivePath,
1230
+ approval: `— completed without audit (your choice).`,
1231
+ record: `— record: ${escArchivePath}`,
1232
+ auditNote: "completed without audit (your choice)",
1225
1233
  });
1226
1234
  if (!archiveCurrentGoal(ctx, "complete", terminalReason)) {
1227
1235
  return {
@@ -1229,23 +1237,18 @@ function registerAgentTools(pi: any): void {
1229
1237
  details: {},
1230
1238
  };
1231
1239
  }
1232
- const brief = terminalHumanBrief({
1233
- goal: terminalGoal,
1234
- status: "complete",
1235
- stopReason: terminalReason,
1236
- archivePath: path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, terminalGoal.id)) || archivedGoalPath(ctx.cwd, terminalGoal.id),
1237
- });
1238
1240
  // v0.38.20: the command output keeps the informing details (stale
1239
1241
  // `Next:` stripped); the chat notify is outcome + approval +
1240
1242
  // record pointer like every other approval path.
1241
- const briefBlock = [...withoutStaleNext(brief.details), `— completed without audit (your choice).`].join("\n");
1242
- ctx.ui.notify(buildApprovalChatLines({
1243
- outcome: brief.outcome,
1244
- details: brief.details,
1245
- approval: `— completed without audit (your choice).`,
1246
- record: `— record: ${path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, terminalGoal.id)) || archivedGoalPath(ctx.cwd, terminalGoal.id)}`,
1247
- }).join("\n"), "info");
1248
- notifyExternal(ctx, `Goal complete without audit (user choice): ${recap}`);
1243
+ const briefBlock = escRender.transcriptLines.join("\n");
1244
+ ctx.ui.notify(escRender.chatLines.join("\n"), "info");
1245
+ notifyExternal(ctx, `Goal complete without audit (user choice): ${escRender.recap}`);
1246
+ persistApprovalRender(ctx.cwd, {
1247
+ goalId: terminalGoal.id,
1248
+ objective: terminalGoal.objective,
1249
+ chatLines: escRender.chatLines,
1250
+ delivered: !isApprovalContextIdle(ctx),
1251
+ });
1249
1252
  return { content: [{ type: "text", text: `Goal marked complete without audit (user choice).\n\n${briefBlock}` }], details: {} };
1250
1253
  }
1251
1254
  scheduleContinuation(ctx, true);
@@ -1265,22 +1268,25 @@ function registerAgentTools(pi: any): void {
1265
1268
  // saying "auditor approved" — pure process, no information
1266
1269
  // (Screenshot_20260808_012905/013220/013515).
1267
1270
  const terminalReason = `auditor ${result.model} approved`;
1268
- const recap = compactTerminalCompletionSummary({
1269
- goal: state.goal,
1270
- status: "complete",
1271
- stopReason: terminalReason,
1272
- archivePath: path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, state.goal.id)) || archivedGoalPath(ctx.cwd, state.goal.id),
1273
- }, state.goal.completionSummary);
1274
- // Computed pre-archive: archiveCurrentGoal clears state.goal.
1275
- const brief = terminalHumanBrief({
1271
+ const manualArchivePath = path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, state.goal.id)) || archivedGoalPath(ctx.cwd, state.goal.id);
1272
+ // v0.38.20: captured pre-archive — archiveCurrentGoal clears
1273
+ // state.goal, so the record pointer must be computed here.
1274
+ const manualArchiveRecord = `— record: ${manualArchivePath}`;
1275
+ // v0.38.25: canonical render — same voice as the detached path.
1276
+ const manualRender = buildTerminalApprovalRender({
1276
1277
  goal: state.goal,
1277
1278
  status: "complete",
1278
1279
  stopReason: terminalReason,
1279
- archivePath: path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, state.goal.id)) || archivedGoalPath(ctx.cwd, state.goal.id),
1280
- }, state.goal.completionSummary);
1281
- // v0.38.20: captured pre-archive — archiveCurrentGoal clears
1282
- // state.goal, so the record pointer must be computed here.
1283
- const manualArchiveRecord = `— record: ${path.relative(ctx.cwd, archivedGoalPath(ctx.cwd, state.goal.id)) || archivedGoalPath(ctx.cwd, state.goal.id)}`;
1280
+ archivePath: manualArchivePath,
1281
+ completionSummary: state.goal.completionSummary,
1282
+ approval: `— auditor ${result.model} approved.`,
1283
+ record: manualArchiveRecord,
1284
+ extras: inspectionSessionPath
1285
+ ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1286
+ : [],
1287
+ });
1288
+ const manualObjective = state.goal.objective;
1289
+ const manualGoalId = state.goal.id;
1284
1290
  const archived = archiveCurrentGoal(ctx, "complete", terminalReason);
1285
1291
  if (!archived) {
1286
1292
  // The archive helper preserves the live objective and emits the
@@ -1299,17 +1305,14 @@ function registerAgentTools(pi: any): void {
1299
1305
  // v0.38.20: same approval voice as the detached path — the stale
1300
1306
  // pre-verdict `Next:` never reaches the chat.
1301
1307
  // PR #43: append the kept inspection-session pointer when present.
1302
- ctx.ui.notify([...buildApprovalChatLines({
1303
- outcome: brief.outcome,
1304
- details: brief.details,
1305
- approval: `— auditor ${result.model} approved.`,
1306
- record: manualArchiveRecord,
1307
- }),
1308
- ...(inspectionSessionPath
1309
- ? [`Auditor session kept for review: pi --session ${inspectionSessionPath} (or pi --fork ${inspectionSessionPath}).`]
1310
- : []),
1311
- ].join("\n"), "info");
1312
- notifyExternal(ctx, `Goal complete (auditor approved): ${recap}`);
1308
+ ctx.ui.notify(manualRender.chatLines.join("\n"), "info");
1309
+ notifyExternal(ctx, `Goal complete (auditor approved): ${manualRender.recap}`);
1310
+ persistApprovalRender(ctx.cwd, {
1311
+ goalId: manualGoalId,
1312
+ objective: manualObjective,
1313
+ chatLines: manualRender.chatLines,
1314
+ delivered: !isApprovalContextIdle(ctx),
1315
+ });
1313
1316
  return { content: [{ type: "text", text: `Goal approved by auditor ${result.model}.` }], details: {} };
1314
1317
  }
1315
1318
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.24",
3
+ "version": "0.38.25",
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",