pi-goal-list-loop-audit 0.33.0 → 0.33.2

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.
@@ -338,6 +338,7 @@ export async function runGoalCompletionAuditor(args: {
338
338
  } finally {
339
339
  clearInterval(stallTimer);
340
340
  unsub();
341
+ args.signal?.removeEventListener("abort", abort); // v0.33.1: don't retain the session via the signal
341
342
  // v0.32.0: dispose the auditor session — each complete_goal leaked one
342
343
  // session's subscriptions/stream resources for the parent's lifetime.
343
344
  (session as any).dispose?.();
@@ -48,6 +48,12 @@ export function truncate(s: string, max: number): string {
48
48
  return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
49
49
  }
50
50
 
51
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
52
+ /** v0.33.1: painted strings measure by their VISIBLE width. */
53
+ function visibleLen(s: string): number {
54
+ return s.replace(ANSI_RE, "").length;
55
+ }
56
+
51
57
  /** v0.33.0: 5-cell meter with a rounding guard (command-code's rule — never
52
58
  * shows empty or full unless the value truly is 0 or 1). */
53
59
  export function meter(frac: number, cells = 5): string {
@@ -296,7 +302,6 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
296
302
  : g.status === "auditing"
297
303
  ? paint(theme, "accent", "⟡")
298
304
  : paint(theme, "success", "●");
299
- const headBase = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), budgetFor(width, 3, 48))}`;
300
305
  // v0.24.7: a list item is named as such and points at /list — before,
301
306
  // the widget called it "active" and hinted "/goal status", reading as if
302
307
  // queue work were a standalone goal.
@@ -325,7 +330,12 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
325
330
  // ∞/↓/↑ loop, ⟡ auditing, ⏸ paused) + the type-named footer verbs.
326
331
  // Token segment only when a budget is set (v0.22.0): the guard is opt-in,
327
332
  // and "0/0 tok" carried no information when off.
328
- const head = `${headBase} ${paint(theme, "dim", "·")} ${headSegs.join(` ${paint(theme, "dim", "·")} `)}`;
333
+ // v0.33.1: the head must FIT the terminal the segments are fixed, so
334
+ // the objective absorbs whatever room is left (was: objective budgeted
335
+ // alone, segments appended unbudgeted → 140-col heads at width 100).
336
+ const segsText = headSegs.join(` ${paint(theme, "dim", "·")} `);
337
+ const objBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText)) : 48;
338
+ const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
329
339
  const lines = [head];
330
340
  if (g.status === "auditing") {
331
341
  lines.push(`├─ auditor: ${audit?.label ?? "running"}${audit?.currentTool ? ` · ${truncate(audit.currentTool, 30)}` : ""}`);
@@ -427,16 +437,19 @@ function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: numb
427
437
  segs.push(fmtElapsed(now - Date.parse(l.startedAt)));
428
438
  if (l.measureCmd) {
429
439
  segs.push(`best ${paint(theme, "success", `${l.bestValue ?? "n/a"}`)}`);
440
+ segs.push(`last ${l.lastValue ?? "n/a"}`); // v0.33.1: a plateauing loop's current reading stays visible
430
441
  const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
431
442
  segs.push(l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText);
432
443
  }
433
- const lines = [`${icon} ${truncate(l.target, budgetFor(width, 3, 44))} ${paint(theme, "dim", "·")} ${segs.join(` ${paint(theme, "dim", "·")} `)}${stallNote}`];
444
+ const segsText = segs.join(` ${paint(theme, "dim", "·")} `);
445
+ const targetBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText) - visibleLen(stallNote)) : 44;
446
+ const lines = [`${icon} ${truncate(l.target, targetBudget)} ${paint(theme, "dim", "·")} ${segsText}${stallNote}`];
434
447
  const act = extras?.recent?.[extras.recent.length - 1];
435
448
  if (act) {
436
449
  lines.push(`├─ ${paint(theme, act.ok ? "success" : "error", act.ok ? "✓" : "✗")} ${act.name}${act.arg ? ` ${paint(theme, "dim", truncate(act.arg, 24))}` : ""}${act.ms > 0 ? ` ${paint(theme, "dim", `(${fmtElapsed(act.ms)})`)}` : ""}`);
437
450
  }
438
451
  const footer = !l.measureCmd
439
- ? "metricless (no plateau) · /loop stop · /loop polish"
452
+ ? "metricless (no plateau) · /loop stop · /loop refine" // v0.33.2: the verb exists now
440
453
  : `${l.kind === "audit" ? "metric: closed findings" : truncate(l.measureCmd, budgetFor(width, 3, 30))} · /loop stop`;
441
454
  lines.push(`└─ ${paint(theme, "dim", footer)}`);
442
455
  if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
@@ -10,6 +10,7 @@
10
10
  * self-reports progress.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto";
13
14
  import { existsSync, readFileSync, statSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
 
@@ -110,6 +111,20 @@ export interface LoopState {
110
111
  /** v0.25.1: /loop start toolsamerepeat=N — legacy same-tool-same-result
111
112
  * check window. 0 disables it (multi-signal detector only). */
112
113
  toolSameRepeat?: number;
114
+ /** v0.33.2: respec loops carry their spec file — drift detection
115
+ * (specHash compared per tick), checkbox progress (specChecked →
116
+ * spec_item_progress events), and the refine tool's specText write path. */
117
+ specFile?: string;
118
+ specHash?: string;
119
+ specChecked?: number;
120
+ /** v0.33.2: hypothesis feedback loop — the last turn's HYPOTHESIS line
121
+ * plus the verdict computed against the metric movement, injected into
122
+ * the next iteration's prompt. */
123
+ lastHypothesis?: string;
124
+ hypothesisFeedback?: string;
125
+ /** v0.33.2: /loop refine <text> — the operator's respec suggestion rides
126
+ * the next iteration's prompt; the agent proposes via propose_loop_refine. */
127
+ refineHint?: string;
113
128
  /** v0.25.1: per-iteration progress-signal accumulators for the
114
129
  * multi-signal stuck gate. fileWrites bumps on write/edit tool results;
115
130
  * iterationStartHead/At snapshot when the iteration BEGAN so the tick can
@@ -464,6 +479,37 @@ export function countOpenAuditFindings(cwd: string): number {
464
479
  }
465
480
  }
466
481
 
482
+ /** v0.33.2: the first OPEN finding's text — the reprieve note names what
483
+ * to close, not just how many remain. */
484
+ export function topOpenAuditFinding(cwd: string): string | null {
485
+ try {
486
+ const p = join(cwd, AUDIT_FINDINGS_REL);
487
+ if (!existsSync(p)) return null;
488
+ const line = readFileSync(p, "utf-8").split("\n").find((l) => /^- \[ \]/.test(l));
489
+ return line ? line.replace(/^- \[ \]\s*/, "").trim().slice(0, 120) : null;
490
+ } catch {
491
+ return null;
492
+ }
493
+ }
494
+
495
+ /** v0.33.2: spec drift detection — short sha256 of the spec file. */
496
+ export function specFileHash(p: string): string | null {
497
+ try {
498
+ return createHash("sha256").update(readFileSync(p, "utf-8")).digest("hex").slice(0, 16);
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+
504
+ /** v0.33.2: checked checkbox count in a spec file (spec_item_progress). */
505
+ export function countCheckedSpecItems(p: string): number | null {
506
+ try {
507
+ return readFileSync(p, "utf-8").split("\n").filter((l) => /^- \[x\]/i.test(l)).length;
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+
467
513
  // ---- /goal audit-project (v0.29.8) ----
468
514
 
469
515
  /**
@@ -190,7 +190,19 @@ export function forwardTransitionPaired(input: LoopStuckInput): boolean {
190
190
  * (new detector only), undefined = REPETITION.toolResultRepeat.
191
191
  */
192
192
  export function isActuallyStuck(input: LoopStuckInput, toolSameRepeat?: number): string | undefined {
193
- if ((input.fileWriteCount ?? 0) > 0) return undefined;
193
+ if ((input.fileWriteCount ?? 0) > 0) {
194
+ // v0.33.2: write-exemption abuse — endless cosmetic edits with a
195
+ // near-identical reply are churn, not progress (the metricless
196
+ // doorknob leak). The stuck ladder's first rung is a prompt note;
197
+ // any genuinely different iteration resets it.
198
+ if (input.previousText && input.assistantText && normalizeForPrint(input.assistantText).length > REPETITION.minSimilarLength) {
199
+ const sim = trigramSimilarity(input.assistantText, input.previousText);
200
+ if (sim >= REPETITION.similarityThreshold) {
201
+ return `cosmetic churn: wrote files but the reply is ~${Math.round(sim * 100)}% identical to the previous iteration`;
202
+ }
203
+ }
204
+ return undefined;
205
+ }
194
206
  if ((input.gitCommitCount ?? 0) > 0) return undefined;
195
207
  if ((input.specItemProgressCount ?? 0) > 0) return undefined;
196
208
  if (forwardTransitionPaired(input)) return undefined;
@@ -211,6 +211,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
211
211
  "stallSimilarityThreshold",
212
212
  "postaudit",
213
213
  "toolOverrides",
214
+ "reviewer", // v0.33.1: legacy alias — menu saves can write it; provenance must know it exists
214
215
  ];
215
216
 
216
217
  /** Where each effective setting comes from (for the /glla display). */
@@ -169,6 +169,9 @@ import {
169
169
  LOOP_DEFAULTS,
170
170
  resolveSpecFiles,
171
171
  respecTarget,
172
+ topOpenAuditFinding,
173
+ specFileHash,
174
+ countCheckedSpecItems,
172
175
  auditMeasureCmd,
173
176
  auditTarget,
174
177
  AUDIT_PLATEAU_MAX_REPRIEVES,
@@ -639,13 +642,17 @@ function summarizeToolArg(name: string, input: any): string | undefined {
639
642
  if (!input || typeof input !== "object") return undefined;
640
643
  const v = input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.query ?? input.url ?? input.title;
641
644
  if (typeof v !== "string" || v.length === 0) return undefined;
642
- const base = name === "bash" ? v : v.split("/").pop() || v;
645
+ // v0.33.1: strip control chars BEFORE truncating a raw \n breaks the
646
+ // widget card into un-prefixed lines (footer spoof), a raw ESC corrupts
647
+ // the TUI. The objective path was already whitespace-collapsed; this
648
+ // path was the gap.
649
+ const base = (name === "bash" ? v : v.split("/").pop() || v).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
643
650
  return base.length <= 24 ? base : base.slice(0, 23) + "…";
644
651
  }
645
652
  function noteToolCall(event: any): void {
646
653
  const name = String(event?.toolName ?? "?");
647
654
  const id = String(event?.toolCallId ?? event?.id ?? `anon-${Date.now()}`);
648
- if (inFlightToolCalls.size > 20) inFlightToolCalls.delete(inFlightToolCalls.keys().next().value!);
655
+ if (inFlightToolCalls.size >= 20) inFlightToolCalls.delete(inFlightToolCalls.keys().next().value!); // v0.33.1: evict BEFORE the 21st
649
656
  inFlightToolCalls.set(id, { name, arg: summarizeToolArg(name, event?.input ?? event?.args), at: Date.now() });
650
657
  }
651
658
  function noteToolResult(event: any): void {
@@ -865,6 +872,14 @@ function heartbeatTick(): void {
865
872
  if (!absorbStaleIfSuperseded(ctx)) goStaleTerminal(ctx, "heartbeat probe");
866
873
  return;
867
874
  }
875
+ // v0.33.1: nothing supervised → the compact debt/resync belong to a dead
876
+ // goal/loop. Discharge here so a later goal can't inherit a bogus RESYNC
877
+ // block or a spurious forced refire (the old in-guard `else` was
878
+ // unreachable — isSupervising() ≡ isLoopActive() || isActionableGoal()).
879
+ if (!isSupervising() && (postCompactResumeOwed || postCompactResyncPending)) {
880
+ postCompactResumeOwed = false;
881
+ postCompactResyncPending = false;
882
+ }
868
883
  // v0.32.1: post-compaction resume debt — retry on every heartbeat tick
869
884
  // past grace until a turn actually starts. Fixed-offset settles alone
870
885
  // can both lose (pi busy at 2s AND at grace+2s = a dangling chain).
@@ -1104,7 +1119,10 @@ function sendContinuation(goalId: string): void {
1104
1119
  }
1105
1120
  if (!extensionApi || extensionApiStale) return;
1106
1121
  try {
1107
- const resync = postCompactResyncPending ? buildPostCompactResync() : "";
1122
+ let resync = "";
1123
+ // v0.33.1: a builder throw (corrupt restored state) must not masquerade
1124
+ // as a transport failure — send without the block instead.
1125
+ if (postCompactResyncPending) { try { resync = buildPostCompactResync(); } catch { resync = ""; } }
1108
1126
  extensionApi.sendMessage({
1109
1127
  customType: GOAL_EVENT_ENTRY,
1110
1128
  content: resync + continuationPrompt(state.goal!),
@@ -1279,6 +1297,14 @@ function notifyPersistenceState(ctx: ExtensionContext): void {
1279
1297
  }
1280
1298
 
1281
1299
  function setGoal(goal: Goal, ctx: ExtensionContext, via = "user"): void {
1300
+ // v0.33.1: per-goal module state resets at activation — a new goal must
1301
+ // not inherit the previous goal's compact debt/resync, quota streak,
1302
+ // token-message dedupe set, or widget action feed.
1303
+ postCompactResumeOwed = false;
1304
+ postCompactResyncPending = false;
1305
+ quotaRetryStreak = 0;
1306
+ countedTokenMessages.clear();
1307
+ recentActions.length = 0;
1282
1308
  // v0.28.14: never silently orphan a live goal — a paused/active goal
1283
1309
  // being replaced is archived honestly first (the old behavior left it in
1284
1310
  // goals/ but untracked: "older goals lying around leading to confusion").
@@ -1397,6 +1423,8 @@ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
1397
1423
  }
1398
1424
 
1399
1425
  function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?: string): void {
1426
+ postCompactResumeOwed = false; // v0.33.1: the dead goal's compact debt/resync dies with it
1427
+ postCompactResyncPending = false;
1400
1428
  if (!state.goal) return;
1401
1429
  const goal = state.goal;
1402
1430
  ensureDirs(ctx.cwd);
@@ -2589,7 +2617,7 @@ async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: bool
2589
2617
  }
2590
2618
  }
2591
2619
 
2592
- function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string, interventionNote = "", variantNote = ""): string {
2620
+ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string, interventionNote = "", variantNote = "", hypothesisNote = "", refineHintNote = ""): string {
2593
2621
  // v0.23.0: metricless loops get their own prompt — no metric section,
2594
2622
  // anti-doorknob rules instead of anti-gaming rules.
2595
2623
  const metricless = !loop.measureCmd;
@@ -2616,7 +2644,9 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
2616
2644
  .replace(/\$\{STRATEGY_NOTE\}/g, strategyNote)
2617
2645
  .replace(/\$\{BOUNDS_NOTE\}/g, boundsNote)
2618
2646
  .replace(/\$\{INTERVENTION_NOTE\}/g, interventionNote)
2619
- .replace(/\$\{VARIANT_NOTE\}/g, variantNote);
2647
+ .replace(/\$\{VARIANT_NOTE\}/g, variantNote)
2648
+ .replace(/\$\{HYPOTHESIS_NOTE\}/g, hypothesisNote)
2649
+ .replace(/\$\{REFINE_HINT\}/g, refineHintNote);
2620
2650
  }
2621
2651
 
2622
2652
  function scheduleLoopTick(ctx: ExtensionContext): void {
@@ -2638,7 +2668,13 @@ function sendLoopTurn(): void {
2638
2668
  if (!isLoopActive() || !extensionApi) return;
2639
2669
  const ctx = freshCtx();
2640
2670
  if (!ctx || !ctx.isIdle() || ctx.hasPendingMessages()) {
2641
- if (ctx) accountSendRearm(ctx, "loop");
2671
+ if (!ctx) {
2672
+ // v0.33.1: mirror sendContinuation — probe the handle (terminal exit)
2673
+ // and advance the streak so the cadence backs off instead of spinning
2674
+ // a flat 50ms below every watchdog.
2675
+ if (probeExtensionApiStale()) return;
2676
+ loopRearmStreak++;
2677
+ } else accountSendRearm(ctx, "loop");
2642
2678
  loopTimer = setTimeout(() => sendLoopTurn(), sendRearmDelayMs(loopRearmStreak)); // v0.28.29: backing-off cadence
2643
2679
  loopTimer.unref?.();
2644
2680
  return;
@@ -2662,7 +2698,13 @@ function sendLoopTurn(): void {
2662
2698
  // Strategy rotation (from pi-loop-mode's one good idea): one stall before
2663
2699
  // the plateau window closes, stop polishing and change approach entirely.
2664
2700
  const strategyNote = loop.stallCount >= loop.plateauWindow - 1 && loop.stallCount > 0
2665
- ? "**You are one stall from a plateau stop. Small tweaks are not working — try a FUNDAMENTALLY different approach: different file, different technique, or revert and rethink the angle of attack.**"
2701
+ ? "**You are one stall from a plateau stop. Small tweaks are not working — try a FUNDAMENTALLY different approach: different file, different technique, or revert and rethink the angle of attack.**" +
2702
+ // v0.33.2: a metric flat AT BEST may mean the spec stopped capturing
2703
+ // "better" — the loop holds the evidence, so it says so (was: the
2704
+ // prompt said "call propose_loop_refine" but the loop never suggested it).
2705
+ (loop.lastValue !== null && loop.lastValue === loop.bestValue
2706
+ ? " **The metric has been flat at best — if the spec no longer captures 'better' (saturated metric, drifted target), call propose_loop_refine.**"
2707
+ : "")
2666
2708
  : "";
2667
2709
  // v0.15.0: arbitrary bounds (never "completion") — surface what's armed.
2668
2710
  // v0.23.0: for metricless loops the bounds are the ONLY stop (no
@@ -2693,11 +2735,19 @@ function sendLoopTurn(): void {
2693
2735
  // v0.24.0: identical prompts invite identical answers — rotate the base
2694
2736
  // instruction (metricless loops; metric loops already vary via values).
2695
2737
  const variantNote = metricless ? continueVariant(loop.iteration) : "";
2738
+ // v0.33.2: one-shot prompt payloads, consumed on use.
2739
+ const hypothesisNote = loop.hypothesisFeedback ?? "";
2740
+ if (hypothesisNote) loop.hypothesisFeedback = undefined;
2741
+ const refineHintNote = loop.refineHint
2742
+ ? `**The operator suggests refining the spec:** ${loop.refineHint} — if the current spec no longer captures "better", call propose_loop_refine (target and/or measureCmd${loop.specFile ? " and/or specText/specAppend" : ""}); if it still stands, say why in one line and keep working.`
2743
+ : "";
2744
+ if (refineHintNote) loop.refineHint = undefined;
2696
2745
  try {
2697
- const loopResync = postCompactResyncPending ? buildPostCompactResync() : "";
2746
+ let loopResync = "";
2747
+ if (postCompactResyncPending) { try { loopResync = buildPostCompactResync(); } catch { loopResync = ""; } } // v0.33.1
2698
2748
  extensionApi.sendMessage({
2699
2749
  customType: GOAL_EVENT_ENTRY,
2700
- content: loopResync + loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
2750
+ content: loopResync + loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote, hypothesisNote, refineHintNote),
2701
2751
  display: false,
2702
2752
  }, { triggerTurn: true, deliverAs: "followUp" });
2703
2753
  if (loopResync) postCompactResyncPending = false; // consumed only by a landed send
@@ -2768,6 +2818,23 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
2768
2818
  }
2769
2819
  } catch { /* no ledger yet */ }
2770
2820
  }
2821
+ // v0.33.2: respec spec drift + checkbox progress — hash compared per
2822
+ // tick (external edits ledger spec_updated); newly checked boxes emit
2823
+ // the spec_item_progress signal the stuck gate already consumes (it was
2824
+ // consumed-but-never-emitted until now).
2825
+ if (loop.specFile) {
2826
+ const hash = specFileHash(loop.specFile);
2827
+ if (hash && loop.specHash && loop.specHash !== hash) {
2828
+ appendLedger(ctx.cwd, "spec_updated", { via: "external", iteration: loop.iteration });
2829
+ ctx.ui.notify("Spec file changed mid-loop — drift ledgered (spec_updated).", "info");
2830
+ }
2831
+ if (hash) loop.specHash = hash;
2832
+ const checked = countCheckedSpecItems(loop.specFile);
2833
+ if (checked !== null && loop.specChecked !== undefined && checked > loop.specChecked) {
2834
+ appendLedger(ctx.cwd, "spec_item_progress", { iteration: loop.iteration, newlyChecked: checked - loop.specChecked, totalChecked: checked });
2835
+ }
2836
+ if (checked !== null) loop.specChecked = checked;
2837
+ }
2771
2838
  const iterSignals = {
2772
2839
  fileWrites: loop.iterMetrics?.fileWrites ?? 0,
2773
2840
  gitCommits,
@@ -2807,6 +2874,26 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
2807
2874
  loop.lastStuckReason = undefined;
2808
2875
  }
2809
2876
  let outcome: LoopTickOutcome = metricless ? applyMetriclessTick(loop, nowIso()) : applyMeasurement(loop, value, nowIso());
2877
+ // v0.33.2: close the hypothesis feedback loop — the prediction went into
2878
+ // the ledger; now the VERDICT rides the next iteration's prompt.
2879
+ if (loop.lastHypothesis) {
2880
+ const h = loop.history;
2881
+ const cur = h.length >= 1 ? h[h.length - 1]!.value : null;
2882
+ const prev = h.length >= 2 ? h[h.length - 2]!.value : null;
2883
+ if (metricless || cur === null) {
2884
+ loop.hypothesisFeedback = `Last iteration you predicted: "${loop.lastHypothesis}". ${metricless ? "Metricless loop — no number to verify it against; say honestly whether the prediction landed." : "The measure printed no number — the prediction is unverifiable."}`;
2885
+ } else {
2886
+ const moved = prev === null
2887
+ ? `first measurement ${cur}`
2888
+ : cur === prev
2889
+ ? `flat at ${cur}`
2890
+ : loop.direction === "min"
2891
+ ? (cur < prev ? `improved ${prev} → ${cur}` : `regressed ${prev} → ${cur}`)
2892
+ : (cur > prev ? `improved ${prev} → ${cur}` : `regressed ${prev} → ${cur}`);
2893
+ loop.hypothesisFeedback = `Last iteration you predicted: "${loop.lastHypothesis}". Result: metric ${moved} (best ${loop.bestValue}).`;
2894
+ }
2895
+ }
2896
+ loop.lastHypothesis = hypothesis;
2810
2897
  persistState(ctx);
2811
2898
  appendLedger(ctx.cwd, "loop_measured", {
2812
2899
  iteration: loop.iteration,
@@ -2860,7 +2947,8 @@ async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
2860
2947
  loop.stopReason = undefined;
2861
2948
  loop.stallCount = 0;
2862
2949
  loop.auditPlateauReprieves = reprieves;
2863
- loop.auditReprieveNote = `PLATEAU REPRIEVE (${reprieves}/${AUDIT_PLATEAU_MAX_REPRIEVES}): ${open} finding(s) still OPEN in ${AUDIT_FINDINGS_REL} — the plateau stop does not fire while the well isn't dry. Stop hunting and stop narrating: pick the smallest OPEN finding and CLOSE it this iteration (fix commit + checked box). ${AUDIT_PLATEAU_MAX_REPRIEVES - reprieves} reprieve(s) remain.`;
2950
+ const topFinding = topOpenAuditFinding(ctx.cwd); // v0.33.2: name what to close, not just the count
2951
+ loop.auditReprieveNote = `PLATEAU REPRIEVE (${reprieves}/${AUDIT_PLATEAU_MAX_REPRIEVES}): ${open} finding(s) still OPEN in ${AUDIT_FINDINGS_REL} — the plateau stop does not fire while the well isn't dry. Stop hunting and stop narrating: pick the smallest OPEN finding and CLOSE it this iteration (fix commit + checked box).${topFinding ? ` Top open: ${topFinding}` : ""} ${AUDIT_PLATEAU_MAX_REPRIEVES - reprieves} reprieve(s) remain.`;
2864
2952
  persistState(ctx);
2865
2953
  appendLedger(ctx.cwd, "audit_plateau_reprieve", { open, reprieves, best: loop.bestValue });
2866
2954
  ctx.ui.notify(`Audit loop plateau reprieve (${reprieves}/${AUDIT_PLATEAU_MAX_REPRIEVES}): ${open} open findings — the well isn't dry, continuing.`, "info");
@@ -2919,6 +3007,9 @@ interface LoopConfig {
2919
3007
  deferBaseline?: boolean;
2920
3008
  /** v0.29.10: audit loops get audit-flavoured regression wording. */
2921
3009
  kind?: "audit";
3010
+ /** v0.33.2: respec loops carry their spec file (drift detection,
3011
+ * checkbox progress, refine specText writes). */
3012
+ specFile?: string;
2922
3013
  }
2923
3014
 
2924
3015
  /** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
@@ -2985,6 +3076,9 @@ async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Prom
2985
3076
  branchName,
2986
3077
  originalBranch,
2987
3078
  toolSameRepeat: cfg.toolSameRepeat,
3079
+ specFile: cfg.specFile,
3080
+ specHash: cfg.specFile ? specFileHash(cfg.specFile) ?? undefined : undefined,
3081
+ specChecked: cfg.specFile ? countCheckedSpecItems(cfg.specFile) ?? undefined : undefined,
2988
3082
  iterMetrics: { fileWrites: 0, iterationStartAt: nowIso() },
2989
3083
  },
2990
3084
  };
@@ -3103,6 +3197,28 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
3103
3197
 
3104
3198
  // v0.28.14: /loop cancel is a first-class alias — users reached for
3105
3199
  // /goal cancel to kill loops because "cancel" is the verb they know.
3200
+ if (sub === "refine" || sub === "polish") {
3201
+ // v0.33.2: the operator's respec verb. The refine flow stays
3202
+ // agent-proposed + user-confirmed (propose_loop_refine) — this command
3203
+ // queues the operator's suggestion into the next iteration's prompt.
3204
+ // ("polish" accepted as an alias: the widget footer advertised it
3205
+ // before the command existed — now it does.)
3206
+ if (!isLoopActive()) {
3207
+ ctx.ui.notify("No active loop to refine — /loop start first.", "warning");
3208
+ return;
3209
+ }
3210
+ const hint = rest.trim();
3211
+ if (!hint) {
3212
+ ctx.ui.notify("Usage: /loop refine <what the spec should capture better> — the suggestion rides the next iteration's prompt; the agent proposes via propose_loop_refine and you confirm.", "info");
3213
+ return;
3214
+ }
3215
+ state.loop!.refineHint = hint.slice(0, 300);
3216
+ persistState(ctx);
3217
+ appendLedger(ctx.cwd, "loop_refine_hint", { iteration: state.loop!.iteration, hint: state.loop!.refineHint });
3218
+ ctx.ui.notify("Refine hint queued — it rides the next iteration's prompt.", "info");
3219
+ return;
3220
+ }
3221
+
3106
3222
  if (sub === "stop" || sub === "cancel") {
3107
3223
  if (!state.loop) {
3108
3224
  ctx.ui.notify("No loop to stop.", "info");
@@ -3241,6 +3357,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
3241
3357
  maxIterations: 0,
3242
3358
  branch: false,
3243
3359
  force: false,
3360
+ specFile: specPath, // v0.33.2
3244
3361
  });
3245
3362
  return;
3246
3363
  }
@@ -4092,12 +4209,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4092
4209
  parameters: Type.Object({
4093
4210
  target: Type.Optional(Type.String({ description: "The sharpened target text (omit to keep the current target)" })),
4094
4211
  measureCmd: Type.Optional(Type.String({ description: "The new measure command printing ONE number (omit to keep the current metric)" })),
4212
+ specText: Type.Optional(Type.String({ description: "v0.33.2: full replacement text for the loop's spec file (respec loops only) — the orchestrator owns the write on user confirm" })),
4213
+ specAppend: Type.Optional(Type.String({ description: "v0.33.2: lines to append to the loop's spec file (respec loops only)" })),
4095
4214
  rationale: Type.String({ description: "Why the current spec no longer captures 'better' — shown to the user in the Confirm dialog" }),
4096
4215
  }),
4097
4216
  async execute(_id, params, _signal, _onUpdate, execCtx) {
4098
4217
  const foreign4 = foreignToolGuard(execCtx);
4099
4218
  if (foreign4) return { content: [{ type: "text", text: foreign4 }], details: {} };
4100
- const p = params as { target?: string; measureCmd?: string; rationale: string };
4219
+ const p = params as { target?: string; measureCmd?: string; specText?: string; specAppend?: string; rationale: string };
4101
4220
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
4102
4221
  const loop = state.loop;
4103
4222
  if (!loop?.active) {
@@ -4110,8 +4229,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4110
4229
  if (!loop.measureCmd && p.measureCmd?.trim()) {
4111
4230
  return { content: [{ type: "text", text: "This loop is metricless — refining it into a measured loop isn't supported. /loop stop, then /loop start with a metric." }], details: {} };
4112
4231
  }
4113
- if (newTarget === loop.target && newMeasure === loop.measureCmd) {
4114
- return { content: [{ type: "text", text: "Refinement proposed no changes — provide a new target, a new measureCmd, or both." }], details: {} };
4232
+ const specChange = (p.specText?.trim() || p.specAppend?.trim()) ? true : false;
4233
+ if (specChange && !loop.specFile) {
4234
+ return { content: [{ type: "text", text: "This loop has no spec file (specText/specAppend apply to /loop respec loops). Refine the target instead." }], details: {} };
4235
+ }
4236
+ if (newTarget === loop.target && newMeasure === loop.measureCmd && !specChange) {
4237
+ return { content: [{ type: "text", text: "Refinement proposed no changes — provide a new target, a new measureCmd, a spec change, or any combination." }], details: {} };
4115
4238
  }
4116
4239
  // Measure change → orchestrator test-runs the new command first.
4117
4240
  let newBaseline: number | null = null;
@@ -4142,7 +4265,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4142
4265
  confirmed = (await confirmDraft(
4143
4266
  liveCtx,
4144
4267
  "Confirm loop spec refinement",
4145
- `Rationale: ${p.rationale}\n\nTarget:\n old: ${loop.target.slice(0, 120)}\n new: ${newTarget.slice(0, 120)}\n\nMeasure:\n old: ${loop.measureCmd}\n new: ${newMeasure}${newMeasure !== loop.measureCmd ? `\n test-run: ${testOutput.slice(0, 120)} → ${newBaseline}` : ""}\n\nThe loop keeps running against the refined spec (iteration ${loop.iteration} so far). Apply?`,
4268
+ `Rationale: ${p.rationale}\n\nTarget:\n old: ${loop.target.slice(0, 120)}\n new: ${newTarget.slice(0, 120)}\n\nMeasure:\n old: ${loop.measureCmd}\n new: ${newMeasure}${newMeasure !== loop.measureCmd ? `\n test-run: ${testOutput.slice(0, 120)} → ${newBaseline}` : ""}${specChange ? `\n\nSpec file (${loop.specFile}):\n ${p.specText?.trim() ? `REPLACE with ${p.specText!.trim().length} chars` : ""}${p.specText?.trim() && p.specAppend?.trim() ? " + " : ""}${p.specAppend?.trim() ? `APPEND: ${p.specAppend!.trim().slice(0, 120)}` : ""}` : ""}\n\nThe loop keeps running against the refined spec (iteration ${loop.iteration} so far). Apply?`,
4146
4269
  )) === "yes";
4147
4270
  } catch {
4148
4271
  confirmed = false;
@@ -4159,9 +4282,22 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4159
4282
  oldMeasureCmd: loop.measureCmd ?? "",
4160
4283
  newMeasureCmd: newMeasure,
4161
4284
  }, newBaseline);
4285
+ // v0.33.2: the orchestrator owns the spec write (honesty stays
4286
+ // inspectable — the agent never edits the spec it's judged against
4287
+ // outside a confirmed refine).
4288
+ if (specChange && loop.specFile) {
4289
+ try {
4290
+ if (p.specText?.trim()) fs.writeFileSync(loop.specFile, p.specText.trim() + "\n");
4291
+ if (p.specAppend?.trim()) fs.appendFileSync(loop.specFile, (p.specText?.trim() ? "" : "\n") + p.specAppend.trim() + "\n");
4292
+ loop.specHash = specFileHash(loop.specFile) ?? undefined;
4293
+ appendLedger(liveCtx.cwd, "spec_updated", { via: "refine", iteration: loop.iteration, replaced: Boolean(p.specText?.trim()), appended: Boolean(p.specAppend?.trim()) });
4294
+ } catch (e) {
4295
+ return { content: [{ type: "text", text: `Spec file write failed: ${String(e).slice(0, 200)}. The target/measure refinement was applied; re-propose the spec change.` }], details: {} };
4296
+ }
4297
+ }
4162
4298
  persistState(liveCtx);
4163
- appendLedger(liveCtx.cwd, "loop_refined", { iteration: loop.iteration, newTarget, newMeasureCmd: newMeasure, newBaseline });
4164
- liveCtx.ui.notify(`Loop spec refined at iteration ${loop.iteration}.${newBaseline !== null ? ` New baseline: ${newBaseline}.` : ""}`, "info");
4299
+ appendLedger(liveCtx.cwd, "loop_refined", { iteration: loop.iteration, newTarget, newMeasureCmd: newMeasure, newBaseline, specChanged: specChange || undefined });
4300
+ liveCtx.ui.notify(`Loop spec refined at iteration ${loop.iteration}.${newBaseline !== null ? ` New baseline: ${newBaseline}.` : ""}${specChange ? " Spec file updated." : ""}`, "info");
4165
4301
  return { content: [{ type: "text", text: "Refinement confirmed and applied. Continue improving against the NEW spec — one small change per turn." }], details: {} };
4166
4302
  },
4167
4303
  }));
@@ -5920,6 +6056,9 @@ export default function (pi: ExtensionAPI): void {
5920
6056
  writeOwnerFile(ctx.cwd);
5921
6057
  sessionReplacementUntil = 0;
5922
6058
  zombieStoodDown = false;
6059
+ staleTerminalDone = false; // v0.33.1: a rebound session must be able to go terminal AGAIN (was: one-shot for the process lifetime)
6060
+ postCompactResumeOwed = false; // v0.33.1: a compact from a previous session must not resync THIS one
6061
+ postCompactResyncPending = false;
5923
6062
  const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
5924
6063
  appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
5925
6064
  if (extensionApiStale) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 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 \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",
@@ -31,6 +31,8 @@ fails, retry with a different approach — just continue, don't stall the loop
31
31
  asking permission. You remain the single writer: apply the edit yourself.
32
32
 
33
33
  ${INTERVENTION_NOTE}
34
+ ${HYPOTHESIS_NOTE}
35
+ ${REFINE_HINT}
34
36
  ${REGRESSION_NOTE}
35
37
  ${STRATEGY_NOTE}
36
38
 
@@ -35,6 +35,8 @@ fails, retry with a different approach — just continue, don't stall the loop
35
35
  asking permission. You remain the single writer: apply the edit yourself.
36
36
 
37
37
  ${INTERVENTION_NOTE}
38
+ ${HYPOTHESIS_NOTE}
39
+ ${REFINE_HINT}
38
40
  ${REGRESSION_NOTE}
39
41
  ${STRATEGY_NOTE}
40
42