Skip to content

Latest commit

 

History

History
102 lines (67 loc) · 20.9 KB

ROADMAP.md

File metadata and controls

102 lines (67 loc) · 20.9 KB

Roadmap

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).

Where the library stands

  • Editing: replace/delete/insert_*/rewrite_paragraph on hash-anchored paragraph refs (P3#a7b2), batched atomically with dry-run validation; \n in edit text is a tracked paragraph split; a note= 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_revisions and every resolve verb handle w: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:t and w:tab (one atomic \t); text-box content is excluded; only word/document.xml is 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-session CLI (persistent Jupyter kernel with a JSON eval), and skills/docx/SKILL.md as the agent-facing contract.

Design commitments (constraints every future item inherits)

  • Refs are session-scoped: a paragraph's hash may change between sessions and after edits to it; consumers re-find, they never persist refs.
  • \n means "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:date per 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.

Next

Ordered by value. Each is one board task and one PR unless noted.

6. w:br in the paragraph text map [second half of #6; w:tab shipped in 0.8.1]

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.

63c. Refuse edits that land inside a field result

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.

83. Inserted runs inherit formatting from the raw DOM, not the accepted view

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.

81. Workspace creation bypasses the unpack symlink guard

#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.

84. accept_revision/reject_revision resolve a duplicated w:id by position [data loss]

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.

85. _rejoin_paragraph drops the tail paragraph's other revisions [data loss]

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.

86. Historical marks inside change records list as live revisions

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.

87. An unhandled mark's w:id can resolve a handled revision [data loss]

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.

69. Return-leg reconciliation: which changesets survived? [design]

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.

Later (demand-gated — revisit when a named consumer asks)

  • 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 by w: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-session kernel covers the same need today.

Out of scope — deliberate refusals

  • 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_revisions markup 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.

How this roadmap is set

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.

Shipped

Newest first. Details live in each PR and in the release notes.

  • unreleased, on main: #75 author-filtered accept_all/reject_all no 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); #73 track_changes.py decomposed into a package — RevisionManager = 8 mixins over a typed base, manager.py 5,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:pPrChange as revisions (#82); #6a w:tab in 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