@ansonlai/docx-redline-js 0.6.2 → 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.
@@ -38,6 +38,16 @@
38
38
  },
39
39
  "additionalProperties": false
40
40
  },
41
+ "localizedReplacement": {
42
+ "type": "object",
43
+ "required": ["find", "replace"],
44
+ "properties": {
45
+ "find": { "type": "string", "minLength": 1 },
46
+ "replace": { "type": "string" },
47
+ "occurrence": { "type": "integer", "minimum": 1 }
48
+ },
49
+ "additionalProperties": false
50
+ },
41
51
  "target": {
42
52
  "oneOf": [
43
53
  { "type": "string", "minLength": 1 },
@@ -99,6 +109,9 @@
99
109
  },
100
110
  "additionalProperties": false
101
111
  },
112
+ {
113
+ "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["replacements"], "properties": { "type": { "enum": ["redline", "replace"] }, "replacements": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/localizedReplacement" } } } } ]
114
+ },
102
115
  {
103
116
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] }, "anchor": { "$ref": "#/$defs/rejectedTextInsertionAnchor" } } } ]
104
117
  },
@@ -0,0 +1,90 @@
1
+ # Localized Patching and Turn-Reduction Rollout Audit
2
+
3
+ **Date:** 2026-09-14
4
+ **Scope:** WP-05 correctness matrix and controlled workflow benchmark for CLI
5
+ contract 8
6
+
7
+ ## Outcome
8
+
9
+ The contract-8 literal fast path removes one of two tool turns and reduces the
10
+ generated mutation request by roughly two-thirds to four-fifths in the measured
11
+ cases. Native DOCX execution remains essentially the same speed: median changes
12
+ ranged from 0.58% slower to 5.41% faster. This is the intended result—the
13
+ feature attacks agent turn and output-generation latency without replacing the
14
+ OOXML reconciliation or validation pipeline.
15
+
16
+ All successful candidates produced the expected accepted-view text, positive
17
+ localized change verification, and a byte-identical source document. Refused
18
+ speculation remained non-writing in the automated suite.
19
+
20
+ ## Controlled comparison
21
+
22
+ Run:
23
+
24
+ ```bash
25
+ npm run benchmark:localized
26
+ ```
27
+
28
+ The benchmark uses `tests/fixtures/sample_doc_test.docx`, 3 warmups, and 11
29
+ measured iterations per workflow. Both paths run on the same current source
30
+ tree to isolate workflow shape from runtime or machine drift:
31
+
32
+ - **0.6.2-style baseline:** focused `extract`, followed by a canonical
33
+ full-paragraph operation.
34
+ - **Contract-8 candidate:** targetless localized `apply`, either globally or
35
+ within a directional heading scope.
36
+
37
+ | Case | Tool turns | Request bytes | Token proxy | Median local ms | p95 local ms |
38
+ |---|---:|---:|---:|---:|---:|
39
+ | Short punctuation, baseline → candidate | 2 → 1 | 244 → 79 | 61 → 20 | 55.34 → 53.37 | 83.89 → 62.11 |
40
+ | Long provision, baseline → candidate | 2 → 1 | 352 → 123 | 88 → 31 | 58.02 → 54.88 | 70.04 → 59.44 |
41
+ | Directional heading, baseline → candidate | 2 → 1 | 626 → 129 | 157 → 33 | 56.46 → 56.79 | 77.35 → 63.92 |
42
+
43
+ Observed reductions:
44
+
45
+ - Tool turns: **50%** in every case.
46
+ - Generated request bytes: **65.06%–79.39%**.
47
+ - Deterministic token proxy: **64.77%–78.98%**.
48
+ - Median local engine/I/O time: **-0.58%–5.41%** (effectively neutral).
49
+ - p95 local engine/I/O time: **15.13%–25.96% lower** in this run; timing is
50
+ observational and not a correctness gate.
51
+
52
+ The directional candidate returned 20 more response bytes than its two-turn
53
+ baseline because its one response includes explicit location and before/after
54
+ verification. That small cost is intentional: it removes the verification turn
55
+ while making the applied change auditable.
56
+
57
+ ## Measurement boundary
58
+
59
+ `generatedTokenProxy` is `ceil(UTF-8 request bytes / 4)`. It is a deterministic
60
+ comparison aid, not a provider tokenizer or billed-token count. Local timing
61
+ uses `performance.now()` and includes DOCX reads, inspection, mutation,
62
+ validation, and output writing.
63
+
64
+ The benchmark does **not** measure model reasoning, token streaming, tool
65
+ transport, network latency, or end-to-end Claude/OpenCode wall time. Those
66
+ components require external harness transcript instrumentation and must not be
67
+ inferred from native timing. The JSON artifact is written to
68
+ `tmp/benchmarks/localized-turn-reduction-latest.json`.
69
+
70
+ ## Correctness coverage
71
+
72
+ The expanded suites cover:
73
+
74
+ - simultaneous localized replacements, ambiguity, deletion, no-op and overlap
75
+ rejection, unsupported v1 shapes, summary ordering, excerpt bounds, and
76
+ atomic rollback disposition;
77
+ - global speculative success; `0:0`, `0:3`, `1:3`, `-3:-1`, and `-3:3`
78
+ directional windows; boundary clamping; repeated-anchor window deduplication;
79
+ missing anchors and patch sources; multi-paragraph and same-paragraph
80
+ ambiguity; occurrence scoping; stale IDs; protected outputs; response bounds;
81
+ and agent-profile exit behavior.
82
+
83
+ ## Verification
84
+
85
+ - `npm test`: 113 suites passed, 0 failed with the expanded assertions.
86
+ - `npm run check:types`: 123 runtime exports have declarations.
87
+ - `npm run test:isolation`: dependency and Word-API isolation passed.
88
+ - Focused ESLint for the localized compiler, CLI, benchmark, and tests passed.
89
+ - `npm pack --dry-run --json`: 154 entries; the compiler and this report are
90
+ included, while the fixture-dependent benchmark script is excluded.
package/index.d.ts CHANGED
@@ -23,6 +23,8 @@ export type {
23
23
  DocumentOperationBatchResult,
24
24
  HighlightDocumentOperation,
25
25
  InsertionAffinity,
26
+ LocalizedReplacement,
27
+ LocalizedReplacementChange,
26
28
  OperationConflict,
27
29
  OperationDependencyPlan,
28
30
  OperationPreflightItemResult,
package/node/cli-help.js CHANGED
@@ -39,6 +39,14 @@ const APPLY_EXAMPLES = Object.freeze([
39
39
  modified: 'Revised clause.'
40
40
  }
41
41
  },
42
+ {
43
+ description: 'Localized tracked replacement in one strong accepted-view paragraph target.',
44
+ operation: {
45
+ type: 'redline',
46
+ target: { paragraphId: '1A2B3C4D' },
47
+ replacements: [{ find: 'thirty (30) days', replace: 'sixty (60) days' }]
48
+ }
49
+ },
42
50
  {
43
51
  description: 'Comment the complete target paragraph.',
44
52
  operation: {
@@ -116,7 +124,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
116
124
  },
117
125
  apply: {
118
126
  summary: 'Apply canonical document operations and write a derived DOCX.',
119
- usage: 'docx-redline apply <file.docx> --operations <file.json|-> [options]',
127
+ usage: 'docx-redline apply <file.docx> (--operations <file.json|->|<inline operation>) [options]',
120
128
  options: [
121
129
  HELP,
122
130
  option('operations', '--operations <file.json|->, --operations-file <file>', 'Read an operation array/envelope from a UTF-8 file or serializer-backed stdin.'),
@@ -124,7 +132,14 @@ export const CLI_COMMAND_HELP = Object.freeze({
124
132
  option('noClobber', '--no-clobber', 'Alias of --no-overwrite.'),
125
133
  option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
126
134
  option('target', '--target <text>', 'Inline one-operation target text.'),
135
+ option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
127
136
  option('modified', '--modified <text>', 'Complete desired accepted-view target content.'),
137
+ option('find', '--find <exact text>', 'Exact case-sensitive source span for one localized replacement.'),
138
+ option('replace', '--replace <text>', 'Replacement text for --find; an empty string deletes the span.'),
139
+ option('occurrence', '--occurrence <N>', 'Positive 1-based --find occurrence within the uniquely resolved target paragraph.'),
140
+ option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; all matching anchors are considered.'),
141
+ option('contextRange', '--context-range <START:END>', 'Signed inclusive paragraph offsets (-20..20) from each --search anchor; 1:3 means strictly the next three paragraphs.'),
142
+ option('around', '--around <N>', 'With --search, shorthand for symmetric --context-range -N:N (0-20).'),
128
143
  option('comment', '--comment <text>', 'Create an inline comment operation.'),
129
144
  option('textToComment', '--text-to-comment <text>', 'Anchor an inline comment to an exact subspan.'),
130
145
  option('targetRef', '--target-ref <N>', 'Disambiguate an inline target with a 1-based machine index.'),
@@ -138,6 +153,10 @@ export const CLI_COMMAND_HELP = Object.freeze({
138
153
  ],
139
154
  notes: [
140
155
  'modified is complete desired accepted-view content, not only inserted words.',
156
+ 'Use either modified or find/replace. A strong accepted-view target is preferred when already known.',
157
+ 'Without a strong target, find/replace may resolve globally or within --search plus a directional context range; it fails unless exactly one paragraph is eligible.',
158
+ '--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
159
+ 'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
141
160
  'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
142
161
  'The source is never overwritten unless --in-place is explicit.',
143
162
  'The agent profile keeps progressive execution unless --atomic is explicit and does not change existing-revision policy.',
@@ -215,4 +234,3 @@ export function buildCliHelp(command = null) {
215
234
  documentation: [...CLI_DOCUMENTATION_URLS]
216
235
  };
217
236
  }
218
-
package/node/cli.js CHANGED
@@ -10,7 +10,7 @@ import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
10
  import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
11
11
 
12
12
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
13
- const CLI_CONTRACT_VERSION = 7;
13
+ const CLI_CONTRACT_VERSION = 8;
14
14
  const DEFAULT_INSPECTION_LIMIT = 20;
15
15
  const INSPECTION_SOFT_BYTE_LIMIT = 48 * 1024;
16
16
  const CLI_CAPABILITIES = [
@@ -28,12 +28,25 @@ const CLI_CAPABILITIES = [
28
28
  'inspection-context-v1',
29
29
  'bounded-inspection-v1',
30
30
  'human-document-references-v1',
31
+ 'localized-replacements-v1',
32
+ 'speculative-search-apply-v1',
33
+ 'localized-change-summary-v1',
31
34
  'deduplicated-cli-receipts',
32
35
  'compact-cli-json-v1'
33
36
  ];
34
37
  const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
35
38
 
36
- function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: normalizeErrorWithRecovery({ code, message, ...(details ? { details } : {}) }), exitCode }; }
39
+ function cliError(code, message, exitCode = 2, details) {
40
+ return {
41
+ status: 'error',
42
+ error: normalizeErrorWithRecovery({
43
+ ...(details && typeof details === 'object' ? details : {}),
44
+ code,
45
+ message
46
+ }),
47
+ exitCode
48
+ };
49
+ }
37
50
  const optionAliases = new Map([
38
51
  ['operationsFile', 'operations'],
39
52
  ['o', 'output'],
@@ -59,7 +72,7 @@ function parseArgs(argv) {
59
72
  const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
60
73
  const key = optionAliases.get(normalizedKey) || normalizedKey;
61
74
  if (inline !== undefined) flags[key] = inline;
62
- else if (argv[index + 1] && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
75
+ else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
63
76
  else flags[key] = true;
64
77
  }
65
78
  return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
@@ -150,31 +163,232 @@ async function readUtf8Stream(stream) {
150
163
  return Buffer.concat(chunks).toString('utf8');
151
164
  }
152
165
 
153
- async function readOperations(file, flags = {}, stdin = process.stdin) {
154
- if (!file && flags?.target) {
155
- let op;
156
- if (flags.comment) {
157
- op = {
158
- type: 'comment',
159
- target: String(flags.target),
160
- commentContent: String(flags.comment),
161
- ...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
162
- ...(flags.targetRef ? { targetRef: positiveInteger(flags.targetRef) } : {}),
163
- ...(flags.author ? { author: String(flags.author) } : {})
164
- };
165
- } else {
166
- op = {
167
- type: 'replace',
168
- target: String(flags.target),
169
- modified: flags.modified !== undefined ? String(flags.modified) : '',
170
- ...(flags.targetRef ? { targetRef: positiveInteger(flags.targetRef) } : {}),
171
- ...(flags.author ? { author: String(flags.author) } : {}),
172
- ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
173
- };
174
- }
175
- return { operations: [op], expectedRevision: null };
176
- }
177
- if (!file) throw Object.assign(new Error('Use --operations <file.json> or --target <text>.'), { code: 'OPERATIONS_REQUIRED' });
166
+ function parseContextRange(value) {
167
+ const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
168
+ if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
169
+ const start = Number(match[1]);
170
+ const end = Number(match[2]);
171
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
172
+ throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
173
+ }
174
+ return { start, end, text: `${start}:${end}` };
175
+ }
176
+
177
+ function speculativeCandidate(paragraph) {
178
+ return {
179
+ excerpt: boundedText(paragraph.exactText, 240),
180
+ index: paragraph.index,
181
+ ref: paragraph.ref,
182
+ paragraphId: paragraph.paragraphId,
183
+ fingerprint: paragraph.fingerprint,
184
+ revisionView: 'accepted',
185
+ inTable: paragraph.inTable,
186
+ humanReference: paragraph.humanReference,
187
+ provision: paragraph.provision,
188
+ nearestHeading: paragraph.nearestHeading
189
+ };
190
+ }
191
+
192
+ function speculativeError(code, message, details = {}) {
193
+ return Object.assign(new Error(message), { code, ...details });
194
+ }
195
+
196
+ function resolveSpeculativePatchTarget(document, flags) {
197
+ if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
198
+ if (flags.contextRange !== undefined && flags.around !== undefined) {
199
+ throw invalidFilter('Use --context-range or --around, not both.');
200
+ }
201
+ const search = flags.search === undefined ? null : String(flags.search).trim();
202
+ if (flags.search !== undefined && (!search || flags.search === true)) {
203
+ throw invalidFilter('--search must be non-empty text.');
204
+ }
205
+ if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
206
+ throw invalidFilter('--context-range and --around require --search.');
207
+ }
208
+ let range = { start: 0, end: 0, text: '0:0' };
209
+ if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
210
+ if (flags.around !== undefined) {
211
+ const text = String(flags.around).trim();
212
+ if (!/^\d+$/.test(text) || Number(text) > 20) {
213
+ throw invalidFilter('--around must be an integer from 0 through 20.');
214
+ }
215
+ const around = Number(text);
216
+ range = { start: -around, end: around, text: `${-around}:${around}` };
217
+ }
218
+
219
+ const inspected = document.inspect({ revisionView: 'accepted' });
220
+ const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
221
+ let anchors = [];
222
+ let eligible = paragraphs;
223
+ if (search) {
224
+ const folded = search.toLowerCase();
225
+ anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
226
+ if (anchors.length === 0) {
227
+ throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
228
+ context: { search, range: range.text },
229
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
230
+ });
231
+ }
232
+ const eligibleIndexes = new Set();
233
+ for (const anchor of anchors) {
234
+ const anchorPosition = anchor.index - 1;
235
+ for (let offset = range.start; offset <= range.end; offset += 1) {
236
+ const position = anchorPosition + offset;
237
+ if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
238
+ }
239
+ }
240
+ eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
241
+ }
242
+
243
+ const find = String(flags.find);
244
+ const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
245
+ const context = {
246
+ ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
247
+ };
248
+ if (matches.length === 0) {
249
+ throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
250
+ ? `No paragraph in the contextual range contains the exact patch source: "${find}".`
251
+ : `No paragraph contains the exact patch source: "${find}".`, {
252
+ context,
253
+ ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
254
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
255
+ });
256
+ }
257
+ if (matches.length > 1) {
258
+ throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
259
+ context,
260
+ candidates: matches.map(speculativeCandidate),
261
+ recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
262
+ });
263
+ }
264
+ const selected = matches[0];
265
+ return {
266
+ target: {
267
+ exactText: selected.exactText,
268
+ ...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
269
+ fingerprint: selected.fingerprint,
270
+ revisionView: 'accepted',
271
+ inTable: selected.inTable
272
+ },
273
+ context: {
274
+ ...context,
275
+ humanReference: selected.humanReference
276
+ }
277
+ };
278
+ }
279
+
280
+ async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
281
+ const hasInlineTarget = flags?.target !== undefined
282
+ || flags?.targetId !== undefined
283
+ || flags?.targetRef !== undefined;
284
+ const hasInlinePatch = flags?.find !== undefined
285
+ || flags?.replace !== undefined
286
+ || flags?.occurrence !== undefined;
287
+ const hasSpeculativeScope = flags?.search !== undefined
288
+ || flags?.contextRange !== undefined
289
+ || flags?.around !== undefined;
290
+ if (file && (hasInlinePatch || hasSpeculativeScope)) {
291
+ throw Object.assign(new Error('Use --operations or inline speculative find/replace options, not both.'), {
292
+ code: 'INVALID_OPERATION'
293
+ });
294
+ }
295
+ if (hasInlineTarget && hasSpeculativeScope) {
296
+ throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
297
+ code: 'INVALID_OPERATION'
298
+ });
299
+ }
300
+ if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope)) {
301
+ if (hasSpeculativeScope && !hasInlinePatch) {
302
+ throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
303
+ code: 'INVALID_OPERATION'
304
+ });
305
+ }
306
+ const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
307
+ if (flags.targetRef !== undefined && targetRef == null) {
308
+ throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
309
+ code: 'INVALID_OPERATION'
310
+ });
311
+ }
312
+ const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
313
+ if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
314
+ throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
315
+ code: 'INVALID_OPERATION'
316
+ });
317
+ }
318
+ const targetText = flags.target === undefined ? null : String(flags.target);
319
+ let target = targetId
320
+ ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
321
+ : targetText;
322
+ let speculativeContext = null;
323
+ let op;
324
+ if (flags.comment) {
325
+ if (hasInlinePatch) {
326
+ throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
327
+ code: 'INVALID_OPERATION'
328
+ });
329
+ }
330
+ op = {
331
+ type: 'comment',
332
+ ...(target == null ? {} : { target }),
333
+ commentContent: String(flags.comment),
334
+ ...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
335
+ ...(targetRef == null ? {} : { targetRef }),
336
+ ...(flags.author ? { author: String(flags.author) } : {})
337
+ };
338
+ } else if (hasInlinePatch) {
339
+ if (flags.modified !== undefined) {
340
+ throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
341
+ code: 'INVALID_OPERATION'
342
+ });
343
+ }
344
+ if (
345
+ flags.find === undefined
346
+ || flags.find === true
347
+ || String(flags.find).length === 0
348
+ || flags.replace === undefined
349
+ || flags.replace === true
350
+ ) {
351
+ throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
352
+ code: 'INVALID_OPERATION'
353
+ });
354
+ }
355
+ const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
356
+ if (flags.occurrence !== undefined && occurrence == null) {
357
+ throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
358
+ code: 'INVALID_OPERATION'
359
+ });
360
+ }
361
+ if (!hasInlineTarget) {
362
+ const resolution = resolveSpeculativePatchTarget(document, flags);
363
+ target = resolution.target;
364
+ speculativeContext = resolution.context;
365
+ }
366
+ op = {
367
+ type: 'redline',
368
+ ...(target == null ? {} : { target }),
369
+ ...(targetRef == null ? {} : { targetRef }),
370
+ replacements: [{
371
+ find: String(flags.find),
372
+ replace: String(flags.replace),
373
+ ...(occurrence == null ? {} : { occurrence })
374
+ }],
375
+ ...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
376
+ ...(flags.author ? { author: String(flags.author) } : {}),
377
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
378
+ };
379
+ } else {
380
+ op = {
381
+ type: 'replace',
382
+ ...(target == null ? {} : { target }),
383
+ modified: flags.modified !== undefined ? String(flags.modified) : '',
384
+ ...(targetRef == null ? {} : { targetRef }),
385
+ ...(flags.author ? { author: String(flags.author) } : {}),
386
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
387
+ };
388
+ }
389
+ return { operations: [op], expectedRevision: null };
390
+ }
391
+ if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
178
392
  let parsed;
179
393
  try {
180
394
  const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
@@ -470,6 +684,11 @@ function compactMutationResult(value) {
470
684
  } = serialized;
471
685
  const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
472
686
  const status = compact.status || 'ok';
687
+ const localizedChangesVerified = results.every(result => !result?.change || (
688
+ result.change.committed === true
689
+ && result.change.finalDisposition === 'applied'
690
+ && result.change.verification?.acceptedViewMatchesCompiledText === true
691
+ ));
473
692
  return {
474
693
  ...compact,
475
694
  ...(Array.isArray(compact.results) ? { results } : {}),
@@ -486,6 +705,7 @@ function compactMutationResult(value) {
486
705
  && status !== 'error'
487
706
  && status !== 'partial'
488
707
  && results.every(result => result?.status !== 'error')
708
+ && localizedChangesVerified
489
709
  };
490
710
  }
491
711
 
@@ -606,7 +826,7 @@ export async function executeCli(argv, io = process) {
606
826
  return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
607
827
  }
608
828
  const opsData = command === 'preflight' || command === 'apply'
609
- ? await readOperations(flags.operations, flags, io.stdin || process.stdin)
829
+ ? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
610
830
  : null;
611
831
  const operations = opsData?.operations || null;
612
832
  let expectedRevision = opsData?.expectedRevision || null;
@@ -663,8 +883,13 @@ export async function executeCli(argv, io = process) {
663
883
  generateRedlines,
664
884
  ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
665
885
  ...(expectedRevision ? { expectedRevision } : {})
666
- });
667
- const mutationResult = await writeMutation(command, input, flags, result);
886
+ });
887
+ const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
888
+ item.change.committed !== true
889
+ || item.change.finalDisposition !== 'applied'
890
+ || item.change.verification?.acceptedViewMatchesCompiledText !== true
891
+ ));
892
+ const mutationResult = await writeMutation(command, input, flags, result);
668
893
  return compactMutationResult({
669
894
  command,
670
895
  input,
@@ -672,7 +897,7 @@ export async function executeCli(argv, io = process) {
672
897
  ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
673
898
  ...(result.status === 'error'
674
899
  ? { exitCode: 2 }
675
- : (result.status === 'partial' && requireComplete
900
+ : ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
676
901
  ? { exitCode: 3 }
677
902
  : {}))
678
903
  });
@@ -681,8 +906,8 @@ export async function executeCli(argv, io = process) {
681
906
  if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
682
907
  const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
683
908
  return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
684
- } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
685
- }
909
+ } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
910
+ }
686
911
 
687
912
  export async function runCli(argv = process.argv.slice(2), io = process) {
688
913
  configureLogger({}, { level: 'silent' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,15 +46,18 @@
46
46
  "scripts/",
47
47
  "!scripts/benchmark-agent-workflow.mjs",
48
48
  "!scripts/benchmark-agent-cli-discovery.mjs",
49
+ "!scripts/benchmark-localized-turn-reduction.mjs",
49
50
  "!scripts/lib/agent-performance-cases.mjs",
50
51
  "docs/AGENT_FAST_START.md",
51
52
  "docs/AGENT_KNOWLEDGE_BASE.md",
52
53
  "docs/SKILL_AUTHORING.md",
53
54
  "docs/TESTING.md",
55
+ "docs/releases/0.7.0.md",
54
56
  "docs/schemas/document-operations.schema.json",
55
57
  "docs/validation-reports/2026-09-12-agent-protocol-rollout.md",
56
58
  "docs/validation-reports/2026-09-12-agent-cli-discovery-baseline.md",
57
59
  "docs/validation-reports/2026-09-13-agent-cli-efficiency-rollout.md",
60
+ "docs/validation-reports/2026-09-14-localized-patching-rollout.md",
58
61
  "index.js",
59
62
  "index.d.ts",
60
63
  "dist/",
@@ -93,6 +96,7 @@
93
96
  "benchmark:session": "node scripts/benchmark-operation-session.mjs",
94
97
  "benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
95
98
  "benchmark:agent-cli": "node scripts/benchmark-agent-cli-discovery.mjs",
99
+ "benchmark:localized": "node scripts/benchmark-localized-turn-reduction.mjs",
96
100
  "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
97
101
  "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
98
102
  "profile:routes": "node scripts/profile-route-selection.mjs",
@@ -450,8 +450,9 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
450
450
  authorUsed: result.authorUsed,
451
451
  ...(result.resolvedBy ? { resolvedBy: result.resolvedBy } : {}),
452
452
  ...(result.resolvedTarget ? { resolvedTarget: result.resolvedTarget } : {}),
453
- ...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
454
- ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
453
+ ...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
454
+ ...(result.change ? { change: result.change } : {}),
455
+ ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
455
456
  ...(result.error ? { error: result.error } : {}),
456
457
  ...(result.receipt ? { receipt: result.receipt } : {})
457
458
  });
@@ -531,26 +532,34 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
531
532
  }
532
533
  }
533
534
 
534
- if (rolledBack) {
535
- for (const r of results) {
536
- if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
537
- r.receipt.finalDisposition = 'rolled_back';
538
- r.receipt.committed = false;
539
- }
540
- }
535
+ if (rolledBack) {
536
+ for (const r of results) {
537
+ if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
538
+ r.receipt.finalDisposition = 'rolled_back';
539
+ r.receipt.committed = false;
540
+ }
541
+ if (r.change) {
542
+ r.change.finalDisposition = 'rolled_back';
543
+ r.change.committed = false;
544
+ }
545
+ }
541
546
  for (const receipt of allReceipts) {
542
547
  if (receipt.attemptedDisposition === 'applied') {
543
548
  receipt.finalDisposition = 'rolled_back';
544
549
  receipt.committed = false;
545
550
  }
546
551
  }
547
- } else {
548
- for (const r of results) {
549
- if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
550
- r.receipt.committed = true;
551
- r.receipt.finalDisposition = 'applied';
552
- }
553
- }
552
+ } else {
553
+ for (const r of results) {
554
+ if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
555
+ r.receipt.committed = true;
556
+ r.receipt.finalDisposition = 'applied';
557
+ }
558
+ if (r.change) {
559
+ r.change.finalDisposition = 'applied';
560
+ r.change.committed = true;
561
+ }
562
+ }
554
563
  for (const receipt of allReceipts) {
555
564
  if (receipt.attemptedDisposition === 'applied') {
556
565
  receipt.committed = true;