@ansonlai/docx-redline-js 0.6.1 → 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.
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.',
@@ -182,6 +201,14 @@ export function commandOptionKeys(command) {
182
201
  return (CLI_COMMAND_HELP[command]?.options || []).map(item => item.key);
183
202
  }
184
203
 
204
+ const DOCS_BASE_URL = 'https://github.com/AnsonLai/docx-redline-js/blob/main/';
205
+ export const CLI_DOCUMENTATION_URLS = Object.freeze([
206
+ `${DOCS_BASE_URL}AGENTS.md`,
207
+ `${DOCS_BASE_URL}docs/AGENT_FAST_START.md`,
208
+ `${DOCS_BASE_URL}docs/SKILL_AUTHORING.md`,
209
+ `${DOCS_BASE_URL}docs/schemas/document-operations.schema.json`
210
+ ]);
211
+
185
212
  export function buildCliHelp(command = null) {
186
213
  if (!command) {
187
214
  return {
@@ -190,7 +217,7 @@ export function buildCliHelp(command = null) {
190
217
  usage: 'docx-redline <command> [file.docx] [options]',
191
218
  commands: CLI_COMMANDS.map(name => ({ name, summary: CLI_COMMAND_HELP[name].summary })),
192
219
  notes: ['Run docx-redline <command> --help for flags, semantics, and bounded examples.'],
193
- documentation: ['AGENTS.md', 'docs/AGENT_FAST_START.md', 'docs/SKILL_AUTHORING.md', 'docs/schemas/document-operations.schema.json']
220
+ documentation: [...CLI_DOCUMENTATION_URLS]
194
221
  };
195
222
  }
196
223
  const entry = CLI_COMMAND_HELP[command];
@@ -204,6 +231,6 @@ export function buildCliHelp(command = null) {
204
231
  options: entry.options.map(({ key: _key, ...publicOption }) => publicOption),
205
232
  notes: entry.notes,
206
233
  examples: entry.examples,
207
- documentation: ['AGENTS.md', 'docs/AGENT_FAST_START.md', 'docs/SKILL_AUTHORING.md', 'docs/schemas/document-operations.schema.json']
234
+ documentation: [...CLI_DOCUMENTATION_URLS]
208
235
  };
209
236
  }
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.1",
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;
@@ -309,6 +309,36 @@ export function inspectDocumentParts(parts, options = {}) {
309
309
  comments.set(id, definition);
310
310
  }
311
311
  const selected = selectInspectionParagraphs(paragraphs, options);
312
+ if (options.search && selected.selection && selected.selection.totalMatches === 0) {
313
+ const needle = String(options.search).toLowerCase();
314
+ const alternateView = revisionView === 'accepted' ? 'rejected' : 'accepted';
315
+ let rangeStart = 1;
316
+ let rangeEnd = paragraphNodes.length;
317
+ if (options.range) {
318
+ rangeStart = Number(options.range.start ?? options.range[0]);
319
+ rangeEnd = Number(options.range.end ?? options.range[1]);
320
+ }
321
+ let alternateMatches = 0;
322
+ for (let i = 0; i < paragraphNodes.length; i++) {
323
+ const index = i + 1;
324
+ if (options.range && (index < rangeStart || index > rangeEnd)) continue;
325
+ if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
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;
330
+ const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
331
+ if (options.skipEmpty && altText.length === 0) continue;
332
+ if (altText.toLowerCase().includes(needle)) {
333
+ alternateMatches++;
334
+ }
335
+ }
336
+ if (alternateMatches > 0) {
337
+ selected.selection.hint = revisionView === 'accepted'
338
+ ? `0 matches in accepted view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view rejected. If searching for deleted text to restore, re-run with --view rejected.`
339
+ : `0 matches in rejected view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view accepted. Re-run with --view accepted.`;
340
+ }
341
+ }
312
342
  paragraphs = selected.paragraphs;
313
343
  const allRevisionAuthors = [...new Set(paragraphs.flatMap(item => item.revisionAuthors))].sort();
314
344
  const coveredEntries = extractDocumentPartsEntries(parts);