@lucascardozo/pi-edit-guard
v0.13.0
Published
Pi extension that wraps the native edit tool with indentation-drift recovery, uniqueness enforcement, and batch-aware error reporting. Fixes the most common LLM failure mode in code editing.
Maintainers
Readme
pi-edit-guard
Pi extension that wraps the native edit tool with silent auto-fix, uniqueness enforcement, and batch-aware error reporting.
Fixes the most common LLM failure mode in code editing: the model counts spaces wrong, sends an oldText that doesn't match the file's actual indentation, and the edit fails with a generic "Could not find" error. The model then has to either re-read the file (cost) or give up.
pi-edit-guard silently corrects these failures for the most common case (uniform leading-space shift) by mutating event.input in place and letting native edit run with corrected oldText/newText. When the failure is more complex (ambiguous match, character difference, no match), it surfaces a consolidated report so the model can fix everything in one pass instead of N.
Install
pi install npm:@lucascardozo/pi-edit-guardQuick start
After pi install, the extension is active by default with zero config.
The most common failure mode (model writes oldText with wrong leading
indentation) is handled silently — pi-edit-guard mutates the edit in
place so native edit succeeds.
Verify it works
The debug logger is off by default — no files are written to /tmp
unless you opt in. To capture a session for triage, set the env vars
before launching Pi:
PI_EDIT_GUARD_DEBUG=1 pi # capture one NDJSON line per edit
PI_EDIT_GUARD_DEBUG=1 PI_EDIT_GUARD_LOG_FULL=1 PI_EDIT_GUARD_LOG_SNAPSHOTS=1 pi # full content + file snapshotsOnce enabled, the log goes to /tmp/pi-edit-guard-<pid>.log (override
with PI_EDIT_GUARD_LOG_PATH=...). Run a model turn that touches a file
and inspect:
# in one terminal
tail -f /tmp/pi-edit-guard-$(pgrep -f "pi-coding-agent" | head -1).log
# in another: ask the model to edit any file in your projectIf the extension is intercepting correctly, you'll see one JSON line per
edit with fields like result: "autofixed" (silent correction), result: "blocked"
(atomic block with consolidated report), or result: "formatter-rewritten"
(formatter applied after a successful native edit).
Optional: configure a formatter
If you want the extension to defer to an external formatter (prettier,
biome, deno fmt, etc.) instead of mutating newText itself, create a
config file. See Auto-format (opt-in, v0.12.0+)
for the schema and Common formatter recipes
for copy-paste configs.
The simplest setup — use prettier on everything:
mkdir -p .pi/extensions/pi-edit-guard
cat > .pi/extensions/pi-edit-guard/config.json <<'JSON'
{
"commands": { "prettier": ["npx", "prettier", "--write"] },
"filetypes": { "*": "prettier" }
}
JSONRestart Pi. From now on, any edit the model makes will run through
prettier and the model will see the atomic final state
(original → formatted), never the intermediate drift.
Disabling temporarily
To verify whether pi-edit-guard is causing a problem in a specific
session, set the env var before launching Pi:
# Bypass autofix but keep cascade (lets an external formatter handle drift):
PI_EDIT_GUARD_TRUST_FORMATTER=1 pi
# Capture a debug session (default is OFF — no files written unless opted in):
PI_EDIT_GUARD_DEBUG=1 pi
# Full content + file snapshots for richer triage:
PI_EDIT_GUARD_DEBUG=1 PI_EDIT_GUARD_LOG_FULL=1 PI_EDIT_GUARD_LOG_SNAPSHOTS=1 piThere's no kill switch that disables the extension entirely — the
guard's design assumes it's better to have it on with a noisy error
message than to silently let edits fail with Pi's generic
"Could not find the exact text" message. If you really want to
disable it, pi uninstall @lucascardozo/pi-edit-guard.
What it does
Layer 1 — tool_call (before native edit runs)
For each oldText in the batch:
- Counts line-anchored literal occurrences (native edit semantics: match must start at the beginning of a line)
- 1 unique → pass through
- 0 → continue to step 2
1 → block with examples
- Whitespace-normalized exact match (strip leading spaces per line)
- 1 unique → auto-fix path (silent, see below) or block if auto-fix can't apply
1 → block with examples
- 0 → continue to step 3
- Char-level Levenshtein per line, averaged across the block
- 1 candidate above threshold (default 0.90) → block with similarity
1 above threshold → block with examples
- 0 above threshold → block with best-match hint
Auto-fix (silent success) — when the cascade resolves to unique-drift with a uniform leading-space shift, pi-edit-guard mutates the edit in place:
oldTextis replaced with the file's verbatim block (the cascade already guarantees this matches).newTextis shifted by the same delta, per non-blank line.- For the cases auto-fix declines (non-uniform drift, tabs, large delta), the model gets a clear verdict + the file's verbatim block. To opt out of autofix entirely (e.g. when running
pi-autoformatalongside), setPI_EDIT_GUARD_TRUST_FORMATTER=1; the guard only validates, then passesnewTextverbatim to native edit (see Configuration table).
No-op detection — when oldText === newText, the edit would make no change. Pi's native edit rejects this with a misleading "No changes made... special characters or text not existing" message. pi-edit-guard catches it before the cascade and returns a clear, actionable verdict so the model knows it sent a no-op.
- The model receives a normal edit-success result. No error message, no retry cost.
Auto-fix declines (and falls back to the block path) with a specific reason when:
missing-text—oldTextornewTextis empty/missing.line-count-mismatch—oldTexthas different line count than the matched file block.tab-in-oldtext—oldTextuses tabs (spaces-only file assumption).tab-in-newtext— any line ofnewTexthas a leading tab (would write mixed-indent).tab-in-file-block— the matched file block uses tabs.non-uniform-delta— different non-blank lines need different shifts (not a clean shift mistake).zero-delta—oldTextalready matches (cascade would have returnedok-literalanyway).delta-too-large— defensive cap of ±50 spaces exceeded.
The decline reason attaches to the EditEvaluation and is surfaced as a specific hint in the consolidated report so the model can correct on the next try instead of looping.
Layer 2 — tool_result (when native edit fails atomically)
Re-runs the cascade against the file's current state and mutates the error message in-place to surface the most probable target.
Batch semantics
Both layers iterate over input.edits[]. The atomicity rule: when the cascade resolves to auto-fixable for some edits but unfixable for others, the entire batch is blocked with a consolidated report so the model can fix everything in one pass:
Edit guard: 1 of 2 edits have issues.
Edit 2: Found 6 similar blocks (similarity ≥ 0.90).
First 3 examples:
- Lines 3-3:});
- Lines 7-7:});
- Lines 11-11:});
Re-read the file and provide a more specific oldText that uniquely identifies the target block.
Fix the issues above and re-submit the entire batch. Edits already passing will be re-evaluated with the new file state.The consolidated report is the model's single source of truth: it knows exactly which edits failed and why, and can fix all of them in one go instead of N trial-and-error rounds.
Mutates events in-place
tool_call: mutatesevent.input.edits[i].oldTextand.newTextin place when auto-fix applies. The Pi runtime applies mutations across handlers; native edit then runs with the corrected arguments.tool_result: mutatesevent.contentin place so custom renderers (e.g.gentle-pi'squiet-tools) pick up the enriched message.isErroris set tofalseso renderers likequiet-toolscollapse long output via theirCOLLAPSED_TAIL_LINE_LIMIT.
Output format
Single edit: fuzzy-match (character difference)
When auto-fix doesn't apply and the cascade surfaces a fuzzy match:
Error: Edit failed. Your oldText had a small difference from the file.
Lines 12-12. Use this block verbatim as your new oldText:
return 11;(similarity 0.92)Single edit: drift recovery (unfixable case)
When auto-fix declines (e.g. the file uses tabs, or the delta is non-uniform), the consolidated report includes the specific decline reason so the model knows what to fix:
Error: Edit failed. Indentation in your oldText didn't match the file.
Lines 12-16. Use these lines verbatim as your new oldText (including leading whitespace):
if (item % 2 === 0) {
return acc + item * 2;
} else {
return acc + item;
}(Hint: autofix declined — tab detected in newText line 3. Replace the tab with spaces to match the file's indent.)The block is the file's actual lines, byte-exact. The model copies them as-is — no transformation needed. The hint narrows the retry to a specific cause.
Single edit: ambiguous (with examples)
Found 13 similar blocks (literal).
First 3 examples:
- Lines 12-14:});
- Lines 47-49:});
- Lines 81-83:});
Re-read the file and provide a more specific oldText that uniquely identifies the target block.Single edit: no match (with best similarity + hint)
No sufficiently similar block found.
Best match: similarity 0.62 at line 47 (below threshold 0.90).
Closest block (lines 47-49): await triggerOrderStatusChanged(...)
Re-read the file to see its current contents before retrying.When the best similarity is below the hint minimum (default 0.50), the closest block is omitted to avoid misleading the model.
Auto-format (opt-in, v0.12.0+)
When a formatter is configured for a file, pi-edit-guard skips its own
auto-fix layer and lets the external formatter rewrite the file post-edit.
The model sees the atomic final state (original → formatted), never the
intermediate drift between newText and the formatter's output.
Config file
Create one of these (or both — project overrides global for matching keys):
- Global:
~/.pi/agent/extensions/pi-edit-guard/config.json - Project:
.pi/extensions/pi-edit-guard/config.json
Schema (same as pi-code-formatter):
{
"commands": {
"prettier": ["npx", "prettier", "--write"],
"eslint": ["npx", "eslint", "--fix"]
},
"filetypes": {
"*.ts": "prettier",
"*.md": "prettier",
"*.{js,jsx}": "eslint",
"*": "prettier"
}
}Common formatter recipes
Copy-paste configs for the most common stacks. Drop into
~/.pi/agent/extensions/pi-edit-guard/config.json (global) or
.pi/extensions/pi-edit-guard/config.json (project overrides global).
Prettier only (TypeScript / JavaScript / CSS / Markdown / HTML):
{
"commands": {
"prettier": ["npx", "prettier", "--write", "--ignore-unknown"]
},
"filetypes": {
"*.{ts,tsx,js,jsx,mjs,cjs}": "prettier",
"*.{css,scss,less}": "prettier",
"*.{md,mdx}": "prettier",
"*.{html,json,yaml,yml}": "prettier",
"*": "prettier"
}
}Prettier + ESLint (format + lint-fix on JS/TS, prettier-only elsewhere):
{
"commands": {
"prettier": ["npx", "prettier", "--write"],
"eslint": ["npx", "eslint", "--fix"]
},
"filetypes": {
"*.{ts,tsx,js,jsx}": "eslint",
"*.{md,css,html,json,yaml,yml}": "prettier",
"*": "prettier"
}
}Biome (modern replacement for ESLint + Prettier in one binary):
{
"commands": {
"biome": ["npx", "@biomejs/biome", "format", "--write"]
},
"filetypes": {
"*.{ts,tsx,js,jsx,json}": "biome",
"*": "biome"
}
}Deno fmt (Deno projects):
{
"commands": {
"denofmt": ["deno", "fmt", "--quiet"]
},
"filetypes": {
"*.{ts,tsx,js,jsx,json,md}": "denofmt",
"*": "denofmt"
}
}Python (Ruff) — fast linter + formatter:
{
"commands": {
"ruff": ["ruff", "format"]
},
"filetypes": {
"*.py": "ruff",
"*": "ruff"
}
}Python (Black):
{
"commands": {
"black": ["black", "--quiet"]
},
"filetypes": {
"*.py": "black",
"*": "black"
}
}Go (goimports) — runs gofmt internally plus import sorting::
{
"commands": {
"goimports": ["goimports", "-w"]
},
"filetypes": {
"*.go": "goimports"
}
}Mixed polyglot project (different tools per file type, wildcard for anything else):
{
"commands": {
"prettier": ["npx", "prettier", "--write"],
"biome": ["npx", "@biomejs/biome", "format", "--write"],
"ruff": ["ruff", "format"],
"gofmt": ["gofmt", "-w"]
},
"filetypes": {
"*.{ts,tsx,js,jsx}": "biome",
"*.py": "ruff",
"*.go": "gofmt",
"*": "prettier"
}
}Important: every command must accept the file path as its last
argument (after --). The extension invokes formatters as
<command[0]> <command[1..n]> -- <absolute-file-path> with a 5-second
timeout. If your formatter has a different flag convention, adapt the
command accordingly — e.g., ["black", "-"] won't work because black
expects stdin, not a path argument.
Pattern rules:
*.ext— glob, matches files ending in.ext(specific, wins over*)/regex/— explicit regex, the body between slashesliteral— literal suffix match*— wildcard fallback (only matches if no specific pattern matched)
The first matching pattern wins. More-specific patterns are tried before the wildcard. Unknown command names are skipped with a console warning.
Behavior
When a formatter matches the file being edited:
- In
tool_call: autofix is skipped (matchedFormatterpropagates intoprocessEditInput). The guard still validates that there IS a match — ambiguous/fuzzy/no-match still block with the consolidated report. The model'snewTextpasses through verbatim. - In
tool_result(success only): the formatter runs viapi.exec(5-second timeout,--separator + absolute path). If the formatter changes the file,details.{patch, diff, firstChangedLine}are rewritten so the model seesoriginal → formatted. If the formatter fails, exits non-zero, or makes no change, the original details are kept (best-effort, never throws).
The result the model sees is the atomic final state — never the drift intermediate. This closes the "surrender" loop where the model re-reads the file to "fix" indentation it never actually broke.
Backward compatibility
If no config file exists, behavior is identical to v0.11.0: autofix runs for unique drift, blocks for everything else. The formatter integration is purely opt-in.
The pre-existing PI_EDIT_GUARD_TRUST_FORMATTER=1 flag (and
--trust-formatter CLI flag) still works for projects that prefer an
external formatter without the in-process rewrite — it's now redundant
when a config file is present, but kept for users who already rely on it.
Debug logging (production triage)
The debug logger is off by default — no files are written to /tmp
unless you opt in. To capture a session for triage, set the env vars
before launching Pi:
PI_EDIT_GUARD_DEBUG=1 \ # enable the NDJSON log (sha + length + 200-char preview)
PI_EDIT_GUARD_LOG_FULL=1 \ # log full oldText/newText content
PI_EDIT_GUARD_LOG_SNAPSHOTS=1 \ # save verbatim file snapshots
piWhen enabled, the log goes to /tmp/pi-edit-guard-<pid>.log with one
NDJSON line per cascade invocation. With LOG_SNAPSHOTS=1, snapshots
go to /tmp/pi-edit-guard-<pid>/snapshots/<sha>.orig.
Fields per log entry:
| Field | Meaning |
|---|---|
| source | 'tool_call' | 'tool_result' — which hook fired. Pair the two to see what we intercepted vs what native returned. |
| path | File path the edit targets. |
| fileBytes / fileSha / filePreview | Length, sha256 (12 hex), and the file content (full when LOG_FULL is on; first 200 chars + [+N chars] when redacted). |
| fileLeadingNewlines / fileTrailingNewlines | Helpful to spot BOM or trailing-newline mismatches. |
| edits[i].oldTextBytes / oldTextSha / oldTextPreview / oldTextLeadingSpaces | What the model sent (full content by default; redacted when LOG_FULL=0). |
| edits[i].newTextBytes / newTextSha / newTextPreview / newTextLeadingSpaces | What the model wants to write. |
| edits[i].evaluationKind | ok-literal, unique-drift, fuzzy-match, ambiguous-*, no-match. |
| edits[i].autofixOutcome | ok (with autofixDelta) | declined (with declineReason) | n/a. |
| result | autofixed | blocked (with blockReasonBytes) | pass | pass-oversized | pass-unreadable | formatter-rewritten | formatter-noop | formatter-failed. |
| formatterMatched | True when a formatter was configured and matched this file's path. Orthogonal to result — when present with result: 'autofixed', the autofix ran in trust mode (corrected oldText only, left newText verbatim for the formatter). |
| autofixedCount | Number of edits silently corrected (only present when result: 'autofixed'). |
| snapshotPath | Absolute path to the saved file snapshot (always present by default; absent when LOG_SNAPSHOTS=0). |
| nativeError | What the native edit tool returned (only on tool_result events with isError). Shows what the model actually saw. |
| formatterCommand | The resolved formatter command (only on tool_result formatter events). |
| formatterApplied | true when the formatter actually changed the file content. |
| formatterReason | Brief reason when formatter was skipped, failed, or no-op (exit-code-N, no-change, exec-threw). |
| formatterStderr | Truncated stderr (only when formatter exited non-zero). |
| formatterDurationMs | Wall-clock duration of the formatter run. |
The log rotates at 5 MB. Snapshots are capped at 200 files / 100MB total (oldest by mtime get pruned).
Custom log path
PI_EDIT_GUARD_LOG_PATH=./edit-guard.log piSnapshots go to ./edit-guard/snapshots/ (i.e. <dirname(log-path)>/<basename-without-ext>/snapshots/). The grouping is per log file so that all artifacts from one session live together — rm -rf /tmp/pi-edit-guard-* is enough to clean up both the log and snapshots.
Triage workflow
- Reproduce the issue with debug logging enabled:
PI_EDIT_GUARD_DEBUG=1 pi(or+LOG_FULL=1+LOG_SNAPSHOTS=1for richer triage). cat <log-path> | jq .(or use any NDJSON viewer).- Filter by
sourceto see what the extension intercepted (tool_call) vs what came back from native (tool_result). Ontool_resultwithnativeError, you'll see exactly what the model saw. - If
snapshotPathis set,cat <snapshotPath>shows the file at edit time. - Compare
oldTextLeadingSpacesvs the snapshot's leading whitespace per line — this is the smoking gun for any "I copied verbatim but it doesn't match" mystery.
Configuration
| Env var | Default | Opt-out | Effect |
|---|---|---|---|
| PI_EDIT_GUARD_THRESHOLD | 0.90 | n/a | Similarity threshold for fuzzy matches (Level 3). Lower = more permissive. |
| PI_EDIT_GUARD_EXAMPLES | 3 | n/a | Max number of example blocks shown for ambiguous cases. |
| PI_EDIT_GUARD_HINT_MIN | 0.50 | n/a | Min similarity to show the closest block as hint in no-match messages. |
| PI_EDIT_GUARD_DEBUG | OFF | =1 | NDJSON debug log written per cascade invocation. Off by default — no /tmp files unless opted in. |
| PI_EDIT_GUARD_LOG_PATH | /tmp/pi-edit-guard-<pid>.log | n/a | Where the NDJSON log goes when enabled. Snapshots go to <dirname>/<basename-without-ext>/snapshots/ (e.g. /tmp/pi-edit-guard-<pid>/snapshots/). Only consulted when PI_EDIT_GUARD_DEBUG=1. |
| PI_EDIT_GUARD_LOG_SNAPSHOTS | OFF | =1 | Save a verbatim copy of the file at edit time to <log-dir>/snapshots/<sha>.orig (e.g. /tmp/pi-edit-guard-<pid>/snapshots/<sha>.orig). Dedupe by sha, capped at 200 files / 100MB. Off by default — no snapshot directory is created unless opted in. |
| PI_EDIT_GUARD_TRUST_FORMATTER | unset | =0 | Opt-in trust mode: skip autofix, pass newText verbatim to native edit. Designed for projects that run an external formatter (pi-autoformat, biome, prettier, etc.) alongside this extension. The cascade still validates; ambiguous/fuzzy/no-match still block. Also registered as --trust-formatter CLI flag for discoverability. Redundant when an auto-format config file is present (v0.12.0+); the config file is the preferred opt-in path. |
All three log flags default to OFF — no /tmp files are created unless
you opt in. Set the env var to 1, true, or yes to enable that flag.
0 / false / no is also accepted (no-op with the new defaults, but
explicit).
Set before launching Pi:
PI_EDIT_GUARD_THRESHOLD=0.85 pi
PI_EDIT_GUARD_EXAMPLES=5 PI_EDIT_GUARD_HINT_MIN=0.6 pi
PI_EDIT_GUARD_DEBUG=1 pi # capture session for triageWhat it does NOT do
- It does not silently fix content differences, only indentation drift with a uniform leading-spaces shift.
- It does not suppress errors for tabs or non-uniform drift — those fall through to the existing block+report path, now with a specific decline hint.
- It does not change the
bash,write,read, or any other tool — onlyedit.
Testing
A test suite is included in tests/. It is split by module so each test runs independently and is fast:
tests/
├── _framework.ts # dependency-free assertion library
├── run.ts # test runner (imports all test modules)
├── autofix.test.ts # src/autofix.ts (delta + shift logic + decline reasons)
├── whitespace.test.ts # src/whitespace.ts
├── block.test.ts # src/block.ts
├── debug.test.ts # src/debug.ts (NDJSON logger)
├── matchers/
│ ├── literal.test.ts # src/matchers/literal.ts
│ ├── normalized.test.ts # src/matchers/normalized.ts
│ └── fuzzy.test.ts # src/matchers/fuzzy.ts
├── evaluate.test.ts # src/evaluate.ts
├── format.test.ts # src/format/* (all output formats)
├── format/
│ ├── runner.test.ts # src/format/runner.ts (fake pi.exec)
│ └── tool-result-rewriter.test.ts # src/format/tool-result-rewriter.ts
└── extension.test.ts # src/extension.ts (e2e via jiti, formatter flow)Coverage:
- Unit tests for every pure module (whitespace, block, autofix, each matcher, evaluate, format, debug)
- E2E test that loads the extension via jiti (same loader Pi uses), registers hooks, and fires events with real files
- Regression cases from v0.5.0/v0.6.0 (drift, fuzzy, ok, ambiguous)
- Autofix happy path (uniform shift) + decline paths (tabs, non-uniform, MAX_SANE_DELTA, line-count, missing)
- Decline-reason assertions per
AutofixDeclineReasonvariant - Batch semantics: atomic block when one edit can't be auto-fixed
- CRLF normalization, edge cases, max-examples limit
Run with:
pnpm test # one-shot
pnpm test:watch # watch mode
pnpm run typecheck(uses Node 22+ --experimental-strip-types, no build step required)
Project structure
pi-edit-guard/
├── index.ts # entry point (re-export from src/extension.ts)
├── src/
│ ├── extension.ts # composition root: default export with tool_call/tool_result hooks
│ ├── evaluate.ts # evaluateEdit, evaluateBatch (pure cascade)
│ ├── autofix.ts # tryAutofix — pure leading-spaces delta + shift logic + decline reasons
│ ├── mutate.ts # in-place mutation of tool result events
│ ├── config.ts # env var readers and constants
│ ├── debug.ts # opt-in NDJSON debug logger
│ ├── types.ts # shared EditEvaluation and CandidateKind types
│ ├── block.ts # BlockExcerpt type and toBlockExcerpt adapter
│ ├── whitespace.ts # stripLeadingWhitespace, normalizeText (spaces-only)
│ ├── formatter-config.ts # auto-format config: loadConfig, resolveFormatters, findFormatter
│ ├── format/
│ │ ├── index.ts # barrel re-export
│ │ ├── candidate.ts # formatCandidate: fuzzy-match + unfixable drift
│ │ ├── ambiguous.ts # formatAmbiguousMessage + formatExamples
│ │ ├── consolidated.ts # formatConsolidatedReport (atomic block output)
│ │ └── no-match.ts # formatNoMatchMessage (best-similarity hint)
│ │ ├── runner.ts # runFormatter via pi.exec (formatter subprocess)
│ │ └── tool-result-rewriter.ts # generateRewriteResult: original → formatted patch/diff
│ └── matchers/
│ ├── index.ts # barrel re-export
│ ├── literal.ts # countLineAnchoredMatches, findLineAnchoredMatches
│ ├── normalized.ts # findNormalizedMatches
│ └── fuzzy.ts # findFuzzyMatches, lineCharSimilarity, levenshteinDistance
├── tests/ # (see Testing section)
├── README.md
├── LICENSE
└── package.jsonEach module has one responsibility. To add a new matcher (e.g. AST-based), create a file in src/matchers/ and add it to the barrel. To add a new output format, add a function under src/format/. The cascade in src/evaluate.ts is the only place that knows about the order of matchers.
Compatibility
- Pi: 0.84+
- Node: 22+ (uses native
--experimental-strip-typesfor tests,node:fs/promisesfor runtime) - TypeScript: source-only (Pi loads via jiti, no build step required)
Tested alongside
gentle-piv2.1.2 withquiet-tools.tsenabled — works correctly, custom renderer respects the mutation
Credits
The auto-format integration (v0.12.0+) is adapted from
pi-code-formatter by
losnappas, under the MIT License. The pattern compilation, config schema,
tool_result rewriting, and pi.exec formatter runner pattern were ported
from that extension. See the header comments in src/formatter-config.ts
and src/format/tool-result-rewriter.ts for attribution.
License
MIT
