@drbaher/draft-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file. The
4
4
  format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
5
5
  and the project adheres to semantic versioning once it leaves 0.x.
6
6
 
7
+ ## 0.5.0 — 2026-05-16
8
+
9
+ ### Added
10
+
11
+ - **Positional addressing** for same-text placeholders with different
12
+ semantic roles. Long-form schema entries can declare a `positions`
13
+ array; each position gets its own canonical key (via `role`), so the
14
+ CLI uses standard `--<role>` flags. Validated against the YC SAFE
15
+ `$[_____________] × 2` case (valuation cap vs. purchase amount).
16
+ ```json
17
+ "blank": {
18
+ "aliases": ["_____________"],
19
+ "type": "money", "currency": "USD",
20
+ "positions": [
21
+ { "role": "valuation_cap" },
22
+ { "role": "purchase_amount" }
23
+ ]
24
+ }
25
+ ```
26
+ ```sh
27
+ draft safe.docx \
28
+ --valuation-cap 5000000 \
29
+ --purchase-amount 100000
30
+ ```
31
+ Decisions locked (V2_BRIEFS_REMAINING Q1.1–Q1.3):
32
+ - **Q1.1 Index base**: schema positions are 0-indexed internally;
33
+ the CLI uses role names, not numeric indices.
34
+ - **Q1.2 Length mismatch**: schema declares N positions but
35
+ detection finds M ≠ N occurrences → hard error (exit 4).
36
+ - **Q1.3 Bare-key CLI**: a `--<role>` flag targets its specific
37
+ position; values still flow through `--params` JSON or
38
+ `--interactive` normally.
39
+
40
+ ### Constraints
41
+
42
+ - Positional addressing only works at tier T1 (bracket) and T2
43
+ (mustache) — those tiers carry per-hit byte indices needed for
44
+ position-specific substitution. T3 (docx-highlight), T4 (heuristic),
45
+ T5 (LLM) raise a positional error if a positional schema entry's
46
+ aliases are matched by them. `.docx` templates with `[X]` brackets
47
+ that fire T1 still work; `.docx` templates that rely on T3 highlights
48
+ for the same alias do not.
49
+
50
+ ### Schema-contract change
51
+
52
+ `PARAM_SCHEMA.md` §5 gains a "Positional addressing" subsection. Long-
53
+ form entries can now include a `positions` array; short form is
54
+ unchanged. Forward-compatible with v0.4.x readers — they'll ignore the
55
+ unknown field and treat the entry as a regular non-positional
56
+ placeholder (which means the first detected occurrence wins for
57
+ substitution, and ambiguity is unresolved).
58
+
7
59
  ## 0.4.0 — 2026-05-16
8
60
 
9
61
  ### Added
@@ -263,22 +315,25 @@ suite ([cli.drbaher.com](https://cli.drbaher.com)).
263
315
  that contains at least one letter. False positives are filtered with
264
316
  the schema file; false negatives in this domain are higher-cost.
265
317
 
266
- ## Deferred (v2 candidates)
267
-
268
- - **`.docx` output round-trip.** v1 writes plain markdown even from a
269
- `.docx` input. Re-writing back into a `.docx` (preserving styles,
270
- numbering, and run formatting) is a separate problem.
271
- - **Computed placeholders** (`[Effective Date + 2 years]`). The long-form
272
- schema reserves a future `"computed"` field.
273
- - **Typed parameters** (`party`, `date`, `money` with format validation).
274
- Schema reserves a future `"type"` field.
275
- - **LLM-assisted parameter inference from a deal description.** v1's T5
276
- only suggests placeholders from template text not from external prose
277
- describing the deal.
278
- - **Cross-template parameter registry** (`parties.json` remembering
279
- addresses, e-signature contacts, etc.). Additive would layer
280
- underneath `--params` in precedence.
281
- - **Multi-document bundles** (MSA + SOW sharing parameters in one call).
282
- v1 is one document per invocation.
318
+ ## Deferred (post-v0.4.0 candidates)
319
+
320
+ Three of the original seven v1 "Deferred" entries shipped in v0.2.0,
321
+ v0.3.2, and v0.4.0 (see entries above). The four remaining items are
322
+ the next chunk of design work, with briefs in `V2_BRIEFS_REMAINING.md`:
323
+
324
+ - **Positional addressing.** Disambiguate same-text placeholders by
325
+ index in the schema. The validated case: YC SAFE has
326
+ `$[_____________]` twice once for the valuation cap, once for
327
+ the purchase amount. Smallest of the four (~150 LOC).
328
+ - **Cross-template `parties.json` registry.** Declare parties once
329
+ with `ref:parties.<key>.<field>` references from schemas. Eliminates
330
+ duplicating party metadata across every template (~250 LOC).
331
+ - **Multi-document bundles.** Resolve placeholders once and emit
332
+ multiple documents in one call (MSA + Order Form + DPA with shared
333
+ parameter values) (~250 LOC).
334
+ - **LLM inference from a deal description.** `--from-deal <path>`
335
+ reads free-form deal text and asks the T5 LLM provider to fill the
336
+ schema's parameters. Inverse of the existing T5 detection (~250 LOC).
283
337
  - **`.docx` highlight detection beyond yellow/green/cyan/magenta.** v1
284
- ignores other colors (black/white/none) by design.
338
+ ignores other colors (black/white/none) by design. Backlog, not in
339
+ V2_BRIEFS_REMAINING (low priority).
package/PARAM_SCHEMA.md CHANGED
@@ -331,6 +331,63 @@ that format).
331
331
  Programmatic API for drivers: `parseDuration`, `addDuration`,
332
332
  `computeValues`.
333
333
 
334
+ ### Positional addressing (v0.5.0, opt-in)
335
+
336
+ Some templates have the same placeholder text appearing multiple times
337
+ with *different* semantic roles. The validated YC SAFE case:
338
+ `$[_____________]` appears twice — once as valuation cap, once as
339
+ purchase amount. Long-form entries can declare a `positions` array
340
+ that splits each occurrence into its own canonical-keyed placeholder.
341
+
342
+ ```json
343
+ {
344
+ "_meta": { "schema_version": 1 },
345
+ "blank": {
346
+ "aliases": ["_____________"],
347
+ "type": "money", "currency": "USD",
348
+ "positions": [
349
+ { "role": "valuation_cap" },
350
+ { "role": "purchase_amount" }
351
+ ]
352
+ }
353
+ }
354
+ ```
355
+
356
+ CLI uses standard `--<role>` flags (no special `@N` grammar):
357
+
358
+ ```sh
359
+ draft safe.docx --valuation-cap 5000000 --purchase-amount 100000
360
+ ```
361
+
362
+ **Q1.1 locked:** index base is 0 internally; the CLI uses role names,
363
+ not numeric indices.
364
+
365
+ **Q1.2 locked:** count mismatch (schema declares N positions but
366
+ detection finds M ≠ N occurrences of the alias) is a **hard error**
367
+ (exit 4). The schema and the template are out of sync; silently filling
368
+ or trimming hides the bug.
369
+
370
+ **Q1.3 locked:** there is no bare-key CLI variant (`--<parent-key>
371
+ VALUE` with no role). The CLI uses role-named flags. Bare `--<role>`
372
+ targets that role's position; values can also come from `--params`
373
+ JSON keyed by role, or `--interactive`.
374
+
375
+ **Tier constraint:** positional addressing only works at T1 (bracket)
376
+ or T2 (mustache). T3/T4/T5 detection paths don't carry per-hit byte
377
+ indices needed for position-specific substitution; if a positional
378
+ schema entry's aliases are matched by those tiers, the command exits 4
379
+ with a clear error.
380
+
381
+ **Validation:** at schema parse time, positions must be a non-empty
382
+ array of `{role: string}` objects; roles must be valid snake_case keys
383
+ and unique within the entry.
384
+
385
+ Programmatic API: positions flow through detection and resolution as
386
+ normal `Placeholder` objects with `position_parent` (parent schema key)
387
+ and `position_index` (0-based) fields. `substitute` switches to
388
+ byte-index substitution for these, which `substituteDocxXml` does not
389
+ currently support.
390
+
334
391
  ### Orphan handling (Q4 locked)
335
392
 
336
393
  Schema declares a key whose alias list matches no detected phrase →
package/draft-cli.mjs CHANGED
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
70
70
  */
71
71
 
72
72
  /** @type {string} */
73
- export const VERSION = "0.4.0";
73
+ export const VERSION = "0.5.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -914,6 +914,40 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
914
914
  e.exitCode = EXIT.IO;
915
915
  throw e;
916
916
  }
917
+ // v2 #7: positional addressing. Optional `positions` array; each
918
+ // element declares a role (its own canonical key) for the Nth detected
919
+ // occurrence of this entry's aliases. Roles must be valid keys and
920
+ // unique within the entry.
921
+ let positions = null;
922
+ if (v.positions !== undefined) {
923
+ if (!Array.isArray(v.positions) || v.positions.length === 0) {
924
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' positions must be a non-empty array`);
925
+ e.exitCode = EXIT.IO;
926
+ throw e;
927
+ }
928
+ const roleSet = new Set();
929
+ positions = [];
930
+ for (let pi = 0; pi < v.positions.length; pi++) {
931
+ const pos = v.positions[pi];
932
+ if (!pos || typeof pos !== "object" || Array.isArray(pos)) {
933
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}] must be an object with a 'role' string`);
934
+ e.exitCode = EXIT.IO;
935
+ throw e;
936
+ }
937
+ if (typeof pos.role !== "string" || !validKey(pos.role)) {
938
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}].role must be a valid snake_case key`);
939
+ e.exitCode = EXIT.IO;
940
+ throw e;
941
+ }
942
+ if (roleSet.has(pos.role)) {
943
+ const e = new Error(`${sourceLabel}: '${k}'.positions has duplicate role '${pos.role}'`);
944
+ e.exitCode = EXIT.IO;
945
+ throw e;
946
+ }
947
+ roleSet.add(pos.role);
948
+ positions.push({ role: pos.role });
949
+ }
950
+ }
917
951
  // v2 #2: computed placeholders. Optional `computed` block on long-form
918
952
  // entries; { from: <other-key>, op: "+"|"-", value: "<n> <unit>" }.
919
953
  let computed = null;
@@ -951,6 +985,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
951
985
  format: typeof v.format === "string" ? v.format : null,
952
986
  currency: typeof v.currency === "string" ? v.currency : null,
953
987
  computed,
988
+ positions,
954
989
  };
955
990
  } else {
956
991
  if (!Array.isArray(v)) {
@@ -958,7 +993,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
958
993
  e.exitCode = EXIT.IO;
959
994
  throw e;
960
995
  }
961
- entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null };
996
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null, positions: null };
962
997
  }
963
998
  }
964
999
  // v2 #2: validate computed references (point to existing keys; no cycles).
@@ -1126,6 +1161,7 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1126
1161
  format: resolved.format,
1127
1162
  currency: resolved.currency,
1128
1163
  computed: resolved.computed,
1164
+ positions: resolved.positions,
1129
1165
  hits: [],
1130
1166
  });
1131
1167
  }
@@ -1133,7 +1169,52 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1133
1169
  entry.occurrences += 1;
1134
1170
  entry.hits.push(h);
1135
1171
  }
1136
- return { tier, placeholders: [...byKey.values()], warnings, unmapped };
1172
+ // v2 #7: expand positional entries. Each detected occurrence becomes a
1173
+ // separate role-keyed placeholder. Count mismatch → positional_errors;
1174
+ // tier T3/T4/T5 (no per-hit index) → positional_errors (not supported).
1175
+ const placeholders = [];
1176
+ const positional_errors = [];
1177
+ const detected_schema_keys = [...byKey.keys()];
1178
+ for (const p of byKey.values()) {
1179
+ if (!p.positions) {
1180
+ placeholders.push(p);
1181
+ continue;
1182
+ }
1183
+ if (tier !== "bracket" && tier !== "mustache") {
1184
+ positional_errors.push({
1185
+ key: p.key,
1186
+ reason: `tier '${tier}' does not carry per-hit index info; positional addressing requires T1 (bracket) or T2 (mustache) detection`,
1187
+ });
1188
+ continue;
1189
+ }
1190
+ if (p.hits.length !== p.positions.length) {
1191
+ positional_errors.push({
1192
+ key: p.key,
1193
+ reason: `schema declares ${p.positions.length} position(s) but detected ${p.hits.length} occurrence(s) of "${p.aliases[0] || p.key}"`,
1194
+ });
1195
+ continue;
1196
+ }
1197
+ for (let i = 0; i < p.positions.length; i++) {
1198
+ placeholders.push({
1199
+ key: p.positions[i].role,
1200
+ first_seen_as: p.hits[i].inner,
1201
+ occurrences: 1,
1202
+ tier,
1203
+ required: true,
1204
+ default: null,
1205
+ aliases: p.aliases.slice(),
1206
+ type: p.type,
1207
+ format: p.format,
1208
+ currency: p.currency,
1209
+ computed: null,
1210
+ positions: null, // expanded; no further re-expansion
1211
+ hits: [p.hits[i]],
1212
+ position_parent: p.key,
1213
+ position_index: i,
1214
+ });
1215
+ }
1216
+ }
1217
+ return { tier, placeholders, warnings, unmapped, positional_errors, detected_schema_keys };
1137
1218
  }
1138
1219
 
1139
1220
  function resolveKey(hit, schema, fromLlm) {
@@ -1149,6 +1230,7 @@ function resolveKey(hit, schema, fromLlm) {
1149
1230
  format: entry.format || null,
1150
1231
  currency: entry.currency || null,
1151
1232
  computed: entry.computed || null,
1233
+ positions: entry.positions || null,
1152
1234
  };
1153
1235
  }
1154
1236
  }
@@ -1156,7 +1238,7 @@ function resolveKey(hit, schema, fromLlm) {
1156
1238
  }
1157
1239
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1158
1240
  if (!validKey(key)) return null;
1159
- return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null };
1241
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null, positions: null };
1160
1242
  }
1161
1243
 
1162
1244
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1592,9 +1674,17 @@ async function nodePrompter(placeholder) {
1592
1674
  * @param {Placeholder[]} placeholders
1593
1675
  * @returns {Array<{key: string, aliases: string[]}>}
1594
1676
  */
1595
- export function findOrphans(schema, placeholders) {
1677
+ export function findOrphans(schema, placeholders, detectedSchemaKeys = null) {
1596
1678
  if (!schema) return [];
1597
- const present = new Set(placeholders.map((p) => p.key));
1679
+ // v2 #7: for positional entries we check `detectedSchemaKeys` (the
1680
+ // pre-expansion key set) since the placeholders list shows role keys,
1681
+ // not the parent positional key. When detectedSchemaKeys is not given
1682
+ // (older callers / no schema-expansion path), fall back to the
1683
+ // placeholders list — same behavior as before v0.5.0.
1684
+ const presentForPositional = detectedSchemaKeys
1685
+ ? new Set(detectedSchemaKeys)
1686
+ : new Set(placeholders.map((p) => p.key));
1687
+ const presentForRegular = new Set(placeholders.map((p) => p.key));
1598
1688
  // v2 #2: an entry that another entry's `computed.from` points at is
1599
1689
  // legitimately not in the template — it's a "feeder" used only for
1600
1690
  // computation. Exempt those from the orphan check.
@@ -1604,8 +1694,9 @@ export function findOrphans(schema, placeholders) {
1604
1694
  }
1605
1695
  const orphans = [];
1606
1696
  for (const [key, entry] of Object.entries(schema.entries)) {
1607
- if (present.has(key)) continue;
1608
1697
  if (computedFromTargets.has(key)) continue;
1698
+ const present = entry.positions ? presentForPositional : presentForRegular;
1699
+ if (present.has(key)) continue;
1609
1700
  orphans.push({ key, aliases: entry.aliases.slice() });
1610
1701
  }
1611
1702
  return orphans;
@@ -1625,8 +1716,30 @@ export function findOrphans(schema, placeholders) {
1625
1716
  * @returns {string} the substituted body.
1626
1717
  */
1627
1718
  export function substitute(body, placeholders, values, tier) {
1719
+ // v2 #7: positional placeholders (`position_index !== undefined`) substitute
1720
+ // at a specific byte index, not by global replace. Collect them first,
1721
+ // apply in reverse-index order so earlier hits' indices stay stable. Then
1722
+ // the remaining (non-positional) placeholders use the original
1723
+ // replaceAll/regex logic, which is safe because positional hits all share
1724
+ // the same alias text — and after the index-based substitution, only the
1725
+ // exact bytes at each position have been replaced.
1628
1726
  let out = body;
1727
+ const positionalSubs = [];
1728
+ for (const p of placeholders) {
1729
+ if (p.position_index === undefined) continue;
1730
+ const v = values[p.key];
1731
+ if (v === undefined) continue;
1732
+ for (const h of p.hits) {
1733
+ if (typeof h.index !== "number") continue;
1734
+ positionalSubs.push({ index: h.index, length: h.match.length, value: v });
1735
+ }
1736
+ }
1737
+ positionalSubs.sort((a, b) => b.index - a.index);
1738
+ for (const s of positionalSubs) {
1739
+ out = out.slice(0, s.index) + s.value + out.slice(s.index + s.length);
1740
+ }
1629
1741
  for (const p of placeholders) {
1742
+ if (p.position_index !== undefined) continue; // already handled above
1630
1743
  const v = values[p.key];
1631
1744
  if (v === undefined) continue;
1632
1745
  for (const h of p.hits) {
@@ -1805,7 +1918,14 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1805
1918
  err.write(paint("error: no placeholders detected by any tier\n", "red", err));
1806
1919
  return EXIT.VALIDATION;
1807
1920
  }
1808
- const orphans = findOrphans(schema, result.placeholders);
1921
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
1922
+ if (result.positional_errors && result.positional_errors.length > 0) {
1923
+ for (const pe of result.positional_errors) {
1924
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
1925
+ }
1926
+ return EXIT.VALIDATION;
1927
+ }
1928
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1809
1929
  if (orphans.length > 0) {
1810
1930
  for (const o of orphans) {
1811
1931
  err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
@@ -1891,8 +2011,15 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1891
2011
  }
1892
2012
  }
1893
2013
 
2014
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
2015
+ if (result.positional_errors && result.positional_errors.length > 0) {
2016
+ for (const pe of result.positional_errors) {
2017
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
2018
+ }
2019
+ return EXIT.VALIDATION;
2020
+ }
1894
2021
  // Orphan check.
1895
- const orphans = findOrphans(schema, result.placeholders);
2022
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1896
2023
  if (orphans.length > 0) {
1897
2024
  for (const o of orphans) {
1898
2025
  err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Fill placeholders in a legal-document template with parameter values. Part of the contract-operations suite.",
5
5
  "type": "module",
6
6
  "bin": {