All notable changes to this project will be documented in this file. The format is loosely based on Keep a Changelog, and the project adheres to semantic versioning once it leaves 0.x.
Robustness/correctness fixes from a follow-up source audit.
ref:party-field values are no longer silently coerced.resolveRefnow routes values throughcoerceParamValue, so an object/array/boolean/nullis rejected withEXIT.VALIDATIONinstead of beingString()-coerced ([object Object], comma-joins,"true","") into the document at exit 0. The 0.10.1 fix had left this sibling raw.$-patterns in resolved values are inserted literally.substituteandsubstituteDocxXmluse a function replacer so$&/$$/$`/$'in a value are no longer interpreted as replacement patterns.- No more spurious "spans multiple runs" warning.
substituteDocxXmlqueues cross-run work only when the phrase still exists post-substitution, so a phrase occurring N times no longer re-queues the 2nd..Nth hit. --version/--catalognow report the real version (VERSIONhad drifted to0.10.0while the package was0.10.1).
- Non-string param values are no longer silently coerced into the output
document. A
--params/--from-dealvalue that wasn't a string or number (object, array,null, boolean) used to beString()-coerced and written into the legal document at exit 0 — an object became the literal[object Object], an array comma-joined,null→"null"(silent data loss). Such values now fail with a clearEXIT.VALIDATION(exit 2) error naming the offending key; finite numbers are still accepted and stringified. - Custom dictionaries with non-string elements now error clearly
(
dictionary entries must be strings,EXIT.IO) instead of leaking an internals.replace is not a functionTypeError when the tier-4 heuristic runs. - Argument parsing robustness:
--params/--output/--parties/--bundle/--from-deal/--dictionarynow error if no value follows instead of silently swallowing the next flag, and--catalogonly consumes the next token when it is exactlyjson(sodraft --catalog template.txtkeepstemplate.txtas the input file). The bare-stdout sentinel is still accepted as a value.
draft --catalog json— the suite-wide discovery contract ({name, bin, version, description, flags, exitCodes}), so an agent learns every flag at startup instead of hardcoding them.
--bundleis now listed indraft --catalog json(it was accepted but unlisted). Friendly first-run hint on a baredraftinvocation (was a terse error); exit-code, cross-CLI, and metadata doc accuracy.
-
.docxsubstitution now merges runs across a placeholder by default. Real-world Word documents (notably the Common Paper templates) often split[Fill in: …]placeholders across two or three<w:r>runs because brackets and inner text carry different styling. v0.2.0 detected this case and skipped the substitution with a warning, leaving the user to retype the placeholder in Word — fine for the rare case, but a blocker on most production contracts where every placeholder spans runs.v0.9.0 changes the default: when a placeholder spans multiple runs inside the same paragraph,
substituteDocxXmlnow collapses the contributing runs into one run that uses the first contributing run's<w:rPr>. Mid-placeholder styling variations are lost (e.g. if[was bold and the inner text was italic, the merged run is bold). Styling on the runs flanking the placeholder is preserved: any trailing text from the last contributing run keeps its original<w:rPr>as a second run. Each merge emits awarning: docx run merge applied for "<key>": …line on stderr so the user knows what was rewritten. -
New
--strict-runsflag restores v0.2.0 behavior: cross-run placeholders are skipped (not merged) and emit askippedwarning. Use this when you want to preserve every style boundary inside placeholders and are willing to retype them in Word. -
Cross-paragraph placeholders are still skipped regardless of
--strict-runs— collapsing runs across a<w:p>boundary would destroy paragraph structure, which is rarely what the user wants.
substituteDocxXml(xml, placeholders, values, tier, opts?)— signature gains a fifthoptsargument with{ mergeRuns?: boolean }(defaulttrue). Return shape changes from{ xml, warnings: string[] }to{ xml, merged: string[], skipped: string[] }— the two arrays list placeholder keys, and callers (or the CLI) format their own warning text.
- Q1.1 (.docx round-trip behavior, revised): v0.2.0 said split-
run placeholders are skipped with a warning. v0.9.0 changes the
default to merge with the first contributing run's
<w:rPr>— the trade-off (in-placeholder style loss) is acceptable because (a) most templates use uniform styling inside[…]anyway and (b) the alternative (skipping by default) blocks ~every real- world Common Paper-style template.--strict-runsis the documented escape hatch for users who care about every style boundary.
- The original
originalTextheuristic (scan the whole document for the find string after a failed per-run substitution) is now the trigger for phase 2 (cross-run merge) rather than a warning. - Tests updated: the v0.2.0 "spans multiple runs → skipped"
assertion is preserved under
--strict-runs; new tests cover the merge path (Common Paper-style split brackets, prefix/suffix styling preservation, multiple split occurrences per paragraph).
- Animated SVG demo embedded in README. Self-contained
(
assets/demo.svg, ~11 KB) — plays inline on github.com and npmjs.com without an asciinema.org dependency. Shows--list-placeholdersthen a--diffsubstitution against the Common Paper Mutual NDA cover page fixture inexamples/. - Strictly docs + assets; no code or behavior change.
- README refreshed for the v2 surface. New "v2 features" section
with one-paragraph showcases for each shipped v2 capability (.docx
round-trip, typed parameters, computed placeholders, positional
addressing,
parties.jsonregistry, multi-document bundles,--from-dealLLM inference). Command reference updated with new flags + version annotations. Exit-code table updated with code 4 (validation / typed / computed / ref / positional) and code 5 (LLM). Intro paragraph rewritten to reflect the v2 capability scope instead of just v1. - No code or behavior change. v0.8.0's API surface, schema contract, and CLI flags are unchanged. Strictly a docs publish.
- LLM inference from a deal description (last v2 item). New
--from-deal PATHflag reads a free-form deal description and asks the configured T5 LLM provider to extract values for the schema's declared placeholders:Wheredraft nda.md --from-deal deal-notes.txt --output draft.md
deal-notes.txtis unstructured prose:The LLM is asked to fillMutual NDA between Acme Corporation (DE) and Globex (UK), effective June 1, 2026, for a 2-year term.party_a,party_a_state,party_b,effective_date, etc. — only the keys already detected as placeholders are extracted. - Value-resolution precedence updated:
CLI flag > --params JSON > --from-deal (LLM) > --interactive > schema default > error. CLI / --params always win, so users can fix or override anything the LLM got wrong. - New public API:
inferFromDeal(dealText, placeholders, providerCfg, { fetcher }).
- Q4.1 Provider: same T5 provider config —
ANTHROPIC_API_KEY,OPENAI_API_KEY, or explicitDRAFT_LLM_*. No separate inference provider; one network surface, one set of env vars. - Q4.2 Extra keys: keys the LLM emits that aren't in the detected placeholders are warned to stderr (not dropped silently). The LLM gets a fresh list of allowed keys in the prompt so this is rare in practice.
- Q4.3 Auto-LLM:
--from-dealdoes not require an explicit--llmflag — the inference is implicit.--no-llmstill disables it (the user can opt out of the network call).
--from-dealerrors are fatal (EXIT.LLMfor provider / network / parse failures). Users with bad provider configs see the issue immediately rather than silently running with no inferred values.- Bundle mode (v0.7.0) does not yet thread
--from-dealthrough per-template inference. Deferred to a follow-up; the shared parameter resolution makes the single-doc API already useful for bundles via--params.
- Multi-document bundles.
draft --bundle <bundle.json>reads a bundle definition and fills multiple templates with the same set of parameter values in one invocation:Each template runs through detection independently. Placeholders across templates are unioned by key (so a key declared in any template's schema applies to all — Q3.3 locked). Resolution, typed-parameter normalization, and computed values all run once on the union. Each output is then substituted using its own template/tier and written to its own path.{ "_meta": { "schema_version": 1 }, "outputs": [ { "template": "msa/v3.md", "output": "out/msa.md" }, { "template": "order-form/v3.md", "output": "out/order-form.md" } ] }parties.jsonrefs (v0.6.0) resolve inside bundle entries too. - Schema-union semantics. A key declared/detected in any bundle template applies to every template in the bundle. First-occurrence metadata wins; resolved values flow to all templates that reference the same key.
.docxbundle entries round-trip throughsubstituteDocxXmlwhen the entry'soutputpath has the.docxextension. Same runs/styles preservation as single-doc.docxmode.- New public API:
loadBundle(path),cmdBundle(opts, bundle, paramsObj, envObj, io).
- Q3.1 Bundle file format: JSON object with
outputsarray of{template, output}pairs. Each entry has its own output path, enabling per-doc overrides without inventing a custom DSL. - Q3.2 Partial-failure policy: abort-all. Any pre-write error (no detection in an entry, missing required param across the union, type / computed / ref failure, positional mismatch, schema orphan) exits 4 before any file is written. Write failures mid-bundle exit 1; earlier successful writes are not rolled back (best-effort atomicity at the filesystem boundary).
- Q3.3 Schema union semantics: keys declared in any template's schema (or detected as canonical-key matches without a schema) apply across the bundle. Same value resolves into every template that references the key.
- Cross-template
parties.jsonregistry. A repo-localparties.jsondeclares known parties once; templates' schemas reference fields withref:parties.<key>.<field>:// parties.json { "acme_corp": { "name": "Acme Corporation", "state": "Delaware" } }
Resolution happens between// <template>.params.json { "_meta": { "v": 1 }, "party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" }, "party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" } }
resolveValuesand typed-parameter normalization, soref:-resolved values feed cleanly intotype: date | money | partyflows. --parties PATHflag overrides the default CWD/parties.jsonlookup. Missing explicit path → exit 1 with a clear error.- New public API:
loadParties(path),resolveRef(value, parties),resolveRefs(resolved, sources, parties).
- Q2.1 File location: default is
./parties.jsonin CWD; override with--parties PATH. - Q2.2 Ref scope: refs resolve in
--paramsJSON and schemadefaultvalues only. CLI flag values withref:prefix pass through unchanged — they're treated as literal strings. - Q2.3 Versioning: out of scope for v0.6.0. When a party's
metadata changes in
parties.json, all drafts that ref it produce different output if re-run. Documented as a known property.
PARAM_SCHEMA.md §5 gains a "Cross-template parties.json registry"
subsection. v0.6.0 schemas are forward-compatible with v0.5.x
readers — ref: strings just look like literal values to older
readers (and won't substitute correctly, but won't error out either
since "ref:..." is a valid string).
- Positional addressing for same-text placeholders with different
semantic roles. Long-form schema entries can declare a
positionsarray; each position gets its own canonical key (viarole), so the CLI uses standard--<role>flags. Validated against the YC SAFE$[_____________] × 2case (valuation cap vs. purchase amount)."blank": { "aliases": ["_____________"], "type": "money", "currency": "USD", "positions": [ { "role": "valuation_cap" }, { "role": "purchase_amount" } ] }
Decisions locked (V2_BRIEFS_REMAINING Q1.1–Q1.3):draft safe.docx \ --valuation-cap 5000000 \ --purchase-amount 100000
- Q1.1 Index base: schema positions are 0-indexed internally; the CLI uses role names, not numeric indices.
- Q1.2 Length mismatch: schema declares N positions but detection finds M ≠ N occurrences → hard error (exit 4).
- Q1.3 Bare-key CLI: a
--<role>flag targets its specific position; values still flow through--paramsJSON or--interactivenormally.
- Positional addressing only works at tier T1 (bracket) and T2
(mustache) — those tiers carry per-hit byte indices needed for
position-specific substitution. T3 (docx-highlight), T4 (heuristic),
T5 (LLM) raise a positional error if a positional schema entry's
aliases are matched by them.
.docxtemplates with[X]brackets that fire T1 still work;.docxtemplates that rely on T3 highlights for the same alias do not.
PARAM_SCHEMA.md §5 gains a "Positional addressing" subsection. Long-
form entries can now include a positions array; short form is
unchanged. Forward-compatible with v0.4.x readers — they'll ignore the
unknown field and treat the entry as a regular non-positional
placeholder (which means the first detected occurrence wins for
substitution, and ambiguity is unresolved).
- Computed placeholders. Long-form schema entries can declare a
computedblock referencing another key:At substitution time, if no value was supplied via CLI /"term_end": { "aliases": ["Term End"], "type": "date", "format": "MMMM d, yyyy", "computed": { "from": "effective_date", "op": "+", "value": "2 years" } }
--params/ interactive / default, the computed entry's value is derived from thefromplaceholder. Explicit CLI /--paramsvalues still win — computed only fills the gap. Q2.1 locked: expression syntax lives in the schema only, not in template text — keeps T1 detection unchanged. Q2.2 locked: v0.4.0 supports date arithmetic only (+/-with<n> day|week|month|year[s]durations). Money math and string concat deferred to a future release. - Schema-time cycle detection.
parseSchemathrows if anycomputed.fromchain revisits a key (e.g.a → b → a), or ifcomputed.fromreferences a key that doesn't exist in the same schema. Catches misconfiguration before substitution starts. - Orphan-check exemption. Schema entries that are referenced only
as another entry's
computed.fromsource (and never appear as detected aliases in the template) are no longer reported as orphans. They're "feeders" — declared so a computed entry can reference them, even though the template doesn't show them. - New public API:
parseDuration(raw),addDuration(date, op, dur),computeValues(placeholders, resolved).
PARAM_SCHEMA.md §5 gains a "Computed placeholders" section. Long-
form entries can now include a computed: { from, op, value } block;
short form is unchanged. v0.4.0 schemas are forward-compatible with
v0.3.x readers (which will silently ignore the computed field as
unrecognized long-form metadata, treating the entry as a regular
placeholder — but then the user has to supply a value, since v0.3.x
won't compute one).
- Publish auth: restored
NODE_AUTH_TOKENenv block. v0.3.1's hotfix bumpedpublish.ymlNode from 20 to 22 on the hypothesis that npm CLI 11.5.1+ would auto-detect OIDC and ignore the setup-node placeholder. It didn't: Node 22's npm 11.x still sent the literalXXXXX-XXXXX-XXXXX-XXXXXplaceholder and got the same 404 from npm. Root cause is not Node version; eithersetup-node@v6always writes the placeholder env into the publish step, or npm CLI prefers the.npmrctoken over OIDC even when both are available. Under investigation. - v0.3.1 tag exists on GitHub but did NOT publish to npm. Skip
it. Registry latest is
0.2.0until v0.3.2 ships.
Pragmatic call: v0.3.2 ships via the bootstrap NPM_TOKEN path.
Trusted Publisher stays configured on npm so the switch back to
pure OIDC is a one-line change in publish.yml once we understand
why npm CLI isn't using OIDC. feedback_oidc_setup_node_v6_placeholder.md
in memory tracks the symptom + workarounds tried.
publish.ymlnow uses Node 22 instead of Node 20. npm Trusted Publishing requires npm CLI 11.5.1 or later, which ships with Node 22.14+. Node 20 (npm 10.x) silently falls back toNODE_AUTH_TOKENwhen configured for a registry, and tosetup-node@v6's placeholder value (XXXXX-...) when the env isn't set — producing a 404 from npm masking the actual 401.- v0.3.0 tag exists on GitHub but did NOT publish to npm. v0.3.0
was the first publish attempt without an
NPM_TOKENfallback (PR #10 reverted the bootstrap); the npm-CLI-too-old issue surfaced immediately. No package was uploaded. v0.3.1 is the rebrand of v0.3.0's typed-parameter feature with the workflow fix applied. Skip v0.3.0.
- Typed parameters (
type: date | money | party). Long-form schema entries can declare atype, with optionalformat(date) orcurrency(money). Inputs are validated and normalized between value resolution and substitution; bad inputs hard-error with a per-key message (exit 4). SeePARAM_SCHEMA.md§5 for the accepted shapes per type, the rejected ambiguous forms (Q3.1: USMM/DD/YYYYand EuropeanDD/MM/YYYYare rejected as ambiguous), and the v2 currency scope (Q3.2: USD only). --validatenow catches type errors before draft runs. With--json, errors are emitted as atype_errorsarray on the result payload.- New public API:
parseDateValue(raw),formatDateValue(date, fmt),parseMoneyValue(raw)→ minor units,formatMoneyValue(minor, currency),normalizeTypedValue(raw, placeholder),normalizeTypedValues(placeholders, resolved).
PARAM_SCHEMA.md §5 gains a "Typed parameters" section. Long-form
entries can now include type, format, and currency fields; short
form is unchanged. v0.3.0 schemas are forward-compatible with v0.2.x
readers (which will silently ignore the new fields, since they're
opt-in metadata on the long-form entry).
.docxoutput round-trip. Templates read from.docx(tier 3 highlight detection) now write back as.docx, preserving runs, styles, paragraph breaks, and every non-document part of the package ([Content_Types].xml, relationships, images, headers, etc.). Default output filename is<basename>-filled.docxnext to the input; override with--output PATH.docx. Schema-rescue, T1/T2 bracket/mustache detection, and T4/T5 substitution all benefit too — any tier that detects a placeholder in a.docxtemplate now substitutes back into the same runs.--output -writes plain text to stdout (Unix-convention). Use this on a.docxinput to get the substituted body as text instead of a.docxfile:draft contract.docx --output -.writeDocxBuffer(originalPath, newDocumentXml),makeDocxOutputPath(inputPath),substituteDocxXml(xml, placeholders, values, tier),decideDocxOutput(opts, input), andencodeXml(s)added to the public API for programmatic drivers. Same import surface assubstituteandextractDocxText.
- Default output for
.docxinput is now<basename>-filled.docx, not stdout text. Previously,draft contract.docx(no--output) extracted text and wrote substituted plain text to stdout. v0.2.0 writescontract-filled.docxnext to the input. Pipelines that depended on the stdout-text behavior should pass--output -to opt back in.
When a placeholder's text spans multiple <w:t> runs in the source
.docx (Word sometimes splits runs at punctuation, auto-correct
boundaries, or comment anchors), v0.2.0 emits a warning and skips
that substitution rather than merging the runs and losing run-level
styling. The warning explains how to fix the source: open the
document, retype the placeholder so it lives in one run, save, and
retry. This decision is logged in PARAM_SCHEMA.md §2.
- Globally-installed
draftbinary now runsmain(). The entrypoint check at the bottom ofdraft-cli.mjscomparedfileURLToPath(import.meta.url)againstresolve(process.argv[1]).resolveonly resolves relative → absolute; it does not resolve symlinks. Whennpm install -gcreates a bin symlink (e.g./opt/homebrew/bin/draft → ../lib/node_modules/@drbaher/draft-cli/draft-cli.mjs),process.argv[1]is the symlink path, so the comparison failed,main()was never called, anddraft --version/draft --demosilently exited 0 with no output. The fix wrapsresolve(...)inrealpathSync(...)to canonicalize through symlinks.
- CI smoke step asserts on stdout. Previously the workflow ran
draft --versionanddraft --demobut did not check exit code or expected output. Since the v0.1.0 bug made the bin a silent no-op with exit 0, smoke passed. The step now grepsdraft --versionstdout for thedraft-cliprefix anddraft --demostdout for the substituted Party A value (Acme Corporation) — proving both that the bin runs and that substitution happens end-to-end. A regression of either shape would now fail CI.
Initial release. Single-file Node.js CLI for deterministic placeholder substitution in legal-document templates. Part of the contract-ops suite (cli.drbaher.com).
- Five-tier sequential-with-stop detection cascade. First non-empty
tier wins.
- T1:
[Title Case]brackets. Common Paper / YC SAFE / Bonterms. - T2:
{{Title Case}}or{{snake_case}}mustache (opt-in via--syntax mustache). - T3:
.docxhighlight runs (yellow / green / cyan / magenta) viajszip+ regex onword/document.xml. - T4: Heuristic dictionary (
Acme Corporation,John Doe,example@example.com,MM/DD/YYYY, etc.). Warn-only by default; requires interactive confirmation or--yes-heuristicto substitute. - T5: LLM (Anthropic / OpenAI / explicit
DRAFT_LLM_*). Auto-runs only when.envor process env configures a provider.--no-llmdisables.
- T1:
- Schema file
<template>.params.jsonin short or long form. Auto-selected by presence of a top-level_metakey. Short form is{ key: [aliases…] }; long form supportsrequiredanddefault. - Value resolution precedence: CLI flag >
--paramsJSON >--interactiveprompt > schemadefault> error. - Three modes: main
draft,--list-placeholders,--validate. All three support--jsonand--whystructured explanation. - Composable I/O: stdin (
-), stdout default,--output PATH,template-vault getintegration for<category>/<name>[@version]refs. - ANSI color honors
NO_COLORandFORCE_COLOR; auto-disables off-TTY. --demoflag for a zero-file 30-second first run (npx @drbaher/draft-cli@latest --demo).--completion bash|zshflag that emits a hand-rolled shell completion script to stdout. Completes top-level flags, the--syntaxvalue (bracket/mustache), the--completionshell name, and file paths for--params/--output/--dictionary. No third-party generator.--check-llmruns a one-token roundtrip against the configured LLM provider — verifies env, auth, and reachability without sending any template content. Exits0on success,1if no provider is configured,4on provider error. Useful for CI / startup health checks in agent pipelines.--diffprints a per-placeholder substitution table to stdout and exits — never writes output. With--json, emits a structureddiffarray. Unresolved placeholders appear as(unresolved)/to: nullrather than erroring, so the caller can decide what to do.-q/--silentsuppresses all stderr (warnings,--whyblock, notes, heuristic confirmations) for fully-quiet pipeline use. Argument- parse errors still surface on the real stderr.- Schema-rescue for T1/T2 detection. Bracketed runs whose inner text
matches a schema-declared alias are admitted by detection even when
the heuristic rule would reject them. Lets all-caps signature-block
markers (
[COMPANY]) and fill-in markers ([_____________]) be brought into the alias map without loosening the heuristic itself. - Typo guard on
--<param-name>flags. Unused flags are surfaced as warnings and named in the missing-required error, so a typo'd--party-bbdoesn't silently fall through to a "missing party_b" error with no connection. - Exit codes:
0ok,1i/o,2validation,3template-vault failure,4LLM failure. - GitHub Actions CI: Ubuntu × macOS × Node 18 / 20 / 22 test matrix,
coverage gate at 80% line, and smoke job that packs + installs + runs
--version+--demo. - GitHub Actions publish: npm Trusted Publishing on
v*tag push, with version-vs-tag check and--provenanceattestation. - Test suite: 106 tests across 13 files (
unittest-style per concern), 87.2% line coverage ondraft-cli.mjs.
- One runtime dependency only:
jszip(MIT, zero transitive deps). - The LLM tier sends template text only — no params, no
.envcontents, no other data. No network call by default. - Configuration contract is captured in PARAM_SCHEMA.md, reviewed and locked before code.
- T1 bracket rule is permissive, not strict Title-Case. Real
Common Paper / YC SAFE / Bonterms templates use sentence-shaped
placeholders with full punctuation (
[Today’s date],[1 year(s)],[Fill in city or county and state, i.e. "courts located in New Castle, DE"]). The rule rejects markdown links ([label](url)), checkbox markers ([x],[ ]), pure section refs ([3.1]), all-caps headings, and punctuation-only brackets — but otherwise admits anything bracketed that contains at least one letter. False positives are filtered with the schema file; false negatives in this domain are higher-cost.
Three of the original seven v1 "Deferred" entries shipped in v0.2.0,
v0.3.2, and v0.4.0 (see entries above). The four remaining items are
the next chunk of design work, with briefs in V2_BRIEFS_REMAINING.md:
- Positional addressing. Disambiguate same-text placeholders by
index in the schema. The validated case: YC SAFE has
$[_____________]twice — once for the valuation cap, once for the purchase amount. Smallest of the four (~150 LOC). - Cross-template
parties.jsonregistry. Declare parties once withref:parties.<key>.<field>references from schemas. Eliminates duplicating party metadata across every template (~250 LOC). - Multi-document bundles. Resolve placeholders once and emit multiple documents in one call (MSA + Order Form + DPA with shared parameter values) (~250 LOC).
- LLM inference from a deal description.
--from-deal <path>reads free-form deal text and asks the T5 LLM provider to fill the schema's parameters. Inverse of the existing T5 detection (~250 LOC). .docxhighlight detection beyond yellow/green/cyan/magenta. v1 ignores other colors (black/white/none) by design. Backlog, not in V2_BRIEFS_REMAINING (low priority).