pi-goal-list-loop-audit 0.39.21 → 0.39.23

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
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.39.23 — terminal summary hardening: agent-proof headings, punctuation, clips (2026-10-10)
6
+
7
+ ### Summary render
8
+
9
+ - Agent-written `###`/`####` lines inside summary findings demote to
10
+ bold on chat (the Pi TUI has no heading style); archives keep real
11
+ headings.
12
+ - Finding outcomes strip stranded leading punctuation, matching the
13
+ reason husk fix.
14
+ - The headline clipper extends through a closing backtick instead of
15
+ stranding an unmatched one inside a code span.
16
+ - The `complete_goal` `leftOut` guidance forbids repeating Unresolved
17
+ facts (Unresolved = open problems; leftOut = deferred scope + why).
18
+
19
+ ## 0.39.22 — stalled-audit UI: dead audits named on every surface (2026-10-10)
20
+
21
+ ### Audit visibility
22
+
23
+ - Past one hour of worker silence with no in-budget tool, a quiet audit
24
+ escalates to **stalled** on every surface: the detailed card drops the
25
+ live-worker claim (`no worker responds`) and offers the retry
26
+ (`/goal resume retries the claim`); the compact glance card and footer
27
+ agree (`AUDIT STALLED`, error tone, ✖ glyph) instead of claiming
28
+ `detached worker` / `No action needed`. The quiet watcher fires its own
29
+ one-shot stall notice.
30
+ - Elapsed ages read in days past 24h (`15d 00h`, not `360h 00m`).
31
+ - Display-only: no retry or recovery behavior changed.
32
+
5
33
  ## 0.39.21 — 5h retry-window policy, stall-proof test cleanup (2026-10-09)
6
34
 
7
35
  ### Recovery policy
package/docs/INDEX.md CHANGED
@@ -31,7 +31,7 @@
31
31
  ## Versions and shipped content
32
32
 
33
33
  [CHANGELOG.md](../CHANGELOG.md) records the release history from
34
- v0.35.14–v0.39.21 and unreleased work. `/glla version` reports the loaded version
34
+ v0.35.14–v0.39.23 and unreleased work. `/glla version` reports the loaded version
35
35
  and registry comparison. A checkout can contain changes not yet published.
36
36
 
37
37
  The package includes this documentation, [examples](../examples/),
@@ -3,8 +3,8 @@
3
3
  Generated by `node scripts/generate-inventory.mjs`. Run with `--check` to detect drift. Counts describe files and literal registrations, not executed coverage.
4
4
 
5
5
  - Extension/script files: 109
6
- - Extension/script lines: 67486
7
- - Top-level test files: 378
6
+ - Extension/script lines: 67574
7
+ - Top-level test files: 380
8
8
  - Ambient bridge names: 179 (audit baseline: 179)
9
9
  - Commands: `/glla`, `/goal`, `/list`, `/loop`, `/review`
10
10
  - Tools: `audit_project_increment`, `block_project_requirement`, `claim_project_task`, `complete_goal`, `complete_task`, `list_activate`, `list_add`, `list_status`, `pause_goal`, `plan_project_increment`, `propose_goal_draft`, `propose_loop_draft`, `propose_loop_refine`, `propose_project_refinement`, `propose_project_requirements`, `propose_task_list`, `record_goal_judgment`, `resume_goal`, `unblock_project_requirement`, `update_task_batch`, `update_task_status`, `wait_for_background`
@@ -14,10 +14,10 @@ Generated by `node scripts/generate-inventory.mjs`. Run with `--check` to detect
14
14
  | Module | Lines |
15
15
  |---|---:|
16
16
  | [extensions/goal-loop-core.ts](../extensions/goal-loop-core.ts) | 5043 |
17
- | [extensions/loops/goal-tools.ts](../extensions/loops/goal-tools.ts) | 4227 |
17
+ | [extensions/loops/goal-tools.ts](../extensions/loops/goal-tools.ts) | 4229 |
18
18
  | [extensions/loops/goal-activation.ts](../extensions/loops/goal-activation.ts) | 3880 |
19
19
  | [extensions/goal-commands.ts](../extensions/goal-commands.ts) | 3614 |
20
- | [extensions/goal-loop-display.ts](../extensions/goal-loop-display.ts) | 2858 |
20
+ | [extensions/goal-loop-display.ts](../extensions/goal-loop-display.ts) | 2884 |
21
21
  | [extensions/goal-loop-auditor-process.ts](../extensions/goal-loop-auditor-process.ts) | 2685 |
22
22
  | [extensions/loops/goal-auditor-hooks.ts](../extensions/loops/goal-auditor-hooks.ts) | 2654 |
23
23
  | [extensions/goal-heartbeat.ts](../extensions/goal-heartbeat.ts) | 2104 |
@@ -509,10 +509,14 @@ function findingPresentation(finding: string, chat: boolean): { outcome: string;
509
509
  // evidence tokens ride mid-sentence, so strip a leading `,;:` husk.
510
510
  // Content words are untouched — only the join seam is cleaned.
511
511
  const normalizedReason = [...new Set(reasonParts)].join(" · ").trim().replace(/^[,;:]+/, "").trim();
512
+ // v0.39.23 (field 2026-10-10 — Clean-Web card `- , - with …`): the same
513
+ // stranded-punctuation husk can lead the outcome when the agent doubles
514
+ // punctuation around the em-dash split. Strip it like the reason husk.
515
+ const stripHusk = (text: string): string => text.trim().replace(/^[,;:]+/, "").trim();
512
516
  return {
513
517
  // Chat removes machine receipts; the archive keeps the full evidence
514
518
  // prose. The outcome/reason split itself is shared by both surfaces.
515
- outcome: chat ? chatNarrative(parsed.outcome) : sanitizeDisplayText(parsed.outcome),
519
+ outcome: stripHusk(chat ? chatNarrative(parsed.outcome) : sanitizeDisplayText(parsed.outcome)),
516
520
  reason: chat ? chatNarrative(normalizedReason) : sanitizeDisplayText(normalizedReason),
517
521
  evidence: parsed.evidence,
518
522
  };
@@ -1058,6 +1062,15 @@ export function buildRichTerminalParts(args: {
1058
1062
  };
1059
1063
  }
1060
1064
 
1065
+ /** Demote agent-written markdown headings to TUI-safe bold for chat.
1066
+ * A `#`-heading line that survives into chat content renders literally
1067
+ * (the Pi TUI has no heading style); `**bold**` renders everywhere the
1068
+ * card does. Archive/non-chat lines bypass this — a .md file renders
1069
+ * headings natively. */
1070
+ function terminalizeHeadings(line: string): string {
1071
+ return line.replace(/^(\s*)#{1,6}\s+(.*\S)\s*$/, "$1**$2**");
1072
+ }
1073
+
1061
1074
  /** Compose parts + banner + section headers into markdown lines.
1062
1075
  * v0.38.50: the duration line rides directly under the headline.
1063
1076
  * Audit 2026-09-13: findings-first order is pinned — findings, then the
@@ -1081,22 +1094,31 @@ export function composeRichTerminalLines(parts: RichTerminalParts): string[] {
1081
1094
  // the screen literally. Chat/terminal surfaces get plain section labels;
1082
1095
  // the archived markdown keeps its headings (a .md file renders them).
1083
1096
  const head = (text: string): string => (parts.chat ? text : `### ${text}`);
1097
+ // v0.39.23 (field 2026-10-10 — Clean-Web terminal screenshots): agents
1098
+ // write `###`/`####` headers INSIDE finding/remaining/next text and the
1099
+ // Pi TUI renders bold/lists but not `#` headings, so they reached the
1100
+ // screen literally. Chat-facing content demotes markdown headings to
1101
+ // bold (which the TUI renders); the archived markdown keeps them.
1102
+ const contentLine = (line: string): string => {
1103
+ const clean = sanitizeDisplayText(line);
1104
+ return parts.chat ? terminalizeHeadings(clean) : clean;
1105
+ };
1084
1106
  if (parts.summaryLines.length > 0) {
1085
- lines.push(head("Summary"), ...parts.summaryLines.map((line) => sanitizeDisplayText(line)), "");
1107
+ lines.push(head("Summary"), ...parts.summaryLines.map(contentLine), "");
1086
1108
  }
1087
1109
  if (parts.findingLines.length > 0) {
1088
- lines.push(head("What Changed"), ...parts.findingLines.map((line) => sanitizeDisplayText(line)), "");
1110
+ lines.push(head("What Changed"), ...parts.findingLines.map(contentLine), "");
1089
1111
  }
1090
1112
  if (parts.remainingLines.length > 0) {
1091
- lines.push(head("Remaining"), ...parts.remainingLines.map((line) => sanitizeDisplayText(line)), "");
1113
+ lines.push(head("Remaining"), ...parts.remainingLines.map(contentLine), "");
1092
1114
  }
1093
1115
  if (parts.verificationSummaryLine) {
1094
- lines.push(head("Verification"), sanitizeDisplayText(parts.verificationSummaryLine), "");
1116
+ lines.push(head("Verification"), contentLine(parts.verificationSummaryLine), "");
1095
1117
  } else if (!parts.chat && parts.tableLines.length > 0) {
1096
1118
  lines.push("### Verification Evidence", ...parts.tableLines.map((line) => sanitizeDisplayText(line)), "");
1097
1119
  }
1098
1120
  if (parts.nextLines.length > 0) {
1099
- lines.push(head("Next"), ...parts.nextLines.map((line) => sanitizeDisplayText(line)), "");
1121
+ lines.push(head("Next"), ...parts.nextLines.map(contentLine), "");
1100
1122
  }
1101
1123
  if (parts.repoLines.length > 0) {
1102
1124
  lines.push(head("Final Repository State"), ...parts.repoLines.map((line) => `- ${sanitizeDisplayText(line)}`), "");
@@ -166,9 +166,24 @@ export function clipSummaryValue(value: string, limit: number): string {
166
166
  headUnits.forEach((unit, i) => {
167
167
  if (/[,;:·—–(+[\[]/u.test(unit)) boundary = i;
168
168
  });
169
+ // v0.39.23 (field 2026-10-10 — Clean-Web card headline stranded
170
+ // "lands on `sendThreshold…" with an unmatched backtick): never cut
171
+ // inside an inline code span. When the kept head holds an unmatched
172
+ // backtick, extend through the closing backtick when it lands within a
173
+ // short bound; the capped overrun keeps the span readable instead of
174
+ // dangling.
175
+ const finalize = (text: string): string => {
176
+ const opens = (text.match(/`/g) ?? []).length;
177
+ if (opens % 2 === 0) return `${text}…`;
178
+ const consumed = [...text].length;
179
+ const rest = units.slice(consumed, consumed + 40).join("");
180
+ const close = rest.indexOf("`");
181
+ if (close >= 0) return `${text}${rest.slice(0, close + 1)}…`;
182
+ return `${text}…`;
183
+ };
169
184
  if (boundary >= floor) {
170
185
  const cut = headUnits.slice(0, boundary).join("").replace(/[,;:·—–(+[\[\s]+$/u, "");
171
- if ([...cut].length >= Math.min(floor, 16)) return `${cut}…`;
186
+ if ([...cut].length >= Math.min(floor, 16)) return finalize(cut);
172
187
  }
173
188
  let space = -1;
174
189
  headUnits.forEach((unit, i) => {
@@ -177,5 +192,5 @@ export function clipSummaryValue(value: string, limit: number): string {
177
192
  const kept = (space > capped / 2 ? headUnits.slice(0, space).join("") : head)
178
193
  .trimEnd()
179
194
  .replace(/[,;:·—–(+\[]$/u, "");
180
- return `${kept}…`;
195
+ return finalize(kept);
181
196
  }
@@ -58,7 +58,11 @@ export function fmtElapsed(ms: number): string {
58
58
  // liveness signal — minute-only granularity looks frozen on a 1s tick.
59
59
  if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
60
60
  const h = Math.floor(m / 60);
61
- return `${h}h ${String(m % 60).padStart(2, "0")}m`;
61
+ if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`;
62
+ // v0.39.22: day granularity past 24h — a 15-day stalled audit reads
63
+ // "15d 00h", not "360h 00m". Only affects renders past a day.
64
+ const d = Math.floor(h / 24);
65
+ return `${d}d ${String(h % 24).padStart(2, "0")}h`;
62
66
  }
63
67
 
64
68
  export function fmtTokens(n: number): string {
@@ -801,11 +805,17 @@ export interface AuditDisplayProgress {
801
805
  sessionPath?: string;
802
806
  }
803
807
 
804
- type AuditorDisplayPhase = "queued" | "running" | "quiet" | "blocked" | "awaiting-verdict";
808
+ type AuditorDisplayPhase = "queued" | "running" | "quiet" | "stalled" | "blocked" | "awaiting-verdict";
805
809
  /** v0.35.15: exported — the parent-side quiet watcher (goal-ui ticker) uses
806
810
  * the same threshold for the one-shot proactive notify so the notification
807
811
  * and the status chip can never disagree about when "quiet" begins. */
808
812
  export const AUDITOR_QUIET_MS = 3 * 60_000;
813
+ /** v0.39.22 (field 2026-10-10: seo goal quiet 15 days, worker dead):
814
+ * quiet past this bound with no worker activity and no in-budget tool is
815
+ * a dead worker, not a slow one. An hour of total silence exceeds any
816
+ * legitimate inter-tool gap; the stalled presentation names the death and
817
+ * offers the retry (/goal resume) instead of only the discard. */
818
+ export const AUDITOR_STALLED_MS = 60 * 60_000;
809
819
  /** v0.35.15: how long the "silent Xm then resumed" fact stays in the
810
820
  * detailed auditor card after a quiet stretch ends — long enough to be seen,
811
821
  * short enough not to become permanent noise. */
@@ -911,6 +921,7 @@ export function auditorDisplayPhase(g: Goal, audit: AuditDisplayProgress | null
911
921
  toolAgeMs < audit.toolTimeoutMs
912
922
  )
913
923
  return "running";
924
+ if (age > AUDITOR_STALLED_MS) return "stalled";
914
925
  return "quiet";
915
926
  }
916
927
  // Same lifecycle scope as above: a stale goal snapshot that still
@@ -959,6 +970,7 @@ function auditorPhaseLabel(phase: AuditorDisplayPhase): string {
959
970
  case "queued": return "queued";
960
971
  case "running": return "running";
961
972
  case "quiet": return "quiet";
973
+ case "stalled": return "stalled";
962
974
  case "blocked": return "blocked";
963
975
  case "awaiting-verdict": return "awaiting completion review";
964
976
  }
@@ -973,6 +985,7 @@ export function auditorPhaseGlyph(phase: AuditorDisplayPhase): string {
973
985
  case "queued": return "⋯"; // waiting to start
974
986
  case "running": return "▶"; // worker alive
975
987
  case "quiet": return "◌"; // silent — possibly stuck
988
+ case "stalled": return "✖"; // silent past the death bound — worker dead
976
989
  case "blocked": return "⛔"; // infra failure / needs intervention
977
990
  case "awaiting-verdict": return "✓"; // worker done, verdict pending
978
991
  }
@@ -1508,11 +1521,11 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1508
1521
  // v0.35.15: leading phase glyph + draining activity meter — a glance
1509
1522
  // answers "is the audit alive?" without reading the sentence.
1510
1523
  const phaseText = `auditor ${auditorPhaseGlyph(phase)} ${observed}`;
1511
- const color = phase === "blocked" || phase === "quiet" ? "warning" : live ? "success" : "accent";
1524
+ const color = phase === "stalled" ? "error" : phase === "blocked" || phase === "quiet" ? "warning" : live ? "success" : "accent";
1512
1525
  const activityMeter = auditorActivityMeter(audit, phase, now);
1513
1526
  const label = live
1514
1527
  ? `${paint(theme, "success", phaseText)} ${paint(theme, "accent", activityMeter)} ${activityBadge("AUDITOR · DETACHED · LIVE", now, theme)}`
1515
- : `${paint(theme, color, phaseText)} ${paint(theme, color === "warning" ? "warning" : "dim", activityMeter)}`;
1528
+ : `${paint(theme, color, phaseText)} ${paint(theme, color === "warning" || color === "error" ? color : "dim", activityMeter)}`;
1516
1529
  // The persistent footer is the liveness surface only: host, phase, and
1517
1530
  // freshness. The auditor card above already owns the transition hint
1518
1531
  // ("next:"), the worker attribution ("detached worker"), and the
@@ -1521,7 +1534,7 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1521
1534
  const quietAge = phase === "quiet" ? auditorActivityAge(audit, now) : undefined;
1522
1535
  const quietSuffix = quietAge !== undefined ? ` · silent ${fmtElapsed(quietAge)}` : "";
1523
1536
  const compactPrefix = extras?.compactAuditCard
1524
- ? `${paint(theme, color, durableLabel ?? (toolWait ? `AUDIT TOOL WAIT · ${sanitizeDisplayText(audit!.currentTool!)} ${fmtElapsed(now - audit!.currentToolStartedAt!)} / ${fmtElapsed(audit!.toolTimeoutMs!)}` : phase === "running" ? "AUDIT RUNNING" : phase === "quiet" ? "AUDIT QUIET — may be stuck" : phase === "blocked" ? "AUDIT BLOCKED" : phase === "queued" ? "AUDIT STARTING" : "AUDIT REVIEW"))} · `
1537
+ ? `${paint(theme, color, durableLabel ?? (toolWait ? `AUDIT TOOL WAIT · ${sanitizeDisplayText(audit!.currentTool!)} ${fmtElapsed(now - audit!.currentToolStartedAt!)} / ${fmtElapsed(audit!.toolTimeoutMs!)}` : phase === "running" ? "AUDIT RUNNING" : phase === "quiet" ? "AUDIT QUIET — may be stuck" : phase === "stalled" ? "AUDIT STALLED — worker dead" : phase === "blocked" ? "AUDIT BLOCKED" : phase === "queued" ? "AUDIT STARTING" : "AUDIT REVIEW"))} · `
1525
1538
  : "";
1526
1539
  const activityAge = auditorActivityAge(audit, now);
1527
1540
  const freshness = extras?.compactAuditCard && phase !== "quiet" && !toolWait
@@ -1983,12 +1996,13 @@ function heldLoopLines(l: LoopState, now: number, theme?: DisplayTheme, width?:
1983
1996
  }
1984
1997
 
1985
1998
  /** Phase colour for the auditing card's lead row. Blocked/quiet are
1986
- * warnings; a verdict that landed or live worker evidence is success;
1999
+ * warnings, stalled is an error; a verdict that landed or live worker evidence is success;
1987
2000
  * queued/running-without-evidence stay neutral accent — a hung provider
1988
2001
  * must never wear success (v0.34.39). Colour always pairs with the phase
1989
2002
  * words (paint wraps the label, never replaces it), so a themeless
1990
2003
  * renderer loses colour but never meaning. */
1991
2004
  function auditorPhaseTone(phase: AuditorDisplayPhase, live: boolean): DisplayColor {
2005
+ if (phase === "stalled") return "error";
1992
2006
  if (phase === "blocked" || phase === "quiet") return "warning";
1993
2007
  if (phase === "awaiting-verdict" || (phase === "running" && live)) return "success";
1994
2008
  return "accent";
@@ -2009,6 +2023,7 @@ function auditorCardModelRef(audit: AuditDisplayProgress | null | undefined, cla
2009
2023
  * row and the closing line can never name different actions. */
2010
2024
  function auditorNextAction(phase: AuditorDisplayPhase): string {
2011
2025
  if (phase === "quiet") return "/goal cancel discards the claim";
2026
+ if (phase === "stalled") return "/goal resume retries the claim";
2012
2027
  if (phase === "blocked") return "/goal resume retries the claim";
2013
2028
  if (phase === "awaiting-verdict") return "completion review applying";
2014
2029
  if (phase === "queued") return "worker starting";
@@ -2082,7 +2097,11 @@ function auditingCardBlock(g: Goal, audit: AuditDisplayProgress | null | undefin
2082
2097
  const staleSeg = activity === undefined && durable?.stale && durable.idleMs !== undefined
2083
2098
  ? ` · no progress ${fmtElapsed(durable.idleMs)}`
2084
2099
  : "";
2085
- const lead = [`├─ auditor: ${paint(theme, auditorPhaseTone(phase, phaseLive), effectivePhaseLabel)}${detail} · detached worker · ${ageSeg}${staleSeg}`];
2100
+ // v0.39.22: a stalled audit asserts no live worker — printing
2101
+ // "detached worker" would claim a process that has not spoken for over
2102
+ // an hour. The `auditor:` anchor stays; only the worker segment changes.
2103
+ const workerSeg = phase === "stalled" ? "no worker responds" : "detached worker";
2104
+ const lead = [`├─ auditor: ${paint(theme, auditorPhaseTone(phase, phaseLive), effectivePhaseLabel)}${detail} · ${workerSeg} · ${ageSeg}${staleSeg}`];
2086
2105
  // Lead row 2: current tool + time budget, effective model + thinking.
2087
2106
  // Absent facts are omitted, never invented. The tool observation lives
2088
2107
  // ONLY here (it moved out of the tail) so the card keeps its
@@ -2161,7 +2180,9 @@ function auditingCardBlock(g: Goal, audit: AuditDisplayProgress | null | undefin
2161
2180
  tail.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, "dim", observation)}`);
2162
2181
  });
2163
2182
  const last = auditorLastActivity(audit, now);
2164
- if (phase === "quiet") {
2183
+ if (phase === "stalled") {
2184
+ tail.push(`└─ ${paint(theme, "error", `auditor stalled ${fmtElapsed(activity ?? 0)} — no worker activity, claim durable; /goal resume retries, /goal cancel discards`)}`);
2185
+ } else if (phase === "quiet") {
2165
2186
  const quietMs = activity ?? 0;
2166
2187
  tail.push(`└─ ${paint(theme, "warning", `auditor quiet ${fmtElapsed(quietMs)}${last} — may be stuck; /goal cancel discards the claim`)}`);
2167
2188
  } else if (phase === "blocked") {
@@ -2306,7 +2327,12 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
2306
2327
  if (extras?.compactAuditCard) {
2307
2328
  const phase = auditorDisplayPhase(g, audit, now);
2308
2329
  const durable = auditLifecycleProjection(g.pendingCompletion, { now });
2309
- const needsRecovery = auditRecoveryPending(g) || phase === "quiet" || phase === "blocked" || durable?.phase === "settling";
2330
+ // v0.39.22: stalled needs recovery like quiet/blocked — without it
2331
+ // the glance path falls through to "Audit review pending · detached
2332
+ // worker" + "No action needed", preserving the can't-tell gap on
2333
+ // the production surface. With it, the glance reuses the detailed
2334
+ // lead (stalled · no worker responds) and its retry action row.
2335
+ const needsRecovery = auditRecoveryPending(g) || phase === "quiet" || phase === "stalled" || phase === "blocked" || durable?.phase === "settling";
2310
2336
  const elapsed = auditorElapsedMs(audit, now);
2311
2337
  const finished = audit?.toolCalls?.filter(call => call.finishedAt !== undefined).length;
2312
2338
  const age = auditorActivityAge(audit, now);
@@ -637,6 +637,8 @@ function registerAgentTools(pi: any): void {
637
637
  "v0.38.37: what this turn deliberately left out (scope cut, deferred item + why, in plain words). " +
638
638
  "Renders under `### Remaining` in the user-facing terminal summary. " +
639
639
  "Bounds match the documented 10k-char value guard — longer input clips at a clause boundary, never a refusal. " +
640
+ "Never repeat a fact already covered by Unresolved: Unresolved names open problems and risks, leftOut names deliberately deferred scope and why. " +
641
+ "When both would say the same thing, put it in Unresolved once and omit leftOut. " +
640
642
  "Omit when nothing was deliberately left out — absent stays absent, never invented.",
641
643
  })),
642
644
  showVerification: Type.Optional(Type.Boolean({
@@ -914,6 +914,8 @@ function scheduleUIRefresh(): void {
914
914
  // suppresses the notify (it is automatic machinery), never the recording.
915
915
  let auditorQuietSince: number | null = null;
916
916
  let auditorQuietNotified = false;
917
+ let auditorStallSince: number | null = null;
918
+ let auditorStallNotified = false;
917
919
  let lastAuditorQuietStretch: { ms: number; endedAt: number } | null = null;
918
920
 
919
921
  /** Drive the quiet watcher once per UI tick. Returns a warning message to
@@ -929,6 +931,8 @@ export function __auditorQuietWatchTick(now = Date.now()): string | null {
929
931
  auditorQuietSince = null;
930
932
  auditorQuietNotified = false;
931
933
  }
934
+ auditorStallSince = null;
935
+ auditorStallNotified = false;
932
936
  return null;
933
937
  }
934
938
  const phase = auditorDisplayPhase(g, latestAuditProgress, now);
@@ -945,6 +949,23 @@ export function __auditorQuietWatchTick(now = Date.now()): string | null {
945
949
  }
946
950
  return null;
947
951
  }
952
+ // v0.39.22: a stalled worker gets its own one-shot notify — the quiet
953
+ // notice fired an hour ago and reads as routine by now. The claim stays
954
+ // durable either way; the notify names the retry, not just the discard.
955
+ if (phase === "stalled") {
956
+ if (auditorStallSince === null) {
957
+ auditorStallSince = typeof latestAuditProgress?.lastActivityAt === "number"
958
+ ? latestAuditProgress.lastActivityAt
959
+ : now;
960
+ }
961
+ if (!auditorStallNotified && !supervisorPaused(state)) {
962
+ auditorStallNotified = true;
963
+ return `glla: the detached auditor is STALLED — no worker activity for ${fmtElapsed(now - auditorStallSince)} and no tool running. The claim is durable; /goal resume retries it, /goal cancel discards it.`;
964
+ }
965
+ return null;
966
+ }
967
+ if (auditorStallSince !== null) auditorStallSince = null;
968
+ auditorStallNotified = false;
948
969
  // Left the quiet phase: record the ended stretch for the footer summary.
949
970
  if (auditorQuietSince !== null) {
950
971
  const ms = now - auditorQuietSince;
@@ -1212,6 +1233,8 @@ export function __testOnlyAuditorQuietWatchTick(
1212
1233
  export function __testOnlyResetAuditorQuietWatch(): void {
1213
1234
  auditorQuietSince = null;
1214
1235
  auditorQuietNotified = false;
1236
+ auditorStallSince = null;
1237
+ auditorStallNotified = false;
1215
1238
  lastAuditorQuietStretch = null;
1216
1239
  }
1217
1240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.39.21",
3
+ "version": "0.39.23",
4
4
  "description": "Durable work supervision for Pi: turn outcomes into goals, audited queues and spec-driven builds, recover interruptions, and verify completion against evidence.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",