@drbaher/draft-cli 0.6.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,55 @@ 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
+
7
56
  ## 0.6.0 — 2026-05-16
8
57
 
9
58
  ### Added
package/PARAM_SCHEMA.md CHANGED
@@ -451,6 +451,70 @@ substitution always uses string output.
451
451
  Programmatic API: `loadParties(path)`, `resolveRef(value, parties)`,
452
452
  `resolveRefs(resolved, sources, parties)`.
453
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
+
454
518
  ### Orphan handling (Q4 locked)
455
519
 
456
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.6.0";
73
+ export const VERSION = "0.7.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -278,6 +278,7 @@ export function parseArgs(argv) {
278
278
  if (a === "--diff") { opts.diff = true; continue; }
279
279
  if (a === "--params") { opts.params = argv[++i]; continue; }
280
280
  if (a === "--parties") { opts.parties = argv[++i]; continue; }
281
+ if (a === "--bundle") { opts.bundle = argv[++i]; continue; }
281
282
  if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
282
283
  if (a === "--syntax") {
283
284
  const v = argv[++i];
@@ -1375,6 +1376,79 @@ export function resolveRefs(resolved, sources, parties) {
1375
1376
  return { ok: errors.length === 0, errors };
1376
1377
  }
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
+
1378
1452
  /**
1379
1453
  * Resolve a value for every placeholder using the locked precedence chain:
1380
1454
  * CLI flag > `--params` JSON > `--interactive` prompt > schema default >
@@ -2282,6 +2356,151 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
2282
2356
  return EXIT.OK;
2283
2357
  }
2284
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
+
2285
2504
  function describeInput(input) {
2286
2505
  if (input.path) return input.path;
2287
2506
  if (input.kind === "text") return "stdin";
@@ -2583,6 +2802,33 @@ export async function main(argv, io = {}) {
2583
2802
  return await runCheckLlm(envObj, out, err, { fetcher });
2584
2803
  }
2585
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
+
2586
2832
  if (opts.positional.length === 0) {
2587
2833
  err.write(paint(`error: no template given\n`, "red", err));
2588
2834
  err.write(`run \`draft --help\` for usage.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.6.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": {