@ansonlai/docx-redline-js 0.6.2 → 0.7.1

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/node/cli-help.js CHANGED
@@ -12,7 +12,7 @@ const COMPACT = option('compact', '--compact', 'Emit one-line JSON to reduce pro
12
12
  const INSPECTION_OPTIONS = Object.freeze([
13
13
  HELP,
14
14
  option('search', '--search <text>', 'Case-insensitive substring search.'),
15
- option('revised', '--revised', 'Select paragraphs containing tracked revisions.'),
15
+ option('revised', '--revised', 'Select paragraphs containing tracked revisions; extract identifies authors and fully hidden deleted/inserted paragraphs.'),
16
16
  option('table', '--table', 'Select paragraphs inside tables.'),
17
17
  option('body', '--body', 'Select paragraphs outside tables.'),
18
18
  option('nonEmpty', '--non-empty', 'Exclude empty paragraphs; this is not a narrow document scope.'),
@@ -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: {
@@ -48,15 +56,14 @@ const APPLY_EXAMPLES = Object.freeze([
48
56
  }
49
57
  },
50
58
  {
51
- description: 'Counterpropose a wholly foreign-deleted paragraph found through --view rejected.',
59
+ description: 'Restore one wholly foreign-deleted paragraph verbatim from a strong rejected-view target.',
52
60
  operation: {
53
61
  type: 'restore',
54
62
  target: {
55
63
  exactText: 'Deleted source paragraph.',
56
64
  paragraphId: '3A2B3C4D',
57
65
  revisionView: 'rejected'
58
- },
59
- modified: 'Restored and revised paragraph.'
66
+ }
60
67
  }
61
68
  }
62
69
  ]);
@@ -116,7 +123,7 @@ export const CLI_COMMAND_HELP = Object.freeze({
116
123
  },
117
124
  apply: {
118
125
  summary: 'Apply canonical document operations and write a derived DOCX.',
119
- usage: 'docx-redline apply <file.docx> --operations <file.json|-> [options]',
126
+ usage: 'docx-redline apply <file.docx> (--operations <file.json|->|<inline operation>) [options]',
120
127
  options: [
121
128
  HELP,
122
129
  option('operations', '--operations <file.json|->, --operations-file <file>', 'Read an operation array/envelope from a UTF-8 file or serializer-backed stdin.'),
@@ -124,7 +131,15 @@ export const CLI_COMMAND_HELP = Object.freeze({
124
131
  option('noClobber', '--no-clobber', 'Alias of --no-overwrite.'),
125
132
  option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
126
133
  option('target', '--target <text>', 'Inline one-operation target text.'),
134
+ option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
135
+ option('restore', '--restore', 'Restore one rejected-view paragraph identified by --target-id; omit --modified for verbatim restoration.'),
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; prefer a longer, fairly unique phrase because all matches contribute scope.'),
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,12 @@ 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
+ '--restore requires --target-id from rejected-view extraction; omit modified for verbatim restore or use one inline find/replace.',
158
+ '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.',
159
+ 'Generic or repeated search anchors widen the unioned scope and may cause AMBIGUOUS_TARGET; on success, anchorMatchCount greater than 1 signals that the returned location and excerpts should be confirmed.',
160
+ '--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
161
+ 'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
141
162
  'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
142
163
  'The source is never overwritten unless --in-place is explicit.',
143
164
  'The agent profile keeps progressive execution unless --atomic is explicit and does not change existing-revision policy.',
@@ -215,4 +236,3 @@ export function buildCliHelp(command = null) {
215
236
  documentation: [...CLI_DOCUMENTATION_URLS]
216
237
  };
217
238
  }
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,26 @@ 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',
34
+ 'restore-shortcuts-v1',
31
35
  'deduplicated-cli-receipts',
32
36
  'compact-cli-json-v1'
33
37
  ];
34
38
  const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
35
39
 
36
- function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: normalizeErrorWithRecovery({ code, message, ...(details ? { details } : {}) }), exitCode }; }
40
+ function cliError(code, message, exitCode = 2, details) {
41
+ return {
42
+ status: 'error',
43
+ error: normalizeErrorWithRecovery({
44
+ ...(details && typeof details === 'object' ? details : {}),
45
+ code,
46
+ message
47
+ }),
48
+ exitCode
49
+ };
50
+ }
37
51
  const optionAliases = new Map([
38
52
  ['operationsFile', 'operations'],
39
53
  ['o', 'output'],
@@ -59,7 +73,7 @@ function parseArgs(argv) {
59
73
  const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
60
74
  const key = optionAliases.get(normalizedKey) || normalizedKey;
61
75
  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];
76
+ else if (argv[index + 1] !== undefined && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
63
77
  else flags[key] = true;
64
78
  }
65
79
  return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
@@ -150,31 +164,259 @@ async function readUtf8Stream(stream) {
150
164
  return Buffer.concat(chunks).toString('utf8');
151
165
  }
152
166
 
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' });
167
+ function parseContextRange(value) {
168
+ const match = String(value).match(/^\s*(-?\d+)\s*:\s*(-?\d+)\s*$/);
169
+ if (!match) throw invalidFilter('--context-range must use START:END with signed integer offsets from -20 through 20.');
170
+ const start = Number(match[1]);
171
+ const end = Number(match[2]);
172
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < -20 || end > 20 || start > end) {
173
+ throw invalidFilter('--context-range requires START <= END and both offsets from -20 through 20.');
174
+ }
175
+ return { start, end, text: `${start}:${end}` };
176
+ }
177
+
178
+ function speculativeCandidate(paragraph) {
179
+ return {
180
+ excerpt: boundedText(paragraph.exactText, 240),
181
+ index: paragraph.index,
182
+ ref: paragraph.ref,
183
+ paragraphId: paragraph.paragraphId,
184
+ fingerprint: paragraph.fingerprint,
185
+ revisionView: 'accepted',
186
+ inTable: paragraph.inTable,
187
+ humanReference: paragraph.humanReference,
188
+ provision: paragraph.provision,
189
+ nearestHeading: paragraph.nearestHeading
190
+ };
191
+ }
192
+
193
+ function speculativeError(code, message, details = {}) {
194
+ return Object.assign(new Error(message), { code, ...details });
195
+ }
196
+
197
+ function resolveSpeculativePatchTarget(document, flags) {
198
+ if (!document) throw speculativeError('INVALID_OPERATION', 'Speculative localized replacement requires an open document.');
199
+ if (flags.contextRange !== undefined && flags.around !== undefined) {
200
+ throw invalidFilter('Use --context-range or --around, not both.');
201
+ }
202
+ const search = flags.search === undefined ? null : String(flags.search).trim();
203
+ if (flags.search !== undefined && (!search || flags.search === true)) {
204
+ throw invalidFilter('--search must be non-empty text.');
205
+ }
206
+ if ((flags.contextRange !== undefined || flags.around !== undefined) && !search) {
207
+ throw invalidFilter('--context-range and --around require --search.');
208
+ }
209
+ let range = { start: 0, end: 0, text: '0:0' };
210
+ if (flags.contextRange !== undefined) range = parseContextRange(flags.contextRange);
211
+ if (flags.around !== undefined) {
212
+ const text = String(flags.around).trim();
213
+ if (!/^\d+$/.test(text) || Number(text) > 20) {
214
+ throw invalidFilter('--around must be an integer from 0 through 20.');
215
+ }
216
+ const around = Number(text);
217
+ range = { start: -around, end: around, text: `${-around}:${around}` };
218
+ }
219
+
220
+ const inspected = document.inspect({ revisionView: 'accepted' });
221
+ const paragraphs = Array.isArray(inspected.paragraphs) ? inspected.paragraphs : [];
222
+ let anchors = [];
223
+ let eligible = paragraphs;
224
+ if (search) {
225
+ const folded = search.toLowerCase();
226
+ anchors = paragraphs.filter(paragraph => paragraph.exactText.toLowerCase().includes(folded));
227
+ if (anchors.length === 0) {
228
+ throw speculativeError('TARGET_NOT_FOUND', `No paragraph matched search query: "${search}".`, {
229
+ context: { search, range: range.text },
230
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
231
+ });
232
+ }
233
+ const eligibleIndexes = new Set();
234
+ for (const anchor of anchors) {
235
+ const anchorPosition = anchor.index - 1;
236
+ for (let offset = range.start; offset <= range.end; offset += 1) {
237
+ const position = anchorPosition + offset;
238
+ if (position >= 0 && position < paragraphs.length) eligibleIndexes.add(position);
239
+ }
240
+ }
241
+ eligible = [...eligibleIndexes].sort((a, b) => a - b).map(position => paragraphs[position]);
242
+ }
243
+
244
+ const find = String(flags.find);
245
+ const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
246
+ const context = {
247
+ ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
248
+ };
249
+ if (matches.length === 0) {
250
+ throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
251
+ ? `No paragraph in the contextual range contains the exact patch source: "${find}".`
252
+ : `No paragraph contains the exact patch source: "${find}".`, {
253
+ context,
254
+ ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
255
+ recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
256
+ });
257
+ }
258
+ if (matches.length > 1) {
259
+ throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
260
+ context,
261
+ candidates: matches.map(speculativeCandidate),
262
+ recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
263
+ });
264
+ }
265
+ const selected = matches[0];
266
+ return {
267
+ target: {
268
+ exactText: selected.exactText,
269
+ ...(selected.paragraphId ? { paragraphId: selected.paragraphId } : {}),
270
+ fingerprint: selected.fingerprint,
271
+ revisionView: 'accepted',
272
+ inTable: selected.inTable
273
+ },
274
+ context: {
275
+ ...context,
276
+ humanReference: selected.humanReference
277
+ }
278
+ };
279
+ }
280
+
281
+ async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
282
+ const inlineRestore = flags?.restore !== undefined;
283
+ const hasInlineTarget = flags?.target !== undefined
284
+ || flags?.targetId !== undefined
285
+ || flags?.targetRef !== undefined;
286
+ const hasInlinePatch = flags?.find !== undefined
287
+ || flags?.replace !== undefined
288
+ || flags?.occurrence !== undefined;
289
+ const hasSpeculativeScope = flags?.search !== undefined
290
+ || flags?.contextRange !== undefined
291
+ || flags?.around !== undefined;
292
+ if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
293
+ throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
294
+ code: 'INVALID_OPERATION'
295
+ });
296
+ }
297
+ if (hasInlineTarget && hasSpeculativeScope) {
298
+ throw Object.assign(new Error('Use a strong inline target or --search/--context-range, not both.'), {
299
+ code: 'INVALID_OPERATION'
300
+ });
301
+ }
302
+ if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
303
+ if (inlineRestore && flags.restore !== true) {
304
+ throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
305
+ code: 'INVALID_OPERATION'
306
+ });
307
+ }
308
+ if (hasSpeculativeScope && !hasInlinePatch) {
309
+ throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
310
+ code: 'INVALID_OPERATION'
311
+ });
312
+ }
313
+ const targetRef = flags.targetRef === undefined ? null : positiveInteger(flags.targetRef);
314
+ if (flags.targetRef !== undefined && targetRef == null) {
315
+ throw Object.assign(new Error('--target-ref must be a positive 1-based integer.'), {
316
+ code: 'INVALID_OPERATION'
317
+ });
318
+ }
319
+ const targetId = flags.targetId === undefined ? null : String(flags.targetId).trim();
320
+ if (flags.targetId !== undefined && (!targetId || flags.targetId === true)) {
321
+ throw Object.assign(new Error('--target-id must be a non-empty paragraphId.'), {
322
+ code: 'INVALID_OPERATION'
323
+ });
324
+ }
325
+ const targetText = flags.target === undefined ? null : String(flags.target);
326
+ let target = targetId
327
+ ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
328
+ : targetText;
329
+ if (inlineRestore) {
330
+ if (!targetId) {
331
+ throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
332
+ code: 'INVALID_OPERATION'
333
+ });
334
+ }
335
+ target = { ...target, revisionView: 'rejected' };
336
+ }
337
+ let speculativeContext = null;
338
+ let op;
339
+ if (flags.comment) {
340
+ if (inlineRestore) {
341
+ throw Object.assign(new Error('Use --restore or --comment, not both.'), {
342
+ code: 'INVALID_OPERATION'
343
+ });
344
+ }
345
+ if (hasInlinePatch) {
346
+ throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
347
+ code: 'INVALID_OPERATION'
348
+ });
349
+ }
350
+ op = {
351
+ type: 'comment',
352
+ ...(target == null ? {} : { target }),
353
+ commentContent: String(flags.comment),
354
+ ...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
355
+ ...(targetRef == null ? {} : { targetRef }),
356
+ ...(flags.author ? { author: String(flags.author) } : {})
357
+ };
358
+ } else if (hasInlinePatch) {
359
+ if (flags.modified !== undefined) {
360
+ throw Object.assign(new Error('Use --modified or --find/--replace, not both.'), {
361
+ code: 'INVALID_OPERATION'
362
+ });
363
+ }
364
+ if (
365
+ flags.find === undefined
366
+ || flags.find === true
367
+ || String(flags.find).length === 0
368
+ || flags.replace === undefined
369
+ || flags.replace === true
370
+ ) {
371
+ throw Object.assign(new Error('Inline localized replacement requires non-empty --find and string --replace values.'), {
372
+ code: 'INVALID_OPERATION'
373
+ });
374
+ }
375
+ const occurrence = flags.occurrence === undefined ? null : positiveInteger(flags.occurrence);
376
+ if (flags.occurrence !== undefined && occurrence == null) {
377
+ throw Object.assign(new Error('--occurrence must be a positive 1-based integer.'), {
378
+ code: 'INVALID_OPERATION'
379
+ });
380
+ }
381
+ if (!hasInlineTarget) {
382
+ const resolution = resolveSpeculativePatchTarget(document, flags);
383
+ target = resolution.target;
384
+ speculativeContext = resolution.context;
385
+ }
386
+ op = {
387
+ type: inlineRestore ? 'restore' : 'redline',
388
+ ...(target == null ? {} : { target }),
389
+ ...(targetRef == null ? {} : { targetRef }),
390
+ replacements: [{
391
+ find: String(flags.find),
392
+ replace: String(flags.replace),
393
+ ...(occurrence == null ? {} : { occurrence })
394
+ }],
395
+ ...(speculativeContext ? { _speculativeContext: speculativeContext } : {}),
396
+ ...(flags.author ? { author: String(flags.author) } : {}),
397
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
398
+ };
399
+ } else if (inlineRestore) {
400
+ op = {
401
+ type: 'restore',
402
+ target,
403
+ ...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
404
+ ...(flags.author ? { author: String(flags.author) } : {}),
405
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
406
+ };
407
+ } else {
408
+ op = {
409
+ type: 'replace',
410
+ ...(target == null ? {} : { target }),
411
+ modified: flags.modified !== undefined ? String(flags.modified) : '',
412
+ ...(targetRef == null ? {} : { targetRef }),
413
+ ...(flags.author ? { author: String(flags.author) } : {}),
414
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
415
+ };
416
+ }
417
+ return { operations: [op], expectedRevision: null };
418
+ }
419
+ if (!file) throw Object.assign(new Error('Use --operations <file.json> or an inline operation.'), { code: 'OPERATIONS_REQUIRED' });
178
420
  let parsed;
179
421
  try {
180
422
  const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
@@ -216,8 +458,14 @@ function compactExtractParagraph(paragraph) {
216
458
  const {
217
459
  humanReference, provision, nearestHeading, index, ref, paragraphId,
218
460
  fingerprint, revisionView, exactText, inTable, list, selectionRole,
219
- contextFor
461
+ contextFor, hasRevisions, revisionAuthors, segments
220
462
  } = paragraph;
463
+ const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
464
+ && segments.some(segment => segment.text.length > 0 && (
465
+ revisionView === 'rejected'
466
+ ? segment.acceptedStart !== null
467
+ : segment.rejectedStart !== null
468
+ ));
221
469
  return {
222
470
  humanReference,
223
471
  provision,
@@ -230,6 +478,12 @@ function compactExtractParagraph(paragraph) {
230
478
  exactText,
231
479
  inTable,
232
480
  list,
481
+ ...(hasRevisions ? {
482
+ hasRevisions: true,
483
+ revisionAuthors,
484
+ ...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
485
+ ...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
486
+ } : {}),
233
487
  ...(selectionRole ? { selectionRole } : {}),
234
488
  ...(Array.isArray(contextFor) ? { contextFor } : {})
235
489
  };
@@ -470,6 +724,11 @@ function compactMutationResult(value) {
470
724
  } = serialized;
471
725
  const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
472
726
  const status = compact.status || 'ok';
727
+ const localizedChangesVerified = results.every(result => !result?.change || (
728
+ result.change.committed === true
729
+ && result.change.finalDisposition === 'applied'
730
+ && result.change.verification?.acceptedViewMatchesCompiledText === true
731
+ ));
473
732
  return {
474
733
  ...compact,
475
734
  ...(Array.isArray(compact.results) ? { results } : {}),
@@ -486,6 +745,7 @@ function compactMutationResult(value) {
486
745
  && status !== 'error'
487
746
  && status !== 'partial'
488
747
  && results.every(result => result?.status !== 'error')
748
+ && localizedChangesVerified
489
749
  };
490
750
  }
491
751
 
@@ -606,7 +866,7 @@ export async function executeCli(argv, io = process) {
606
866
  return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
607
867
  }
608
868
  const opsData = command === 'preflight' || command === 'apply'
609
- ? await readOperations(flags.operations, flags, io.stdin || process.stdin)
869
+ ? await readOperations(flags.operations, flags, io.stdin || process.stdin, document)
610
870
  : null;
611
871
  const operations = opsData?.operations || null;
612
872
  let expectedRevision = opsData?.expectedRevision || null;
@@ -663,8 +923,13 @@ export async function executeCli(argv, io = process) {
663
923
  generateRedlines,
664
924
  ...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
665
925
  ...(expectedRevision ? { expectedRevision } : {})
666
- });
667
- const mutationResult = await writeMutation(command, input, flags, result);
926
+ });
927
+ const localizedVerificationFailed = (result.results || []).some(item => item?.change && (
928
+ item.change.committed !== true
929
+ || item.change.finalDisposition !== 'applied'
930
+ || item.change.verification?.acceptedViewMatchesCompiledText !== true
931
+ ));
932
+ const mutationResult = await writeMutation(command, input, flags, result);
668
933
  return compactMutationResult({
669
934
  command,
670
935
  input,
@@ -672,7 +937,7 @@ export async function executeCli(argv, io = process) {
672
937
  ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
673
938
  ...(result.status === 'error'
674
939
  ? { exitCode: 2 }
675
- : (result.status === 'partial' && requireComplete
940
+ : ((result.status === 'partial' || localizedVerificationFailed) && requireComplete
676
941
  ? { exitCode: 3 }
677
942
  : {}))
678
943
  });
@@ -681,8 +946,8 @@ export async function executeCli(argv, io = process) {
681
946
  if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
682
947
  const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
683
948
  return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
684
- } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
685
- }
949
+ } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message, 2, error); }
950
+ }
686
951
 
687
952
  export async function runCli(argv = process.argv.slice(2), io = process) {
688
953
  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.1",
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;
@@ -324,9 +324,8 @@ export function inspectDocumentParts(parts, options = {}) {
324
324
  if (options.range && (index < rangeStart || index > rangeEnd)) continue;
325
325
  if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
326
326
  const pNode = paragraphNodes[i];
327
- if (options.table && !hasAncestor(pNode, 'tc')) continue;
328
- if (options.body && hasAncestor(pNode, 'tc')) continue;
329
- if (options.revised && revisionAuthors(pNode).length === 0) continue;
327
+ if (options.inTable != null && hasAncestor(pNode, 'tc') !== !!options.inTable) continue;
328
+ if (options.revisedOnly && revisionAuthors(pNode).length === 0) continue;
330
329
  const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
331
330
  if (options.skipEmpty && altText.length === 0) continue;
332
331
  if (altText.toLowerCase().includes(needle)) {