pi-goal-list-loop-audit 0.38.17 → 0.38.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.18 — completion/lifecycle field trilogy (2026-09-04)
4
+
5
+ ### Fixed
6
+ - Track 1 (pipe-syntax): the mechanical pre-audit checker runs narrow `cmd 2>&1 | tail/head/grep` pipelines shell-free with pipefail-head semantics instead of 126-rejecting finished work (`parseMechanicalPipeline`, `runMechanicalPipeline`; `tee`/grep-file-flags still refused).
7
+ - Track 2 (post-answer stall): a turn that never streamed parks on the first abort with no hot retry (`zombie_auto_retry_refused_never_streamed`, honest park copy), and the rearm milestone names an open-but-silent turn instead of claiming "no turn started".
8
+ - Track 3 (stale waiting-verdict): the detached-approval branch delivers the `✓ done` brief into the conversation as a fire-once followUp turn (`sendTerminalCompletionNotice`), so the transcript records the archive instead of narrating "waiting" forever. Skipped for manual `/goal verify` (in-turn closure already exists).
9
+
3
10
  ## 0.38.17 — release-contract docs trail (2026-09-04)
4
11
 
5
12
  ### 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.17 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.38.18 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
@@ -31,6 +31,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
31
31
  import { state, replaceState } from "./goal-state.js";
32
32
  import {
33
33
  appendLedger,
34
+ readLedgerTail,
34
35
  nowIso,
35
36
  newGoalId,
36
37
  archivedGoalPath,
@@ -267,6 +268,12 @@ let continuationRearmSince = 0;
267
268
  // (rearm storm fires with no accepted dispatch — the user's "pi did not
268
269
  // start a turn" state under a recovery park).
269
270
  let lastNoTurnStartedNotifiedAt = 0;
271
+ // v0.38.18 (track 2): the wall clock of the last turn/agent begin-marker
272
+ // observed by dispatchStartAcknowledged, regardless of pending dispatch.
273
+ // Lets the milestone notify distinguish "no turn started" from "a turn is
274
+ // open but never picked up the dispatch" (neonbreak: creation turn open
275
+ // and silent since 12:57 while the storm reported "no turn started").
276
+ let lastObservedTurnStartAt = 0;
270
277
  let continuationRearmMilestone = 0;
271
278
  // v0.34.57: per-record counter capping the compaction-paused re-arm loop.
272
279
  // A stuck session that never produces a new compaction event must not
@@ -348,10 +355,18 @@ export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "
348
355
  const noDispatchAccepted = lastContinuationSentAt === 0 || lastContinuationSentAt < continuationRearmSince;
349
356
  if (noDispatchAccepted && lastNoTurnStartedNotifiedAt + SEND_REARM_LEDGER_MILESTONES_MS[0]! <= Date.now()) {
350
357
  lastNoTurnStartedNotifiedAt = Date.now();
351
- appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000) });
358
+ // v0.38.18 (track 2): a turn that began after the storm started is
359
+ // open — reporting "no turn started" then is false and sends the
360
+ // user down the wrong path (resume a queue that is not stuck vs
361
+ // interrupt a hung turn). Say which is true.
362
+ const turnOpen = lastObservedTurnStartAt >= since && lastObservedTurnStartAt > 0;
363
+ const turnOpenMinutes = turnOpen ? Math.max(1, Math.round((Date.now() - lastObservedTurnStartAt) / 60000)) : 0;
364
+ appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000), turnOpen, turnOpenMinutes });
352
365
  const aggressive = resolveEffectiveAggressiveSettings(loadSettings(ctx.cwd)).aggressiveMode;
353
366
  const recoveryStop = aggressive ? "a state-based stop" : "the automatic horizon";
354
- const msg = `glla: pi accepted no continuation for ${Math.round(elapsed / 60000)}m (${streak} re-arms, no turn started) — the send queue may be stuck. The generic recovery probe is retrying automatically; no action needed unless it reaches ${recoveryStop}.`;
367
+ const msg = turnOpen
368
+ ? `glla: a turn started ${turnOpenMinutes}m ago but no continuation was accepted since (${streak} re-arms) — the open turn has not picked up the queued dispatch. If its stream counters are frozen it is hung, not thinking; Esc interrupts it, then resume retries. No action needed unless this reaches ${recoveryStop}.`
369
+ : `glla: pi accepted no continuation for ${Math.round(elapsed / 60000)}m (${streak} re-arms, no turn started) — the send queue may be stuck. The generic recovery probe is retrying automatically; no action needed unless it reaches ${recoveryStop}.`;
355
370
  ctx.ui.notify(msg, "warning");
356
371
  notifyExternal(ctx, msg);
357
372
  }
@@ -569,6 +584,10 @@ export function dispatchFailed(ctx: ExtensionContext, record: ContinuationDispat
569
584
  }
570
585
 
571
586
  export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string, prompt?: unknown): boolean {
587
+ // v0.38.18 (track 2): record every begin-marker even when no dispatch is
588
+ // pending — the rearm milestone uses it to tell an open-but-silent turn
589
+ // from a turn that never started.
590
+ if (source === "turn_start" || source === "agent_start") lastObservedTurnStartAt = Date.now();
572
591
  // v0.34.104 ([Image-#1]): any real agent activity during the
573
592
  // post-list-completion settle window means pi woke up on its own — the
574
593
  // deferred continuation must be cancelled so we don't double-dispatch.
@@ -1058,6 +1077,57 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
1058
1077
  continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), delay);
1059
1078
  }
1060
1079
 
1080
+ export interface TerminalCompletionNotice {
1081
+ goalId: string;
1082
+ outcome: string;
1083
+ details: string[];
1084
+ }
1085
+
1086
+ /** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
1087
+ * verifier settles asynchronously — the transcript's last word is "the
1088
+ * verdict will be applied asynchronously", and a toast is the only
1089
+ * closure. A later "how are we looking" then truthfully re-reports the
1090
+ * stale transcript as still-waiting even though the goal is archived.
1091
+ * This delivers the `✓ done` brief INTO the conversation as a followUp
1092
+ * turn so the transcript records the completion. Goal-null-safe (the goal
1093
+ * is already archived when this fires), fire-once per goal via a durable
1094
+ * ledger fence, and fenced like every other automatic send. Returns true
1095
+ * when the notice was dispatched. */
1096
+ export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: TerminalCompletionNotice): boolean {
1097
+ if (supervisorPaused(state)) return false;
1098
+ if (mainModelRecoveryActive()) return false;
1099
+ if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
1100
+ if (!flags.extensionApi) return false;
1101
+ if (isForeignCtx(ctx)) return false;
1102
+ try {
1103
+ const already = readLedgerTail(ctx.cwd, 400, (entry) =>
1104
+ entry.type === "terminal_completion_notice_sent" &&
1105
+ typeof (entry.value as { goalId?: unknown } | null)?.goalId === "string" &&
1106
+ (entry.value as { goalId: string }).goalId === notice.goalId,
1107
+ );
1108
+ if (already.length > 0) return false;
1109
+ } catch {
1110
+ return false;
1111
+ }
1112
+ const content = [
1113
+ `✓ done — ${notice.outcome}`,
1114
+ ...notice.details,
1115
+ "— goal archived; nothing further is owed. Acknowledge briefly; start follow-up work only if asked.",
1116
+ ].join("\n");
1117
+ try {
1118
+ flags.extensionApi.sendMessage({
1119
+ customType: GOAL_EVENT_ENTRY,
1120
+ content,
1121
+ display: false,
1122
+ }, { triggerTurn: true, deliverAs: "followUp" });
1123
+ } catch {
1124
+ appendLedger(ctx.cwd, "terminal_completion_notice_unsent", { goalId: notice.goalId });
1125
+ return false;
1126
+ }
1127
+ appendLedger(ctx.cwd, "terminal_completion_notice_sent", { goalId: notice.goalId, payloadChars: content.length });
1128
+ return true;
1129
+ }
1130
+
1061
1131
  export function buildMarkerContent(goalId: string): string {
1062
1132
  return `[GOAL CHECKPOINT goalId=${goalId}]`;
1063
1133
  }
@@ -1504,6 +1574,11 @@ export function clearQueueStuckProbe(): void {
1504
1574
  export function setContinuationRearmStreak(v: number): void {
1505
1575
  continuationRearmStreak = v;
1506
1576
  }
1577
+
1578
+ /** Test-only: pretend a turn begin-marker was observed at the given clock. */
1579
+ export function __testOnlySetObservedTurnStartAt(v: number): void {
1580
+ lastObservedTurnStartAt = v;
1581
+ }
1507
1582
  export function setContinuationRearmSince(v: number): void {
1508
1583
  continuationRearmSince = v;
1509
1584
  }
@@ -1521,6 +1596,7 @@ export function resetContinuationDispatchState(cwd: string): boolean {
1521
1596
  continuationRearmSince = 0;
1522
1597
  continuationRearmMilestone = 0;
1523
1598
  lastNoTurnStartedNotifiedAt = 0;
1599
+ lastObservedTurnStartAt = 0;
1524
1600
  continuationStartCompactionRearms.clear();
1525
1601
  continuationStartRecoveryRearms.clear();
1526
1602
  return clearDispatchRecord(cwd);
@@ -277,7 +277,15 @@ export function zombieRetryDecision(
277
277
  key: string,
278
278
  prev: ZombieRetryStreak,
279
279
  maxAttempts = DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
280
- ): { retry: boolean; streak: ZombieRetryStreak } {
280
+ // v0.38.18 (track 2: neonbreak never-streamed hot loop): the stream clock
281
+ // observed when the aborted turn STARTED. When the abort's observation
282
+ // point never advanced past it, the turn produced zero stream activity in
283
+ // its whole life — an immediate retry replays the same silence (field:
284
+ // 3 abort cycles in 5m, same frozen clock, 23k full payload each). Refuse
285
+ // at once and park for an explicit resume instead of burning the budget.
286
+ // 0/unknown preserves the legacy behavior (retry within budget).
287
+ turnStartAt = 0,
288
+ ): { retry: boolean; streak: ZombieRetryStreak; neverStreamed?: boolean } {
281
289
  const limit = Number.isInteger(maxAttempts) && maxAttempts >= 0
282
290
  ? Math.min(maxAttempts, MAX_ZOMBIE_RETRY_ATTEMPTS)
283
291
  : DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
@@ -287,6 +295,9 @@ export function zombieRetryDecision(
287
295
  const streak: ZombieRetryStreak = sameEpisode
288
296
  ? { key, count: Math.min(prev.count + 1, MAX_ZOMBIE_RETRY_ATTEMPTS + 1), lastAbortStreamAt: observedStreamAt }
289
297
  : { key, count: 1, lastAbortStreamAt: observedStreamAt };
298
+ if (turnStartAt > 0 && observedStreamAt <= turnStartAt) {
299
+ return { retry: false, streak, neverStreamed: true };
300
+ }
290
301
  return { retry: streak.count <= limit, streak };
291
302
  }
292
303
 
@@ -246,6 +246,168 @@ function safeFileMarkerCompound(command: string): string[] | null {
246
246
  return [`test -${flag} ${testPath}`, `grep -q ${marker} ${grepPath}`];
247
247
  }
248
248
 
249
+ /**
250
+ * v0.38.18 (track 1: endless-td pipe-syntax wedge): a narrow pipeline form.
251
+ * Contracts legitimately truncate noisy suites (`bun test 2>&1 | tail -n 4`);
252
+ * rejecting the `|` deterministically fast-failed finished work with no
253
+ * agent-side remedy. A pipeline is accepted only when EVERY segment is
254
+ * independently safe: the head is an ordinary safe command (resolved through
255
+ * the canonical runner exactly like a bare command) and each tail segment is
256
+ * an allowlisted text filter. Execution never touches a shell — the head
257
+ * runs through runMechanicalCommand and each filter is spawned with piped
258
+ * stdio. The pipeline passes iff the HEAD exits 0 (pipefail-head semantics:
259
+ * `tail` exits 0 even when the suite is red, so the tail's status is
260
+ * meaningless); filters only truncate evidence. `2>&1` on the head is
261
+ * accepted and ignored — head output already merges stderr. Every other
262
+ * redirect, substitution, quote, or second program stays exit-126 rejected.
263
+ */
264
+ const MECHANICAL_HEAD_REDIRECT = /\s*2>&1\s*$/;
265
+ const MECHANICAL_TAIL_FILTER = /^(?:tail|head)\s+-[nc]\s+\d{1,6}$/;
266
+ const MECHANICAL_GREP_FILTER = /^grep\s+(?:-[a-z]+\s+)?[A-Za-z0-9_./:@=+,%-]+$/;
267
+
268
+ function isAllowlistedMechanicalFilter(segment: string): boolean {
269
+ return MECHANICAL_TAIL_FILTER.test(segment) || MECHANICAL_GREP_FILTER.test(segment);
270
+ }
271
+
272
+ interface MechanicalPipeline {
273
+ head: string;
274
+ filters: string[];
275
+ }
276
+
277
+ /** Split a `head | filter | ...` command, or return null when any segment
278
+ * is outside the narrow allowed shape (callers fall through to the 126
279
+ * unsafe rejection). Naive `|` splitting is safe here: quotes and
280
+ * escapes are rejected characters, so a `|` is always a real pipe. */
281
+ export function parseMechanicalPipeline(command: string): MechanicalPipeline | null {
282
+ if (!command.includes("|")) return null;
283
+ const segments = command.split("|").map((s) => s.trim());
284
+ if (segments.length < 2 || segments.some((s) => !s)) return null;
285
+ let head = segments[0]!.replace(MECHANICAL_HEAD_REDIRECT, "").trim();
286
+ if (!head) return null;
287
+ if (!isSafeMechanicalCommand(head)) return null;
288
+ const filters = segments.slice(1);
289
+ if (!filters.every(isAllowlistedMechanicalFilter)) return null;
290
+ return { head, filters };
291
+ }
292
+
293
+ const MECHANICAL_FILTER_STAGE_TIMEOUT_MS = 30_000;
294
+
295
+ function runMechanicalFilterStage(
296
+ input: string,
297
+ filter: string,
298
+ timeoutMs: number,
299
+ ): Promise<{ output: string; exitCode: number; timedOut: boolean; launchError?: string }> {
300
+ return new Promise((resolve) => {
301
+ const [program = "", ...args] = filter.split(/[ \t]+/);
302
+ let child: ChildProcess;
303
+ try {
304
+ child = spawn(program, args, {
305
+ detached: process.platform !== "win32",
306
+ stdio: ["pipe", "pipe", "pipe"],
307
+ });
308
+ } catch (error) {
309
+ resolve({ output: "", exitCode: 1, timedOut: false, launchError: error instanceof Error ? error.message : String(error) });
310
+ return;
311
+ }
312
+ let output = "";
313
+ let settled = false;
314
+ const timer = setTimeout(() => {
315
+ if (settled) return;
316
+ settled = true;
317
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
318
+ resolve({ output: output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS), exitCode: 1, timedOut: true });
319
+ }, timeoutMs);
320
+ child.stdout?.on("data", (chunk: Buffer) => {
321
+ output += chunk.toString("utf8");
322
+ if (output.length > MECHANICAL_OUTPUT_TAIL_CHARS * 2) {
323
+ output = output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS);
324
+ }
325
+ });
326
+ child.stderr?.on("data", () => { /* filter diagnostics never enter evidence */ });
327
+ child.on("error", (error) => {
328
+ if (settled) return;
329
+ settled = true;
330
+ clearTimeout(timer);
331
+ resolve({ output: "", exitCode: 1, timedOut: false, launchError: error.message });
332
+ });
333
+ child.on("close", (code) => {
334
+ if (settled) return;
335
+ settled = true;
336
+ clearTimeout(timer);
337
+ resolve({ output: output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS), exitCode: code ?? 1, timedOut: false });
338
+ });
339
+ try {
340
+ child.stdin?.write(input);
341
+ child.stdin?.end();
342
+ } catch { /* stdin already closed */ }
343
+ });
344
+ }
345
+
346
+ async function runMechanicalPipeline(
347
+ cwd: string,
348
+ pipeline: MechanicalPipeline,
349
+ rawCommand: string,
350
+ scripts: Record<string, string>,
351
+ effectiveTimeoutMs: number,
352
+ signal: AbortSignal | undefined,
353
+ effectiveProcessGroupSize: number,
354
+ ): Promise<MechanicalCheckResult> {
355
+ if (process.platform === "win32") {
356
+ return {
357
+ passed: false,
358
+ failedCommand: rawCommand,
359
+ output: "Mechanical pipelines need POSIX coreutils (tail/head/grep) and are not supported on Windows; reword the contract line to a bare runner invocation.",
360
+ exitCode: 126,
361
+ };
362
+ }
363
+ const { program, args } = resolveCanonicalRunnerCommand(pipeline.head, scripts);
364
+ if (!program) {
365
+ return { passed: false, failedCommand: rawCommand, output: "Empty mechanical command.", exitCode: 126 };
366
+ }
367
+ // ONE bounded automatic retry for the whole pipeline on a plain head
368
+ // failure (mirrors the single-command v0.35.20 retry); containment
369
+ // events fail immediately without a second untrusted process tree.
370
+ let firstFailure: { output: string; exitCode: number } | null = null;
371
+ for (let attempt = 1; attempt <= 2; attempt++) {
372
+ const head = await runMechanicalCommand(cwd, program, args, effectiveTimeoutMs, signal, effectiveProcessGroupSize);
373
+ if (head.timedOut || head.aborted || head.outputLimit || head.processGroupLimit) {
374
+ return { passed: false, failedCommand: rawCommand, ...formatMechanicalFailure(head, effectiveTimeoutMs) };
375
+ }
376
+ let stageInput = head.output;
377
+ let stageFailed: { output: string; exitCode: number } | null = null;
378
+ for (const filter of pipeline.filters) {
379
+ const stage = await runMechanicalFilterStage(stageInput, filter, MECHANICAL_FILTER_STAGE_TIMEOUT_MS);
380
+ if (stage.timedOut || stage.launchError) {
381
+ stageFailed = {
382
+ output: `[pipeline filter '${filter}' did not complete: ${stage.timedOut ? "timed out" : stage.launchError}]`,
383
+ exitCode: 1,
384
+ };
385
+ break;
386
+ }
387
+ stageInput = stage.output;
388
+ }
389
+ if (stageFailed) {
390
+ return { passed: false, failedCommand: rawCommand, ...stageFailed };
391
+ }
392
+ if (head.exitCode === 0) {
393
+ return { passed: true };
394
+ }
395
+ const evidence = [`[pipeline head exited ${head.exitCode}]`, stageInput || "(no output survived the filters)"]
396
+ .join("\n")
397
+ .slice(-MECHANICAL_OUTPUT_TAIL_CHARS);
398
+ if (attempt === 1) {
399
+ firstFailure = { output: evidence, exitCode: head.exitCode };
400
+ } else {
401
+ return {
402
+ passed: false,
403
+ failedCommand: rawCommand,
404
+ output: `[mechanical pipeline retried once after a failed first attempt (head exit ${firstFailure!.exitCode}); second attempt also failed — output tail below]\n` + evidence,
405
+ };
406
+ }
407
+ }
408
+ return { passed: false, failedCommand: rawCommand, output: firstFailure?.output ?? "", exitCode: firstFailure?.exitCode ?? 1 };
409
+ }
410
+
249
411
  const DEFAULT_MECHANICAL_CHECK_TIMEOUT_MS = 600_000;
250
412
  const MECHANICAL_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
251
413
  const MECHANICAL_OUTPUT_TAIL_CHARS = 64 * 1024;
@@ -590,6 +752,11 @@ export async function runMechanicalPreAuditChecks(
590
752
  : DEFAULT_MECHANICAL_CHECK_TIMEOUT_MS;
591
753
  const effectiveProcessGroupSize = normalizeMechanicalProcessGroupLimit(maxProcessGroupSize);
592
754
  const recoveredRetries: string[] = [];
755
+ let scripts: Record<string, string> = {};
756
+ try {
757
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
758
+ if (pkg && typeof pkg.scripts === "object" && pkg.scripts) scripts = pkg.scripts;
759
+ } catch { /* no package.json — nothing to resolve */ }
593
760
  for (const rawCommand of commands) {
594
761
  const cmd = rawCommand.trim();
595
762
  const compound = safeFileMarkerCompound(cmd);
@@ -603,11 +770,21 @@ export async function runMechanicalPreAuditChecks(
603
770
  }
604
771
  continue;
605
772
  }
773
+ // v0.38.18 (track 1): narrow `head | tail/head/grep` pipelines run
774
+ // shell-free before the single-command gate below.
775
+ const pipeline = parseMechanicalPipeline(cmd);
776
+ if (pipeline) {
777
+ const pipelineResult = await runMechanicalPipeline(cwd, pipeline, rawCommand, scripts, effectiveTimeoutMs, signal, effectiveProcessGroupSize);
778
+ if (!pipelineResult.passed) return pipelineResult;
779
+ continue;
780
+ }
606
781
  if (!isSafeMechanicalCommand(cmd)) {
607
782
  return {
608
783
  passed: false,
609
784
  failedCommand: rawCommand,
610
- output: "Rejected unsafe mechanical command syntax; only a single executable followed by literal arguments is allowed.",
785
+ output: cmd.includes("|")
786
+ ? "Rejected unsafe mechanical pipeline syntax; only `command | tail -n N`, `command | head -n N`, and `command | grep <literal>` are allowed (no redirects except a trailing 2>&1, no second program)."
787
+ : "Rejected unsafe mechanical command syntax; only a single executable followed by literal arguments is allowed.",
611
788
  exitCode: 126,
612
789
  };
613
790
  }
@@ -617,11 +794,7 @@ export async function runMechanicalPreAuditChecks(
617
794
  // flags the suite requires (serialization/isolation/timeouts). Running
618
795
  // the bare runner ignores that configuration and fails spuriously while
619
796
  // the real gate is green (field: fourth audit round, 2026-08-21).
620
- let scripts: Record<string, string> = {};
621
- try {
622
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
623
- if (pkg && typeof pkg.scripts === "object" && pkg.scripts) scripts = pkg.scripts;
624
- } catch { /* no package.json — nothing to resolve */ }
797
+ // (scripts are loaded once above the loop so the pipeline path shares them.)
625
798
  const { program, args } = resolveCanonicalRunnerCommand(cmd, scripts);
626
799
  if (!program) {
627
800
  return { passed: false, failedCommand: rawCommand, output: "Empty mechanical command.", exitCode: 126 };
@@ -460,6 +460,13 @@ const ZOMBIE_PAUSE_REASON = "automatic zero-stream abort — no provider activit
460
460
  // the park standing for manual resume — an honest degradation.
461
461
  let zombieRetryStreak: ZombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
462
462
  let zombieRetryTimer: NodeJS.Timeout | null = null;
463
+ // v0.38.18 (track 2): the stream clock when the current turn's begin-marker
464
+ // was last observed. Compared against the abort's observation point to tell
465
+ // "wedged after streaming" (deserves budget retries) from "never streamed
466
+ // since birth" (an immediate retry replays the silence — park at once).
467
+ // 0 means no begin-marker observed (extension loaded mid-turn): unknown, and
468
+ // unknown preserves the legacy retry-within-budget behavior.
469
+ let lastTurnStartAt = 0;
463
470
  // An in-band provider pane is observed during tool_result and consumed at the
464
471
  // matching agent_end. Keep the raw text only in memory; the durable ledger
465
472
  // records the bounded classification, never the provider payload.
@@ -477,15 +484,15 @@ function scheduleZombieAutoRetry(
477
484
  goalId: string | undefined,
478
485
  observedStreamAt: number,
479
486
  silentMinutes: number,
480
- ): { scheduled: boolean; attempt: number; maxAttempts: number } {
487
+ ): { scheduled: boolean; attempt: number; maxAttempts: number; reason?: "never-streamed" | "budget-exhausted" } {
481
488
  const key = goalId ?? "loop";
482
489
  const maxAttempts = zombieRetryMaxAttemptsOverride
483
490
  ?? loadSettings(ctx.cwd).zombieRetryMaxAttempts
484
491
  ?? DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
485
- const { retry, streak } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak, maxAttempts);
492
+ const { retry, streak, neverStreamed } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak, maxAttempts, lastTurnStartAt);
486
493
  zombieRetryStreak = streak;
487
- const plan = { scheduled: retry, attempt: streak.count, maxAttempts };
488
- appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : "zombie_auto_retry_refused_streak", {
494
+ const plan = { scheduled: retry, attempt: streak.count, maxAttempts, reason: neverStreamed ? "never-streamed" as const : retry ? undefined : "budget-exhausted" as const };
495
+ appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : neverStreamed ? "zombie_auto_retry_refused_never_streamed" : "zombie_auto_retry_refused_streak", {
489
496
  goalId,
490
497
  streakCount: streak.count,
491
498
  maxAttempts,
@@ -566,6 +573,12 @@ export function __testOnlyResetZombieAutoRetry(): void {
566
573
  }
567
574
  zombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
568
575
  zombieRetryMaxAttemptsOverride = null;
576
+ lastTurnStartAt = 0;
577
+ }
578
+
579
+ /** Test-only: pretend a turn begin-marker was observed at the given clock. */
580
+ export function __testOnlySetTurnStartAt(at: number): void {
581
+ lastTurnStartAt = at;
569
582
  }
570
583
 
571
584
  let zombieRetryDelayOverride: number | null = null;
@@ -621,7 +634,7 @@ export function abortZombieRun(
621
634
  status: "paused",
622
635
  pauseKind: "error",
623
636
  pauseReason: reason,
624
- pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. Automatic retries use the configured zero-stream budget; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.`,
637
+ pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. ${lastTurnStartAt > 0 && observedStreamAt <= lastTurnStartAt ? `The turn never produced stream activity, so no automatic retry was scheduled; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.` : `Automatic retries use the configured zero-stream budget; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.`}`,
625
638
  }, current);
626
639
  } else if (loop) {
627
640
  clearLoopTimer();
@@ -649,6 +662,9 @@ export function abortZombieRun(
649
662
  // v0.35.x: keep the park as a bounded waystation for repeated
650
663
  // zero-stream attempts. Once the configured budget is exhausted the
651
664
  // refusal remains manual and durable.
665
+ // v0.38.18 (track 2): a turn that never streamed gets no hot retry — say
666
+ // so in the park copy instead of promising budget retries that never come.
667
+ const neverStreamed = lastTurnStartAt > 0 && observedStreamAt <= lastTurnStartAt;
652
668
  const retryPlan = scheduleZombieAutoRetry(current, generation, goal?.id, observedStreamAt, silentMinutes);
653
669
  const loopRecap = loop
654
670
  ? compactLoopCompletionSummary({ ...loop, historyLength: loop.history.length })
@@ -656,7 +672,9 @@ export function abortZombieRun(
656
672
  const message = `${goal ? goal.policy === "list" ? "List item" : "Goal" : "Loop"} paused after ${silentMinutes}m with zero stream activity; the zombie turn was aborted and queued retries were stopped. Work is saved. ${
657
673
  retryPlan.scheduled
658
674
  ? `Automatic retry ${retryPlan.attempt}/${retryPlan.maxAttempts} starts in ~90s; ${resume} retries immediately instead.`
659
- : `${resume} retries it, or ${cancel} discards/stops it; the automatic retry budget is exhausted.`
675
+ : retryPlan.reason === "never-streamed"
676
+ ? `The turn never produced stream activity, so no automatic retry was scheduled — a retry would replay the same silence. ${resume} retries it, or ${cancel} discards/stops it.`
677
+ : `${resume} retries it, or ${cancel} discards/stops it; the automatic retry budget is exhausted.`
660
678
  }${loopRecap ? `\nRecap: ${loopRecap}` : ""}`;
661
679
  current.ui.notify(message, abortError ? "warning" : "info");
662
680
  notifyExternal(current, message);
@@ -2590,6 +2608,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
2590
2608
  if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || isForeignCtx(ctx)) return;
2591
2609
  ensureAgentToolsReady(ctx, true);
2592
2610
  lastStreamActivityAt = Date.now();
2611
+ lastTurnStartAt = lastStreamActivityAt;
2593
2612
  streamActivityObserved = true;
2594
2613
  // v0.32.1: a real turn started — the post-compaction resume debt is
2595
2614
  // discharged (the heartbeat stops retrying it).
@@ -2605,6 +2624,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
2605
2624
  if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || isForeignCtx(ctx)) return;
2606
2625
  ensureAgentToolsReady(ctx, true);
2607
2626
  lastStreamActivityAt = Date.now();
2627
+ lastTurnStartAt = lastStreamActivityAt;
2608
2628
  streamActivityObserved = true;
2609
2629
  dispatchStartAcknowledged(ctx, "turn_start");
2610
2630
  });
@@ -165,6 +165,7 @@ import {
165
165
  setLastContinuationSentPayloadRef,
166
166
  setContinuationRearmStreak,
167
167
  setContinuationRearmSince,
168
+ sendTerminalCompletionNotice,
168
169
  type ContinuationFlags,
169
170
  type ContinuationDeps,
170
171
  } from "../goal-continuation.js";
@@ -1443,6 +1444,19 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1443
1444
  // single line (pager/sound safe).
1444
1445
  liveCtx.ui.notify(`✓ done — ${brief.outcome}\n${[...brief.details, `— auditor ${result.model} approved${approvalVia}.`].join("\n")}`, "info");
1445
1446
  notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${recap}`);
1447
+ // v0.38.18 (track 3): the toast above is ephemeral — without a
1448
+ // transcript entry the session keeps narrating "waiting on the
1449
+ // auditor's verdict" after the archive (junk-runner field). Deliver
1450
+ // the brief into the conversation. Skipped for manual /goal verify:
1451
+ // that runs inside a turn whose command output already closes the
1452
+ // transcript. Fire-once fenced inside the sender.
1453
+ if (origin !== "manual") {
1454
+ sendTerminalCompletionNotice(liveCtx, {
1455
+ goalId,
1456
+ outcome: brief.outcome,
1457
+ details: [...brief.details, `— auditor ${result.model} approved${approvalVia}.`],
1458
+ });
1459
+ }
1446
1460
  return;
1447
1461
  }
1448
1462
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.17",
3
+ "version": "0.38.18",
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",