pi-goal-list-loop-audit 0.38.22 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/docs/DESIGN-subagent-visibility.md +26 -7
  3. package/docs/INDEX.md +6 -3
  4. package/docs/SETTINGS.md +85 -0
  5. package/extensions/approval-render-store.ts +159 -0
  6. package/extensions/auditor-extensions.ts +19 -6
  7. package/extensions/completion-summary.ts +101 -4
  8. package/extensions/context-checkpoint.ts +26 -42
  9. package/extensions/drafter-model.ts +8 -1
  10. package/extensions/goal-agents-panel.ts +106 -53
  11. package/extensions/goal-commands.ts +42 -5
  12. package/extensions/goal-compactor.ts +42 -2
  13. package/extensions/goal-continuation.ts +80 -8
  14. package/extensions/goal-heartbeat.ts +12 -0
  15. package/extensions/goal-loop-core.ts +90 -0
  16. package/extensions/goal-loop-display.ts +154 -52
  17. package/extensions/goal-loop-shield.ts +31 -0
  18. package/extensions/goal-recovery.ts +24 -5
  19. package/extensions/goal-settings.ts +74 -10
  20. package/extensions/loops/goal-activation.ts +47 -5
  21. package/extensions/loops/goal-auditor-hooks.ts +33 -28
  22. package/extensions/loops/goal-list-queue.ts +9 -0
  23. package/extensions/loops/goal-orchestrator.ts +5 -1
  24. package/extensions/loops/goal-runtime-globals.ts +24 -0
  25. package/extensions/loops/goal-session.ts +40 -5
  26. package/extensions/loops/goal-settings-ui.ts +36 -9
  27. package/extensions/loops/goal-tools.ts +50 -45
  28. package/extensions/loops/goal-ui.ts +20 -12
  29. package/extensions/model-selector.ts +13 -5
  30. package/extensions/multi-model-picker.ts +12 -0
  31. package/extensions/settings-menu.ts +6 -4
  32. package/extensions/state-root-owner.ts +79 -4
  33. package/package.json +1 -1
  34. package/scripts/release-pack-smoke.mjs +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
11
+ ## 0.38.24 — audit pass: abort-latch send guards, ownership compare-and-swap, auditor inherit parity (2026-09-07)
12
+
13
+ ### Fixed
14
+ - Full display/lifecycle/settings audit pass (goal `20260907131550-12ddoy`, three parallel read-only scouts): 44 findings → 0 open. Display decisions (user-grilled): head owns liveness (status drops the `LIVE` capsule/tails, keeps `[WORKING]` + counts), exceptions-only `quiet` default (troubled rows + count line; healthy fan-out lives on the fleet panel), `active {age}` / `quiet {age}` worker labels.
15
+ - Lifecycle HIGH: `sendContinuation` / `sendStallEscalation` / `sendLengthContinue` refuse on `flags.abortedStandDown`; terminal-notice refusal scoped to the abort latch so transcript closure still wins; zombie retry routes on the abort-closure owner (loop abort no longer stranded by a goal started mid-delay); cycle-reset consumes budget (`attempts+1`) and honors the 24h horizon while the probe turn stays prompt per the v0.34.132 contract.
16
+ - Lifecycle MED/LOW: fallback `agent_start`/`turn_start` acks refuse on intervening user messages; successor absorb + self-heal share `clearDeadGenerationDispatch` (no more idle behind a "re-armed" lie); ownership refresh is compare-and-swap (live foreign claims never clobbered; dead records still refresh for heartbeat reclaim); command entry forces a fresh ownership read past the background throttle; length-continue lost its stale-classifier exemption.
17
+ - Settings: `stallShortWords: 0` (= off) survives normalization; auditor thinking gains the drafter's `session — inherit` row + non-reasoning picks clear stale overrides; bare `/glla fallbacks` reads on stale handles (only `clear/off/unset/none` mutates); typing `auto` in the notify editor round-trips to unset; v0.38.23 comment/doc drift swept (richness/quiet copy, reviewer write→migrate lifecycle, DESIGN one-line rows, SETTINGS wedge vs aggressive default). Full audit: `audit/AUDIT-PASS-2026-09-07.md`. Coverage: `release:check` 1979 pass / 0 fail.
18
+
19
+ ## 0.38.23 — below-chat widget: forced placement, single-line rows, evidence lifesign (2026-09-07)
20
+
21
+ ### Fixed
22
+ - Below-chat pinning (field: GLLA card vs fleet visibly swapping slots across ticks, 2026-09-06 screenshots). The `pi-glla` widget now passes `{ placement: "belowEditor" }` — the same zone the native fleet defaults to — so both surfaces stop trading places. Forced, not a setting: one stable layout for everyone, no new settings row or drift pin.
23
+ - Extension-meta footers deleted. The orphan-worker `└─ /glla agents for full worker detail`, the card `└─ /goal status · /glla` / `└─ /list · /glla` hints, and the `… N more agents · /glla agents` overflow pointer all named our extension, not the user's task — pure noise once `/glla` is known. Queue depth survives as task info (`└─ N queued`); empty queue means no footer at all.
24
+ - Option-2 worker rows: one glyph-first line per tracked child (`▶ scout · ui-fixes · silent 2m`, `⚠ worker · art-batch · silent 31m`), age before any suffix so narrow-terminal truncation cuts the least important field first. The `→ <objective>` task-linkage header is gone (the card head already names the objective — it repeated it verbatim), ids stay in the `/glla agents` table, overflow names its count only. `/glla agents` full table untouched (`└ blocks:`, Recent hangs, abort guidance all intact).
25
+ - Evidence lifesign on active-clear heads: freshest-worker-evidence readout as the last head segment (`· stream 8s`), breathing glyph derived from evidence counters (`Σ toolUses + outputTokens mod 4` over `●→◉→○→◉`) — never from wall-clock, so the widget key stays stable between evidence and the v0.37.1 re-layout cannot return. Fresh (<5m) breathes, aging (5–30m) freezes the ring, stale (30m+) hollows it, a hung child triangles the head. Non-active statuses (⏸/⟡/⚠/⏳) keep their own glyph language; no rows means no readout invented.
26
+ - Semantic color ramp on head glyph + age text + row glyphs only (success <5m, warning to 30m, error past it; queued caps at amber — waiting is not broken; meter stays progress-colored, fleet line untouched). Color never rides alone: glyph shape (▶/◉/⚠) plus the silence number carry the same meaning unpainted, and a no-theme render contains zero ANSI. Coverage: `tests/lifesign.test.ts` (7 tests: bands incl. queued cap, empty-invents-nothing, counter-derived breath, head readout/triangle/pause/bare, row color + unpainted shape+number, 80-col fit + bucket key-stability) plus reshaped pins in `display`/`agents-panel`/`subagent-display-richness`.
27
+
3
28
  ## 0.38.22 — subagent display unification: richness ladder + upstream triple-render report (2026-09-05)
4
29
 
5
30
  ### Fixed
@@ -29,15 +29,29 @@ holds (no new instrumentation):
29
29
  - Ledger history of `subagent_hang_detected` for the "Recent hangs" footer
30
30
  of the panel.
31
31
 
32
- Row shape:
32
+ Row shape (shipped one-glyph-line since v0.38.23: age-first single lines
33
+ replace the v0.38.22 two-line shape — footers deleted, ids live in
34
+ `/glla agents`; audit 2026-09-07 verified this against the doc and kept
35
+ the code — the doc, not the pins, was stale):
33
36
 
34
37
  ```
35
- ● explore map-model-picker RUNNING 4m12s tools 18 · out 2.1k · silent 0m
36
- ● plan audit-contract HUNG? 31m tools 6 → frozen · out 890 · silent 26m
37
- └ blocks: foreground subagent call (zombie stand-down active)
38
- ✓ explore schema-check ENDED ok 3m44s
38
+ ▶ explore · map-model-picker · active 8s
39
+ ▶ plan · audit-contract · quiet 26m
40
+ ⚠ plan · stuck-render · HUNG · quiet 31m · id e5f6…
39
41
  ```
40
42
 
43
+ Troubled rows append the short id; healthy rows stay self-contained
44
+ without the fleet panel (the v0.38.22 `└ check the Agents panel` footer
45
+ is gone — the row itself says what is wrong). `/glla agents` keeps the
46
+ full detail view (recent hangs read the durable `subagent_hang_detected`
47
+ ledger, absent when empty — never a placeholder).
48
+
49
+ The `└ blocks:` row renders only from an OBSERVED in-flight parent
50
+ subagent wait (tool args carry no run id, so per-row correlation is
51
+ impossible — the wait NAME is named, never inferred). The "Recent hangs"
52
+ footer reads the durable `subagent_hang_detected` ledger (last 3, absent
53
+ when empty — never a placeholder).
54
+
41
55
  Hung classification reuses `classifyHungSubagents` semantics (record-frozen
42
56
  vs event-only evidence). Cap display at ~20 rows; prune ended probes per
43
57
  the existing SUBAGENT_HANG_PRUNE_MS rule.
@@ -83,8 +97,13 @@ safety property by other means — `renderAgentsWidgetLines` buckets silence
83
97
  ages exactly like the compact line, so the widget key only moves on genuine
84
98
  state transitions — and restores rich ambient rows behind the
85
99
  `subagentDisplayRichness` ladder (`rich` default / `compact` / `quiet`, HUNG
86
- never silent), plus the task-linkage header (`→ <objective>`) only GLLA can
87
- show. Triplication/ordering against pi-subagents native panels is upstream
100
+ never silent), plus — until v0.38.23 — the task-linkage header
101
+ (`→ <objective>`) only GLLA can show. v0.38.23 removes the header (the
102
+ card head already names the objective), collapses worker rows to one
103
+ glyph-first line each, forces the card below the chat
104
+ (`{ placement: "belowEditor" }`), and adds the evidence lifesign
105
+ (counter-derived breathing glyph + `stream {age}` readout + semantic
106
+ band colors on head and rows, queued capped at amber). Triplication/ordering against pi-subagents native panels is upstream
88
107
  (nicobailon/pi-subagents#1931, read-only); GLLA owns its slot only and
89
108
  documents the native escapes. Assembly is the pure `assembleAgentsExtras`
90
109
  (pinned in `tests/subagent-display-richness.test.ts`).
package/docs/INDEX.md CHANGED
@@ -18,15 +18,17 @@ 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.22 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
25
25
  subagent recovery, bounded repair/replan recovery, production RPC child
26
26
  stopping, mandatory hermetic auditor-extension validation, optional provider
27
27
  extensions, bounded zero-stream retry containment, crash-safe persistence,
28
- and packed-artifact release verification — see CHANGELOG.md for the full
29
- trail.
28
+ packed-artifact release verification, and the 2026-09-07 display/lifecycle/
29
+ settings audit pass (abort-latch send guards, ownership compare-and-swap,
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.
30
32
  - `../README.md` — what the plugin is, install, quickstart, and the
31
33
  architectural guarantee (drafting + confirm + detached auditor).
32
34
  - `../INSTALL.md` — source install / local development setup; the recommended
@@ -35,6 +37,7 @@ For shipped docs, the relevant entry points are:
35
37
 
36
38
  ## Entry points
37
39
  - `../README.md` — what the plugin is, install, quickstart
40
+ - `SETTINGS.md` — canonical settings reference (files, precedence, every key)
38
41
  - `../INSTALL.md` — source install / local development setup
39
42
  - `../CHANGELOG.md` — user-facing changelog; the first versioned heading is
40
43
  the current released package version (use `/glla version` to compare with
@@ -0,0 +1,85 @@
1
+ # GLLA settings reference
2
+
3
+ Canonical settings reference (audit 2026-09-06 — settings truth previously
4
+ lived only in the menu/headless renderers and the changelog). The live
5
+ views remain `/glla` (headless display with provenance) and the `/glla`
6
+ settings menu; this file is the shipped, reviewable contract. A release-gate
7
+ test pins that every `SETTINGS_KEYS` entry in `extensions/goal-settings.ts`
8
+ appears here, so the table cannot silently drift.
9
+
10
+ ## Files and precedence
11
+
12
+ - Global file: `~/.pi/agent/glla/settings.json` (machine/provider policy).
13
+ - Project file: `<cwd>/.pi-glla/settings.json` (project artifact).
14
+ - Effective value: project wins over global wins over built-in default.
15
+ - `/glla` shows per-key provenance (`project` / `global` / `default`).
16
+ - Hand-edited files are normalized on every load
17
+ (`normalizeLoadedSettings`): unknown enums fall back, out-of-range
18
+ numbers reset to unset (consumer `??` fallbacks apply), junk strings are
19
+ dropped, and legacy keys migrate (`reviewer` → `postaudit`,
20
+ `auditorModelFallback` → `auditorModelFallbacks`,
21
+ `hourlyQuotaProbe` → `hourlyRetryProbe`).
22
+
23
+ ## Global-only keys
24
+
25
+ These describe machine/provider policy, not a project artifact. Project
26
+ copies are ignored (the recovery runtime reads the global file):
27
+
28
+ `stateRoot`, `mainModelFallbacks`, `mainModelRetryMinutes`,
29
+ `mainModelFailback`, `mainModelPrimaryProbeMinutes`, `hourlyRetryProbe`,
30
+ `autoResume`, `drafterModel`, `drafterThinkingLevel`,
31
+ `drafterModelFallbacks`, `compactorModel`, `compactorModelFallbacks`,
32
+ `auditorModelFallbacks`, `auditorToolTimeoutMs`, `auditorStallMs`,
33
+ `auditJobRetentionMs`, `auditorInspection`.
34
+
35
+ ## Keys
36
+
37
+ | Key | Default | Meaning |
38
+ | --- | ------- | ------- |
39
+ | `stateRoot` | `"workingDir"` | Where durable state lives; `"sessionDir"` is explicit opt-in. Global-only. |
40
+ | `mainModelFallbacks` | `[]` | Ordered provider/model refs on main-model failure. Global-only. |
41
+ | `drafterModel` | unset (session) | Drafting-only primary model. Global-only. |
42
+ | `drafterThinkingLevel` | unset (inherit) | Thinking level for the drafting agent. Global-only. |
43
+ | `drafterModelFallbacks` | `[]` | Ordered drafting fallbacks; session model is final. Global-only. |
44
+ | `compactorModel` | unset (plan B) | Emergency-compactor primary; never the session model. Global-only. |
45
+ | `compactorModelFallbacks` | `[]` | Ordered compactor fallbacks; no session last resort. Global-only. |
46
+ | `mainModelRetryMinutes` | `15` | Base minutes before main-session recovery; doubles per attempt, caps 5h. Global-only. |
47
+ | `mainModelFailback` | `"auto"` | `"auto"` re-probes the primary; `"sticky"` keeps the fallback. Global-only. |
48
+ | `mainModelPrimaryProbeMinutes` | `15` | Minutes between preferred-primary health probes. Global-only. |
49
+ | `forbiddenModels` | `[]` | Refs that must never be selected (case-insensitive substring). |
50
+ | `blockForbiddenModelSwitches` | `true` | Revert forbidden switches (`false` = stand but ledger). |
51
+ | `visionAssist` | `true` | Continuation prompts carry the prefer-native-vision directive. |
52
+ | `auditorModel` | unset (session) | Detached auditor primary (`"provider/id"` or bare id). |
53
+ | `auditorModelFallbacks` | `[]` | Ordered auditor fallbacks; session model is final. Global-only. |
54
+ | `auditorAllowedExtensions` | `[]` | Extension specs the detached auditor may load; resolved fail-closed, `[]` = isolated. |
55
+ | `auditorSameSessionSwap` | `true` | Walk the fallback chain when the auditor is the session model. |
56
+ | `auditorThinkingLevel` | unset (inherit) | Detached auditor reasoning level; picked with the model. |
57
+ | `auditorToolTimeoutMs` | `300000` | Base budget per auditor tool call (30s–6h). Global-only. |
58
+ | `auditorStallMs` | `600000` | Base silence budget for the detached auditor (1m–24h). Global-only. |
59
+ | `auditJobRetentionMs` | `900000` | How long proven-dead audit job dirs are kept (0–7d, 0 = reap now). Global-only. |
60
+ | `auditorInspection` | `false` | Auditor runs as a persistent session you can tail/resume. Global-only. |
61
+ | `notifyCmd` | unset | Shell command on goal complete / pause / loop stop; message is `$1`. |
62
+ | `tokenLimit` | unset (off) | Per-goal token budget; crossing it pauses. `0` = off. |
63
+ | `wedgeAlertMinutes` | unset (30, or off while aggressive mode is on — the default) | Busy-but-silent minutes before the wedge alert; `0` = off. The menu shows the effective value. |
64
+ | `autoResume` | `false` | Restored goals/loops/lists auto-resume in fresh sessions. Global-only. |
65
+ | `decisionPopup` | `true` | Decision pauses pop the picker (`false` = widget card only). |
66
+ | `carryover` | `"pause"` | Stale carryover on new activation: `"pause"` / `"clear"` / `"resume"`. |
67
+ | `autoAcceptDrafts` | `false` | Drafts activate without the Confirm dialog (unattended rigs). |
68
+ | `auditCap` | `5` (10 aggressive) | Pause after N consecutive auditor disapprovals (`0` = unlimited). |
69
+ | `auditFeedbackChars` | `0` (full) | Max auditor-report chars returned after disapproval (`0` = full). |
70
+ | `auditorSilent` | `true` | Auditor report renders final-only, no word-by-word HUD. |
71
+ | `auditorProgressSignals` | `true` | Silent audits show phase label + byte counter. |
72
+ | `hourlyRetryProbe` | `true` | Extra blind retry at :00:30 every hour while parked. Global-only. |
73
+ | `subagentModelStrategy` | `"inherit-parent"` | Default subagent model policy for new sessions. |
74
+ | `subagentModelOverrides` | unset | Per-agent-type model pin; always wins over strategy. |
75
+ | `subagentFallbacks` | unset | Per-role fallback chains (first eligible ref wins). |
76
+ | `aggressiveMode` | `true` | Keep-going defaults (`false` = pause-first policy). |
77
+ | `stuckMaxInterventions` | `5` (10 aggressive) | Consecutive stuck interventions before a loop stops. |
78
+ | `subagentHangEscalationMinutes` | `30` | Confirmed no-progress minutes before child-specific abort (`0` = warn only). |
79
+ | `stallEscalationRefires` | `5` | Heartbeat refires before pause/stop (`0` = never). |
80
+ | `zombieRetryMaxAttempts` | `3` | Re-dispatches per busy/no-stream episode (`0` = manual; max 10). |
81
+ | `stallShortWords` | `15` | Tool-less turn under this many words is a nudge. |
82
+ | `stallSimilarityThreshold` | `0.6` | Trigram similarity above this (tool-less) is a nudge. |
83
+ | `postaudit` | unset | Post-completion audit config (same shape as legacy `reviewer`). |
84
+ | `toolOverrides` | unset | Per-tool allow/hide/per-tool-config overrides. |
85
+ | `reviewer` | legacy | Deprecated alias for `postaudit`; migrated on load, `postaudit` wins. |
@@ -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
+ }
@@ -185,7 +185,10 @@ export function resolveAuditorAllowedExtensions(
185
185
  return out;
186
186
  }
187
187
 
188
- /** Bounded, deterministic normalization of the allowlist value. */
188
+ /** Bounded, deterministic normalization of the allowlist value.
189
+ * Audit 2026-09-06: dedup is case-insensitive like the model fallback
190
+ * chains (npm names cannot differ by case; a same-case pair is always the
191
+ * same intent). The first spelling wins; the original is preserved. */
189
192
  export function normalizeAuditorAllowedExtensions(value: unknown): string[] {
190
193
  if (!Array.isArray(value)) return [];
191
194
  const seen = new Set<string>();
@@ -193,9 +196,10 @@ export function normalizeAuditorAllowedExtensions(value: unknown): string[] {
193
196
  for (const entry of value) {
194
197
  if (typeof entry !== "string") continue;
195
198
  const spec = entry.trim();
196
- if (!spec || seen.has(spec)) continue;
199
+ const key = spec.toLowerCase();
200
+ if (!spec || seen.has(key)) continue;
197
201
  if (out.length >= 32) break; // bounded: the request hash stays sane
198
- seen.add(spec);
202
+ seen.add(key);
199
203
  out.push(spec);
200
204
  }
201
205
  return out;
@@ -294,10 +298,19 @@ export function discoverAuditorExtensions(home: string, cwd?: string): Discovere
294
298
  }
295
299
 
296
300
  /** Expand the allowlist into `pi` CLI args: ["--extension", spec, …].
297
- * Empty/absent list → [] (the worker keeps plain --no-extensions). */
298
- export function auditorExtensionArgs(allowed: string[] | undefined): string[] {
301
+ * Audit 2026-09-06: entries resolve fail-closed through
302
+ * resolveAuditorAllowedExtensions first — raw `npm:`/relative specs are
303
+ * never emitted (a raw npm: entry would trigger a fresh network install,
304
+ * or silently load nothing offline). Empty/absent list → [] (the worker
305
+ * keeps plain --no-extensions). */
306
+ export function auditorExtensionArgs(
307
+ allowed: string[] | undefined,
308
+ home: string,
309
+ cwd?: string,
310
+ settingsBase?: string,
311
+ ): string[] {
299
312
  const args: string[] = [];
300
- for (const spec of normalizeAuditorAllowedExtensions(allowed)) {
313
+ for (const spec of resolveAuditorAllowedExtensions(allowed, home, cwd, settingsBase)) {
301
314
  args.push("--extension", spec);
302
315
  }
303
316
  return args;
@@ -1,4 +1,5 @@
1
1
  import type { Goal, Status } from "./goal-loop-core.js";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
3
 
3
4
  /**
4
5
  * The durable, user-facing terminal recap contract. Keep this as a small
@@ -172,26 +173,118 @@ export function withoutStaleNext(details: string[] | undefined): string[] {
172
173
  * informing details (the full record lives in the archive and the
173
174
  * transcript notice), then the approval trailer and the record pointer.
174
175
  * Five 120-char label lines scan as soup, not a summary (field
175
- * 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). */
176
179
  export function buildApprovalChatLines(notice: {
177
180
  outcome: string;
178
181
  details: string[] | undefined;
179
182
  approval: string;
180
183
  record: string;
184
+ counts?: string;
181
185
  }): string[] {
182
186
  return [
183
187
  `✓ done — ${notice.outcome}`,
184
188
  ...withoutStaleNext(notice.details).slice(0, 2),
185
189
  notice.approval,
190
+ ...(notice.counts ? [notice.counts] : []),
186
191
  notice.record,
187
192
  ];
188
193
  }
189
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
+
190
283
  /** Multi-line projection: one `Label: value` line per label with generous
191
284
  * word-bounded values. This is the user-facing `✓ done` block — six short
192
285
  * facts that stay scannable in chat. The single-line projection remains
193
286
  * for width-bound surfaces (TUI widget card, external notifies). */
194
- export function completionSummaryLines(text: string | undefined, maxValueLength = 240): string[] {
287
+ export function completionSummaryLines(text: string | undefined, maxValueLength = 240, lineWidth?: number): string[] {
195
288
  const source = completionSummaryBody(text ?? "").replace(/\s+/g, " ").trim();
196
289
  const lower = source.toLowerCase();
197
290
  const positions = COMPLETION_SUMMARY_LABELS
@@ -207,7 +300,10 @@ export function completionSummaryLines(text: string | undefined, maxValueLength
207
300
  .map((entry) => entry.start)
208
301
  .sort((a, b) => a - b)[0] ?? source.length;
209
302
  const rawValue = source.slice(valueStart, nextStart).trim();
210
- return `${name}: ${clipSummaryValue(rawValue || "not recorded", maxValueLength)}`;
303
+ const line = `${name}: ${clipSummaryValue(rawValue || "not recorded", maxValueLength)}`;
304
+ // Audit 2026-09-06: optional width budget for width-bound surfaces —
305
+ // the default 240-char values previously had no width-conscious path.
306
+ return lineWidth && lineWidth > 0 ? truncateToWidth(line, lineWidth, "…") : line;
211
307
  });
212
308
  }
213
309
 
@@ -322,8 +418,9 @@ export function terminalCompletionSummaryLines(
322
418
  facts: CompletionSummaryFacts,
323
419
  candidate = facts.goal.completionSummary,
324
420
  maxValueLength = 240,
421
+ lineWidth?: number,
325
422
  ): string[] {
326
- return completionSummaryLines(resolveCompletionSummary(facts, candidate).summary, maxValueLength);
423
+ return completionSummaryLines(resolveCompletionSummary(facts, candidate).summary, maxValueLength, lineWidth);
327
424
  }
328
425
 
329
426
  /**