git:20261009.2c03dcb to git:20261009.113be1e

7 added, 7 removed. Audit A to A.

# src/core/track-changes — `TrackChanges` lens, accept/reject machinery, tracked-range shapes
Five files behind the `@core/track-changes` barrel ([index.tsx](index.tsx)), plus two imported directly by their consumers (`core/edit`, and the view readers):
- [index.tsx](index.tsx) — the `TrackChanges` lens + small free helpers (`resolveAuthor`, `resolveDate`, `convertTextToDelText`)
- [apply.ts](apply.ts) — accept/reject state machine: `previewTrackedChanges`, `applyTrackedChanges`, `applyTrackedDecisions` (the mixed accept+reject pass behind `track-changes apply` — both lists resolved against the pre-mutation tree, applied in one reverse-preorder traversal via the shared `applyResolvedTargets` core), `collectTrackedChanges`, the `actionFor` dispatch table, body-side note pairing, table-grid resync
- [emit.tsx](emit.tsx) — leaf primitives: `<Ins meta>`, `<Del meta>`, `markParagraphMarkAs(paragraph, kind, meta)` (drops a self-closing ins/del marker into `<w:pPr><w:rPr>`)
- [replace.tsx](replace.tsx) — range edit/delete shapes empirically validated against Word: `applyTrackedRangeReplace`, `applyTrackedRangeDelete`, `applyUntrackedRangeReplace`, `applyUntrackedRangeDelete`, the shared `assertParagraphOnlyTrackedRange` guard + `TrackedRangeConflictError`, `applyFormattingPreservingEdit` (the word-level diff for `edit --text`), and `removeParagraphLine` — the cell-safe single-paragraph removal shared by `docx delete --at pN` and `docx edit --at pN --text ""` (a `<w:tc>`'s last paragraph is blanked, not deleted, so we never emit an invalid empty `<w:tc/>`)
- [preserve-formatting.tsx](preserve-formatting.tsx) — the LCS-based word-level diff that drives `applyFormattingPreservingEdit`: `extractOldTokens`, `tokenize`, `diffTokens`, `buildTrackedRuns`, `buildUntrackedRuns`
- [pending-authors.ts](pending-authors.ts) — `watchPendingRevisions`, the before/after snapshot `Edit.paragraph`/`Edit.range` use to report the other authors whose pending revisions a content rebuild resolved (issue #17; not re-exported by the barrel)
- [revision-visibility.ts](revision-visibility.ts) — `isRevisionVisible(change, view)`, the ONE rule for whether a run shows in `current`/`accepted`/`baseline` (not re-exported by the barrel; `core/ast/text.ts`, `core/ast/document/body.ts`, `core/find/index.ts` and `cli/read/markdown.ts` import it directly). See "Nested revisions" below.
## Nested revisions — the all-ancestors visibility rule
- Word nests one author's `<w:del>` inside another author's still-pending `<w:ins>` (`<w:ins A><w:r>…</w:r><w:del B><w:r><w:delText>…</w:delText></w:r></w:del></w:ins>`), and the mirror (`<w:ins>` inside `<w:del>`). The reader keeps the run's `trackedChange` as the INNERMOST wrapper (its `tcN` is what accept/reject targets) and records the enclosing chain in `TrackedChange.within` (see [ast/CLAUDE.md](../ast/CLAUDE.md)). **A run is visible in a view iff EVERY enclosing wrapper is visible in that view** — `accepted` hides `del`/`moveFrom`, `baseline` hides `ins`/`moveTo`, `current` shows all — because rejecting the outer insertion removes the nested deletion with it, and accepting the outer deletion removes the nested insertion. That is exactly what the reverse-pre-order `accept --all`/`reject --all` produces, so `read --baseline` ≡ `reject --all` and `read --accepted` ≡ `accept --all` (and `wc`/`find` agree). Classifying by the innermost wrapper alone showed the nested deletion's text in `--baseline`. The XML-side LENGTH/PLACEMENT walkers (`sumVisibleTextLength`/`isWrapperVisibleInView` in [find/replace-span.tsx](../find/replace-span.tsx), [find/replace-across.tsx](../find/replace-across.tsx) and [comments/markers.tsx](../comments/markers.tsx)) never descend a wrapper hidden in the view, so a wrapper nested inside a hidden one contributes nothing — the same rule, structurally; keep it that way. (Known gap, separate from views: `splitWrapperAcrossSpan` in replace-span.tsx does not recurse into a wrapper nested inside the wrapper it splits — a `replace` span that crosses a nested `<w:ins>`/`<w:hyperlink>` inside a `<w:ins>` slices at the wrong offsets.)
+ Word nests one author's `<w:del>` inside another author's still-pending `<w:ins>` (`<w:ins A><w:r>…</w:r><w:del B><w:r><w:delText>…</w:delText></w:r></w:del></w:ins>`), and the mirror (`<w:ins>` inside `<w:del>`). The reader keeps the run's `trackedChange` as the INNERMOST wrapper (its `tcN` is what accept/reject targets) and records the enclosing chain in `TrackedChange.within` (see [ast/CLAUDE.md](../ast/CLAUDE.md)). **A run is visible in a view iff EVERY enclosing wrapper is visible in that view** — `accepted` hides `del`/`moveFrom`, `baseline` hides `ins`/`moveTo`, `current` shows all — because rejecting the outer insertion removes the nested deletion with it, and accepting the outer deletion removes the nested insertion. That is exactly what the reverse-pre-order `accept --all`/`reject --all` produces, so `read --baseline` ≡ `reject --all` and `read --accepted` ≡ `accept --all` (and `wc`/`find` agree). Classifying by the innermost wrapper alone showed the nested deletion's text in `--baseline`. The XML-side LENGTH/PLACEMENT walkers (`sumVisibleTextLength`/`isWrapperVisibleInView` in [find/replace-span.tsx](../find/replace-span.tsx), [find/replace-across.tsx](../find/replace-across.tsx) and [comments/markers.tsx](../comments/markers.tsx)) never descend a wrapper hidden in the view, so a wrapper nested inside a hidden one contributes nothing — the same rule, structurally; keep it that way. (Untracked, `rebuildAcrossBoundaries` in replace-span.tsx never splits a revision wrapper the span crosses (issue #16): an additive one is cut IN PLACE by recursing the same walker into its content — so a nested `<w:hyperlink>`/field/revision is sliced at the right offsets, the replacement lands where the span starts (inside the ins, credited to its author, as for a match wholly inside it), and no `w:id` is duplicated; a subtractive one stays whole. Known gap, tracked only: `splitWrapperAcrossSpan` does not recurse into a wrapper nested inside the wrapper it splits, so a `--track` span that crosses a nested `<w:hyperlink>`/`<w:ins>` inside a `<w:ins>` still slices at the wrong offsets — its post-half does get a FRESH id from the allocator.)
## TrackChanges lens API
`new TrackChanges(document).…`:
- `mintMeta(authorFlag?)` — one-shot `TrackedMeta`. For multi-revision operations call `createAllocator()` instead.
- `createAllocator()` — `{ next(): number }` seeded from `computeMaxRevisionId` (scans document.xml + footnotes.xml + endnotes.xml so a new id can't collide with one already in a note body).
- - `setEnabled(on)` — toggles `<w:trackRevisions/>` in settings.xml; turning on materializes the part.
+ - `setEnabled(on)` — toggles `<w:trackChanges/>` in settings.xml; turning on materializes the part.
- `list()` — `collectTrackedChanges(document)`; reads `document.trackedChangeReferences` (the reader's map), never re-walks.
- `preview(target, verb)` — accept/reject preview for `--dry-run`. Throws `TrackedChangeNotFoundError` on unknown id.
- `accept(target)` / `reject(target)` — apply; returns `ChangeRecord[]`. Caller saves.
- `apply(accepts, rejects)` — the `track-changes apply` finalize: accept one handle list AND reject another in ONE pre-mutation-resolved pass (no inter-call renumbering, no half-finalized file). Returns the combined `ChangeRecord[]`. Caller saves.
- `applyInsertion(paragraph, authorFlag?)` / `applyDeletion(paragraph, authorFlag?)` — wrap a freshly-built paragraph's trackable children (`w:r`, `m:oMath`, `m:oMathPara`) in `<w:ins>` / `<w:del>` and mark its paragraph break. Used by `Insert.paragraph` and `delete --at pN` under tracking. Non-trackable siblings (e.g. `<w:pPr>`) pass through at their existing positions via `wrapContiguousTrackable`.
- `applyContentInsertion(paragraph, authorFlag?)` — `applyInsertion` WITHOUT the paragraph-mark ins. Used when bare-cell insert reuses Word's mandatory empty paragraph: reject removes the new content but preserves the valid `<w:p>` container.
- `applyContentDeletion(paragraph, authorFlag?)` — `applyDeletion` WITHOUT the paragraph-mark del: wraps content in `<w:del>` but keeps the `<w:p>`. Used by `removeParagraphLine` (replace.tsx) for a table cell's last paragraph, where accept-all must leave a valid empty paragraph rather than merge the cell away.
## The reader is the single source of `tcN` ids
`document.trackedChangeReferences` (a `Map<string, TrackedChangeReference>` on Document) is populated **exclusively** by [core/ast/read.ts](../ast/read.ts) in one walk over the body, then `readNoteTrackedChanges` appends standalone note-body revisions. `apply.ts::collectTrackedChanges` reconstructs `ChangeFound` records from the map (via `changeFoundFromReference`) — it never re-walks. So `list` and `accept`/`reject --at tcN` can't disagree on what `tcN` means.
## actionFor — the dispatch table
`actionFor(target, verb)` in [apply.ts](apply.ts) is the single source for "given a kind + accept/reject, what action does this become?" Memorize the patterns:
- **Additive vs subtractive run wrappers**: `ins` / `moveTo` are additive (accept = `unwrap`, reject = `delete`). `del` / `moveFrom` are subtractive (accept = `delete`, reject = `unwrap` after `<w:delText>` → `<w:t>` rename).
- **Paragraph-mark ins/del** (a self-closing wrapper inside `<w:pPr><w:rPr>`, recognized in `changeFoundFromReference` by `parent === paragraphRef.node…rPr.children`): accept-ins = `delete` the marker; reject-ins = drop the inserted break — but **conditionally**: a PURELY-inserted paragraph (all content is `<w:ins>`/`<w:moveTo>` — a fresh `insert`, or new paragraphs 2..N of a range replace) splices out wholesale, while a paragraph that ALSO holds reject-surviving content (a `<w:del>` carrying the original text — the *transition* paragraph of a 1→N range replace — or a plain untracked run) keeps its `<w:p>` and only strips the break marker (`paragraphHasSurvivingContent` in apply.ts). Unconditional `deleteParagraph` here silently destroyed the transition's restored original on reject-all, so every N≥2 multi-block edit lost its baseline (an empty `<w:p/>` where the paragraph had been). accept-del = `merge` with next paragraph (per ECMA-376 §17.13.5.4 — the merged paragraph adopts the **NEXT** paragraph's `<w:pPr>`, since its mark is the one that survives; keeping the deleted paragraph's pPr silently stripped a following list item's `<w:numPr>` / heading style on accept, collapsing a run of clauses into one un-numbered paragraph — see `mergeParagraphWithNext`); reject-del = `delete` the marker.
- **Property-change snapshots** (`sectPrChange`, `tblGridChange`, `tblPrChange`, `tcPrChange`): accept drops the snapshot, reject restores its children to the parent property element.
- **Table-structural** (`rowIns` / `rowDel` / `cellIns` / `cellDel`): the marker lives inside `<w:trPr>` or `<w:tcPr>`; the row/cell node is carried separately on `ChangeFound.tableRow`/`tableCell`. Accept-ins/reject-del = `stripMarker` (keep the row/cell); reject-ins/accept-del = `deleteRow` / `deleteCell` (remove the whole container).
- **`checkboxToggle`**: dispatches to `acceptCheckboxToggle` / `rejectCheckboxToggle` in [task-list/toggle.ts](../task-list/toggle.ts). Reject infers the prior `w14:checked` value from the deleted glyph — see [task-list/CLAUDE.md](../task-list/CLAUDE.md).
## Reverse pre-order traversal
`applyTrackedChanges` walks targets `[...targets].reverse()` so a nested `<w:ins>` inside another `<w:ins>` is processed *before* its parent — the inner mutation leaves the outer node's stored `parent` array intact for the outer pass. Forget this and a `<w:ins>` inside `<w:hyperlink>` inside `<w:ins>` blows up on the second pass.
## Note pairing post-pass
Footnotes/endnotes added or deleted under tracking have a body-side `<w:ins>` / `<w:del>` (in footnotes.xml / endnotes.xml) paired with a reference-side wrapper in document.xml. The reader hides the body-side from `tcN` ids so add/delete surfaces as ONE change. After applying targets, `applyNotePairing` consumes the `(kind, noteId)` pairs `collectAffectedNotes` snapshotted **before** mutation (post-mutation the `<w:del>` is gone and the link is lost):
- If no live `<w:footnoteReference>`/`<w:endnoteReference>` remains for that id → GC the entire `<w:footnote>` / `<w:endnote>` from the part. Word does the same.
- Otherwise → `normalizeNoteBody`: unwrap any remaining `<w:ins>` / `<w:del>` in the note's paragraphs (with `<w:delText>` → `<w:t>` rename on del), and strip any paragraph-mark ins/del markers.
`computeMaxRevisionId` scans all three parts (document + footnotes + endnotes) so allocated ids never collide with one already living in a note body.
## Table-grid resync
Cell removals (accept-cellDel / reject-cellIns) shrink rows without touching `<w:tblGrid>`. After applying targets, `resyncTableGrids` walks every `<w:tbl>` and reconciles the grid column count with the widest row, so the table model stays consistent. Standalone `tblGridChange` snapshots are handled by `restorePropertySnapshot` and don't need this pass.
## Tracked range edit / delete — Word-canonical shapes
`applyTrackedRangeReplace` (range edit) and `applyTrackedRangeDelete` (range delete) implement the exact XML shape Word produces for the equivalent operations. Both were empirically validated — see `scripts/word-redlines.sh` for the AppleScript oracle and `/tmp/range-probe/*.docx` for the probe outputs. Don't tweak the shape on a hunch; verify against a fresh probe.
The shorthand:
- **Range edit** (M old → N new paragraphs): old 1..M-1 get content `<w:del>` + paragraph-mark `<w:del>` (accept-all merges them forward); the transition (old M) has its content `<w:del>`'d, then the first new's content appended inline `<w:ins>`-wrapped, with paragraph-mark `<w:ins>` if N≥2; new 2..N each splice in as fresh `<w:p>` with content `<w:ins>` and paragraph-mark `<w:ins>` (except the trailing one, whose mark is bare).
- **Range delete** (M paragraphs): every paragraph in `[startIndex, endIndex]` gets content `<w:del>`; paragraphs `startIndex..endIndex-1` get paragraph-mark `<w:del>`. The last one's mark stays bare so accept-all leaves an empty residue paragraph (markdown render hides it).
**Re-editing an already-tracked paragraph — drop your own unaccepted insertions.** Every deletion-direction transform (`convertParagraphContentToDeleted` for the range paths, `applyDeletion` / `applyContentDeletion` for single delete) FIRST runs `dropOwnInsertions` (in [index.tsx](index.tsx)) over the paragraph's children: a top-level `<w:ins>` is content an earlier tracked edit in this same pass added but that hasn't been accepted, so re-deleting/replacing the paragraph must **un-insert** it (remove the wrapper + runs), not leave it visible. Without this, a paragraph edited twice under tracking (a single-block `--markdown` then a multi-block split — the contract-markup A-r1 shape — or an `edit` then a `delete`) kept the first edit's `<w:ins>` AND the second's, so Accept All showed the sentence twice and merged the following headings into a run-on. Dropping is correct on both sides of the review — accept and reject each make an unaccepted-then-replaced insertion vanish (reject restores the true baseline from the sibling `<w:del>`). This assumes the doc-level single-author ("Reviewer") model; a genuine cross-author prior insertion would need Word's nested del-of-ins shape, which only the span replace models (see "Tracked span replace inside another author's insertion" below), not these whole-paragraph transforms. `wrapContiguousTrackable` still passes existing `<w:del>` / `<w:hyperlink>` / `<w:moveFrom>` / `<w:fldSimple>` / `<w:smartTag>` wrappers through untouched — only additive `<w:ins>` is dropped, and only in the deletion direction (never in `applyInsertion` / `wrapNewParagraphContentAsInserted`, which build or preserve NEW insertions, e.g. CriticMarkup from `edit --markdown`).
## The shared range guard
`assertParagraphOnlyTrackedRange(rangeRef)` throws `TrackedRangeConflictError` if any block in `[startIndex, endIndex]` is non-`<w:p>` (most commonly a table). The tracked-range walkers inject `<w:pPr>` into every span block, which would corrupt `<w:tbl>`. Untracked range paths splice cleanly across any block tag, so the guard only fires when tracking is on. Used by `Edit.range` and `cli/delete/index.tsx::commitRangeDelete`; each catches the error and maps to its own error code.
- ## Word-level diff for `edit --text` (and word-only markdown)
+ ## Word-level diff for `edit --text`
- `applyFormattingPreservingEdit` is what `Edit.paragraph` falls through to when the spec is `--text` without override formatting flags and `--no-formatting` isn't set — AND when a single-paragraph markdown edit changes only words (`markdownAsTextEdit` in [core/edit/markdown-as-text-edit.ts](../edit/markdown-as-text-edit.ts): one plain paragraph, no visible emphasis/link on either side, nothing the rebuild can't carry back; skipped when ride-along `--size`/`--clear` will restyle the result, since on this path it would restyle the kept words too). Markdown is the default channel, so without that route every clause rewrite redlined the whole paragraph. **Under tracking, a paragraph that already carries pending revisions never takes the diff** (`hasPendingRevisions` in `core/edit/index.tsx`): the diff rebuilds from the VISIBLE runs, so it would unwrap a prior `<w:ins>` and drop a prior `<w:del>` — a second tracked edit erased the original wording, unrecoverable by reject. Such a paragraph takes the whole-paragraph tracked replace instead (coarser redline; per-word run formatting of kept text is not preserved). It:
+ `applyFormattingPreservingEdit` is what `Edit.paragraph` falls through to when the spec is `--text` without override formatting flags and `--no-formatting` isn't set. It:
1. **Extracts old tokens** via `extractOldTokens` — recursively walks the paragraph descending through visible-in-accepted-view run-bearing wrappers (`<w:ins>`, `<w:moveTo>`, `<w:hyperlink>`, `<w:fldSimple>`, `<w:smartTag>`; skips `<w:del>`, `<w:moveFrom>`), concatenates every visible run's text once, builds a per-character rPr index, and tokenizes the full string. This handles source docs that split words across `<w:r>` boundaries (e.g. `"me" + "ssenger"` in adjacent italic runs) — flat per-run tokenization would shred those into spurious del+ins pairs.
2. **Tokenizes the new text** identically.
3. **LCS diff** aligns new tokens against old (`diffTokens`).
- 4. **Re-emits runs** grouped by rPr: untracked path emits only kept + inserted tokens (inherited rPr from nearest matched neighbor); tracked path emits kept tokens as plain runs, inserts in `<w:ins>`, deletes in `<w:del>`. **`<w:hyperlink>` is the one wrapper that comes BACK**: each `OldToken` remembers the link it sat in (by node identity), an inserted token inherits the link of the deleted token it is position-paired with (swap the email, keep the mailto) or of BOTH neighbors when typed inside a link (never extends a link from one edge — Word's rule too), and the builders re-wrap contiguous link-bearing tokens in a clone of the link (`segmentByLink` → `wrapInLink`, attributes preserved so the rId stays referenced). The link is the OUTERMOST wrapper: `<w:hyperlink><w:del>…</w:del><w:ins>…</w:ins>` is valid, a hyperlink inside `<w:ins>` is not — so `buildTrackedRuns` segments by link first and by kind inside. A whitespace keep at a link edge is never demoted (`bordersAnotherLink`), so a swapped link word lands as ONE `<w:hyperlink><w:del/><w:ins/></w:hyperlink>` — one `revN`. When an edit group mixes link and non-link text, the delete-first order can still give the link one twin around its `<w:del>` and another around its `<w:ins>`; accept/reject empties one, and `pruneEmptyHyperlinks` (apply.ts post-pass) removes it. Retyping the résumé contact line used to drop the mailto even with the email unchanged. Pure pPr properties (`--style`, `--alignment`) apply in place via `applyParagraphOptionsInPlace`.
+ 4. **Re-emits runs** grouped by rPr: untracked path emits only kept + inserted tokens (inherited rPr from nearest matched neighbor); tracked path emits kept tokens as plain runs, inserts in `<w:ins>`, deletes in `<w:del>`. Pure pPr properties (`--style`, `--alignment`) apply in place via `applyParagraphOptionsInPlace`.
- **Other authors' pending revisions are resolved, not kept (issue #17, interim).** The diff rebuilds runs from the visible text under the single-author model, so another author's `<w:ins>` becomes plain text (or, on the `--markdown` path, is dropped) and their `<w:del>` vanishes. Until the rebuild keeps them, the CLI warns on stderr (exit 0) whenever a content edit — whole-paragraph `text`/`markdown`/`runs` (single or `--batch`), a `pN-pM` range replace, or `code edit` — resolves pending revisions by an author other than the editor, and points at span edits (`replace`, `edit --at pN:S-E`), which keep them. `Edit.paragraph` returns the authors as `resolvedAuthors` (`Edit.range` returns the list): `watchPendingRevisions` ([pending-authors.ts](pending-authors.ts)) snapshots the other authors' `<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>`/`<w:pPrChange>` BEFORE the rebuild and reports the ones missing from the edited region AFTER it. Don't replace that with a prediction of what each path keeps — the `--text` diff carries text boxes and inline content controls through and keeps the pPr, while the markdown/runs replace keeps the box (lifted and reattached) but drops any pPrChange and, under tracking, keeps an existing `<w:del>` but drops every `<w:ins>`; only the before/after diff is right for all of them.
+ **Other authors' pending revisions are resolved, not kept (issue #17, interim).** The diff rebuilds runs from the visible text under the single-author model, so another author's `<w:ins>` becomes plain text (or, on the `--markdown` path, is dropped) and their `<w:del>` vanishes. Until the rebuild keeps them, the CLI warns on stderr (exit 0) whenever a content edit — whole-paragraph `text`/`markdown`/`runs` (single or `--batch`), a `pN-pM` range replace, or `code edit` — resolves pending revisions by an author other than the editor, and points at span edits (`replace`, `edit --at pN:S-E`), which keep them. `Edit.paragraph` returns the authors as `resolvedAuthors` (`Edit.range` returns the list): `watchPendingRevisions` ([pending-authors.ts](pending-authors.ts)) snapshots the other authors' `<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>`/`<w:pPrChange>` BEFORE the rebuild and reports the ones missing from the edited region AFTER it. Don't replace that with a prediction of what each path keeps — the `--text` diff carries text boxes and inline content controls through and keeps the pPr, while the markdown/runs replace drops the box and any pPrChange and, under tracking, keeps an existing `<w:del>` but drops every `<w:ins>`; only the before/after diff is right for all of them.
rPr equality is structural (XML-string compare on cloned nodes). The codebase's emitter produces canonical output so this works in practice; non-canonical rPr from an external producer might leave equivalent rPr blocks unmerged (each token gets its own run). Acceptable.
## Tracked span replace inside another author's insertion (issue #13)
- `replaceSpanInParagraph` (the engine behind `replace --track` and `edit --at pN:S-E --track`) has ONE early branch, dispatching to [core/find/replace-in-revision.tsx](../find/replace-in-revision.tsx). **The gate**: the replace is tracked, the span is non-empty, every overlapping run shares the same immediate parent, and that parent is a `<w:ins>` / `<w:moveTo>` sitting DIRECTLY under the paragraph. Everything else — untracked, empty spans, a span crossing the wrapper's edge, a wrapper nested in a hyperlink/smartTag/field, a match inside a `<w:del>` under `--current`/`--baseline` — stays on the general walkers, byte-for-byte as before. Inside the gate, `w:author` decides (exact string match): **the wrapper is split the way Word does it** — it keeps its `w:id` plus the text before the span and the editor's `<w:del>` of the cut nested inside it (a del-of-ins, so rejecting either author's revision restores the right text), the replacement rides the editor's own paragraph-level `<w:ins>`, and the content after the span rides a copy of the wrapper with a FRESH id from the shared allocator — unless that tail is pure markers (comment range end + reference run, bookmark end), which stay in the wrapper after the cut instead of minting an empty revision (`isPureMarker`). **The editor's own `<w:ins>` is rebuilt in place** (same id, same date, no `<w:del>`, no split) — the replacement is just more of that same insertion — but every ZERO-width child of the cut runs (note/comment reference, drawing, text-box anchor — `inlineMarkerWidth === 0`; a tab/break/sym the offsets counted as a character goes with the text) is kept (`cutRemnants`), range starts ahead of the replacement and the rest behind it, so nothing anchored on the replaced words vanishes and a comment that opened inside the cut brackets the new words. A `<w:moveTo>` is split even by its own author: moved text is relocated original text, not new words. `revN` pairing in `cli/track-changes/groups.ts` requires both halves to share an author, so the other author's head ins never pairs with the editor's nested del. The new path computes the wrapper's start offset from the siblings before it, where the general `rebuildContainer` starts its walk at the first OVERLAPPING run's offset — correct only when the match sits in the wrapper's first run, which is the "deleted the wrong words" half of the bug (the nested `<w:del>` in the issue is just what put the match in a later run; the same miscount still applies to an untracked multi-run wrapper and to hyperlinks, both deliberately untouched here). Nested non-run children (a third author's `<w:del>`, bookmarks) move WHOLE, counted at their view-visible length (a `<w:del>` is 0 wide in the accepted view), and are never wrapped in the new `<w:del>`: one inside the span keeps its place, so the cut is deleted as one `<w:del>` per contiguous run group. Known limitations, all deliberately left on the old paths: an insertion nested in a hyperlink still gets a bare run inside it; an empty span (`pN:S-S`) inserts a bare run inside the other author's wrapper; a span crossing out of the wrapper takes `rebuildAcrossBoundaries`, whose two halves still share the original `w:id`; a `--current` match inside another author's `<w:del>` still lands the replacement inside that deletion.
+ `replaceSpanInParagraph` (the engine behind `replace --track` and `edit --at pN:S-E --track`) has ONE early branch, dispatching to [core/find/replace-in-revision.tsx](../find/replace-in-revision.tsx). **The gate**: the replace is tracked, the span is non-empty, every overlapping run shares the same immediate parent, and that parent is a `<w:ins>` / `<w:moveTo>` sitting DIRECTLY under the paragraph. Everything else — untracked, empty spans, a span crossing the wrapper's edge, a wrapper nested in a hyperlink/smartTag/field, a match inside a `<w:del>` under `--current`/`--baseline` — stays on the general walkers, byte-for-byte as before. Inside the gate, `w:author` decides (exact string match): **the wrapper is split the way Word does it** — it keeps its `w:id` plus the text before the span and the editor's `<w:del>` of the cut nested inside it (a del-of-ins, so rejecting either author's revision restores the right text), the replacement rides the editor's own paragraph-level `<w:ins>`, and the content after the span rides a copy of the wrapper with a FRESH id from the shared allocator — unless that tail is pure markers (comment range end + reference run, bookmark end), which stay in the wrapper after the cut instead of minting an empty revision (`isPureMarker`). **The editor's own `<w:ins>` is rebuilt in place** (same id, same date, no `<w:del>`, no split) — the replacement is just more of that same insertion — but every ZERO-width child of the cut runs (note/comment reference, drawing, text-box anchor — `inlineMarkerWidth === 0`; a tab/break/sym the offsets counted as a character goes with the text) is kept (`cutRemnants`), range starts ahead of the replacement and the rest behind it, so nothing anchored on the replaced words vanishes and a comment that opened inside the cut brackets the new words. A `<w:moveTo>` is split even by its own author: moved text is relocated original text, not new words. `revN` pairing in `cli/track-changes/groups.ts` requires both halves to share an author, so the other author's head ins never pairs with the editor's nested del. The new path computes the wrapper's start offset from the siblings before it, where the general `rebuildContainer` starts its walk at the first OVERLAPPING run's offset — correct only when the match sits in the wrapper's first run, which is the "deleted the wrong words" half of the bug (the nested `<w:del>` in the issue is just what put the match in a later run; `rebuildContainer` now starts at the container's own start offset — `RunSlot.parentStart` — so an untracked match in a later run of a multi-run wrapper cuts the right words too (#16)). Nested non-run children (a third author's `<w:del>`, bookmarks) move WHOLE, counted at their view-visible length (a `<w:del>` is 0 wide in the accepted view), and are never wrapped in the new `<w:del>`: one inside the span keeps its place, so the cut is deleted as one `<w:del>` per contiguous run group. Known limitations, all deliberately left on the old paths: an insertion nested in a hyperlink still gets a bare run inside it; an empty span (`pN:S-S`) inserts a bare run inside the other author's wrapper; a span crossing out of the wrapper takes `rebuildAcrossBoundaries` (its post-half gets a fresh `w:id` since #16); a `--current` match inside another author's `<w:del>` still lands the replacement inside that deletion.
## Adding a new tracked-change kind
See [cli/track-changes/CLAUDE.md "Adding a tracked-change kind"](../../cli/track-changes/CLAUDE.md). The summary: extend `TrackedChange["kind"]` in `core/ast/types.ts`, widen `cli/info/schema.ts`, register in `walkRunContainer` in `core/ast/read.ts` (the single source of `tcN` ids), and handle in `actionFor` / `applyAccept` / `applyReject` here. If the wrapper tag is ambiguous (table-structural, checkboxToggle), set `reference.kind` explicitly in the reader so `changeFoundFromReference` doesn't have to guess; otherwise `trackedChangeKindForTag` covers it. If the kind affects visible text, teach `isRevisionVisible` ([revision-visibility.ts](revision-visibility.ts)) which views hide it — `paragraphTextAccepted` / `paragraphTextBaseline` ([core/ast/text.ts](../ast/text.ts)), `find`, `iterateBlocks` and `cli/read/markdown.ts` all read that one predicate.