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
6 changes: 6 additions & 0 deletions docs/borrowed-ideas.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ decoding — returning schema-conformant output. See memory
- **Effort:** M. **Risk:** low. **ROI:** high (no endpoint dependency).
- **Done when:** read/edit tool responses carry window + outcome context, and an
eval shows fewer wasted "re-read the file" turns.
- **Status (PR #60):** the write-guard already echoes type/lint outcomes after a
write. Now adds the **preventive ACI echo** — a CLEAN write whose content the
strip/auto-format pass reshaped returns the post-format file (numbered, ≤80
lines) so the model edits against disk reality, not its now-stale copy. The
preventive complement to #57's corrective carry-content-on-not-found. No echo
when nothing diverged (no wasted tokens); a short re-read note for large files.

### 3. Robust edit application + lenient output parsing

Expand Down
58 changes: 57 additions & 1 deletion packages/core/src/loop/write-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PER_WRITE_META_RULES,
type IMetaRuleContext,
} from "../meta-rules";
import { HL_LINE_SEP } from "../files/hashline-format";
import type { Reporter } from "./loop.types";
import type { ILoopCtx } from "./turn";

Expand Down Expand Up @@ -220,6 +221,9 @@ async function writeGuard(
// `file` is workspace-relative (the create/edit handler normalized it); the
// strip/linter/readFileSync need the absolute path.
const absPath = join(cwd, file);
// Snapshot what the model just WROTE (before strip/format reshape it) so a clean
// write can echo back the post-format content only when it actually diverged.
const written = safeRead(absPath);
// Strip the model's reflexive needless literal-to-union casts NOW (deterministic,
// safe) so it's never told about them and never spends a turn removing them.
const stripped = await stripLiteralCasts(absPath).catch(() => 0);
Expand Down Expand Up @@ -257,7 +261,11 @@ async function writeGuard(
const total = typeErrors.length + lintProblems.length;

if (total === 0 && dependants.length === 0) {
return "";
// Clean write. If strip/auto-format reshaped what the model wrote, its
// in-context copy is now stale — echo the post-format content so its NEXT edit
// anchors on disk reality, preventing the stale-anchor not-found the edit tool
// only recovers from. No-op when nothing changed (no divergence to report).
return reformatEcho(file, written, safeRead(absPath));
}

const detail = writeGuardLines(absPath, cwd, typeErrors, lintProblems);
Expand Down Expand Up @@ -287,6 +295,54 @@ async function writeGuard(
);
}

/** Read a file synchronously, or "" if it can't be read (just-written files
* always exist; this only guards a transient race). */
function safeRead(absPath: string): string {
try {
return readFileSync(absPath, "utf8");
} catch {
return "";
}
}

/** Lines above which a reformat echo would cost more context than it saves; larger
* files fall back to the corrective (re-read-on-not-found) path. */
const REFORMAT_ECHO_MAX_LINES = 80;

/**
* Feedback for a CLEAN write whose content the strip/auto-format pass reshaped:
* echo the post-format file so the model edits against disk reality, not the
* (now-stale) text it wrote — the preventive half of the edit-on-autoformat fix
* (the corrective half inlines content on a not-found rejection). Returns "" when
* nothing diverged (the model's copy is already correct) or the file is too large
* to inline cheaply (a short note then, so the model knows to re-read).
*/
export function reformatEcho(
file: string,
written: string,
current: string
): string {
if (written === current || current.length === 0) {
return "";
}

// Drop the standard trailing newline before splitting, so a `…\n`-terminated
// file doesn't number a phantom empty last line the model could anchor on.
const lines = (current.endsWith("\n") ? current.slice(0, -1) : current).split(
"\n"
);

if (lines.length > REFORMAT_ECHO_MAX_LINES) {
return `\n\nℹ ${file} was auto-formatted (imports/quotes/blank lines normalized) — re-read it before your next edit so your oldString matches what's on disk.`;
}

const numbered = lines
.map((line, i) => `${i + 1}${HL_LINE_SEP}${line}`)
.join("\n");

return `\n\nℹ ${file} was auto-formatted — its CURRENT content is below; copy any future oldString from THIS (not from what you wrote):\n${numbered}`;
}

/** A meta-rule context scoped to ONE just-written file, so the per-write rules
* check exactly that file. The file is placed in whichever list field matches its
* kind (mirroring buildMetaRuleContext's categorization); the other lists are
Expand Down
46 changes: 46 additions & 0 deletions packages/core/tests/write-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect, describe } from "bun:test";
import { reformatEcho } from "../src/loop/write-guard";

describe("reformatEcho (preventive ACI echo on a clean write)", () => {
test("no echo when the auto-format changed nothing", () => {
const code = "export const x = 1;\n";

expect(reformatEcho("a.ts", code, code)).toBe("");
});

test("no echo when the file is unreadable (current empty)", () => {
expect(reformatEcho("a.ts", "whatever", "")).toBe("");
});

test("echoes the post-format content (numbered) when it diverged", () => {
const written = "export const x = 'a'\n"; // single quotes, no semicolon
const current = 'export const x = "a";\n'; // prettier-normalized
const out = reformatEcho("a.ts", written, current);

expect(out).toContain("auto-formatted");
expect(out).toContain('export const x = "a";'); // the actual on-disk text
// Tells the model to anchor on THIS, not what it wrote.
expect(out.toLowerCase()).toContain("oldstring");
});

test("does not number a phantom trailing line for a newline-terminated file", () => {
// Two real lines + standard trailing newline → exactly lines 1 and 2, no `3`.
const out = reformatEcho("a.ts", "a\nb", "const a = 1;\nconst b = 2;\n");
const lineNumbers = [...out.matchAll(/^(\d+)/gmu)].map((m) => m[1]);

expect(out).toContain("const a = 1;");
expect(out).toContain("const b = 2;");
expect(lineNumbers).toEqual(["1", "2"]); // no phantom "3" from the trailing \n
});

test("a large reshaped file gets a re-read note, not inlined content", () => {
const written = "x";
const current = Array.from({ length: 200 }, (_, i) => `line${i}`).join(
"\n"
);
const out = reformatEcho("big.ts", written, current);

expect(out).toContain("re-read");
expect(out).not.toContain("line5"); // content NOT inlined (too large)
});
});