pi-goal-list-loop-audit 0.26.3 → 0.26.5

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.
@@ -90,6 +90,44 @@ export const MEASURE_TIMEOUT_MS = 10 * 60_000;
90
90
  * disapprove, never hang the completion gate forever. */
91
91
  export const AUDITOR_STALL_MS = 10 * 60_000;
92
92
 
93
+ /** v0.26.5: pending-latch watchdog threshold. Field-observed failure: a
94
+ * continuation sent right at compaction was ACCEPTED by pi (sendMessage
95
+ * returned) but the turn trigger was dropped — pi's pending-message flag
96
+ * then stayed set forever. sessionIdle (= isIdle && !hasPendingMessages)
97
+ * never went true, so the heartbeat refire path AND the stall escalation
98
+ * were both suppressed: 22 minutes of total silence until a manual nudge.
99
+ * The wedge alert was blind too (22m < 30m threshold, and its "hung
100
+ * command" framing would be wrong anyway). This watchdog owns that
101
+ * shape: idle + pending + silent >= threshold = the latch is stuck. */
102
+ export const PENDING_LATCH_STUCK_MS = 3 * 60_000;
103
+
104
+ export interface PendingLatchInput {
105
+ /** A goal is active (autoContinue) or a loop is running. */
106
+ supervising: boolean;
107
+ /** ctx.isIdle() — the session is NOT mid-turn. */
108
+ idle: boolean;
109
+ /** ctx.hasPendingMessages() — pi believes a message is still queued. */
110
+ pending: boolean;
111
+ /** A continuation or loop timer is already scheduled. */
112
+ timerPending: boolean;
113
+ /** Milliseconds since the last observed agent activity. */
114
+ silentMs: number;
115
+ /** Threshold in ms; 0 disables the watchdog. */
116
+ thresholdMs: number;
117
+ }
118
+
119
+ /** Should the pending-latch watchdog count a stall right now? It never
120
+ * re-sends: the message is ALREADY queued pi-side, and the hegemon
121
+ * zombie proved re-sends don't unstick a dropped trigger (619 sends,
122
+ * zero turns). Count + notify + escalate to a loud stop instead. */
123
+ export function shouldFirePendingLatchWatchdog(input: PendingLatchInput): boolean {
124
+ if (!input.supervising) return false;
125
+ if (!input.idle || !input.pending) return false;
126
+ if (input.timerPending) return false;
127
+ if (input.thresholdMs <= 0) return false;
128
+ return input.silentMs >= input.thresholdMs;
129
+ }
130
+
93
131
  export interface WedgeInput {
94
132
  /** A goal is active (autoContinue) or a loop is running. */
95
133
  supervising: boolean;
@@ -160,6 +160,8 @@ import {
160
160
  MEASURE_TIMEOUT_MS,
161
161
  WEDGE_ALERT_DEFAULT_MINUTES,
162
162
  shouldWedgeAlert,
163
+ PENDING_LATCH_STUCK_MS,
164
+ shouldFirePendingLatchWatchdog,
163
165
  } from "../goal-loop-backoff.js";
164
166
 
165
167
  // =================================================================
@@ -280,15 +282,74 @@ function startUITicker(): void {
280
282
  uiTicker.unref?.();
281
283
  }
282
284
 
285
+ /** v0.26.5: shared loud-stop for both stall paths (refire streak and
286
+ * pending-latch streak). Returns true when it escalated. */
287
+ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
288
+ if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
289
+ consecutiveStalls = 0;
290
+ appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
291
+ if (isLoopActive()) {
292
+ clearLoopTimer();
293
+ state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${threshold} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
294
+ persistState(ctx);
295
+ ctx.ui.notify(`Loop stopped: ${threshold} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
296
+ notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
297
+ return true;
298
+ }
299
+ if (state.goal && state.goal.status === "active") {
300
+ updateGoal({
301
+ status: "paused",
302
+ pauseReason: `stalled: ${threshold} continuation refires landed no turn`,
303
+ pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
304
+ }, ctx);
305
+ ctx.ui.notify(`Goal paused: ${threshold} refires produced no turn. Restart pi, then /goal resume.`, "warning");
306
+ notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
307
+ return true;
308
+ }
309
+ return true;
310
+ }
311
+
283
312
  function heartbeatTick(): void {
284
313
  const ctx = freshCtx();
285
314
  if (!ctx) return;
286
- let sessionIdle = false;
315
+ let idle = false;
316
+ let pending = false;
287
317
  try {
288
- sessionIdle = ctx.isIdle() && !ctx.hasPendingMessages();
318
+ idle = ctx.isIdle();
319
+ pending = ctx.hasPendingMessages();
289
320
  } catch {
290
321
  return;
291
322
  }
323
+ const sessionIdle = idle && !pending;
324
+ // v0.26.5: pending-latch watchdog — a queued continuation whose turn
325
+ // trigger was dropped (field-observed post-compaction: continuation
326
+ // ACCEPTED at compact+0s, then 22 minutes of silence). The stuck latch
327
+ // keeps sessionIdle false, which suppresses the refire path AND the
328
+ // stall escalation below — without this branch the session is silent
329
+ // forever. We never re-send here (the message is already queued
330
+ // pi-side; hegemon proved re-sends don't unstick a dropped trigger) —
331
+ // count, notify, escalate to a loud stop.
332
+ const latchSilentMs = Date.now() - lastActivityAt;
333
+ if (
334
+ shouldFirePendingLatchWatchdog({
335
+ supervising: isSupervising(),
336
+ idle,
337
+ pending,
338
+ timerPending: continuationTimer !== null || loopTimer !== null,
339
+ silentMs: latchSilentMs,
340
+ thresholdMs: PENDING_LATCH_STUCK_MS,
341
+ })
342
+ ) {
343
+ consecutiveStalls++;
344
+ appendLedger(ctx.cwd, "pending_latch_stuck", { consecutiveStalls, silentMs: latchSilentMs });
345
+ noteActivity(); // re-arm the 3-minute cadence; never resets the stall streak
346
+ const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
347
+ if (escalateStallNow(ctx, stallEscalation)) return;
348
+ const msg = `Heartbeat: a queued continuation never started its turn for ${Math.round(latchSilentMs / 60_000)}m — pi's pending-message latch appears stuck (known post-compaction failure; stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}). If this repeats, restart pi.`;
349
+ ctx.ui.notify(msg, "warning");
350
+ notifyExternal(ctx, msg);
351
+ return;
352
+ }
292
353
  const fire = shouldHeartbeatRefire({
293
354
  supervising: isSupervising(),
294
355
  sessionIdle,
@@ -305,7 +366,9 @@ function heartbeatTick(): void {
305
366
  if (
306
367
  shouldWedgeAlert({
307
368
  supervising: isSupervising(),
308
- sessionBusy: !sessionIdle,
369
+ // v0.26.5: !idle, not !sessionIdle — an idle session with a stuck
370
+ // pending latch is the watchdog's job above, not a "hung command".
371
+ sessionBusy: !idle,
309
372
  silentMs: Date.now() - lastActivityAt,
310
373
  msSinceLastAlert: Date.now() - lastWedgeAlertAt,
311
374
  thresholdMs: wedgeMinutes * 60_000,
@@ -338,28 +401,7 @@ function heartbeatTick(): void {
338
401
  // this — they count turns, and a zombie runs none. Escalate to a loud,
339
402
  // actionable stop instead of spinning silently forever.
340
403
  const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
341
- if (shouldEscalateStall(consecutiveStalls, stallEscalation)) {
342
- consecutiveStalls = 0;
343
- appendLedger(ctx.cwd, "stall_escalated", { threshold: stallEscalation, kind: isLoopActive() ? "loop" : "goal" });
344
- if (isLoopActive()) {
345
- clearLoopTimer();
346
- state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${stallEscalation} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
347
- persistState(ctx);
348
- ctx.ui.notify(`Loop stopped: ${stallEscalation} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
349
- notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
350
- return;
351
- }
352
- if (state.goal && state.goal.status === "active") {
353
- updateGoal({
354
- status: "paused",
355
- pauseReason: `stalled: ${stallEscalation} continuation refires landed no turn`,
356
- pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
357
- }, ctx);
358
- ctx.ui.notify(`Goal paused: ${stallEscalation} refires produced no turn. Restart pi, then /goal resume.`, "warning");
359
- notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
360
- return;
361
- }
362
- }
404
+ if (escalateStallNow(ctx, stallEscalation)) return;
363
405
  ctx.ui.notify(`Heartbeat: supervisor active but session stalled — re-firing continuation (stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}).`, "info");
364
406
  if (isLoopActive()) {
365
407
  scheduleLoopTick(ctx);
@@ -595,8 +637,12 @@ function fireReviewer(
595
637
  } catch {
596
638
  /* archive md may not exist for manual review of a live goal */
597
639
  }
640
+ // v0.26.4 source curation: an APPROVED audit report is the executor's
641
+ // own completion claims — meta-text with zero finding signal (the
642
+ // 0.26.2/0.26.3 misfires both mined it). Disapprovals/errors carry the
643
+ // independent auditor's required-fixes — the real findings.
598
644
  const auditTexts = readAuditLog(ctx.cwd)
599
- .filter((e) => e.goalId === source.goalId)
645
+ .filter((e) => e.goalId === source.goalId && (e.verdict === "disapproved" || e.verdict === "error"))
600
646
  .map((e) => e.report);
601
647
  for (const t of auditTexts) sources.push({ name: "audit", text: t });
602
648
  let ledgerEntries: Array<{ type: string; at?: string; value?: any }> = [];
@@ -80,11 +80,13 @@ const CLASS_PATTERNS: Array<{ class: FindingClass; re: RegExp }> = [
80
80
  * the reviewer's own report/config vocabulary. Observed false positives
81
81
  * from the 0.26.2 completion: a test("…architectural…") name, the
82
82
  * INSTALL.md mode-matrix row, and ship-doc prose. */
83
- const SKIP_LINE = /^\s*(test|it|describe|assert|expect)\s*\(|^\s*(const|let|var|function|import|export|require)\b|\{\s*\.\.\.\s*\}|,\s*\.\.\.$|^\s*\|/;
84
- const REVIEWER_VOCAB = /architectural-class|bug-class|refactor-class|strategic-class|reviewer found|cascade step|\*\*Mode\*\*|problems\s*\/\s*(improvements|architectural)/i;
83
+ const SKIP_LINE = /^\s*(test|it|describe|assert|expect)\s*\(|^\s*(const|let|var|function|import|export|require)\b|\{\s*\.\.\.\s*\}|,\s*\.\.\.$|^\s*\||^\s*[{\[\]}]|^\s*['"]|^\s*ℹ/; // ℹ = test-runner/status noise ("ℹ todo 0" was enqueued as a /list item by the 0.26.2 reviewer)
84
+ const REVIEWER_VOCAB = /architectural-class|bug-class|refactor-class|strategic-class|reviewer found|cascade step|\*\*Mode\*\*|problems\s*\/\s*\(?(improvements|architectural)/i;
85
85
 
86
86
  export function classifyFindingText(line: string): FindingClass | undefined {
87
- const t = line.trim();
87
+ // Strip list markers here too (extractFindings already does, but direct
88
+ // callers/tests pass raw report lines like "- ℹ todo 0").
89
+ const t = line.trim().replace(/^[-*>\s\[\]x]+/, "");
88
90
  if (t.length < 8) return undefined;
89
91
  if (SKIP_LINE.test(t) || REVIEWER_VOCAB.test(t)) return undefined;
90
92
  for (const { class: cls, re } of CLASS_PATTERNS) {
@@ -93,12 +95,23 @@ export function classifyFindingText(line: string): FindingClass | undefined {
93
95
  return undefined;
94
96
  }
95
97
 
96
- /** Scan source texts line-by-line for finding-shaped content. */
98
+ /** v0.26.4: remove fenced code blocks and inline code spans. Quoted
99
+ * code is how completion summaries leak vocabulary into extraction —
100
+ * the 0.26.3 misfire matched a backticked reviewer.ts line containing
101
+ * four architectural patterns. */
102
+ export function stripCodeSpans(text: string): string {
103
+ return text
104
+ .replace(/```[\s\S]*?```/g, " ")
105
+ .replace(/`[^`\n]*`/g, " ");
106
+ }
107
+
108
+ /** Scan source texts line-by-line for finding-shaped content. Code
109
+ * spans are stripped first (v0.26.4) — findings live in prose. */
97
110
  export function extractFindings(sources: Array<{ name: string; text: string }>, max: number): Finding[] {
98
111
  const out: Finding[] = [];
99
112
  const seen = new Set<string>();
100
113
  for (const { name, text } of sources) {
101
- for (const line of text.split("\n")) {
114
+ for (const line of stripCodeSpans(text).split("\n")) {
102
115
  const cls = classifyFindingText(line);
103
116
  if (!cls) continue;
104
117
  const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "").slice(0, 200);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.26.3",
3
+ "version": "0.26.5",
4
4
  "description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",