@superdoc/sdk 2.13.1-next.2 → 2.13.1-next.3

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.
@@ -713,15 +713,6 @@ async function executeMoveRangeStep(context, plan) {
713
713
  details: { startNodeId: plan.startNodeId, endNodeId: resolvedEndNodeId },
714
714
  });
715
715
  }
716
- if (plan.changeMode === 'tracked') {
717
- return engine.workflowStepFailure({
718
- status: 'failed',
719
- phase: 'execute',
720
- code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_TRACKED_UNSUPPORTED',
721
- message: 'move_range is direct-only today. Tracked mode would require a tracked block-range move. Nothing was changed. Use move_text for tracked text-span moves or run move_range without changeMode.',
722
- details: { startNodeId: plan.startNodeId, endNodeId: resolvedEndNodeId },
723
- });
724
- }
725
716
  const destinationIndex = findBlockIndexByNodeId(blocks, plan.destinationNodeId);
726
717
  if (destinationIndex < 0) {
727
718
  return engine.workflowStepFailure({
@@ -771,6 +762,30 @@ async function executeMoveRangeStep(context, plan) {
771
762
  });
772
763
  }
773
764
  const anchorBlock = blocks[anchorIndex] ?? destinationBlock;
765
+ const sourceStart = blocks[startIndex];
766
+ const sourceEnd = blocks[endIndex];
767
+ if (sourceStart == null || sourceEnd == null) {
768
+ return engine.workflowStepFailure({
769
+ status: 'failed',
770
+ phase: 'execute',
771
+ code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_NOT_FOUND',
772
+ message: 'move_range lost the source range while preparing deletion.',
773
+ });
774
+ }
775
+ if (plan.changeMode === 'tracked') {
776
+ try {
777
+ await context.documentHandle.blocks.deleteRange({ start: toListedBlockTarget(sourceStart), end: toListedBlockTarget(sourceEnd), force: true }, {
778
+ ...withChangeModeOption(context.invokeOptions, plan.changeMode),
779
+ dryRun: true,
780
+ expectedRevision: beforeRevision,
781
+ });
782
+ }
783
+ catch (error) {
784
+ if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string')
785
+ throw error;
786
+ return engine.workflowStepFailure({ status: 'failed', phase: 'execute', code: error.code, message: error.message });
787
+ }
788
+ }
774
789
  const insertedBlockNodeIds = [];
775
790
  // First copy lands relative to the anchor with the requested position; every
776
791
  // subsequent copy lands right after the previous one, preserving range order.
@@ -790,16 +805,6 @@ async function executeMoveRangeStep(context, plan) {
790
805
  previousInserted = await createCopiedSectionBlock({ context, block, at: insertionAt, changeMode: plan.changeMode });
791
806
  insertedBlockNodeIds.push(previousInserted.nodeId);
792
807
  }
793
- const sourceStart = blocks[startIndex];
794
- const sourceEnd = blocks[endIndex];
795
- if (sourceStart == null || sourceEnd == null) {
796
- return engine.workflowStepFailure({
797
- status: 'failed',
798
- phase: 'execute',
799
- code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_NOT_FOUND',
800
- message: 'move_range lost the source range while preparing deletion.',
801
- });
802
- }
803
808
  // Delete the ORIGINAL source range by nodeId. The freshly-created copies have
804
809
  // new nodeIds, so the delete cannot touch them (the copy is preserved).
805
810
  const deleteResult = await context.documentHandle.blocks.deleteRange({
@@ -710,15 +710,6 @@ async function executeMoveRangeStep(context, plan) {
710
710
  details: { startNodeId: plan.startNodeId, endNodeId: resolvedEndNodeId },
711
711
  });
712
712
  }
713
- if (plan.changeMode === 'tracked') {
714
- return workflowStepFailure({
715
- status: 'failed',
716
- phase: 'execute',
717
- code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_TRACKED_UNSUPPORTED',
718
- message: 'move_range is direct-only today. Tracked mode would require a tracked block-range move. Nothing was changed. Use move_text for tracked text-span moves or run move_range without changeMode.',
719
- details: { startNodeId: plan.startNodeId, endNodeId: resolvedEndNodeId },
720
- });
721
- }
722
713
  const destinationIndex = findBlockIndexByNodeId(blocks, plan.destinationNodeId);
723
714
  if (destinationIndex < 0) {
724
715
  return workflowStepFailure({
@@ -768,6 +759,30 @@ async function executeMoveRangeStep(context, plan) {
768
759
  });
769
760
  }
770
761
  const anchorBlock = blocks[anchorIndex] ?? destinationBlock;
762
+ const sourceStart = blocks[startIndex];
763
+ const sourceEnd = blocks[endIndex];
764
+ if (sourceStart == null || sourceEnd == null) {
765
+ return workflowStepFailure({
766
+ status: 'failed',
767
+ phase: 'execute',
768
+ code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_NOT_FOUND',
769
+ message: 'move_range lost the source range while preparing deletion.',
770
+ });
771
+ }
772
+ if (plan.changeMode === 'tracked') {
773
+ try {
774
+ await context.documentHandle.blocks.deleteRange({ start: toListedBlockTarget(sourceStart), end: toListedBlockTarget(sourceEnd), force: true }, {
775
+ ...withChangeModeOption(context.invokeOptions, plan.changeMode),
776
+ dryRun: true,
777
+ expectedRevision: beforeRevision,
778
+ });
779
+ }
780
+ catch (error) {
781
+ if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string')
782
+ throw error;
783
+ return workflowStepFailure({ status: 'failed', phase: 'execute', code: error.code, message: error.message });
784
+ }
785
+ }
771
786
  const insertedBlockNodeIds = [];
772
787
  // First copy lands relative to the anchor with the requested position; every
773
788
  // subsequent copy lands right after the previous one, preserving range order.
@@ -787,16 +802,6 @@ async function executeMoveRangeStep(context, plan) {
787
802
  previousInserted = await createCopiedSectionBlock({ context, block, at: insertionAt, changeMode: plan.changeMode });
788
803
  insertedBlockNodeIds.push(previousInserted.nodeId);
789
804
  }
790
- const sourceStart = blocks[startIndex];
791
- const sourceEnd = blocks[endIndex];
792
- if (sourceStart == null || sourceEnd == null) {
793
- return workflowStepFailure({
794
- status: 'failed',
795
- phase: 'execute',
796
- code: 'WORKFLOW_STRUCTURE_INSERT_RANGE_NOT_FOUND',
797
- message: 'move_range lost the source range while preparing deletion.',
798
- });
799
- }
800
805
  // Delete the ORIGINAL source range by nodeId. The freshly-created copies have
801
806
  // new nodeIds, so the delete cannot touch them (the copy is preserved).
802
807
  const deleteResult = await context.documentHandle.blocks.deleteRange({
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ var documentEvidence = require('../runtime/document-evidence.cjs');
4
+ var operationCatalog = require('./operation-catalog.cjs');
5
+
6
+ function bindActionChangeMode(document, changeMode) {
7
+ const wrap = (value, path, owner) => new Proxy(value, {
8
+ get(target, property, receiver) {
9
+ const member = Reflect.get(target, property, receiver);
10
+ if (typeof property !== 'string')
11
+ return member;
12
+ const memberPath = [...path, property];
13
+ if (typeof member === 'function' && operationCatalog.getOperationCatalogEntry(`doc.${memberPath.join('.')}`)?.isMutating) {
14
+ return (input = {}, options) => Reflect.apply(member, target, [
15
+ // CLI inputs can already contain a mode. Keep it consistent with
16
+ // the options used by in-process Document API hosts.
17
+ input.changeMode === undefined ? input : { ...input, changeMode },
18
+ { ...options, changeMode },
19
+ ]);
20
+ }
21
+ if (member && (typeof member === 'object' || typeof member === 'function')) {
22
+ return wrap(member, memberPath, target);
23
+ }
24
+ return member;
25
+ },
26
+ apply(target, receiver, args) {
27
+ return Reflect.apply(target, owner ?? receiver, args);
28
+ },
29
+ });
30
+ const wrapped = wrap(document, []);
31
+ documentEvidence.forwardDocumentEvidence(document, wrapped);
32
+ return wrapped;
33
+ }
34
+
35
+ exports.bindActionChangeMode = bindActionChangeMode;
@@ -0,0 +1,3 @@
1
+ import type { BoundDocApi } from '../generated/client.js';
2
+ import type { AgentChangeMode } from './ir.js';
3
+ export declare function bindActionChangeMode(document: BoundDocApi, changeMode: AgentChangeMode): BoundDocApi;
@@ -0,0 +1,30 @@
1
+ import { forwardDocumentEvidence } from '../runtime/document-evidence.js';
2
+ import { getOperationCatalogEntry } from './operation-catalog.js';
3
+ export function bindActionChangeMode(document, changeMode) {
4
+ const wrap = (value, path, owner) => new Proxy(value, {
5
+ get(target, property, receiver) {
6
+ const member = Reflect.get(target, property, receiver);
7
+ if (typeof property !== 'string')
8
+ return member;
9
+ const memberPath = [...path, property];
10
+ if (typeof member === 'function' && getOperationCatalogEntry(`doc.${memberPath.join('.')}`)?.isMutating) {
11
+ return (input = {}, options) => Reflect.apply(member, target, [
12
+ // CLI inputs can already contain a mode. Keep it consistent with
13
+ // the options used by in-process Document API hosts.
14
+ input.changeMode === undefined ? input : { ...input, changeMode },
15
+ { ...options, changeMode },
16
+ ]);
17
+ }
18
+ if (member && (typeof member === 'object' || typeof member === 'function')) {
19
+ return wrap(member, memberPath, target);
20
+ }
21
+ return member;
22
+ },
23
+ apply(target, receiver, args) {
24
+ return Reflect.apply(target, owner ?? receiver, args);
25
+ },
26
+ });
27
+ const wrapped = wrap(document, []);
28
+ forwardDocumentEvidence(document, wrapped);
29
+ return wrapped;
30
+ }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var executionContext = require('./execution-context.cjs');
4
+ var actionChangeMode = require('./action-change-mode.cjs');
4
5
  var errors = require('../runtime/errors.cjs');
5
6
  var listTransform = require('../action-primitives/tools/list-transform.cjs');
6
7
  var structureInsert = require('../action-primitives/tools/structure-insert.cjs');
@@ -67,7 +68,7 @@ const ACTION_HINTS = {
67
68
  create_table: 'rows, columns, cellTexts? (2D: rows of cells; each cell is a plain string OR a STYLED object {runs:[{text,marks}], marks?} to format the cell — marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex)/highlight), placement?, changeMode? — style cells HERE, do not format_text afterward; changeMode:"tracked" makes the table insertion itself a tracked change',
68
69
  rewrite_block: "selector, text, changeMode? — REPLACES the whole block text using the document API's formatting preservation. For appending a NEW paragraph/clause use insert_paragraphs, and for small wording edits prefer replace_text with a minimal find span",
69
70
  fill_placeholders: 'values[] and/or fields[{label?,value}], changeMode?',
70
- move_range: 'fromText (text in the FIRST block of the range to move), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). Direct-only today: changeMode:"tracked" fails with no mutation because block-range deletion cannot be tracked. Moves plain paragraph/heading text only; a range containing a table, list, or image is refused with nothing changed. THE way to MOVE a contiguous block range or a visual "section" identified BY TEXT (works on styled-paragraph sections like PREAMBLE / SCHEDULE A that are NOT Word heading nodes). afterText on a heading-like block lands the range after that block\'s WHOLE section.',
71
+ move_range: 'fromText (text in the FIRST block of the range to move), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). changeMode:"tracked" records both the copies and source deletion for review. Moves plain paragraph/heading text only; a range containing a table, list, or image is refused with nothing changed. THE way to MOVE a contiguous block range or a visual "section" identified BY TEXT (works on styled-paragraph sections like PREAMBLE / SCHEDULE A that are NOT Word heading nodes). afterText on a heading-like block lands the range after that block\'s WHOLE section.',
71
72
  comment_paragraphs: 'commentText, scope?: all|body, excludeBlockQuotes?',
72
73
  add_comments: 'commentText, selector (one block) OR selectors[] to comment MANY blocks in ONE call (same text) — use selectors[] for "comment every heading/section/clause"; NEVER emit multiple add_comments calls, batch the targets into selectors[]',
73
74
  resolve_comments: 'anchorText? (resolve only comments anchored on text containing this; omit to resolve ALL open comments), reopen? (true = reopen resolved comments instead) — THE way to resolve (or reopen) comments. Use for "resolve the comment(s)" / "mark comments resolved".',
@@ -86,7 +87,7 @@ const ACTION_HINTS = {
86
87
  delete_table_column: 'tableOrdinal?, columnIndex, changeMode?',
87
88
  split_table: 'tableOrdinal?, rowIndex, separatorText?, changeMode?',
88
89
  convert_list: 'kind: ordered|bullet, listOrdinal? 1-based or anchorText? text inside an item of the target list, OR fromMarker+toMarker rendered clause numbers like "2.1."/"2.3." to convert a numbered-clause range, OR fromText+toText exact text inside the first/last of consecutive plain paragraphs to convert them into a list IN PLACE, changeMode? — converts lists, clause ranges, or plain paragraphs to a list without touching text; never recreate-and-delete to make existing paragraphs a list',
89
- split_list: 'anchorText (text inside the item that should START the second list), restartNumbering? (default true — the new list restarts at 1; false keeps continuous numbering) — THE way to SPLIT one list into two at an item ("split the list starting at item 7 into a new list with reset numbering"). Nested sub-items stay with their parent. Direct edit (not tracked).',
90
+ split_list: 'anchorText (text inside the item that should START the second list), restartNumbering? (default true — the new list restarts at 1; false keeps continuous numbering) — THE way to SPLIT one list into two at an item ("split the list starting at item 7 into a new list with reset numbering"). Nested sub-items stay with their parent. changeMode? records the change for review.',
90
91
  undo_changes: 'untilMarker? rendered marker to restore e.g. "2.1.", steps? 1-25 — deterministic revert that steps document history back until the marker reappears',
91
92
  redo_changes: 'steps? 1-25 (default 1) — the inverse of undo_changes: steps history FORWARD to re-apply edits a prior undo removed. THE recovery when an undo overshot. Only works right after an undo, before any new edit.',
92
93
  attach_numbering: 'anchorText|nodeId, likeMarker e.g. "10.", changeMode? — make an existing block a numbered clause at the same scheme/level as the sibling rendering likeMarker; changeMode:"tracked" records the former unnumbered state as a w:pPrChange',
@@ -100,9 +101,9 @@ const ACTION_HINTS = {
100
101
  style_table: 'tableOrdinal? (1-based, default 1), accentColor? (header fill hex, default dark navy) — THE way to make a table look professional: filled accent header row with white bold text, bold first-column labels, banded rows, clean borders. Use after create_table or on any existing table.',
101
102
  move_table: 'tableOrdinal? (1-based, default 1), placement {at:"document_end"|"document_start"|"after"|"before", selector?} — THE way to MOVE a whole table (with all its content) to a new position IN ONE call. Use for "move the table to the end / under section X". NEVER delete-and-recreate a table to relocate it, and never chain inserts/undos to fake a move.',
102
103
  delete_table: 'tableOrdinal? (1-based, default 1), changeMode? — THE way to DELETE an entire table in one call. Use for "remove/delete the table". Do NOT delete rows one by one or hijack another table.',
103
- set_paragraph_spacing: 'selector (block/blocks to space), lineSpacing? (multiplier e.g. 1.5 or 2), spaceBefore? / spaceAfter? (points) — THE way to set line spacing or space before/after paragraphs. Use this for "add spacing" / "1.5 line spacing" / "more room between paragraphs" — NEVER insert blank paragraphs for spacing. Direct edit (not tracked).',
104
- insert_page_break: 'selector (block that should start on a new page) — THE way to "start X on a new page" / add a page break before a block (sets pageBreakBefore). Use instead of inserting empty paragraphs to push content down. Direct edit (not tracked).',
105
- add_hyperlink: 'text (exact text to turn into a link), url, tooltip? — THE way to make existing text a clickable hyperlink to a URL. Direct edit (not tracked).',
104
+ set_paragraph_spacing: 'selector (block/blocks to space), lineSpacing? (multiplier e.g. 1.5 or 2), spaceBefore? / spaceAfter? (points) — THE way to set line spacing or space before/after paragraphs. Use this for "add spacing" / "1.5 line spacing" / "more room between paragraphs" — NEVER insert blank paragraphs for spacing. changeMode? records the change for review.',
105
+ insert_page_break: 'selector (block that should start on a new page) — THE way to "start X on a new page" / add a page break before a block (sets pageBreakBefore). Use instead of inserting empty paragraphs to push content down. changeMode? records the change for review.',
106
+ add_hyperlink: 'text (exact text to turn into a link), url, tooltip? — THE way to make existing text a clickable hyperlink to a URL. changeMode? records the change for review.',
106
107
  };
107
108
  /** Category grouping for the rendered tool description. Must cover ACTION_NAMES exactly (unit-tested). */
108
109
  const ACTION_GROUPS = [
@@ -197,7 +198,7 @@ const ACTION_ARGS = {
197
198
  delete_table_column: ['tableOrdinal', 'columnIndex', 'changeMode'],
198
199
  split_table: ['tableOrdinal', 'rowIndex', 'separatorText', 'changeMode'],
199
200
  convert_list: ['kind', 'fromMarker', 'toMarker', 'fromText', 'toText', 'listOrdinal', 'anchorText', 'changeMode'],
200
- split_list: ['anchorText', 'restartNumbering'],
201
+ split_list: ['anchorText', 'restartNumbering', 'changeMode'],
201
202
  undo_changes: ['untilMarker', 'steps'],
202
203
  redo_changes: ['steps'],
203
204
  attach_numbering: ['likeMarker', 'nodeId', 'anchorText', 'changeMode'],
@@ -216,15 +217,15 @@ const ACTION_ARGS = {
216
217
  'fontSize',
217
218
  'changeMode',
218
219
  ],
219
- apply_style: ['selector', 'styleId', 'headingLevel', 'likeText'],
220
+ apply_style: ['selector', 'styleId', 'headingLevel', 'likeText', 'changeMode'],
220
221
  format_paragraph: ['selector', 'alignment', 'changeMode', 'evidence'],
221
222
  move_text: ['text', 'afterText', 'changeMode'],
222
- style_table: ['tableOrdinal', 'accentColor'],
223
- move_table: ['tableOrdinal', 'placement'],
223
+ style_table: ['tableOrdinal', 'accentColor', 'changeMode'],
224
+ move_table: ['tableOrdinal', 'placement', 'changeMode'],
224
225
  delete_table: ['tableOrdinal', 'changeMode'],
225
- set_paragraph_spacing: ['selector', 'lineSpacing', 'spaceBefore', 'spaceAfter'],
226
- insert_page_break: ['selector'],
227
- add_hyperlink: ['text', 'url', 'tooltip'],
226
+ set_paragraph_spacing: ['selector', 'lineSpacing', 'spaceBefore', 'spaceAfter', 'changeMode'],
227
+ insert_page_break: ['selector', 'changeMode'],
228
+ add_hyperlink: ['text', 'url', 'tooltip', 'changeMode'],
228
229
  };
229
230
  const ACTION_DISPOSITIONS = {
230
231
  insert_paragraphs: 'tracked',
@@ -245,7 +246,7 @@ const ACTION_DISPOSITIONS = {
245
246
  set_font_family: 'tracked',
246
247
  apply_letter_spacing: 'tracked',
247
248
  fill_placeholders: 'tracked',
248
- move_range: 'direct',
249
+ move_range: 'tracked',
249
250
  insert_toc: 'tracked',
250
251
  insert_table_row: 'tracked',
251
252
  insert_table_column: 'tracked',
@@ -253,21 +254,21 @@ const ACTION_DISPOSITIONS = {
253
254
  delete_table_column: 'tracked',
254
255
  split_table: 'tracked',
255
256
  convert_list: 'tracked',
256
- split_list: 'direct',
257
+ split_list: 'tracked',
257
258
  undo_changes: 'direct',
258
259
  redo_changes: 'direct',
259
260
  attach_numbering: 'tracked',
260
261
  add_list_items: 'tracked',
261
262
  format_text: 'tracked',
262
- apply_style: 'direct',
263
+ apply_style: 'tracked',
263
264
  format_paragraph: 'tracked',
264
265
  move_text: 'tracked',
265
- style_table: 'direct',
266
- move_table: 'direct',
266
+ style_table: 'tracked',
267
+ move_table: 'tracked',
267
268
  delete_table: 'tracked',
268
- set_paragraph_spacing: 'direct',
269
- insert_page_break: 'direct',
270
- add_hyperlink: 'direct',
269
+ set_paragraph_spacing: 'tracked',
270
+ insert_page_break: 'tracked',
271
+ add_hyperlink: 'tracked',
271
272
  };
272
273
  /** Fresh copy of {@link ACTION_DISPOSITIONS} for integrators (safe to mutate). */
273
274
  function getActionDispositions() {
@@ -886,14 +887,7 @@ async function executeCommentCreate(doc, snapshot, commentText, blockId) {
886
887
  target,
887
888
  });
888
889
  }
889
- async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode,
890
- /**
891
- * Force the cell fill to run as a direct edit even under `changeMode:'tracked'`.
892
- * Set only by the column-insert caller, whose new cells land in pre-existing
893
- * rows that carry no tracked-insertion marker for the adapter to absorb them
894
- * into. See the tracked-mode note below.
895
- */
896
- forceDirectFill = false) {
890
+ async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode) {
897
891
  const nonEmptyCells = cellTexts.filter((cell) => cell.text.trim().length > 0);
898
892
  if (nonEmptyCells.length === 0)
899
893
  return [];
@@ -933,19 +927,6 @@ forceDirectFill = false) {
933
927
  // block rewrite the v2 collab backend silently drops for freshly-inserted
934
928
  // cells (rows/columns land empty), so it is only a fallback for non-collab or
935
929
  // older hosts without the op.
936
- //
937
- // Tracked mode: the adapter accepts a tracked cell fill only where the target
938
- // cell's row carries a same-author `<w:trPr><w:ins/>` marker, which already
939
- // owns the accept/reject fate of everything inside it. A tracked row insert
940
- // and a tracked new table both produce such rows, so their fills go through
941
- // tracked and the transport now lets them reach the adapter
942
- // (supportsConditionalTrackedMode, SD-4035).
943
- //
944
- // A tracked COLUMN insert is the exception: its new cells are added to
945
- // PRE-EXISTING rows, which carry no marker, so the adapter fails closed with
946
- // CAPABILITY_UNAVAILABLE and the column would land empty. Those fills stay
947
- // direct — the table-level insertion still owns the redline.
948
- const cellFillChangeMode = forceDirectFill && changeMode === 'tracked' ? undefined : changeMode;
949
930
  const setCellFn = maybeMethod(doc, ['tables', 'setCellText']);
950
931
  const legacySteps = [];
951
932
  const resolved = [];
@@ -964,7 +945,7 @@ forceDirectFill = false) {
964
945
  rowIndex: cell.rowIndex,
965
946
  columnIndex: cell.columnIndex,
966
947
  text: cell.text,
967
- ...(cellFillChangeMode ? { changeMode: cellFillChangeMode } : {}),
948
+ ...(changeMode ? { changeMode: changeMode } : {}),
968
949
  });
969
950
  }
970
951
  else {
@@ -977,7 +958,7 @@ forceDirectFill = false) {
977
958
  }
978
959
  }
979
960
  if (legacySteps.length > 0)
980
- await executeMutations(doc, legacySteps, cellFillChangeMode);
961
+ await executeMutations(doc, legacySteps, changeMode);
981
962
  return resolved;
982
963
  }
983
964
  /**
@@ -2242,7 +2223,7 @@ async function runAddListItems(doc, args, opts) {
2242
2223
  * split_list — split one list into two at a target item, restarting the new
2243
2224
  * half's numbering (default). Thin wrapper over doc.lists.split (separate +
2244
2225
  * setValue). The split point is the item that should START the second list,
2245
- * located by anchorText. Direct-only: lists.split rejects tracked mode.
2226
+ * located by anchorText.
2246
2227
  */
2247
2228
  async function runSplitList(doc, args) {
2248
2229
  const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
@@ -4317,9 +4298,7 @@ async function runInsertTableColumn(doc, args) {
4317
4298
  columnCells = [{ rowIndex: 0, columnIndex: newColumnIndex, text: args.headerText }];
4318
4299
  }
4319
4300
  if (columnCells.length > 0) {
4320
- // forceDirectFill: a new column's cells live in pre-existing rows, which
4321
- // carry no tracked-insertion marker, so a tracked fill fails closed.
4322
- const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, columnCells, args.changeMode, true);
4301
+ const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, columnCells, args.changeMode);
4323
4302
  if (applied.length > 0) {
4324
4303
  executed.push({
4325
4304
  operationId: 'doc.mutations.apply',
@@ -5933,13 +5912,14 @@ function indentsEqual(a, b) {
5933
5912
  }
5934
5913
  async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, allowNumberingAttach, isTitle = false, allowLeadPattern = true, createdText = '') {
5935
5914
  try {
5936
- // Reference: placement anchor first, then nearest same-type sibling
5937
- // (previous preferred), skipping empties and other fresh blocks.
5938
- let reference = anchorRow && !anchorRow.isEmpty ? anchorRow : null;
5915
+ // A requested heading level constrains style matching, including explicit
5916
+ // placement anchors: copying another level would change document structure.
5917
+ const compatibleHeading = (row) => created.headingLevel == null || row.headingLevel === created.headingLevel;
5918
+ let reference = anchorRow && !anchorRow.isEmpty && compatibleHeading(anchorRow) ? anchorRow : null;
5939
5919
  if (!reference) {
5940
5920
  for (let i = createdIndex - 1; i >= 0; i--) {
5941
5921
  const b = postBlocks[i];
5942
- if (preIds.has(b.nodeId) && b.nodeType === created.nodeType && !b.isEmpty) {
5922
+ if (preIds.has(b.nodeId) && b.nodeType === created.nodeType && !b.isEmpty && compatibleHeading(b)) {
5943
5923
  reference = b;
5944
5924
  break;
5945
5925
  }
@@ -5948,7 +5928,7 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
5948
5928
  if (!reference) {
5949
5929
  for (let i = createdIndex + 1; i < postBlocks.length; i++) {
5950
5930
  const b = postBlocks[i];
5951
- if (preIds.has(b.nodeId) && b.nodeType === created.nodeType && !b.isEmpty) {
5931
+ if (preIds.has(b.nodeId) && b.nodeType === created.nodeType && !b.isEmpty && compatibleHeading(b)) {
5952
5932
  reference = b;
5953
5933
  break;
5954
5934
  }
@@ -5983,11 +5963,28 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
5983
5963
  if (reference.styleId && reference.styleId !== created.styleId) {
5984
5964
  const setStyleFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyle']);
5985
5965
  if (setStyleFn) {
5986
- await setStyleFn({
5987
- target: { kind: 'block', nodeType: created.nodeType, nodeId: created.nodeId },
5988
- styleId: reference.styleId,
5989
- });
5990
- applied.styleId = reference.styleId;
5966
+ const target = { kind: 'block', nodeType: created.nodeType, nodeId: created.nodeId };
5967
+ let canCopyStyle = true;
5968
+ if (created.headingLevel != null) {
5969
+ // The reference's effective level can come from a direct outline
5970
+ // override that its style does not contain. Preserve it before copying.
5971
+ const setOutlineFn = maybeMethod(doc, ['format', 'paragraph', 'setOutlineLevel']);
5972
+ if (!setOutlineFn) {
5973
+ canCopyStyle = false;
5974
+ applied.styleSkipped = 'Heading outline preservation is unavailable.';
5975
+ }
5976
+ else {
5977
+ const result = asRecord(await setOutlineFn({ target, outlineLevel: created.headingLevel - 1 }));
5978
+ if (result?.success === false) {
5979
+ canCopyStyle = false;
5980
+ applied.styleSkipped = 'Heading outline preservation failed.';
5981
+ }
5982
+ }
5983
+ }
5984
+ if (canCopyStyle) {
5985
+ await setStyleFn({ target, styleId: reference.styleId });
5986
+ applied.styleId = reference.styleId;
5987
+ }
5991
5988
  }
5992
5989
  }
5993
5990
  // Same "inline look" the style-copy paths use, minus properties the
@@ -7161,7 +7158,8 @@ function parseExactTrackedChangeIdList(value) {
7161
7158
  }
7162
7159
  async function superdocPerformAction(doc, args) {
7163
7160
  try {
7164
- return await dispatchSuperdocPerformAction(doc, args);
7161
+ const changeMode = isRecord(args) ? parseChangeMode(args.changeMode) : undefined;
7162
+ return await dispatchSuperdocPerformAction(changeMode ? actionChangeMode.bindActionChangeMode(doc, changeMode) : doc, args);
7165
7163
  }
7166
7164
  catch (error) {
7167
7165
  if (!(error instanceof docSnapshot.MutationSnapshotError))
@@ -7180,7 +7178,9 @@ async function superdocPerformAction(doc, args) {
7180
7178
  function parseChangeMode(value) {
7181
7179
  if (value === 'direct' || value === 'tracked')
7182
7180
  return value;
7183
- return undefined;
7181
+ if (value === undefined)
7182
+ return undefined;
7183
+ throw new errors.SuperDocCliError('changeMode must be direct or tracked.', { code: 'INVALID_ARGUMENT' });
7184
7184
  }
7185
7185
  function parseReplaceTextChangeMode(value) {
7186
7186
  if (value === undefined)
@@ -284,6 +284,7 @@ export type InsertTocArgs = {
284
284
  changeMode?: AgentChangeMode;
285
285
  };
286
286
  export type StyleTableArgs = {
287
+ changeMode?: AgentChangeMode;
287
288
  action: 'style_table';
288
289
  /** 1-based table ordinal (default 1). */
289
290
  tableOrdinal: number;
@@ -291,6 +292,7 @@ export type StyleTableArgs = {
291
292
  accentColor?: string;
292
293
  };
293
294
  export type MoveTableArgs = {
295
+ changeMode?: AgentChangeMode;
294
296
  action: 'move_table';
295
297
  /** 1-based table ordinal to move (default 1). */
296
298
  tableOrdinal?: number;
@@ -362,6 +364,7 @@ export type RedoChangesArgs = {
362
364
  steps?: number;
363
365
  };
364
366
  export type SplitListArgs = {
367
+ changeMode?: AgentChangeMode;
365
368
  action: 'split_list';
366
369
  /** Text inside the list item that should START the new (second) list. */
367
370
  anchorText: string;
@@ -375,6 +378,7 @@ export type AttachNumberingArgs = {
375
378
  likeMarker: string;
376
379
  };
377
380
  export type ApplyStyleArgs = {
381
+ changeMode?: AgentChangeMode;
378
382
  action: 'apply_style';
379
383
  /** Block to restyle. */
380
384
  selector: AgentSelector;
@@ -437,13 +441,9 @@ export declare const ACTION_ARGS: Record<ActionName, readonly string[]>;
437
441
  * What an action does with `changeMode`.
438
442
  *
439
443
  * - `tracked`: honors `changeMode: "tracked"` and records a reviewable tracked
440
- * change (a multi-step action may run a bookkeeping step directly on purpose —
441
- * insert_table_column fills the new cells directly so a column shows up as ONE
442
- * Word-style tracked change, not one per cell).
443
- * - `direct`: edits the document directly whatever `changeMode` says. Either the
444
- * action never reads the argument (apply_style, insert_page_break, …), the
445
- * engine refuses the tracked form (move_range), or the action is a review /
446
- * comment operation that has no tracked form by nature.
444
+ * change.
445
+ * - `direct`: performs a review, comment, or history operation that does not
446
+ * create a tracked document edit.
447
447
  *
448
448
  * Source of truth for the tracked-only agent policy (`enforceChangeMode`):
449
449
  * `direct` actions are hidden and refused unless a host allowlists them.
@@ -482,6 +482,7 @@ export type MoveTextArgs = {
482
482
  changeMode?: AgentChangeMode;
483
483
  };
484
484
  export type SetParagraphSpacingArgs = {
485
+ changeMode?: AgentChangeMode;
485
486
  action: 'set_paragraph_spacing';
486
487
  selector: AgentSelector;
487
488
  /** Line-spacing multiplier (1, 1.5, 2). */
@@ -492,10 +493,12 @@ export type SetParagraphSpacingArgs = {
492
493
  spaceAfter?: number;
493
494
  };
494
495
  export type InsertPageBreakArgs = {
496
+ changeMode?: AgentChangeMode;
495
497
  action: 'insert_page_break';
496
498
  selector: AgentSelector;
497
499
  };
498
500
  export type AddHyperlinkArgs = {
501
+ changeMode?: AgentChangeMode;
499
502
  action: 'add_hyperlink';
500
503
  /** Exact text to turn into a hyperlink. */
501
504
  text: string;