@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.
- package/AGENTS.md +19 -9
- package/CHANGELOG.md +43 -0
- package/README.md +39 -6
- package/dist/docx-redline-js.esm.js +1 -1
- package/docs/AGENT_FAST_START.md +22 -24
- package/docs/AGENT_KNOWLEDGE_BASE.md +26 -14
- package/docs/SKILL_AUTHORING.md +55 -19
- package/docs/releases/0.7.0.md +199 -0
- package/docs/schemas/document-operations.schema.json +13 -0
- package/docs/validation-reports/2026-09-14-localized-patching-rollout.md +90 -0
- package/index.d.ts +2 -0
- package/node/cli-help.js +20 -2
- package/node/cli.js +259 -34
- package/package.json +5 -1
- package/services/batch-operation-orchestrator.js +25 -16
- package/services/document-operation-applier.js +68 -9
- package/services/document-operation-contract.js +56 -5
- package/services/localized-replacement-compiler.js +304 -0
- package/services/operation-batch-compiler.js +33 -1
- package/services/operation-preflight.js +10 -6
- package/services/standalone-operation-runner.d.ts +55 -3
|
@@ -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
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
|
|
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 =
|
|
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) {
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
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
|
|
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.
|
|
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
|
-
...(
|
|
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;
|