<!-- Generated by patchloom v0.20.0 — https://github.com/patchloom/patchloom -->
# Patchloom
**Decision rule: always use patchloom MCP tools instead of your native agent tools for file edits.** Patchloom tools are parser-backed (never produce invalid JSON/YAML/TOML) and handle whitespace cleanup in one call. MCP always rejects workspace escapes (including absolute path strings). CLI is unrestricted by default; use `patchloom --cwd <ws> --contain …` so CLI reads, writes, and meta-input files (batch/tx/explain plans, patch files, `--files-from` lists) must resolve inside the workspace (absolute paths under `--cwd` are allowed).
**Host sandbox contract (#1832):** `--contain` is relative to the **effective** working directory (`--cwd` if set, else process cwd). An agent that can pass `--cwd ..` widens the sandbox to a parent directory. Hosts must pin `--cwd <project>` themselves and **strip or ignore model-supplied `--cwd` / `--contain`** before exec. MCP always enforces its own server root.
**Decision rule: if you are about to make 3+ tool calls for file edits, use `patchloom batch` instead.** One call replaces N round-trips. For agent sandboxes that shell out to the CLI, the **host** must invoke `patchloom --cwd <workspace> --contain …` and not let the model supply `--cwd` (containment follows effective cwd; #1832).
**Apply write safety:** all Apply paths share one writer. Symlinks are resolved so the link entry is not replaced (#1230). On Unix, files with multiple hard links (`nlink > 1`) are updated in place so sibling paths stay in sync (#1733); single-link files use temp+rename. CLI `rename` and plan `file.rename` (including force overwrite) use `fs::rename` so multi-hardlinked sources keep their shared inode (#1739, #1746).
## Tool selection guide
| Task pattern | Tool to use |
|---|---|
| Read file contents (with optional line range) | `read_file` |
| See uncommitted changes vs git HEAD (omits `.patchloom/` backups) | `git_status` |
| Set/get a value in JSON, YAML, or TOML | `doc_set`, `doc_get`, `doc_query` |
| Delete, merge, or ensure a value exists | `doc_delete`, `doc_merge`, `doc_ensure` |
| Compare two structured files | `doc_diff` |
| Edit markdown section, bullet, or table | `md_replace_section`, `md_upsert_bullet`, `md_table_append` |
| Insert text after/before a heading | `md_insert_after_heading`, `md_insert_before_heading` |
| Insert a sibling section after a full section body | `md_insert_after_section` |
| Move a heading section (same file or cross-file) | `md_move_section` |
| Remove later whole sections with a duplicate heading | `md_dedupe_headings` |
| Lint markdown for structural issues | `md_lint` |
| Fix trailing whitespace or missing newlines | `fix_whitespace` (one file) or `batch_tidy` (multiple files) |
| Create, append, prepend, rename, or delete a file | `create_file`, `append_file`, `prepend_file`, `move_file`, `delete_file` |
| Find/replace text in a file | `replace_text` (one file) or `batch_replace` (same replacement across multiple files) |
| Search across files | `search_files` |
| Apply a unified diff patch | `apply_patch` |
| List/read/rename symbols (AST-aware) | `ast_list`, `ast_read`, `ast_rename`, `ast_replace`, `ast_rewrite_signature` |
| Insert, wrap, or manage imports | `ast_insert`, `ast_wrap`, `ast_imports` |
| Reorder, group, or move symbols | `ast_reorder`, `ast_group`, `ast_move` |
| Extract or split files by symbol | `ast_extract_to_file`, `ast_split` |
| Validate syntax, find refs, or analyze impact | `ast_validate`, `ast_refs`, `ast_impact`, `ast_search` |
| Repo map, imports, or structural diff | `ast_map`, `ast_deps`, `ast_diff` |
| Apply same operation to many files | `execute_plan` with `for_each` glob |
| Get server version and working directory | `server_info` |
**`replace_text` / plan replace flags (default false):**
- `require_change`: error when the pattern matches zero times (fail closed).
- `command_position`: rewrite only shell invocable tokens (`sudo`/`timeout`/`flock`/`runuser`/`setsid`/`run0`/`gosu`/`su-exec`/`tini`/`dumb-init`/`unshare`/`nsenter`/`taskset`/`systemd-run`/`firejail`/`busybox`/`chpst`/`softlimit`/`envdir`/`setlock` wrappers yes; `uv pip` no).
- `fuzzy`: similarity fallback when exact match fails (also with before_context/after_context).
- `min_fuzzy_score`: reject fuzzy matches below this floor (0.0..=1.0); exact/anchored unaffected (#1687).
- `allow_absent_old`: only then apply fuzzy when exact `old` is not in the file (#1758). Default false (fail closed).
Example: `{"path":"install.sh","old":"pip","new":"uv","command_position":true,"require_change":true}`
**Library `ReplaceOptions::for_agent` (#1965):** Rust hosts with primary + fallback replace paths should call `ReplaceOptions::for_agent()` in **both** places (not hand-copy `ReplaceOptions { ... }` twice). Preset: `unique=true`, `require_change=true`, `fuzzy=true`, `min_fuzzy_score=Some(AGENT_MIN_FUZZY_SCORE)` (`0.90`), **`allow_absent_old=false`** (fail closed). Overrides via struct update: replace-all → `unique: false`; deliberate approximate recovery → `allow_absent_old: true`; word-boundary rename → `fuzzy: false`, `min_fuzzy_score: None`, `word_boundary: true`. `command_position` cannot combine with fuzzy/word_boundary/regex/whole_line (typed `invalid_input`). This is **not** a host-specific recovery policy; approximate rewrite of missing `old` stays an explicit opt-in.
**Fuzzy defaults fail closed when exact old is absent (#1758):**
- If `old` is not present, fuzzy will **not** rewrite a nearby live span by default, even when score ≥ `min_fuzzy_score`. JSON error explains the best candidate; set `allow_absent_old=true` only for deliberate approximate recovery.
- Prefer `ast_rename` / `ast_rename_project` for code identifiers. Fuzzy is a last resort for typos in non-AST text (prose, comments), not a general rename tool.
- When you opt into `allow_absent_old`, still check JSON `matched_text` before treating the edit as semantic success (#1736).
**Library embedder undo / post-write (Rust hosts, not CLI-only):**
- After `ApplyMode::Apply`, `EditResult.backup_session` is the session id for that write (#1686).
- `backup::restore_path_from_latest_backup(project_root, path)` — latest session that contains the path
- `backup::restore_path_from_session(project_root, timestamp, path)` — one path from a chosen session (#1660)
- `backup::list_sessions_under(root, &ListSessionsOptions { descendants: true, .. })` — nested monorepo sessions (#1688)
- `backup::find_backup_roots(path)` — walk path and parents for roots that own `.patchloom/backups` (nearest first; #1934)
- File create/delete/rename/append and `ast_rewrite_signature` peel via `edit_error_kind` / `error_kind_str` (#1935 / #1936 / #1947 / #1948):
| Condition | `EditErrorKind` / CLI string |
|-----------|-----------------------------|
| Create/rename dest exists without force | `AlreadyExists` / `already_exists` (not `InvalidInput`; use force/overwrite) |
| Missing path I/O | `NotFound` / `not_found` |
| Directory target / empty path / unreadable IO | `InvalidInput` / `invalid_input` |
| Binary (NUL probe) | `Binary` / `binary` (#1963) |
| Invalid UTF-8 text | `InvalidEncoding` / `invalid_encoding` (#1963) |
| Force create over binary/unreadable prior | succeeds with empty original (#1962) |
| PathGuard / `--contain` escape | `GuardRejected` / `guard_rejected` |
| Patch merge conflict markers | `Conflicts` / `conflicts` (not batch `ConflictingEdit`) |
| Check/assert-count exit-2 soft fail | `ChangesDetected` / `changes_detected` |
| AST missing symbol | `NoMatch` / `no_matches` |
- Bool peels (match `edit_error_kind`): `api::is_already_exists`, `is_not_found`, `is_conflicts`, `is_changes_detected`, `is_type_error`, `is_format_failed`, `is_guard_rejected`, `is_invalid_input`, `is_binary`, `is_invalid_encoding`, `is_load_text_strict_fail` (binary|encoding|invalid_input), `is_no_match`, `is_ambiguous`; string peel: `api::error_kind_str` / one-shot `api::peel_error` (kind + message + suggestion) for CLI-stable envelopes (#1947 / #1948 / #1963 / #1964).
- New `EditErrorKind` variants must be **appended** (after the last variant) so discriminants of published kinds stay stable under cargo-semver-checks (#1955).
- CLI: `patchloom undo --list` walks nested `.patchloom/backups` under the cwd (#1695). Bare CLI undo is dry-run (exit 2); restore needs the write apply flag (see CLI agent-rules).
- `api::run_post_write_validation` / `ReplaceOptions.post_write` / `WritePolicyOptions.post_write` (#1663, #1690) maps to `format_failed` / `EditErrorKind::FormatFailed`
- Multi-doc / wrong-root doc navigation peels to `EditErrorKind::TypeError` via `edit_error_kind` / `classify_error` (CLI JSON `error_kind: type_error`; #1883). Do not collapse with `InvalidInput` (empty patterns, bad options) or `Binary` / `InvalidEncoding` for content SoftSkip (#1963).
- Multi-doc `api::doc_merge(path, value, mode, guard, selector)`: pass `Some("0")` (or `"[0]"`) to merge into document 0; `None` is root-only and refuses a non-array overlay on multi-doc root with `TypeError` (#1909).
- Path binary preflight: `api::is_binary_file` / `files::is_binary_file` (8 KiB NUL probe; open fail → false; #1884)
- Sole-path text load for hosts: `api::load_text(path)` or `files::load_text_strict` (#1894, #1910)
- `EditErrorKind` is `#[non_exhaustive]`; match with a wildcard arm (#1910)
- Text I/O honesty (#1894):
| Surface | Binary / invalid UTF-8 | Unreadable (IO) |
|---------|------------------------|-----------------|
| Sole explicit path (`load_text_strict` / `sole_explicit_non_text`) | `binary` / `invalid_encoding` / `invalid_input` | IO / `not_found` |
| Explicit multi-file list | `refused[]` reason `binary` or `invalid_utf8` | `refused[]` reason `unreadable` |
| Directory walk (`try_read_text_file`) | content SoftSkip (silent) | SoftSkip; empty scan must not report pattern `no_matches` if unreadable may have masked it (AST rename) |
| Tx multi-path probe (`read_and_probe`) | SoftSkip `Ok(false)` | Hard `Err` (plan names paths) |
Byte rule: `classify_text_bytes`. Patch file + patch targets use Strict (#1896).
- Project rename: `api::ast_rename_project(root, old, new, &opts, guard)` (#1689)
- Fuzzy tip: bare-identifier typos use token span matching. Library agent hosts: `ReplaceOptions::for_agent()` uses `AGENT_MIN_FUZZY_SCORE` (0.90). CLI examples may use 0.80 as a looser floor. Always check `matched_text` (#1687, #1694, #1736, #1965)
**Match reporting in JSON:** CLI `replace --json`, MCP `replace_text` / `batch_replace` / `execute_plan`, and library `EditResult` report `match_mode` (`exact`/`fuzzy`/`anchored`), optional `match_score`, optional `matched_text` (actual span for fuzzy/anchored; may differ from `old`), and replace `match_count` (plan/tx also on each change + sum) so agents can verify fuzzy sites. Multi-file / multi-op aggregates use worst-case rollup (`fuzzy` > `anchored` > `exact`) and the **minimum** fuzzy `match_score` across paths/ops (lowest confidence). Soft no-match / fuzzy fail-closed refuse sets `ok: false` and `error_kind: no_matches` on CLI, plan/tx, and MCP (body and `is_error` agree; #1791). When some paths write and others soft-refuse or soft-miss, overall ok/success may still be true: check `refused[]` (path, match_mode, match_score, matched_text, reason=`exact_old_absent`, `below_min_fuzzy_score`, or `no_matches` for exact soft miss on explicit multi-path lists; #1792) so partial apply is not mistaken for full coverage. Soft no-match CLI JSON may include `similar_targets` (did-you-mean) for literal patterns (#1669, #1674, #1736, #1747).
**Missing paths CLI vs MCP (#1793):** CLI multi-path `replace` soft-skips missing explicit paths under `skipped[]` and still applies the rest. Plan/batch path ops hard-fail missing files with `not_found` and roll back (atomic) unless `if_exists` / `--if-exists` is set, which soft-skips the missing path like CLI. Soft zero-match on existing paths still applies matches and lists `refused[]` with overall success. Under `--json`/`--jsonl`, CLI does not also print a human stderr "No such file" line for paths already in `skipped[]` (#1797).
**Empty `--files-from`:** An empty list file or empty stdin (`--files-from -`) is `error_kind: invalid_input` (exit 1) for **search**, **replace**, and **tidy** (not pattern `no_matches`, and not tidy `ok:true` with zero issues). Fix the path list; do not widen the pattern or treat the workspace as clean (#1796).
**`--files-from` comments (#1811):** Lines whose first non-whitespace character is `#` are comments and are ignored (gitignore-style). Blank lines are ignored. Do not emit markdown headers as path lines.
**Do not branch on `ok` alone (#1804):** Prefer `applied: true` (CLI write JSON) or MCP/tx `files_changed > 0` with `status: success` for "edit landed." Soft refuse / no match uses `error_kind`/`status: no_matches` (and `applied: false` / `files_changed: 0`). Partial multi-path: read `refused[]` / `skipped[]`. Format failure: `error_kind: format_failed` with `applied: true` means the write already landed on disk (also `write_applied` for one release; prefer `applied`; #1831).
**`applied: false` with no write:** When a write was requested but no bytes change (doc ensure/set identity, empty append/prepend, identity replace), JSON still sets `applied: false` and omits `backup_session`. Do not treat success alone as a write. Pre-write hard failures (`already_exists`, `not_found`, `invalid_input`, `guard_rejected`, `parse_error`, `type_error`, `conflicts`, `ambiguous`, `no_matches`, `changes_detected`) also set `applied: false` so agents can branch on `applied` alone.
**Batch `replace` order:** Batch lines are `replace PATH OLD NEW` (not CLI `replace OLD --new NEW path`). CLI flag `--new` (and plan-shaped `--from`/`--to`) is rejected with a PATH OLD NEW hint; path-last positionals also fail with a parse hint when the third token is an existing file.
**`identity: true` (#1801):** Replace JSON when the pattern matched but every replacement was identical (`old == new`); `match_count > 0` but `file_count: 0` / empty `files` and no write.
**`require_change` + `if_exists` (#1800):** When both are set, `if_exists` wins: zero matches → success (`ok: true`, `match_count: 0`), not fail-closed `no_matches`.
**`tx`/`execute_plan` `strict: false` (#1803):** Continues past soft replace `no_matches` on existing paths. Does **not** continue past hard errors such as `not_found` (missing path). Optional **files** must be omitted from the plan or ensured to exist; optional **content** can use soft miss.
**`for_each` (plan-level field, not an op; #1842):** Fan-out is a top-level plan field with required `glob`. Example:
```json
{
"for_each": { "glob": "**/*.txt" },
"operations": [
{ "op": "replace", "path": "{path}", "old": "x", "new": "y" }
]
}
```
Placeholders: `{path}`, `{item}` (alias of `{path}`), `{dir}`, `{stem}`, `{ext}`, `{name}`. Unknown lone `{name}` in path/from/to fields fails with `invalid_input` (not opaque `not_found`). Do not put `for_each` inside `operations` and do not pass a bare path array.
**Binary / non-text / unreadable paths (#1813 / SoftTextSkip / #1963):** Sole explicit **binary** path is `error_kind: binary`; sole **invalid UTF-8** is `invalid_encoding`; sole **unreadable** IO is `invalid_input` (not pattern `no_matches` / not vacuous tidy clean / not unsupported-language / not missing-symbol). This includes a **sole path supplied only via file-backed `--files-from`** (not stdin `-`). Multi-file **replace**, **search**, and **tidy** lists (positionals or `--files-from`) put non-text co-paths in `refused[]` with `reason: binary`, `invalid_utf8`, or `unreadable` (do not treat a partial success as covering every listed path). Multi-file read reports binary paths under `skipped[]` with top-level `error_kind: binary` (or `invalid_encoding` when all failures are encoding). Directory walks soft-skip content SoftSkip (binary/utf8) without failing the whole search. If a walk finds **zero** matches/issues and **any** path was unreadable, the result is `invalid_input` (not pattern `no_matches` / not vacuous tidy clean / not `assert-count` success with actual 0 / not AST “no symbols”), because IO may have masked the scan.
**Search max_results:** CLI/MCP/tx `search` with `max_results` keeps full `match_count` (and full `file_count`) but may cap the detailed `matches` array **and** the `files` list in `--count` / `--files-with-matches` modes; when capped, JSON sets `truncated: true` (omitted when complete; #1798). Under `--jsonl`, capped content searches append a final `{"type":"summary","match_count":N,"match_emitted":M,"truncated":true}` line so agents still see the total. Do not treat `matches.len()` or `files.len()` as total coverage when `truncated` is true.
**Multi-result `--json`:** Commands that emit many items (`ast list` / `ast search` / `ast validate` / `ast deps`, `ast map`, undo list, etc.) print one JSON array under `--json` (single `json.loads` on full stdout) and one object per line under `--jsonl`. Do not expect concatenated pretty multi-documents for `--json`.
**Diagnostic envelopes:** `md lint-agents --json` and MCP `md_lint` emit a single object `{ok, path, issue_count, issues}` (not a bare array). `tidy check --json` is the multi-file sibling: `{ok, issue_count, issues}` with `path` on each issue (optional `skipped[]`), not a top-level path. When issues exist: `ok: false`, exit 2, and `error_kind`/`status` `changes_detected` (same kind as search `--assert-count` mismatch). Branch on `error_kind` or `issue_count`. MCP `md_lint` keeps isError=false when issues are present (same as clean file success with `ok: true`). CLI JSONL stays one issue object per line. (#1854, #1859)
**Preview vs apply (#1808, #1810, #1812):** CLI write JSON always includes `applied` (`true` after apply mode, `false` for default preview / check mode). Plan/batch/tx JSON also includes `applied` (same meaning; pair with `status`: `success` vs `changes_detected`). `changed: true` or `files_changed: N` on preview means **would** change, not that bytes were written. Exit 2 = changes detected / dry-run.
**`backup_session` on success (#1802):** Successful CLI replace/doc/tx apply JSON includes `backup_session` when a backup was created (same field as `format_failed` and library `EditResult`). Use it for surgical undo; do not guess newest session under parallel agents.
**CLI vs plan/MCP names (#1809):** CLI replace is positional `OLD` + `--new NEW` (not plan aliases `from`/`to` as flags). CLI `doc set` is `doc set PATH SELECTOR VALUE` (not a `--key` flag). Plan/MCP accept legacy aliases `from`/`to` and `key` for selector.
**CLI `md upsert-bullet`:** use `--bullet` (or alias `--content`; #1839) with `--heading`.
**Doc query `--json` (#1838):** `doc get`/`has`/`keys`/`len`/`select`/`flatten` success is `{"ok":true,"value":...,"path":...,"selector":...}` (selector omitted for flatten). Text mode stays bare. `doc has` prints `true`/`false` and exits **0** for both (missing key is not `no_matches`; #1843).
**Plan/batch `tidy.fix` defaults (#1840, #1847):** Omitting write-policy fields matches CLI `tidy fix` (trim trailing whitespace + ensure final newline). Precedence: defaults → plan `write_policy` → op fields. Op fields stick through commit (plan `write_policy` is not re-applied to that path); a later non-tidy write clears that. Bare example: `{"op":"tidy.fix","path":"f.txt"}`.
**Replace jsonl multi-file (#1799):** Streams one object per success path (`status: ok`), refused soft-miss (`status: refused`), skipped missing (`status: skipped`), then a `type: summary` trailer with counts. Prefer this or MCP `batch_replace` over assuming success lines are the full path list.
Use patchloom when:
- Editing JSON, YAML, or TOML (parser-backed, preserves comments, output is always valid)
- Editing markdown sections, bullets, or tables by heading
- Batching edits across multiple files in one call
- You need atomic rollback if any edit fails
**CLI undo (dry-run by default):** `patchloom undo` only previews the most recent backup and exits 2 with `applied: false` and `status: changes_detected`. Restore with `patchloom undo --apply` (optional `--session <timestamp>`), which sets `applied: true` and `status: restored` (#1830). List sessions with `patchloom undo --list`. There is no `--latest` flag.
**Replace mode flags (#1829):** CLI replacement text is `--new` (not positional NEW and not `--to`). Missing mode errors name `--new` / `--insert-before` / `--insert-after` first; plan/MCP still accept field aliases `new`/`to`.
**Insert line placement (#1885):** `--insert-before` / `--insert-after` (and plan/MCP `insert_before` / `insert_after`) are line-oriented by default: payloads that look like a new line (indent, `//`/`#` comment, embedded newline) or a whole-line anchor get a separating newline so agents do not glue text onto the match. Pure mid-line inserts (e.g. `X` after `foo` inside a line) stay byte-exact.
Example (anchor is positional OLD; insert text is on the flag; path last; never `replace ANCHOR NEW path`):
`patchloom replace 'use std::io;' --insert-after 'use std::fs;' src/main.rs --apply`
## MCP mode
**ALWAYS use MCP tools for ALL file edits.**
- For any multi-op or multi-file change, **use `execute_plan`** with an inline plan (or plan_path). It provides atomic execution + rollback, exactly like the CLI `tx` command.
- Use `batch_replace` or `batch_tidy` only when applying the *exact same* operation to multiple files.
- Do **not** issue parallel write calls against the same path(s) — per-call success does not guarantee a coherent combined result.
- Nested trees: set a **relative** `cwd` under the server workspace and keep op paths short. Do not use absolute `cwd` on MCP. Do not combine `cwd` with `for_each`.
- `search_files`: canonical multi-root field is `paths` (array). Singular `path` is accepted as an alias for one root.
Example (edit under a fixture without prefixing every path):
```json
{
"plan": {
"version": 1,
"cwd": "fixtures/svc",
"operations": [
{"op": "doc.set", "path": "configs/app.yaml", "selector": "name", "value": "updated"}
]
}
}
```
That re-roots to `fixtures/svc/configs/app.yaml` (not a same-named file at the workspace root).
## Canonical parameter names
Use these names in plans, MCP args, and CLI flags (do not invent alternates):
| Concept | Canonical name | Notes |
|---------|----------------|-------|
| Text/identifier before | `old` | CLI **replace**: positional `OLD` (not `--old`). CLI **ast rename/replace**: `--old`. Plans/MCP: `"old"`. |
| Text/identifier after | `new` | CLI: `--new`. Plans/MCP: `"new"`. |
| Doc path into a document | `selector` | CLI positional. Plans/MCP: `"selector"`. |
| AST rename / replace / read | path first | `ast rename PATH --old X --new Y`; `ast replace PATH SYMBOL --old … --new …` (no `--symbol` flag). |
| AST refs / impact | symbol first | `ast refs SYMBOL PATH` / `ast impact SYMBOL PATH` (no `--name` flag; #1841). |
| Schema capability filter | `weak` / `medium` / `strong` | `schema --tier` only accepts these (not `small`/`large`). |
Replace example: `patchloom replace OLD --new NEW path` (positional OLD + `--new`; never `replace --old …`).
Insert example: `patchloom replace ANCHOR --insert-after 'new line' path` (or `--insert-before`; insert text is never a second positional).
Some plan/MCP fields still **accept** legacy aliases (`from`/`to` for replace, `key` for doc selector, `ops` for the plan `operations` array) so older agent prompts keep working, but examples and new plans must use the canonical names above.
## Batching (the main speed win)
Six file edits via native tools = six round-trips. One `batch` call = one round-trip:
```bash
patchloom batch --apply <<'EOF'
doc.set config.json version "2.0.0"
doc.set config.yaml app.version "2.0.0"
replace README.md "1.0.0" "2.0.0"
replace src/main.rs "proccess" "process" --fuzzy --min-fuzzy-score 0.80
file.create hello.txt "Hello, World!"
file.rename old.txt new.txt
md.upsert_bullet CHANGELOG.md "## Changes" "- Bumped to 2.0.0"
EOF
```
One line per operation. Double-quote values with spaces. Unquoted JSON objects/arrays (`file.create f.json {"x":1}`) keep inner quotes. In `file.create`/`append`/`prepend` content, `\n` `\t` `\r` `\\` `\"` expand (multi-line content on one batch line).
Batch `replace` accepts optional flags after path/old/new: `--fuzzy`, `--min-fuzzy-score`, `--word-boundary`/`-w`, `--command-position`, `--require-change`, `-i`/`--case-insensitive`, `--if-exists`. Advanced options (regex, context, nth) need a `tx` plan.
On Windows (where heredocs are not available), write operations to a file and pass it:
```bash
patchloom batch ops.txt --apply
```
**Note:** Values are parsed as JSON first. An unquoted `1.0` is parsed as a number. To force a string, wrap in JSON quotes. Unix-like shells (bash/zsh): `doc.set config.json version '"1.0"'`. Windows (`cmd.exe`/PowerShell): `doc.set config.json version "\"1.0\""`.
For complex plans needing format/validate lifecycle, regex replace, or `--nth`, use `tx` with JSON:
```bash
patchloom tx plan.json --apply
```
Relative paths for batch ops files, `tx`/`explain` plan files, `patch` files, and `--files-from` lists resolve under `--cwd` (same base as operation targets). Absolute meta-input paths are unchanged. With `--contain`, those meta-input paths (and operation targets) must resolve inside the workspace: absolute paths under `--cwd` are allowed; `../` escapes and absolute paths outside the workspace are rejected.
In plan JSON, doc ops use the field name `selector` (not `key`). `key` is accepted as an alias if a model emits it, but prefer `selector`.
## Structured edits
```bash
# Edit a value in JSON/YAML/TOML by selector path (parser-backed, preserves comments)
patchloom doc set config.json version '"2.0.0"' --apply
patchloom doc merge config.yaml --value '{"db":{"pool":10}}' --apply
# Append a row to a markdown table
patchloom md table-append README.md --heading "## API" --row "| new | row |" --apply
```
On Windows, use double-quote escaping:
```bash
patchloom doc set config.json version "\"2.0.0\"" --apply
patchloom doc merge config.yaml --value "{\"db\":{\"pool\":10}}" --apply
patchloom md table-append README.md --heading "## API" --row "| new | row |" --apply
```
Add `--apply` to all write commands. Without it, patchloom previews changes without writing.
## Project configuration
Create `.patchloom.toml` in the project root to set defaults for all commands:
```toml
[write_policy]
ensure_final_newline = true
normalize_eol = "lf"
trim_trailing_whitespace = true
collapse_blanks = true
[tx]
strict = false
[exclude]
globs = ["target/**", "node_modules/**"]
```
CLI flags override config values. The file is searched upward from the working directory.
## Workflow examples
### Rename a function across a codebase
```bash
# Prefer AST-aware rename for code identifiers (skips strings/comments)
patchloom ast rename src/ --old old_function_name --new new_function_name --apply
# Fallback: text-based workflow when AST mode is unavailable
patchloom search --count "old_function_name" src/
patchloom replace "old_function_name" --new "new_function_name" src/ --apply
```
Default: `replace --fuzzy` with a misspelled `old` that is **not** in the file refuses the write (#1758). Opt in only with `--allow-absent-old` for deliberate approximate recovery, then check `matched_text` (#1736).
### Insert a line after an anchor
```bash
# OLD is the preserved anchor; payload goes on --insert-after (not positional NEW)
patchloom replace 'use std::io;' --insert-after 'use std::fs;' src/main.rs --apply
# Same idea before a match:
patchloom replace 'fn main() {' --insert-before '// entry point' src/main.rs --apply
```
Whole-line anchors and line-like payloads get a separating newline by default (#1885).
### Delete lines matching a pattern
```bash
# Delete entire lines containing a pattern; collapse consecutive blanks
patchloom replace 'dbg!' --whole-line --new '' src/ --collapse-blanks --apply
# Restrict to a line range (e.g. implementation only, skip tests)
patchloom replace 'TODO' --whole-line --range 10:200 --new '' notes.md --apply
# Rewrite shell invocable tokens only (not uv pip / pipenv):
patchloom replace pip --new uv install.sh --command-position --require-change --apply
patchloom replace 'fn proccess_data' --new 'fn process_data' src/ --fuzzy --allow-absent-old --apply
patchloom replace 'fn proccess_data' --new 'fn process_data' src/ --fuzzy --allow-absent-old --min-fuzzy-score 0.80 --apply
```
### Edit a CI workflow
```bash
# Set a value in a YAML workflow by selector path (preserves comments and formatting)
patchloom doc set .github/workflows/ci.yml jobs.test.timeout-minutes 30 --apply
```
### Stale patch recovery via three-way merge
```bash
# Apply a patch using three-way merge when context has drifted
patchloom patch apply changes.patch --on-stale merge --apply
# Or use the merge subcommand directly
patchloom patch merge changes.patch --apply
# If merge produces conflicts, allow them to be written as markers
# WARNING: never commit files containing conflict markers
patchloom patch merge changes.patch --apply --allow-conflicts
# Diff from stdin (--stdin or a bare '-' path both work)
cat changes.patch | patchloom patch apply --stdin --apply
cat changes.patch | patchloom patch apply - --apply
```
```bash
# Same via transaction plan (JSON):
patchloom tx - --apply <<'EOF'
{"version": 1, "operations": [
{"op": "patch.apply", "diff": "...", "on_stale": "merge", "allow_conflicts": true}
]}
EOF
```
### Bump a version across config files
```bash
patchloom batch --apply <<'EOF'
doc.set package.json version "2.0.0"
doc.set Cargo.toml package.version "2.0.0"
replace README.md "1.0.0" "2.0.0"
md.upsert_bullet CHANGELOG.md "## [2.0.0]" "- Initial 2.0 release"
EOF
```
### Multi-file refactoring with a transaction
```bash
patchloom tx - --apply <<'EOF'
{"version": 1, "operations": [
{"op": "replace", "path": "src/config.rs", "old": "old_default", "new": "new_default"},
{"op": "doc.set", "path": "config.toml", "selector": "default_value", "value": "new_default"},
{"op": "md.replace_section", "path": "docs/config.md", "heading": "## Defaults",
"content": "The default value is now `new_default`.\n"}
]}
EOF
```
All operations succeed atomically or roll back together.
Plan/MCP `replace` accepts library flags (default false):
- `require_change`: fail when the pattern matches zero times (agent fail-closed).
- `command_position`: rewrite only shell invocable tokens (`sudo`/`timeout`/`flock`/`runuser`/`setsid`/`run0`/`gosu`/`su-exec`/`tini`/`dumb-init`/`unshare`/`nsenter`/`taskset`/`systemd-run`/`firejail`/`busybox`/`chpst`/`softlimit`/`envdir`/`setlock` wrappers yes; `uv pip` no).
- `fuzzy`: similarity fallback when exact match fails (also with before_context/after_context).
Example: `{"op":"replace","path":"install.sh","old":"pip","new":"uv","command_position":true,"require_change":true}`
Successful plan/tx and `batch_replace` JSON includes `match_mode` (`exact`/`fuzzy`/`anchored`), optional `matched_text` for fuzzy/anchored spans, and `match_count` on replace-backed changes plus worst-case aggregate mode and sum of counts when any replace matched (#1674, #1736). Partial soft-refuses list `refused[]` with reason (`exact_old_absent` / `below_min_fuzzy_score` / `no_matches`); do not treat ok/success alone as full multi-path coverage. Tx search with `max_results` may set `truncated: true` on search results when the matches array was capped.
## AST-aware operations
Tree-sitter-backed operations that understand code structure (20 languages).
```bash
# Rename an identifier across a file, skipping strings and comments
# (path first, then --old / --new)
patchloom ast rename src/lib.rs --old OldName --new NewName --apply
# Replace text only within a specific function body
# (PATH then SYMBOL are positionals — no --symbol flag; #1841)
patchloom ast replace src/config.rs default_timeout --old 30 --new 60 --apply
# List all symbol definitions
patchloom ast list src/lib.rs
# Find all references to a symbol
# (SYMBOL then PATH — order differs from replace/read; #1841)
patchloom ast refs my_function src/
# impact is also SYMBOL then PATH: ast impact my_function src/
```
**AST CLI arg order:** `rename`/`replace`/`read`/`validate` use path-first; `refs`/`impact` use symbol-first. There is no `--symbol` or `--name` flag on these commands.
AST rename, replace, and rewrite_signature can also be used in batch and tx plans:
```bash
# In a batch file (path, then old, then new — same names as CLI flags):
ast.rename src/lib.rs OldStruct NewStruct
ast.replace src/config.rs default_timeout "30" "60"
# path old parameters [return_type]:
ast.rewrite_signature src/lib.rs process "(x: u64)" "-> u64"
```
## Selector path syntax
All `doc` operations use selector paths to address values inside JSON, YAML, and TOML files.
| Syntax | Meaning | Example |
|--------|---------|---------|
| `name` | Object key | `database.host` |
| `[N]` | Array index (zero-based) | `servers[0].port` |
| `[*]` | Wildcard (all array elements) | `jobs[*].timeout` |
| `[key=val]` | Predicate (filter by field value) | `deps[name=express].version` |
Segments are separated by `.` or adjacent brackets. A single leading `/` is treated as "from root" and stripped (JSON Pointer habit), so `/feature_flag` sets key `feature_flag`, not a key literally named `/feature_flag` (#1794). Only one leading slash is special; `//a` keeps a key named `/a`. Prefer bare keys in prompts (`feature_flag`, `server.port`). Examples:
```text
scripts.test # simple selector path
/feature_flag # same as feature_flag (leading slash stripped)
jobs[0].steps[*].name # index + wildcard
dependencies[name=react].version # predicate filter
```
**Write ops and predicates:** `doc set` / `doc ensure` / `doc delete` / `doc move` are **single-path only** (keys and indexes such as `items.0.val`). Wildcards and predicates (`items[id=b].val`, `items[*].enabled`) belong on `doc update` (multi-match write) or `doc delete-where` (array filter). If you pass a predicate to `doc set`, the error points you at `doc update` or an index path.
**Multi-document YAML:** Files with multiple `---` documents parse as a **top-level array** (one element per document). Address a field with a document index first, e.g. `0.metadata.name` or `[0].metadata.name`, not a bare key at the stream root. A bare root key on multi-doc (or any top-level array) fails with `error_kind: type_error` and an index-form hint on `doc get`/`select`/`has` and `doc set` (not soft `no_matches` / not soft `has: false`). `doc keys .` on multi-doc root is also `type_error` (list keys on `0` / `[0]` first). `doc merge` of any overlay into multi-doc **root** is `type_error` (object *or* array overlay would replace the whole stream). Merge into one document with `--selector 0` / plan `"selector": "0"` (or `doc.set` under `0.`). Bare-key `doc append`/`prepend`/`delete`/`update`/`move`/`ensure` on multi-doc root are also `type_error` with `0.key` / `[0].key` hints (not soft no-match or `invalid_input`). Address fields under `0.` / `[0].` instead. Writes keep the multi-doc form (still `---` separators, not a single YAML sequence).
**Markdown section bounds:** `md_replace_section` / `md_insert_after_section` / section moves end at the next heading of the **same or higher** level. Nested lower-level headings belong to the parent (replacing `# Intro` also rewrites following `##` children until the next `#`). Prefer peer-level headings when siblings must survive. `md_dedupe_headings` removes later **whole sections** with a duplicate level+text heading (body under the second heading is discarded, not merged).
**Markdown insert placement:** `md_insert_after_heading` inserts **under the heading line** (before existing body). To add a sibling `##` section after the full section body, use `md_insert_after_section`.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Success (operation completed, or no changes needed) |
| 1 | Failure (error during execution, CLI usage/invalid args/unknown flags/subcommands), or tx `rollback_failed` when mid-commit rollback could not fully restore files |
| 2 | Changes detected (`--check` / write preview including `undo` without `--apply`, or CLI/MCP/plan/tx `search` `assert_count` mismatch; `error_kind: changes_detected` for assert_count). For undo, re-run with `--apply` to restore. |
| 3 | No matches (search/replace pattern miss, undo with no sessions, missing AST symbol for extract/insert/reorder/wrap/move, or tx/plan AST/md/doc target not found; `error_kind: no_matches`) |
| 4 | Parse error (malformed input file or plan, invalid AST search pattern/query, or AST validate parse failure; `error_kind: parse_error`) |
| 5 | Ambiguous (CLI/tx `unique` multi-match, or stale/missing patch context; `error_kind: ambiguous` in CLI/tx JSON) |
| 6 | Validation failed (tx plan validation step returned non-zero; writes may remain when not strict) |
| 7 | Rollback (tx mid-commit failure or strict lifecycle failure; changes were rolled back) |
| 8 | Patch merge conflicts (CLI `patch merge` or plan/tx `patch.apply` with `on_stale: merge` without `allow_conflicts`; `error_kind: conflicts`) |
| 9 | Tx operation staging failure (`operation_failed`) |
**JSON `error_kind` (exit 1):** Prefer branching on kind, not English text. File ops set `already_exists` (create/rename without force, create race), `not_found` (delete/append/prepend/rename missing source, missing `--files-from`/batch input, missing plan file, missing git blob for AST, or `read` when every path fails), `invalid_input` (bad flags, non-file target, malformed `read --lines` (0-based or end before start; past-EOF range is `no_matches` exit 3), replace `--nth` past the last match with live match count in the message, `status` outside a git repo, AST map non-dir, doc merge flag conflicts, invalid `normalize_eol`, md table-append row/table failures, bad selector/for_each templates, MCP bind/TLS config, CLI usage errors under `--json`/`--jsonl`, empty path arguments, all-explicit-paths-missing for search/replace/tidy, invalid search/replace regex patterns (unclosed groups, etc.), and plan op option conflicts such as replace whole_line+multiline, tidy dedent+indent, md.move_section before/after, search invert_match+multiline), `guard_rejected` (PathGuard / `--contain` path escape or plan cwd escape; #1935; not empty-path `invalid_input`), `format_failed` (post-write `--format` command non-zero exit or format-timeout; write may already be on disk; JSON includes `backup_session` when a session was created so agents can `patchloom undo --session <id>`, plus `applied: true` (canonical; #1831), `write_applied: true` (deprecated alias), `files_changed`, and `files[].path` for every path already written so agents need not re-scan disk (#1795); re-run the formatter or undo). Empty `--files-from` is `invalid_input` (not `no_matches`; #1796). Doc type mismatches set `type_error` (`doc keys`/`len` on wrong type, library doc mutation type errors). Clap usage failures with `--json`/`--jsonl` emit the same envelope on stdout before any subcommand runs.
**JSON `error_kind` (exit 4):** Batch line parse failures, `explain` plan parse failures, invalid AST search patterns/queries, AST validate parse failures, and malformed JSON/YAML/TOML document content set `parse_error` so agents can distinguish syntax mistakes from runtime failures.
**Doc write JSON tip:** With `--json` (CLI) or MCP write tools / `execute_plan`, doc delete success includes `changed` (bool) and `removed` (usize). Multi-op plans also list per-op rows under `mutations`. Exit 0 / `ok: true` with `removed: 0` means idempotent cleanup (nothing matched); do not assume data was deleted from exit status alone.
## Operations (from schema registry)
- `replace`: Replace text in a file using literal string matching. Optional require_change (fail closed on zero matches), command_position (shell invocable tokens only; peels sudo/timeout/busybox/flock/runuser/run0/gosu/unshare/nsenter/taskset/systemd-run/firejail/chpst/softlimit/envdir/setlock wrappers, not uv pip), and fuzzy (similarity fallback when exact match fails). When exact old is absent, fuzzy refuses by default even above min_fuzzy_score; set allow_absent_old for deliberate approximate recovery (#1758). Prefer ast.rename for identifiers.
- `file.append`: Append content to an existing file.
- `file.prepend`: Prepend content to an existing file.
- `file.create`: Create a new file with specified content.
- `file.delete`: Delete a file.
- `file.rename`: Rename (move) a file.
- `tidy.fix`: Normalize whitespace in a file. When op fields are omitted, defaults match CLI tidy fix (trim trailing whitespace + ensure final newline; normalize_eol stays keep). Precedence: defaults → plan write_policy → op fields. Plan write_policy is not re-applied at commit for paths last written by tidy.fix so op fields stick (#1840, #1847).
- `doc.set`: Set a value at a selector path in a JSON, YAML, or TOML file. Parser-backed; output is always valid.
- `doc.delete`: Delete a value at a selector path in a JSON, YAML, or TOML file. CLI --json and MCP/tx success include changed and removed (0 on missing key; exit 0 / ok is idempotent).
- `doc.merge`: Deep-merge a JSON object into a document root, or into a selector path (e.g. multi-doc YAML `0` / `[0]`).
- `doc.append`: Append a value to an array at a selector path.
- `doc.move`: Move a value from one selector path to another within the same file.
- `doc.ensure`: Set a value only if the selector path does not already exist.
- `md.replace_section`: Replace the body of a markdown section identified by heading (section ends at the next same-or-higher-level heading; nested lower-level headings are included).
- `md.upsert_bullet`: Insert or update a bullet point under a markdown heading.
- `md.table_append`: Append a row to a markdown table under a heading.
- `md.insert_after_heading`: Insert content immediately after a markdown heading line (before any existing body). For a sibling section after the full body, use md.insert_after_section.
- `md.insert_after_section`: Insert content after the full section body (sibling placement). Use when adding a new ## section after this section's content. Prefer md.insert_after_heading for content under the heading line.
- `md.insert_before_heading`: Insert content immediately before a markdown heading line.
- `md.move_section`: Move a heading section to a new position (same-file reorder or cross-file move). The moved range ends at the next same-or-higher-level heading (nested lower headings included). Exactly one of before or after is required.
- `patch.apply`: Apply a unified diff patch to one or more files. Supports three-way merge on stale context.
- `doc.prepend`: Prepend a value to the beginning of an array at a selector path.
- `doc.update`: Set a new value at every location matching a selector. Use wildcards (items[*].enabled) or selector predicates (items[name=foo].v). Not a separate --where flag; the filter is part of the selector string.
- `doc.delete_where`: Delete array elements matching a key=value predicate via --predicate (CLI) or the predicate field (plans). For scalar arrays use .=x, _=x, or value=x. Different from doc.update, which filters inside the selector path. CLI --json and MCP/tx success include changed and removed (0 when no elements match; exit 0 / ok is idempotent).
- `search`: Search for text across files with optional regex, context, and count assertion. Supports advanced layered ignores: literal (vs regex), globs (include), exclude_patterns, custom_ignore_filenames, max_results, before_context/after_context.
- `read`: Read file contents with optional line range.
- `md.dedupe_headings`: Remove later whole sections whose heading text+level already appeared (heading and body until next same-or-higher heading; unique second-section content is discarded).
- `md.lint_agents`: Lint an AGENTS.md file for common issues.
- `ast.rename`: AST-aware rename: rename identifiers skipping strings and comments.
- `ast.replace`: Replace text within a specific symbol's body (AST-scoped).
- `ast.rewrite_signature`: Rewrite a function signature with structured fields (visibility, parameters, return_type) or a full new_signature string. Multi-language via tree-sitter.
- `ast.insert`: Insert code at a structurally-aware position: inside a container, or after/before a named symbol.
- `ast.wrap`: Wrap existing code in a structural block (module, impl, cfg, etc.).
- `ast.imports`: Manage import/use statements: add (idempotent), remove, deduplicate.
- `ast.reorder`: Reorder symbols within a file or scope by name, kind, or custom order.
- `ast.group`: Group symbols into a named module within a file.
- `ast.move`: Move symbols between files with optional target creation.
- `ast.extract_to_file`: Extract a symbol to a separate file with optional module unwrapping.
- `ast.split`: Split a file into multiple target files by distributing symbols.
## Troubleshooting
If a command produces unexpected results, enable verbose logging to see what patchloom is doing internally:
```bash
patchloom --verbose <command> [args]
# or via environment variable:
PATCHLOOM_LOG=1 patchloom <command> [args]
```
Diagnostic messages are printed to stderr prefixed with `[patchloom]`.