Skip to content

feat(move): relocate blocks in place — untracked splice + tracked w:moveFrom/w:moveTo #38

Open

Description

@kklimuk

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)
  • --at takes pN, pN-pM, tN, a cell paragraph tN:rRcC:pK, or a text-box paragraph tbxN:pK. Destination semantics match insert --before/--after exactly, including cell boundaries.
  • --subtree on a heading pN expands the source to the heading plus every following block up to the next heading of the same or higher level (the same tree outline builds). This is Word's Navigation-pane drag and the most common restructure ask. Don't call it --section: sN already 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 respondAck a no-op.
  • Add a top-level docx --help one-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/.

  1. Resolve the source (resolveBlockOrFail / resolveBlockRangeOrFail, plus the cell, text-box and subtree resolvers) and the destination anchor to live XmlNode refs before any mutation, the same discipline delete --batch uses.
  2. 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) and dcx:raw blocks.
  3. 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 in removeParagraphLine.

Refuse, with a hint, when:

  • the destination is inside the source;
  • the source carries a section break (a <w:sectPr> in a paragraph's pPr). Moving it would move the section boundary; point to docx 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 way markParagraphMarkAs handles ins/del.
  • Destination: a copy of the source nodes, with fresh ids for anything id-bearing, wrapped the same way using moveTo* and the same w:name.
  • Get the exact shape from Word, not the spec. Run the scripts/word-redlines.sh AppleScript 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 the moveTo side. 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:docPr ids 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 (the moveTo side, so they survive Accept All) and the moveFrom side holds text only.
  • Pair them in review. This is the piece that's actually missing today. apply.ts treats moveFrom and moveTo as independent kinds, so apply --accept <moveTo> --reject <moveFrom> would leave the text in both places. Group the two halves by w:name: track-changes list should show one revision for the move, accept/reject/apply should resolve both halves together, and cli/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; --track forces it.
  • Read view: CriticMarkup has no move syntax, so the two halves render as {--…--} / {++…++}. Make the tcN footnote 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.ts to dogfood docx 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.md sentence plus a criteria.md check for moveFrom/moveTo and that the comment is still anchored).
  • Docs: REFERENCE.md, src/cli/CLAUDE.md, and the skill's common-workflows section.

Open questions

  1. Comment ranges that straddle the source boundary: refuse (proposed v1), or shrink the range to the part that stays?
  2. 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 keeps numPr.
  3. 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

  1. kklimuk commented on Nov 6, 2026

    @kklimuk
    OwnerAuthor

    🤖 docx-cli triage

    Confirmed on main (e830c0c): docx move doesn'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 covers pN / pN-pM / tN / cell and text-box paragraphs, --subtree, --after/--before including 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:moveTo writing, 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, move refuses 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions