@ansonlai/docx-redline-js 0.5.4 → 0.6.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.
Files changed (51) hide show
  1. package/AGENTS.md +78 -697
  2. package/ARCHITECTURE.md +13 -1
  3. package/CHANGELOG.md +5 -0
  4. package/README.md +140 -30
  5. package/core/paragraph-targeting.js +14 -2
  6. package/dist/docx-redline-js.esm.js +113 -33
  7. package/dist/docx-redline-js.esm.js.map +3 -3
  8. package/dist/docx-redline-js.esm.min.js +75 -75
  9. package/dist/docx-redline-js.esm.min.js.map +4 -4
  10. package/docs/AGENT_FAST_START.md +59 -0
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
  12. package/docs/TESTING.md +20 -1
  13. package/docs/schemas/document-operations.schema.json +5 -1
  14. package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
  15. package/engine/oxml-engine.js +80 -13
  16. package/engine/run-builders.js +5 -15
  17. package/index.d.ts +17 -1
  18. package/node/cli.js +111 -27
  19. package/node/docx-document.js +120 -69
  20. package/node/index.d.ts +6 -2
  21. package/package.json +10 -3
  22. package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
  23. package/services/batch-operation-orchestrator.js +215 -120
  24. package/services/document-inspection.js +5 -3
  25. package/services/document-operation-applier.js +52 -34
  26. package/services/document-operation-contract.js +10 -6
  27. package/services/document-operation-mutations.js +51 -5
  28. package/services/document-operation-session.js +4 -0
  29. package/services/error-recovery.js +174 -0
  30. package/services/operation-batch-compiler.js +394 -0
  31. package/services/operation-preflight.js +91 -72
  32. package/services/standalone-operation-runner.d.ts +17 -1
  33. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
  34. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -1399
  35. package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
  36. package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
  37. package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
  38. package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
  39. package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
  40. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
  41. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
  42. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
  43. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
  44. package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
  45. package/docs/test-comparison-dashboard.html +0 -4338
  46. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
  47. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
  48. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
  49. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
  50. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
  51. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
package/docs/TESTING.md CHANGED
@@ -22,7 +22,10 @@ fixtures rarely contain.
22
22
  | Multimodal LLM visual spot check | On-demand / sampled | Evaluates rendered real-document pages with vision models for layout, table alignment, and typography regressions | Full-corpus automated coverage (intentionally decoupled and sampled due to cost/time) |
23
23
  | XSD and LibreOffice | See [Release validation and independent oracles](#release-validation-and-independent-oracles) | Schema conformance and acceptance by a second consumer | Word-specific revision semantics |
24
24
  | Agent inspection and package facade | `node tests/document_inspection_tests.mjs`, `node tests/docx_package_facade_tests.mjs` | Canonical text, comment/list resolution, package-scoped IDs, untouched-part preservation, and atomic rollback | Desktop Word rendering |
25
- | Agent CLI | `node tests/agent_cli_tests.mjs` | JSON contracts, exact-text extraction, author requirements, safe output behavior, all command families, and operation-schema readability | Cross-platform CI beyond the current runner |
25
+ | Agent CLI | `node tests/agent_cli_tests.mjs`, `node tests/agent_cli_protocol_tests.mjs` | JSON contracts, exact-text extraction, stdin operations, named agent-profile defaults, UTF-8 fidelity, complete-success exits, source safety, all command families, and operation-schema readability | Cross-platform CI beyond the current runner |
26
+ | Example agent session | `node tests/agent_session_example_tests.mjs`, `node tests/localized_edit_example_tests.mjs` | A non-package sample wrapper delegates to the Node facade, binds and refreshes opaque handles across package revisions, returns context windows, compiles exact localized edits, preserves accepted/rejected lifecycle and atomic rollback, and supports review resolution | LLM/provider latency or a production tool-server transport |
27
+ | Batch compilation and recovery | `node tests/batch_source_binding_tests.mjs`, `node tests/error_recovery_contract_tests.mjs`, `node tests/capture_dependency_graph_tests.mjs` | Batch-start source identity survives structural index drift and savepoint restoration; true overlap and capture fan-out fail before mutation; recovery envelopes, retry bases, bounded CLI diagnostics, and complete-success exits remain stable | Whether a model follows the returned recovery action correctly |
28
+ | Agent documentation contract | `node tests/agent_documentation_contract_tests.mjs` | The ordinary fast start stays within its 40–60 line/600-word budget, retains required safety contracts, and the launch card routes to detailed sources while the development wrapper stays outside package files | Whether a particular model reads or follows the instructions |
26
29
  | Agent edge cases | `node tests/canonical_paragraph_text_tests.mjs`, `node tests/document_inspection_edge_tests.mjs`, `node tests/docx_package_transaction_edge_tests.mjs`, `node tests/node_zip_archive_tests.mjs`, `node tests/agent_cli_edge_tests.mjs` | Revision-view semantics, cross-paragraph anchors, nested numbering, transaction reuse, multi-author cleanup, malformed ZIP handling, and destructive CLI safeguards | Desktop Word rendering and non-Windows CI |
27
30
  | Performance boundary regression | `node tests/performance_phase2_boundary_tests.mjs` | Stable facade re-exports, leaf imports, session rollback, isolated context commit, and comment-first scheduling | The Phase 1 one-parse/one-serialize performance target |
28
31
  | Live-session accuracy and instrumentation | `node tests/performance_phase1_session_tests.mjs` | One full parse/serialization, sequential semantic equivalence, exact accepted/rejected text, valid revisions, list/table/comment/highlight preservation, savepoint no-ops, and zero-serialization rollback | Desktop Word rendering |
@@ -79,6 +82,22 @@ not correctness gates. Per-operation DOM savepoints are retained because
79
82
  redline accuracy, no-op isolation, and rollback fidelity take precedence over
80
83
  the aspirational speed target.
81
84
 
85
+ Run the observational agent-protocol benchmark with:
86
+
87
+ ```powershell
88
+ npm run benchmark:agent
89
+ ```
90
+
91
+ It writes `tmp/benchmarks/agent-workflow-latest.json` and compares canonical
92
+ stateless Node, legacy file-based CLI, compact stdin/profile CLI, and the
93
+ development-only session example. It records native/in-process CLI time, heap,
94
+ serialized request bytes, protocol calls, instruction words/bytes, exact
95
+ accepted/rejected text, comment preservation, ordering, recovery, and
96
+ cross-author attribution. It does not measure or estimate LLM reasoning, exact
97
+ tokens, tool transport, Claude, or OpenCode wall time; collect those separately
98
+ in the calling harness. The checked WP-07 results are summarized in the
99
+ [agent protocol rollout audit](validation-reports/2026-09-12-agent-protocol-rollout.md).
100
+
82
101
  ## Cross-author revision slicing test suite
83
102
 
84
103
  The cross-author revision slicing subsystem introduces six complementary test lanes:
@@ -48,7 +48,11 @@
48
48
  "index": { "type": ["integer", "string"] }, "paragraphIndex": { "type": ["integer", "string"] },
49
49
  "paragraphId": { "type": "string" }, "occurrence": { "type": "integer", "minimum": 1 },
50
50
  "inTable": { "type": "boolean" }, "fingerprint": { "type": "string" }, "sourceFingerprint": { "type": "string" },
51
- "revisionView": { "type": "string", "enum": ["accepted", "rejected"] },
51
+ "revisionView": {
52
+ "type": "string",
53
+ "enum": ["accepted", "rejected"],
54
+ "description": "Defaults to rejected for restore operations and accepted for other operation types."
55
+ },
52
56
  "captureRef": { "type": "string", "minLength": 1, "maxLength": 256 },
53
57
  "select": { "type": "string", "minLength": 1 }
54
58
  },
@@ -0,0 +1,82 @@
1
+ # Agent Protocol Rollout Audit
2
+
3
+ **Date:** 2026-09-12
4
+ **Scope:** WP-06 shell/documentation work and WP-07 repository-level rollout
5
+ **Benchmark:** `npm run benchmark:agent` on Node v24.11.1 / Windows x64,
6
+ 7 measured iterations after 2 warmups
7
+
8
+ ## Outcome
9
+
10
+ The compact shell path removes the operations-file tool turn, and the
11
+ development session example removes repeated target/policy payload. All measured
12
+ paths produced the requested accepted view, restored the exact source text in
13
+ the rejected view, passed package/revision validation, and preserved comment
14
+ content. These are protocol and native-runtime measurements; no LLM/provider
15
+ latency or token count was estimated.
16
+
17
+ | Measure | Legacy | WP-06/07 result |
18
+ |---|---:|---:|
19
+ | Ordinary agent instructions | 2,023 words | 349 words (82.75% reduction) |
20
+ | Repository launch card | 2,023 words | 527 words |
21
+ | Shell workflow calls | extract + file write + apply (3) | extract + stdin apply (2; 33.33% reduction) |
22
+ | Independent early-split permutations | one previously order-sensitive direction | both directions succeed |
23
+
24
+ ## Measured workflow results
25
+
26
+ Median wall time includes the in-process CLI file/stdin transport for CLI rows.
27
+ It does not include process startup, a model, provider tokenization, or tool
28
+ transport.
29
+
30
+ | Case | Legacy CLI | Compact stdin CLI | Session example | Session request bytes vs legacy |
31
+ |---|---:|---:|---:|---:|
32
+ | Terminal punctuation | 49.63 ms | 46.05 ms | 49.50 ms | 125 vs 255 (50.98% fewer) |
33
+ | Term-duration phrase | 55.50 ms | 51.30 ms | 56.05 ms | 128 vs 630 (79.68% fewer) |
34
+ | Simple mutuality | 62.82 ms | 50.22 ms | 58.45 ms | 281 vs 935 (69.95% fewer) |
35
+ | Full-clause rewrite | 50.55 ms | 50.31 ms | 53.81 ms | 223 vs 407 (45.21% fewer) |
36
+ | Comment + redline | 71.94 ms | 62.11 ms | 63.81 ms | 290 vs 724 (59.94% fewer) |
37
+
38
+ Compared with the canonical stateless Node request envelope, localized session
39
+ requests remained 86.11% smaller for punctuation, 89.96% smaller for a duration
40
+ change, and 82.22% smaller for deterministic mutuality. A complete clause
41
+ rewrite is naturally less compressible and is not presented as a localized-edit
42
+ win.
43
+
44
+ ## Fidelity and recovery audit
45
+
46
+ - The benchmark validates accepted and rejected paragraph text for every text
47
+ edit and verifies the mixed-batch comment definition remains present.
48
+ - Both ten-edit permutations around an early paragraph split complete without
49
+ target errors.
50
+ - An ambiguous target returns two candidates without auto-selection.
51
+ - A stale session handle returns `STALE_TARGET_HANDLE` with recovery action
52
+ `reinspect`.
53
+ - The default cross-author policy refuses the edit with `EXISTING_REVISIONS`.
54
+ `slice-cross-author` succeeds while retaining both original authors (Anson
55
+ Lai and John Doe), and rejecting the benchmark author's work still retains
56
+ those authors.
57
+ - The CLI protocol regression proves UTF-8 stdin transport, package-revision
58
+ enforcement, source immutability, agent-profile defaults, atomic rollback,
59
+ progressive exit code 3, and rejected-view restoration.
60
+
61
+ ## Configuration decision
62
+
63
+ No project or auto-discovered configuration file was added. The CLI already
64
+ defaults author, strict targeting, validation, tracked changes, revision safety,
65
+ and output naming. The explicit `--profile agent` flag captures the remaining
66
+ atomic/complete-success policy and reports `effectiveOptions`. Removing that one
67
+ flag would not justify hidden configuration discovery or precedence reasoning.
68
+
69
+ ## Remaining external measurement
70
+
71
+ Claude/OpenCode wall time, prompt tokens, generated tokens, and provider tool
72
+ latency must be captured by those calling harnesses. The repository benchmark
73
+ records bytes, words, calls, native time, failure behavior, and document
74
+ fidelity so those external observations can be compared without conflating the
75
+ library with model reasoning.
76
+
77
+ ## Automated verification
78
+
79
+ - `npm test`: 107 passed, 0 failed.
80
+ - `npm run check:types`: 123 runtime exports have declarations.
81
+ - `npm run test:isolation`: core dependency and Word-API isolation passed.
82
+ - Focused ESLint for all WP-06/07 source, benchmark, and test files passed.
@@ -28,7 +28,8 @@ import { applySurgicalMode } from './surgical-mode.js';
28
28
  import { applyReconstructionMode } from './reconstruction-mode.js';
29
29
  import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
30
30
  import { getDefaultAuthor } from '../adapters/config.js';
31
- import { containsTrackedChanges, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
31
+ import { containsTrackedChanges, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
32
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
32
33
  import {
33
34
  NS_W,
34
35
  RevisionIdAllocator,
@@ -52,6 +53,62 @@ function getCommentIdsInOoxml(node) {
52
53
  }
53
54
  return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
54
55
  }
56
+
57
+ function directWordChild(node, localName) {
58
+ return Array.from(node?.childNodes || []).find(child => (
59
+ child?.nodeType === 1
60
+ && child.namespaceURI === NS_W
61
+ && child.localName === localName
62
+ )) || null;
63
+ }
64
+
65
+ function insertedParagraphMarkMetadata(paragraph, author) {
66
+ const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
67
+ if (!marker) return null;
68
+ const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
69
+ if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
70
+ return {
71
+ id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
72
+ author: markerAuthor,
73
+ date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
74
+ };
75
+ }
76
+
77
+ function emptyParagraphBaseline(paragraph, serializer) {
78
+ const clone = paragraph.cloneNode(false);
79
+ const pPr = directWordChild(paragraph, 'pPr');
80
+ if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
81
+ return serializer.serializeToString(clone);
82
+ }
83
+
84
+ function restoreInsertedParagraphMark(oxml, metadata) {
85
+ if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
86
+ const parsed = parseOoxmlSafe(oxml, 'text/xml');
87
+ if (!parsed.doc || parsed.error) return oxml;
88
+ const paragraph = parsed.doc.documentElement?.localName === 'p'
89
+ ? parsed.doc.documentElement
90
+ : getDocumentParagraphs(parsed.doc)[0];
91
+ if (!paragraph) return oxml;
92
+
93
+ let pPr = directWordChild(paragraph, 'pPr');
94
+ if (!pPr) {
95
+ pPr = createWordElement(parsed.doc, 'w:pPr');
96
+ paragraph.insertBefore(pPr, paragraph.firstChild);
97
+ }
98
+ let rPr = directWordChild(pPr, 'rPr');
99
+ if (!rPr) {
100
+ rPr = createWordElement(parsed.doc, 'w:rPr');
101
+ pPr.appendChild(rPr);
102
+ }
103
+ if (!directWordChild(rPr, 'ins')) {
104
+ const marker = createWordElement(parsed.doc, 'w:ins');
105
+ if (metadata.id) marker.setAttribute('w:id', metadata.id);
106
+ marker.setAttribute('w:author', metadata.author);
107
+ if (metadata.date) marker.setAttribute('w:date', metadata.date);
108
+ rPr.appendChild(marker);
109
+ }
110
+ return serializeXml(parsed.doc);
111
+ }
55
112
 
56
113
  /**
57
114
  * Applies redline track changes to OOXML by modifying the DOM in-place.
@@ -77,11 +134,12 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
77
134
  const serializer = createSerializer();
78
135
  let parseWarnings = [];
79
136
  const operationWarnings = [];
80
- let normalizedExistingRevisions = false;
137
+ let normalizedExistingRevisions = false;
138
+ let preservedInsertedParagraphMark = null;
81
139
  const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
82
140
  const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
83
- const finalize = result => {
84
- const withStatus = { ...result };
141
+ const finalize = result => {
142
+ const withStatus = { ...result };
85
143
  if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
86
144
  if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
87
145
  withStatus.oxml = workingOoxml;
@@ -105,10 +163,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
105
163
  if (warnings.length > 0) {
106
164
  withStatus.warnings = [...new Set(warnings)];
107
165
  }
108
- if (!withStatus.status) {
109
- withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
110
- }
111
- return withOoxmlSourceType(withStatus);
166
+ if (!withStatus.status) {
167
+ withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
168
+ }
169
+ if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
170
+ withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
171
+ }
172
+ return withOoxmlSourceType(withStatus);
112
173
  };
113
174
  const finalizeUnchanged = () => {
114
175
  if (normalizedExistingRevisions && keepNormalizedNoOp) {
@@ -181,9 +242,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
181
242
  });
182
243
  }
183
244
  log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
184
- const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
185
- if (rejected.status === 'error') return finalize(rejected);
186
- workingOoxml = rejected.oxml;
245
+ const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
246
+ preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
247
+ const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
248
+ if (rejected.status === 'error') return finalize(rejected);
249
+ workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
250
+ ? emptyParagraphBaseline(soleParagraph, serializer)
251
+ : rejected.oxml;
187
252
  normalizedExistingRevisions = true;
188
253
  const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
189
254
  parseWarnings.push(...rejectedParsed.warnings);
@@ -230,8 +295,10 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
230
295
  hasChanges: false,
231
296
  status: 'error',
232
297
  error: {
233
- code: 'EXISTING_REVISIONS',
234
- message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
298
+ code: 'EXISTING_REVISIONS',
299
+ message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
300
+ revisionAuthors: authors,
301
+ currentPolicy: existingRevisionsPolicy
235
302
  }
236
303
  });
237
304
  } else {
@@ -9,6 +9,7 @@ import { extractFormatFromRPr, RPR_SCHEMA_ORDER } from './rpr-helpers.js';
9
9
  import { createRevisionMetadata } from '../core/types.js';
10
10
  import { getFirstElementByTag } from '../core/xml-query.js';
11
11
  import { createWordElement } from '../core/word-xml.js';
12
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
12
13
 
13
14
  /**
14
15
  * Creates an insertion/deletion wrapper.
@@ -319,14 +320,9 @@ export function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceN
319
320
  rPrChange.setAttribute('w:author', metadata.author);
320
321
  rPrChange.setAttribute('w:date', dateStr || metadata.date);
321
322
 
322
- const previousRPr = createWordElement(xmlDoc, 'w:rPr');
323
323
  const source = sourceNode || rPr;
324
-
325
- Array.from(source.childNodes).forEach(child => {
326
- if (child.nodeName !== 'w:rPrChange') {
327
- previousRPr.appendChild(child.cloneNode(true));
328
- }
329
- });
324
+ const previousRPr = clonePropertiesWithoutRevisionHistory(source)
325
+ || createWordElement(xmlDoc, 'w:rPr');
330
326
 
331
327
  rPrChange.appendChild(previousRPr);
332
328
 
@@ -369,14 +365,9 @@ export function snapshotAndAttachPPrChange(xmlDoc, pPr, author, dateStr, sourceN
369
365
  pPrChange.setAttribute('w:author', metadata.author);
370
366
  pPrChange.setAttribute('w:date', dateStr || metadata.date);
371
367
 
372
- const previousPPr = createWordElement(xmlDoc, 'w:pPr');
373
368
  const source = sourceNode || pPr;
374
-
375
- Array.from(source.childNodes).forEach(child => {
376
- if (child.nodeType === 1 && child.nodeName !== 'w:pPrChange' && child.localName !== 'pPrChange') {
377
- previousPPr.appendChild(child.cloneNode(true));
378
- }
379
- });
369
+ const previousPPr = clonePropertiesWithoutRevisionHistory(source)
370
+ || createWordElement(xmlDoc, 'w:pPr');
380
371
 
381
372
  pPrChange.appendChild(previousPPr);
382
373
 
@@ -390,4 +381,3 @@ export function snapshotAndAttachPPrChange(xmlDoc, pPr, author, dateStr, sourceN
390
381
  pPr.appendChild(pPrChange);
391
382
  return pPrChange;
392
383
  }
393
-
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type OoxmlSourceType = 'package' | 'document' | 'fragment';
2
- export type RedlineStatus = 'ok' | 'no-op' | 'error';
2
+ export type RedlineStatus = 'ok' | 'no-op' | 'partial' | 'error';
3
3
  export type ExistingRevisionsPolicy = 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
4
4
  export type RevisionView = 'accepted' | 'rejected';
5
5
 
@@ -68,6 +68,22 @@ export interface RedlineError {
68
68
  actual?: unknown;
69
69
  commentIds?: string[];
70
70
  comments?: Array<{ id: string; author: string; text: string }>;
71
+ recoveryVersion?: number;
72
+ category?: 'request_fixable' | 'target_refresh_required' | 'candidate_selection_required' | 'policy_choice_required' | 'user_authorization_required' | 'source_conflict' | 'library_or_builder_failure' | 'manual_document_resolution' | string;
73
+ context?: Record<string, unknown>;
74
+ recovery?: {
75
+ action: string;
76
+ sameArgumentsSafe: boolean;
77
+ requiresReinspection: boolean;
78
+ requiresUserAuthorization: boolean;
79
+ field?: string;
80
+ recommendedValue?: unknown;
81
+ };
82
+ expectedRevision?: RevisionToken;
83
+ currentRevision?: RevisionToken;
84
+ issueSummary?: { total: number; byCode: Array<{ code: string; count: number }> };
85
+ candidates?: unknown[];
86
+ [key: string]: unknown;
71
87
  }
72
88
 
73
89
  export interface RedlineOptions {
package/node/cli.js CHANGED
@@ -6,29 +6,35 @@ import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
6
  import { validateRedlineOoxml } from '../core/redline-validation.js';
7
7
  import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
+ import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
9
10
 
10
11
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
11
- const CLI_CONTRACT_VERSION = 3;
12
+ const CLI_CONTRACT_VERSION = 5;
12
13
  const CLI_CAPABILITIES = [
13
14
  'atomic-batch-results-on-package-failure',
14
15
  'baseline-aware-validation',
15
16
  'compact-mutation-results',
16
17
  'cross-author-revision-slicing',
17
- 'document-scoped-list-revision-ids'
18
+ 'document-scoped-list-revision-ids',
19
+ 'batch-start-source-binding',
20
+ 'recovery-envelope-v1',
21
+ 'require-complete-exit',
22
+ 'operations-stdin',
23
+ 'agent-profile-v1'
18
24
  ];
19
25
  const commandOptions = {
20
26
  version: new Set(['help']),
21
27
  inspect: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
22
28
  extract: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
23
29
  preflight: new Set(['help', 'operations', 'author', 'strictTargets', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions']),
24
- apply: new Set(['help', 'operations', 'author', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber', 'expectedRevision', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions', 'atomic', 'generateRedlines', 'noRedlines']),
30
+ apply: new Set(['help', 'operations', 'author', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber', 'expectedRevision', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions', 'atomic', 'generateRedlines', 'noRedlines', 'requireComplete', 'profile']),
25
31
  accept: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
26
32
  reject: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
27
33
  'delete-comments': new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
28
34
  validate: new Set(['help', 'baseline'])
29
35
  };
30
36
 
31
- function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: { code, message, ...(details ? { details } : {}) }, exitCode }; }
37
+ function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: normalizeErrorWithRecovery({ code, message, ...(details ? { details } : {}) }), exitCode }; }
32
38
  const optionAliases = new Map([
33
39
  ['operationsFile', 'operations'],
34
40
  ['o', 'output'],
@@ -39,7 +45,8 @@ const optionAliases = new Map([
39
45
  ['no-overwrite', 'noOverwrite'],
40
46
  ['no-clobber', 'noClobber'],
41
47
  ['no-redlines', 'noRedlines'],
42
- ['generate-redlines', 'generateRedlines']
48
+ ['generate-redlines', 'generateRedlines'],
49
+ ['require-complete', 'requireComplete']
43
50
  ]);
44
51
  function parseArgs(argv) {
45
52
  const positionals = []; const flags = {};
@@ -51,7 +58,7 @@ function parseArgs(argv) {
51
58
  const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
52
59
  const key = optionAliases.get(normalizedKey) || normalizedKey;
53
60
  if (inline !== undefined) flags[key] = inline;
54
- else if (argv[index + 1] && (!argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
61
+ else if (argv[index + 1] && (argv[index + 1] === '-' || !argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
55
62
  else flags[key] = true;
56
63
  }
57
64
  return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
@@ -113,7 +120,16 @@ function inspectionOptions(flags) {
113
120
  }
114
121
  return options;
115
122
  }
116
- async function readOperations(file, flags = {}) {
123
+ async function readUtf8Stream(stream) {
124
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
125
+ throw new Error('No readable stdin stream was provided.');
126
+ }
127
+ const chunks = [];
128
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
129
+ return Buffer.concat(chunks).toString('utf8');
130
+ }
131
+
132
+ async function readOperations(file, flags = {}, stdin = process.stdin) {
117
133
  if (!file && flags?.target) {
118
134
  let op;
119
135
  if (flags.comment) {
@@ -138,7 +154,14 @@ async function readOperations(file, flags = {}) {
138
154
  return { operations: [op], expectedRevision: null };
139
155
  }
140
156
  if (!file) throw Object.assign(new Error('Use --operations <file.json> or --target <text>.'), { code: 'OPERATIONS_REQUIRED' });
141
- let parsed; try { parsed = JSON.parse(await readFile(file, 'utf8')); } catch (error) { throw Object.assign(new Error(`Could not read operations JSON: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' }); }
157
+ let parsed;
158
+ try {
159
+ const source = file === '-' ? await readUtf8Stream(stdin) : await readFile(file, 'utf8');
160
+ parsed = JSON.parse(source);
161
+ } catch (error) {
162
+ const location = file === '-' ? ' from stdin' : '';
163
+ throw Object.assign(new Error(`Could not read operations JSON${location}: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' });
164
+ }
142
165
  const operations = Array.isArray(parsed) ? parsed : (parsed?.operations || parsed?.changes);
143
166
  if (!Array.isArray(operations)) throw Object.assign(new Error('Operations JSON must be an array or an object with an operations or changes array.'), { code: 'INVALID_OPERATIONS_FILE' });
144
167
  return { operations, expectedRevision: parsed?.expectedRevision || null };
@@ -177,7 +200,9 @@ function compactError(error) {
177
200
  if (!error || typeof error !== 'object') return error;
178
201
  const fields = [
179
202
  'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
180
- 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds'
203
+ 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds',
204
+ 'recoveryVersion', 'category', 'field', 'captureRef', 'operationIndexes',
205
+ 'consumedByOperation', 'expectedScope', 'actualScope'
181
206
  ];
182
207
  const compact = {};
183
208
  for (const field of fields) {
@@ -191,7 +216,37 @@ function compactError(error) {
191
216
  }));
192
217
  }
193
218
  if (Array.isArray(error.candidates)) {
194
- compact.candidates = error.candidates.map(compactResolvedTarget);
219
+ compact.candidates = error.candidates.map(candidate => {
220
+ if (!candidate || typeof candidate !== 'object') return candidate;
221
+ const excerpt = boundedText(candidate.excerpt ?? candidate.exactText ?? candidate.text ?? '', 240);
222
+ return { ...compactResolvedTarget(candidate), ...(excerpt ? { excerpt } : {}) };
223
+ });
224
+ }
225
+ for (const field of ['recovery', 'issueSummary', 'expectedRevision', 'currentRevision']) {
226
+ if (error[field] !== undefined) compact[field] = error[field];
227
+ }
228
+ if (error.context && typeof error.context === 'object') {
229
+ compact.context = {
230
+ ...error.context,
231
+ ...(error.context.currentTarget ? {
232
+ currentTarget: {
233
+ ...compactResolvedTarget(error.context.currentTarget),
234
+ excerpt: boundedText(
235
+ error.context.currentTarget.excerpt
236
+ ?? error.context.currentTarget.exactText
237
+ ?? error.context.currentTarget.text
238
+ ?? '',
239
+ 240
240
+ )
241
+ }
242
+ } : {})
243
+ };
244
+ }
245
+ if (error.sourceTarget && typeof error.sourceTarget === 'object') {
246
+ compact.sourceTarget = {
247
+ ...compactResolvedTarget(error.sourceTarget),
248
+ excerpt: boundedText(error.sourceTarget.text ?? error.sourceTarget.exactText ?? '', 240)
249
+ };
195
250
  }
196
251
  compact.message = boundedText(error.message || String(error));
197
252
  return compact;
@@ -314,7 +369,7 @@ function subtractValidationIssues(issues, baselineIssues) {
314
369
  });
315
370
  }
316
371
 
317
- export async function executeCli(argv) {
372
+ export async function executeCli(argv, io = process) {
318
373
  const { command, input: rawInput, extraPositionals, flags } = parseArgs(argv);
319
374
  if (command === 'help' || flags.help) return { status: 'ok', command: 'help', usage: 'docx-redline <version|inspect|extract|preflight|apply|accept|reject|delete-comments|validate> [file.docx] [options]' };
320
375
  if (!command) return cliError('COMMAND_REQUIRED', 'A command is required.');
@@ -336,6 +391,10 @@ export async function executeCli(argv) {
336
391
  if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
337
392
  return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
338
393
  }
394
+ const profile = flags.profile == null ? null : String(flags.profile);
395
+ if (profile && profile !== 'agent') {
396
+ return cliError('INVALID_PROFILE', `Unknown execution profile: "${profile}". Supported profiles: agent.`);
397
+ }
339
398
  let inspectOptions = null;
340
399
  if (command === 'inspect' || command === 'extract') {
341
400
  try { inspectOptions = inspectionOptions(flags); }
@@ -374,7 +433,9 @@ export async function executeCli(argv) {
374
433
  const hasErrors = issues.some(issue => issue.severity === 'error');
375
434
  return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
376
435
  }
377
- const opsData = command === 'preflight' || command === 'apply' ? await readOperations(flags.operations, flags) : null;
436
+ const opsData = command === 'preflight' || command === 'apply'
437
+ ? await readOperations(flags.operations, flags, io.stdin || process.stdin)
438
+ : null;
378
439
  const operations = opsData?.operations || null;
379
440
  let expectedRevision = opsData?.expectedRevision || null;
380
441
  if (flags.expectedRevision) {
@@ -400,15 +461,31 @@ export async function executeCli(argv) {
400
461
  }),
401
462
  command,
402
463
  input
403
- };
404
- if (command === 'apply') {
405
- const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
406
- const generateRedlines = flags.generateRedlines !== undefined
407
- ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
408
- : (!flags.noRedlines);
409
- const result = await document.applyOperations(operations, {
410
- author,
411
- atomic: flags.atomic === true || flags.atomic === 'true',
464
+ };
465
+ if (command === 'apply') {
466
+ const agentProfile = profile === 'agent';
467
+ const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
468
+ const generateRedlines = flags.generateRedlines !== undefined
469
+ ? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
470
+ : (!flags.noRedlines);
471
+ const atomic = flags.atomic !== undefined
472
+ ? (flags.atomic === true || flags.atomic === 'true')
473
+ : agentProfile;
474
+ const requireComplete = flags.requireComplete !== undefined
475
+ ? (flags.requireComplete === true || flags.requireComplete === 'true')
476
+ : agentProfile;
477
+ const effectiveOptions = {
478
+ author,
479
+ atomic,
480
+ strictTargets: true,
481
+ validate: true,
482
+ generateRedlines,
483
+ existingRevisions: flags.existingRevisions || 'merge-same-author',
484
+ requireComplete
485
+ };
486
+ const result = await document.applyOperations(operations, {
487
+ author,
488
+ atomic,
412
489
  validate: true,
413
490
  strictTargets: true,
414
491
  generateRedlines,
@@ -420,7 +497,12 @@ export async function executeCli(argv) {
420
497
  command,
421
498
  input,
422
499
  ...serializable(mutationResult),
423
- ...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
500
+ ...(profile ? { executionProfile: profile, effectiveOptions } : {}),
501
+ ...(result.status === 'error'
502
+ ? { exitCode: 2 }
503
+ : (result.status === 'partial' && requireComplete
504
+ ? { exitCode: 3 }
505
+ : {}))
424
506
  });
425
507
  }
426
508
  const filter = flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
@@ -430,8 +512,10 @@ export async function executeCli(argv) {
430
512
  } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
431
513
  }
432
514
 
433
- export async function runCli(argv = process.argv.slice(2), io = process) {
434
- configureLogger({}, { level: 'silent' });
435
- const result = await executeCli(argv); io.stdout.write(`${JSON.stringify(serializable(result), null, 2)}\n`);
436
- return result.status === 'error' ? (result.exitCode || 1) : 0;
437
- }
515
+ export async function runCli(argv = process.argv.slice(2), io = process) {
516
+ configureLogger({}, { level: 'silent' });
517
+ const result = await executeCli(argv, io); io.stdout.write(`${JSON.stringify(serializable(result), null, 2)}\n`);
518
+ return Number.isInteger(result.exitCode) && result.exitCode !== 0
519
+ ? result.exitCode
520
+ : (result.status === 'error' ? 1 : 0);
521
+ }