@drbaher/draft-cli 0.3.2 → 0.5.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,102 @@ 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.5.0 — 2026-05-16
8
+
9
+ ### Added
10
+
11
+ - **Positional addressing** for same-text placeholders with different
12
+ semantic roles. Long-form schema entries can declare a `positions`
13
+ array; each position gets its own canonical key (via `role`), so the
14
+ CLI uses standard `--<role>` flags. Validated against the YC SAFE
15
+ `$[_____________] × 2` case (valuation cap vs. purchase amount).
16
+ ```json
17
+ "blank": {
18
+ "aliases": ["_____________"],
19
+ "type": "money", "currency": "USD",
20
+ "positions": [
21
+ { "role": "valuation_cap" },
22
+ { "role": "purchase_amount" }
23
+ ]
24
+ }
25
+ ```
26
+ ```sh
27
+ draft safe.docx \
28
+ --valuation-cap 5000000 \
29
+ --purchase-amount 100000
30
+ ```
31
+ Decisions locked (V2_BRIEFS_REMAINING Q1.1–Q1.3):
32
+ - **Q1.1 Index base**: schema positions are 0-indexed internally;
33
+ the CLI uses role names, not numeric indices.
34
+ - **Q1.2 Length mismatch**: schema declares N positions but
35
+ detection finds M ≠ N occurrences → hard error (exit 4).
36
+ - **Q1.3 Bare-key CLI**: a `--<role>` flag targets its specific
37
+ position; values still flow through `--params` JSON or
38
+ `--interactive` normally.
39
+
40
+ ### Constraints
41
+
42
+ - Positional addressing only works at tier T1 (bracket) and T2
43
+ (mustache) — those tiers carry per-hit byte indices needed for
44
+ position-specific substitution. T3 (docx-highlight), T4 (heuristic),
45
+ T5 (LLM) raise a positional error if a positional schema entry's
46
+ aliases are matched by them. `.docx` templates with `[X]` brackets
47
+ that fire T1 still work; `.docx` templates that rely on T3 highlights
48
+ for the same alias do not.
49
+
50
+ ### Schema-contract change
51
+
52
+ `PARAM_SCHEMA.md` §5 gains a "Positional addressing" subsection. Long-
53
+ form entries can now include a `positions` array; short form is
54
+ unchanged. Forward-compatible with v0.4.x readers — they'll ignore the
55
+ unknown field and treat the entry as a regular non-positional
56
+ placeholder (which means the first detected occurrence wins for
57
+ substitution, and ambiguity is unresolved).
58
+
59
+ ## 0.4.0 — 2026-05-16
60
+
61
+ ### Added
62
+
63
+ - **Computed placeholders.** Long-form schema entries can declare a
64
+ `computed` block referencing another key:
65
+ ```json
66
+ "term_end": {
67
+ "aliases": ["Term End"],
68
+ "type": "date",
69
+ "format": "MMMM d, yyyy",
70
+ "computed": { "from": "effective_date", "op": "+", "value": "2 years" }
71
+ }
72
+ ```
73
+ At substitution time, if no value was supplied via CLI / `--params`
74
+ / interactive / default, the computed entry's value is derived from
75
+ the `from` placeholder. Explicit CLI / `--params` values still win —
76
+ computed only fills the gap. Q2.1 locked: expression syntax lives
77
+ in the schema only, not in template text — keeps T1 detection
78
+ unchanged. Q2.2 locked: v0.4.0 supports date arithmetic only
79
+ (`+` / `-` with `<n> day|week|month|year[s]` durations). Money
80
+ math and string concat deferred to a future release.
81
+ - **Schema-time cycle detection.** `parseSchema` throws if any
82
+ `computed.from` chain revisits a key (e.g. `a → b → a`), or if
83
+ `computed.from` references a key that doesn't exist in the same
84
+ schema. Catches misconfiguration before substitution starts.
85
+ - **Orphan-check exemption.** Schema entries that are referenced only
86
+ as another entry's `computed.from` source (and never appear as
87
+ detected aliases in the template) are no longer reported as
88
+ orphans. They're "feeders" — declared so a computed entry can
89
+ reference them, even though the template doesn't show them.
90
+ - **New public API:** `parseDuration(raw)`, `addDuration(date, op, dur)`,
91
+ `computeValues(placeholders, resolved)`.
92
+
93
+ ### Schema-contract change
94
+
95
+ `PARAM_SCHEMA.md` §5 gains a "Computed placeholders" section. Long-
96
+ form entries can now include a `computed: { from, op, value }` block;
97
+ short form is unchanged. v0.4.0 schemas are forward-compatible with
98
+ v0.3.x readers (which will silently ignore the `computed` field as
99
+ unrecognized long-form metadata, treating the entry as a regular
100
+ placeholder — but then the user has to supply a value, since v0.3.x
101
+ won't compute one).
102
+
7
103
  ## 0.3.2 — 2026-05-16
8
104
 
9
105
  ### Fixed
@@ -219,22 +315,25 @@ suite ([cli.drbaher.com](https://cli.drbaher.com)).
219
315
  that contains at least one letter. False positives are filtered with
220
316
  the schema file; false negatives in this domain are higher-cost.
221
317
 
222
- ## Deferred (v2 candidates)
223
-
224
- - **`.docx` output round-trip.** v1 writes plain markdown even from a
225
- `.docx` input. Re-writing back into a `.docx` (preserving styles,
226
- numbering, and run formatting) is a separate problem.
227
- - **Computed placeholders** (`[Effective Date + 2 years]`). The long-form
228
- schema reserves a future `"computed"` field.
229
- - **Typed parameters** (`party`, `date`, `money` with format validation).
230
- Schema reserves a future `"type"` field.
231
- - **LLM-assisted parameter inference from a deal description.** v1's T5
232
- only suggests placeholders from template text not from external prose
233
- describing the deal.
234
- - **Cross-template parameter registry** (`parties.json` remembering
235
- addresses, e-signature contacts, etc.). Additive would layer
236
- underneath `--params` in precedence.
237
- - **Multi-document bundles** (MSA + SOW sharing parameters in one call).
238
- v1 is one document per invocation.
318
+ ## Deferred (post-v0.4.0 candidates)
319
+
320
+ Three of the original seven v1 "Deferred" entries shipped in v0.2.0,
321
+ v0.3.2, and v0.4.0 (see entries above). The four remaining items are
322
+ the next chunk of design work, with briefs in `V2_BRIEFS_REMAINING.md`:
323
+
324
+ - **Positional addressing.** Disambiguate same-text placeholders by
325
+ index in the schema. The validated case: YC SAFE has
326
+ `$[_____________]` twice once for the valuation cap, once for
327
+ the purchase amount. Smallest of the four (~150 LOC).
328
+ - **Cross-template `parties.json` registry.** Declare parties once
329
+ with `ref:parties.<key>.<field>` references from schemas. Eliminates
330
+ duplicating party metadata across every template (~250 LOC).
331
+ - **Multi-document bundles.** Resolve placeholders once and emit
332
+ multiple documents in one call (MSA + Order Form + DPA with shared
333
+ parameter values) (~250 LOC).
334
+ - **LLM inference from a deal description.** `--from-deal <path>`
335
+ reads free-form deal text and asks the T5 LLM provider to fill the
336
+ schema's parameters. Inverse of the existing T5 detection (~250 LOC).
239
337
  - **`.docx` highlight detection beyond yellow/green/cyan/magenta.** v1
240
- ignores other colors (black/white/none) by design.
338
+ ignores other colors (black/white/none) by design. Backlog, not in
339
+ V2_BRIEFS_REMAINING (low priority).
package/PARAM_SCHEMA.md CHANGED
@@ -270,6 +270,124 @@ Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
270
270
  `parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
271
271
  `normalizeTypedValues`.
272
272
 
273
+ ### Computed placeholders (v0.4.0, opt-in)
274
+
275
+ Long-form entries can declare a `computed` block referencing another
276
+ key in the same schema. At substitution time, if no value was
277
+ supplied via CLI / `--params` / interactive / default, the computed
278
+ entry's value is derived from the `from` placeholder via simple
279
+ date arithmetic. Explicit user-supplied values still win — computed
280
+ only fills the gap.
281
+
282
+ ```json
283
+ {
284
+ "_meta": { "schema_version": 1 },
285
+ "effective_date": {
286
+ "aliases": ["Effective Date"],
287
+ "type": "date", "format": "MMMM d, yyyy"
288
+ },
289
+ "term_end": {
290
+ "aliases": ["Term End"],
291
+ "type": "date", "format": "MMMM d, yyyy",
292
+ "computed": { "from": "effective_date", "op": "+", "value": "2 years" }
293
+ }
294
+ }
295
+ ```
296
+
297
+ | Field | Type | Required | Notes |
298
+ | ------- | -------- | -------- | ----- |
299
+ | `from` | string | yes | Key of another entry in the same schema. Schema validation rejects unknown references. |
300
+ | `op` | `"+"`/`"-"` | yes | Add or subtract the duration from the `from` value. |
301
+ | `value` | string | yes | Duration in `<n> <unit>` form, where `<unit>` is `day`, `week`, `month`, or `year` (singular or plural; case-insensitive). |
302
+
303
+ **Q2.1 locked:** Expression syntax lives in the schema, **not** in
304
+ template text. T1 bracket detection treats `[Term End]` as an
305
+ ordinary placeholder; the schema-level `computed` block decides how
306
+ its value is derived.
307
+
308
+ **Q2.2 locked:** v0.4.0 supports **date arithmetic only**. Money
309
+ math (`+ 10%`) and string concat (`Party A + " Inc."`) are deferred
310
+ to a future release once the date-arithmetic design is proven against
311
+ real templates.
312
+
313
+ **Resolution order:** value resolution → typed-parameter normalization
314
+ (§ above) → computed-placeholder evaluation → substitution.
315
+
316
+ **Cycle and reference safety:** `parseSchema` walks every
317
+ `computed.from` chain and throws at load time if a chain revisits a
318
+ key (e.g. `a → b → a`) or references a non-existent key. Caught
319
+ before substitution.
320
+
321
+ **Orphan-check exemption:** an entry that's referenced only as
322
+ another entry's `computed.from` (never appears in the template) is
323
+ **not** an orphan. It's a feeder used solely for computation.
324
+
325
+ **Format inheritance:** the computed entry's `format` field is used
326
+ to render the result. If the computed entry doesn't declare `format`,
327
+ the default `MMMM d, yyyy` applies. The `from` entry's `format` is
328
+ used for parsing the source value (since by then it's normalized to
329
+ that format).
330
+
331
+ Programmatic API for drivers: `parseDuration`, `addDuration`,
332
+ `computeValues`.
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
+
273
391
  ### Orphan handling (Q4 locked)
274
392
 
275
393
  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.3.2";
73
+ export const VERSION = "0.5.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -914,6 +914,66 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
914
914
  e.exitCode = EXIT.IO;
915
915
  throw e;
916
916
  }
917
+ // v2 #7: positional addressing. Optional `positions` array; each
918
+ // element declares a role (its own canonical key) for the Nth detected
919
+ // occurrence of this entry's aliases. Roles must be valid keys and
920
+ // unique within the entry.
921
+ let positions = null;
922
+ if (v.positions !== undefined) {
923
+ if (!Array.isArray(v.positions) || v.positions.length === 0) {
924
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' positions must be a non-empty array`);
925
+ e.exitCode = EXIT.IO;
926
+ throw e;
927
+ }
928
+ const roleSet = new Set();
929
+ positions = [];
930
+ for (let pi = 0; pi < v.positions.length; pi++) {
931
+ const pos = v.positions[pi];
932
+ if (!pos || typeof pos !== "object" || Array.isArray(pos)) {
933
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}] must be an object with a 'role' string`);
934
+ e.exitCode = EXIT.IO;
935
+ throw e;
936
+ }
937
+ if (typeof pos.role !== "string" || !validKey(pos.role)) {
938
+ const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}].role must be a valid snake_case key`);
939
+ e.exitCode = EXIT.IO;
940
+ throw e;
941
+ }
942
+ if (roleSet.has(pos.role)) {
943
+ const e = new Error(`${sourceLabel}: '${k}'.positions has duplicate role '${pos.role}'`);
944
+ e.exitCode = EXIT.IO;
945
+ throw e;
946
+ }
947
+ roleSet.add(pos.role);
948
+ positions.push({ role: pos.role });
949
+ }
950
+ }
951
+ // v2 #2: computed placeholders. Optional `computed` block on long-form
952
+ // entries; { from: <other-key>, op: "+"|"-", value: "<n> <unit>" }.
953
+ let computed = null;
954
+ if (v.computed !== undefined) {
955
+ if (!v.computed || typeof v.computed !== "object" || Array.isArray(v.computed)) {
956
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' has invalid 'computed' (must be an object)`);
957
+ e.exitCode = EXIT.IO;
958
+ throw e;
959
+ }
960
+ if (typeof v.computed.from !== "string") {
961
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.from must be a string (key of another schema entry)`);
962
+ e.exitCode = EXIT.IO;
963
+ throw e;
964
+ }
965
+ if (v.computed.op !== "+" && v.computed.op !== "-") {
966
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.op must be "+" or "-"`);
967
+ e.exitCode = EXIT.IO;
968
+ throw e;
969
+ }
970
+ if (typeof v.computed.value !== "string") {
971
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.value must be a string (duration like "2 years")`);
972
+ e.exitCode = EXIT.IO;
973
+ throw e;
974
+ }
975
+ computed = { from: v.computed.from, op: v.computed.op, value: v.computed.value };
976
+ }
917
977
  entries[k] = {
918
978
  aliases: v.aliases.slice(),
919
979
  required: v.required !== false,
@@ -924,6 +984,8 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
924
984
  type: typeof v.type === "string" ? v.type : null,
925
985
  format: typeof v.format === "string" ? v.format : null,
926
986
  currency: typeof v.currency === "string" ? v.currency : null,
987
+ computed,
988
+ positions,
927
989
  };
928
990
  } else {
929
991
  if (!Array.isArray(v)) {
@@ -931,7 +993,30 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
931
993
  e.exitCode = EXIT.IO;
932
994
  throw e;
933
995
  }
934
- entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
996
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null, positions: null };
997
+ }
998
+ }
999
+ // v2 #2: validate computed references (point to existing keys; no cycles).
1000
+ for (const [key, entry] of Object.entries(entries)) {
1001
+ if (!entry.computed) continue;
1002
+ if (!entries[entry.computed.from]) {
1003
+ const e = new Error(`${sourceLabel}: '${key}'.computed.from = "${entry.computed.from}" does not match any other key in this schema`);
1004
+ e.exitCode = EXIT.IO;
1005
+ throw e;
1006
+ }
1007
+ // Walk the computed.from chain from this key; bail if we revisit.
1008
+ const visited = [key];
1009
+ let cursor = entry.computed.from;
1010
+ while (cursor) {
1011
+ if (visited.includes(cursor)) {
1012
+ const e = new Error(`${sourceLabel}: computed cycle detected: ${[...visited, cursor].join(" → ")}`);
1013
+ e.exitCode = EXIT.IO;
1014
+ throw e;
1015
+ }
1016
+ visited.push(cursor);
1017
+ const next = entries[cursor];
1018
+ if (!next || !next.computed) break;
1019
+ cursor = next.computed.from;
935
1020
  }
936
1021
  }
937
1022
  return { form: long ? "long" : "short", entries };
@@ -1075,6 +1160,8 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1075
1160
  type: resolved.type,
1076
1161
  format: resolved.format,
1077
1162
  currency: resolved.currency,
1163
+ computed: resolved.computed,
1164
+ positions: resolved.positions,
1078
1165
  hits: [],
1079
1166
  });
1080
1167
  }
@@ -1082,7 +1169,52 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1082
1169
  entry.occurrences += 1;
1083
1170
  entry.hits.push(h);
1084
1171
  }
1085
- return { tier, placeholders: [...byKey.values()], warnings, unmapped };
1172
+ // v2 #7: expand positional entries. Each detected occurrence becomes a
1173
+ // separate role-keyed placeholder. Count mismatch → positional_errors;
1174
+ // tier T3/T4/T5 (no per-hit index) → positional_errors (not supported).
1175
+ const placeholders = [];
1176
+ const positional_errors = [];
1177
+ const detected_schema_keys = [...byKey.keys()];
1178
+ for (const p of byKey.values()) {
1179
+ if (!p.positions) {
1180
+ placeholders.push(p);
1181
+ continue;
1182
+ }
1183
+ if (tier !== "bracket" && tier !== "mustache") {
1184
+ positional_errors.push({
1185
+ key: p.key,
1186
+ reason: `tier '${tier}' does not carry per-hit index info; positional addressing requires T1 (bracket) or T2 (mustache) detection`,
1187
+ });
1188
+ continue;
1189
+ }
1190
+ if (p.hits.length !== p.positions.length) {
1191
+ positional_errors.push({
1192
+ key: p.key,
1193
+ reason: `schema declares ${p.positions.length} position(s) but detected ${p.hits.length} occurrence(s) of "${p.aliases[0] || p.key}"`,
1194
+ });
1195
+ continue;
1196
+ }
1197
+ for (let i = 0; i < p.positions.length; i++) {
1198
+ placeholders.push({
1199
+ key: p.positions[i].role,
1200
+ first_seen_as: p.hits[i].inner,
1201
+ occurrences: 1,
1202
+ tier,
1203
+ required: true,
1204
+ default: null,
1205
+ aliases: p.aliases.slice(),
1206
+ type: p.type,
1207
+ format: p.format,
1208
+ currency: p.currency,
1209
+ computed: null,
1210
+ positions: null, // expanded; no further re-expansion
1211
+ hits: [p.hits[i]],
1212
+ position_parent: p.key,
1213
+ position_index: i,
1214
+ });
1215
+ }
1216
+ }
1217
+ return { tier, placeholders, warnings, unmapped, positional_errors, detected_schema_keys };
1086
1218
  }
1087
1219
 
1088
1220
  function resolveKey(hit, schema, fromLlm) {
@@ -1097,6 +1229,8 @@ function resolveKey(hit, schema, fromLlm) {
1097
1229
  type: entry.type || null,
1098
1230
  format: entry.format || null,
1099
1231
  currency: entry.currency || null,
1232
+ computed: entry.computed || null,
1233
+ positions: entry.positions || null,
1100
1234
  };
1101
1235
  }
1102
1236
  }
@@ -1104,7 +1238,7 @@ function resolveKey(hit, schema, fromLlm) {
1104
1238
  }
1105
1239
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1106
1240
  if (!validKey(key)) return null;
1107
- return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
1241
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null, positions: null };
1108
1242
  }
1109
1243
 
1110
1244
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1177,7 +1311,9 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
1177
1311
  sources[p.key] = "default";
1178
1312
  continue;
1179
1313
  }
1180
- if (p.required) missing.push(p);
1314
+ // v2 #2: computed placeholders auto-resolve later via `computeValues`.
1315
+ // Don't count them as missing here even though no source supplied a value.
1316
+ if (p.required && !p.computed) missing.push(p);
1181
1317
  }
1182
1318
  return { resolved, missing, sources };
1183
1319
  }
@@ -1255,6 +1391,46 @@ export function formatDateValue(date, format) {
1255
1391
  });
1256
1392
  }
1257
1393
 
1394
+ /**
1395
+ * Parse a duration string for computed placeholders (v2 #2).
1396
+ * Accepts `<n> <unit>` where unit is one of `day | week | month | year`
1397
+ * (singular or plural). Returns an object with the unit as plural key.
1398
+ * Returns `null` on parse failure.
1399
+ *
1400
+ * @param {string} raw
1401
+ * @returns {{ days?: number, weeks?: number, months?: number, years?: number } | null}
1402
+ */
1403
+ export function parseDuration(raw) {
1404
+ const m = /^(\d+)\s+(day|week|month|year)s?$/i.exec(String(raw).trim());
1405
+ if (!m) return null;
1406
+ const n = parseInt(m[1], 10);
1407
+ if (!isFinite(n) || n < 0) return null;
1408
+ const unit = m[2].toLowerCase();
1409
+ return { [`${unit}s`]: n };
1410
+ }
1411
+
1412
+ /**
1413
+ * Add or subtract a duration from a Date. Uses UTC field manipulation
1414
+ * via `setUTC*` methods, so day/month/year overflow follows JavaScript's
1415
+ * default behavior (e.g. Jan 31 + 1 month = Mar 3, not Feb 28). For
1416
+ * legal-doc use cases ("2 years from effective date") this is the
1417
+ * expected behavior; anniversary dates are unambiguous.
1418
+ *
1419
+ * @param {Date} date
1420
+ * @param {"+"|"-"} op
1421
+ * @param {{ days?: number, weeks?: number, months?: number, years?: number }} dur
1422
+ * @returns {Date}
1423
+ */
1424
+ export function addDuration(date, op, dur) {
1425
+ const sign = op === "-" ? -1 : 1;
1426
+ const d = new Date(date.getTime());
1427
+ if (dur.years) d.setUTCFullYear(d.getUTCFullYear() + sign * dur.years);
1428
+ if (dur.months) d.setUTCMonth(d.getUTCMonth() + sign * dur.months);
1429
+ if (dur.weeks) d.setUTCDate(d.getUTCDate() + sign * dur.weeks * 7);
1430
+ if (dur.days) d.setUTCDate(d.getUTCDate() + sign * dur.days);
1431
+ return d;
1432
+ }
1433
+
1258
1434
  /**
1259
1435
  * Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
1260
1436
  * Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
@@ -1394,6 +1570,91 @@ export function normalizeTypedValues(placeholders, resolved) {
1394
1570
  return { ok: errors.length === 0, errors, normalized };
1395
1571
  }
1396
1572
 
1573
+ // ─── COMPUTED PLACEHOLDERS (v2 #2) ──────────────────────────────────────────
1574
+ // Schema entries can declare a `computed` block referencing another key in
1575
+ // the same schema:
1576
+ //
1577
+ // "term_end": { "aliases": ["Term End"], "type": "date",
1578
+ // "computed": { "from": "effective_date", "op": "+", "value": "2 years" } }
1579
+ //
1580
+ // At substitution time, if no value was supplied via CLI/--params/interactive/
1581
+ // default, the computed entry's value is derived from its `from` placeholder.
1582
+ // CLI/--params explicit values still win — computed only fills the gap.
1583
+ //
1584
+ // Cycles in `from` references are detected at parseSchema time. Missing-`from`
1585
+ // errors and bad-duration errors surface at compute time with a per-key
1586
+ // message; like typed-param errors, all errors are collected before returning
1587
+ // so the user sees every failure at once.
1588
+
1589
+ /**
1590
+ * Run computed-placeholder evaluation on already-resolved values. Mutates
1591
+ * `resolved` in place with computed values for any placeholder that has a
1592
+ * `computed` block and no existing value. Iterative — handles chains
1593
+ * (B from A, C from B) without an explicit topological sort.
1594
+ *
1595
+ * @param {Placeholder[]} placeholders
1596
+ * @param {Object<string,string>} resolved
1597
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, computed: Object<string,{from: string, op: string, value: string, to: string}> }}
1598
+ */
1599
+ export function computeValues(placeholders, resolved) {
1600
+ const errors = [];
1601
+ const computed = {};
1602
+ const pending = placeholders.filter(
1603
+ (p) => p.computed && resolved[p.key] === undefined
1604
+ );
1605
+ if (pending.length === 0) return { ok: true, errors: [], computed: {} };
1606
+
1607
+ let progress = true;
1608
+ while (progress && pending.length > 0) {
1609
+ progress = false;
1610
+ for (let i = pending.length - 1; i >= 0; i--) {
1611
+ const p = pending[i];
1612
+ const fromKey = p.computed.from;
1613
+ const fromValue = resolved[fromKey];
1614
+ if (fromValue === undefined) continue;
1615
+ try {
1616
+ const result = computeOneValue(p, fromValue);
1617
+ resolved[p.key] = result;
1618
+ computed[p.key] = { from: fromKey, op: p.computed.op, value: p.computed.value, to: result };
1619
+ pending.splice(i, 1);
1620
+ progress = true;
1621
+ } catch (e) {
1622
+ errors.push({ key: p.key, message: e.message });
1623
+ pending.splice(i, 1);
1624
+ }
1625
+ }
1626
+ }
1627
+ for (const p of pending) {
1628
+ errors.push({
1629
+ key: p.key,
1630
+ message: `cannot compute: depends on "${p.computed.from}" which is unresolved`,
1631
+ });
1632
+ }
1633
+ return { ok: errors.length === 0, errors, computed };
1634
+ }
1635
+
1636
+ function computeOneValue(p, fromValue) {
1637
+ // v2: dates only. Future expansions (money math, string concat) would
1638
+ // dispatch on placeholder type here.
1639
+ const date = parseDateValue(fromValue);
1640
+ if (!date) {
1641
+ throw new Error(
1642
+ `cannot parse "${fromValue}" as a date (from "${p.computed.from}"). ` +
1643
+ `Computed placeholders need a date-shaped source value.`
1644
+ );
1645
+ }
1646
+ const dur = parseDuration(p.computed.value);
1647
+ if (!dur) {
1648
+ throw new Error(
1649
+ `cannot parse duration "${p.computed.value}". Expected ` +
1650
+ `"<n> <unit>" where unit is day, week, month, or year (singular ` +
1651
+ `or plural).`
1652
+ );
1653
+ }
1654
+ const result = addDuration(date, p.computed.op, dur);
1655
+ return formatDateValue(result, p.format || "MMMM d, yyyy");
1656
+ }
1657
+
1397
1658
  async function nodePrompter(placeholder) {
1398
1659
  if (!process.stdin.isTTY) return null;
1399
1660
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -1413,12 +1674,30 @@ async function nodePrompter(placeholder) {
1413
1674
  * @param {Placeholder[]} placeholders
1414
1675
  * @returns {Array<{key: string, aliases: string[]}>}
1415
1676
  */
1416
- export function findOrphans(schema, placeholders) {
1677
+ export function findOrphans(schema, placeholders, detectedSchemaKeys = null) {
1417
1678
  if (!schema) return [];
1418
- const present = new Set(placeholders.map((p) => p.key));
1679
+ // v2 #7: for positional entries we check `detectedSchemaKeys` (the
1680
+ // pre-expansion key set) since the placeholders list shows role keys,
1681
+ // not the parent positional key. When detectedSchemaKeys is not given
1682
+ // (older callers / no schema-expansion path), fall back to the
1683
+ // placeholders list — same behavior as before v0.5.0.
1684
+ const presentForPositional = detectedSchemaKeys
1685
+ ? new Set(detectedSchemaKeys)
1686
+ : new Set(placeholders.map((p) => p.key));
1687
+ const presentForRegular = new Set(placeholders.map((p) => p.key));
1688
+ // v2 #2: an entry that another entry's `computed.from` points at is
1689
+ // legitimately not in the template — it's a "feeder" used only for
1690
+ // computation. Exempt those from the orphan check.
1691
+ const computedFromTargets = new Set();
1692
+ for (const entry of Object.values(schema.entries)) {
1693
+ if (entry.computed && entry.computed.from) computedFromTargets.add(entry.computed.from);
1694
+ }
1419
1695
  const orphans = [];
1420
1696
  for (const [key, entry] of Object.entries(schema.entries)) {
1421
- if (!present.has(key)) orphans.push({ key, aliases: entry.aliases.slice() });
1697
+ if (computedFromTargets.has(key)) continue;
1698
+ const present = entry.positions ? presentForPositional : presentForRegular;
1699
+ if (present.has(key)) continue;
1700
+ orphans.push({ key, aliases: entry.aliases.slice() });
1422
1701
  }
1423
1702
  return orphans;
1424
1703
  }
@@ -1437,8 +1716,30 @@ export function findOrphans(schema, placeholders) {
1437
1716
  * @returns {string} the substituted body.
1438
1717
  */
1439
1718
  export function substitute(body, placeholders, values, tier) {
1719
+ // v2 #7: positional placeholders (`position_index !== undefined`) substitute
1720
+ // at a specific byte index, not by global replace. Collect them first,
1721
+ // apply in reverse-index order so earlier hits' indices stay stable. Then
1722
+ // the remaining (non-positional) placeholders use the original
1723
+ // replaceAll/regex logic, which is safe because positional hits all share
1724
+ // the same alias text — and after the index-based substitution, only the
1725
+ // exact bytes at each position have been replaced.
1440
1726
  let out = body;
1727
+ const positionalSubs = [];
1441
1728
  for (const p of placeholders) {
1729
+ if (p.position_index === undefined) continue;
1730
+ const v = values[p.key];
1731
+ if (v === undefined) continue;
1732
+ for (const h of p.hits) {
1733
+ if (typeof h.index !== "number") continue;
1734
+ positionalSubs.push({ index: h.index, length: h.match.length, value: v });
1735
+ }
1736
+ }
1737
+ positionalSubs.sort((a, b) => b.index - a.index);
1738
+ for (const s of positionalSubs) {
1739
+ out = out.slice(0, s.index) + s.value + out.slice(s.index + s.length);
1740
+ }
1741
+ for (const p of placeholders) {
1742
+ if (p.position_index !== undefined) continue; // already handled above
1442
1743
  const v = values[p.key];
1443
1744
  if (v === undefined) continue;
1444
1745
  for (const h of p.hits) {
@@ -1617,7 +1918,14 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1617
1918
  err.write(paint("error: no placeholders detected by any tier\n", "red", err));
1618
1919
  return EXIT.VALIDATION;
1619
1920
  }
1620
- const orphans = findOrphans(schema, result.placeholders);
1921
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
1922
+ if (result.positional_errors && result.positional_errors.length > 0) {
1923
+ for (const pe of result.positional_errors) {
1924
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
1925
+ }
1926
+ return EXIT.VALIDATION;
1927
+ }
1928
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1621
1929
  if (orphans.length > 0) {
1622
1930
  for (const o of orphans) {
1623
1931
  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));
@@ -1647,6 +1955,20 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1647
1955
  }
1648
1956
  return EXIT.VALIDATION;
1649
1957
  }
1958
+ // v2 #2: computed-placeholder validation (same gate as cmdDraft).
1959
+ const computeCheck = computeValues(result.placeholders, resolved);
1960
+ if (!computeCheck.ok) {
1961
+ for (const ce of computeCheck.errors) {
1962
+ err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
1963
+ }
1964
+ if (opts.json) {
1965
+ out.write(JSON.stringify({
1966
+ ok: false,
1967
+ computed_errors: computeCheck.errors.map(({ key, message }) => ({ key, message })),
1968
+ }, null, 2) + "\n");
1969
+ }
1970
+ return EXIT.VALIDATION;
1971
+ }
1650
1972
  if (opts.json) {
1651
1973
  out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1652
1974
  } else {
@@ -1689,8 +2011,15 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1689
2011
  }
1690
2012
  }
1691
2013
 
2014
+ // v2 #7: positional addressing errors (count mismatch, unsupported tier).
2015
+ if (result.positional_errors && result.positional_errors.length > 0) {
2016
+ for (const pe of result.positional_errors) {
2017
+ err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
2018
+ }
2019
+ return EXIT.VALIDATION;
2020
+ }
1692
2021
  // Orphan check.
1693
- const orphans = findOrphans(schema, result.placeholders);
2022
+ const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
1694
2023
  if (orphans.length > 0) {
1695
2024
  for (const o of orphans) {
1696
2025
  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));
@@ -1727,6 +2056,19 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1727
2056
  return EXIT.VALIDATION;
1728
2057
  }
1729
2058
 
2059
+ // v2 #2: computed placeholders. Fill any computed entries whose value
2060
+ // wasn't already supplied via CLI / --params / --interactive / default.
2061
+ // Runs after typed normalization so the source values are in canonical
2062
+ // form (e.g. a "date" type is already in the format string before we
2063
+ // parse it back for arithmetic).
2064
+ const computeCheck = computeValues(result.placeholders, resolved);
2065
+ if (!computeCheck.ok) {
2066
+ for (const ce of computeCheck.errors) {
2067
+ err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
2068
+ }
2069
+ return EXIT.VALIDATION;
2070
+ }
2071
+
1730
2072
  // Diff mode: print a substitution table and exit without writing output.
1731
2073
  if (opts.diff) {
1732
2074
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.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": {