pi-goal-list-loop-audit 0.34.57 → 0.34.80

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/README.md CHANGED
@@ -263,8 +263,10 @@ limits and user aborts (`non-recoverable`), plus auditor watchdog timeouts
263
263
  (a hanging verification command will hang again — the stored claim waits for
264
264
  an explicit resume).
265
265
  A provider hint (`retry_after`/`reset_at`) is honored when it fits the
266
- five-hour probe budget; a week-long hint is shown and held instead of
267
- scheduling a hidden week-long timer. With global `autoResume=on`, pending
266
+ five-hour probe budget; an over-budget hint (e.g. a week-long reset) never
267
+ parks the goal — the bounded cadence owns the wait, and only the 24h horizon
268
+ ends automatic probes (a `/goal resume`/`/list resume`/`/loop resume` then
269
+ starts a fresh window). With global `autoResume=on`, pending
268
270
  probes survive a session reload. For continuous work, configure ordered
269
271
  **Main model backups** in `/glla` using a model from a different provider or
270
272
  billing/quota pool — another model on the same exhausted plan is not a real
@@ -0,0 +1,73 @@
1
+ # Vision Assist — see with mmx, not a model switch
2
+
3
+ **v0.34.72** · note.md 2026-08-07: *"the agent is too eager when couldnt see it
4
+ tried to use expensive mdoels. we need to special a vision setting where it
5
+ called another model or cli like mmx vision to see if stuck. but not just this
6
+ we need to specify that it cant be too eager to switch only preapproved."*
7
+
8
+ ## Policy
9
+
10
+ The executor (pi's main agent) has no eyes. When a task needs it to **look**
11
+ at something — a screenshot, a UI state, an error dialog, a rendered mockup —
12
+ it must NOT switch models to get vision. The check routes to the **mmx vision
13
+ CLI** (the `mmx-cli` skill, MiniMax VLM):
14
+
15
+ ```bash
16
+ mmx vision describe --image <path-or-url> --prompt "<question>" --quiet --non-interactive
17
+ ```
18
+
19
+ - The image is usually a screenshot the user already pasted into the
20
+ conversation (e.g. `/home/dracon/Pictures/Screenshots/...`). Pass its path
21
+ straight through.
22
+ - Keep the question short and specific: *"What does this screenshot show?"*,
23
+ *"Is there an error dialog?"*, *"What is the terminal output?"*.
24
+ - Reading the returned description is the agent's job — no model switch
25
+ needed. (Verified 2026-08-07: `mmx vision describe` returns clean JSON/text
26
+ with `status_code: 0`.)
27
+
28
+ ## The preapproval gate (model switches)
29
+
30
+ A model switch is sanctioned **only when the target is preapproved** — i.e.
31
+ NOT in the `forbiddenModels` policy:
32
+
33
+ - Default forbidden list: `gpt-5.5`, `sonnet`, `opus` (matched
34
+ case-insensitively as a substring against the `provider/id` ref).
35
+ - `/glla forbiddenModels=...` edits the list; `blockForbiddenModelSwitches`
36
+ (default on) reverts a forbidden selection to the previous model.
37
+ - Every switch to a forbidden model is ledgered as `forbidden_model_switch`
38
+ (with `blocked: true|false`).
39
+ - With vision assist on (default), the same event also appends a
40
+ `vision_assist` ledger entry — the routing alternative: `{ route:
41
+ "mmx-vision", blockedSwitch: <ref>, reason: "forbidden_model_switch" }`.
42
+
43
+ Even a preapproved vision-capable model is a second choice: mmx vision is the
44
+ default for every vision check.
45
+
46
+ ## The setting
47
+
48
+ `visionAssist` (default **on** — opt-out):
49
+
50
+ - **on** → every continuation prompt carries the `## VISION-ASSIST — SEE WITH
51
+ MMX, NOT A MODEL SWITCH` directive (`extensions/vision-assist.ts`
52
+ `VISION_ASSIST_GUIDANCE`), and a forbidden switch also records the
53
+ `vision_assist` routing entry.
54
+ - **off** → no vision guidance is injected; the `forbiddenModels` gate still
55
+ stands (forbidden switches remain blocked/ledgered).
56
+
57
+ Edit: `/glla` → Keep-going → Vision assist, or `/glla visionAssist=off`.
58
+
59
+ ## Implementation map
60
+
61
+ | Piece | Where |
62
+ |---|---|
63
+ | Guidance block (single source of truth) | `extensions/vision-assist.ts` → `VISION_ASSIST_GUIDANCE` |
64
+ | Command builder | `visionDescribeCommand(imagePath, question?)` |
65
+ | Routing rule (pure) | `routeVisionCheck(request)` — mmx by default; forbidden target → mmx + `blockedSwitch`; preapproved target → `model-switch` allowed |
66
+ | Ledger payload builder | `visionAssistLedger(route, request)` |
67
+ | Continuation injection | `extensions/loops/goal.ts` `continuationPrompt()` (gated on `visionAssist !== false`) |
68
+ | Forbidden-switch hook | `observeModelChange()` forbidden branch → `vision_assist` entry |
69
+ | Setting | `extensions/goal-settings.ts` (default true), menu row in `extensions/settings-menu.ts`, editor + `/glla` row in `extensions/loops/goal.ts` |
70
+ | Tests | `tests/vision-assist.test.ts` |
71
+
72
+ The `vision_assist` ledger type is the audit trail: every entry says where the
73
+ check routed and (when a switch was blocked) which model was refused.
@@ -0,0 +1,130 @@
1
+ // pi-goal-list-loop-audit — v0.2.0
2
+ // extensions/confirm-draft.ts
3
+ //
4
+ // v0.34.78 (GitHub #4): the draft-class confirm dialog as a real TUI
5
+ // component. ctx.ui.select renders plain text with no wrapping; this
6
+ // component renders the SAME title/body as Markdown (objective + contract
7
+ // readable at full width) with a SelectList for the Yes / Yes-and-always /
8
+ // No choices. Kept in its own file so tests can construct and render it
9
+ // without dragging in the whole goal loop.
10
+
11
+ import {
12
+ type Component,
13
+ Container,
14
+ Markdown,
15
+ type MarkdownTheme,
16
+ type SelectItem,
17
+ SelectList,
18
+ type SelectListTheme,
19
+ Spacer,
20
+ Text,
21
+ } from "@earendil-works/pi-tui";
22
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
23
+
24
+ export interface ConfirmDraftFactoryDeps {
25
+ title: string;
26
+ body: string;
27
+ options: string[];
28
+ }
29
+
30
+ /** Structural type for the KeybindingsManager — mirrors settings-menu.ts. */
31
+ export interface KeybindingsManagerLike {
32
+ matches(data: string, key: string): boolean;
33
+ }
34
+
35
+ /** Pure: the markdown rendered in the dialog. The title is the H1, the
36
+ * body (objective + verification contract) is the content. */
37
+ export function buildConfirmDraftMarkdown(title: string, body: string): string {
38
+ return `# ${title}\n\n${body}`;
39
+ }
40
+
41
+ /** Build a MarkdownTheme from the runtime Theme's fg()/bold() primitives.
42
+ * Uses the theme's own md* colors so the dialog follows the active theme. */
43
+ function markdownTheme(theme: Theme): MarkdownTheme {
44
+ const fg = (color: Parameters<Theme["fg"]>[0]) => (t: string) => theme.fg(color, t);
45
+ return {
46
+ heading: (t) => theme.bold(fg("mdHeading")(t)),
47
+ link: (t) => fg("mdLink")(t),
48
+ linkUrl: (t) => fg("mdLinkUrl")(t),
49
+ code: (t) => fg("mdCode")(t),
50
+ codeBlock: (t) => fg("mdCodeBlock")(t),
51
+ codeBlockBorder: (t) => fg("mdCodeBlockBorder")(t),
52
+ quote: (t) => fg("mdQuote")(t),
53
+ quoteBorder: (t) => fg("mdQuoteBorder")(t),
54
+ hr: (t) => fg("mdHr")(t),
55
+ listBullet: (t) => fg("mdListBullet")(t),
56
+ bold: (t) => theme.bold(t),
57
+ italic: (t) => t,
58
+ strikethrough: (t) => t,
59
+ underline: (t) => t,
60
+ codeBlockIndent: " ",
61
+ };
62
+ }
63
+
64
+ function selectListTheme(theme: Theme): SelectListTheme {
65
+ return {
66
+ selectedPrefix: (t) => theme.fg("accent", t),
67
+ selectedText: (t) => theme.fg("accent", t),
68
+ description: (t) => theme.fg("muted", t),
69
+ scrollInfo: (t) => theme.fg("dim", t),
70
+ noMatch: (t) => theme.fg("warning", t),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The confirm dialog: DynamicBorder frame, markdown title+body, spacer, the
76
+ * three-choice SelectList, and a help line. Exported so tests can construct
77
+ * it with a fake theme and assert the rendered lines.
78
+ */
79
+ export class ConfirmDraftComponent implements Component {
80
+ private readonly md: Markdown;
81
+ private readonly selectList: SelectList;
82
+ private readonly requestRender: () => void;
83
+ private readonly theme: Theme;
84
+ private readonly keybindings: KeybindingsManagerLike;
85
+
86
+ constructor(
87
+ deps: ConfirmDraftFactoryDeps,
88
+ requestRender: () => void,
89
+ theme: Theme,
90
+ keybindings: KeybindingsManagerLike,
91
+ done: (value: string | undefined) => void,
92
+ ) {
93
+ this.requestRender = requestRender;
94
+ this.theme = theme;
95
+ this.keybindings = keybindings;
96
+ this.md = new Markdown(buildConfirmDraftMarkdown(deps.title, deps.body), 1, 1, markdownTheme(theme));
97
+ const items: SelectItem[] = deps.options.map((o) => ({ value: o, label: o }));
98
+ this.selectList = new SelectList(items, Math.min(items.length, 10), selectListTheme(theme));
99
+ this.selectList.onSelect = (item) => done(item.value);
100
+ this.selectList.onCancel = () => done(undefined);
101
+ }
102
+
103
+ render(width: number): string[] {
104
+ const container = new Container();
105
+ container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
106
+ container.addChild(this.md);
107
+ container.addChild(new Spacer(1));
108
+ container.addChild(this.selectList);
109
+ container.addChild(new Spacer(1));
110
+ container.addChild(new Text(this.theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
111
+ container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
112
+ return container.render(width);
113
+ }
114
+
115
+ invalidate(): void {
116
+ this.md.invalidate();
117
+ this.selectList.invalidate();
118
+ this.requestRender();
119
+ }
120
+
121
+ handleInput(data: string): void {
122
+ this.selectList.handleInput(data);
123
+ this.requestRender();
124
+ }
125
+
126
+ /** Exposed for tests. */
127
+ getSelectedItem(): string | null {
128
+ return this.selectList.getSelectedItem()?.value ?? null;
129
+ }
130
+ }
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
13
13
  import * as path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
- import { stripThinkBlocks, type Goal } from "./goal-loop-core.js";
16
+ import { stripThinkBlocks, captureGoalRevision, type Goal, type GoalRevisionToken } from "./goal-loop-core.js";
17
17
  import { buildGoalAuditorPrompt } from "./goal-loop-auditor.js";
18
18
  import { checkRegressionShield, parseAuditorVerdict } from "./goal-loop-shield.js";
19
19
 
@@ -28,6 +28,12 @@ export interface GoalAuditorResult {
28
28
  error?: string;
29
29
  regressionShieldPassed?: boolean;
30
30
  regressionShieldMissing?: string[];
31
+ /** v0.34.59: focus revision token echoed from request.json. The parent
32
+ * compares this against the current state.goal.revision after the audit
33
+ * finishes; mismatch → the verdict is treated as stale-refused, not a
34
+ * silent overwrite. The caller decides what to do (typically: skip the
35
+ * verdict, log stale_revision_refused, surface the refusal in the HUD). */
36
+ goalRevision?: GoalRevisionToken;
31
37
  }
32
38
 
33
39
  export interface AuditorProgress {
@@ -56,6 +62,19 @@ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "bash"] as const;
56
62
  const PROTOCOL_VERSION = 1;
57
63
  const DEFAULT_WALL_TIMEOUT_MS = 30 * 60_000;
58
64
  const DEFAULT_POLL_INTERVAL_MS = 250;
65
+ /** v0.34.57 (steal-list #7 / bug #1.4): heartbeat-without-progress watchdog.
66
+ * A worker heartbeat (`lastActivityAt`) fresher than this is "activity";
67
+ * older than this is "silence" (the worker's own GLLA_AUDITOR_STALL_MS
68
+ * brake owns that case — the parent watchdog must not double-fire it). */
69
+ const DEFAULT_HEARTBEAT_FRESH_MS = 60_000;
70
+ /** v0.34.57: if the heartbeat stays fresh but no NEW tool call or report
71
+ * output arrives for this long, the worker is alive but wedged (auto-retry
72
+ * loop, empty stream, hung tool). Demote to quiet, emit `auditor_stalled`,
73
+ * and auto-cancel the detached job. Mirrors the worker's 10m default brake
74
+ * on the complementary axis: silence→worker cancels, activity-without-
75
+ * progress→parent cancels. Both are far inside the 30m wall bound and the
76
+ * observed 1h50m stuck case. */
77
+ const DEFAULT_HEARTBEAT_NO_PROGRESS_MS = 10 * 60_000;
59
78
  const ATTEMPT_ID_RE = /^[A-Za-z0-9._-]{1,100}$/;
60
79
  const activeChildren = new Map<string, ChildProcess>();
61
80
 
@@ -104,6 +123,11 @@ interface AuditorRequest {
104
123
  thinkingLevel: string;
105
124
  createdAt: string;
106
125
  wallDeadlineAt: number;
126
+ /** v0.34.59: focus revision token captured at dispatch. Echoed in
127
+ * result.json; the parent re-validates against current disk state
128
+ * before applying the verdict. Mismatch → stale-refusal, not a silent
129
+ * overwrite. */
130
+ goalRevision?: GoalRevisionToken;
107
131
  }
108
132
 
109
133
  interface AuditorToolCall {
@@ -122,6 +146,10 @@ interface AuditorResultFile {
122
146
  thinkingLevel: string;
123
147
  toolCalls: AuditorToolCall[];
124
148
  error?: string;
149
+ /** v0.34.59: focus revision token echoed from request.json. The parent
150
+ * compares this against the current state.goal.revision; mismatch → the
151
+ * verdict is treated as stale-refused, not a silent overwrite. */
152
+ goalRevision?: GoalRevisionToken;
125
153
  }
126
154
 
127
155
  interface AuditorProgressFile {
@@ -156,12 +184,34 @@ export interface AuditorProcessRuntime {
156
184
  wallTimeoutMs?: number;
157
185
  now?: () => number;
158
186
  attemptId?: () => string;
187
+ /** v0.34.57: watchdog window — cancel the detached job when the worker's
188
+ * heartbeat stays fresh but no new tool call or report output arrives for
189
+ * this long (default 10m). Tests shrink this. */
190
+ heartbeatNoProgressMs?: number;
191
+ /** v0.34.57: freshness horizon for `lastActivityAt` — only heartbeats
192
+ * younger than this count as "activity" for the watchdog (default 60s). */
193
+ heartbeatFreshMs?: number;
159
194
  /** Environment is inherited by default; useful for a fake pi binary in tests. */
160
195
  env?: NodeJS.ProcessEnv;
161
196
  }
162
197
 
163
198
  export type AuditorProgressCallback = (progress: AuditorProgress) => void;
164
199
 
200
+ /** v0.34.57: payload for the heartbeat-without-progress watchdog. The parent
201
+ * persists this as the `auditor_stalled` ledger event. */
202
+ export interface AuditorStalledInfo {
203
+ /** When the watchdog fired. */
204
+ at: number;
205
+ /** Age of the last worker heartbeat at detection (`now - lastActivityAt`).
206
+ * Fresh (≤ heartbeatFreshMs) by construction — this is activity without
207
+ * progress, not silence. */
208
+ heartbeatAgeMs: number;
209
+ /** How long the no-progress streak had been running (≥ heartbeatNoProgressMs). */
210
+ noProgressMs: number;
211
+ /** The worker phase in the last progress snapshot. */
212
+ phase: AuditorProgress["phase"];
213
+ }
214
+
165
215
  /** Return a stable JSON representation for request-hash validation. */
166
216
  export function stableJson(value: unknown): string {
167
217
  if (value === null || typeof value !== "object") return JSON.stringify(value);
@@ -246,8 +296,28 @@ function asProgress(file: AuditorProgressFile, startedAt: number): AuditorProgre
246
296
  };
247
297
  }
248
298
 
249
- function infra(model: string, thinkingLevel: string, error: string, output = ""): GoalAuditorResult {
250
- return { approved: false, disapproved: false, output, model, thinkingLevel, error };
299
+ /** v0.34.57: the progress-bearing subset of a worker snapshot. Heartbeat
300
+ * events refresh `lastActivityAt` and may oscillate `phase` (running ↔
301
+ * thinking on message_start/agent_start) without delivering progress — this
302
+ * signature deliberately excludes both, so only a NEW finished tool call,
303
+ * new report output, or a NEW tool start counts as progress. */
304
+ function progressSignature(file: AuditorProgressFile): string {
305
+ const calls = file.toolCalls;
306
+ const lastToolFinishedAt = calls.length > 0 ? (calls[calls.length - 1]?.finishedAt ?? 0) : 0;
307
+ return `${calls.length}|${lastToolFinishedAt}|${file.recentOutput.join("\u0000")}|${file.currentTool ?? ""}|${file.currentToolStartedAt ?? 0}`;
308
+ }
309
+
310
+ function infra(model: string, thinkingLevel: string, error: string, output = "", capturedToken?: GoalRevisionToken): GoalAuditorResult {
311
+ return { approved: false, disapproved: false, output, model, thinkingLevel, error, ...(capturedToken ? { goalRevision: capturedToken } : {}) };
312
+ }
313
+
314
+ /** v0.34.59: stamp the captured focus revision onto a successful verdict
315
+ * result so the parent can re-validate before applying. Mismatched tokens
316
+ * cause the verdict to be refused (logged as stale_revision_refused in the
317
+ * parent) rather than silently overwriting a goal that moved on. */
318
+ function stampToken<T extends GoalAuditorResult>(result: T, capturedToken: GoalRevisionToken | undefined): T {
319
+ if (!capturedToken) return result;
320
+ return { ...result, goalRevision: capturedToken };
251
321
  }
252
322
 
253
323
  /**
@@ -264,6 +334,10 @@ export async function runDetachedGoalCompletionAuditor(args: {
264
334
  thinkingLevel?: string;
265
335
  signal?: AbortSignal;
266
336
  onProgress?: AuditorProgressCallback;
337
+ /** v0.34.57: fired once when the heartbeat-without-progress watchdog
338
+ * detects a wedged worker and auto-cancels the detached job. The parent
339
+ * persists this as the `auditor_stalled` ledger event. */
340
+ onStalled?: (info: AuditorStalledInfo) => void;
267
341
  runtime?: AuditorProcessRuntime;
268
342
  }): Promise<GoalAuditorResult> {
269
343
  const runtime = args.runtime ?? {};
@@ -274,11 +348,18 @@ export async function runDetachedGoalCompletionAuditor(args: {
274
348
  const now = runtime.now ?? Date.now;
275
349
  const wallTimeoutMs = runtime.wallTimeoutMs ?? DEFAULT_WALL_TIMEOUT_MS;
276
350
  const pollIntervalMs = Math.max(10, runtime.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
351
+ const heartbeatFreshMs = Math.max(10, runtime.heartbeatFreshMs ?? DEFAULT_HEARTBEAT_FRESH_MS);
352
+ const heartbeatNoProgressMs = Math.max(50, runtime.heartbeatNoProgressMs ?? DEFAULT_HEARTBEAT_NO_PROGRESS_MS);
277
353
  const attemptId = runtime.attemptId?.() ?? `${Date.now().toString(36)}-${randomUUID()}`;
354
+ // v0.34.59: capture the focus revision token at dispatch. Every result
355
+ // shape returned to the parent carries this token so the parent can
356
+ // re-validate before applying a verdict. Pre-revision goals pass through
357
+ // unchanged (captured is null).
358
+ const capturedRevisionToken: GoalRevisionToken | undefined = captureGoalRevision(args.goal) ?? undefined;
278
359
  try {
279
360
  assertAttemptId(attemptId);
280
361
  } catch (error) {
281
- return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error));
362
+ return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error), "", capturedRevisionToken);
282
363
  }
283
364
 
284
365
  const jobDir = path.resolve(args.cwd, ".pi-glla", "audit-jobs", attemptId);
@@ -292,6 +373,13 @@ export async function runDetachedGoalCompletionAuditor(args: {
292
373
  let lockHeld = false;
293
374
  let child: ChildProcess | undefined;
294
375
  let lastProgressSerialized = "";
376
+ // v0.34.57: heartbeat-without-progress watchdog state. `lastProgressAt` is
377
+ // reset whenever the progress signature changes; the watchdog fires when
378
+ // the worker heartbeat stays fresh but the signature has not changed for
379
+ // `heartbeatNoProgressMs` — the worker is alive but wedged.
380
+ let lastProgressAt = startedAt;
381
+ let lastProgressSignature = "";
382
+ let lastProgress: AuditorProgressFile | undefined;
295
383
 
296
384
  try {
297
385
  await fs.mkdir(jobsRoot, { recursive: true, mode: 0o700 });
@@ -308,6 +396,11 @@ export async function runDetachedGoalCompletionAuditor(args: {
308
396
  thinkingLevel,
309
397
  createdAt: new Date(startedAt).toISOString(),
310
398
  wallDeadlineAt,
399
+ // v0.34.59: capture the focus revision token at dispatch. The
400
+ // worker echoes it in result.json; the parent re-validates before
401
+ // applying the verdict. A stale-handle ghost can no longer silently
402
+ // overwrite a goal that moved on.
403
+ goalRevision: capturedRevisionToken,
311
404
  };
312
405
  const request: AuditorRequest = { ...requestWithoutHash, requestHash: requestHash(requestWithoutHash) };
313
406
  await writeAtomicJson(requestPath, request);
@@ -336,37 +429,38 @@ export async function runDetachedGoalCompletionAuditor(args: {
336
429
  args.signal?.addEventListener("abort", abort, { once: true });
337
430
  try {
338
431
  while (true) {
339
- if (args.signal?.aborted) return infra(model, thinkingLevel, "Auditor aborted.");
432
+ if (args.signal?.aborted) return infra(model, thinkingLevel, "Auditor aborted.", "", capturedRevisionToken);
340
433
  if (now() >= wallDeadlineAt) {
341
434
  if (childAlive(child)) child.kill("SIGTERM");
342
- return infra(model, thinkingLevel, `Auditor exceeded its ${Math.round(wallTimeoutMs / 60_000)}m wall-clock bound and was aborted.`);
435
+ return infra(model, thinkingLevel, `Auditor exceeded its ${Math.round(wallTimeoutMs / 60_000)}m wall-clock bound and was aborted.`, "", capturedRevisionToken);
343
436
  }
344
437
  try {
345
438
  const progress = await readJson<AuditorProgressFile>(progressPath);
346
439
  if (progress.protocolVersion !== PROTOCOL_VERSION || progress.attemptId !== attemptId || progress.requestHash !== request.requestHash) {
347
- return infra(model, thinkingLevel, "auditor progress identity/request-hash mismatch");
440
+ return infra(model, thinkingLevel, "auditor progress identity/request-hash mismatch", "", capturedRevisionToken);
348
441
  }
442
+ lastProgress = progress;
349
443
  const serialized = stableJson(progress);
350
444
  if (serialized !== lastProgressSerialized) {
351
445
  lastProgressSerialized = serialized;
352
446
  args.onProgress?.(asProgress(progress, startedAt));
353
447
  }
354
448
  } catch (error) {
355
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor progress: ${error instanceof Error ? error.message : String(error)}`);
449
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor progress: ${error instanceof Error ? error.message : String(error)}`, "", capturedRevisionToken);
356
450
  }
357
451
  try {
358
452
  const result = await readJson<AuditorResultFile>(resultPath);
359
453
  if (result.protocolVersion !== PROTOCOL_VERSION || result.attemptId !== attemptId || result.requestHash !== request.requestHash) {
360
- return infra(model, thinkingLevel, "auditor result identity/request-hash mismatch");
454
+ return infra(model, thinkingLevel, "auditor result identity/request-hash mismatch", "", capturedRevisionToken);
361
455
  }
362
456
  const output = stripThinkBlocks(result.output);
363
- if (!result.ok) return infra(model, thinkingLevel, result.error || "detached auditor failed", output);
364
- if (!output.trim()) return infra(model, thinkingLevel, "auditor produced no output");
457
+ if (!result.ok) return infra(model, thinkingLevel, result.error || "detached auditor failed", output, capturedRevisionToken);
458
+ if (!output.trim()) return infra(model, thinkingLevel, "auditor produced no output", output, capturedRevisionToken);
365
459
  const parsed = parseAuditorVerdict(output);
366
- if (!parsed.approved && !parsed.disapproved && !parsed.impossible) return infra(model, thinkingLevel, "auditor produced no verdict marker");
460
+ if (!parsed.approved && !parsed.disapproved && !parsed.impossible) return infra(model, thinkingLevel, "auditor produced no verdict marker", output, capturedRevisionToken);
367
461
  const usedReadTool = result.toolCalls.some((call) => (READ_ONLY_TOOLS as readonly string[]).includes(call.name));
368
462
  if (parsed.approved && !usedReadTool) {
369
- return { approved: false, disapproved: true, output, model, thinkingLevel, error: "Auditor approved without calling any read-only tool; treated as disapproved." };
463
+ return stampToken({ approved: false, disapproved: true, output, model, thinkingLevel, error: "Auditor approved without calling any read-only tool; treated as disapproved." }, capturedRevisionToken);
370
464
  }
371
465
  if (parsed.approved && args.goal.verificationContract?.trim()) {
372
466
  const shield = checkRegressionShield(output, args.goal.verificationContract);
@@ -375,27 +469,66 @@ export async function runDetachedGoalCompletionAuditor(args: {
375
469
  // regression shield blocked acceptance because the report did
376
470
  // not cite every contract item. Keep that outcome distinct from
377
471
  // both a work disapproval and infrastructure failure.
378
- return {
472
+ return stampToken({
379
473
  approved: true, disapproved: false, output, model, thinkingLevel,
380
474
  regressionShieldPassed: false, regressionShieldMissing: shield.missingItems,
381
- };
475
+ }, capturedRevisionToken);
382
476
  }
383
477
  args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
384
- return { approved: true, disapproved: false, output, model, thinkingLevel, regressionShieldPassed: true };
478
+ return stampToken({ approved: true, disapproved: false, output, model, thinkingLevel, regressionShieldPassed: true }, capturedRevisionToken);
385
479
  }
386
480
  args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
387
- return { approved: parsed.approved, disapproved: parsed.disapproved, impossible: parsed.impossible, impossibleReason: parsed.impossibleReason, output, model, thinkingLevel };
481
+ return stampToken({ approved: parsed.approved, disapproved: parsed.disapproved, impossible: parsed.impossible, impossibleReason: parsed.impossibleReason, output, model, thinkingLevel }, capturedRevisionToken);
388
482
  } catch (error) {
389
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor result: ${error instanceof Error ? error.message : String(error)}`);
483
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor result: ${error instanceof Error ? error.message : String(error)}`, "", capturedRevisionToken);
484
+ }
485
+ // v0.34.57: heartbeat-without-progress watchdog (steal-list #7 /
486
+ // bug #1.4). The worker's own stall brake only fires on TOTAL silence
487
+ // (and skips it while a read-only tool is running); a worker that
488
+ // keeps emitting RPC events — auto-retry loops, empty message
489
+ // updates, a hung tool — refreshes `lastActivityAt` forever without
490
+ // delivering any new tool call or report output. That is the 1h50m
491
+ // "alive but wedged" class: fail fast instead.
492
+ if (lastProgress && lastProgress.lastActivityAt !== undefined && now() - lastProgress.lastActivityAt <= heartbeatFreshMs) {
493
+ const signature = progressSignature(lastProgress);
494
+ if (signature !== lastProgressSignature) {
495
+ lastProgressSignature = signature;
496
+ lastProgressAt = now();
497
+ }
498
+ const noProgressMs = now() - lastProgressAt;
499
+ if (noProgressMs >= heartbeatNoProgressMs) {
500
+ // Demote to quiet first: a final progress snapshot WITHOUT the
501
+ // live heartbeat, so the HUD cannot render LIVE + "worker activity
502
+ // 0s ago" for the wedged worker.
503
+ args.onProgress?.({
504
+ phase: "running",
505
+ elapsedMs: now() - startedAt,
506
+ recentOutput: lastProgress.recentOutput,
507
+ toolCalls: lastProgress.toolCalls,
508
+ unmatchedToolStarts: lastProgress.unmatchedToolStarts ?? [],
509
+ unmatchedToolEnds: lastProgress.unmatchedToolEnds ?? [],
510
+ });
511
+ const stallLabel = heartbeatNoProgressMs >= 60_000
512
+ ? `${Math.max(1, Math.round(heartbeatNoProgressMs / 60_000))}m`
513
+ : `${Math.max(1, Math.round(heartbeatNoProgressMs / 1_000))}s`;
514
+ args.onStalled?.({
515
+ at: now(),
516
+ heartbeatAgeMs: now() - lastProgress.lastActivityAt,
517
+ noProgressMs,
518
+ phase: lastProgress.phase,
519
+ });
520
+ if (child && childAlive(child)) child.kill("SIGTERM");
521
+ return infra(model, thinkingLevel, `Auditor stalled — heartbeats without progress for ${stallLabel} (no new tool call or output); the detached job was auto-cancelled.`, "", capturedRevisionToken);
522
+ }
390
523
  }
391
- if (child && !childAlive(child)) return infra(model, thinkingLevel, "auditor worker exited without an atomic result");
524
+ if (child && !childAlive(child)) return infra(model, thinkingLevel, "auditor worker exited without an atomic result", "", capturedRevisionToken);
392
525
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
393
526
  }
394
527
  } finally {
395
528
  args.signal?.removeEventListener("abort", abort);
396
529
  }
397
530
  } catch (error) {
398
- return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error));
531
+ return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error), "", capturedRevisionToken);
399
532
  } finally {
400
533
  activeChildren.delete(childKey(args.cwd, attemptId));
401
534
  if (lockHeld) await fs.unlink(lockPath).catch(() => {});
@@ -259,7 +259,10 @@ export function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | n
259
259
  "<raw command output here>",
260
260
  "</evidence>",
261
261
  "",
262
- "An approval without a complete <evidence> section will be rejected automatically.",
262
+ "v0.34.77: quote each item VERBATIM in the contract's ORIGINAL language — a translated",
263
+ "or paraphrased item (e.g. an English gloss of a Chinese line) cannot be matched by the",
264
+ "shield and the approval will be rejected automatically. An approval without a complete",
265
+ "<evidence> section will be rejected automatically.",
263
266
  ]
264
267
  : []),
265
268
  ].join("\n");