@drbaher/draft-cli 0.4.0 → 0.6.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,104 @@ 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.6.0 — 2026-05-16
8
+
9
+ ### Added
10
+
11
+ - **Cross-template `parties.json` registry.** A repo-local
12
+ `parties.json` declares known parties once; templates' schemas
13
+ reference fields with `ref:parties.<key>.<field>`:
14
+ ```json
15
+ // parties.json
16
+ { "acme_corp": { "name": "Acme Corporation", "state": "Delaware" } }
17
+ ```
18
+ ```json
19
+ // <template>.params.json
20
+ {
21
+ "_meta": { "v": 1 },
22
+ "party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
23
+ "party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
24
+ }
25
+ ```
26
+ Resolution happens between `resolveValues` and typed-parameter
27
+ normalization, so `ref:`-resolved values feed cleanly into
28
+ `type: date | money | party` flows.
29
+ - **`--parties PATH` flag** overrides the default CWD/`parties.json`
30
+ lookup. Missing explicit path → exit 1 with a clear error.
31
+ - **New public API:** `loadParties(path)`, `resolveRef(value, parties)`,
32
+ `resolveRefs(resolved, sources, parties)`.
33
+
34
+ ### Decisions locked (V2_BRIEFS_REMAINING Q2.1–Q2.3)
35
+
36
+ - **Q2.1 File location:** default is `./parties.json` in CWD;
37
+ override with `--parties PATH`.
38
+ - **Q2.2 Ref scope:** refs resolve in `--params` JSON and schema
39
+ `default` values only. CLI flag values with `ref:` prefix pass
40
+ through **unchanged** — they're treated as literal strings.
41
+ - **Q2.3 Versioning:** out of scope for v0.6.0. When a party's
42
+ metadata changes in `parties.json`, all drafts that ref it produce
43
+ different output if re-run. Documented as a known property.
44
+
45
+ ### Schema-contract change
46
+
47
+ `PARAM_SCHEMA.md` §5 gains a "Cross-template `parties.json` registry"
48
+ subsection. v0.6.0 schemas are forward-compatible with v0.5.x
49
+ readers — `ref:` strings just look like literal values to older
50
+ readers (and won't substitute correctly, but won't error out either
51
+ since "ref:..." is a valid string).
52
+
53
+ ## 0.5.0 — 2026-05-16
54
+
55
+ ### Added
56
+
57
+ - **Positional addressing** for same-text placeholders with different
58
+ semantic roles. Long-form schema entries can declare a `positions`
59
+ array; each position gets its own canonical key (via `role`), so the
60
+ CLI uses standard `--<role>` flags. Validated against the YC SAFE
61
+ `$[_____________] × 2` case (valuation cap vs. purchase amount).
62
+ ```json
63
+ "blank": {
64
+ "aliases": ["_____________"],
65
+ "type": "money", "currency": "USD",
66
+ "positions": [
67
+ { "role": "valuation_cap" },
68
+ { "role": "purchase_amount" }
69
+ ]
70
+ }
71
+ ```
72
+ ```sh
73
+ draft safe.docx \
74
+ --valuation-cap 5000000 \
75
+ --purchase-amount 100000
76
+ ```
77
+ Decisions locked (V2_BRIEFS_REMAINING Q1.1–Q1.3):
78
+ - **Q1.1 Index base**: schema positions are 0-indexed internally;
79
+ the CLI uses role names, not numeric indices.
80
+ - **Q1.2 Length mismatch**: schema declares N positions but
81
+ detection finds M ≠ N occurrences → hard error (exit 4).
82
+ - **Q1.3 Bare-key CLI**: a `--<role>` flag targets its specific
83
+ position; values still flow through `--params` JSON or
84
+ `--interactive` normally.
85
+
86
+ ### Constraints
87
+
88
+ - Positional addressing only works at tier T1 (bracket) and T2
89
+ (mustache) — those tiers carry per-hit byte indices needed for
90
+ position-specific substitution. T3 (docx-highlight), T4 (heuristic),
91
+ T5 (LLM) raise a positional error if a positional schema entry's
92
+ aliases are matched by them. `.docx` templates with `[X]` brackets
93
+ that fire T1 still work; `.docx` templates that rely on T3 highlights
94
+ for the same alias do not.
95
+
96
+ ### Schema-contract change
97
+
98
+ `PARAM_SCHEMA.md` §5 gains a "Positional addressing" subsection. Long-
99
+ form entries can now include a `positions` array; short form is
100
+ unchanged. Forward-compatible with v0.4.x readers — they'll ignore the
101
+ unknown field and treat the entry as a regular non-positional
102
+ placeholder (which means the first detected occurrence wins for
103
+ substitution, and ambiguity is unresolved).
104
+
7
105
  ## 0.4.0 — 2026-05-16
8
106
 
9
107
  ### Added
@@ -263,22 +361,25 @@ suite ([cli.drbaher.com](https://cli.drbaher.com)).
263
361
  that contains at least one letter. False positives are filtered with
264
362
  the schema file; false negatives in this domain are higher-cost.
265
363
 
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.
364
+ ## Deferred (post-v0.4.0 candidates)
365
+
366
+ Three of the original seven v1 "Deferred" entries shipped in v0.2.0,
367
+ v0.3.2, and v0.4.0 (see entries above). The four remaining items are
368
+ the next chunk of design work, with briefs in `V2_BRIEFS_REMAINING.md`:
369
+
370
+ - **Positional addressing.** Disambiguate same-text placeholders by
371
+ index in the schema. The validated case: YC SAFE has
372
+ `$[_____________]` twice once for the valuation cap, once for
373
+ the purchase amount. Smallest of the four (~150 LOC).
374
+ - **Cross-template `parties.json` registry.** Declare parties once
375
+ with `ref:parties.<key>.<field>` references from schemas. Eliminates
376
+ duplicating party metadata across every template (~250 LOC).
377
+ - **Multi-document bundles.** Resolve placeholders once and emit
378
+ multiple documents in one call (MSA + Order Form + DPA with shared
379
+ parameter values) (~250 LOC).
380
+ - **LLM inference from a deal description.** `--from-deal <path>`
381
+ reads free-form deal text and asks the T5 LLM provider to fill the
382
+ schema's parameters. Inverse of the existing T5 detection (~250 LOC).
283
383
  - **`.docx` highlight detection beyond yellow/green/cyan/magenta.** v1
284
- ignores other colors (black/white/none) by design.
384
+ ignores other colors (black/white/none) by design. Backlog, not in
385
+ V2_BRIEFS_REMAINING (low priority).
package/PARAM_SCHEMA.md CHANGED
@@ -331,6 +331,126 @@ 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
+
391
+ ### Cross-template `parties.json` registry (v0.6.0, opt-in)
392
+
393
+ A repo-local `parties.json` declares known parties once; templates'
394
+ schemas reference fields with `ref:parties.<party_key>.<field>`.
395
+ Eliminates duplicating party metadata (name, state, CIK, signing
396
+ contact) across every template.
397
+
398
+ ```json
399
+ // parties.json
400
+ {
401
+ "acme_corp": {
402
+ "name": "Acme Corporation",
403
+ "state": "Delaware",
404
+ "cik": "0001234567"
405
+ }
406
+ }
407
+ ```
408
+
409
+ ```json
410
+ // <template>.params.json
411
+ {
412
+ "_meta": { "schema_version": 1 },
413
+ "party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
414
+ "party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
415
+ }
416
+ ```
417
+
418
+ **Q2.1 locked:** default file location is `./parties.json` in the
419
+ process CWD. Override with `--parties PATH`. Missing explicit path
420
+ is exit 1 (`EXIT.IO`); missing default file silently means "no
421
+ registry loaded" (refs then fail at resolution time with a clear hint).
422
+
423
+ **Q2.2 locked:** refs resolve in `--params` JSON values and schema
424
+ `default` values only. **CLI flag values pass through unchanged** —
425
+ `--party-a "ref:parties.acme.name"` is treated as a literal string
426
+ that happens to start with `ref:`. This keeps CLI parsing
427
+ unambiguous and avoids users accidentally leaking parties data on
428
+ the command line.
429
+
430
+ **Q2.3 locked:** versioning is out of scope for v0.6.0. When a
431
+ party's metadata changes in `parties.json`, all drafts that ref it
432
+ produce different output if re-run. This is by design (single source
433
+ of truth for party info), but worth knowing — historical drafts may
434
+ diverge from their original `parties.json` values.
435
+
436
+ **Ref syntax:** `ref:parties.<party_key>.<field>` where both
437
+ `<party_key>` and `<field>` match `[A-Za-z_][A-Za-z0-9_]*`. Malformed
438
+ refs, unknown party keys, and unknown fields all surface as hard
439
+ errors before substitution (exit 4).
440
+
441
+ **Resolution order:** value resolution → ref expansion → typed-param
442
+ normalization → computed values → substitute. Refs run before typed
443
+ normalization so a ref returning `"2027-01-15"` can still flow
444
+ through `type: date` formatting.
445
+
446
+ **Field-value coercion:** ref'd fields are coerced to strings via
447
+ `String(value)` (e.g. `cik: 1234567` becomes `"1234567"`). The
448
+ parties registry can store non-string values for ergonomics, but
449
+ substitution always uses string output.
450
+
451
+ Programmatic API: `loadParties(path)`, `resolveRef(value, parties)`,
452
+ `resolveRefs(resolved, sources, parties)`.
453
+
334
454
  ### Orphan handling (Q4 locked)
335
455
 
336
456
  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.6.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -277,6 +277,7 @@ export function parseArgs(argv) {
277
277
  if (a === "--silent" || a === "-q") { opts.silent = true; continue; }
278
278
  if (a === "--diff") { opts.diff = true; continue; }
279
279
  if (a === "--params") { opts.params = argv[++i]; continue; }
280
+ if (a === "--parties") { opts.parties = argv[++i]; continue; }
280
281
  if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
281
282
  if (a === "--syntax") {
282
283
  const v = argv[++i];
@@ -914,6 +915,40 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
914
915
  e.exitCode = EXIT.IO;
915
916
  throw e;
916
917
  }
918
+ // v2 #7: positional addressing. Optional `positions` array; each
919
+ // element declares a role (its own canonical key) for the Nth detected
920
+ // occurrence of this entry's aliases. Roles must be valid keys and
921
+ // unique within the entry.
922
+ let positions = null;
923
+ if (v.positions !== undefined) {
924
+ if (!Array.isArray(v.positions) || v.positions.length === 0) {
925
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' positions must be a non-empty array`);
926
+ e.exitCode = EXIT.IO;
927
+ throw e;
928
+ }
929
+ const roleSet = new Set();
930
+ positions = [];
931
+ for (let pi = 0; pi < v.positions.length; pi++) {
932
+ const pos = v.positions[pi];
933
+ if (!pos || typeof pos !== "object" || Array.isArray(pos)) {
934
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}] must be an object with a 'role' string`);
935
+ e.exitCode = EXIT.IO;
936
+ throw e;
937
+ }
938
+ if (typeof pos.role !== "string" || !validKey(pos.role)) {
939
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}].role must be a valid snake_case key`);
940
+ e.exitCode = EXIT.IO;
941
+ throw e;
942
+ }
943
+ if (roleSet.has(pos.role)) {
944
+ const e = new Error(`${sourceLabel}: '${k}'.positions has duplicate role '${pos.role}'`);
945
+ e.exitCode = EXIT.IO;
946
+ throw e;
947
+ }
948
+ roleSet.add(pos.role);
949
+ positions.push({ role: pos.role });
950
+ }
951
+ }
917
952
  // v2 #2: computed placeholders. Optional `computed` block on long-form
918
953
  // entries; { from: <other-key>, op: "+"|"-", value: "<n> <unit>" }.
919
954
  let computed = null;
@@ -951,6 +986,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
951
986
  format: typeof v.format === "string" ? v.format : null,
952
987
  currency: typeof v.currency === "string" ? v.currency : null,
953
988
  computed,
989
+ positions,
954
990
  };
955
991
  } else {
956
992
  if (!Array.isArray(v)) {
@@ -958,7 +994,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
958
994
  e.exitCode = EXIT.IO;
959
995
  throw e;
960
996
  }
961
- entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null };
997
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null, positions: null };
962
998
  }
963
999
  }
964
1000
  // v2 #2: validate computed references (point to existing keys; no cycles).
@@ -1126,6 +1162,7 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1126
1162
  format: resolved.format,
1127
1163
  currency: resolved.currency,
1128
1164
  computed: resolved.computed,
1165
+ positions: resolved.positions,
1129
1166
  hits: [],
1130
1167
  });
1131
1168
  }
@@ -1133,7 +1170,52 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1133
1170
  entry.occurrences += 1;
1134
1171
  entry.hits.push(h);
1135
1172
  }
1136
- return { tier, placeholders: [...byKey.values()], warnings, unmapped };
1173
+ // v2 #7: expand positional entries. Each detected occurrence becomes a
1174
+ // separate role-keyed placeholder. Count mismatch → positional_errors;
1175
+ // tier T3/T4/T5 (no per-hit index) → positional_errors (not supported).
1176
+ const placeholders = [];
1177
+ const positional_errors = [];
1178
+ const detected_schema_keys = [...byKey.keys()];
1179
+ for (const p of byKey.values()) {
1180
+ if (!p.positions) {
1181
+ placeholders.push(p);
1182
+ continue;
1183
+ }
1184
+ if (tier !== "bracket" && tier !== "mustache") {
1185
+ positional_errors.push({
1186
+ key: p.key,
1187
+ reason: `tier '${tier}' does not carry per-hit index info; positional addressing requires T1 (bracket) or T2 (mustache) detection`,
1188
+ });
1189
+ continue;
1190
+ }
1191
+ if (p.hits.length !== p.positions.length) {
1192
+ positional_errors.push({
1193
+ key: p.key,
1194
+ reason: `schema declares ${p.positions.length} position(s) but detected ${p.hits.length} occurrence(s) of "${p.aliases[0] || p.key}"`,
1195
+ });
1196
+ continue;
1197
+ }
1198
+ for (let i = 0; i < p.positions.length; i++) {
1199
+ placeholders.push({
1200
+ key: p.positions[i].role,
1201
+ first_seen_as: p.hits[i].inner,
1202
+ occurrences: 1,
1203
+ tier,
1204
+ required: true,
1205
+ default: null,
1206
+ aliases: p.aliases.slice(),
1207
+ type: p.type,
1208
+ format: p.format,
1209
+ currency: p.currency,
1210
+ computed: null,
1211
+ positions: null, // expanded; no further re-expansion
1212
+ hits: [p.hits[i]],
1213
+ position_parent: p.key,
1214
+ position_index: i,
1215
+ });
1216
+ }
1217
+ }
1218
+ return { tier, placeholders, warnings, unmapped, positional_errors, detected_schema_keys };
1137
1219
  }
1138
1220
 
1139
1221
  function resolveKey(hit, schema, fromLlm) {
@@ -1149,6 +1231,7 @@ function resolveKey(hit, schema, fromLlm) {
1149
1231
  format: entry.format || null,
1150
1232
  currency: entry.currency || null,
1151
1233
  computed: entry.computed || null,
1234
+ positions: entry.positions || null,
1152
1235
  };
1153
1236
  }
1154
1237
  }
@@ -1156,7 +1239,7 @@ function resolveKey(hit, schema, fromLlm) {
1156
1239
  }
1157
1240
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1158
1241
  if (!validKey(key)) return null;
1159
- return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null };
1242
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null, positions: null };
1160
1243
  }
1161
1244
 
1162
1245
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1190,6 +1273,108 @@ export function loadParamsFile(path) {
1190
1273
  }
1191
1274
  }
1192
1275
 
1276
+ /**
1277
+ * Load a `parties.json` registry (v2 #5). Returns the parsed object or
1278
+ * `null` if no file is present. Explicit `--parties PATH` errors if
1279
+ * the path doesn't exist; the default `./parties.json` is treated as
1280
+ * absent if the file isn't there (no error).
1281
+ *
1282
+ * @param {string | null} explicitPath — value of `--parties PATH`, or null
1283
+ * to auto-detect `./parties.json` in CWD.
1284
+ * @returns {Object<string, Object<string, *>> | null}
1285
+ * @throws {Error} with `.exitCode = EXIT.IO` on missing explicit file or invalid JSON.
1286
+ */
1287
+ export function loadParties(explicitPath) {
1288
+ const fallback = "parties.json";
1289
+ const path = explicitPath || (existsSync(fallback) ? fallback : null);
1290
+ if (!path) return null;
1291
+ if (!existsSync(path)) {
1292
+ const e = new Error(`parties file not found: ${path}`);
1293
+ e.exitCode = EXIT.IO;
1294
+ throw e;
1295
+ }
1296
+ let parsed;
1297
+ try {
1298
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1299
+ } catch (err) {
1300
+ const e = new Error(`could not parse ${path}: ${err.message}`);
1301
+ e.exitCode = EXIT.IO;
1302
+ throw e;
1303
+ }
1304
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1305
+ const e = new Error(`parties file ${path} must be a JSON object`);
1306
+ e.exitCode = EXIT.IO;
1307
+ throw e;
1308
+ }
1309
+ // Reject non-object party entries early so downstream `ref:` resolution
1310
+ // can safely lookup fields without a per-call shape check.
1311
+ for (const [partyKey, party] of Object.entries(parsed)) {
1312
+ if (!party || typeof party !== "object" || Array.isArray(party)) {
1313
+ const e = new Error(`parties file ${path}: entry "${partyKey}" must be a JSON object`);
1314
+ e.exitCode = EXIT.IO;
1315
+ throw e;
1316
+ }
1317
+ }
1318
+ return parsed;
1319
+ }
1320
+
1321
+ /**
1322
+ * Resolve a `ref:parties.<party>.<field>` reference against a loaded
1323
+ * parties object. Throws on malformed ref, missing parties registry,
1324
+ * unknown party, or unknown field. Non-`ref:` strings pass through
1325
+ * unchanged.
1326
+ *
1327
+ * @param {string} value
1328
+ * @param {Object<string, Object<string, *>> | null} parties
1329
+ * @returns {string}
1330
+ * @throws {Error} on malformed or unresolvable reference.
1331
+ */
1332
+ export function resolveRef(value, parties) {
1333
+ if (typeof value !== "string" || !value.startsWith("ref:")) return value;
1334
+ if (!parties) {
1335
+ throw new Error(`reference "${value}" but no parties.json loaded (pass --parties PATH or put parties.json in cwd)`);
1336
+ }
1337
+ const m = /^ref:parties\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
1338
+ if (!m) {
1339
+ throw new Error(`malformed reference "${value}" (expected "ref:parties.<party_key>.<field>")`);
1340
+ }
1341
+ const [, partyKey, fieldKey] = m;
1342
+ const party = parties[partyKey];
1343
+ if (!party) {
1344
+ throw new Error(`unknown party "${partyKey}" in reference "${value}"`);
1345
+ }
1346
+ if (!(fieldKey in party)) {
1347
+ throw new Error(`unknown field "${fieldKey}" on party "${partyKey}" in reference "${value}"`);
1348
+ }
1349
+ const out = party[fieldKey];
1350
+ return out == null ? "" : String(out);
1351
+ }
1352
+
1353
+ /**
1354
+ * Walk a resolved-values map and replace any `ref:` strings with their
1355
+ * resolved values from the parties registry. CLI-sourced values are
1356
+ * left alone (Q2.2: refs are params/default only, never CLI). Collects
1357
+ * all errors before returning so the user sees every failure at once.
1358
+ *
1359
+ * @param {Object<string,string>} resolved — mutated in place.
1360
+ * @param {Object<string,string>} sources — from `resolveValues`.
1361
+ * @param {Object<string, Object<string, *>> | null} parties
1362
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }> }}
1363
+ */
1364
+ export function resolveRefs(resolved, sources, parties) {
1365
+ const errors = [];
1366
+ for (const [key, value] of Object.entries(resolved)) {
1367
+ if (sources[key] === "cli") continue; // Q2.2: CLI values pass through
1368
+ if (typeof value !== "string" || !value.startsWith("ref:")) continue;
1369
+ try {
1370
+ resolved[key] = resolveRef(value, parties);
1371
+ } catch (e) {
1372
+ errors.push({ key, message: e.message });
1373
+ }
1374
+ }
1375
+ return { ok: errors.length === 0, errors };
1376
+ }
1377
+
1193
1378
  /**
1194
1379
  * Resolve a value for every placeholder using the locked precedence chain:
1195
1380
  * CLI flag > `--params` JSON > `--interactive` prompt > schema default >
@@ -1592,9 +1777,17 @@ async function nodePrompter(placeholder) {
1592
1777
  * @param {Placeholder[]} placeholders
1593
1778
  * @returns {Array<{key: string, aliases: string[]}>}
1594
1779
  */
1595
- export function findOrphans(schema, placeholders) {
1780
+ export function findOrphans(schema, placeholders, detectedSchemaKeys = null) {
1596
1781
  if (!schema) return [];
1597
- const present = new Set(placeholders.map((p) => p.key));
1782
+ // v2 #7: for positional entries we check `detectedSchemaKeys` (the
1783
+ // pre-expansion key set) since the placeholders list shows role keys,
1784
+ // not the parent positional key. When detectedSchemaKeys is not given
1785
+ // (older callers / no schema-expansion path), fall back to the
1786
+ // placeholders list — same behavior as before v0.5.0.
1787
+ const presentForPositional = detectedSchemaKeys
1788
+ ? new Set(detectedSchemaKeys)
1789
+ : new Set(placeholders.map((p) => p.key));
1790
+ const presentForRegular = new Set(placeholders.map((p) => p.key));
1598
1791
  // v2 #2: an entry that another entry's `computed.from` points at is
1599
1792
  // legitimately not in the template — it's a "feeder" used only for
1600
1793
  // computation. Exempt those from the orphan check.
@@ -1604,8 +1797,9 @@ export function findOrphans(schema, placeholders) {
1604
1797
  }
1605
1798
  const orphans = [];
1606
1799
  for (const [key, entry] of Object.entries(schema.entries)) {
1607
- if (present.has(key)) continue;
1608
1800
  if (computedFromTargets.has(key)) continue;
1801
+ const present = entry.positions ? presentForPositional : presentForRegular;
1802
+ if (present.has(key)) continue;
1609
1803
  orphans.push({ key, aliases: entry.aliases.slice() });
1610
1804
  }
1611
1805
  return orphans;
@@ -1625,8 +1819,30 @@ export function findOrphans(schema, placeholders) {
1625
1819
  * @returns {string} the substituted body.
1626
1820
  */
1627
1821
  export function substitute(body, placeholders, values, tier) {
1822
+ // v2 #7: positional placeholders (`position_index !== undefined`) substitute
1823
+ // at a specific byte index, not by global replace. Collect them first,
1824
+ // apply in reverse-index order so earlier hits' indices stay stable. Then
1825
+ // the remaining (non-positional) placeholders use the original
1826
+ // replaceAll/regex logic, which is safe because positional hits all share
1827
+ // the same alias text — and after the index-based substitution, only the
1828
+ // exact bytes at each position have been replaced.
1628
1829
  let out = body;
1830
+ const positionalSubs = [];
1831
+ for (const p of placeholders) {
1832
+ if (p.position_index === undefined) continue;
1833
+ const v = values[p.key];
1834
+ if (v === undefined) continue;
1835
+ for (const h of p.hits) {
1836
+ if (typeof h.index !== "number") continue;
1837
+ positionalSubs.push({ index: h.index, length: h.match.length, value: v });
1838
+ }
1839
+ }
1840
+ positionalSubs.sort((a, b) => b.index - a.index);
1841
+ for (const s of positionalSubs) {
1842
+ out = out.slice(0, s.index) + s.value + out.slice(s.index + s.length);
1843
+ }
1629
1844
  for (const p of placeholders) {
1845
+ if (p.position_index !== undefined) continue; // already handled above
1630
1846
  const v = values[p.key];
1631
1847
  if (v === undefined) continue;
1632
1848
  for (const h of p.hits) {
@@ -1799,13 +2015,20 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
1799
2015
  return EXIT.OK;
1800
2016
  }
1801
2017
 
1802
- export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
2018
+ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
1803
2019
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1804
2020
  if (result.tier === "none") {
1805
2021
  err.write(paint("error: no placeholders detected by any tier\n", "red", err));
1806
2022
  return EXIT.VALIDATION;
1807
2023
  }
1808
- const orphans = findOrphans(schema, result.placeholders);
2024
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
2025
+ if (result.positional_errors && result.positional_errors.length > 0) {
2026
+ for (const pe of result.positional_errors) {
2027
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
2028
+ }
2029
+ return EXIT.VALIDATION;
2030
+ }
2031
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1809
2032
  if (orphans.length > 0) {
1810
2033
  for (const o of orphans) {
1811
2034
  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));
@@ -1820,6 +2043,22 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1820
2043
  }
1821
2044
  return EXIT.VALIDATION;
1822
2045
  }
2046
+ // v2 #5: parties.json ref resolution. Refs like
2047
+ // `ref:parties.acme_corp.name` in --params or schema defaults expand
2048
+ // before typed normalization. CLI values pass through unchanged.
2049
+ const refCheck = resolveRefs(resolved, sources, parties);
2050
+ if (!refCheck.ok) {
2051
+ for (const re of refCheck.errors) {
2052
+ err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
2053
+ }
2054
+ if (opts.json) {
2055
+ out.write(JSON.stringify({
2056
+ ok: false,
2057
+ ref_errors: refCheck.errors.map(({ key, message }) => ({ key, message })),
2058
+ }, null, 2) + "\n");
2059
+ }
2060
+ return EXIT.VALIDATION;
2061
+ }
1823
2062
  // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1824
2063
  // `--validate` catches type errors before the user runs draft.
1825
2064
  const typeCheck = normalizeTypedValues(result.placeholders, resolved);
@@ -1857,7 +2096,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1857
2096
  return EXIT.OK;
1858
2097
  }
1859
2098
 
1860
- export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
2099
+ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
1861
2100
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1862
2101
  if (result.tier === "none") {
1863
2102
  const hasProvider = Boolean(llmProviderFromEnv(envObj));
@@ -1891,8 +2130,15 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1891
2130
  }
1892
2131
  }
1893
2132
 
2133
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
2134
+ if (result.positional_errors && result.positional_errors.length > 0) {
2135
+ for (const pe of result.positional_errors) {
2136
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
2137
+ }
2138
+ return EXIT.VALIDATION;
2139
+ }
1894
2140
  // Orphan check.
1895
- const orphans = findOrphans(schema, result.placeholders);
2141
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1896
2142
  if (orphans.length > 0) {
1897
2143
  for (const o of orphans) {
1898
2144
  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));
@@ -1918,6 +2164,18 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1918
2164
  return EXIT.VALIDATION;
1919
2165
  }
1920
2166
 
2167
+ // v2 #5: parties.json ref resolution. Refs like
2168
+ // `ref:parties.acme_corp.name` in --params or schema defaults expand
2169
+ // before typed normalization. CLI values pass through unchanged
2170
+ // (Q2.2 lock).
2171
+ const refCheck = resolveRefs(resolved, sources, parties);
2172
+ if (!refCheck.ok) {
2173
+ for (const re of refCheck.errors) {
2174
+ err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
2175
+ }
2176
+ return EXIT.VALIDATION;
2177
+ }
2178
+
1921
2179
  // v2 #3: typed-parameter normalization. Schema entries can declare
1922
2180
  // `type: date | money | party`. Inputs are validated and normalized
1923
2181
  // before substitution. Hard error on bad input (Q3.3 decision).
@@ -2335,12 +2593,13 @@ export async function main(argv, io = {}) {
2335
2593
  return EXIT.IO;
2336
2594
  }
2337
2595
 
2338
- let input, schema, paramsObj, envObj;
2596
+ let input, schema, paramsObj, envObj, parties;
2339
2597
  try {
2340
2598
  input = await resolveInput(opts.positional[0], { spawner, stdinReader });
2341
2599
  schema = loadSchema(input.path);
2342
2600
  paramsObj = loadParamsFile(opts.params);
2343
2601
  envObj = effectiveEnv(cwd, processEnv);
2602
+ parties = loadParties(opts.parties || null);
2344
2603
  } catch (e) {
2345
2604
  err.write(paint(`error: ${e.message}\n`, "red", err));
2346
2605
  return e.exitCode || EXIT.IO;
@@ -2351,9 +2610,9 @@ export async function main(argv, io = {}) {
2351
2610
  return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
2352
2611
  }
2353
2612
  if (opts.validate) {
2354
- return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2613
+ return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2355
2614
  }
2356
- return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2615
+ return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2357
2616
  } catch (e) {
2358
2617
  err.write(paint(`error: ${e.message}\n`, "red", err));
2359
2618
  return e.exitCode || EXIT.IO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {