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.
- package/extensions/goal-loop-auditor.ts +1 -0
- package/extensions/goal-loop-display.ts +17 -4
- package/extensions/goal-loop-forever.ts +46 -0
- package/extensions/goal-loop-repetition.ts +13 -1
- package/extensions/goal-settings.ts +1 -0
- package/extensions/loops/goal.ts +155 -16
- package/package.json +1 -1
- package/prompts/goal-loop-forever-metricless.md +2 -0
- package/prompts/goal-loop-forever.md +2 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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). */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4114
|
-
|
|
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.
|
|
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
|
|