Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/core/src/loop/near-green-checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,57 @@ export interface INearGreenCheckpoint {
readonly depFiles: ReadonlyMap<string, Uint8Array>;
}

/** 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<string> = new Set([
"reachability",
"i18n-locale-keys-used",
]);

/** 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);
}

/** 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. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/loop/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
69 changes: 66 additions & 3 deletions packages/core/src/loop/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { unseenGuidesForErrors } from "./conventions";
import {
shouldCheckpoint,
shouldRollback,
nextCompletionPhase,
MAX_NEAR_GREEN_ROLLBACKS,
type INearGreenCheckpoint,
} from "./near-green-checkpoint";
Expand Down Expand Up @@ -431,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
Expand Down Expand Up @@ -1977,14 +1987,33 @@ 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;

if (!near && !regressed) {
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) {
Expand Down Expand Up @@ -2085,8 +2114,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,
state.completionPhase === true
);

ctx.messages.push({
role: "user",
Expand Down Expand Up @@ -2263,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,
Expand Down Expand Up @@ -2306,6 +2348,18 @@ async function nearGreenCheckpointStep(
return;
}

// 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;
}

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);
Expand Down Expand Up @@ -2339,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;
}
Expand Down
22 changes: 22 additions & 0 deletions packages/core/tests/near-green-banner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
152 changes: 152 additions & 0 deletions packages/core/tests/near-green-checkpoint-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -138,6 +139,157 @@ 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<IValidateResult> => ({
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("#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<IValidateResult> => ({
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-"));

Expand Down
Loading