@superdoc/sdk 2.13.0-next.8 → 2.13.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.
@@ -62,7 +62,7 @@ export const ACTION_HINTS = {
62
62
  delete_blocks: 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table and delete_text to clear a table cell (a cell must keep a paragraph)',
63
63
  append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end',
64
64
  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',
65
- rewrite_block: "selector, text, changeMode? — REPLACES the whole block text; the block's intra-paragraph run pattern is preserved best-effort over the unchanged prefix/suffix. For appending a NEW paragraph/clause use insert_paragraphs, and for small wording edits prefer replace_text with a minimal find span",
65
+ 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",
66
66
  fill_placeholders: 'values[] and/or fields[{label?,value}], changeMode?',
67
67
  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.',
68
68
  comment_paragraphs: 'commentText, scope?: all|body, excludeBlockQuotes?',
@@ -223,6 +223,53 @@ export const ACTION_ARGS = {
223
223
  insert_page_break: ['selector'],
224
224
  add_hyperlink: ['text', 'url', 'tooltip'],
225
225
  };
226
+ export const ACTION_DISPOSITIONS = {
227
+ insert_paragraphs: 'tracked',
228
+ insert_heading: 'tracked',
229
+ replace_text: 'tracked',
230
+ delete_text: 'tracked',
231
+ delete_blocks: 'tracked',
232
+ append_list: 'tracked',
233
+ create_table: 'tracked',
234
+ comment_paragraphs: 'direct',
235
+ add_comments: 'direct',
236
+ resolve_comments: 'direct',
237
+ reply_to_comment: 'direct',
238
+ rewrite_block: 'tracked',
239
+ accept_tracked_changes: 'direct',
240
+ reject_tracked_changes: 'direct',
241
+ normalize_body_font_size: 'tracked',
242
+ set_font_family: 'tracked',
243
+ apply_letter_spacing: 'tracked',
244
+ fill_placeholders: 'tracked',
245
+ move_range: 'direct',
246
+ insert_toc: 'tracked',
247
+ insert_table_row: 'tracked',
248
+ insert_table_column: 'tracked',
249
+ delete_table_row: 'tracked',
250
+ delete_table_column: 'tracked',
251
+ split_table: 'tracked',
252
+ convert_list: 'tracked',
253
+ split_list: 'direct',
254
+ undo_changes: 'direct',
255
+ redo_changes: 'direct',
256
+ attach_numbering: 'tracked',
257
+ add_list_items: 'tracked',
258
+ format_text: 'tracked',
259
+ apply_style: 'direct',
260
+ format_paragraph: 'tracked',
261
+ move_text: 'tracked',
262
+ style_table: 'direct',
263
+ move_table: 'direct',
264
+ delete_table: 'tracked',
265
+ set_paragraph_spacing: 'direct',
266
+ insert_page_break: 'direct',
267
+ add_hyperlink: 'direct',
268
+ };
269
+ /** Fresh copy of {@link ACTION_DISPOSITIONS} for integrators (safe to mutate). */
270
+ export function getActionDispositions() {
271
+ return { ...ACTION_DISPOSITIONS };
272
+ }
226
273
  export function isActionName(value) {
227
274
  return typeof value === 'string' && ACTION_NAMES.includes(value);
228
275
  }
@@ -638,8 +685,8 @@ function evaluateChecks(pre, post, checks) {
638
685
  else if (check.kind === 'tracked-change-count-delta') {
639
686
  results.push({
640
687
  check,
641
- passed: post.trackedChanges.length - pre.trackedChanges.length === check.delta,
642
- detail: `pre=${pre.trackedChanges.length} post=${post.trackedChanges.length}`,
688
+ passed: post.counts.trackedChanges - pre.counts.trackedChanges === check.delta,
689
+ detail: `pre=${pre.counts.trackedChanges} post=${post.counts.trackedChanges}`,
643
690
  });
644
691
  }
645
692
  else if (check.kind === 'revision-changed') {
@@ -1146,11 +1193,6 @@ async function runReplaceTextUnchecked(doc, args) {
1146
1193
  let skippedEdits = [];
1147
1194
  let steps;
1148
1195
  let applyExpectedRevision;
1149
- // Blocks whose text this call rewrites, with their pre-mutation run layout:
1150
- // the engine replace lane renders the replaced range as uniform runs, so a
1151
- // whole-paragraph find/replace flattens intra-paragraph formatting (bold
1152
- // lead-ins). Restore it positionally after the mutation.
1153
- const preserveTargets = [];
1154
1196
  if (args.selector) {
1155
1197
  const target = selectorToBlockTarget(args.selector, pre);
1156
1198
  if (!target) {
@@ -1189,13 +1231,6 @@ async function runReplaceTextUnchecked(doc, args) {
1189
1231
  ];
1190
1232
  applyExpectedRevision = pre.revision;
1191
1233
  selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
1192
- preserveTargets.push({
1193
- nodeId: target.nodeId,
1194
- nodeType: target.nodeType ?? 'paragraph',
1195
- oldText: target.text,
1196
- newText: rewrittenText,
1197
- runs: await captureBlockRuns(doc, target.nodeId, target.text),
1198
- });
1199
1234
  }
1200
1235
  else {
1201
1236
  let candidates = args.edits.map((edit, editIndex) => ({
@@ -1253,12 +1288,6 @@ async function runReplaceTextUnchecked(doc, args) {
1253
1288
  steps = candidates.map((candidate) => candidate.step);
1254
1289
  }
1255
1290
  const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
1256
- const preserved = [];
1257
- for (const entry of preserveTargets) {
1258
- const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
1259
- if (restored)
1260
- preserved.push({ nodeId: entry.nodeId, ...restored });
1261
- }
1262
1291
  if (args.selector && selectedTargets[0]) {
1263
1292
  const post = await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1264
1293
  const blockId = selectedTargets[0].matched[0];
@@ -1287,10 +1316,7 @@ async function runReplaceTextUnchecked(doc, args) {
1287
1316
  preSnapshot: { revision: pre.revision, counts: pre.counts },
1288
1317
  postSnapshot: { revision: post.revision, counts: post.counts },
1289
1318
  selectedTargets,
1290
- executedOperations: [
1291
- { operationId: 'doc.mutations.apply', result },
1292
- ...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
1293
- ],
1319
+ executedOperations: [{ operationId: 'doc.mutations.apply', result }],
1294
1320
  verification,
1295
1321
  };
1296
1322
  }
@@ -1314,7 +1340,6 @@ async function runReplaceTextUnchecked(doc, args) {
1314
1340
  ...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
1315
1341
  result,
1316
1342
  },
1317
- ...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
1318
1343
  ],
1319
1344
  verification,
1320
1345
  };
@@ -1563,11 +1588,25 @@ async function runDeleteBlocks(doc, args) {
1563
1588
  const changeMode = parseChangeMode(args.changeMode);
1564
1589
  const executedOperations = [];
1565
1590
  const deleted = [];
1591
+ const addressedTrackedChangeIds = new Set();
1592
+ let allTrackedEffectsAddressed = true;
1566
1593
  for (const target of targets) {
1567
1594
  try {
1568
1595
  const result = await deleteFn({ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } }, changeMode ? { changeMode } : undefined);
1569
1596
  executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) });
1570
1597
  deleted.push({ nodeId: target.nodeId, nodeType: target.nodeType });
1598
+ if (changeMode === 'tracked') {
1599
+ // One block transaction can create multiple logical review entities
1600
+ // (for example, text plus its section break). Exact receipt refs let
1601
+ // us fail closed on missing entities. Older handles omit refs, and
1602
+ // adjacent deletions can coalesce into one review entity, so their
1603
+ // truthful fallback is the positive observed inventory delta rather
1604
+ // than one assumed entity per operation.
1605
+ const refIds = trackedChangeRefIds(result);
1606
+ allTrackedEffectsAddressed &&= refIds.size > 0;
1607
+ for (const entityId of refIds)
1608
+ addressedTrackedChangeIds.add(entityId);
1609
+ }
1571
1610
  }
1572
1611
  catch (err) {
1573
1612
  errors.push({
@@ -1581,14 +1620,37 @@ async function runDeleteBlocks(doc, args) {
1581
1620
  // Tracked deletions leave the block in place until the revision is decided,
1582
1621
  // so the block count cannot move — count the structural revisions instead.
1583
1622
  // Direct deletions must show one fewer block of each deleted node type.
1623
+ const observedTrackedChangeDelta = post.counts.trackedChanges - pre.counts.trackedChanges;
1624
+ // A later deletion can coalesce the batch and replace entity identities
1625
+ // returned by earlier operation receipts. For a multi-operation batch the
1626
+ // final inventory is therefore the authoritative count. A single operation
1627
+ // keeps using its addressed refs so fan-out remains verifiable even when
1628
+ // the inventory listing is capped.
1629
+ const expectedTrackedChangeDelta = deleted.length > 1
1630
+ ? observedTrackedChangeDelta
1631
+ : allTrackedEffectsAddressed
1632
+ ? addressedTrackedChangeIds.size
1633
+ : Math.max(observedTrackedChangeDelta, addressedTrackedChangeIds.size);
1584
1634
  const checks = changeMode === 'tracked'
1585
- ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }]
1635
+ ? [
1636
+ {
1637
+ kind: 'tracked-change-count-delta',
1638
+ delta: expectedTrackedChangeDelta,
1639
+ },
1640
+ ]
1586
1641
  : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({
1587
1642
  kind: 'block-count-delta',
1588
1643
  nodeType,
1589
1644
  delta: -deleted.filter((entry) => entry.nodeType === nodeType).length,
1590
1645
  }));
1591
1646
  const verification = evaluateChecks(pre, post, checks);
1647
+ if (changeMode === 'tracked' && observedTrackedChangeDelta <= 0) {
1648
+ verification[0] = {
1649
+ ...verification[0],
1650
+ passed: false,
1651
+ detail: `pre=${pre.counts.trackedChanges} post=${post.counts.trackedChanges}; no tracked review entity was created`,
1652
+ };
1653
+ }
1592
1654
  // Compare against the DEDUPED target list: two selectors naming the same
1593
1655
  // block are one deletion, and counting the raw selectors would report a
1594
1656
  // fully applied call as `partial` with no error to explain it. Selectors
@@ -2587,7 +2649,6 @@ async function runRewriteBlock(doc, args) {
2587
2649
  throw new Error('selector did not resolve to a body block');
2588
2650
  context.selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
2589
2651
  const text = normalizeTitleLikeRewriteText(target.text, args.text);
2590
- const runs = await captureBlockRuns(doc, target.nodeId, target.text);
2591
2652
  await executeMutations(context.document, [
2592
2653
  {
2593
2654
  id: 'rewrite-block-1',
@@ -2596,7 +2657,6 @@ async function runRewriteBlock(doc, args) {
2596
2657
  args: { replacement: { text }, style: preserveRewriteStyle() },
2597
2658
  },
2598
2659
  ], args.changeMode, pre.revision);
2599
- await preserveRunPatternAfterRewrite(context.document, target.nodeId, target.text, runs, text);
2600
2660
  const post = await context.finish();
2601
2661
  const rewritten = await post.block(target.nodeId);
2602
2662
  const verification = [
@@ -2669,23 +2729,8 @@ async function captureBlockRuns(doc, nodeId, text) {
2669
2729
  }
2670
2730
  }
2671
2731
  /**
2672
- * Re-apply a block's pre-rewrite run pattern after a whole-block text.rewrite.
2673
- * The engine renders the rewritten block as a single uniform run, which both
2674
- * drops intra-paragraph formatting AND can smear the first run's marks over
2675
- * everything. Marks are restored positionally over the parts of the text the
2676
- * rewrite did NOT change (the common prefix and suffix of old vs new text),
2677
- * and the changed middle takes the marks of the run it continues from —
2678
- * Word's "typing continues formatting" rule. Best-effort: never fails the
2679
- * rewrite.
2680
- */
2681
- /**
2682
- * Span-aware run-pattern restore for scoped replaces. The generic
2683
- * preserveRunPatternAfterRewrite diffs prefix/suffix and treats everything
2684
- * between the FIRST and LAST change as one modified middle — with two
2685
- * separated replacements that flattens independently-styled runs between
2686
- * them. Here the exact spans are known, so every untouched interval keeps its
2687
- * original run formatting at the offset-mapped position, and each replacement
2688
- * inherits the marks of the run it starts in.
2732
+ * Restore scoped replacements from their known spans: each untouched interval
2733
+ * keeps its run formatting, and replacements inherit the marks at their start.
2689
2734
  * `spans` are FULL-TEXT coordinates, ascending, non-overlapping.
2690
2735
  */
2691
2736
  async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
@@ -2778,74 +2823,6 @@ async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
2778
2823
  return { skipped: reasonOf(err) };
2779
2824
  }
2780
2825
  }
2781
- async function preserveRunPatternAfterRewrite(doc, nodeId, oldText, oldRuns, newText) {
2782
- // Nothing to restore when the block was uniformly plain; a single STYLED run
2783
- // still needs restoring (whole-block replaces drop even uniform marks).
2784
- if (!oldRuns || oldRuns.length === 0)
2785
- return null;
2786
- if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
2787
- return null;
2788
- const formatFn = maybeMethod(doc, ['format', 'apply']);
2789
- if (!formatFn)
2790
- return null;
2791
- // Boolean marks any old run carried: plain ranges must explicitly clear
2792
- // these, because the rewritten single run may have smeared them everywhere.
2793
- const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
2794
- // query.match run offsets are relative to the trimmed text used to match.
2795
- const oldTrim = oldText.trim();
2796
- const newTrimStart = newText.length - newText.trimStart().length;
2797
- const newTrim = newText.trim();
2798
- let prefix = 0;
2799
- const maxCommon = Math.min(oldTrim.length, newTrim.length);
2800
- while (prefix < maxCommon && oldTrim[prefix] === newTrim[prefix])
2801
- prefix += 1;
2802
- let suffix = 0;
2803
- while (suffix < maxCommon - prefix && oldTrim[oldTrim.length - 1 - suffix] === newTrim[newTrim.length - 1 - suffix])
2804
- suffix += 1;
2805
- const delta = newTrim.length - oldTrim.length;
2806
- let applied = 0;
2807
- const applyRange = async (start, end, inline) => {
2808
- if (end <= start || Object.keys(inline).length === 0)
2809
- return;
2810
- // Always direct, even under tracked mode. This pass RESTORES the marks the
2811
- // rewrite flattened; it is not new authorial formatting, and the text
2812
- // revision already owns the span's accept/reject fate. Applying it tracked
2813
- // adds a second reviewable change per rewrite (SD-4309's conformance matrix
2814
- // counts one per action), and rejecting that change alone would strand the
2815
- // text in the flattened look the rewrite produced.
2816
- const changed = await applyInlineQuiet(formatFn, {
2817
- blockId: nodeId,
2818
- start: newTrimStart + start,
2819
- end: newTrimStart + end,
2820
- inline,
2821
- });
2822
- if (changed)
2823
- applied += 1;
2824
- };
2825
- try {
2826
- for (const run of oldRuns) {
2827
- const inline = inlineFromSnapshotRun(run, normalize);
2828
- // Portion of the run inside the unchanged prefix.
2829
- await applyRange(run.start, Math.min(run.end, prefix), inline);
2830
- // Portion inside the unchanged suffix, shifted by the length delta.
2831
- const sufStart = Math.max(run.start, oldTrim.length - suffix);
2832
- if (run.end > sufStart)
2833
- await applyRange(sufStart + delta, run.end + delta, inline);
2834
- }
2835
- // The CHANGED middle keeps whatever the engine smeared; give it the marks
2836
- // of the run the change continues from (the one containing the last
2837
- // unchanged prefix character).
2838
- const middleEnd = newTrim.length - suffix;
2839
- if (middleEnd > prefix) {
2840
- const anchorRun = oldRuns.find((run) => prefix > run.start && prefix <= run.end) ?? oldRuns[oldRuns.length - 1];
2841
- await applyRange(prefix, middleEnd, inlineFromSnapshotRun(anchorRun, normalize));
2842
- }
2843
- return applied > 0 ? { restoredRunRanges: applied } : null;
2844
- }
2845
- catch (err) {
2846
- return { skipped: reasonOf(err) };
2847
- }
2848
- }
2849
2826
  const NAMED_COLORS = {
2850
2827
  black: '000000',
2851
2828
  blue: '0000FF',
@@ -4617,6 +4594,15 @@ function compactOpResult(result) {
4617
4594
  compact.stepCount = result.steps.length;
4618
4595
  return compact;
4619
4596
  }
4597
+ function trackedChangeRefIds(result) {
4598
+ const refs = asRecord(result)?.trackedChangeRefs;
4599
+ if (!Array.isArray(refs))
4600
+ return new Set();
4601
+ return new Set(refs.flatMap((entry) => {
4602
+ const entityId = asString(asRecord(entry)?.entityId);
4603
+ return entityId ? [entityId] : [];
4604
+ }));
4605
+ }
4620
4606
  function parseScopedReplaceArgs(args, changeMode) {
4621
4607
  if (args.action !== 'replace_text')
4622
4608
  return null;
@@ -4730,9 +4716,8 @@ async function runScopedReplaceUnchecked(doc, args) {
4730
4716
  },
4731
4717
  args: { replacement: { text: span.replace } },
4732
4718
  }));
4733
- // Select rewrites flatten the block's runs, so the tracked lane needs the
4734
- // same restoration the whole-block lane below performs. The preserve pass
4735
- // applies directly, so restoring marks adds no second reviewable change.
4719
+ // Restore the known edit spans directly so the formatting pass adds no
4720
+ // separate tracked change.
4736
4721
  const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4737
4722
  try {
4738
4723
  result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
@@ -4922,10 +4907,24 @@ function leadInShapeOf(blockText, labelText, quote) {
4922
4907
  if (occurrence > 0)
4923
4908
  return { kind: 'inside', delimiter: inside, occurrence };
4924
4909
  }
4910
+ // A single emphasised word followed by a space and plain prose: the token
4911
+ // itself is the label ("WHEREAS the Party…"). Multi-word runs stay
4912
+ // unrecognised — an emphasised opening clause is prose, not a label.
4913
+ if (/^\S+\s$/.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText)) {
4914
+ return { kind: 'word', label: labelText.trimEnd() };
4915
+ }
4925
4916
  return null;
4926
4917
  }
4927
4918
  /** The span of `text` that carries the label look, or null when it has none. */
4928
4919
  function leadInSpanOf(text, shape) {
4920
+ if (shape.kind === 'word') {
4921
+ // Only the SAME token inherits the look — "WHEREAS the Parties…" does,
4922
+ // "The Parties further agree…" does not (no guessing a label there).
4923
+ const next = text.charAt(shape.label.length);
4924
+ return text.startsWith(shape.label) && (next === '' || /\s/.test(next))
4925
+ ? { start: 0, end: shape.label.length }
4926
+ : null;
4927
+ }
4929
4928
  if (shape.kind === 'quoted') {
4930
4929
  const opener = text.slice(0, 2).indexOf(shape.quote);
4931
4930
  if (opener < 0)
@@ -543,18 +543,61 @@ function toProviderTool(provider, definition) {
543
543
  return toVercelTool(definition);
544
544
  return toOpenAiTool(definition);
545
545
  }
546
- function validateExclusions(options) {
547
- const excludedActions = new Set(options?.excludeActions ?? []);
548
- const knownActions = new Set(actions.ACTION_NAMES_LIST);
549
- for (const name of excludedActions) {
546
+ /** Wildcard entry for `allowDirectActions`: keep every direct-only action callable. */
547
+ const ALLOW_ALL_DIRECT_ACTIONS = '*';
548
+ function assertKnownActions(option, names, knownActions) {
549
+ for (const name of names) {
550
550
  if (!knownActions.has(name)) {
551
- throw new errors.SuperDocCliError(`excludeActions: unknown action "${name}".`, {
551
+ throw new errors.SuperDocCliError(`${option}: unknown action "${name}".`, {
552
552
  code: 'INVALID_ARGUMENT',
553
553
  details: { unknownAction: name, knownActions: [...knownActions] },
554
554
  });
555
555
  }
556
556
  }
557
- return { excludedActions };
557
+ }
558
+ function resolveActionPolicy(options, resolveOptions) {
559
+ const knownActions = new Set(actions.ACTION_NAMES_LIST);
560
+ const ignoreUnknown = resolveOptions?.ignoreUnknownActions === true;
561
+ const configuredExclusions = new Set(options?.excludeActions ?? []);
562
+ if (!ignoreUnknown)
563
+ assertKnownActions('excludeActions', configuredExclusions, knownActions);
564
+ const enforceChangeMode = options?.enforceChangeMode;
565
+ if (enforceChangeMode !== undefined && enforceChangeMode !== 'tracked') {
566
+ throw new errors.SuperDocCliError(`enforceChangeMode: only "tracked" is supported; got ${JSON.stringify(enforceChangeMode)}.`, {
567
+ code: 'INVALID_ARGUMENT',
568
+ details: { enforceChangeMode, supported: ['tracked'] },
569
+ });
570
+ }
571
+ if (options?.allowDirectActions !== undefined && enforceChangeMode === undefined) {
572
+ throw new errors.SuperDocCliError('allowDirectActions requires enforceChangeMode: "tracked"; without enforcement every action already runs as requested.', {
573
+ code: 'INVALID_ARGUMENT',
574
+ details: { allowDirectActions: [...options.allowDirectActions] },
575
+ });
576
+ }
577
+ // `'*'` is the "I accept every direct-only action" shortcut: tracked-capable
578
+ // actions are still forced tracked, the direct bucket just stays callable.
579
+ const requestedAllow = new Set(options?.allowDirectActions ?? []);
580
+ const allowAllDirect = requestedAllow.delete(ALLOW_ALL_DIRECT_ACTIONS);
581
+ if (!ignoreUnknown)
582
+ assertKnownActions('allowDirectActions', requestedAllow, knownActions);
583
+ const allowDirectActions = new Set((allowAllDirect ? [...actions.ACTION_NAMES_LIST] : [...requestedAllow]).filter((name) => actions.ACTION_DISPOSITIONS[name] === 'direct'));
584
+ const directOnlyExclusions = new Set();
585
+ if (enforceChangeMode === 'tracked') {
586
+ for (const name of actions.ACTION_NAMES_LIST) {
587
+ if (actions.ACTION_DISPOSITIONS[name] !== 'direct')
588
+ continue;
589
+ if (configuredExclusions.has(name) || allowDirectActions.has(name))
590
+ continue;
591
+ directOnlyExclusions.add(name);
592
+ }
593
+ }
594
+ return {
595
+ excludedActions: new Set([...configuredExclusions, ...directOnlyExclusions]),
596
+ configuredExclusions,
597
+ directOnlyExclusions,
598
+ enforceChangeMode,
599
+ allowDirectActions,
600
+ };
558
601
  }
559
602
  /**
560
603
  * Tools advertised to the LLM. Only PUBLIC_AGENT_TOOL_NAMES (currently
@@ -566,7 +609,7 @@ function validateExclusions(options) {
566
609
  * don't want the model to see certain capabilities.
567
610
  */
568
611
  function listAgentTools(provider, options) {
569
- const { excludedActions } = validateExclusions(options);
612
+ const { excludedActions } = resolveActionPolicy(options);
570
613
  const publicSet = new Set(PUBLIC_AGENT_TOOL_NAMES);
571
614
  const includedActions = actions.ACTION_NAMES_LIST.filter((name) => !excludedActions.has(name));
572
615
  return AGENT_TOOL_DEFINITIONS.filter((d) => publicSet.has(d.name))
@@ -587,6 +630,7 @@ function isAgentToolName(toolName) {
587
630
  exports.ACTION_ARG_SCHEMA = ACTION_ARG_SCHEMA;
588
631
  exports.AGENT_TOOL_DEFINITIONS = AGENT_TOOL_DEFINITIONS;
589
632
  exports.AGENT_TOOL_NAMES = AGENT_TOOL_NAMES;
633
+ exports.ALLOW_ALL_DIRECT_ACTIONS = ALLOW_ALL_DIRECT_ACTIONS;
590
634
  exports.CELL_SCHEMA = CELL_SCHEMA;
591
635
  exports.LIST_ITEM_SCHEMA = LIST_ITEM_SCHEMA;
592
636
  exports.PUBLIC_AGENT_TOOL_NAMES = PUBLIC_AGENT_TOOL_NAMES;
@@ -595,4 +639,4 @@ exports.SELECTOR_SCHEMA = SELECTOR_SCHEMA;
595
639
  exports.buildPerformActionDefinition = buildPerformActionDefinition;
596
640
  exports.isAgentToolName = isAgentToolName;
597
641
  exports.listAgentTools = listAgentTools;
598
- exports.validateExclusions = validateExclusions;
642
+ exports.resolveActionPolicy = resolveActionPolicy;
@@ -326,7 +326,15 @@ export declare const ACTION_ARG_SCHEMA: Record<string, unknown>;
326
326
  */
327
327
  export declare function buildPerformActionDefinition(includedActions: readonly string[]): AgentToolDefinition;
328
328
  export declare const AGENT_TOOL_DEFINITIONS: readonly AgentToolDefinition[];
329
- /** Customer-facing narrowing of the advertised tool surface. */
329
+ /** The only change mode a host can enforce today. */
330
+ export type EnforceChangeMode = 'tracked';
331
+ /** Wildcard entry for `allowDirectActions`: keep every direct-only action callable. */
332
+ export declare const ALLOW_ALL_DIRECT_ACTIONS = "*";
333
+ /**
334
+ * Customer-facing narrowing of the advertised tool surface. The same options
335
+ * shape the tool schema, the system prompt, and the dispatch guard so the
336
+ * three can never disagree.
337
+ */
330
338
  export interface ListAgentToolsOptions {
331
339
  /**
332
340
  * Action names to REMOVE from `superdoc_perform_action` — the enum, the
@@ -335,7 +343,52 @@ export interface ListAgentToolsOptions {
335
343
  * every action drops the tool entirely.
336
344
  */
337
345
  excludeActions?: readonly string[];
346
+ /**
347
+ * Fail-closed tracked-changes-only policy. With `'tracked'`, every action
348
+ * whose disposition is `direct` (see ACTION_DISPOSITIONS) is removed from the
349
+ * surface exactly like an excluded action, and every tracked-capable action
350
+ * is upgraded to `changeMode: "tracked"` at dispatch whatever the model
351
+ * asked for. Only `'tracked'` is accepted.
352
+ */
353
+ enforceChangeMode?: EnforceChangeMode;
354
+ /**
355
+ * Direct-only actions a host permits under `enforceChangeMode`. They stay in
356
+ * the enum and prompt and run directly at dispatch. `['*']` allows every
357
+ * direct-only action (tracked-capable ones are still forced tracked).
358
+ * Requires `enforceChangeMode`; unknown names throw; tracked-capable names
359
+ * are ignored so a list survives an action gaining tracked support.
360
+ */
361
+ allowDirectActions?: readonly string[];
362
+ }
363
+ /** The exclusion set a policy resolves to, split by why each action is out. */
364
+ export interface ResolvedActionPolicy {
365
+ /** Every action removed from the enum, prompt, and dispatch. */
366
+ excludedActions: Set<string>;
367
+ /** Removed by `excludeActions`. Wins over `allowDirectActions`. */
368
+ configuredExclusions: Set<string>;
369
+ /** Removed because it is direct-only under `enforceChangeMode` and not allowlisted. */
370
+ directOnlyExclusions: Set<string>;
371
+ enforceChangeMode: EnforceChangeMode | undefined;
372
+ /** Direct-only actions the host allowlisted (validated, tracked names dropped). */
373
+ allowDirectActions: Set<string>;
374
+ }
375
+ /**
376
+ * Validate and resolve the surface-narrowing options into one exclusion set.
377
+ * Every layer (schema, prompt, dispatch) calls this so the policy is computed
378
+ * exactly one way.
379
+ */
380
+ export interface ResolveActionPolicyOptions {
381
+ /**
382
+ * Skip the unknown-name check. A preset built on top of core (custom actions)
383
+ * forwards ONE exclusion list that mixes its own action names with core's, so
384
+ * at dispatch time a name core does not own is another layer's business, not
385
+ * a caller mistake. Configure-time entry points (getTools, getSystemPrompt)
386
+ * leave this off so a typo still fails loudly where it was written.
387
+ */
388
+ ignoreUnknownActions?: boolean;
338
389
  }
390
+ export declare function resolveActionPolicy(options?: ListAgentToolsOptions, resolveOptions?: ResolveActionPolicyOptions): ResolvedActionPolicy;
391
+ /** @deprecated Use {@link resolveActionPolicy}; kept for callers that only need the union. */
339
392
  export declare function validateExclusions(options?: ListAgentToolsOptions): {
340
393
  excludedActions: Set<string>;
341
394
  };
@@ -1,5 +1,5 @@
1
1
  import { SuperDocCliError } from '../runtime/errors.js';
2
- import { ACTION_NAMES_LIST, ACTION_HINTS, ACTION_GROUPS, ACTION_ARGS } from './actions.js';
2
+ import { ACTION_NAMES_LIST, ACTION_HINTS, ACTION_GROUPS, ACTION_ARGS, ACTION_DISPOSITIONS } from './actions.js';
3
3
  export const AGENT_TOOL_NAMES = [
4
4
  'superdoc_inspect',
5
5
  'superdoc_perform_action',
@@ -540,18 +540,65 @@ function toProviderTool(provider, definition) {
540
540
  return toVercelTool(definition);
541
541
  return toOpenAiTool(definition);
542
542
  }
543
- export function validateExclusions(options) {
544
- const excludedActions = new Set(options?.excludeActions ?? []);
545
- const knownActions = new Set(ACTION_NAMES_LIST);
546
- for (const name of excludedActions) {
543
+ /** Wildcard entry for `allowDirectActions`: keep every direct-only action callable. */
544
+ export const ALLOW_ALL_DIRECT_ACTIONS = '*';
545
+ function assertKnownActions(option, names, knownActions) {
546
+ for (const name of names) {
547
547
  if (!knownActions.has(name)) {
548
- throw new SuperDocCliError(`excludeActions: unknown action "${name}".`, {
548
+ throw new SuperDocCliError(`${option}: unknown action "${name}".`, {
549
549
  code: 'INVALID_ARGUMENT',
550
550
  details: { unknownAction: name, knownActions: [...knownActions] },
551
551
  });
552
552
  }
553
553
  }
554
- return { excludedActions };
554
+ }
555
+ export function resolveActionPolicy(options, resolveOptions) {
556
+ const knownActions = new Set(ACTION_NAMES_LIST);
557
+ const ignoreUnknown = resolveOptions?.ignoreUnknownActions === true;
558
+ const configuredExclusions = new Set(options?.excludeActions ?? []);
559
+ if (!ignoreUnknown)
560
+ assertKnownActions('excludeActions', configuredExclusions, knownActions);
561
+ const enforceChangeMode = options?.enforceChangeMode;
562
+ if (enforceChangeMode !== undefined && enforceChangeMode !== 'tracked') {
563
+ throw new SuperDocCliError(`enforceChangeMode: only "tracked" is supported; got ${JSON.stringify(enforceChangeMode)}.`, {
564
+ code: 'INVALID_ARGUMENT',
565
+ details: { enforceChangeMode, supported: ['tracked'] },
566
+ });
567
+ }
568
+ if (options?.allowDirectActions !== undefined && enforceChangeMode === undefined) {
569
+ throw new SuperDocCliError('allowDirectActions requires enforceChangeMode: "tracked"; without enforcement every action already runs as requested.', {
570
+ code: 'INVALID_ARGUMENT',
571
+ details: { allowDirectActions: [...options.allowDirectActions] },
572
+ });
573
+ }
574
+ // `'*'` is the "I accept every direct-only action" shortcut: tracked-capable
575
+ // actions are still forced tracked, the direct bucket just stays callable.
576
+ const requestedAllow = new Set(options?.allowDirectActions ?? []);
577
+ const allowAllDirect = requestedAllow.delete(ALLOW_ALL_DIRECT_ACTIONS);
578
+ if (!ignoreUnknown)
579
+ assertKnownActions('allowDirectActions', requestedAllow, knownActions);
580
+ const allowDirectActions = new Set((allowAllDirect ? [...ACTION_NAMES_LIST] : [...requestedAllow]).filter((name) => ACTION_DISPOSITIONS[name] === 'direct'));
581
+ const directOnlyExclusions = new Set();
582
+ if (enforceChangeMode === 'tracked') {
583
+ for (const name of ACTION_NAMES_LIST) {
584
+ if (ACTION_DISPOSITIONS[name] !== 'direct')
585
+ continue;
586
+ if (configuredExclusions.has(name) || allowDirectActions.has(name))
587
+ continue;
588
+ directOnlyExclusions.add(name);
589
+ }
590
+ }
591
+ return {
592
+ excludedActions: new Set([...configuredExclusions, ...directOnlyExclusions]),
593
+ configuredExclusions,
594
+ directOnlyExclusions,
595
+ enforceChangeMode,
596
+ allowDirectActions,
597
+ };
598
+ }
599
+ /** @deprecated Use {@link resolveActionPolicy}; kept for callers that only need the union. */
600
+ export function validateExclusions(options) {
601
+ return { excludedActions: resolveActionPolicy(options).excludedActions };
555
602
  }
556
603
  /**
557
604
  * Tools advertised to the LLM. Only PUBLIC_AGENT_TOOL_NAMES (currently
@@ -563,7 +610,7 @@ export function validateExclusions(options) {
563
610
  * don't want the model to see certain capabilities.
564
611
  */
565
612
  export function listAgentTools(provider, options) {
566
- const { excludedActions } = validateExclusions(options);
613
+ const { excludedActions } = resolveActionPolicy(options);
567
614
  const publicSet = new Set(PUBLIC_AGENT_TOOL_NAMES);
568
615
  const includedActions = ACTION_NAMES_LIST.filter((name) => !excludedActions.has(name));
569
616
  return AGENT_TOOL_DEFINITIONS.filter((d) => publicSet.has(d.name))