stetdocdocs

Changelog

View as markdown

All notable changes to stetdoc are recorded here. The format follows Keep a Changelog (opens another site), and the project follows semantic versioning (opens another site) from 1.0.0. Releases before 1.0.0 may change the interface between minor versions.

0.9.1 - 2026-11-02

Fixed

  • A replacement no longer cuts a bracket pair in half. thirty (30) to forty-five (45) used to strike thirty (30 and leave the ) standing after the insertion; now the whole thirty (30) is struck and the whole forty-five (45) inserted, however wide the search text. An edit that leaves (, [, { or “ open takes the unchanged closer right after it, and the mirror for a closer, unless the bracket is in another author's pending change. ops and compare both get it, and glued % or 's still stay outside the edit, as in Word. The compare noise baseline is re-taken for it: over the LAB pairs the redline has 0.05% more characters.

0.9.0 - 2026-11-02

The first release: the stetdoc CLI with its MCP server and the agent plugins, built for seven targets, from GitHub Releases, npm, PyPI, Homebrew and crates.io (cargo install stetdoc --locked, which also publishes the engine as the stetdoc-core library).

Added

  • Receipt::text_within(cap) composes a receipt's text in about cap bytes, as text is in about 11,000. The MCP tools that write (edit, replace, comment, pack) answer with a receipt of about 2 KB: the head line with its base, the first operations and blocks, what was left out, and the check line. detail: true returns the whole receipt.

  • stetdoc comment FILE ANCHOR TEXT [ANCHOR TEXT ...] adds several comments in one edit, all or none, as replace takes several pairs. The MCP comment tool takes them in comments, a list of {anchor, text}, beside anchor and text for one.

  • stetdoc view --find TEXT --story STORY finds only in that story, such as the headers and footers, where --find and --story used to be refused together. Scope::Find has story (decision 0013 (on GitHub)).

  • The text of a receipt is capped at about 11,000 bytes, so replace --all or comment --all over hundreds of places prints a receipt an agent can read whole: the first operations and blocks, how many it left out, and the stetdoc view command that shows them. The head line, the base, the warnings and the check line stay whole, and the JSON is unchanged.

  • --clause may be given more than once on view, replace and comment, such as stetdoc view FILE --clause 4.01 --clause 4.02: the result is the union of the clauses' blocks in document order, each once. Scope::Clause holds a list, and JSON takes one clause as a string or several as a list (decision 0012 (on GitHub)).

  • A Python distribution of the CLI in packaging/pypi/: maturin builds the native stetdoc binary into a wheel per release target (bindings = "bin"), so uv tool install stetdoc, pipx install stetdoc, pip install stetdoc and uvx stetdoc run it with no Python at run time. The release workflow's pypi and pypi-sdist jobs attach the wheels and the sdist to the draft release, and cargo xtask release check and stamp.mjs --check keep them in step.

  • The npm package stetdoc replaces its Node launcher with the platform's binary in a postinstall step, as esbuild does, so a call through npm install -g stetdoc starts in about 6 ms instead of about 100 ms. It copies a file npm already installed and downloads nothing. With --ignore-scripts and on Windows the launcher stays.

  • The skill stub and stetdoc skills get core teach the default output (NAME.edited.docx, then --in-place on it), --manual markup and when to use it, reporting tracked changes by others, --ignore-case, comment --all and --expect, view --revisions --context N, table hits, and the MCP edit tool's typed ops.

  • stetdoc init --agent antigravity installs the skill for Antigravity CLI (agy), which replaced Gemini CLI: in ~/.gemini/config/skills/stetdoc (read by its CLI, app and IDE) or a project's .agents/skills/stetdoc. It is detected by agy on the PATH or ~/.gemini/antigravity-cli, and --mcp prints agy mcp add stetdoc stetdoc mcp. Picking Gemini CLI now notes that Google retired it.

  • The skill stub tells the agent to write each output next to its input in the user's folder, never in a temporary folder, and, when stetdoc is missing, not to offer another way into the .docx.

  • stetdoc view --revisions --context N shows the blocks that hold the changes whole, as the redline shows them, with N blocks on each side, so each change reads in its paragraph. It takes --author, --kind and --find as the list does. RevisionFilter has context. --context without --find or --revisions is refused with a next step.

  • The Node bindings have Document.occurrences(text, scope?), typed with Occurrences and Scope.

  • Occurrence has heading: the nearest heading when the paragraph is in no clause, which the CLI prints as (under "RECITALS").

  • Every receipt of edit, replace, comment and compare, and the head of every view but the ledger, says which tracked changes in the document authors other than you made, in one or two lines: how many by whom, the first three with their number, kind, ref and text, and "Tell the user about them." The JSON has them in others.

  • replace and comment take --ignore-case (the MCP tools' ignore_case), and a not_found refusal lists the places where the text is in another case. Document::occurrences_ignoring_case finds the same way.

  • comment takes --all and --expect N, one comment on each place, as replace does.

  • Manual markup: stetdoc replace --manual and stetdoc edit --manual keep the old text, struck through, and add the new text underlined, as plain formatting with no tracked changes, for a reader who asks for a redline without Track Changes. A deletion is struck text only and an insertion underlined text only. --manual-color also makes deleted text red and new text blue. The MCP replace and edit tools take manual and manual_color, the core has Edit::markup(Markup::Manual), and the receipt says the markup is manual and lists what each operation struck and underlined (OpResult::marked). split, merge, row and deleting a table are refused in a manual edit.

  • The MCP edit tool takes typed operations in ops: a list of objects, each with op and that operation's fields, with the schema generated from the same table as capabilities and the operations reference. batch still takes text, and the file the CLI's --ops names is now ops_file. Every MCP tool's description carries the rules a model needs to call it right, for clients that never pass the server's instructions on, and no schema uses a union any more, so Gemini's function declarations take them as they are.

  • A batch applied to a .docx may leave out its base, in the CLI and the MCP tool alike: the file is read and edited in one process, as replace does, so its refs are read against the file as it is now. A bare JSON list of operations works there too. A working copy still needs the base, and a batch without one is refused with where to find it.

  • The eval harness keeps each run's report and transcript, counts only check findings that Word would reject as invalid, and its graders accept an edit made as a longer phrase and judge the stale case on any mention of the other author's change.

  • An eval suite for the Claude Code plugin in evals/, run with evals/run.sh on Claude Code's claude plugin eval: nine cases on real contracts, graded from stetdoc's own reading of the documents the agent wrote, with turns, tokens, cost and time per case, and arms to compare with no plugin or another plugin. See docs/evals.md.

  • stetdoc replace FILE OLD NEW [OLD NEW ...] changes text in one step, on a .docx (-o OUT or --in-place) or a working copy: each OLD, found once in the text as the final view reads it, becomes NEW as tracked changes, all pairs in one edit, all or nothing. An empty NEW deletes OLD. Text that is not there, or is there more than once, is refused with every place listed (ref, clause, text around it). --all replaces every occurrence and says how many, --expect N only when there are exactly N, --range and --clause limit the search, and --dry-run writes nothing.

  • stetdoc comment FILE ANCHOR TEXT comments on text found the same way, in one step.

  • The MCP server has replace (its pairs an array of {old, new}) and comment tools, with the JSON receipts of edit and the places found in found.

  • Document::occurrences lists where a text is, as find matches it, in the whole document, a story, a range or a clause, and the new error code count_mismatch (exit 3) refuses a request whose expected count does not match.

  • stetdoc view --find takes several texts (--find payment --find "Due Date") and finds blocks that hold any of them. Each line says which texts it holds and how often, and the position line counts each text. The MCP view tool's find takes one string or a list, and Scope::Find has needles (JSON still reads needle and one string).

  • The Claude Code plugin carries a guard hook, plugins/claude/hooks/docx-guard.sh, that blocks tool calls reading or writing a .docx another way: an archive tool, a script or a .docx library on a Word file, a conversion, an in-place edit of an unzipped part, and the Write and Edit tools on a Word file. It allows every stetdoc command, needs only sh and awk, and tells the agent to use stetdoc or say it is not installed. docs/agents.md lists what it blocks and shows how to add it to a skill install, and stetdoc init --agent claude-code points there.

  • The skill stub carries the everyday commands (view --find, replace, comment and compare), so a simple task needs no stetdoc skills get core. In Claude Code it sends the agent straight to the command. stetdoc init replaces the previous stub as one it wrote. The core skill makes replace the default way to change text and comment the way to comment, with batches for the rest, and the README shows the same flow.

  • The skill stub and the core skill tell the agent to report tracked changes it did not make, such as a colleague's edit made since its last view, with who made them and what they change. Its comment example writes a new file, so an agent copying it leaves the user's original untouched.

  • stetdoc view --find TEXT --range p40..p60 looks only in that range, in the CLI and the MCP view tool. A find pages like a range, and its position line counts the matches: find "days": 44 matches in 38 blocks, showing 1..9.

  • stetdoc view --revisions --find TEXT lists only the changes whose inserted or deleted text contains it, and --revisions --full shows the whole text of each change (up to 1,000 characters) and not 80, still paged.

  • stetdoc view --all is the same as --budget 0. A view of a body too long for one page shows its outline with an outline_shown warning that names the range of the whole body and --all, and the -- more: line of a page says where it stops, how much follows and the command for the next page.

  • stetdoc inspect FILE p42 lists the block's tracked changes (ids, kind, author, text) and comments, or says it has none, so text such as [DELETED: ...] in a document can be told from a real change.

  • The MCP view tool's schema names, in each property's description, the arguments it cannot be used with, and a call that sends such a pair gets a next step that names both, in the MCP server and in the CLI's --json errors.

  • The agent guidance picks the surface and keeps agents away from the package. The skill stub sends an agent with stetdoc's MCP tools to skills_get, and otherwise to the CLI, with a quick reference that covers a simple task. With neither, the agent tells the user stetdoc is not installed and reads nothing another way. The core skill says how paging works, that [DELETED: ...] without braces and an id is document text and not a revision, never to read the XML, to state or ask which reading of an ambiguous rule it takes before resolving revisions in bulk (judging each change from --revisions --full or --find), and to name the output and remove the working copy at the end with pack --done. It documents --all, --find with --range, inspect's list of a block's revisions, edit --in-place and the one-line summary of problems already in the input. stetdoc init --mcp, docs/agents.md and the MCP server's instructions say to install the skill with the server. The README gives the one-line Claude Code install, and the Claude Code plugin has an icon.

  • The CLI is published as the stetdoc crate, so cargo install stetdoc installs the stetdoc binary. The library is published as stetdoc-core.

  • stetdoc view FILE --revisions lists the tracked changes as a ledger, one line each: the ids, the kind (insert, delete, move or format), the author, the day, the block and the text. --author and --kind filter it, and --after change:N continues a long list. It lists what Document::revisions returns. It was unsupported before.

  • The agent skill says where the author of a revision comes from: --author, then STETDOC_AUTHOR, then the name saved by stetdoc init, so an agent no longer asks for a name the config already holds.

  • Docs for users and builders: docs/agents.md (setup for each agent), docs/self-hosting-mcp.md (serving stetdoc mcp to cloud apps) and docs/build-on-stetdoc.md (the crate, the Node package, the CLI and MCP).

  • Packaging for the releases: packaging/install.sh (checks the SHA-256 of the release archive), a Homebrew formula template, npm packages for the CLI (packaging/npm/stetdoc), plugin and listing manifests for Claude Code, Cursor, OpenAI and the MCP Registry, and a draft release that carries all of them. See packaging/README.md.

  • White space at the ends of a w:t without xml:space="preserve" is text, as Word for the web shows it (hand check 37), where LibreOffice drops it, a documented deviation; xml:space is read as inherited from an ancestor, and what stetdoc writes or copies says it where needed; a w16se:symEx emoji shows its character (or its fallback's text); the text of a vertically merged continuation cell is flagged hidden; the Final view of the notes leaves out a note whose reference is a pending deletion, and the Original view one whose reference is a pending insertion, each giving a note the mark Word shows once its changes are resolved (mark=), while its id stays the same in every view; and a text edit never deletes a note's own number. Found by the LibreOffice oracle (cargo xtask oracle libreoffice), which reads a seeded sample of 500 outputs.

  • stetdoc pack overwrites an existing output only when it is the working copy's original or a .docx it packed; anything else is the new error code stale_target (exit 4), unless --force. The default date of a revision honours TZ, with the time zone database built in as a fallback, and a zone that cannot be read gives UTC with a warning. An error from a readable file names its file and line once.

  • The stetdoc command surface: all twelve commands with their flags, --json on every command, exit codes from the error table, and the stamp from --author or STETDOC_AUTHOR with the local clock as the default date. view, inspect, lookup, check, edit -o, compare and capabilities work on a .docx.

  • The agent skill, embedded in the binary: stetdoc skills get [core|operations|errors] and --full. The operation and error tables are generated from capabilities() and the command list from the CLI's own definitions, so they cannot drift.

  • stetdoc init picks agents: Claude Code, Codex, Cursor, OpenCode, OpenClaw, Gemini CLI, Copilot, Amp, Hermes, Grok Build, cmx2 and Factory. It installs into the home folder by default and into a project with --project, which also keeps a short block in AGENTS.md. It asks in a terminal, installs for the detected agents otherwise, and takes --agent a,b or all; a folder several agents share is written once. --list shows each agent's detection, folder and installed version, --remove deletes only folders that hold nothing but an unchanged stetdoc stub, --mcp prints how to register stetdoc mcp with each agent without editing its config, and --dry-run changes nothing. The stub records the version that wrote it. stetdoc init --author NAME, or the answer to its question in a terminal, saves the author to stetdoc/config.toml in the config folder, and --list shows it. skills/stetdoc/SKILL.md is the same stub, and a test fails when the two drift.

  • The author of a revision comes from --author, then STETDOC_AUTHOR, then the name saved with stetdoc init; without any, the command says to run stetdoc init. It is never a name the user did not confirm.

  • stetdoc mcp: a Model Context Protocol server on stdio, speaking revision 2026-07-28 and the earlier handshake revisions. Every command but init is a tool whose schema comes from the CLI's own definitions, a call returns the JSON the command prints with --json, and a failure is a tool error with its message and next step. --root DIR confines every path argument to DIR, refusing .. and links that lead out of it. --author NAME stamps the calls that name no author.

  • docs/reference/cli.md, generated from the CLI's own definitions by cargo xtask docs, with examples that run as tests.

  • FIDELITY.md: per feature, what is kept exactly, what is normalized, what is read-only and what is left out, with the design behind each settled detail. The README shows the working CLI.

  • Working copies on disk: unpack, pack, edit, check, view, inspect and lookup on a directory. Every write follows the core's write plan with atomic renames under an advisory lock on .stet/lock, and a command that finds an interrupted write recovers it first.

  • The workspace: stetdoc-core, stetdoc-cli, stetdoc-napi, stetdoc-editor, stetdoc-testkit and xtask, on a pinned toolchain.

  • The stetdoc-core module tree, with a contract in each module.

  • ErrorCode, ExitClass and StetError: the error vocabulary, with a generated error reference.

  • The internal error code and exit class 7: a bug in stetdoc, which callers report and never retry around.

  • cargo xtask check and cargo xtask check --quick, and CI that runs the same check.

  • The entry-point docs, the licence files (MIT OR Apache-2.0) and NOTICE.

  • cargo xtask corpus fetch: downloads the corpus manifest's documents from their pinned commits, verifies every SHA-256 and caches them, offline once cached. cargo xtask check runs it and sets STETDOC_CORPUS_DIR for the tests.

  • stetdoc_testkit::fixtures: 85 edge, 7 Word and 87 hostile synthetic documents built in memory, each with the outcome stetdoc must give and, for a hostile one, why. They include the package layer's own 117 cases, ported without duplicates: 53 are new fixtures and the rest are the fixtures the testkit already had. A snapshot pins the SHA-256 of every fixture, so a change to the builder or to the deflate library shows up as a reviewed diff.

  • stetdoc_testkit::expect: Expect and Stage, shared by the corpus and the fixtures. A refusal is an error code at a stage (open, read, xml or document), and a layer sees only the refusals it reaches. A refusal at read or xml also names the parts that carry it (Entry::parts, Fixture::parts), and Expect::seen_by_reading gives Ok to a layer that never reads them. Every refusal in corpus/manifest/ has a stage, a part where that applies and a reason from corpus/reasons.tsv, and cargo xtask check enforces it.

  • Fuzz targets for the container and the span tree, run with cargo xtask fuzz <target>: a libFuzzer target that opens, reads, replaces a part of and writes a package, and one that parses, serializes clean and with every tag dirty, and edits a part, each aborting when a law of the module breaks. They are seeded from the fixtures and the curated corpus, built with a nightly toolchain pinned for fuzzing alone, and run for 60 seconds per target in CI. The core reaches them through the off-by-default fuzzing feature (decision 0002 (on GitHub)), and cargo xtask check now runs clippy with the default features and with all of them, the tests and rustdoc with all features, and cargo-deny on the fuzz crate under its own policy (fuzz/deny.toml, the root policy and the NCSA licence of libFuzzer).

  • cargo xtask bench and the benchmark harness, stetdoc_testkit::bench: the time and the peak memory of package open and save, span tree parse and serialize, schema validation, the state (open, a batch of fifty writes, save, export, resume), wordml (scan, field pass, text pass, refresh, fifty text edits, field edits and paragraph insertions) and the style cascade and clause numbers (the gate of 25 ms on a 20,000-paragraph body), on a 300-page contract and on four more inputs (the densest real document and three synthetic worst bodies), held to the budgets of ARCHITECTURE.md and to a baseline for each platform. The memory is counted by an allocator that the core's test binary installs, so it is the same on every run. The only exemption from a budget is a named pair of a benchmark and an input that is a known memory gap, which ends by itself when the pair fits (there is none today). A time regression against the baseline is a warning (--strict-time fails it), and the other failures are the budgets, a memory regression and a closed gap. --ci has the margins of a shared runner, --out DIR keeps the table and the rows, and a nightly workflow runs it with the budgets as written and uploads both. A benchmark is a small file in stetdoc-core/src/bench (decision 0003 (on GitHub)).

  • projection and index in the core: what an agent reads and edits. view gives a page of a document within a token budget (the estimate is ceil(ascii / 4) plus the other characters): the whole body when it fits, otherwise its outline, or a range, a clause, the results of a find or one story, with a cursor that is the ref of the last block shown, margin notes (what a find matched, what a citation such as clause 9.1 came to, what cites a clause), and the notes and comments a page cites. render_files writes the readable files (document.md, notes.md, headers.md, comments.md) in the stet/1 format or as json/1, and files::to_batch turns an edit of them into operations by comparing two drafts, in no syntax, so the alignment is written once: a changed slot is one replace, a line removed is a delete, a line with no ref is an insert after the paragraph it follows, and a paragraph cut into lines or lines joined are recognized as exact splits and merges only when the text is conserved. Everything else is applied as written or refused with the file, the line and the way out (readonly_region, unsupported, cross_block, stale_files), and the files of an unedited document give no operation. render_blocks shows the blocks of a receipt as the files hold them, with warnings (looks_hidden, duplicate_number and the rest). lookup finds where a term is defined and used, which block is clause 4.2(a) (computed or typed, composed across lists), and what refers to a block, and returns every candidate when it is not unique. Laws M1 to M7, F1 to F8, T1 to T8, P1 to P8 and I1 to I6 run over the fetched corpus in cargo xtask check, and the benchmarks time the first view, a page with notes, the readable files, to_batch and the first lookup on a 300-page contract. See the design notes (on GitHub).

  • Every testkit fixture is opened with Package::open and held to the container laws and to its expectation by a test of the core, with the row to add for State::open when it merges. The laws now also check that the parts a refusal at read or xml does not name read and parse cleanly, which Expect::seen_by_reading relies on, and a part refused with another code than the index gives is named in the message, where it used to be reported as a clean one.

  • The corpus cache is safe to share between checkouts and between concurrent runs: fetching holds a lock, work happens in a per-process directory, and a set is built once, under a name that carries the manifest hash, and never changed. A repair of a damaged blob builds a new generation of the set behind the stable link, so a reader of the old one sees no change. A repository that was deleted or made private fails its source alone, with a message that asks for a new pin.

  • cargo xtask codegen, which generates Word's element order and value types from the Open XML SDK's schema data (pinned, MIT), and the schema module that uses them: where a new child element goes (insert_in_order, reorder) and the violation set behind the no-new-violations rule (validate_part). The schema code reads the span tree (SpanTree implements SchemaTree, and Refs lets code that holds NodeRefs call the rules), finds the context of an element (ctx_at), and is the one place that says which branch of an mc:AlternateContent Word reads and what mc:ProcessContent keeps (taken_branch, disposition).

  • Mode (Final, Original, Redline) in api::view, re-exported from the crate root: how tracked changes appear when a paragraph's text is read. Final applies every revision, Original rejects every one, and Redline keeps both.

  • wordml in the core: the stories of a document and the blocks in them, each paragraph's text in the three modes with a map from every character to the XML it came from, and the definitions of styles and numbering. The map is the base that edits, revisions and projections build on. See the design notes (on GitHub).

  • package and xml in the core: the ZIP container and the XML span tree. A package opens lazily, checks a part's size and CRC-32 when it is read, and writes back the input bytes exactly when nothing changed. Replacing one part rewrites that entry and moves the offsets after it, and an edit through the span tree changes only the bytes of the nodes it touched (the root start tag may gain the namespace declarations new content needs). A package is read as Python's zipfile, unzip and bsdtar read the same file, or it is refused: the central directory is found from the end record, entries lie end to end, and a ZIP64 record has to agree with the end record. Limits bounds the archive, the entries, the compression ratio and the XML (depth, attributes, nodes, namespace declarations in scope). The laws L1 and a one-edit L2 run over the fetched corpus in cargo xtask check, against a second ZIP reader and against the stage and part the index gives for each refusal. See the design notes (on GitHub).

  • The span tree stores its nodes and attributes in chunks that a clone shares until it changes one, so cloning a tree for copy-on-write staging copies a few pointers and an edit copies the chunks it changes, and a tree holds what it uses where a growing vector held up to twice that. The densest benchmark inputs went from 103% of the memory budget to 70% (writing a part while its tree is held), a clone of the densest input with 50 paragraphs changed from 207 MiB to 97 MiB, and the four known memory gaps closed. SpanTree refuses ]]> in text (malformed_input, reason xml.cdata_end_in_text; none of 51,831 documents has one), an element holds at most 4,096 attributes, and a text node that an edit makes meet another text node reports is_dirty, so a node that is not dirty is written exactly as it was read.

  • check in the core: the findings and the report of a check (FindingCode, Finding, CheckReport in api::check, with stable codes and a sentence for each), check::document (every part: the schema, the Word rules for deletions, xml:space, nested revisions, unique ids, paired ranges, cells, tables, notes, empty revisions and properties, and fields cut by a deletion, and the comment audit) and check::edit, the check of a batch under the no-new-violations rule. edit finds the paragraphs, tables, notes and frames that a transaction changed from the span trees, with an equality test against the base so that a long session does not pay for its history, and compares each with its origin, so a problem the input had never blocks an edit, and an element the compiler renames keeps the key of its problems. schema gained walk_unit, read_children, is_unit_boundary and in_taken_branch, so the Word rules read a unit as the validator does. See the check design (on GitHub).

  • Under a scope, accept and reject never take a change of the body's last section properties, which name no paragraph, and the scope guard's second pass refuses any change to what lies between blocks or after the last one unless the block after it is in scope. Typed quotes read the character before the text, so 's typed after a word becomes an apostrophe. The document_* benchmarks time an edit, its dry run, an edit of the files, the first view and export through Document.

  • The comment operations through Document::edit: comment, reply, resolve, edit-comment and delete-comment, and delete-note (a note named as the view shows it, fn3). The receipt lists what each did to which comment, and reconcile runs after the operations. Under a scope a comment operation is checked by the paragraphs it is anchored to.

  • The operations on properties through Document::edit: set (any property of a paragraph, run, mark, section, table, row or cell), and format, paragraph, style and list over it, with values in human units (12pt, 0.5in, 1.5cm). Each is a tracked property change. None of them writes or removes revision markup, and a section break through set is untracked only.

  • The Comments scope of view: one entry per comment thread, with the root's anchor, every comment's author, date and text and whether the thread is resolved, filtered by author and by resolved, and paged by the root of the last thread shown (c7). The comments story says which comment a reply answers and that its thread is resolved, and the summary line counts resolved threads.

  • A JSON view gives each paragraph its final_text, the text that the offsets of an operation count in.

  • A relative cross-reference lists the candidates it was chosen over after its target, in a margin note (relative to this block, or else p3 (...)) and in a lookup.

  • compare through Document: an original and a revised document into a redline whose transaction check verifies before it commits, with the original's content, style and numbering definitions and copied parts declared as foreign origins, so a problem they already had is carried and not refused. The receipt has the revisions by author, the moves as refs, and compare's warnings, with a warning naming each block it could not show as changes (compare_not_redlined) or that rejecting the changes does not give back (compare_not_restored), by a block of the redline or, when there is none, by the original's first words, and one naming each copy whose paragraphs and tables did not pair with the original's and so may keep any of their problems (compare_keys_merged). stetdoc compare now writes the redline.

  • laws::compare in the testkit: N5 and N7 through Document. A redline must pass check when compare writes it, and once saved it has no violation that neither input has after its revisions are accepted. It runs over the contract, over the compare pairs of the corpus (corpus/pairs.tsv, the 241 curated ones in cargo xtask check), and over forty curated documents against an edited form of themselves, and it counts the compare_keys_merged warnings.

  • The benchmarks document_open, document_save (after a fifty-operation edit), document_resume (the working copy of an edited document) and document_files (the readable files alone).

  • The fuzz targets batch (a batch parsed as JSON or block text, written as JSON and parsed back, then applied to a small document tracked, untracked or as a dry run) and files (an edit of document.md), with their dictionaries and seeds. Both run in CI's fuzz job.

  • The TypeScript library over Node-API (crates/stetdoc-napi, package @clauseo/stetdoc in its npm/ directory): Document with open, resume, view, edit, save, inspect, revisions, lookup, check, checkPending, files and export, and compare, capabilities and jsonSchema. Calls are synchronous, data crosses as the core's JSON, offsets count UTF-16 code units unless an edit asks for "scalar", and every failure is a thrown StetdocError with its code, next, and the opIndex, file and line the core set. A smoke test runs the same file under Node and Bun.

  • stetdoc-editor: the backend of Claude's text editor tool (text_editor_20250728) over the readable files. view shows a file with line numbers or lists the files, str_replace needs a unique match (otherwise ambiguous_match, listing each match with context, or not_found), str_replace and insert commit at once as one tracked edit and show the touched blocks where they now are, and create and undo_edit are unsupported. The library offers it as Document.textEditor(input, { author, date, pathPrefix }), which takes the input exactly as the model sends it and returns the tool_result text, its isError flag, whether it committed and the receipt.

  • The library's data types in npm/types.d.ts are generated from the core's JSON Schema by cargo xtask codegen, and a test fails when they are stale. The library exports version and formatVersion, and loads in Node and Bun worker threads.

  • Packaging and release: one npm package per target (@clauseo/stetdoc-<target>, seven of them) as exact optional dependencies of @clauseo/stetdoc, with no install script; .github/workflows/release.yml, which builds the CLI and the addon for the seven targets, smoke-tests them on Bun 1.3.13, Bun 1.3.14 and Node 24, and attaches the archives, their SHA-256 files and the npm tarballs to a draft GitHub release without publishing anything; a napi job in CI; and cargo xtask release check, which replaces the placeholder cargo xtask release and keeps the packages and the workflow in step with the workspace.

  • The library's documentation, crates/stetdoc-napi/npm/README.md, shipped in the package, and its sections in README.md and CONTRIBUTING.md.

  • stetdoc edit FILE.docx --in-place (and in_place on the MCP edit tool) writes the result back to the .docx it read, atomically: a temporary file beside it, readable by its owner only until it gets the file's permissions, an fsync and a rename (and a sync of the folder on Unix). A link is written through, a hard link is broken, the owner is not kept, and a read-only file is refused. If someone wrote the file during the edit, the write is refused with stale_target and the file is untouched. The meaning of stale_target now covers this case; its code and exit class are unchanged. It is refused with --dry-run, with -o, and on a working copy, which an edit always writes. -o naming the input is still refused, and its next now names --in-place.

  • stetdoc pack DIR --done (and done on the MCP pack tool) removes the working copy once the .docx is written and validated. It removes only what stetdoc writes (the manifest first), so files it did not write, in the folder or in .stet/, stay with their folders and are named. A working copy named through a link has the folder it links to removed, then the link. When the pack fails, the working copy stays.

  • stetdoc check FILE --all lists every finding the report keeps (the first 200), not only the first 20.

Changed

  • The MCP tools' list is 11 tools and about 4,100 tokens (16,419 characters), from 12 and about 7,200: each description says the few words a model needs, steers to one replace, comment or edit call on the .docx with no unpack, pack, check or dry run, and leaves the detail to skills_get and the refusals. capabilities is no longer an MCP tool (skills_get teaches the operations and the error codes; the CLI keeps it), and the edit tool leaves out offsets (decision 0013 (on GitHub)).

  • stetdoc mcp answers a call with the text the CLI prints, which is what the skill teaches and a tenth of the size of the JSON for a typical receipt; structuredContent still carries the JSON whole. Next steps and refusals name the tool's properties (all: true, range, output), not the CLI's flags.

  • stetdoc mcp takes "true" and "false" as text for a boolean argument and for a typed operation's boolean field, and a number as text for a typed operation's number field, since some clients (Gemini's among them) send them so.

  • stetdoc mcp finds a working copy or a document passed by a relative name that is not there as given in the folders of the documents the session has named or written, most recent first, so contract.stet works after unpack wrote it beside a document in another folder. A path to write is never moved, and --root still confines every path.

  • Every receipt's check line reads as the completed validation it is: validated 3 units: no problems, and this file needs no separate check. The core skill no longer carries the table of refusal codes (skills get errors has it), and check is described, in the MCP tool and the skill, as for files stetdoc did not write and for hand edits.

  • An ambiguous_match refusal of replace or comment says how to retry in one call: --all for every place, or --range with the ref of one place, such as --range p12..p12, and that no dry run is needed.

  • The npm packages move under the Clauseo organisation: the Node library is @clauseo/stetdoc with platform packages @clauseo/stetdoc-<target>, and the CLI's platform packages are @clauseo/stetdoc-cli-<target>. The CLI itself stays stetdoc, and its release tarball is stetdoc-<version>.tgz. The Homebrew tap is clauseo-ai/homebrew-tap, so the install line is brew install clauseo-ai/tap/stetdoc.

  • Publishing a release on GitHub uploads its wheels and sdist to PyPI by trusted publishing (the publish-pypi job of release.yml, in the pypi environment). The tag build still only makes the draft.

  • packaging/README.md and the npm package's README say how Bun users get the fast install: bun add -g --trust stetdoc or bun pm -g trust stetdoc, and bunx --bun stetdoc for one-off runs.

  • The skill says to use --manual when the user, or a document or note they point to (such as a client email), asks for manual markup or no Track Changes, and never view --all on a long document: page with -- more: or narrow with --find or --range. skills get core is shorter, so every part of the skill fits in one tool output (under 25 KB), and the stub stays near 450 tokens. The previous stub is skill/earlier/stub-8.md.

  • The released binaries, the npm addon and the wheels are built with a new dist profile (fat LTO, one codegen unit, stripped), and the CLI allocates through mimalloc: the macOS binary is 7.4 MB instead of 9.9 MB, the engine benchmarks run about 8% faster, and CLI calls about 9% faster. cargo build --release is unchanged.

  • docs/agents.md and packaging/README.md recommend a binary on the PATH (Homebrew, the install script, uv tool install) for agents that call stetdoc many times, and npx stetdoc mcp for the MCP server.

  • A find hit or an occurrence names a clause only when its paragraph is that clause or its own text. A recital after the parties "(1)" and "(2)" names no clause and sits under its heading ({in p15 "RECITALS"}), and a paragraph after a list item such as (g) names the clause the list is in ({§14.1}, not {§14.1(g)}). A bare label keeps its noun: {§Schedule 1}.

  • A find hit in a table cell shows the other cells of its row ({row: p36 "Greater than 4.25 to 1.00", p38 "2.25%"}), and a compact find pages by hit line, so a table with many matching cells no longer comes on one page.

  • edit, replace and comment on a .docx with neither -o nor --in-place write NAME.edited.docx beside it, the name pack uses, or NAME.edited-2.docx and so on when that exists, and say so. Before, they refused.

  • An empty w:rPr or w:pPr (empty_properties) is a note, not a violation: the schema allows it. check and check --strict pass a document that has them, and a receipt no longer counts them as problems.

  • The MCP edit and replace tools' descriptions say to call skills_get first for the batch syntax and the rules.

  • stetdoc edit --dry-run on a .docx with -o or --in-place writes nothing, and says so on its first line, instead of refusing the flag. A dry run needs neither flag, as replace and comment already allowed.

  • stetdoc view --find lists one line for each paragraph that matches: its ref, its clause number, and about 100 characters of its exact text around the match, under a line for each top-level clause, with a tip that names the clause to view whole when a clause heading matches. --context N (N of 1 or more) shows the blocks whole, as before. On a 75,000-character contract, --find days went from 11.5 KB for the first 9 of 38 hits to 5.3 KB for all 38.

  • The Claude Code plugin no longer registers the MCP server. In Claude Code the skill and the stetdoc command are the route: Claude Code keeps MCP tools behind its tool search, so with both an agent switched between surfaces, and the server is the same binary, so it was no fallback. The Cursor and Codex plugins keep it.

  • The receipt of a command that writes a .docx (edit -o, edit --in-place, compare) carries the base of the written file, which opens in a new ref namespace, so the next batch on it can start from the receipt. When the edit added or removed blocks, the written file numbers them again: the receipt then has no base (null in JSON) and a next: line that says to view the file. Before, the receipt's base was refused as stale_ref on the written file.

  • The next of a stale_ref for a batch from another namespace says that a file rewritten since the view or receipt has new refs, and to view it again.

  • compare ends its receipt with next: stetdoc view OUT --revisions, which lists the redline's changes without unpacking.

  • A batch's ids reads [57, 58] and #57, #58 as well as 57, 58, and a value that is not a number is refused with a message that says ids is a comma-separated list of whole numbers.

  • The agent skill says to view a .docx directly rather than unpack it to read, to use --in-place only on a file the user asked to update or that the task wrote, and how to take the next base after writing a .docx.

  • The default page of a view is 3,000 tokens (about 12 KB), not 8,000, in the CLI, the MCP view tool and Budget::default(), so that one view, or two or three in one call, stays under what an agent's tool result holds.

  • A receipt of edit, pack or compare counts the problems that were already in the input on one line, such as "66 problems were already in the input, and no new one was added: stetdoc check --all lists them.", instead of listing ten of them. The JSON still has the first ten in validation.carried. A problem the edit would add still refuses it with validation_failed, naming the problems.

  • The pack receipt says that the working copy stays until the folder is deleted, and that pack --done removes it. The JSON has working_copy_removed.

  • The README said that any batch written against an older state is refused. What is refused is a batch from another document or history, or one that names a block changed since its base; a batch from an older state of the same history that touches only unchanged blocks applies, as the design says (docs/design/refs-and-state.md).

  • The first view of a large document, and the first range with its margin notes, take about half the time they did: the text model is built only for the paragraphs that hold a note reference when the notes are numbered, a UTF-8 part's names and text are not checked again on every read, a revision is looked for by its name before its namespace, and mentions of clauses are found in one pass over the words. Views, receipts and peak memory are unchanged.

Fixed

  • stetdoc check lists mc_prefix_undeclared first, on a .docx, a working copy and the MCP check tool, so a prefix that mc:Ignorable names and nothing declares is the first thing the check says about the input. It used to come after the part's schema findings, past the 20 that the text lists without --all, and past the 200 that a report keeps when the part had more. Its message names the part and the prefixes, says that Word refuses the file or offers to repair it, and says which prefixes stetdoc declares and which it removes when it writes the document (decision 0014 (on GitHub)).

  • An input whose mc:Ignorable names prefixes it never declares, as python-docx leaves some templates' main part, no longer gives an output that Word refuses to open ("Word found unreadable content"). Every edit, compare, accept and reject now declares the prefixes Word knows (w14, wp14, w15, the w16 family and the rest Word writes) on the element that names them and drops the ones nobody knows, in every part the check reads, touched or not, and changes nothing else. The receipt says what it repaired in a new warning, repaired, and stetdoc check reports the defect on any document as mc_prefix_undeclared, a problem already in the input (decision 0014 (on GitHub)). The Harvey indenture of the long markup task opened in Word only after this fix.

  • A run that an edit creates or cuts off (new text, the rest of a split run, a comment's anchor, a new row, compare's runs) gets its run properties in the order Word writes, even when the run it copies from has them out of order. Before, each copy repeated the defect, and the Open XML SDK validator counted every copy as a new schema error (33 new errors in one counter-redline of a credit support annex). The runs the document had keep their bytes, including the part of a split run that stays where it was. check now counts a property out of the order Word writes (schema_non_canonical_order) as a problem the edit added when a block has more of them than before; the ones the input had are still reported as already there.

  • stetdoc edit FILE.docx --dry-run works with neither -o nor --in-place, so the two refusals no longer send an agent round in a loop. The MCP edit tool's dry_run takes the same path.

  • A missing input path is not_found with the message view gives, for every command that takes one (replace, comment, edit, pack, check, inspect, lookup, view, unpack and compare). Before, replace and comment said the file was a .docx that needed -o, and pack said a folder was not a working copy.

  • The random law L2 allows a table next to a removed block to gain, in its first or last paragraph, the comment markers that the removal hoists there (design revisions.md, 3.6.5). It failed on floattable-anchorpos.docx, where deleting a table moved its comment into the first cell of the table after it.

  • Rejecting the inserted paragraphs of a footnote, an endnote or a comment whose reference stays keeps the note's last paragraph, emptied, instead of leaving a note with no paragraph, which the check refused as validation_failed (Revision footnotes.docx). When the reference is rejected too, the note goes with it, as before.

  • The random law L2 reads <w:p a="1" /> opened as <w:p a="1" > to hold the comment markers that a deletion hoists into it as the same start tag. It took the space before the > for a changed paragraph and failed on tdf112450_vml_polyline.docx.

  • Resolving a move removes the range markers of both its sides, as the design says (resolution is by move), also when an earlier rejection had emptied one side. The emptied side's markers stayed under the move's name, and check refused the edit for a move with a source and no destination (Tika-792.docx).

  • Rejecting a paragraph inserted after an empty last paragraph no longer fails with validation_failed when the original paragraph had a problem in its attributes, such as a Word 2007 beta paraId: the paragraph that stays takes the original's attributes back, and the reject now declares that it came from the original, so check carries the problem.

  • An operation's added in a receipt lists only refs the edit minted. When a block a split made was then joined into an older block by an accept or reject of the same batch, the joined block keeps the older ref, and the split listed that ref as added, in a dry run too, which must issue none (H14).

  • An accept that leaves a range marker between blocks (the paragraph that held only a move's range start goes) no longer fails with validation_failed when the marker already had a problem, such as a w:moveFromRangeStart without its w:date: check gives the body the problems of the paragraph the marker came from, as it does for any other unit.

  • Accepting the deletion of a table keeps a comment's range start before its end when the markers stand at different depths (the end inside another author's insertion). The end came first, and check refused the accept.

  • Accepting or rejecting a move removes a deletion that held nothing but the move's range marker, instead of leaving an empty w:del that the compiler refused as internal and check as a new violation.

  • A table of contents, or another field whose result spans paragraphs, no longer collapses in a view when that hides something the view shows. In Redline a tracked change on the mark of the paragraph where the field ends keeps it open, so a tracked split there is shown. In Final that paragraph joins the next one when its mark is deleted, so the field stays open when the next paragraph has text, and takes in an empty one, as it does once the deletion is accepted. A paragraph that joins the one where the field begins is read the same way.

  • The random law L5 compares each paragraph's style against its own view's common style, so a reject that changes which style is commonest in the document no longer reads as a change on every line. It tolerates exactly one empty paragraph that a reject adds as the last block of a cell whose last block was a table (W9), where it used to drop every such paragraph from both sides (tdf153891.docx).

  • A split before a paragraph's text (at: 0, or before its first word) keeps the paragraph's ref, element and attributes on the text and adds the new empty paragraph, with the new ref, before it. Rejecting the split gives back the very paragraph under its old ref; before, the text came back under the new ref and lost the paragraph's w14:paraId and rsids.

  • A deleted paragraph is one ParagraphDeleted change with every piece of its deletion, a piece that holds nothing with width (a field's begin and code) included. Such a piece was a change of its own, so rejecting the paragraph's deletion gave back a field's result without its begin, and the edit was refused with validation_failed.

  • A tracked merge of two list paragraphs that each hold a legacy w:numberingChange is applied. Their change ids made their properties look different, so the second took the first's, and the copy carried the first's numbering change and its id: two tracked changes had the same id and the edit was refused with validation_failed. The copy now follows the copy rule and leaves out every change-tracking element.

  • A tracked set on a table, row, cell or section whose properties already break the schema (a second w:tblLook) is applied. The copy of the old properties in the change's record carried the same problem, and the check counted it as new and refused the edit with validation_failed.

  • compare places the deleted blocks at the end of a story after the last inserted table, before the paragraphs that end the story, so that rejecting everything restores the original's last paragraph. Placed before the table, the paragraph that took the inserted end mark could not join across the table that rejecting removes, and an empty paragraph was left (DB010).

  • The random law L3 expects a whole-paragraph replace of a note's paragraph to keep the note's own number (U+0003), as every text edit does (design revisions.md, 3.6.4). It expected the bare text and failed on a note of tdf129659.docx.

  • A paragraph inserted after the last paragraph of a cell, a note, a comment or the body is written in Word's Enter shape: the anchor's mark is inserted and the new paragraph keeps the container's own, so Word's Reject All gives the original back. With an inserted mark of its own, Word left the cell an empty paragraph (hand check C11), and stetdoc's own reject now does the same for such a paragraph, as Word and LibreOffice do. Where that shape cannot go (the last paragraph's mark is a pending deletion or move) the insertion is readonly_region. compare writes a run of new paragraphs at the end of a container the same way. A paragraph that holds only a field and joins the next one in a view keeps its place there, with the field.

  • compare never leaves a deleted table directly against an inserted one, which Word showed with a gap that stayed after Accept All and after Reject All: a table replaced by another is one table of deleted rows and inserted rows, whatever their grids: the rows take one grid, with every column edge of both when the deleted rows would not fit the revised grid as they are.

  • Deleting a whole note with delete-note deletes the note's own number with its text, as Word writes it (RP050): with the number left live, Word for the web kept an empty note after Accept All. A text edit of a note still keeps its number.

  • A field that spans paragraphs, such as an index, collapses in the Final view when the only text beside it is a pending deletion, as it does once the deletion is accepted, so accepting no longer changes that view (law L5, release seed 303). Redline still shows the paragraphs, with the deletion.

  • A text or property edit of a paragraph inside a body-level move away (w:moveFrom) or deletion (w:del) is readonly_region, as an edit of deleted text is, where it used to write text that the Final view does not show.

  • A paragraph inserted next to a block inside a body-level move, insertion or deletion goes outside that change, before its first block or after its last, where it used to land inside it and read as empty; anywhere else inside it is readonly_region.

  • An insert of many paragraphs costs time in proportion to them, where each new paragraph used to scan the story again (4,000 empty paragraphs took 2.2 s, now about 0.1 s). Each paragraph of an insert now counts as one operation toward max_batch_ops, as each new paragraph already did on the files path, and the paragraphs of one insert follow the quotes of their anchor as it was before the operation.

  • A style that does not exist, in style, format's char_style or an inserted paragraph, is invalid_payload with the nearest names of that kind, as the error table says, and no longer not_found.

  • set refuses a property its container does not allow wherever the paragraph sits, including a paragraph inside a body-level move, where it used to be written.

  • An operation that names a block of a header or a footer that an earlier operation of the batch took out of the document (an accepted deletion of the section break that named it) is refused as stale_ref, and an edit of that footer earlier in the batch is applied and its refs retired. Both used to fail as internal.

  • A receipt's retired lists the ref of every block a merge or a join absorbed, as merged into p4 (or the survivor's reason, when the same edit removed the survivor too), as the design says. It used to list only blocks that left the document.

  • A delete from ref through to compares its two points in document order: start in the first paragraph may be greater than end in the last, and to naming the same paragraph deletes the range between the two offsets.

  • check reads a paragraph, run, table, row or cell by its own content model wherever it stands (inside a body-level move, under an unknown element), and the survivor of a join carries the problems the joined paragraphs already had.

  • Accepting a merge, or any move, in a document that binds one prefix to different namespaces in different places declares the prefix on what moves, where it used to be refused as unsupported.

  • A block that a dry run creates shows its tags ({¶+ ...} and the others) in the receipt, so it reads as the real edit's line without the ref.

  • A refusal of readable files (a ref on two lines, a syntax error, a read-only line) carries its file and line in StetError::file and StetError::line, not only in its message, as a scope violation does.

  • A merge across an imported document part (w:altChunk) is refused, and a paragraph deleted right before one keeps its mark, so accepting all reads as Final.

  • Text put into an empty paragraph whose mark is hidden is not hidden.

  • A w:t in a w:moveFrom is no longer reported as text in a deletion.

  • Paragraph ids in the w15 namespace of Word 2013's preview are read and validated as w15.

  • A row inserted by one author and deleted by another is gone in Final and in Original.

  • Section 9.2 of This Agreement and PARAGRAPH (a) OF THIS SECTION cite this document, not another: the word after of is compared in any case with this, these and hereof.

  • The readable files are never written or read in json/1: render_files and to_batch refuse it with unsupported.

  • Rejecting a tracked format on a run whose properties hold a property twice (tdf104649) puts both back instead of failing with invalid_payload.

  • A paragraph added after one with a hidden paragraph mark is not hidden text, a copied content control loses its w:dataBinding, and new paragraphs get w14:paraId and w14:textId only in the Word 2010 namespace.

  • A tracked deletion of an equation that already holds a tracked deletion is refused with readonly_region instead of writing a deletion inside a deletion.

  • The quote policy shapes only the quotes an edit types: a paragraph that mixes curly and straight quotes, rewritten with one word changed, keeps the quotes it carried over.

  • Text put into the empty paragraph after an equation does not copy the mark's w:oMath into its run.

  • Rejecting a table whose rows were all inserted keeps a bookmark that starts in it and ends after it.

  • The old run properties in a w:rPrChange are checked for Word's order, so rejecting a format that removed a duplicate property (tdf168070) is not refused as adding it.

  • Clearing the last property of a run removes its empty w:rPr.

  • check carries a copy of a defect in a w:rPr or a w:pPr (a mark's properties copied onto a run, a paragraph's into a w:pPrChange) instead of refusing the edit for it.

  • An untracked edit never rewrites or hides another author's pending change: text inside their insertion, properties under their formatting change, a run they inserted, or a paragraph or row that holds their changes is readonly_region, and the message says to accept or reject that change first. Untracked still means your own changes go untracked.

  • The Claude Code guard hook reads the tool call to the end when STETDOC_GUARD=off turns it off, so Claude Code never writes into a closed pipe. Its test failed now and then in CI for that reason.

  • A comment added to a package that binds w15 to the namespace of Word 2013's preview (http://schemas.microsoft.com/office/word/2010/11/wordml) and has no commentsExtended.xml no longer creates that part, which made desktop Word report unreadable content. A reply, resolve or reopen there is refused as unsupported, since a thread and its done state live only in that part.

  • An insert at the very start of a paragraph goes after w:pPr when an XML comment or processing instruction stands before it. It used to land before w:pPr, and the edit was refused with validation_failed.

  • Deleting a table that is the first or last block keeps a comment whose range runs from inside the table to a marker between blocks, as an empty-range comment like the others. It used to remove that comment.

  • The Final and Original views collapse a long field, such as a table of contents, exactly when the accepted or rejected file would: a pending paragraph mark that joins two of its paragraphs is counted as joined.

  • After a tracked split and a delete of the same paragraph, the view keeps the fields that come back into the paragraph. It used to drop them until the document was opened again.

  • A delete from the middle of one paragraph to the middle of another keeps a comment whose range starts or ends in a paragraph the delete takes whole, or at a marker between paragraphs, as an empty-range comment if all its text goes. The edit used to be refused with validation_failed, and accepting such a tracked delete failed the same way.