docx-editor lets an LLM agent propose edits to a Word document as tracked changes (redlines) and comments, then lets a human adjudicate them in Word. This file is the single source of truth for what is next, what is deliberately not planned, and what shipped.
Item numbers (#N) are stable identifiers, not GitHub issue numbers. PR titles cite them as type: what changed (ROADMAP.md #N). Current release: 0.8.1 (2026-08-30).
- Editing:
replace/delete/insert_*/rewrite_paragraphon hash-anchored paragraph refs (P3#a7b2), batched atomically with dry-run validation;\nin edit text is a tracked paragraph split; anote=on any edit anchors a rationale comment on its revisions. - Revisions, three tiers: revision < group (one edit) < changeset (one batch call). Groups and changesets survive save/reopen by reconstruction from author + collision-bumped
w:date. There is no fourth tier. - Honest on foreign documents: editing inside another author's insertion preserves their proposal;
list_revisionsand every resolve verb handlew:ins/w:del/w:moveFrom/w:moveTo/w:pPrChange, and never claim success while other revision types remain (list_unhandled_revisions,ResolveResult.unhandled). - Text model: the paragraph text map covers
w:tandw:tab(one atomic\t); text-box content is excluded; onlyword/document.xmlis edited. - Verification: ~1,800 tests; a 77-file, 15-producer corpus (24 of them Word-authored redlines) runs weekly in CI through open → edit → save → reopen, a LibreOffice re-save survival check, and a PDF render.
- Interfaces: Python API,
docx-sessionCLI (persistent Jupyter kernel with a JSONeval), andskills/docx/SKILL.mdas the agent-facing contract.
- Refs are session-scoped: a paragraph's hash may change between sessions and after edits to it; consumers re-find, they never persist refs.
\nmeans "tracked paragraph split". Any future visible element mapped into the text map must therefore use a character that is not\n(see #6).- Replacing text inside your own pending insertion amends it in place (no new revision,
group_id=None); undo is rejecting the amended insertion's group. - The collision-bumped
w:dateper changeset is the join key for return-leg reconciliation (#69). Do not change the stamping scheme. - Resolve verbs (
accept_all,reject_all, …) report what they could not resolve rather than returning a clean count. - Write only what LibreOffice and Word both keep on re-save; the corpus gate exists because an unknown element is dropped silently.
Ordered by value. Each is one board task and one PR unless noted.
A run-level line break is still invisible to search, hashes and edit placement. It cannot map to \n (that means paragraph split — see commitments); candidate is a distinct atomic character (e.g. \u2028, LINE SEPARATOR) with the same boundary-only edit rules as tabs. Refs of affected paragraphs change, as they did for tabs.
Only w:instrText is known to the library today. A replace() targeting text inside a TOC entry, REF or page-number field applies, and Word silently discards it on the next field update — the same silent-loss family already eliminated elsewhere. Minimum: detect that a match falls inside fldSimple/fldChar run ranges and refuse with a teaching error; better: a read-side field inventory. Board task f812f113 exists in the backlog.
Every path that decides which rPr a newly inserted run copies picks its boundary run with getElementsByTagName("w:r") — _apply_paragraph_splits's boundary_runs[-1] and _insert_ins_at_paragraph_start's runs[0] in insert.py, and _rewrite_insert_at's empty-paragraph runs[0] in batch.py. That is document order, not the accepted view, so the "boundary" can be a run Word never shows: inside w:del, inside w:moveFrom (the moved-away source, which Word writes as plain w:t), or inside a w:txbxContent text box — the unfinished half of #65. A paragraph whose visible text is Keep plus a bold w:moveFrom run gives the inserted run bold; a bold paragraph carrying an italic text-box caption gives it italic. All of it reproduces before #79, with a plain "A\nC" split. Fix: take the boundary from build_text_map's last (or first) visible position and walk to its w:r ancestor, which already excludes all three. Second defect on the same path: get_rPr_xml copies the boundary rPr verbatim, nested w:rPrChange included, so a new insertion carries a foreign author's tracked formatting change with its original w:id — insert_after("body.", " A") alone turns one unhandled revision into two. Sanitize the copy (drop w:rPrChange, as _new_tail_paragraph already does for the paragraph mark). Found by the multi-review of PR #95.
#77 made unpack_document refuse a symlinked nearest-existing ancestor of output_dir, but Document.open(path, workspace_dir=<symlink>) still extracts through the link: workspace.py creates the workspace directory before calling unpack, so the guard sees an existing real directory. Applying the same rule at workspace creation would refuse workspace_dir="/tmp/<new>" on macOS (/tmp → private/tmp), so it needs the same nearest-existing-ancestor rule and a decision on whether a user-chosen workspace_dir under a system symlink is legitimate (it is on macOS; document or resolve it). Found by the multi-review of PR #88. Also: openspec/specs/structured-errors/spec.md:49 still describes an unscoped replace() call that the runtime rejects.
The per-id half of #75, which fixed only accept_all/reject_all. With B's <w:ins w:id="7"> before A's <w:del w:id="7">, list_revisions(author="A") returns one row with id 7, and reject_revision(7) deletes B's inserted text and leaves A's deletion pending; accept_revision(7) makes B's insertion permanent. The caller cannot disambiguate — Revision carries no element handle — and SKILL.md documents this exact per-id pattern. Fix: either give the per-id verbs an author/identity selector, or refuse an ambiguous id with a teaching error. The duplicate set is already computed — duplicate_ids in _reconstruct_groups (registry.py) — and then discarded; retain or recompute it rather than testing _revision_groups[id] is None, which _adopt_split_tail also writes for an unambiguous split tail whose origin was ungroupable. SKILL.md carries an interim caveat. Found by the multi-review of PR #96.
Rejecting a paragraph-mark insertion migrates only the mark w:ins off the tail paragraph and then drops that paragraph's whole w:pPr, so another author's paragraph-mark w:del/w:moveFrom/w:moveTo and their w:pPrChange are destroyed — uncounted, unwarned, and with the merged paragraph's break left permanently untracked. Reproduces with no author filter, and is order-dependent within a single author: the same document returns 1 or 2 depending only on whether the mark-ins id sorts above the mark-del id. The accept_all docstring names only the w:pPrChange case. Fix: migrate the whole downstream mark set onto the surviving paragraph's mark rPr, since they all describe the break it now owns. Found by the multi-review of PR #96.
list_revisions and _revision_element_index walk with skip_change_records=False while _unhandled_elements uses True, so a revision mark recorded inside a change record is offered as adjudicable. w:pPr/w:rPr/w:rPrChange/w:rPr/w:ins is conforming (CT_ParaRPrOriginal admits EG_ParaRPrTrackChanges) and lists as a live insertion, and resolving it mutates the other author's change record; the nonconforming w:pPrChange-holding-w:rPr shape is worse — it merges two live paragraphs. Fix: have both walks pass skip_change_records=True and leave recorded marks to the honesty floor. Needs its own regression test. Found by the multi-review of PR #96.
list_unhandled_revisions() reports a numeric w:id when the mark has one, and ids are unique only per producer: with <w:rPrChange w:id="7" w:author="B"> alongside <w:ins w:id="7" w:author="A">, reject_revision(row.id) returns True, deletes A's inserted text and leaves the w:rPrChange standing — the caller adjudicated a revision it never asked about while the one it named survives. UnhandledRevision's docstring claimed this could only fail; it now says what actually happens. Not reproduced on the corpus (0 handled/unhandled id collisions across 77 files), so it is a latent shape, not an observed one. Fix: have the per-id verbs verify the located element is the one the id was read from, or refuse when a handled and an unhandled element share the id. Related to #84, which is the same hazard between two handled rows. Found by the multi-review of PR #96.
The product loop is agent proposes → human adjudicates in Word → document comes back; accepted revisions become plain text, rejected ones vanish, and there is no way to ask "which of my changesets survived?". A sent-vs-returned compare keyed on changeset w:date + content. Design first; large.
- 74. Author moves. The library never writes
w:moveFrom/w:moveTo; a move through the API is a delete plus an insert (one changeset when done in one batch call), which Word shows as two revisions rather than one green move pair. Emitting a real move pair when a batch deletes text and inserts the identical text elsewhere is now feasible on #68's machinery (range marks, pairing byw:name). No consumer has asked. - 29. Computed list numbers ("7.2(a)") from
numbering.xml— a real subsystem; only once list-context demand is proven. - 30. Container parts (headers/footers/footnotes/endnotes): multi-part refs (
H1:P2#hash), per-part editors, tracked-change routing. Stage (a) read-only enumeration, (b) locations, (c) tracked edits. Zero requests across four dogfood rounds; the failure mode is a visible miss, not silent corruption. - MCP server (PR #7, parked): persistent document caching behind an MCP interface; the
docx-sessionkernel covers the same need today.
- Redaction. Actively dangerous here: a tracked-changes library preserves history by design, so pseudo-redaction that leaves text in
w:del, prior versions, or metadata is a liability generator. Permanent refusal. - Word automation suite: table/image/style/section authoring, TOC and field authoring, content controls, mail merge, COM/live-Word integration. Cell text edits already work, which is the redline case; the rest enlarges the corruption surface of files shared with external parties.
- Regex find/replace. The consumer is an LLM that reads text and emits exact strings; regex adds span-vs-revision-context semantics and a footgun surface for zero demonstrated demand.
- Two-arbitrary-document diff. The domain version already exists —
list_revisionsmarkup view,get_text/get_original_text, rewrite diffing. The valuable cousin is #69. - Read-only schema validators. The real contract is "opens in Word with zero repair prompts"; a schema check cannot predict that. The corpus gate is the honest version.
- Text boxes as an editing surface — only the exclusion fix (#65) shipped; box text is invisible, not half-editable.
CodeRabbit reviews every PR; its inline Critical/Major/Potential-issue comments are triaged before merge (an audit on 2026-08-30 of 261 unread comments produced #75–#80). Batches come from evidence, not ideas: four dogfooding rounds (consumer-persona agents driving the library from SKILL.md alone, adversarial error QA, token economics, scale), the corpus revision census (which types real producers emit), and a clean-room comparison against KitchenSink4Word (test names only — its code is PolyForm-NC and unusable). A finding becomes an item here, an item becomes a PR citing it, and a release closes a theme.
Newest first. Details live in each PR and in the release notes.
- unreleased, on main: #75 author-filtered
accept_all/reject_allno longer resolve another author's same-id revision — data-loss fix plus four adjacent id-lookup defects its review found (#96); #79 run formatting survives empty split segments (#95); #82 kernel readiness probed on the shell channel, never racing ipykernel's control-stream init — the earlier claim that the crashed callback leaves the control stream broken was wrong (dispatch_control is async, the stream stays open; the stall was the one-shot probe) (#97); #73track_changes.pydecomposed into a package —RevisionManager= 8 mixins over a typed base,manager.py5,215 → 74 lines, every step a byte-verified pure move (scripts/check_pure_move.py), previously-uncovered moved lines got tests along the way (#83 #84 #85 #87 #89 #90 #92 #93 #94); #72 session tests robust to CI load — env-scaled start budget, timeout diagnostics, handshakes for sleeps (#91); #76 comment reply marker order, #77 unpack symlink ancestors, #78 session start leak, #80 docs/spec drift (#88); #73 steps 1–5 (#83, #84, #85, #87, #89); CI sharded, ~8½ min wall (#86) - 0.8.1 (2026-08-30): #68 Resolve moves and
w:pPrChangeas revisions (#82); #6aw:tabin the paragraph text map (#81); #71 LibreOffice opens-clean gate + real Word redline fixtures (#80) - 0.8.0 (2026-08-29): #65 Exclude w:txbxContent from the host paragraph text map (#78); #67 Rationale channel (#79); #66 + #70 settings.xml pair + anti-scope statement (#77); #64 Foreign-revision census + accept_all honesty floor (#76)
- 0.7.2 (2026-08-28): #56+#62 Perf follow-ups + test hygiene (#75); #39 (also filed as #31) Site D own-insertion replace ordering (#74); #52+#60 Ergonomics grab-bag (#73)
- 0.7.1 (2026-07-24): #59 SKILL round-4 notes (#70); #57 Accept-path performance (#71); #58+61
\n= tracked paragraph split (#72) - 0.7.0 (2026-07-23): #50 Session eval JSON + structured errors (#62); #48 Error-contract round 3 (#63); #46 Revision groups survive reopen (#61); #49 SKILL/docs round-3 sync (#64); #53 Collision-bumped w:date stamping (#65); #47 Formatting preservation + span trimming (#66); #51 batch_edit apply performance (#67); #54 Changeset tier (#68); #55 Docs/skills full audit + sync (#69)
- 0.6.1 (2026-07-22): #44 SKILL.md round-2 sync + [create] extra (#56); #41 Open-path robustness (#55); #45 Session CLI ergonomics (#58); #40 Input-validation bugs (#57); #42 Error contract completion (#59); #43 LLM token ergonomics (#60)
- 0.6.0 (2026-07-15): #31 rPr leak + render_wt dedup (#47); #36 Style-chain numbering (#51); #32 SKILL.md sync (#48); #38 Corpus harness (#50); #33 Revision location (#52); #35 Exception contract (#49); #34 find_all + occurrence ergonomics (#53); #37 Revision grouping (#54); #31 rPr correctness + render_wt dedup (#47)
- 0.5.0 (2026-07-14): #18 UTF-8 unpack (#38); #26 List info on ParagraphLocation (#41); #27 Style/outline context on ParagraphLocation (#42); #22-24 Workspace hardening (#44); #28 Section index on ParagraphLocation (#45); #19 Author-aware in-place edits (#43); #20 Run-order preservation (#46); #25 + #7 API hygiene (#40); #21 Occurrence search unification (#39); #21 Occurrence drift in document-wide search (#39); #25 Typed EditOperation constructors (#40); #7 Internal types exported as public API (#40); #22 meta.json written non-atomically (#44); #23 mark_dirty() write-ahead contract violations (#44); #24 No cross-process protection (#44); #19 Editing inside another reviewer's insertion destroys their proposal (#43)
- 0.4.0 (2026-07-14): #12 Workspace → cache directory (#29); #13 Foreign-revision test fixtures (#32); #14 Original text view (#35); #15 Fixed-point accept_all/reject_all (#36); #16 w:delText → w:t fallback (#34); #17 Stale workspace divergence (#37)
- 0.3.2 (2026-07-02): #8 Structured paragraph output (#26); #9 Single-paragraph lookup (#27); #10 Dry-run validation (#28)
- 0.3.1 (2026-06-08): #2 batch_edit() rollback (#19); #3 ZIP path traversal / symlink (#20)
- 0.2.4 (2026-05-18): #5 Comment anchoring bug (#21)
- earlier: #1 Stale workspace handling