pi-goal-list-loop-audit 0.38.33 → 0.38.34
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 +11 -0
- package/docs/INDEX.md +1 -1
- package/extensions/approval-render-store.ts +49 -50
- package/extensions/completion-summary.ts +3 -3
- package/extensions/goal-continuation.ts +9 -28
- package/extensions/loops/goal-activation.ts +16 -5
- package/extensions/loops/goal-auditor-hooks.ts +12 -27
- package/extensions/loops/goal-tools.ts +20 -10
- package/extensions/terminal-summary-delivery.ts +54 -0
- package/package.json +1 -1
- package/prompts/goal-loop-continuation.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.34 — completion communication: fair replay rotation + stale-test refresh (2026-09-08)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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).
|
|
9
|
+
|
|
10
|
+
### Verification
|
|
11
|
+
|
|
12
|
+
- Full `release:check`: 2034 pass / 2 skip / 0 fail across 204 files; `tsc --noEmit` clean.
|
|
13
|
+
|
|
3
14
|
## 0.38.33 — findings.md indent convention normalized (2026-09-08)
|
|
4
15
|
|
|
5
16
|
### 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.
|
|
21
|
+
v0.35.14–v0.38.34 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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
118
|
-
const deliveredBudget = Math.max(0, MAX_STORED_RENDERS - undelivered.length -
|
|
119
|
-
const next = [...undelivered, ...(
|
|
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:
|
|
112
|
+
delivered: false,
|
|
124
113
|
lines: render.chatLines.length,
|
|
125
114
|
});
|
|
126
115
|
return true;
|
|
127
116
|
}
|
|
128
117
|
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
export function replayUndeliveredApprovalRenders(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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,
|
|
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)
|
|
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 +
|
|
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
|
-
/**
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1201
|
-
].join("\n");
|
|
1186
|
+
]).join("\n");
|
|
1202
1187
|
try {
|
|
1203
|
-
flags.extensionApi.
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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:
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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 {
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.38.34",
|
|
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.
|