patchloom 0.15.1

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
<!-- Generated by patchloom v0.15.1 — 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).

**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, pass `--contain` (with `--cwd`) so operation targets and meta-input files cannot escape the workspace.

**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 duplicate headings | `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}`

**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)
- 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`
- Project rename: `api::ast_rename_project(root, old, new, &opts, guard)` (#1689)
- Fuzzy tip: bare-identifier typos use token span matching; prefer `min_fuzzy_score` (e.g. 0.80) for agent hosts; always check `matched_text` (#1687, #1694, #1736)
**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), not pattern `no_matches`. Fix the path list; do not widen the replace pattern (#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` means write may already be on disk.
**`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.
**`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` templates:** Valid placeholders are `{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`).
             **Binary paths (#1813):** Sole explicit binary replace is `invalid_input`. Multi-file lists put binary co-paths in `refused[]` with `reason: binary` (not pattern `no_matches`).
**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`.

**Preview vs apply (#1808, #1810, #1812):** CLI write JSON always includes `applied` (`true` after apply mode, `false` for default preview / check mode). `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.
**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. Restore with `patchloom undo --apply` (optional `--session <timestamp>`). List sessions with `patchloom undo --list`. There is no `--latest` flag.

## 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: `--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 | path first | `ast rename PATH --old X --new Y`; plan `ast.rename` uses `path`/`old`/`new`. |
| Schema capability filter | `weak` / `medium` / `strong` | `schema --tier` only accepts these (not `small`/`large`). |

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).

### 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
patchloom ast rename src/lib.rs --old OldName --new NewName --apply

# Replace text only within a specific function body
patchloom ast replace src/config.rs --symbol default_timeout --old 30 --new 60 --apply

# List all symbol definitions
patchloom ast list src/lib.rs

# Find all references to a symbol
patchloom ast refs src/ --name my_function
```

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. Writes keep the multi-doc form (still `---` separators, not a single YAML sequence).

**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`, `--contain` path rejections / empty paths, 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), `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 `write_applied: true`, `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, line endings, and final newline in a file.
- `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 the root of a document.
- `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.
- `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). 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 duplicate markdown headings in a file.
- `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]`.