@drbaher/draft-cli 0.5.0 → 0.7.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,101 @@ 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.7.0 — 2026-05-17
8
+
9
+ ### Added
10
+
11
+ - **Multi-document bundles.** `draft --bundle <bundle.json>` reads a
12
+ bundle definition and fills multiple templates with the same set of
13
+ parameter values in one invocation:
14
+ ```json
15
+ {
16
+ "_meta": { "schema_version": 1 },
17
+ "outputs": [
18
+ { "template": "msa/v3.md", "output": "out/msa.md" },
19
+ { "template": "order-form/v3.md", "output": "out/order-form.md" }
20
+ ]
21
+ }
22
+ ```
23
+ Each template runs through detection independently. Placeholders
24
+ across templates are unioned by key (so a key declared in any
25
+ template's schema applies to all — Q3.3 locked). Resolution,
26
+ typed-parameter normalization, and computed values all run once on
27
+ the union. Each output is then substituted using its own
28
+ template/tier and written to its own path. `parties.json` refs
29
+ (v0.6.0) resolve inside bundle entries too.
30
+ - **Schema-union semantics.** A key declared/detected in any bundle
31
+ template applies to every template in the bundle. First-occurrence
32
+ metadata wins; resolved values flow to all templates that reference
33
+ the same key.
34
+ - **`.docx` bundle entries** round-trip through `substituteDocxXml`
35
+ when the entry's `output` path has the `.docx` extension. Same
36
+ runs/styles preservation as single-doc `.docx` mode.
37
+ - **New public API:** `loadBundle(path)`, `cmdBundle(opts, bundle,
38
+ paramsObj, envObj, io)`.
39
+
40
+ ### Decisions locked (V2_BRIEFS_REMAINING Q3.1–Q3.3)
41
+
42
+ - **Q3.1 Bundle file format:** JSON object with `outputs` array of
43
+ `{template, output}` pairs. Each entry has its own output path,
44
+ enabling per-doc overrides without inventing a custom DSL.
45
+ - **Q3.2 Partial-failure policy:** abort-all. Any pre-write error
46
+ (no detection in an entry, missing required param across the
47
+ union, type / computed / ref failure, positional mismatch, schema
48
+ orphan) exits 4 before any file is written. Write failures
49
+ mid-bundle exit 1; earlier successful writes are not rolled back
50
+ (best-effort atomicity at the filesystem boundary).
51
+ - **Q3.3 Schema union semantics:** keys declared in any template's
52
+ schema (or detected as canonical-key matches without a schema)
53
+ apply across the bundle. Same value resolves into every template
54
+ that references the key.
55
+
56
+ ## 0.6.0 — 2026-05-16
57
+
58
+ ### Added
59
+
60
+ - **Cross-template `parties.json` registry.** A repo-local
61
+ `parties.json` declares known parties once; templates' schemas
62
+ reference fields with `ref:parties.<key>.<field>`:
63
+ ```json
64
+ // parties.json
65
+ { "acme_corp": { "name": "Acme Corporation", "state": "Delaware" } }
66
+ ```
67
+ ```json
68
+ // <template>.params.json
69
+ {
70
+ "_meta": { "v": 1 },
71
+ "party_a": { "aliases": ["Party A"], "default": "ref:parties.acme_corp.name" },
72
+ "party_a_state": { "aliases": ["Party A State"], "default": "ref:parties.acme_corp.state" }
73
+ }
74
+ ```
75
+ Resolution happens between `resolveValues` and typed-parameter
76
+ normalization, so `ref:`-resolved values feed cleanly into
77
+ `type: date | money | party` flows.
78
+ - **`--parties PATH` flag** overrides the default CWD/`parties.json`
79
+ lookup. Missing explicit path → exit 1 with a clear error.
80
+ - **New public API:** `loadParties(path)`, `resolveRef(value, parties)`,
81
+ `resolveRefs(resolved, sources, parties)`.
82
+
83
+ ### Decisions locked (V2_BRIEFS_REMAINING Q2.1–Q2.3)
84
+
85
+ - **Q2.1 File location:** default is `./parties.json` in CWD;
86
+ override with `--parties PATH`.
87
+ - **Q2.2 Ref scope:** refs resolve in `--params` JSON and schema
88
+ `default` values only. CLI flag values with `ref:` prefix pass
89
+ through **unchanged** — they're treated as literal strings.
90
+ - **Q2.3 Versioning:** out of scope for v0.6.0. When a party's
91
+ metadata changes in `parties.json`, all drafts that ref it produce
92
+ different output if re-run. Documented as a known property.
93
+
94
+ ### Schema-contract change
95
+
96
+ `PARAM_SCHEMA.md` §5 gains a "Cross-template `parties.json` registry"
97
+ subsection. v0.6.0 schemas are forward-compatible with v0.5.x
98
+ readers — `ref:` strings just look like literal values to older
99
+ readers (and won't substitute correctly, but won't error out either
100
+ since "ref:..." is a valid string).
101
+
7
102
  ## 0.5.0 — 2026-05-16
8
103
 
9
104
  ### Added
package/PARAM_SCHEMA.md CHANGED
@@ -388,6 +388,133 @@ and `position_index` (0-based) fields. `substitute` switches to
388
388
  byte-index substitution for these, which `substituteDocxXml` does not
389
389
  currently support.
390
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
+
454
+ ### Multi-document bundles (v0.7.0, opt-in)
455
+
456
+ `draft --bundle <bundle.json>` reads a bundle definition and fills
457
+ multiple templates with one shared set of parameter values:
458
+
459
+ ```json
460
+ {
461
+ "_meta": { "schema_version": 1 },
462
+ "outputs": [
463
+ { "template": "msa/v3.md", "output": "out/msa.md" },
464
+ { "template": "order-form/v3.md", "output": "out/order-form.md" },
465
+ { "template": "dpa/v2.docx", "output": "out/dpa.docx" }
466
+ ]
467
+ }
468
+ ```
469
+
470
+ ```sh
471
+ draft --bundle deal.bundle.json --params deal.json --parties parties.json
472
+ ```
473
+
474
+ **Q3.1 locked:** the bundle file is a JSON object with an `outputs`
475
+ array of `{template, output}` pairs. Each entry has its own
476
+ `template` (filesystem path or `template-vault get` ref) and own
477
+ `output` path. No alternative shorter DSL — JSON is unambiguous and
478
+ extensible.
479
+
480
+ **Q3.2 locked:** abort-all. Any pre-write error (no detection in an
481
+ entry, missing required param across the union, type validation
482
+ failure, computed-value failure, ref-resolution failure, positional
483
+ mismatch, schema orphan) returns exit 4 **before any file is
484
+ written**. The bundle either writes all `outputs` or writes none.
485
+ Filesystem write errors mid-bundle exit 1; earlier successful
486
+ writes are not rolled back (best-effort atomicity at the filesystem
487
+ boundary).
488
+
489
+ **Q3.3 locked:** schema union. A key declared in any template's
490
+ schema, or detected as a canonical-key match without a schema,
491
+ applies across the entire bundle. The same resolved value flows to
492
+ every template that references the key. First-occurrence metadata
493
+ wins (`type`, `format`, `currency`, `computed`, `positions`, etc.);
494
+ templates with richer aliases for the same key contribute their
495
+ aliases for detection in their own body but don't redefine the key.
496
+
497
+ **Per-template detection independence:** each bundle entry runs the
498
+ full T1–T5 cascade against its own body. Different entries can land
499
+ on different tiers (e.g. MSA on T1 brackets, DPA on T3 highlights).
500
+ Positional addressing on T1/T2 still works per entry.
501
+
502
+ **`.docx` entries** with a `.docx` output path round-trip via
503
+ `substituteDocxXml` + `writeDocxBuffer`, preserving runs/styles.
504
+ Mixing text and `.docx` entries in the same bundle works.
505
+
506
+ **`parties.json` refs** (v0.6.0) resolve inside bundles too — load
507
+ the same parties file once via `--parties PATH` (or the CWD
508
+ default), and ref strings in any bundle template's schema default
509
+ or in shared `--params` expand against it.
510
+
511
+ **`--json`** for bundles emits a structured result listing each
512
+ entry's template, output path, and tier, plus the union of resolved
513
+ keys and their sources.
514
+
515
+ Programmatic API: `loadBundle(path)` parses + validates; `cmdBundle`
516
+ runs the orchestration with the same IO contract as `cmdDraft`.
517
+
391
518
  ### Orphan handling (Q4 locked)
392
519
 
393
520
  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.5.0";
73
+ export const VERSION = "0.7.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -277,6 +277,8 @@ 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; }
281
+ if (a === "--bundle") { opts.bundle = argv[++i]; continue; }
280
282
  if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
281
283
  if (a === "--syntax") {
282
284
  const v = argv[++i];
@@ -1272,6 +1274,181 @@ export function loadParamsFile(path) {
1272
1274
  }
1273
1275
  }
1274
1276
 
1277
+ /**
1278
+ * Load a `parties.json` registry (v2 #5). Returns the parsed object or
1279
+ * `null` if no file is present. Explicit `--parties PATH` errors if
1280
+ * the path doesn't exist; the default `./parties.json` is treated as
1281
+ * absent if the file isn't there (no error).
1282
+ *
1283
+ * @param {string | null} explicitPath — value of `--parties PATH`, or null
1284
+ * to auto-detect `./parties.json` in CWD.
1285
+ * @returns {Object<string, Object<string, *>> | null}
1286
+ * @throws {Error} with `.exitCode = EXIT.IO` on missing explicit file or invalid JSON.
1287
+ */
1288
+ export function loadParties(explicitPath) {
1289
+ const fallback = "parties.json";
1290
+ const path = explicitPath || (existsSync(fallback) ? fallback : null);
1291
+ if (!path) return null;
1292
+ if (!existsSync(path)) {
1293
+ const e = new Error(`parties file not found: ${path}`);
1294
+ e.exitCode = EXIT.IO;
1295
+ throw e;
1296
+ }
1297
+ let parsed;
1298
+ try {
1299
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1300
+ } catch (err) {
1301
+ const e = new Error(`could not parse ${path}: ${err.message}`);
1302
+ e.exitCode = EXIT.IO;
1303
+ throw e;
1304
+ }
1305
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1306
+ const e = new Error(`parties file ${path} must be a JSON object`);
1307
+ e.exitCode = EXIT.IO;
1308
+ throw e;
1309
+ }
1310
+ // Reject non-object party entries early so downstream `ref:` resolution
1311
+ // can safely lookup fields without a per-call shape check.
1312
+ for (const [partyKey, party] of Object.entries(parsed)) {
1313
+ if (!party || typeof party !== "object" || Array.isArray(party)) {
1314
+ const e = new Error(`parties file ${path}: entry "${partyKey}" must be a JSON object`);
1315
+ e.exitCode = EXIT.IO;
1316
+ throw e;
1317
+ }
1318
+ }
1319
+ return parsed;
1320
+ }
1321
+
1322
+ /**
1323
+ * Resolve a `ref:parties.<party>.<field>` reference against a loaded
1324
+ * parties object. Throws on malformed ref, missing parties registry,
1325
+ * unknown party, or unknown field. Non-`ref:` strings pass through
1326
+ * unchanged.
1327
+ *
1328
+ * @param {string} value
1329
+ * @param {Object<string, Object<string, *>> | null} parties
1330
+ * @returns {string}
1331
+ * @throws {Error} on malformed or unresolvable reference.
1332
+ */
1333
+ export function resolveRef(value, parties) {
1334
+ if (typeof value !== "string" || !value.startsWith("ref:")) return value;
1335
+ if (!parties) {
1336
+ throw new Error(`reference "${value}" but no parties.json loaded (pass --parties PATH or put parties.json in cwd)`);
1337
+ }
1338
+ const m = /^ref:parties\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
1339
+ if (!m) {
1340
+ throw new Error(`malformed reference "${value}" (expected "ref:parties.<party_key>.<field>")`);
1341
+ }
1342
+ const [, partyKey, fieldKey] = m;
1343
+ const party = parties[partyKey];
1344
+ if (!party) {
1345
+ throw new Error(`unknown party "${partyKey}" in reference "${value}"`);
1346
+ }
1347
+ if (!(fieldKey in party)) {
1348
+ throw new Error(`unknown field "${fieldKey}" on party "${partyKey}" in reference "${value}"`);
1349
+ }
1350
+ const out = party[fieldKey];
1351
+ return out == null ? "" : String(out);
1352
+ }
1353
+
1354
+ /**
1355
+ * Walk a resolved-values map and replace any `ref:` strings with their
1356
+ * resolved values from the parties registry. CLI-sourced values are
1357
+ * left alone (Q2.2: refs are params/default only, never CLI). Collects
1358
+ * all errors before returning so the user sees every failure at once.
1359
+ *
1360
+ * @param {Object<string,string>} resolved — mutated in place.
1361
+ * @param {Object<string,string>} sources — from `resolveValues`.
1362
+ * @param {Object<string, Object<string, *>> | null} parties
1363
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }> }}
1364
+ */
1365
+ export function resolveRefs(resolved, sources, parties) {
1366
+ const errors = [];
1367
+ for (const [key, value] of Object.entries(resolved)) {
1368
+ if (sources[key] === "cli") continue; // Q2.2: CLI values pass through
1369
+ if (typeof value !== "string" || !value.startsWith("ref:")) continue;
1370
+ try {
1371
+ resolved[key] = resolveRef(value, parties);
1372
+ } catch (e) {
1373
+ errors.push({ key, message: e.message });
1374
+ }
1375
+ }
1376
+ return { ok: errors.length === 0, errors };
1377
+ }
1378
+
1379
+ /**
1380
+ * Load and validate a bundle definition (v2 #6). Bundles describe
1381
+ * multiple templates that should be filled with the same set of
1382
+ * parameter values in one invocation:
1383
+ *
1384
+ * {
1385
+ * "_meta": { "schema_version": 1 },
1386
+ * "outputs": [
1387
+ * { "template": "msa/v3.md", "output": "out/msa.md" },
1388
+ * { "template": "order-form/v3.md", "output": "out/order-form.md" }
1389
+ * ]
1390
+ * }
1391
+ *
1392
+ * Returns the parsed bundle. Throws on missing file, invalid JSON, no
1393
+ * `outputs` array, empty `outputs`, missing `template`/`output` on an
1394
+ * entry, or duplicate output paths.
1395
+ *
1396
+ * @param {string} path
1397
+ * @returns {{ outputs: Array<{ template: string, output: string }> }}
1398
+ * @throws {Error} with `.exitCode = EXIT.IO`
1399
+ */
1400
+ export function loadBundle(path) {
1401
+ if (!existsSync(path)) {
1402
+ const e = new Error(`bundle file not found: ${path}`);
1403
+ e.exitCode = EXIT.IO;
1404
+ throw e;
1405
+ }
1406
+ let parsed;
1407
+ try {
1408
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1409
+ } catch (err) {
1410
+ const e = new Error(`could not parse bundle ${path}: ${err.message}`);
1411
+ e.exitCode = EXIT.IO;
1412
+ throw e;
1413
+ }
1414
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1415
+ const e = new Error(`bundle ${path} must be a JSON object`);
1416
+ e.exitCode = EXIT.IO;
1417
+ throw e;
1418
+ }
1419
+ if (!Array.isArray(parsed.outputs) || parsed.outputs.length === 0) {
1420
+ const e = new Error(`bundle ${path}: missing or empty "outputs" array`);
1421
+ e.exitCode = EXIT.IO;
1422
+ throw e;
1423
+ }
1424
+ const seenOutputs = new Set();
1425
+ for (let i = 0; i < parsed.outputs.length; i++) {
1426
+ const o = parsed.outputs[i];
1427
+ if (!o || typeof o !== "object" || Array.isArray(o)) {
1428
+ const e = new Error(`bundle ${path}: outputs[${i}] must be an object`);
1429
+ e.exitCode = EXIT.IO;
1430
+ throw e;
1431
+ }
1432
+ if (typeof o.template !== "string" || !o.template) {
1433
+ const e = new Error(`bundle ${path}: outputs[${i}].template must be a non-empty string`);
1434
+ e.exitCode = EXIT.IO;
1435
+ throw e;
1436
+ }
1437
+ if (typeof o.output !== "string" || !o.output) {
1438
+ const e = new Error(`bundle ${path}: outputs[${i}].output must be a non-empty string`);
1439
+ e.exitCode = EXIT.IO;
1440
+ throw e;
1441
+ }
1442
+ if (seenOutputs.has(o.output)) {
1443
+ const e = new Error(`bundle ${path}: outputs[${i}].output "${o.output}" is duplicated`);
1444
+ e.exitCode = EXIT.IO;
1445
+ throw e;
1446
+ }
1447
+ seenOutputs.add(o.output);
1448
+ }
1449
+ return { outputs: parsed.outputs.map(o => ({ template: o.template, output: o.output })) };
1450
+ }
1451
+
1275
1452
  /**
1276
1453
  * Resolve a value for every placeholder using the locked precedence chain:
1277
1454
  * CLI flag > `--params` JSON > `--interactive` prompt > schema default >
@@ -1912,7 +2089,7 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
1912
2089
  return EXIT.OK;
1913
2090
  }
1914
2091
 
1915
- export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
2092
+ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
1916
2093
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1917
2094
  if (result.tier === "none") {
1918
2095
  err.write(paint("error: no placeholders detected by any tier\n", "red", err));
@@ -1940,6 +2117,22 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1940
2117
  }
1941
2118
  return EXIT.VALIDATION;
1942
2119
  }
2120
+ // v2 #5: parties.json ref resolution. Refs like
2121
+ // `ref:parties.acme_corp.name` in --params or schema defaults expand
2122
+ // before typed normalization. CLI values pass through unchanged.
2123
+ const refCheck = resolveRefs(resolved, sources, parties);
2124
+ if (!refCheck.ok) {
2125
+ for (const re of refCheck.errors) {
2126
+ err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
2127
+ }
2128
+ if (opts.json) {
2129
+ out.write(JSON.stringify({
2130
+ ok: false,
2131
+ ref_errors: refCheck.errors.map(({ key, message }) => ({ key, message })),
2132
+ }, null, 2) + "\n");
2133
+ }
2134
+ return EXIT.VALIDATION;
2135
+ }
1943
2136
  // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1944
2137
  // `--validate` catches type errors before the user runs draft.
1945
2138
  const typeCheck = normalizeTypedValues(result.placeholders, resolved);
@@ -1977,7 +2170,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1977
2170
  return EXIT.OK;
1978
2171
  }
1979
2172
 
1980
- export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err } = {}) {
2173
+ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
1981
2174
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1982
2175
  if (result.tier === "none") {
1983
2176
  const hasProvider = Boolean(llmProviderFromEnv(envObj));
@@ -2045,6 +2238,18 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
2045
2238
  return EXIT.VALIDATION;
2046
2239
  }
2047
2240
 
2241
+ // v2 #5: parties.json ref resolution. Refs like
2242
+ // `ref:parties.acme_corp.name` in --params or schema defaults expand
2243
+ // before typed normalization. CLI values pass through unchanged
2244
+ // (Q2.2 lock).
2245
+ const refCheck = resolveRefs(resolved, sources, parties);
2246
+ if (!refCheck.ok) {
2247
+ for (const re of refCheck.errors) {
2248
+ err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
2249
+ }
2250
+ return EXIT.VALIDATION;
2251
+ }
2252
+
2048
2253
  // v2 #3: typed-parameter normalization. Schema entries can declare
2049
2254
  // `type: date | money | party`. Inputs are validated and normalized
2050
2255
  // before substitution. Hard error on bad input (Q3.3 decision).
@@ -2151,6 +2356,151 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
2151
2356
  return EXIT.OK;
2152
2357
  }
2153
2358
 
2359
+ /**
2360
+ * cmdBundle — orchestrate filling multiple templates with one shared
2361
+ * parameter set (v2 #6). For each bundle entry:
2362
+ * 1. resolveInput + loadSchema (per template)
2363
+ * 2. runCascade (per template)
2364
+ * 3. union placeholders by key
2365
+ * Then resolve values once across the union (CLI/--params/interactive/
2366
+ * default), run typed-param normalization + computed values, and write
2367
+ * each output. Q3.2 locked: any pre-write error (no-detection in an
2368
+ * entry, missing required param across the union, type / computed
2369
+ * failure) aborts the whole bundle before any file is written.
2370
+ *
2371
+ * @param {Object} opts
2372
+ * @param {{outputs: Array<{template: string, output: string}>}} bundle
2373
+ * @param {Object} paramsObj
2374
+ * @param {Object} envObj
2375
+ * @returns {Promise<number>} exit code
2376
+ */
2377
+ export async function cmdBundle(opts, bundle, paramsObj, envObj, { fetcher, out, err, spawner, stdinReader, parties = null } = {}) {
2378
+ // Phase 1: load each template + schema, run detection.
2379
+ const entries = [];
2380
+ for (let i = 0; i < bundle.outputs.length; i++) {
2381
+ const o = bundle.outputs[i];
2382
+ let input, schema, cascade;
2383
+ try {
2384
+ input = await resolveInput(o.template, { spawner, stdinReader });
2385
+ schema = loadSchema(input.path);
2386
+ } catch (e) {
2387
+ err.write(paint(`error: bundle entry ${i} "${o.template}": ${e.message}\n`, "red", err));
2388
+ return e.exitCode || EXIT.IO;
2389
+ }
2390
+ cascade = await runCascade(input, opts, schema, envObj, { fetcher });
2391
+ if (cascade.tier === "none") {
2392
+ err.write(paint(`error: bundle entry ${i} "${o.template}": no placeholders detected by any tier\n`, "red", err));
2393
+ return EXIT.VALIDATION;
2394
+ }
2395
+ // v2 #7 positional errors per template — abort early.
2396
+ if (cascade.positional_errors && cascade.positional_errors.length > 0) {
2397
+ for (const pe of cascade.positional_errors) {
2398
+ err.write(paint(`error: bundle entry ${i} "${o.template}" positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
2399
+ }
2400
+ return EXIT.VALIDATION;
2401
+ }
2402
+ // Orphan check per template (schema declares something not detected here).
2403
+ const orphans = findOrphans(schema, cascade.placeholders, cascade.detected_schema_keys);
2404
+ if (orphans.length > 0) {
2405
+ for (const oo of orphans) {
2406
+ err.write(paint(`error: bundle entry ${i} "${o.template}" schema declares "${oo.key}" but no matching phrase was detected by tier '${cascade.tier}'.\n`, "red", err));
2407
+ }
2408
+ return EXIT.VALIDATION;
2409
+ }
2410
+ entries.push({ output: o.output, input, schema, cascade });
2411
+ }
2412
+
2413
+ // Phase 2: union placeholders by key. Q3.3 locked: union semantics —
2414
+ // a key declared/detected in any template applies to all. First
2415
+ // occurrence's metadata wins (required, default, type, format, etc.);
2416
+ // a per-template later occurrence may have richer aliases but we keep
2417
+ // the first canonical entry.
2418
+ const unionPlaceholders = [];
2419
+ const seenKeys = new Set();
2420
+ for (const e of entries) {
2421
+ for (const p of e.cascade.placeholders) {
2422
+ if (seenKeys.has(p.key)) continue;
2423
+ seenKeys.add(p.key);
2424
+ unionPlaceholders.push(p);
2425
+ }
2426
+ }
2427
+
2428
+ // Phase 3: shared value resolution + footgun guard.
2429
+ const { resolved, missing, sources } = await resolveValues(unionPlaceholders, opts, paramsObj);
2430
+ const declaredKeys = new Set(unionPlaceholders.map((p) => p.key));
2431
+ const unusedFlags = Object.keys(opts.paramFlags).filter((k) => !declaredKeys.has(k));
2432
+ for (const u of unusedFlags) {
2433
+ err.write(paint(`warning: flag --${u.replace(/_/g, "-")} did not match any placeholder in any bundle template (possible typo?)\n`, "yellow", err));
2434
+ }
2435
+ if (missing.length > 0) {
2436
+ printMissing(missing, err);
2437
+ return EXIT.VALIDATION;
2438
+ }
2439
+
2440
+ // v2 #5: parties.json refs resolve across the union before typed
2441
+ // normalization (same order as cmdDraft / cmdValidate).
2442
+ const refCheck = resolveRefs(resolved, sources, parties);
2443
+ if (!refCheck.ok) {
2444
+ for (const re of refCheck.errors) {
2445
+ err.write(paint(`error: parties reference failed for "${re.key}": ${re.message}\n`, "red", err));
2446
+ }
2447
+ return EXIT.VALIDATION;
2448
+ }
2449
+
2450
+ // Phase 4: typed-parameter + computed pipelines (same as cmdDraft).
2451
+ const typeCheck = normalizeTypedValues(unionPlaceholders, resolved);
2452
+ if (!typeCheck.ok) {
2453
+ for (const te of typeCheck.errors) {
2454
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
2455
+ }
2456
+ return EXIT.VALIDATION;
2457
+ }
2458
+ const computeCheck = computeValues(unionPlaceholders, resolved);
2459
+ if (!computeCheck.ok) {
2460
+ for (const ce of computeCheck.errors) {
2461
+ err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
2462
+ }
2463
+ return EXIT.VALIDATION;
2464
+ }
2465
+
2466
+ // Phase 5: substitute per template + write. Q3.2: any write failure
2467
+ // exits with EXIT.IO; earlier successful writes are NOT rolled back
2468
+ // (atomicity at the filesystem is best-effort).
2469
+ for (let i = 0; i < entries.length; i++) {
2470
+ const e = entries[i];
2471
+ const outputText = substitute(e.input.body, e.cascade.placeholders, resolved, e.cascade.tier);
2472
+ try {
2473
+ // For .docx input with .docx output: round-trip via substituteDocxXml.
2474
+ if (e.input.kind === "docx" && extname(e.output) === ".docx") {
2475
+ const { xml: newXml, warnings: dw } = substituteDocxXml(
2476
+ e.input.docxXml, e.cascade.placeholders, resolved, e.cascade.tier
2477
+ );
2478
+ if (dw.length) for (const w of dw) err.write(paint(`warning (entry ${i}): ${w}\n`, "yellow", err));
2479
+ const buf = await writeDocxBuffer(e.input.path, newXml);
2480
+ writeFileSync(e.output, buf);
2481
+ } else {
2482
+ writeFileSync(e.output, outputText, "utf8");
2483
+ }
2484
+ } catch (writeErr) {
2485
+ err.write(paint(`error: bundle entry ${i} could not write ${e.output}: ${writeErr.message}\n`, "red", err));
2486
+ return EXIT.IO;
2487
+ }
2488
+ }
2489
+
2490
+ if (!opts.silent && !opts.json) {
2491
+ err.write(paint(`ok: wrote ${entries.length} document(s) — ${entries.map(e => e.output).join(", ")}\n`, "green", err));
2492
+ }
2493
+ if (opts.json) {
2494
+ out.write(JSON.stringify({
2495
+ ok: true,
2496
+ outputs: entries.map(e => ({ template: e.input.path, output: e.output, tier: e.cascade.tier })),
2497
+ resolved_keys: Object.keys(resolved),
2498
+ sources,
2499
+ }, null, 2) + "\n");
2500
+ }
2501
+ return EXIT.OK;
2502
+ }
2503
+
2154
2504
  function describeInput(input) {
2155
2505
  if (input.path) return input.path;
2156
2506
  if (input.kind === "text") return "stdin";
@@ -2452,6 +2802,33 @@ export async function main(argv, io = {}) {
2452
2802
  return await runCheckLlm(envObj, out, err, { fetcher });
2453
2803
  }
2454
2804
 
2805
+ // v2 #6: bundle mode. `--bundle PATH` reads a bundle definition and
2806
+ // orchestrates filling each entry's template with shared parameters.
2807
+ // In bundle mode, no positional template arg is required (the bundle
2808
+ // declares them).
2809
+ if (opts.bundle) {
2810
+ if (opts.positional.length > 0) {
2811
+ err.write(paint(`error: --bundle does not take a positional template arg (the bundle declares them)\n`, "red", err));
2812
+ return EXIT.IO;
2813
+ }
2814
+ let bundle, paramsObj, envObj, parties;
2815
+ try {
2816
+ bundle = loadBundle(opts.bundle);
2817
+ paramsObj = loadParamsFile(opts.params);
2818
+ envObj = effectiveEnv(cwd, processEnv);
2819
+ parties = loadParties(opts.parties || null);
2820
+ } catch (e) {
2821
+ err.write(paint(`error: ${e.message}\n`, "red", err));
2822
+ return e.exitCode || EXIT.IO;
2823
+ }
2824
+ try {
2825
+ return await cmdBundle(opts, bundle, paramsObj, envObj, { fetcher, out, err, spawner, stdinReader, parties });
2826
+ } catch (e) {
2827
+ err.write(paint(`error: ${e.message}\n`, "red", err));
2828
+ return e.exitCode || EXIT.IO;
2829
+ }
2830
+ }
2831
+
2455
2832
  if (opts.positional.length === 0) {
2456
2833
  err.write(paint(`error: no template given\n`, "red", err));
2457
2834
  err.write(`run \`draft --help\` for usage.\n`);
@@ -2462,12 +2839,13 @@ export async function main(argv, io = {}) {
2462
2839
  return EXIT.IO;
2463
2840
  }
2464
2841
 
2465
- let input, schema, paramsObj, envObj;
2842
+ let input, schema, paramsObj, envObj, parties;
2466
2843
  try {
2467
2844
  input = await resolveInput(opts.positional[0], { spawner, stdinReader });
2468
2845
  schema = loadSchema(input.path);
2469
2846
  paramsObj = loadParamsFile(opts.params);
2470
2847
  envObj = effectiveEnv(cwd, processEnv);
2848
+ parties = loadParties(opts.parties || null);
2471
2849
  } catch (e) {
2472
2850
  err.write(paint(`error: ${e.message}\n`, "red", err));
2473
2851
  return e.exitCode || EXIT.IO;
@@ -2478,9 +2856,9 @@ export async function main(argv, io = {}) {
2478
2856
  return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
2479
2857
  }
2480
2858
  if (opts.validate) {
2481
- return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2859
+ return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2482
2860
  }
2483
- return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2861
+ return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2484
2862
  } catch (e) {
2485
2863
  err.write(paint(`error: ${e.message}\n`, "red", err));
2486
2864
  return e.exitCode || EXIT.IO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {