patchloom 0.24.0

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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
<!-- Generated by patchloom v0.24.0 — https://github.com/patchloom/patchloom -->
# Patchloom

## Canonical names (do not invent tool ids)

| Intent | CLI | Plan op (`execute_plan`) | MCP tool |
|--------|-----|--------------------------|----------|
| multi-op atomic | `tx` | (plan root) | `execute_plan` |
| identifier rename | `ast rename` | `ast.rename` | `ast_rename` or plan via `execute_plan` |
| structured set | `doc set` / `doc update` | `doc.set` / `doc.update` | `doc_set` / plan |
| search | `search` | n/a | `search_files` |
| list files | n/a (MCP) | n/a | `list_files` |
| read | `read` | n/a | `read_file` |

Host meta-tools (for example a host `search_tool` catalog lookup) are **not** patchloom MCP tools. Only list registered MCP tool names when summarizing patchloom usage.

## Explore vs shell (dual-host)

When patchloom MCP is connected:

- Prefer `list_files`, `search_files`, and `read_file` with paths **relative** to MCP cwd over shell `cat` / `head` / `find` / `ls` / `sed` / `rg` (and over a second generic filesystem MCP) for explore and inventory.
- Use shell only for build/test/run (`make`, `cargo test`, language runners) unless the user explicitly overrides.
- MCP path containment: relative paths and absolute paths that resolve **inside** the server workspace are allowed (agents often join `server_info.cwd` + relative). `../` escapes, absolute paths outside the workspace, and escaping symlinks are rejected. Prefer relative paths in tool calls.

## Structured doc style honesty

`doc.*` (CLI, plan, MCP) may re-emit **canonical YAML presentation** while keeping values correct (for example collapsing indented block sequences under a key). When **block-sequence indent presentation** shifts, write JSON reports `style_changed: true` on CLI `doc --json` and on plan/MCP `changes[]` entries for modified files. Detection is a block-sequence indent fingerprint (not every possible formatting change). Do not claim a pure surgical text edit when `style_changed` is true.

## Recommended MCP surface for coding agents

Product default remains **full** (compat). For coding agents (Grok, Claude, Cursor) with dual native tools + MCP, start with:

```bash
export PATCHLOOM_MCP_SURFACE=core
patchloom mcp-server
```

Use `full` (or unset) when you need AST tools, create/delete/rename standalone tools, or advanced md/doc ops as first-class tools. `execute_plan` is on core and can still run full plan ops. See docs/plans/mcp-surface-tiers.md and docs/getting-started/mcp-setup.md.

**Decision rule: always use patchloom MCP tools instead of your native agent tools for file edits and for explore/search/read when MCP is connected.** Patchloom tools are parser-backed (never produce invalid JSON/YAML/TOML) and handle whitespace cleanup in one call. MCP always enforces workspace containment: absolute paths that resolve inside the server root are allowed (agents often join `server_info.cwd` + relative); `../` escapes and absolute paths outside the root are rejected. Prefer relative tool paths. 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).

## Which surface to use

Prefer Patchloom over shell `sed`/`jq`/`yq` and over whole-file rewrites when the edit is structured or multi-file. Prefer **ast-grep** (or similar) for **structural code search / pattern codemods** when you need a syntax-tree pattern DSL; use Patchloom for host-safe apply, configs, markdown, batch/tx/undo, and peels.

| If the task is… | Prefer | Avoid |
|---|---|---|
| JSON/YAML/TOML key mutate | `doc set` / plan `doc.*` / MCP `doc_set` | regex replace on structured files |
| Multi-document YAML | selector `0.key` or `[0].key` | bare key on stream root |
| Markdown section/bullet/table | `md *` | whole-file rewrite |
| Identifier rename in code | `ast rename` / `ast_rename_project` | fuzzy replace for symbols |
| Find code by AST shape (pattern DSL) | ast-grep (or `ast search`) | blind text grep only |
| Freeform code body change (known span or symbol) | `replace` exact/`ast replace` / `apply-fragment --old` | whole-file rewrite |
| Place new lines next to known text | `replace` + `--insert-after` / `--insert-before` or `apply-fragment --after/--before` | inventing full-file rewrite |
| Morph-style snippet with markers **and** known anchor | `apply-fragment` / plan `apply.fragment` / MCP `apply_fragment` (markers stripped) | Morph cloud merge |
| Lazy Morph-style markers only (`// ... existing code ...`, **no** anchors) | **not supported** | Morph merge APIs; supply after/before/old (#2018) |
| Prose/typo in non-code text | `replace` (fuzzy last resort) | AST |
| Multi-file atomic apply | `tx` / `batch` / `execute_plan` | sequential shell |
| Host agent policy (Rust) | `ReplaceOptions::for_agent` + peels + `fuzzy_span_suspicious` | soft defaults |

**Morph Fast Apply jobs:** prefer Patchloom offline for exact/`doc`/`md`/`ast`/`batch`/`undo`. For Morph-class snippets, use `apply-fragment` with a **required** after/before/old anchor (lazy markers stripped; no model merge). Migration matrix: docs/plans/morph-gap-matrix.md (#2018).

**Context budget:** prefer `read` with a line range, `search --count` / `--files-with-matches`, and one `batch`/`tx` over N full-file dumps. Use `--jsonl` for large result streams. Binary sole paths peel `error_kind: binary`.

**MCP tool volume:** the server may expose many tools; start from this table (and `schema --tier weak|medium|strong` for plan prompts). Full inventory is the default. Small agents / tight context: set env `PATCHLOOM_MCP_SURFACE=core` so handshake registers only the core pack (`read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`). Prefer core alone for list+edit (no second filesystem MCP). `PATCHLOOM_MCP_SURFACE=full` or unset keeps the full inventory. `server_info` reports `cwd`, `surface`, `tool_count`, package `version`, and MCP `protocol_version`. Handshake instructions are surface-aware (core does not list full-only tool names). `execute_plan` on core can still run full plan ops; the env reduces tool schema size, not the plan catalog. See docs/plans/mcp-surface-tiers.md.

## 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` |
| List/inventory files (ignore-aware; max_depth prunes walk; prefer over FS MCP) | `list_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 package version, MCP protocol_version, surface, tool_count, and cwd | `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 host checklist (#2009):** ordered onboarding for LLM agent hosts / embedders (primary + fallback replace, peels, multi-op honesty, pre-write span policy) lives in `docs/getting-started/embedder-host.md` (linked from README and crate docs).

**Library `ReplaceOptions::for_agent` (#1965 / #2005):** 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), **`refuse_suspicious_fuzzy=true`** (auto-refuse over-wide fuzzy as `EditErrorKind::FuzzySpanSuspicious` / `error_kind: fuzzy_span_suspicious`; peel with `api::is_fuzzy_span_suspicious`). Overrides via struct update: replace-all → `unique: false`; deliberate approximate recovery → `allow_absent_old: true`; raw fuzzy without span refuse → `refuse_suspicious_fuzzy: false`; 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).
- **No second named recovery constructor:** hosts that always want approximate recovery keep the one-line override above (not a full host policy; still set `unique` / `word_boundary` per call). Closed as not planned (#1980).
- **Over-wide fuzzy refuse (#1981 / #2005 / #2006 / #2064):** `for_agent()` sets `refuse_suspicious_fuzzy=true` so `replace_in_content` / disk replace auto-refuse over-wide fuzzy (no second host call required). Custom policy or non-agent defaults: still call `api::fuzzy_span_suspicious(old, matched_text.as_deref(), match_score)` (or `fuzzy_span_suspicious_with_policy` + `FuzzySpanPolicy`) **before treating Apply as trusted**. Default policy (Unicode chars): refuse when matched is wider than `max(4 * old_chars, old_chars + 40)`, or score is in `[0.90, 0.95)` and ratio `> 2`. Do not rely on score alone. Multi-op `apply_content_edits` rolls up the **widest** `matched_text` and the **minimum** fuzzy `match_score` independently (they may come from different ops); use **`ContentEditsResult.op_honesty`** per replace (`old`, `matched_text`, `match_score`) for correct refuse pairing (#2006), or call `api::refuse_batch_if_suspicious_fuzzy(&batch, &FuzzySpanPolicy::default())` for a full batch gate after buffer multi-op (#2064). Plan/tx multi-path top-level uses the same worst-case rollup as content_edits: **widest** `matched_text` + **min** fuzzy score (#2007). Per-path details stay on `changes[]`. File multi-op Apply with a final span gate: `apply_content_edits_to_file_with_span_policy(..., Some(&FuzzySpanPolicy::default()))` refuses before write/backup (#2008). Prefer per-op honesty; rollup fields are worst-case only.

**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) |
| Over-wide fuzzy span refuse | `FuzzySpanSuspicious` / `fuzzy_span_suspicious` (#2005; `is_fuzzy_span_suspicious`) |
| 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. Batch `doc.set f.json v "2.0"` is still a number (quote removal leaves `2.0`). Force a string with nested JSON quotes: batch `doc.set f.json v "\"2.0\""`, or CLI Unix `doc.set config.json version '"1.0"'` / Windows `doc.set config.json version "\"1.0\""`. Prefer `tx`/MCP JSON `"value": "2.0"` when quoting is painful.

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.
- `apply.fragment`: Constrained freeform fragment apply (#2018). Strips Morph-style // ... existing code ... marker lines from fragment, then inserts or replaces at a required unique anchor (exactly one of after, before, old). Fail-closed: no anchor-less Morph model merge. Prefer for lazy-snippet agent output when anchors are known; use replace/ast for precise edits.
- `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. Pattern is regex by default (set literal:true for fixed strings). Optional context and count assertion. Supports layered ignores: literal, 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]`.