@drbaher/draft-cli 0.5.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,52 @@ 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
+
7
53
  ## 0.5.0 — 2026-05-16
8
54
 
9
55
  ### Added
package/PARAM_SCHEMA.md CHANGED
@@ -388,6 +388,69 @@ 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
+
391
454
  ### Orphan handling (Q4 locked)
392
455
 
393
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.5.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];
@@ -1272,6 +1273,108 @@ export function loadParamsFile(path) {
1272
1273
  }
1273
1274
  }
1274
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
+
1275
1378
  /**
1276
1379
  * Resolve a value for every placeholder using the locked precedence chain:
1277
1380
  * CLI flag > `--params` JSON > `--interactive` prompt > schema default >
@@ -1912,7 +2015,7 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
1912
2015
  return EXIT.OK;
1913
2016
  }
1914
2017
 
1915
- 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 } = {}) {
1916
2019
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1917
2020
  if (result.tier === "none") {
1918
2021
  err.write(paint("error: no placeholders detected by any tier\n", "red", err));
@@ -1940,6 +2043,22 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1940
2043
  }
1941
2044
  return EXIT.VALIDATION;
1942
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
+ }
1943
2062
  // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1944
2063
  // `--validate` catches type errors before the user runs draft.
1945
2064
  const typeCheck = normalizeTypedValues(result.placeholders, resolved);
@@ -1977,7 +2096,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1977
2096
  return EXIT.OK;
1978
2097
  }
1979
2098
 
1980
- 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 } = {}) {
1981
2100
  const result = await runCascade(input, opts, schema, envObj, { fetcher });
1982
2101
  if (result.tier === "none") {
1983
2102
  const hasProvider = Boolean(llmProviderFromEnv(envObj));
@@ -2045,6 +2164,18 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
2045
2164
  return EXIT.VALIDATION;
2046
2165
  }
2047
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
+
2048
2179
  // v2 #3: typed-parameter normalization. Schema entries can declare
2049
2180
  // `type: date | money | party`. Inputs are validated and normalized
2050
2181
  // before substitution. Hard error on bad input (Q3.3 decision).
@@ -2462,12 +2593,13 @@ export async function main(argv, io = {}) {
2462
2593
  return EXIT.IO;
2463
2594
  }
2464
2595
 
2465
- let input, schema, paramsObj, envObj;
2596
+ let input, schema, paramsObj, envObj, parties;
2466
2597
  try {
2467
2598
  input = await resolveInput(opts.positional[0], { spawner, stdinReader });
2468
2599
  schema = loadSchema(input.path);
2469
2600
  paramsObj = loadParamsFile(opts.params);
2470
2601
  envObj = effectiveEnv(cwd, processEnv);
2602
+ parties = loadParties(opts.parties || null);
2471
2603
  } catch (e) {
2472
2604
  err.write(paint(`error: ${e.message}\n`, "red", err));
2473
2605
  return e.exitCode || EXIT.IO;
@@ -2478,9 +2610,9 @@ export async function main(argv, io = {}) {
2478
2610
  return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
2479
2611
  }
2480
2612
  if (opts.validate) {
2481
- return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2613
+ return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2482
2614
  }
2483
- return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err });
2615
+ return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2484
2616
  } catch (e) {
2485
2617
  err.write(paint(`error: ${e.message}\n`, "red", err));
2486
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.5.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": {