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

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.19 — disapproval response: dispatch stall root-cause + state clear (2026-09-04)
4
+
5
+ ### Fixed
6
+ - Track 2 root cause (post-answer stall): the wait-for-idle send gate deferred an owed continuation forever when the session went phantom-busy — busy, nothing pending, zero stream (neonbreak: 35 re-arms, zero sends, 45m to the zombie abort). A busy session with nothing pending and no real stream for `GLLA_BUSY_SILENT_SEND_MS` (default 5m) is wedged, not working: the marker is now sent into pi's followUp queue instead (`goal_continuation_send_busy_bypass`, `busyBypass` on the sent event). Genuinely working sessions and loaded queues keep the old wait path.
7
+ - Track 3 state clear (stale waiting-verdict): `auditorDisplayPhase` projects `awaiting-verdict` only while the goal is still `auditing`. Closed goals (complete/aborted) and pre-archive snapshots carrying a stale running claim fall through to the quiet gate — no stale progress object can resurrect the wait after `goal_archived`.
8
+ - Both fixes carry behavioral MockPi regression tests with empirical fail-before (old code fails the new assertions, guards pass throughout): `tests/answered-question-dispatch.test.ts` (3), `tests/closed-goal-clears-waiting.test.ts` (2).
9
+
10
+ ## 0.38.18 — completion/lifecycle field trilogy (2026-09-04)
11
+
12
+ ### Fixed
13
+ - 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).
14
+ - 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".
15
+ - 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).
16
+
3
17
  ## 0.38.17 — release-contract docs trail (2026-09-04)
4
18
 
5
19
  ### 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.19 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
@@ -323,6 +330,26 @@ export function sendRearmDelayMs(streak: number): number {
323
330
  return 30_000;
324
331
  }
325
332
 
333
+ /** v0.38.19 (track 2): how long a busy session with an owed send may go
334
+ * without real stream output before the marker is sent into pi's followUp
335
+ * queue anyway. 5m matches SEND_REARM_ESCALATE_SILENT_MS's definition of
336
+ * wedged silence, far below the ~45m zombie abort that used to be the only
337
+ * exit from a phantom-busy session. */
338
+ export function busySilentSendMs(): number {
339
+ const raw = Number(process.env.GLLA_BUSY_SILENT_SEND_MS ?? 5 * 60_000);
340
+ return Number.isFinite(raw) && raw >= 0 ? raw : 5 * 60_000;
341
+ }
342
+
343
+ /** v0.38.19 (track 2): true when the session's business is stale enough
344
+ * that waiting for idle is the stall, not patience. Requires observed
345
+ * stream history (lastRealActivityAt > 0): a session that never streamed
346
+ * in this process is ignorance, not evidence of wedging — it keeps the old
347
+ * wait path (storm → escalation → zombie abort). */
348
+ export function busySilentBypassDue(): boolean {
349
+ if (flags.lastRealActivityAt <= 0) return false;
350
+ return Date.now() - flags.lastRealActivityAt >= busySilentSendMs();
351
+ }
352
+
326
353
  export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "loop"): void {
327
354
  const streak = kind === "continuation" ? ++continuationRearmStreak : ++flags.loopRearmStreak;
328
355
  if (streak === 1) {
@@ -348,10 +375,18 @@ export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "
348
375
  const noDispatchAccepted = lastContinuationSentAt === 0 || lastContinuationSentAt < continuationRearmSince;
349
376
  if (noDispatchAccepted && lastNoTurnStartedNotifiedAt + SEND_REARM_LEDGER_MILESTONES_MS[0]! <= Date.now()) {
350
377
  lastNoTurnStartedNotifiedAt = Date.now();
351
- appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000) });
378
+ // v0.38.18 (track 2): a turn that began after the storm started is
379
+ // open — reporting "no turn started" then is false and sends the
380
+ // user down the wrong path (resume a queue that is not stuck vs
381
+ // interrupt a hung turn). Say which is true.
382
+ const turnOpen = lastObservedTurnStartAt >= since && lastObservedTurnStartAt > 0;
383
+ const turnOpenMinutes = turnOpen ? Math.max(1, Math.round((Date.now() - lastObservedTurnStartAt) / 60000)) : 0;
384
+ appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000), turnOpen, turnOpenMinutes });
352
385
  const aggressive = resolveEffectiveAggressiveSettings(loadSettings(ctx.cwd)).aggressiveMode;
353
386
  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}.`;
387
+ const msg = turnOpen
388
+ ? `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}.`
389
+ : `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
390
  ctx.ui.notify(msg, "warning");
356
391
  notifyExternal(ctx, msg);
357
392
  }
@@ -569,6 +604,10 @@ export function dispatchFailed(ctx: ExtensionContext, record: ContinuationDispat
569
604
  }
570
605
 
571
606
  export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string, prompt?: unknown): boolean {
607
+ // v0.38.18 (track 2): record every begin-marker even when no dispatch is
608
+ // pending — the rearm milestone uses it to tell an open-but-silent turn
609
+ // from a turn that never started.
610
+ if (source === "turn_start" || source === "agent_start") lastObservedTurnStartAt = Date.now();
572
611
  // v0.34.104 ([Image-#1]): any real agent activity during the
573
612
  // post-list-completion settle window means pi woke up on its own — the
574
613
  // deferred continuation must be cancelled so we don't double-dispatch.
@@ -1058,6 +1097,57 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
1058
1097
  continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), delay);
1059
1098
  }
1060
1099
 
1100
+ export interface TerminalCompletionNotice {
1101
+ goalId: string;
1102
+ outcome: string;
1103
+ details: string[];
1104
+ }
1105
+
1106
+ /** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
1107
+ * verifier settles asynchronously — the transcript's last word is "the
1108
+ * verdict will be applied asynchronously", and a toast is the only
1109
+ * closure. A later "how are we looking" then truthfully re-reports the
1110
+ * stale transcript as still-waiting even though the goal is archived.
1111
+ * This delivers the `✓ done` brief INTO the conversation as a followUp
1112
+ * turn so the transcript records the completion. Goal-null-safe (the goal
1113
+ * is already archived when this fires), fire-once per goal via a durable
1114
+ * ledger fence, and fenced like every other automatic send. Returns true
1115
+ * when the notice was dispatched. */
1116
+ export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: TerminalCompletionNotice): boolean {
1117
+ if (supervisorPaused(state)) return false;
1118
+ if (mainModelRecoveryActive()) return false;
1119
+ if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
1120
+ if (!flags.extensionApi) return false;
1121
+ if (isForeignCtx(ctx)) return false;
1122
+ try {
1123
+ const already = readLedgerTail(ctx.cwd, 400, (entry) =>
1124
+ entry.type === "terminal_completion_notice_sent" &&
1125
+ typeof (entry.value as { goalId?: unknown } | null)?.goalId === "string" &&
1126
+ (entry.value as { goalId: string }).goalId === notice.goalId,
1127
+ );
1128
+ if (already.length > 0) return false;
1129
+ } catch {
1130
+ return false;
1131
+ }
1132
+ const content = [
1133
+ `✓ done — ${notice.outcome}`,
1134
+ ...notice.details,
1135
+ "— goal archived; nothing further is owed. Acknowledge briefly; start follow-up work only if asked.",
1136
+ ].join("\n");
1137
+ try {
1138
+ flags.extensionApi.sendMessage({
1139
+ customType: GOAL_EVENT_ENTRY,
1140
+ content,
1141
+ display: false,
1142
+ }, { triggerTurn: true, deliverAs: "followUp" });
1143
+ } catch {
1144
+ appendLedger(ctx.cwd, "terminal_completion_notice_unsent", { goalId: notice.goalId });
1145
+ return false;
1146
+ }
1147
+ appendLedger(ctx.cwd, "terminal_completion_notice_sent", { goalId: notice.goalId, payloadChars: content.length });
1148
+ return true;
1149
+ }
1150
+
1061
1151
  export function buildMarkerContent(goalId: string): string {
1062
1152
  return `[GOAL CHECKPOINT goalId=${goalId}]`;
1063
1153
  }
@@ -1155,12 +1245,35 @@ export function sendContinuation(goalId: string): void {
1155
1245
  }
1156
1246
  if (!guardGoalBeforeContinuation(ctx, "dispatch", goalId)) return;
1157
1247
  if (!isActionableGoal()) return;
1248
+ // v0.38.19 (track 2): set when the busy-but-silent bypass below fires so
1249
+ // the sent ledger event carries the marker. The test hook for the stream
1250
+ // clock lives in loops/goal-ui.ts (__testOnlySetLastRealActivityAt).
1251
+ let busyBypass = false;
1158
1252
  if (!ctx.isIdle() || ctx.hasPendingMessages()) {
1159
1253
  accountSendRearm(ctx, "continuation");
1160
- continuationScheduledFor = goalId;
1161
- // v0.28.29: backing-off cadence (was flat 50ms — 6,000 spins in 5m).
1162
- continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), sendRearmDelayMs(continuationRearmStreak));
1163
- return;
1254
+ // v0.38.19 (track 2, auditor-required): busy-but-silent bypass. A
1255
+ // session that is busy with nothing pending and zero real stream for
1256
+ // busySilentSendMs is not doing work a followUp could corrupt — it is
1257
+ // wedged (neonbreak: answer in, 45m of phantom-busy, zero sends because
1258
+ // wait-for-idle never cleared). pi's followUp queue is the designed
1259
+ // vehicle for exactly this: the marker queues behind the stuck turn
1260
+ // instead of rearming into the void until the zombie abort. Genuinely
1261
+ // working sessions (fresh stream) and loaded queues (pending messages)
1262
+ // keep the old wait path.
1263
+ if (!ctx.isIdle() && !ctx.hasPendingMessages() && busySilentBypassDue()) {
1264
+ busyBypass = true;
1265
+ appendLedger(ctx.cwd, "goal_continuation_send_busy_bypass", {
1266
+ goalId,
1267
+ generation: flags.sessionGeneration,
1268
+ silentMs: Date.now() - flags.lastRealActivityAt,
1269
+ rearmStreak: continuationRearmStreak,
1270
+ });
1271
+ } else {
1272
+ continuationScheduledFor = goalId;
1273
+ // v0.28.29: backing-off cadence (was flat 50ms — 6,000 spins in 5m).
1274
+ continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), sendRearmDelayMs(continuationRearmStreak));
1275
+ return;
1276
+ }
1164
1277
  }
1165
1278
  if (!flags.extensionApi || flags.extensionApiStale) return;
1166
1279
  try {
@@ -1194,7 +1307,7 @@ export function sendContinuation(goalId: string): void {
1194
1307
  lastContinuationSentPayload = { content, display: false }; // v0.34.88: verbatim retry payload
1195
1308
  if (!dispatchAccepted(ctx, attempt)) return;
1196
1309
  continuationRearmStreak = 0; continuationRearmSince = 0; // v0.28.5 (E3): an accepted dispatch clears the storm
1197
- appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length });
1310
+ appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length, ...(busyBypass ? { busyBypass: true } : {}) });
1198
1311
  // v0.35.37 (audit finding): the welcome-back recovery notice must fire
1199
1312
  // EXACTLY ONCE per auto-resume. The payload above was built with the
1200
1313
  // notice in it; once the dispatch is ACCEPTED the message has landed in
@@ -1504,6 +1617,11 @@ export function clearQueueStuckProbe(): void {
1504
1617
  export function setContinuationRearmStreak(v: number): void {
1505
1618
  continuationRearmStreak = v;
1506
1619
  }
1620
+
1621
+ /** Test-only: pretend a turn begin-marker was observed at the given clock. */
1622
+ export function __testOnlySetObservedTurnStartAt(v: number): void {
1623
+ lastObservedTurnStartAt = v;
1624
+ }
1507
1625
  export function setContinuationRearmSince(v: number): void {
1508
1626
  continuationRearmSince = v;
1509
1627
  }
@@ -1521,6 +1639,7 @@ export function resetContinuationDispatchState(cwd: string): boolean {
1521
1639
  continuationRearmSince = 0;
1522
1640
  continuationRearmMilestone = 0;
1523
1641
  lastNoTurnStartedNotifiedAt = 0;
1642
+ lastObservedTurnStartAt = 0;
1524
1643
  continuationStartCompactionRearms.clear();
1525
1644
  continuationStartRecoveryRearms.clear();
1526
1645
  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
 
@@ -668,7 +668,15 @@ export function auditorDisplayPhase(g: Goal, audit: AuditDisplayProgress | null
668
668
  const label = audit?.label?.toLowerCase() ?? "";
669
669
  if (label === "queued") return "queued";
670
670
  if (/infra|error|failed|blocked|no verdict/.test(label)) return "blocked";
671
- if (audit?.phase === "complete") return "awaiting-verdict";
671
+ // v0.38.19 (track 3, auditor-required): awaiting-verdict is a LIVE claim
672
+ // — a worker done, its verdict not yet applied. A closed goal (complete /
673
+ // aborted, or anything past auditing) can never be waiting: the verdict
674
+ // landed or the claim died with the archive. A stale progress object
675
+ // handed in for a closed goal must not resurrect the wait (junk-runner:
676
+ // session narrating "waiting on the auditor's verdict" after
677
+ // goal_archived complete). Stale timestamps fall through to the quiet
678
+ // gate below, which is true — no worker will ever speak again.
679
+ if (audit?.phase === "complete" && g.status === "auditing") return "awaiting-verdict";
672
680
  const age = auditorActivityAge(audit, now);
673
681
  if (age !== undefined && age > AUDITOR_QUIET_MS) {
674
682
  // v0.37.0: progress-aware quiet gate. A tool that is still running INSIDE
@@ -691,7 +699,12 @@ export function auditorDisplayPhase(g: Goal, audit: AuditDisplayProgress | null
691
699
  return "running";
692
700
  return "quiet";
693
701
  }
694
- if (!audit && g.pendingCompletion?.phase === "running") return "awaiting-verdict";
702
+ // Same lifecycle scope as above: a stale goal snapshot that still
703
+ // carries a running pendingCompletion must not project a wait either.
704
+ // (The archive strips pendingCompletion; this guards pre-archive
705
+ // snapshots read after the close.)
706
+ if (!audit && g.status === "auditing" && g.pendingCompletion?.phase === "running") return "awaiting-verdict";
707
+ if (!audit && g.pendingCompletion?.phase === "running") return "quiet";
695
708
  return "running";
696
709
  }
697
710
 
@@ -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
 
@@ -1044,6 +1044,12 @@ function isContextStarvedRefused(): boolean {
1044
1044
  export function __testOnlySetLastCompactionAt(at: number | null): void {
1045
1045
  lastCompactionAt = at ?? 0;
1046
1046
  }
1047
+ /** Test-only: set the real-stream clock directly so busy-silence scenarios
1048
+ * (answered question, phantom-busy host, zero stream) are drivable without
1049
+ * wall-clock waits. Exported through loops/goal.js like the other hooks. */
1050
+ export function __testOnlySetLastRealActivityAt(at: number): void {
1051
+ lastRealActivityAt = at;
1052
+ }
1047
1053
 
1048
1054
  /** Test-only: expose the live activity projection without requiring a TUI
1049
1055
  * renderer. This drives the same scope/timestamp checks used by refreshUI. */
@@ -74,7 +74,7 @@ export {
74
74
  __testOnlyResetOwnerSession,
75
75
  __testOnlyRunFanOutListAuditFindings,
76
76
  } from "./goal-session.js";
77
- export { __testOnlySetLastCompactionAt, __testOnlyLoadState, __testOnlyRegisterAgentTools, __testOnlyRememberCtx, __testOnlyDisplayActivityFor, __testOnlyResetToolActivity } from "./goal-ui.js";
77
+ export { __testOnlySetLastCompactionAt, __testOnlyLoadState, __testOnlyRegisterAgentTools, __testOnlyRememberCtx, __testOnlyDisplayActivityFor, __testOnlyResetToolActivity, __testOnlySetLastRealActivityAt } from "./goal-ui.js";
78
78
  export { auditorRetryPlan, runDetachedCompletionWithFallback, __testOnlySetAuditorRecoveryRetryDelay, __testOnlyResetAuditorRecoveryRuntime, type AuditorModelCandidate } from "./goal-auditor-hooks.js";
79
79
  export { handleSettingChoice } from "./goal-settings-ui.js";
80
80
 
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.19",
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",