From 9d7414b54d1c45829ed4573c3fc1c5ee2c6ce0a3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 20 Jul 2026 00:25:44 +0200 Subject: [PATCH 1/3] fix(loop): WS-B must not checkpoint a HOLLOW near-green state (completion-class errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced in THREE live builds (build3/4/5): the model reaches ~1 error where the remaining error clears ONLY by adding code — an unused i18n key it declared (i18n-locale-keys-used), an unreachable feature (reachability), or the hollow-app judge. WS-B locked that hollow state as the near-green best; when the model then made the completeness-guard-DEMANDED CRUD-UI edit (form + buttons + toasts to wire the keys), the count transiently spiked (1→22/27/37) and count-only shouldRollback REVERTED the legitimate completion work back to the list-only page. The model re-did it → sprayed → reverted again (bounded to 3, then confused: 'I changed these but they got reverted'). Fix: classify completion-class errors (isCompletionClass / allCompletionClass, pure + unit-tested) and, in nearGreenCheckpointStep, when EVERY remaining error is completion-class, CLEAR any checkpoint and don't capture — the model must add code, so a transient spike from that is progress, not a spray. Clearing (not just skipping) also drops an earlier compile-state checkpoint so the spray isn't reverted to that either. WS-B resumes normally once errors are fixable-in-place again (a mixed state with any fixable error still checkpoints). settleGate integration test + pure-fn tests; existing WS-B loop/rollback behavior unchanged (non-completion errors). --- .../core/src/loop/near-green-checkpoint.ts | 29 ++++++++ packages/core/src/loop/turn.ts | 14 ++++ .../tests/near-green-checkpoint-loop.test.ts | 73 +++++++++++++++++++ .../core/tests/near-green-checkpoint.test.ts | 33 +++++++++ 4 files changed, 149 insertions(+) diff --git a/packages/core/src/loop/near-green-checkpoint.ts b/packages/core/src/loop/near-green-checkpoint.ts index a79129aa..b9ce67fb 100644 --- a/packages/core/src/loop/near-green-checkpoint.ts +++ b/packages/core/src/loop/near-green-checkpoint.ts @@ -51,6 +51,35 @@ export interface INearGreenCheckpoint { readonly depFiles: ReadonlyMap; } +/** Gate errors that clear ONLY by ADDING code — wiring the i18n keys the feature declared + * (`i18n-locale-keys-used`), making the feature reachable (`reachability`), or passing the + * hollow-app quality `judge`. They are NOT fixable in the current files. A near-green state + * whose remaining errors are all of this class is a HOLLOW state (e.g. a list-only page with + * unused create/edit/delete translations): reaching green REQUIRES the model to add the + * form + buttons + toasts, which transiently spikes the error count. WS-B's count-only spray + * detection can't tell that legitimate completion edit from a bad convention spray, so + * checkpointing this state and reverting to it traps the model in the hollow app. */ +const COMPLETION_CLASS_RULES: ReadonlySet = new Set([ + "reachability", + "i18n-locale-keys-used", + "judge", +]); + +/** Whether a gate error clears only by adding code (see COMPLETION_CLASS_RULES). Matches the + * bare rule id so a plugin-prefixed form (`plugin/i18n-locale-keys-used`) still classifies. */ +export function isCompletionClass(error: IErrorItem): boolean { + const rule = error.rule ?? ""; + const bare = rule.split("/").pop() ?? rule; + + return COMPLETION_CLASS_RULES.has(bare); +} + +/** True when EVERY remaining gate error is completion-class — the hollow near-green state WS-B + * must not protect. Empty is false (green is handled elsewhere; nothing to classify). */ +export function allCompletionClass(errors: readonly IErrorItem[]): boolean { + return errors.length > 0 && errors.every(isCompletionClass); +} + /** Whether a fresh gate result should be CHECKPOINTED: it's a new all-time low, it's near * green (1..N), and not zero (zero means the build just went green — nothing to protect). * `isNewLow` is the loop's own genuine-progress signal, passed in so this stays pure. */ diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index ad8b4d6c..0ba64a4b 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -29,6 +29,7 @@ import { unseenGuidesForErrors } from "./conventions"; import { shouldCheckpoint, shouldRollback, + allCompletionClass, MAX_NEAR_GREEN_ROLLBACKS, type INearGreenCheckpoint, } from "./near-green-checkpoint"; @@ -2306,6 +2307,19 @@ async function nearGreenCheckpointStep( return; } + // Never protect a HOLLOW near-green state — one whose remaining errors ALL clear only by + // ADDING code (wiring i18n keys, reachability, the judge). The completeness guards demand + // that code; the model's large completion edit transiently spikes the count, and a + // checkpoint here would revert it to the hollow app (observed: 1→27 spray reverted to a + // list-only page, model re-does it → thrash). CLEAR any checkpoint (drops an earlier + // compile-state one too, so the spray isn't reverted to that either) and don't capture; + // WS-B resumes once errors are fixable-in-place again. + if (allCompletionClass(gateErrors)) { + state.nearGreenCheckpoint = undefined; + + return; + } + const needsReArm = state.nearGreenCheckpoint === undefined; // A strictly-lower near-green count → refresh (a new best worth protecting). const isBetter = curr < (state.nearGreenBest ?? Number.POSITIVE_INFINITY); diff --git a/packages/core/tests/near-green-checkpoint-loop.test.ts b/packages/core/tests/near-green-checkpoint-loop.test.ts index 820cbf3d..b25cc5f9 100644 --- a/packages/core/tests/near-green-checkpoint-loop.test.ts +++ b/packages/core/tests/near-green-checkpoint-loop.test.ts @@ -10,6 +10,7 @@ import type { ILoopCtx, ILoopState } from "../src/loop/turn"; import { captureNearGreenCheckpoint, rollbackNearGreen, + settleGate, } from "../src/loop/turn"; import type { IValidateResult } from "../src/validate"; import { MAX_NEAR_GREEN_ROLLBACKS } from "../src/loop/near-green-checkpoint"; @@ -138,6 +139,78 @@ test("flag ON: a spray past the near-green checkpoint REVERTS the file to the be } }, 30_000); +test("#61: settleGate does NOT checkpoint a HOLLOW near-green state (all-completion-class errors) and clears any prior one", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-nearg-")); + + try { + await Bun.write(join(dir, "feature.ts"), "export const GOOD = 1;\n"); + + // The gate is near-green with ONE remaining error that clears only by ADDING code (the + // feature declared i18n keys it hasn't wired yet). settleGate must NOT lock this hollow + // state — else the model's demanded completion edit (which spikes the count) gets reverted. + const ctx: ILoopCtx = { + task: { + id: "t", + intent: "test", + accept: "", + files: ["**/*"], + context: [], + }, + cwd: dir, + tsService: null, + report: () => undefined, + messages: [], + tool: { touched: new Set(["feature.ts"]) }, + gate: { + parse: undefined, + runner: { + run: async (): Promise => ({ + passed: false, + errors: [ + { + key: "i18n:supplier.createSuccess", + rule: "i18n-locale-keys-used", + message: "Locale key defined but never referenced", + }, + ], + output: "", + }), + }, + }, + }; + + // Seed a stale checkpoint from an earlier compile-clean cycle — the guard must CLEAR it, + // so a later spray can't be reverted to it either. + const stale = await captureNearGreenCheckpoint(ctx, 1, [ + { key: "old", message: "earlier compile error" }, + ]); + const state: ILoopState = { + prevGateErrors: [], + gateNoProgress: 0, + bestErrorCount: 1, + noNewLow: 0, + errorAge: new Map(), + lastGateCount: 1, + edits: 5, + regressions: 0, + ttsrInterrupts: 0, + steerLevel: 0, + conventionsEnabled: false, + nearGreenCheckpoint: stale, + nearGreenBest: 1, + nearGreenRollbacks: 0, + }; + + await settleGate(ctx, state, 10); + + // The hollow state was NOT protected: the checkpoint is cleared (no revert target), so + // the model's next completion edit proceeds forward instead of being reverted to hollow. + expect(state.nearGreenCheckpoint).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 30_000); + test("rollbackNearGreen resets the convergence guards + tombstones, without touching the ladder", async () => { const dir = await mkdtemp(join(tmpdir(), "tsforge-nearg-")); diff --git a/packages/core/tests/near-green-checkpoint.test.ts b/packages/core/tests/near-green-checkpoint.test.ts index 4de41529..af1fb324 100644 --- a/packages/core/tests/near-green-checkpoint.test.ts +++ b/packages/core/tests/near-green-checkpoint.test.ts @@ -2,13 +2,46 @@ import { test, expect } from "bun:test"; import { shouldCheckpoint, shouldRollback, + isCompletionClass, + allCompletionClass, NEAR_GREEN_N, NEAR_GREEN_M, MAX_NEAR_GREEN_ROLLBACKS, type INearGreenCheckpoint, } from "../src/loop/near-green-checkpoint"; +import type { IErrorItem } from "../src/validate/validate.types"; import type { IFileSnapshot } from "../src/loop/file-snapshot"; +// #61: a HOLLOW near-green state (remaining errors clear only by ADDING code — i18n keys, +// reachability, judge) must NOT be checkpointed, or WS-B reverts the demanded completion edit. +const err = (rule: string): IErrorItem => ({ key: rule, rule, message: rule }); + +test("isCompletionClass: only add-code rules (reachability/i18n-locale-keys-used/judge), bare or prefixed", () => { + expect(isCompletionClass(err("reachability"))).toBe(true); + expect(isCompletionClass(err("i18n-locale-keys-used"))).toBe(true); + expect(isCompletionClass(err("judge"))).toBe(true); + expect(isCompletionClass(err("plugin/i18n-locale-keys-used"))).toBe(true); + // Fixable-in-place errors are NOT completion-class (WS-B still protects against those). + expect(isCompletionClass(err("no-floating-promises"))).toBe(false); + expect(isCompletionClass(err("@typescript-eslint/no-unsafe-argument"))).toBe( + false + ); + expect(isCompletionClass({ key: "x", message: "no rule" })).toBe(false); +}); + +test("allCompletionClass: true only when EVERY error is completion-class; empty is false", () => { + expect(allCompletionClass([err("i18n-locale-keys-used")])).toBe(true); + expect( + allCompletionClass([err("reachability"), err("i18n-locale-keys-used")]) + ).toBe(true); + // A single fixable error among completion ones means the state is NOT purely hollow — + // WS-B should still protect it (the fixable error is a real revert target). + expect( + allCompletionClass([err("i18n-locale-keys-used"), err("no-console")]) + ).toBe(false); + expect(allCompletionClass([])).toBe(false); +}); + // WS-B: the pure checkpoint/rollback decision, unit-locked with the Phase 0a thresholds // (N=2, M=3) away from the Session's file I/O. Real spray data from inv157/inv156. From e178cfa886396134deba551cb6dba11d034a51ea Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 20 Jul 2026 01:27:20 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(loop):=20WS-B=20hollow-state=20?= =?UTF-8?q?=E2=80=94=20also=20flip=20the=20near-green=20banner=20+=20drop?= =?UTF-8?q?=20judge=20(panel=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel found #61 (suppress rollback for a hollow completion state) was INCOMPLETE and too broad: - nearGreenBanner ALSO traps: at near-green it tells the model 'smallest change, do NOT create files, add features/routes' — the exact opposite of what a completion-class error (wire i18n keys → build the create/edit/delete UI) requires. Now when every remaining error is completion-class, the banner flips to 'BUILD the missing UI; the count will rise as you add files — that's progress, not a regression to undo'. So both levers (rollback + banner) stand down together for a genuine completion state. - 'judge' DROPPED from completion-class: the quality judge can reject defects in EXISTING code (fixable in place), so it's not a reliable add-only signal and would falsely disable WS-B. Kept reachability + i18n-locale-keys-used (add-only in this stack; the i18n-destructive-delete guard forbids the removal shortcut). Clearing the checkpoint at a completion state is safe: the fixable errors it protected are already resolved (the current state has none), so it is stale. Tests: banner completion cases + judge-excluded; existing WS-B loop/banner behavior unchanged for fixable errors. --- .../core/src/loop/near-green-checkpoint.ts | 20 ++++++------ packages/core/src/loop/turn.ts | 31 +++++++++++++++++-- packages/core/tests/near-green-banner.test.ts | 22 +++++++++++++ .../core/tests/near-green-checkpoint.test.ts | 6 ++-- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/core/src/loop/near-green-checkpoint.ts b/packages/core/src/loop/near-green-checkpoint.ts index b9ce67fb..da5af26a 100644 --- a/packages/core/src/loop/near-green-checkpoint.ts +++ b/packages/core/src/loop/near-green-checkpoint.ts @@ -51,18 +51,20 @@ export interface INearGreenCheckpoint { readonly depFiles: ReadonlyMap; } -/** Gate errors that clear ONLY by ADDING code — wiring the i18n keys the feature declared - * (`i18n-locale-keys-used`), making the feature reachable (`reachability`), or passing the - * hollow-app quality `judge`. They are NOT fixable in the current files. A near-green state - * whose remaining errors are all of this class is a HOLLOW state (e.g. a list-only page with - * unused create/edit/delete translations): reaching green REQUIRES the model to add the - * form + buttons + toasts, which transiently spikes the error count. WS-B's count-only spray - * detection can't tell that legitimate completion edit from a bad convention spray, so - * checkpointing this state and reverting to it traps the model in the hollow app. */ +/** Gate errors that in THIS stack clear ONLY by ADDING code — wiring the i18n keys the feature + * declared (`i18n-locale-keys-used`; the i18n-destructive-delete guard forbids the removal + * shortcut, so the model MUST add the UI that references them), or making the feature reachable + * (`reachability`; add the route/mount). A near-green state whose remaining errors are all of + * this class is a HOLLOW state (e.g. a list-only page with unused create/edit/delete + * translations): reaching green REQUIRES the model to add the form + buttons + toasts, which + * transiently spikes the error count. WS-B's count-only spray detection can't tell that + * legitimate completion edit from a bad convention spray, so checkpointing this state and + * reverting to it traps the model in the hollow app. NOTE: `judge` is deliberately EXCLUDED — + * the quality judge can reject defects in EXISTING code (fixable in place), not only + * hollowness, so it is not a reliable add-only signal. */ const COMPLETION_CLASS_RULES: ReadonlySet = new Set([ "reachability", "i18n-locale-keys-used", - "judge", ]); /** Whether a gate error clears only by adding code (see COMPLETION_CLASS_RULES). Matches the diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index 0ba64a4b..4050808e 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -1978,7 +1978,11 @@ const NEAR_GREEN_LOCKDOWN = 3; /** A lockdown/regression banner for the top of the feedback, or "" when far from * green and not regressing. `total` = open errors this cycle; `best` = the all-time * low (watermark). Regression = this cycle is WORSE than the best already reached. */ -export function nearGreenBanner(total: number, best: number): string { +export function nearGreenBanner( + total: number, + best: number, + completionOnly = false +): string { const regressed = Number.isFinite(best) && total > best; const near = total > 0 && total <= NEAR_GREEN_LOCKDOWN; @@ -1986,6 +1990,21 @@ export function nearGreenBanner(total: number, best: number): string { return ""; } + // When EVERY remaining error clears only by ADDING code (unused i18n keys / not reachable — + // see allCompletionClass), the normal near-green lockdown ("smallest change, do NOT create + // files or add features") is exactly BACKWARDS: it forbids the create/edit/delete UI the + // feature must have. Emit the opposite instruction so the model builds it (WS-B has already + // stood its checkpoint down for this state, so the resulting spike won't be reverted). + if (completionOnly) { + return ( + `⚠ ${String(total)} error(s) left, and they clear only by ADDING the code the feature is ` + + "missing — it declared UI it hasn't built (unused i18n keys / not reachable). BUILD the " + + "create/edit/delete UI, the success/error toasts that reference those keys, and the route " + + "wiring. The error count WILL rise as you add these files — that's expected progress here, " + + "NOT a regression to undo. Keep going until the added code references everything.\n\n" + ); + } + const lines: string[] = []; if (regressed) { @@ -2086,8 +2105,14 @@ export async function injectFeedback( } // NEAR-GREEN lockdown / regression callout leads everything — the finishing - // discipline that stops "spray after best" (the dominant late-run failure). - const banner = nearGreenBanner(gateErrors.length, state.bestErrorCount); + // discipline that stops "spray after best" (the dominant late-run failure). When the + // remaining errors are all completion-class, the banner flips to "build the missing UI" + // instead of "don't create files" (else it contradicts the completeness guard). + const banner = nearGreenBanner( + gateErrors.length, + state.bestErrorCount, + allCompletionClass(gateErrors) + ); ctx.messages.push({ role: "user", diff --git a/packages/core/tests/near-green-banner.test.ts b/packages/core/tests/near-green-banner.test.ts index 1e7820cc..99e63d11 100644 --- a/packages/core/tests/near-green-banner.test.ts +++ b/packages/core/tests/near-green-banner.test.ts @@ -37,4 +37,26 @@ describe("nearGreenBanner (finishing discipline near green)", () => { test("at the watermark, far from green → no banner", () => { expect(nearGreenBanner(5, 5)).toBe(""); }); + + test("#61: completionOnly flips the banner to BUILD the UI (not the don't-create-files lockdown)", () => { + // The remaining error clears only by ADDING code — the normal lockdown would forbid the + // create/edit/delete UI the feature needs. The banner must instruct the opposite. + const b = nearGreenBanner(1, 1, true); + + expect(b).toContain("ADDING the code"); + expect(b).toContain("BUILD the"); + expect(b).toContain("NOT a regression"); + // The contradictory lockdown text must be GONE for a completion state. + expect(b).not.toContain("Do NOT create new files"); + expect(b).not.toContain("NEAR-GREEN — only"); + }); + + test("#61: completionOnly during a spike (total>best) still says BUILD, not UNDO", () => { + // While the model adds the demanded files the count rises; it must not be told to undo. + const b = nearGreenBanner(8, 1, true); + + expect(b).toContain("BUILD the"); + expect(b).not.toContain("REGRESSION"); + expect(b).not.toContain("UNDO"); + }); }); diff --git a/packages/core/tests/near-green-checkpoint.test.ts b/packages/core/tests/near-green-checkpoint.test.ts index af1fb324..9048180c 100644 --- a/packages/core/tests/near-green-checkpoint.test.ts +++ b/packages/core/tests/near-green-checkpoint.test.ts @@ -16,11 +16,13 @@ import type { IFileSnapshot } from "../src/loop/file-snapshot"; // reachability, judge) must NOT be checkpointed, or WS-B reverts the demanded completion edit. const err = (rule: string): IErrorItem => ({ key: rule, rule, message: rule }); -test("isCompletionClass: only add-code rules (reachability/i18n-locale-keys-used/judge), bare or prefixed", () => { +test("isCompletionClass: only reliable add-code rules (reachability/i18n-locale-keys-used), bare or prefixed", () => { expect(isCompletionClass(err("reachability"))).toBe(true); expect(isCompletionClass(err("i18n-locale-keys-used"))).toBe(true); - expect(isCompletionClass(err("judge"))).toBe(true); expect(isCompletionClass(err("plugin/i18n-locale-keys-used"))).toBe(true); + // `judge` is EXCLUDED — it can reject defects in existing code, not only hollowness, so it + // isn't a reliable add-only signal (would falsely disable WS-B on a fixable judge rejection). + expect(isCompletionClass(err("judge"))).toBe(false); // Fixable-in-place errors are NOT completion-class (WS-B still protects against those). expect(isCompletionClass(err("no-floating-promises"))).toBe(false); expect(isCompletionClass(err("@typescript-eslint/no-unsafe-argument"))).toBe( From d4dd029bc664a0f3d4f5f7186cc3361b9d961bf5 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 20 Jul 2026 01:48:08 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(loop):=20WS-B=20completion=20PHASE=20fl?= =?UTF-8?q?ag=20=E2=80=94=20survive=20the=20mixed-error=20spike=20(panel?= =?UTF-8?q?=20round=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel showed the prior #61 fix was still incomplete: nearGreenRollbackStep runs BEFORE the checkpoint clear, and both the rollback and the 'undo' banner keyed on a PER-CYCLE all-completion check — which flips false the instant the model starts adding the demanded UI (the spike is MIXED: new compile errors + the shrinking i18n error). So rollback re-armed and the banner reverted to REGRESSION/UNDO mid-add — the same thrash. Fix: a PERSISTENT state.completionPhase flag, advanced once at the top of settleGate via nextCompletionPhase(prev, errors): ENTER when all errors are completion-class, STAY while ANY completion error remains (the whole spike), EXIT when none remain (wiring done → WS-B re-engages) or green. While set: nearGreenRollbackStep returns early (no revert), the banner says 'build it', and checkpointStep clears/skips. Reset at the drive boundary. Pure nextCompletionPhase + a spike-suppression integration test (6 mixed errors past the threshold → 0 rollbacks, phase held, file not reverted). 31 near-green tests green. --- .../core/src/loop/near-green-checkpoint.ts | 20 +++++ packages/core/src/loop/session.ts | 1 + packages/core/src/loop/turn.ts | 44 ++++++++--- .../tests/near-green-checkpoint-loop.test.ts | 79 +++++++++++++++++++ .../core/tests/near-green-checkpoint.test.ts | 18 +++++ 5 files changed, 152 insertions(+), 10 deletions(-) diff --git a/packages/core/src/loop/near-green-checkpoint.ts b/packages/core/src/loop/near-green-checkpoint.ts index da5af26a..6ffab7cd 100644 --- a/packages/core/src/loop/near-green-checkpoint.ts +++ b/packages/core/src/loop/near-green-checkpoint.ts @@ -82,6 +82,26 @@ export function allCompletionClass(errors: readonly IErrorItem[]): boolean { return errors.length > 0 && errors.every(isCompletionClass); } +/** Advance the persistent completion-phase flag. ENTER when every error is completion-class (a + * hollow state); STAY while ANY completion error remains (the MIXED spike as the model wires + * the keys — a per-cycle all-completion check would flip false here and re-arm the rollback + + * "undo" banner mid-add); EXIT when no completion error remains (the model finished wiring → + * only fixable errors left → WS-B re-engages) or when green (no errors). */ +export function nextCompletionPhase( + prev: boolean, + errors: readonly IErrorItem[] +): boolean { + if (allCompletionClass(errors)) { + return true; + } + + if (!errors.some(isCompletionClass)) { + return false; + } + + return prev; +} + /** Whether a fresh gate result should be CHECKPOINTED: it's a new all-time low, it's near * green (1..N), and not zero (zero means the build just went green — nothing to protect). * `isNewLow` is the loop's own genuine-progress signal, passed in so this stays pure. */ diff --git a/packages/core/src/loop/session.ts b/packages/core/src/loop/session.ts index da4ba644..5f9fa1aa 100644 --- a/packages/core/src/loop/session.ts +++ b/packages/core/src/loop/session.ts @@ -424,6 +424,7 @@ export function resetDriveConvergence(state: ILoopState): void { delete state.nearGreenCheckpoint; delete state.nearGreenBest; delete state.nearGreenRollbacks; + delete state.completionPhase; } /** How many times a send recovers from a repetition loop before giving up. */ diff --git a/packages/core/src/loop/turn.ts b/packages/core/src/loop/turn.ts index 4050808e..55361b3a 100644 --- a/packages/core/src/loop/turn.ts +++ b/packages/core/src/loop/turn.ts @@ -29,7 +29,7 @@ import { unseenGuidesForErrors } from "./conventions"; import { shouldCheckpoint, shouldRollback, - allCompletionClass, + nextCompletionPhase, MAX_NEAR_GREEN_ROLLBACKS, type INearGreenCheckpoint, } from "./near-green-checkpoint"; @@ -432,6 +432,15 @@ export interface ILoopState { * (e.g. 2→1, or 1→1 different error) instead of a spray reverting to a worse/older saved * state. Reset on green and at the drive boundary; lowered as the checkpoint refreshes. */ nearGreenBest?: number; + /** WS-B: the model is in the COMPLETION phase — it reached an all-completion-class state + * (only add-code errors: unused i18n keys / not reachable) and is now building the demanded + * create/edit/delete UI. This PERSISTS across the resulting error spike (which is MIXED — + * new compile errors + the shrinking completion errors), because a per-cycle all-completion + * check flips false the moment the model starts adding code, re-arming the rollback + the + * "undo" banner mid-add. Set when all errors are completion-class; held while ANY completion + * error remains; cleared when none remain (completion done → WS-B re-engages) or on green / + * at the drive boundary. While set, WS-B does not roll back and the banner says "build it". */ + completionPhase?: boolean; /** Guard-specific identity of the current stuck block (canonical, not the raw error * set). Derived from the guard that fired: samePersist → single error key, * gateStuckRepeats → sorted-join of current keys, plateau → normalized count|keys @@ -2111,7 +2120,7 @@ export async function injectFeedback( const banner = nearGreenBanner( gateErrors.length, state.bestErrorCount, - allCompletionClass(gateErrors) + state.completionPhase === true ); ctx.messages.push({ @@ -2289,6 +2298,13 @@ async function nearGreenRollbackStep( return false; } + // During the COMPLETION phase the model is ADDING the demanded UI, so the error spike is + // progress, not a spray — never roll it back (this runs before checkpointStep, so the flag, + // not a per-cycle all-completion check, is what protects the mixed-error spike). + if (state.completionPhase === true) { + return false; + } + if ( shouldRollback( state.nearGreenCheckpoint, @@ -2332,14 +2348,13 @@ async function nearGreenCheckpointStep( return; } - // Never protect a HOLLOW near-green state — one whose remaining errors ALL clear only by - // ADDING code (wiring i18n keys, reachability, the judge). The completeness guards demand - // that code; the model's large completion edit transiently spikes the count, and a - // checkpoint here would revert it to the hollow app (observed: 1→27 spray reverted to a - // list-only page, model re-does it → thrash). CLEAR any checkpoint (drops an earlier - // compile-state one too, so the spray isn't reverted to that either) and don't capture; - // WS-B resumes once errors are fixable-in-place again. - if (allCompletionClass(gateErrors)) { + // Never protect a HOLLOW state while in the COMPLETION phase — the model is adding the + // demanded create/edit/delete UI (wiring i18n keys / reachability), so a checkpoint here + // would revert it to the list-only page (observed: 1→27 spray reverted, model re-does it → + // thrash). CLEAR any checkpoint (drops an earlier compile-state one too — its fixable errors + // are already resolved, so it's stale) and don't capture; WS-B re-engages when the phase ends + // (no completion error remains). completionPhase was advanced at the top of settleGate. + if (state.completionPhase === true) { state.nearGreenCheckpoint = undefined; return; @@ -2378,6 +2393,15 @@ export async function settleGate( const curr = gateErrors.length; + // WS-B: advance the completion-phase flag BEFORE the rollback/banner/checkpoint steps read + // it — so the phase set at the hollow state survives the mixed-error spike that follows + // (the rollback step runs first, and a per-cycle all-completion check would already be false + // there once the model started adding code). + state.completionPhase = nextCompletionPhase( + state.completionPhase ?? false, + gateErrors + ); + if (state.lastGateCount >= 0 && curr > state.lastGateCount) { state.regressions += 1; } diff --git a/packages/core/tests/near-green-checkpoint-loop.test.ts b/packages/core/tests/near-green-checkpoint-loop.test.ts index b25cc5f9..7a49f56f 100644 --- a/packages/core/tests/near-green-checkpoint-loop.test.ts +++ b/packages/core/tests/near-green-checkpoint-loop.test.ts @@ -211,6 +211,85 @@ test("#61: settleGate does NOT checkpoint a HOLLOW near-green state (all-complet } }, 30_000); +test("#61: during the completion phase, a mixed-error SPIKE is NOT rolled back (banner+rollback stand down through the spike)", async () => { + const dir = await mkdtemp(join(tmpdir(), "tsforge-nearg-")); + + try { + await Bun.write(join(dir, "feature.ts"), "export const GOOD = 1;\n"); + + // The model is mid-add: it entered the completion phase last cycle, and this gate shows a + // MIXED spike (the shrinking i18n completion error + new compile errors from the half-written + // UI) well past the rollback threshold. A per-cycle all-completion check would be FALSE here + // and roll back; the persistent completionPhase flag must keep WS-B (and the undo banner) off. + const ctx: ILoopCtx = { + task: { + id: "t", + intent: "test", + accept: "", + files: ["**/*"], + context: [], + }, + cwd: dir, + tsService: null, + report: () => undefined, + messages: [], + tool: { touched: new Set(["feature.ts"]) }, + gate: { + parse: undefined, + runner: { + run: async (): Promise => ({ + passed: false, + errors: [ + { + key: "i18n:x", + rule: "i18n-locale-keys-used", + message: "unused key", + }, + { key: "c1", rule: "no-unsafe-argument", message: "unsafe" }, + { key: "c2", rule: "no-unsafe-argument", message: "unsafe" }, + { key: "c3", rule: "no-unsafe-argument", message: "unsafe" }, + { key: "c4", rule: "no-unsafe-argument", message: "unsafe" }, + { key: "c5", rule: "no-unsafe-argument", message: "unsafe" }, + ], + output: "", + }), + }, + }, + }; + const cp = await captureNearGreenCheckpoint(ctx, 1, [ + { key: "i18n:x", rule: "i18n-locale-keys-used", message: "unused key" }, + ]); + const state: ILoopState = { + prevGateErrors: [], + gateNoProgress: 0, + bestErrorCount: 1, + noNewLow: 0, + errorAge: new Map(), + lastGateCount: 1, + edits: 5, + regressions: 0, + ttsrInterrupts: 0, + steerLevel: 0, + conventionsEnabled: false, + completionPhase: true, + nearGreenCheckpoint: cp, + nearGreenBest: 1, + nearGreenRollbacks: 0, + }; + + await settleGate(ctx, state, 10); + + // 6 errors is > checkpoint(1) + M(3), so WITHOUT the phase flag WS-B would revert. It did NOT: + // no rollback was counted, and the phase persisted (a completion error still remains). + expect(state.nearGreenRollbacks).toBe(0); + expect(state.completionPhase).toBe(true); + // feature.ts was NOT reverted (no rollback restored the checkpoint snapshot). + expect(await Bun.file(join(dir, "feature.ts")).text()).toContain("GOOD"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 30_000); + test("rollbackNearGreen resets the convergence guards + tombstones, without touching the ladder", async () => { const dir = await mkdtemp(join(tmpdir(), "tsforge-nearg-")); diff --git a/packages/core/tests/near-green-checkpoint.test.ts b/packages/core/tests/near-green-checkpoint.test.ts index 9048180c..cf28ebc7 100644 --- a/packages/core/tests/near-green-checkpoint.test.ts +++ b/packages/core/tests/near-green-checkpoint.test.ts @@ -4,6 +4,7 @@ import { shouldRollback, isCompletionClass, allCompletionClass, + nextCompletionPhase, NEAR_GREEN_N, NEAR_GREEN_M, MAX_NEAR_GREEN_ROLLBACKS, @@ -31,6 +32,23 @@ test("isCompletionClass: only reliable add-code rules (reachability/i18n-locale- expect(isCompletionClass({ key: "x", message: "no rule" })).toBe(false); }); +test("nextCompletionPhase: ENTER on all-completion, STAY through the mixed spike, EXIT when no completion error remains", () => { + const i18n = err("i18n-locale-keys-used"); + const compile = err("no-unsafe-argument"); + + // ENTER: reached the hollow all-completion state. + expect(nextCompletionPhase(false, [i18n])).toBe(true); + // STAY: the model started adding the UI → MIXED errors (this is the case a per-cycle + // all-completion check got wrong — it would flip false here and re-arm rollback/undo). + expect(nextCompletionPhase(true, [i18n, compile, compile])).toBe(true); + // EXIT: the keys are now referenced → only fixable errors remain → WS-B re-engages. + expect(nextCompletionPhase(true, [compile, compile])).toBe(false); + // EXIT on green (no errors at all). + expect(nextCompletionPhase(true, [])).toBe(false); + // Never enters from a purely-fixable state. + expect(nextCompletionPhase(false, [compile])).toBe(false); +}); + test("allCompletionClass: true only when EVERY error is completion-class; empty is false", () => { expect(allCompletionClass([err("i18n-locale-keys-used")])).toBe(true); expect(