# 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

```bash
pi install npm:@lucascardozo/pi-edit-guard
```

## Quick 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:

```bash
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 snapshots
```

Once 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:

```bash
# 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 project
```

If 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+)](#auto-format-opt-in-v0120)
for the schema and [Common formatter recipes](#common-formatter-recipes)
for copy-paste configs.

The simplest setup — use `prettier` on everything:

```bash
mkdir -p .pi/extensions/pi-edit-guard
cat > .pi/extensions/pi-edit-guard/config.json <<'JSON'
{
  "commands": { "prettier": ["npx", "prettier", "--write"] },
  "filetypes": { "*": "prettier" }
}
JSON
```

Restart 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:

```bash
# 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 pi
```

There'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:

1. 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
2. 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
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:

- `oldText` is replaced with the file's verbatim block (the cascade already guarantees this matches).
- `newText` is 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-autoformat` alongside), set `PI_EDIT_GUARD_TRUST_FORMATTER=1`; the guard only validates, then passes `newText` verbatim 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` — `oldText` or `newText` is empty/missing.
- `line-count-mismatch` — `oldText` has different line count than the matched file block.
- `tab-in-oldtext` — `oldText` uses tabs (spaces-only file assumption).
- `tab-in-newtext` — any line of `newText` has 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` — `oldText` already matches (cascade would have returned `ok-literal` anyway).
- `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`: mutates `event.input.edits[i].oldText` and `.newText` in place when auto-fix applies. The Pi runtime applies mutations across handlers; native edit then runs with the corrected arguments.
- `tool_result`: mutates `event.content` in place so custom renderers (e.g. `gentle-pi`'s `quiet-tools`) pick up the enriched message. `isError` is set to `false` so renderers like `quiet-tools` collapse long output via their `COLLAPSED_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/edit-guard.json` (since v0.15.0; was `~/.pi/agent/extensions/pi-edit-guard/config.json`)
- **Project:** `.pi/extensions/pi-edit-guard/config.json`

Schema (same as `pi-code-formatter`):

```json
{
  "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/edit-guard.json` (global) or
`.pi/extensions/pi-edit-guard/config.json` (project overrides global).

**Prettier only** (TypeScript / JavaScript / CSS / Markdown / HTML):

```json
{
  "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):

```json
{
  "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):

```json
{
  "commands": {
    "biome": ["npx", "@biomejs/biome", "format", "--write"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx,json}": "biome",
    "*": "biome"
  }
}
```

**Deno fmt** (Deno projects):

```json
{
  "commands": {
    "denofmt": ["deno", "fmt", "--quiet"]
  },
  "filetypes": {
    "*.{ts,tsx,js,jsx,json,md}": "denofmt",
    "*": "denofmt"
  }
}
```

**Python (Ruff)** — fast linter + formatter:

```json
{
  "commands": {
    "ruff": ["ruff", "format"]
  },
  "filetypes": {
    "*.py": "ruff",
    "*": "ruff"
  }
}
```

**Python (Black)**:

```json
{
  "commands": {
    "black": ["black", "--quiet"]
  },
  "filetypes": {
    "*.py": "black",
    "*": "black"
  }
}
```

**Go (goimports)** — runs gofmt internally plus import sorting::

```json
{
  "commands": {
    "goimports": ["goimports", "-w"]
  },
  "filetypes": {
    "*.go": "goimports"
  }
}
```

**Mixed polyglot project** (different tools per file type, wildcard for
anything else):

```json
{
  "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 slashes
- `literal` — 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:

1. **In `tool_call`:** autofix is skipped (`matchedFormatter` propagates
   into `processEditInput`). The guard still validates that there IS a
   match — ambiguous/fuzzy/no-match still block with the consolidated
   report. The model's `newText` passes through verbatim.
2. **In `tool_result` (success only):** the formatter runs via `pi.exec`
   (5-second timeout, `--` separator + absolute path). If the formatter
   changes the file, `details.{patch, diff, firstChangedLine}` are
   rewritten so the model sees `original → 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:

```bash
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
pi
```

When 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

```bash
PI_EDIT_GUARD_LOG_PATH=./edit-guard.log pi
```

Snapshots 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

1. Reproduce the issue with debug logging enabled: `PI_EDIT_GUARD_DEBUG=1 pi` (or `+LOG_FULL=1+LOG_SNAPSHOTS=1` for richer triage).
2. `cat <log-path> | jq .` (or use any NDJSON viewer).
3. Filter by `source` to see what the extension intercepted (`tool_call`) vs what came back from native (`tool_result`). On `tool_result` with `nativeError`, you'll see exactly what the model saw.
4. If `snapshotPath` is set, `cat <snapshotPath>` shows the file at edit time.
5. Compare `oldTextLeadingSpaces` vs 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:

```bash
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 triage
```

## What 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 — only `edit`.

## Troubleshooting

### "I configured `prettier` but the model still gets drift"

`pi-edit-guard` v0.14.0+ probes each configured formatter at session start
and filters out binaries that are not on PATH. If the formatter binary is
missing, you'll see a one-time warning per session:

```
[pi-edit-guard] "prettier" no está en PATH; usando autofix propio
```

The cascade then runs in **default mode** (autofix applies the
leading-space delta to both `oldText` and `newText`) instead of leaving
the file with raw `newText` drift. This is the same behavior as when no
formatter is configured at all. Install the missing binary or set
`PATH` so the extension can find it, then `/reload` the session.

Under the hood: `binaryAvailable(bin)` runs `which <bin>` on POSIX or
`where <bin>` on Windows, with the bin name as a single argv entry (no
shell, no injection vector). Any probe error (sandbox block, probe
binary missing) is conservative — it returns `false` and the formatter
is treated as not available.

## 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 `AutofixDeclineReason` variant
- Batch semantics: atomic block when one edit can't be auto-fixed
- CRLF normalization, edge cases, max-examples limit

Run with:

```bash
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.json
```

Each 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-types` for tests, `node:fs/promises` for runtime)
- **TypeScript**: source-only (Pi loads via jiti, no build step required)

## Tested alongside

- `gentle-pi` v2.1.2 with `quiet-tools.ts` enabled — works correctly, custom renderer respects the mutation

## Credits

The auto-format integration (v0.12.0+) is adapted from
[pi-code-formatter](https://github.com/losnappas/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
