@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 +119 -18
- package/PARAM_SCHEMA.md +120 -0
- package/draft-cli.mjs +273 -14
- package/package.json +1 -1
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 (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|