pi-goal-list-loop-audit 0.15.0

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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * pi-goal-list-loop-audit — v0.1.0
3
+ * extensions/goal-loop-auditor.ts
4
+ *
5
+ * Isolated completion auditor. Runs in a fresh pi agent session with no
6
+ * extensions, no skills, no prompts, no themes, no editor. Only read tools.
7
+ *
8
+ * In v0.1.0 the auditor behaves the same as pi-goal-x's auditor: it must
9
+ * call at least one read tool before <approved/>.
10
+ *
11
+ * In v0.2.0 (NOT YET IMPLEMENTED) we will add regression_shield: the
12
+ * auditor's report must include raw output (cat / grep / bash) for every
13
+ * must-verify item in the verification contract. The orchestrator will
14
+ * reject <approved/> without that evidence.
15
+ */
16
+
17
+ import type { Model } from "@earendil-works/pi-ai";
18
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
19
+ import {
20
+ createAgentSession,
21
+ createExtensionRuntime,
22
+ SessionManager,
23
+ SettingsManager,
24
+ type ExtensionContext,
25
+ type ResourceLoader,
26
+ } from "@earendil-works/pi-coding-agent";
27
+
28
+ import type { Goal } from "./goal-loop-core.js";
29
+ import { renderGoalMarkdown } from "./goal-loop-core.js";
30
+
31
+ // =================================================================
32
+ // Result type
33
+ // =================================================================
34
+
35
+ export interface GoalAuditorResult {
36
+ approved: boolean;
37
+ disapproved: boolean;
38
+ output: string;
39
+ model: string;
40
+ thinkingLevel?: ThinkingLevel;
41
+ error?: string;
42
+ /** regression_shield outcome when the goal has a verification contract. */
43
+ regressionShieldPassed?: boolean;
44
+ regressionShieldMissing?: string[];
45
+ }
46
+
47
+ // =================================================================
48
+ // Audit log: every tool call the auditor made, with first ~120 chars of args.
49
+ // We use this to enforce "must call at least one tool" and (in v0.2.0)
50
+ // to enforce "must include raw evidence".
51
+ // =================================================================
52
+
53
+ export interface AuditProgress {
54
+ recentOutput: string[];
55
+ phase: "starting" | "running" | "thinking" | "tool_executing" | "producing_report" | "complete";
56
+ elapsedMs: number;
57
+ label?: string;
58
+ percentage?: number;
59
+ currentTool?: string;
60
+ currentToolArgs?: string;
61
+ currentToolStartedAt?: number;
62
+ // Tool-call history for regression_shield:
63
+ toolCalls: Array<{ name: string; argsPrefix: string; finishedAt: number }>;
64
+ }
65
+
66
+ export type AuditorProgressCallback = (progress: AuditProgress) => void;
67
+
68
+ // =================================================================
69
+ // Auditor resource loader — zero extensions, zero skills, zero prompts
70
+ // =================================================================
71
+
72
+ function makeAuditorResourceLoader(): ResourceLoader {
73
+ return {
74
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
75
+ getSkills: () => ({ skills: [], diagnostics: [] }),
76
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
77
+ getThemes: () => ({ themes: [], diagnostics: [] }),
78
+ getAgentsFiles: () => ({ agentsFiles: [] }),
79
+ getSystemPrompt: () => [
80
+ "You are a read-only completion auditor running in an isolated pi agent session.",
81
+ "Inspect the repository and decide whether the claimed goal completion is genuinely satisfied.",
82
+ "Never modify files. Never approve unless the actual user objective is complete.",
83
+ ].join("\n"),
84
+ getAppendSystemPrompt: () => [],
85
+ extendResources: () => {},
86
+ reload: async () => {},
87
+ };
88
+ }
89
+
90
+ // =================================================================
91
+ // Auditor prompt
92
+ // =================================================================
93
+
94
+ function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | undefined, verificationSummary: string | null | undefined): string {
95
+ const goalMd = renderGoalMarkdown(goal);
96
+ return [
97
+ "You are the independent completion auditor for pi-goal-list-loop-audit.",
98
+ "The executor claims the goal is complete. Your job is to decide whether the user's objective is actually satisfied.",
99
+ "Be skeptical and semantic. Do not approve from paperwork, intent, file count, word count, build success, or a plausible summary alone.",
100
+ "Use read/grep/find/ls/bash as needed to inspect real artifacts. Do not mutate files or run destructive commands.",
101
+ "If the work is only an alpha scaffold, generated template, shallow draft, proxy milestone, or lacks the user-facing value requested, disapprove.",
102
+ "If any explicit requirement is missing, weakly verified, contradicted, or not inspectable with the available evidence, disapprove.",
103
+ "Return a concise audit report. The final line MUST be exactly one of:",
104
+ "<approved/>",
105
+ "<disapproved/>",
106
+ "",
107
+ "Goal markdown (full state):",
108
+ "<goal>",
109
+ goalMd,
110
+ "</goal>",
111
+ "",
112
+ "Executor completion claim:",
113
+ "<completion_summary>",
114
+ (completionSummary?.trim() || "(none provided)"),
115
+ "</completion_summary>",
116
+ ...(verificationSummary?.trim() ? [
117
+ "",
118
+ "Executor verification summary:",
119
+ "<verification_summary>",
120
+ verificationSummary.trim(),
121
+ "</verification_summary>",
122
+ ] : []),
123
+ ...(goal.verificationContract?.trim() ? [
124
+ "",
125
+ "Goal verification contract (what the executor was required to verify):",
126
+ "<verification_contract>",
127
+ goal.verificationContract.trim(),
128
+ "</verification_contract>",
129
+ ] : []),
130
+ "",
131
+ "Audit checklist:",
132
+ "1. Extract the real success criteria from the objective, including quality/reader outcomes.",
133
+ "2. Inspect artifacts or command output that can prove or disprove those criteria.",
134
+ ...(verificationSummary?.trim()
135
+ ? ["3. Check the <verification_summary> against real artifacts. If the executor claims to have run tests or searched for references, verify those claims with actual file/shell evidence. The summary is a claim, not proof — cross-check it."]
136
+ : []),
137
+ ...(goal.verificationContract?.trim()
138
+ ? ["4. Verify that the executor has satisfied every item in the <verification_contract>. If any item is missing or weakly addressed, disapprove."]
139
+ : []),
140
+ "5. Explain missing or weak evidence, especially scaffold-vs-final quality gaps.",
141
+ "6. End with exactly <approved/> only if the objective is truly complete; otherwise end with exactly <disapproved/>.",
142
+ ...(goal.verificationContract?.trim()
143
+ ? [
144
+ "",
145
+ "REGRESSION SHIELD (mandatory because this goal has a verification contract):",
146
+ "Your report MUST contain an <evidence> section. For EACH item in the verification contract,",
147
+ "quote the item, then paste the RAW tool output that proves it (real bash/grep/read output,",
148
+ "copied verbatim — not a paraphrase, not a description of what you saw). Format:",
149
+ "",
150
+ "<evidence>",
151
+ "Item: <contract item 1>",
152
+ "Output:",
153
+ "<raw command output here>",
154
+ "Item: <contract item 2>",
155
+ "Output:",
156
+ "<raw command output here>",
157
+ "</evidence>",
158
+ "",
159
+ "An approval without a complete <evidence> section will be rejected automatically.",
160
+ ]
161
+ : []),
162
+ ].join("\n");
163
+ }
164
+
165
+ // regression_shield lives in goal-loop-shield.ts (dependency-free, so unit
166
+ // tests can import it without pulling in pi). Re-exported for callers.
167
+ export { checkRegressionShield, contractItems, type RegressionShieldResult } from "./goal-loop-shield.js";
168
+ import { checkRegressionShield } from "./goal-loop-shield.js";
169
+
170
+ // =================================================================
171
+ // Auditor entry point
172
+ // =================================================================
173
+
174
+ export async function runGoalCompletionAuditor(args: {
175
+ ctx: ExtensionContext;
176
+ goal: Goal;
177
+ completionSummary?: string | null;
178
+ verificationSummary?: string | null;
179
+ model?: Model<any>;
180
+ thinkingLevel?: ThinkingLevel;
181
+ signal?: AbortSignal;
182
+ onProgress?: AuditorProgressCallback;
183
+ }): Promise<GoalAuditorResult> {
184
+ const ctx = args.ctx;
185
+ // Default to the session's current model when no dedicated auditor model is
186
+ // configured (pi-goal-x's resolveAuditorModel does the same). A missing
187
+ // auditor model must never be a silent audit failure.
188
+ const model = args.model ?? ctx.model;
189
+ const thinkingLevel = args.thinkingLevel ?? "medium";
190
+ const outputParts: string[] = [];
191
+ if (!model) {
192
+ return { approved: false, disapproved: false, output: "", model: "(unset)", thinkingLevel, error: "no model (session model also unset)" };
193
+ }
194
+ const toolCalls: AuditProgress["toolCalls"] = [];
195
+
196
+ try {
197
+ const startedAt = Date.now();
198
+ const progress: AuditProgress = {
199
+ recentOutput: [],
200
+ phase: "running",
201
+ elapsedMs: 0,
202
+ toolCalls,
203
+ };
204
+ function emitProgress(): void {
205
+ progress.elapsedMs = Date.now() - startedAt;
206
+ args.onProgress?.({ ...progress });
207
+ }
208
+
209
+ const { session } = await createAgentSession({
210
+ cwd: ctx.cwd,
211
+ model,
212
+ thinkingLevel,
213
+ modelRegistry: ctx.modelRegistry,
214
+ resourceLoader: makeAuditorResourceLoader(),
215
+ sessionManager: SessionManager.inMemory(ctx.cwd),
216
+ // Compaction ENABLED (v0.4.0, closes pi-goal-x flaw #3: context exhaustion
217
+ // mid-audit). Safety: regression_shield is orchestrator-side — if compaction
218
+ // degrades the auditor's memory, its <evidence> block gets weaker and the
219
+ // orchestrator disapproves. Compaction can never cause a false approval.
220
+ settingsManager: SettingsManager.inMemory({ compaction: { enabled: true } }),
221
+ tools: ["read", "grep", "find", "ls", "bash"],
222
+ });
223
+ let streamError: string | undefined;
224
+ const unsub = session.subscribe((event) => {
225
+ // Capture provider/stream errors (401/403/429/credits) — these often
226
+ // arrive as events rather than thrown exceptions, and without this they
227
+ // surface as a silent empty report that looks like a disapproval.
228
+ const anyEvent = event as any;
229
+ if (anyEvent.type === "error" || anyEvent.error || anyEvent.type === "auto_retry_start") {
230
+ const msg = anyEvent.error?.message ?? anyEvent.message ?? anyEvent.errorMessage;
231
+ if (typeof msg === "string") streamError = msg.slice(0, 300);
232
+ }
233
+ if (event.type === "tool_execution_start") {
234
+ progress.currentTool = event.toolName;
235
+ progress.currentToolArgs = typeof event.args === "object" && event.args !== null
236
+ ? JSON.stringify(event.args).slice(0, 120)
237
+ : String(event.args ?? "").slice(0, 120);
238
+ progress.currentToolStartedAt = Date.now();
239
+ progress.phase = "tool_executing";
240
+ emitProgress();
241
+ return;
242
+ }
243
+ if (event.type === "tool_execution_end") {
244
+ if (progress.currentTool) {
245
+ toolCalls.push({
246
+ name: progress.currentTool,
247
+ argsPrefix: progress.currentToolArgs ?? "",
248
+ finishedAt: Date.now(),
249
+ });
250
+ }
251
+ progress.currentTool = undefined;
252
+ progress.currentToolArgs = undefined;
253
+ progress.currentToolStartedAt = undefined;
254
+ progress.phase = "running";
255
+ emitProgress();
256
+ return;
257
+ }
258
+ if (event.type === "message_end") {
259
+ const message = event.message as any;
260
+ if (message?.role !== "assistant") return;
261
+ for (const part of message.content ?? []) {
262
+ if (part.type === "text" && typeof part.text === "string") outputParts.push(part.text);
263
+ }
264
+ const fullText = outputParts.join("\n\n");
265
+ const lines = fullText.split("\n").filter((l: string) => l.trim());
266
+ progress.recentOutput = lines.slice(-8);
267
+ emitProgress();
268
+ return;
269
+ }
270
+ });
271
+ const abort = () => { session.abort(); };
272
+ args.signal?.addEventListener("abort", abort, { once: true });
273
+
274
+ progress.label = "Starting audit...";
275
+ progress.percentage = 0;
276
+ emitProgress();
277
+
278
+ try {
279
+ if (args.signal?.aborted) {
280
+ return { approved: false, disapproved: false, output: "", model: modelLabel(model), thinkingLevel, error: "Auditor aborted." };
281
+ }
282
+ await session.prompt(buildGoalAuditorPrompt(args.goal, args.completionSummary, args.verificationSummary));
283
+ } finally {
284
+ unsub();
285
+ }
286
+
287
+ const output = outputParts.join("\n\n");
288
+
289
+ // SILENT-FAILURE GUARD (v0.9.9, wild-caught): an auditor that produced
290
+ // nothing — or produced text with no verdict marker — did not VERDICT.
291
+ // That is an infrastructure result (dead model, quota, stream error),
292
+ // not a disapproval, and must never be recorded as one.
293
+ if (!output.trim()) {
294
+ return {
295
+ approved: false,
296
+ disapproved: false,
297
+ output,
298
+ model: modelLabel(model),
299
+ thinkingLevel,
300
+ error: `Auditor produced no output${streamError ? `: ${streamError}` : " — the auditor session likely failed (check the model's auth/quota, or set a working one with /gla model=provider/id)"}`,
301
+ };
302
+ }
303
+
304
+ const lastAssistant = [...outputParts].reverse().find((t) => /<\/?(approved|disapproved)\/>/i.test(t)) ?? output;
305
+ const approved = /<approved\/>/i.test(lastAssistant);
306
+ const disapproved = /<disapproved\/>/i.test(lastAssistant);
307
+
308
+ if (!approved && !disapproved) {
309
+ return {
310
+ approved: false,
311
+ disapproved: false,
312
+ output,
313
+ model: modelLabel(model),
314
+ thinkingLevel,
315
+ error: `Auditor produced no verdict marker (<approved/>/<disapproved/>)${streamError ? ` — stream error: ${streamError}` : ""}. Treating as an error, not a verdict.`,
316
+ };
317
+ }
318
+
319
+ // v0.1.0 honesty: must call at least one read tool, otherwise it didn't really audit.
320
+ const usedReadTool = toolCalls.some((c) => ["read", "grep", "find", "ls", "bash"].includes(c.name));
321
+
322
+ if (approved && !usedReadTool) {
323
+ return {
324
+ approved: false,
325
+ disapproved: true,
326
+ output,
327
+ model: modelLabel(model),
328
+ thinkingLevel,
329
+ error: "Auditor approved without calling any read tool; treated as disapproved.",
330
+ };
331
+ }
332
+
333
+ // regression_shield: an approval against a verification contract must
334
+ // carry per-item raw evidence, or it is converted to a disapproval.
335
+ if (approved && args.goal.verificationContract?.trim()) {
336
+ const shield = checkRegressionShield(output, args.goal.verificationContract);
337
+ if (!shield.passed) {
338
+ const why = !shield.hasEvidenceBlock
339
+ ? "report has no <evidence> block"
340
+ : `report's evidence does not address: ${shield.missingItems.join("; ")}`;
341
+ return {
342
+ approved: false,
343
+ disapproved: true,
344
+ output,
345
+ model: modelLabel(model),
346
+ thinkingLevel,
347
+ error: `regression_shield: approved but ${why}`,
348
+ regressionShieldPassed: false,
349
+ regressionShieldMissing: shield.missingItems,
350
+ };
351
+ }
352
+ progress.phase = "complete";
353
+ emitProgress();
354
+ return {
355
+ approved,
356
+ disapproved,
357
+ output,
358
+ model: modelLabel(model),
359
+ thinkingLevel,
360
+ regressionShieldPassed: true,
361
+ };
362
+ }
363
+
364
+ progress.phase = "complete";
365
+ emitProgress();
366
+ return { approved, disapproved, output, model: modelLabel(model), thinkingLevel };
367
+ } catch (err) {
368
+ // v0.11.1 (audit critical): a runtime exception is INFRASTRUCTURE, never
369
+ // a verdict. The three-way split identifies infra by `error &&
370
+ // !disapproved` — setting disapproved here would silently route this to
371
+ // the semantic-disapproval branch (the bug v0.9.9 was built to kill).
372
+ return {
373
+ approved: false,
374
+ disapproved: false,
375
+ output: "",
376
+ model: modelLabel(model),
377
+ thinkingLevel,
378
+ error: err instanceof Error ? err.message : String(err),
379
+ };
380
+ }
381
+ }
382
+
383
+ // =================================================================
384
+ // Model label helper
385
+ // =================================================================
386
+
387
+ function modelLabel(model: Model<any>): string {
388
+ if (typeof model === "string") return model;
389
+ if (model && typeof model === "object" && "id" in model) return (model as { id: string }).id;
390
+ return "(unknown model)";
391
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * pi-goal-list-loop-audit — v0.1.0
3
+ * extensions/goal-loop-backoff.ts
4
+ *
5
+ * Hard 5-minute ceiling on backoff. Anything beyond the ceiling pauses the
6
+ * loop and notifies the user (TUI badge + optional push).
7
+ *
8
+ * Design: see docs/DESIGN.md, decision #3.
9
+ */
10
+
11
+ export const BACKOFF_HARD_CAP_MS = 5 * 60 * 1000;
12
+ export const BACKOFF_IDLE_RETRY_MS = 50; // when adding another iter to queue
13
+ export const BACKOFF_ERROR_BASE_MS = 5_000; // first error retry
14
+ export const BACKOFF_ERROR_MAX_MS = 60_000; // max error retry (separate from stuck cap)
15
+
16
+ /**
17
+ * Return the backoff (ms) before scheduling the next iteration, based on
18
+ * consecutive iterations that produced no meaningful progress.
19
+ *
20
+ * Caps at BACKOFF_HARD_CAP_MS (5 min). Beyond that, the orchestrator should
21
+ * pause and notify the user.
22
+ */
23
+ export function backoffMs(stuckCount: number, mode: "stuck" | "error" | "context" = "stuck"): number {
24
+ if (mode === "error") {
25
+ return Math.min(BACKOFF_ERROR_BASE_MS * 2 ** Math.max(0, stuckCount - 1), BACKOFF_ERROR_MAX_MS);
26
+ }
27
+ if (mode === "context") {
28
+ return Math.min(30_000 * Math.max(1, stuckCount), BACKOFF_HARD_CAP_MS);
29
+ }
30
+ // "stuck" — the main case the user complained about.
31
+ const schedule = [0, 30_000, 60_000, 120_000, 240_000, BACKOFF_HARD_CAP_MS];
32
+ const idx = Math.max(0, Math.min(schedule.length - 1, stuckCount));
33
+ return schedule[idx] ?? BACKOFF_HARD_CAP_MS;
34
+ }
35
+
36
+ /**
37
+ * Determine whether the orchestrator should pause (vs. reschedule).
38
+ *
39
+ * Pause conditions:
40
+ * - stuck for >= 5 minutes
41
+ * - any single iteration has been silent (no tool call) for > N seconds
42
+ */
43
+ export function shouldPauseAfterBackoff(stuckElapsedMs: number, idleIterCount: number): boolean {
44
+ if (stuckElapsedMs >= BACKOFF_HARD_CAP_MS) return true;
45
+ if (idleIterCount >= 3) return true;
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Human-readable label, e.g. "5m", "30s", "1m".
51
+ */
52
+ export function humanMs(ms: number): string {
53
+ if (ms < 1000) return `${ms}ms`;
54
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
55
+ return `${Math.round(ms / 60_000)}m`;
56
+ }
57
+
58
+ // =================================================================
59
+ // Heartbeat self-watchdog (v0.5.0)
60
+ //
61
+ // Replaces the external pi-compaction-continue plugin FOR OUR LOOPS. A goal
62
+ // loop that dies silently (compaction-eaten turn, dropped message, stale ctx)
63
+ // is a hole in this plugin, not something to outsource. One precise check
64
+ // covers every stall cause: supervising + idle + nothing scheduled + quiet
65
+ // for too long → re-fire the continuation ourselves.
66
+ // =================================================================
67
+
68
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
69
+ export const HEARTBEAT_STALL_MS = 60_000;
70
+ export const HEARTBEAT_MAX_NUDGES = 3;
71
+
72
+ export interface HeartbeatInput {
73
+ /** A goal is active (autoContinue) or a loop is running. */
74
+ supervising: boolean;
75
+ /** ctx.isIdle() && !ctx.hasPendingMessages() */
76
+ sessionIdle: boolean;
77
+ /** A continuation or loop timer is already scheduled. */
78
+ timerPending: boolean;
79
+ /** Milliseconds since the last observed agent activity. */
80
+ msSinceActivity: number;
81
+ stallMs?: number;
82
+ }
83
+
84
+ /** Should the heartbeat re-fire the continuation right now? */
85
+ export function shouldHeartbeatRefire(input: HeartbeatInput): boolean {
86
+ if (!input.supervising) return false;
87
+ if (!input.sessionIdle) return false;
88
+ if (input.timerPending) return false;
89
+ return input.msSinceActivity >= (input.stallMs ?? HEARTBEAT_STALL_MS);
90
+ }
91
+
92
+ /**
93
+ * Judge a finished turn for nudge accounting. A supervising turn with zero
94
+ * tool calls produced no real progress — that is a nudge. Anything with a
95
+ * tool call resets the counter. Returns the new consecutive-nudge count.
96
+ */
97
+ export function accountTurnForNudges(toolCalls: number, currentNudges: number): number {
98
+ return toolCalls > 0 ? 0 : currentNudges + 1;
99
+ }