Repository navigation
Description
Problem
There is no way to relocate content. "Put the executive summary first", "move the Governing Law clause to the end", "swap Appendix A and B", "move this row up" all have to be done as read → insert the copied Markdown → delete the original. That round-trips the content through Markdown, which the invariants say we must not do ("make in-place XML mutations, not AST/Markdown round-trips"), and it is lossy in ways a weak agent cannot see.
Repro (0.28.0 / main @ 22e3e2f): one paragraph with red bold text, a comment and a footnote, moved one block down with the workaround:
printf '# Terms\n\n1. Payment due in 30 days.\n2. Late fees apply.\n\nClause B: Governing law is Delaware.\n\nClause C: Notices go to the address above.\n' > m.md
docx create m.docx --from m.md
docx edit m.docx --at p3:0-8 --text "Clause B" --bold --color C00000
docx comments add m.docx --anchor "Delaware" --text "Confirm with legal"
docx footnotes add m.docx --anchor "Delaware" --text "See Del. Code."
LINE=$(docx read m.docx --from p3 --to p3 | grep -v '^<!--' | grep -v '^$' | grep -v '^\[\^' | sed 's/ <!-- p3 -->//')
docx insert m.docx --after p4 "$LINE"
docx delete m.docx --at p3| What | Result |
|---|---|
| Run formatting | survives |
| Comment | orphaned: still in comments list, anchor is now empty, gone from read |
| Footnote | becomes literal text. The copy contains the characters [^fn1]; after track-changes accept --all the real footnote is gone (footnotes list → []) and the body says Delaware[^fn1]. (see related bug below) |
| Under tracking | redlines as a delete of every word plus a fresh insertion, not as a move (Word marks moves distinctly, and a reviewer needs that to know the text didn't change) |
| Cost | the agent re-emits the whole passage (expensive for a long section, and a weak agent may paraphrase it), and must get insert/delete ordering right because locators shift |
Anything Markdown doesn't carry (bookmarks, fields, content controls, raw-inserted XML, paragraph properties read doesn't print) gets dropped or re-derived the same way.
Proposal: docx move
docx move FILE --at LOCATOR (--after LOC | --before LOC) [--subtree] [--track] [--author NAME] [-o PATH] [--dry-run]
docx move FILE --batch moves.jsonl # {"at":"p3","after":"p9"} …
docx tables move-row FILE --at tN:rR (--before tN:rK | --after tN:rK)
--attakespN,pN-pM,tN, a cell paragraphtN:rRcC:pK, or a text-box paragraphtbxN:pK. Destination semantics matchinsert --before/--afterexactly, including cell boundaries.--subtreeon a headingpNexpands the source to the heading plus every following block up to the next heading of the same or higher level (the same treeoutlinebuilds). This is Word's Navigation-pane drag and the most common restructure ask. Don't call it--section:sNalready means a section break.- On success, print the moved blocks' new locators (
respondMinted-style), because a move invalidates the old ones. - Moving a block to where it already is must fail; never
respondAcka no-op. - Add a top-level
docx --helpone-liner, since discoverability is what makes weak agents find it.
Implementation
Untracked: a pure splice of live nodes
Core lives in a new Move lens (src/core/move/), with the CLI as a thin parser in src/cli/move/.
- Resolve the source (
resolveBlockOrFail/resolveBlockRangeOrFail, plus the cell, text-box and subtree resolvers) and the destination anchor to liveXmlNoderefs before any mutation, the same disciplinedelete --batchuses. - Detach the source nodes, then splice them at the destination by its live
indexOf. Because the nodes themselves move, everything inside them rides along with no extra work: comment markers, footnote/endnote references, bookmarks, fields, content controls, drawings, text boxes (mc:AlternateContent) anddcx:rawblocks. - A source cell's last paragraph can't be removed (an empty
<w:tc/>is invalid), so leave Word's mandatory empty paragraph behind. Reuse the cell-safe logic inremoveParagraphLine.
Refuse, with a hint, when:
- the destination is inside the source;
- the source carries a section break (a
<w:sectPr>in a paragraph'spPr). Moving it would move the section boundary; point todocx sections. - a comment range straddles the source boundary (start marker inside, end outside or the reverse). Moving would invert or split the range. Same check for bookmarks. See the open questions.
Tracked: write Word's move markup (w:moveFrom / w:moveTo)
The reader, read --current/--accepted/--baseline, find and accept/reject already understand w:moveFrom/w:moveTo (ast/read.ts, revision-visibility.ts, track-changes/apply.ts, the tracked-moves fixture). Nothing writes them yet.
- Source: a
<w:moveFromRangeStart w:id w:name="moveNNN" w:author w:date/>…<w:moveFromRangeEnd w:id/>pair around the moved paragraphs. Each paragraph's content goes inside<w:moveFrom>(runs keep<w:t>, matching Word and the fixture), and each paragraph mark is marked as moved-from in<w:pPr><w:rPr>, the same waymarkParagraphMarkAshandlesins/del. - Destination: a copy of the source nodes, with fresh ids for anything id-bearing, wrapped the same way using
moveTo*and the samew:name. - Get the exact shape from Word, not the spec. Run the
scripts/word-redlines.shAppleScript oracle on a Word-made move first: which elements carry the range markers, how the paragraph marks are marked, how a multi-paragraph move is laid out, and whether a comment or footnote inside a moved range is duplicated on themoveToside. The track-changes CLAUDE.md is explicit that tracked shapes are empirically validated, never tweaked on a hunch. The same oracle should confirm that Word shows our output as a move (green double-underline/strikethrough) rather than as ins/del. - Id-bearing children in the copy: footnote references, comment markers, bookmarks and
wp:docPrids can't simply be duplicated. Mirror whatever Word does here (decide with the oracle step above). The likely answer is that markers stay on one side (themoveToside, so they survive Accept All) and themoveFromside holds text only. - Pair them in review. This is the piece that's actually missing today.
apply.tstreatsmoveFromandmoveToas independent kinds, soapply --accept <moveTo> --reject <moveFrom>would leave the text in both places. Group the two halves byw:name:track-changes listshould show one revision for the move,accept/reject/applyshould resolve both halves together, andcli/track-changes/groups.ts(which currently excludes moves) should learn about the pair. This also fixes move markup that Word itself wrote. - v1 scope: tracked moves of paragraphs only. A table or table row under tracking hits the same
assertParagraphOnlyTrackedRange-style guard and refuses until we've probed what Word does. Doc-level tracking on → tracked by default, like every other mutator;--trackforces it. - Read view: CriticMarkup has no move syntax, so the two halves render as
{--…--}/{++…++}. Make thetcNfootnote name the move and its other half (e.g.move-from by Reviewer → p9) so an agent can tell a move from a rewrite.
Batch
move --batch follows the house rule: every locator addresses the document as read. Resolve all sources and destinations to live refs first. Refuse overlapping sources, and any destination that sits inside some entry's source. Then apply in entry order using live indexOf, so earlier moves never misdirect later ones. Moving several entries to the same anchor keeps entry order (same per-anchor offset approach as insert --batch).
tables move-row
An untracked detach and re-insert of a <w:tr> within the same table. Refuse if either the moved row or the destination position cuts through a vertical merge (vMerge). The merge-aware logical grid in @core/table already makes this check for the other structural verbs. The header row (tblHeader) can be moved; it stays flagged as a header row.
Tests and coverage
- Unit tests: untracked moves keep comment ranges, footnote references, bookmarks, content controls and a text box anchor byte-identical; every refusal case returns a nonzero exit; batch moves resolve against the document as read; tracked output matches the shape captured from the Word oracle; accepting or rejecting the paired move leaves the text exactly once (accept → at the destination, reject → at the source).
- Fixture: extend
tests/fixtures/setup/tracked-moves.tsto dogfooddocx move(tracked and untracked), so it round-trips through LibreOffice. - Weak-agent scenario: fold "move the Governing Law clause to the end of the agreement" into the existing contract redlining scenario (a
task.mdsentence plus acriteria.mdcheck formoveFrom/moveToand that the comment is still anchored). - Docs: REFERENCE.md,
src/cli/CLAUDE.md, and the skill's common-workflows section.
Open questions
- Comment ranges that straddle the source boundary: refuse (proposed v1), or shrink the range to the part that stays?
- Lists: a moved numbered item keeps its
numPr(it's a move, not new authoring), so it keeps its original list's numbering even when it lands beside a different list. Is that right, or should it join the neighboring list the way new Markdown does (continueList)? Word keepsnumPr. - Moves between stories: allow moves between the body and table cells (common) and within one text box; moves across stories (body ↔ text box, body ↔ notes) are out of scope for v1.
Related bug (should be its own issue)
Copying a read line that contains a footnote back through insert/edit writes the literal characters [^fn1]. GFM only recognizes [^id] as a footnote reference when a matching [^id]: … definition is in the same source, and read prints the definition on a separate line. So the footnoteReferenceRun "no matching definition" error in src/core/markdown/inline.tsx never fires, and the text lands silently. In read the result looks identical to a real footnote. This breaks the "copy a line from the read view, change it, pass it back" contract in edit --help. Fix: pass a known-note reference through as the existing note, or at least reject it as an error.
Activity
kklimuk commentedon Nov 6, 2026
🤖 docx-cli triage
Confirmed on
main(e830c0c):docx movedoesn't exist (Unknown command: move). The read → insert → delete workaround loses the comment's anchor and turns the footnote into literal text, as you describe.#50 is the first slice: untracked
docx move. It coverspN/pN-pM/tN/ cell and text-box paragraphs,--subtree,--after/--beforeincluding bare cells, and--batch. Every refusal you listed is in, plus no-ops. It prints the moved blocks' new locators. On your repro, the moved paragraph's XML is byte-identical and the comment and footnote stay anchored.Still open here: tracked
w:moveFrom/w:moveTowriting, pairing the two halves in review,tables move-row, and the[^fn1]copy-back bug. The tracked shape has to be captured from the Word oracle first, and that can't run in this environment. Until then,moverefuses while track changes is on.Action needed: approve/merge — review #50 and tick its "Decisions for you" checklist (tracking-on refusal, straddling comments, numPr, strict batch anchors). The PR uses
Refs #38, so this issue stays open for the tracked half.Generated by Claude Code