@superdoc/sdk 2.13.0-next.9 → 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.
@@ -226,6 +226,53 @@ const ACTION_ARGS = {
226
226
  insert_page_break: ['selector'],
227
227
  add_hyperlink: ['text', 'url', 'tooltip'],
228
228
  };
229
+ const ACTION_DISPOSITIONS = {
230
+ insert_paragraphs: 'tracked',
231
+ insert_heading: 'tracked',
232
+ replace_text: 'tracked',
233
+ delete_text: 'tracked',
234
+ delete_blocks: 'tracked',
235
+ append_list: 'tracked',
236
+ create_table: 'tracked',
237
+ comment_paragraphs: 'direct',
238
+ add_comments: 'direct',
239
+ resolve_comments: 'direct',
240
+ reply_to_comment: 'direct',
241
+ rewrite_block: 'tracked',
242
+ accept_tracked_changes: 'direct',
243
+ reject_tracked_changes: 'direct',
244
+ normalize_body_font_size: 'tracked',
245
+ set_font_family: 'tracked',
246
+ apply_letter_spacing: 'tracked',
247
+ fill_placeholders: 'tracked',
248
+ move_range: 'direct',
249
+ insert_toc: 'tracked',
250
+ insert_table_row: 'tracked',
251
+ insert_table_column: 'tracked',
252
+ delete_table_row: 'tracked',
253
+ delete_table_column: 'tracked',
254
+ split_table: 'tracked',
255
+ convert_list: 'tracked',
256
+ split_list: 'direct',
257
+ undo_changes: 'direct',
258
+ redo_changes: 'direct',
259
+ attach_numbering: 'tracked',
260
+ add_list_items: 'tracked',
261
+ format_text: 'tracked',
262
+ apply_style: 'direct',
263
+ format_paragraph: 'tracked',
264
+ move_text: 'tracked',
265
+ style_table: 'direct',
266
+ move_table: 'direct',
267
+ delete_table: 'tracked',
268
+ set_paragraph_spacing: 'direct',
269
+ insert_page_break: 'direct',
270
+ add_hyperlink: 'direct',
271
+ };
272
+ /** Fresh copy of {@link ACTION_DISPOSITIONS} for integrators (safe to mutate). */
273
+ function getActionDispositions() {
274
+ return { ...ACTION_DISPOSITIONS };
275
+ }
229
276
  function isActionName(value) {
230
277
  return typeof value === 'string' && ACTION_NAMES.includes(value);
231
278
  }
@@ -641,8 +688,8 @@ function evaluateChecks(pre, post, checks) {
641
688
  else if (check.kind === 'tracked-change-count-delta') {
642
689
  results.push({
643
690
  check,
644
- passed: post.trackedChanges.length - pre.trackedChanges.length === check.delta,
645
- detail: `pre=${pre.trackedChanges.length} post=${post.trackedChanges.length}`,
691
+ passed: post.counts.trackedChanges - pre.counts.trackedChanges === check.delta,
692
+ detail: `pre=${pre.counts.trackedChanges} post=${post.counts.trackedChanges}`,
646
693
  });
647
694
  }
648
695
  else if (check.kind === 'revision-changed') {
@@ -1544,11 +1591,25 @@ async function runDeleteBlocks(doc, args) {
1544
1591
  const changeMode = parseChangeMode(args.changeMode);
1545
1592
  const executedOperations = [];
1546
1593
  const deleted = [];
1594
+ const addressedTrackedChangeIds = new Set();
1595
+ let allTrackedEffectsAddressed = true;
1547
1596
  for (const target of targets) {
1548
1597
  try {
1549
1598
  const result = await deleteFn({ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } }, changeMode ? { changeMode } : undefined);
1550
1599
  executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) });
1551
1600
  deleted.push({ nodeId: target.nodeId, nodeType: target.nodeType });
1601
+ if (changeMode === 'tracked') {
1602
+ // One block transaction can create multiple logical review entities
1603
+ // (for example, text plus its section break). Exact receipt refs let
1604
+ // us fail closed on missing entities. Older handles omit refs, and
1605
+ // adjacent deletions can coalesce into one review entity, so their
1606
+ // truthful fallback is the positive observed inventory delta rather
1607
+ // than one assumed entity per operation.
1608
+ const refIds = trackedChangeRefIds(result);
1609
+ allTrackedEffectsAddressed &&= refIds.size > 0;
1610
+ for (const entityId of refIds)
1611
+ addressedTrackedChangeIds.add(entityId);
1612
+ }
1552
1613
  }
1553
1614
  catch (err) {
1554
1615
  errors$1.push({
@@ -1562,14 +1623,37 @@ async function runDeleteBlocks(doc, args) {
1562
1623
  // Tracked deletions leave the block in place until the revision is decided,
1563
1624
  // so the block count cannot move — count the structural revisions instead.
1564
1625
  // Direct deletions must show one fewer block of each deleted node type.
1626
+ const observedTrackedChangeDelta = post.counts.trackedChanges - pre.counts.trackedChanges;
1627
+ // A later deletion can coalesce the batch and replace entity identities
1628
+ // returned by earlier operation receipts. For a multi-operation batch the
1629
+ // final inventory is therefore the authoritative count. A single operation
1630
+ // keeps using its addressed refs so fan-out remains verifiable even when
1631
+ // the inventory listing is capped.
1632
+ const expectedTrackedChangeDelta = deleted.length > 1
1633
+ ? observedTrackedChangeDelta
1634
+ : allTrackedEffectsAddressed
1635
+ ? addressedTrackedChangeIds.size
1636
+ : Math.max(observedTrackedChangeDelta, addressedTrackedChangeIds.size);
1565
1637
  const checks = changeMode === 'tracked'
1566
- ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }]
1638
+ ? [
1639
+ {
1640
+ kind: 'tracked-change-count-delta',
1641
+ delta: expectedTrackedChangeDelta,
1642
+ },
1643
+ ]
1567
1644
  : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({
1568
1645
  kind: 'block-count-delta',
1569
1646
  nodeType,
1570
1647
  delta: -deleted.filter((entry) => entry.nodeType === nodeType).length,
1571
1648
  }));
1572
1649
  const verification = evaluateChecks(pre, post, checks);
1650
+ if (changeMode === 'tracked' && observedTrackedChangeDelta <= 0) {
1651
+ verification[0] = {
1652
+ ...verification[0],
1653
+ passed: false,
1654
+ detail: `pre=${pre.counts.trackedChanges} post=${post.counts.trackedChanges}; no tracked review entity was created`,
1655
+ };
1656
+ }
1573
1657
  // Compare against the DEDUPED target list: two selectors naming the same
1574
1658
  // block are one deletion, and counting the raw selectors would report a
1575
1659
  // fully applied call as `partial` with no error to explain it. Selectors
@@ -4513,6 +4597,15 @@ function compactOpResult(result) {
4513
4597
  compact.stepCount = result.steps.length;
4514
4598
  return compact;
4515
4599
  }
4600
+ function trackedChangeRefIds(result) {
4601
+ const refs = asRecord(result)?.trackedChangeRefs;
4602
+ if (!Array.isArray(refs))
4603
+ return new Set();
4604
+ return new Set(refs.flatMap((entry) => {
4605
+ const entityId = asString(asRecord(entry)?.entityId);
4606
+ return entityId ? [entityId] : [];
4607
+ }));
4608
+ }
4516
4609
  function parseScopedReplaceArgs(args, changeMode) {
4517
4610
  if (args.action !== 'replace_text')
4518
4611
  return null;
@@ -4817,10 +4910,24 @@ function leadInShapeOf(blockText, labelText, quote) {
4817
4910
  if (occurrence > 0)
4818
4911
  return { kind: 'inside', delimiter: inside, occurrence };
4819
4912
  }
4913
+ // A single emphasised word followed by a space and plain prose: the token
4914
+ // itself is the label ("WHEREAS the Party…"). Multi-word runs stay
4915
+ // unrecognised — an emphasised opening clause is prose, not a label.
4916
+ if (/^\S+\s$/.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText)) {
4917
+ return { kind: 'word', label: labelText.trimEnd() };
4918
+ }
4820
4919
  return null;
4821
4920
  }
4822
4921
  /** The span of `text` that carries the label look, or null when it has none. */
4823
4922
  function leadInSpanOf(text, shape) {
4923
+ if (shape.kind === 'word') {
4924
+ // Only the SAME token inherits the look — "WHEREAS the Parties…" does,
4925
+ // "The Parties further agree…" does not (no guessing a label there).
4926
+ const next = text.charAt(shape.label.length);
4927
+ return text.startsWith(shape.label) && (next === '' || /\s/.test(next))
4928
+ ? { start: 0, end: shape.label.length }
4929
+ : null;
4930
+ }
4824
4931
  if (shape.kind === 'quoted') {
4825
4932
  const opener = text.slice(0, 2).indexOf(shape.quote);
4826
4933
  if (opener < 0)
@@ -7303,8 +7410,10 @@ function parseSelector(value) {
7303
7410
  const ACTION_NAMES_LIST = ACTION_NAMES;
7304
7411
 
7305
7412
  exports.ACTION_ARGS = ACTION_ARGS;
7413
+ exports.ACTION_DISPOSITIONS = ACTION_DISPOSITIONS;
7306
7414
  exports.ACTION_GROUPS = ACTION_GROUPS;
7307
7415
  exports.ACTION_HINTS = ACTION_HINTS;
7308
7416
  exports.ACTION_NAMES_LIST = ACTION_NAMES_LIST;
7417
+ exports.getActionDispositions = getActionDispositions;
7309
7418
  exports.isActionName = isActionName;
7310
7419
  exports.superdocPerformAction = superdocPerformAction;
@@ -433,6 +433,25 @@ export declare const ACTION_GROUPS: ReadonlyArray<{
433
433
  * action at compile time.
434
434
  */
435
435
  export declare const ACTION_ARGS: Record<ActionName, readonly string[]>;
436
+ /**
437
+ * What an action does with `changeMode`.
438
+ *
439
+ * - `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.
447
+ *
448
+ * Source of truth for the tracked-only agent policy (`enforceChangeMode`):
449
+ * `direct` actions are hidden and refused unless a host allowlists them.
450
+ */
451
+ export type ActionDisposition = 'tracked' | 'direct';
452
+ export declare const ACTION_DISPOSITIONS: Record<ActionName, ActionDisposition>;
453
+ /** Fresh copy of {@link ACTION_DISPOSITIONS} for integrators (safe to mutate). */
454
+ export declare function getActionDispositions(): Record<ActionName, ActionDisposition>;
436
455
  export declare function isActionName(value: unknown): value is ActionName;
437
456
  export type ResolveCommentsArgs = {
438
457
  action: 'resolve_comments';
@@ -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') {
@@ -1541,11 +1588,25 @@ async function runDeleteBlocks(doc, args) {
1541
1588
  const changeMode = parseChangeMode(args.changeMode);
1542
1589
  const executedOperations = [];
1543
1590
  const deleted = [];
1591
+ const addressedTrackedChangeIds = new Set();
1592
+ let allTrackedEffectsAddressed = true;
1544
1593
  for (const target of targets) {
1545
1594
  try {
1546
1595
  const result = await deleteFn({ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } }, changeMode ? { changeMode } : undefined);
1547
1596
  executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) });
1548
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
+ }
1549
1610
  }
1550
1611
  catch (err) {
1551
1612
  errors.push({
@@ -1559,14 +1620,37 @@ async function runDeleteBlocks(doc, args) {
1559
1620
  // Tracked deletions leave the block in place until the revision is decided,
1560
1621
  // so the block count cannot move — count the structural revisions instead.
1561
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);
1562
1634
  const checks = changeMode === 'tracked'
1563
- ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }]
1635
+ ? [
1636
+ {
1637
+ kind: 'tracked-change-count-delta',
1638
+ delta: expectedTrackedChangeDelta,
1639
+ },
1640
+ ]
1564
1641
  : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({
1565
1642
  kind: 'block-count-delta',
1566
1643
  nodeType,
1567
1644
  delta: -deleted.filter((entry) => entry.nodeType === nodeType).length,
1568
1645
  }));
1569
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
+ }
1570
1654
  // Compare against the DEDUPED target list: two selectors naming the same
1571
1655
  // block are one deletion, and counting the raw selectors would report a
1572
1656
  // fully applied call as `partial` with no error to explain it. Selectors
@@ -4510,6 +4594,15 @@ function compactOpResult(result) {
4510
4594
  compact.stepCount = result.steps.length;
4511
4595
  return compact;
4512
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
+ }
4513
4606
  function parseScopedReplaceArgs(args, changeMode) {
4514
4607
  if (args.action !== 'replace_text')
4515
4608
  return null;
@@ -4814,10 +4907,24 @@ function leadInShapeOf(blockText, labelText, quote) {
4814
4907
  if (occurrence > 0)
4815
4908
  return { kind: 'inside', delimiter: inside, occurrence };
4816
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
+ }
4817
4916
  return null;
4818
4917
  }
4819
4918
  /** The span of `text` that carries the label look, or null when it has none. */
4820
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
+ }
4821
4928
  if (shape.kind === 'quoted') {
4822
4929
  const opener = text.slice(0, 2).indexOf(shape.quote);
4823
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))