@superdoc/sdk 2.25.0-next.1 → 2.25.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent/action-change-mode.cjs +6 -0
  2. package/dist/agent/action-change-mode.d.ts +1 -0
  3. package/dist/agent/action-change-mode.js +5 -0
  4. package/dist/agent/action-descriptors.json +2 -2
  5. package/dist/agent/actions.cjs +886 -530
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +887 -531
  8. package/dist/agent/catalog.cjs +5 -0
  9. package/dist/agent/catalog.js +5 -0
  10. package/dist/agent/doc-snapshot.cjs +10 -6
  11. package/dist/agent/doc-snapshot.d.ts +4 -1
  12. package/dist/agent/doc-snapshot.js +10 -6
  13. package/dist/agent/ir.d.ts +3 -0
  14. package/dist/embedded-prompts.generated.cjs +2 -1
  15. package/dist/embedded-prompts.generated.js +2 -1
  16. package/dist/embedded-tools.generated.cjs +5 -5
  17. package/dist/embedded-tools.generated.js +5 -5
  18. package/dist/generated/client.d.ts +534 -0
  19. package/dist/generated/contract.cjs +1 -1
  20. package/dist/generated/contract.js +1 -1
  21. package/dist/presets/core.cjs +2 -1
  22. package/dist/presets/core.d.ts +1 -0
  23. package/dist/presets/core.js +2 -2
  24. package/dist/presets/full.cjs +1804 -0
  25. package/dist/presets/full.d.ts +39 -0
  26. package/dist/presets/full.js +1795 -0
  27. package/dist/presets.cjs +2 -0
  28. package/dist/presets.js +2 -0
  29. package/dist/prompts/full-addendum.md +40 -0
  30. package/dist/prompts/system-prompt.md +3 -2
  31. package/dist/runtime/document-rpc.cjs +2 -8
  32. package/dist/runtime/document-rpc.js +2 -8
  33. package/dist/runtime/sdk-version.generated.cjs +1 -1
  34. package/dist/runtime/sdk-version.generated.d.ts +1 -1
  35. package/dist/runtime/sdk-version.generated.js +1 -1
  36. package/dist/runtime/tracked-support.cjs +11 -0
  37. package/dist/runtime/tracked-support.d.ts +6 -0
  38. package/dist/runtime/tracked-support.js +7 -0
  39. package/package.json +6 -6
  40. package/tools/catalog.json +9 -0
  41. package/tools/generation-receipt.json +20 -19
  42. package/tools/tools-policy.json +1 -1
  43. package/tools/tools.anthropic.json +9 -0
  44. package/tools/tools.generic.json +9 -0
  45. package/tools/tools.openai.json +9 -0
  46. package/tools/tools.vercel.json +9 -0
@@ -1,8 +1,9 @@
1
1
  import actionDescriptors from './action-descriptors.json' with { type: 'json' };
2
2
  import { ExecutionContext, evaluateFactChecks } from './execution-context.js';
3
- import { bindActionChangeMode } from './action-change-mode.js';
3
+ import { bindActionChangeMode, withoutActionChangeMode } from './action-change-mode.js';
4
4
  import { CONTRACT } from '../generated/contract.js';
5
5
  import { SuperDocCliError } from '../runtime/errors.js';
6
+ import { operationAcceptsTrackedMode } from '../runtime/tracked-support.js';
6
7
  import { runSuperdocListTransformWorkflow } from '../action-primitives/tools/list-transform.js';
7
8
  import { runSuperdocStructureInsertWorkflow } from '../action-primitives/tools/structure-insert.js';
8
9
  import { runSuperdocTextTransformWorkflow } from '../action-primitives/tools/text-transform.js';
@@ -13,11 +14,7 @@ function projectActionDefinitions(project) {
13
14
  return Object.fromEntries(Object.entries(ACTION_DEFINITIONS).map(([name, definition]) => [name, project(definition)]));
14
15
  }
15
16
  function contractSupportsTrackedOperation(operationId) {
16
- const operation = CONTRACT.operations[`doc.${operationId}`];
17
- return (operation?.trackedSupport === 'always' ||
18
- operation?.trackedSupport === 'conditional' ||
19
- operation?.supportsTrackedMode === true ||
20
- operation?.supportsConditionalTrackedMode === true);
17
+ return operationAcceptsTrackedMode(CONTRACT.operations[`doc.${operationId}`]);
21
18
  }
22
19
  function deriveActionDisposition(definition) {
23
20
  if (definition.trackedOverrideReason)
@@ -172,6 +169,13 @@ function createdBlockTarget(result) {
172
169
  }
173
170
  return null;
174
171
  }
172
+ function placementMissMessage(selector, matchCount) {
173
+ if (selector.kind === 'textSearch' && matchCount === 0 && (selector.occurrence ?? 1) === 1) {
174
+ const text = selector.terms.map((term) => `"${term}"`).join(selector.match === 'any' ? ' or ' : ' and ');
175
+ return `no block contains ${text}; re-inspect and quote the exact text`;
176
+ }
177
+ return 'placement selector did not resolve to a unique body block';
178
+ }
175
179
  function resolvePlacement(placement, snapshot) {
176
180
  if (!placement || placement.at === 'document_end')
177
181
  return { kind: 'documentEnd' };
@@ -179,10 +183,7 @@ function resolvePlacement(placement, snapshot) {
179
183
  return { kind: 'documentStart' };
180
184
  const target = selectorToBlockTarget(placement.selector, snapshot);
181
185
  if (!target) {
182
- throw new SuperDocCliError('placement selector did not resolve to a unique body block', {
183
- code: 'INVALID_ARGUMENT',
184
- details: { placement },
185
- });
186
+ throw new SuperDocCliError(placementMissMessage(placement.selector, resolveSnapshotSelector(snapshot, placement.selector).length), { code: 'INVALID_ARGUMENT', details: { placement } });
186
187
  }
187
188
  return {
188
189
  kind: placement.at === 'before' ? 'before' : 'after',
@@ -644,14 +645,20 @@ async function executeMutations(doc, steps, changeMode, expectedRevision) {
644
645
  ...(expectedRevision ? { expectedRevision } : {}),
645
646
  });
646
647
  }
647
- async function previewMutations(doc, steps, changeMode, expectedRevision) {
648
+ async function previewMutations(doc, steps, changeMode, expectedRevision, detail) {
648
649
  const fn = maybeMethod(doc, ['mutations', 'preview']);
649
650
  if (!fn) {
650
651
  throw new SuperDocCliError('doc.mutations.preview is not available on the document handle.', {
651
652
  code: 'TOOL_DISPATCH_NOT_FOUND',
652
653
  });
653
654
  }
654
- const result = await fn({ atomic: true, changeMode: changeMode ?? 'direct', expectedRevision, steps });
655
+ const result = await fn({
656
+ atomic: true,
657
+ changeMode: changeMode ?? 'direct',
658
+ expectedRevision,
659
+ steps,
660
+ ...(detail ? { detail } : {}),
661
+ });
655
662
  return isRecord(result) ? result : {};
656
663
  }
657
664
  async function executeCreateTable(doc, args, placement) {
@@ -973,185 +980,229 @@ async function runInsertHeading(doc, args) {
973
980
  return failedReceipt('insert_heading', err, pre);
974
981
  }
975
982
  }
976
- async function runReplaceTextUnchecked(doc, args) {
977
- const selectorDomains = args.selector ? snapshotDomainsForSelector(args.selector) : null;
978
- const requiresBlockSnapshot = args.selector != null;
979
- const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc);
980
- const pre = requiresBlockSnapshot
981
- ? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
982
- : snapshotFromIdentity(preIdentity);
983
- try {
984
- assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
985
- assertReplacementTextPayloads(args.edits);
986
- if (args.edits.length === 0) {
987
- return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
988
- }
989
- const caseSensitive = args.caseSensitive === true;
990
- const selectedTargets = [];
991
- let skippedEdits = [];
992
- let steps;
993
- let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
994
- if (args.selector) {
995
- const target = selectorToBlockTarget(args.selector, pre);
996
- if (!target) {
997
- return failedReceipt('replace_text', new Error('selector did not resolve to a unique body block'), pre);
998
- }
999
- let rewrittenText = target.text;
1000
- for (const edit of args.edits) {
1001
- if (!textIncludes(rewrittenText, edit.find, caseSensitive)) {
1002
- return {
1003
- status: 'failed',
1004
- intent: 'replace_text',
1005
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1006
- selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
1007
- executedOperations: [],
1008
- verification: [],
1009
- errors: [
1010
- {
1011
- code: 'ACTION_FAILED',
1012
- message: `selected block does not contain ${JSON.stringify(edit.find)}`,
1013
- },
1014
- ],
1015
- };
1016
- }
1017
- rewrittenText = replaceAllText(rewrittenText, edit.find, edit.replace, caseSensitive);
1018
- }
1019
- steps = [
1020
- {
1021
- id: 'replace-text-in-block-1',
1022
- op: 'text.rewrite',
1023
- where: { by: 'block', nodeType: target.nodeType, nodeId: target.nodeId },
1024
- args: {
1025
- replacement: { text: rewrittenText },
1026
- style: preserveRewriteStyle(),
1027
- },
1028
- },
1029
- ];
1030
- applyExpectedRevision = pre.revision;
1031
- selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
1032
- }
1033
- else {
1034
- let candidates = args.edits.map((edit, editIndex) => ({
1035
- edit,
1036
- step: {
1037
- id: `replace-${editIndex + 1}`,
1038
- op: 'text.rewrite',
1039
- where: {
1040
- by: 'select',
1041
- select: {
1042
- type: 'text',
1043
- pattern: edit.find,
1044
- mode: 'contains',
1045
- caseSensitive,
1046
- },
1047
- require: 'all',
1048
- },
1049
- args: {
1050
- replacement: { text: edit.replace },
1051
- style: preserveRewriteStyle(),
1052
- },
1053
- },
1054
- }));
1055
- if (candidates.length > 1) {
1056
- applyExpectedRevision = pre.revision;
1057
- while (candidates.length > 0) {
1058
- const preview = await previewMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, pre.revision);
1059
- if (preview.valid === true)
1060
- break;
1061
- const failures = Array.isArray(preview.failures) ? preview.failures.filter(isRecord) : [];
1062
- const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
1063
- if (!absent) {
1064
- throw new Error(typeof failures[0]?.message === 'string' ? failures[0].message : 'replacement plan could not be prepared');
1065
- }
1066
- const absentIndex = candidates.findIndex((candidate) => candidate.step.id === absent.stepId);
1067
- if (absentIndex < 0)
1068
- throw new Error('replacement preview returned an unknown step id');
1069
- skippedEdits.push({ find: candidates[absentIndex].edit.find });
1070
- candidates = candidates.filter((_candidate, index) => index !== absentIndex);
1071
- }
1072
- }
1073
- if (candidates.length === 0) {
1074
- return {
1075
- status: 'failed',
1076
- intent: 'replace_text',
1077
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1078
- selectedTargets: [],
1079
- executedOperations: [],
1080
- verification: [],
1081
- errors: [
1082
- { code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
1083
- ],
1084
- };
1085
- }
1086
- steps = candidates.map((candidate) => candidate.step);
1087
- }
1088
- const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
1089
- if (args.selector && selectedTargets[0]) {
1090
- const post = await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1091
- const blockId = selectedTargets[0].matched[0];
1092
- const postTarget = findSnapshotTextByNodeId(post, blockId);
1093
- const preTarget = findSnapshotTextByNodeId(pre, blockId);
1094
- const finalText = postTarget?.text ?? '';
1095
- const expectedText = args.edits.reduce((current, edit) => replaceAllText(current, edit.find, edit.replace, caseSensitive), preTarget?.text ?? '');
1096
- const verification = [
1097
- {
1098
- check: { kind: 'revision-changed' },
1099
- passed: pre.revision !== post.revision,
1100
- detail: `pre=${pre.revision} post=${post.revision}`,
1101
- },
1102
- {
1103
- check: {
1104
- kind: 'block-text-contains',
1105
- nodeId: blockId,
1106
- text: expectedText,
1107
- },
1108
- passed: !!postTarget && verifyRewrittenBlockText(finalText, expectedText, args.changeMode),
983
+ function replacementGranularityArg(granularity) {
984
+ return granularity === undefined ? {} : { replacementGranularity: granularity };
985
+ }
986
+ function globalReplaceCandidates(args) {
987
+ const caseSensitive = args.caseSensitive === true;
988
+ return args.edits.map((edit, editIndex) => ({
989
+ edit,
990
+ step: {
991
+ id: `replace-${editIndex + 1}`,
992
+ op: 'text.rewrite',
993
+ where: {
994
+ by: 'select',
995
+ select: {
996
+ type: 'text',
997
+ pattern: edit.find,
998
+ mode: 'contains',
999
+ caseSensitive,
1109
1000
  },
1110
- ];
1001
+ require: 'all',
1002
+ },
1003
+ args: {
1004
+ replacement: { text: edit.replace },
1005
+ style: preserveRewriteStyle(),
1006
+ ...replacementGranularityArg(args.granularity),
1007
+ },
1008
+ },
1009
+ }));
1010
+ }
1011
+ function previewFailuresOf(preview) {
1012
+ return Array.isArray(preview.failures) ? preview.failures.filter(isRecord) : [];
1013
+ }
1014
+ function previewRefusal(failure, find) {
1015
+ const code = asString(failure?.code) ?? 'ACTION_FAILED';
1016
+ const message = asString(failure?.message) ?? 'replacement plan could not be prepared';
1017
+ const namesFind = find !== undefined && code !== 'PLAN_LIMIT_EXCEEDED';
1018
+ return new SuperDocCliError(namesFind ? `${JSON.stringify(find)}: ${message}` : message, { code });
1019
+ }
1020
+ const PREVIEW_DETAIL_LIMIT = 1000;
1021
+ async function completeResolutionTexts(doc, steps, changeMode, revision, firstPage) {
1022
+ const texts = new Map();
1023
+ const counts = new Map();
1024
+ let page = firstPage;
1025
+ let offset = 0;
1026
+ while (page.valid === true) {
1027
+ let returned = 0;
1028
+ for (const entry of Array.isArray(page.steps) ? page.steps.filter(isRecord) : []) {
1029
+ const stepId = asString(entry.stepId);
1030
+ if (stepId === undefined)
1031
+ continue;
1032
+ const resolutions = Array.isArray(entry.resolutions) ? entry.resolutions : [];
1033
+ texts.set(stepId, [...(texts.get(stepId) ?? []), ...resolutions.map((resolution) => asRecord(resolution)?.text)]);
1034
+ if (typeof entry.matchCount === 'number')
1035
+ counts.set(stepId, entry.matchCount);
1036
+ returned = Math.max(returned, resolutions.length);
1037
+ }
1038
+ const missing = [...texts].some(([stepId, list]) => (counts.get(stepId) ?? list.length) > list.length);
1039
+ if (!missing || returned === 0)
1040
+ break;
1041
+ offset += returned;
1042
+ page = await previewMutations(doc, steps, changeMode, revision, { offset, limit: PREVIEW_DETAIL_LIMIT });
1043
+ }
1044
+ return new Map([...texts].map(([stepId, list]) => [stepId, counts.get(stepId) === list.length ? list : null]));
1045
+ }
1046
+ async function previewGlobalCandidates(doc, initial, changeMode, revision, skippedEdits) {
1047
+ let candidates = [...initial];
1048
+ while (candidates.length > 0) {
1049
+ const steps = candidates.map((candidate) => candidate.step);
1050
+ const preview = await previewMutations(doc, steps, changeMode, revision, {
1051
+ offset: 0,
1052
+ limit: PREVIEW_DETAIL_LIMIT,
1053
+ });
1054
+ const failures = previewFailuresOf(preview);
1055
+ if (preview.valid === true) {
1056
+ const texts = await completeResolutionTexts(doc, steps, changeMode, revision, preview);
1057
+ const resolvedTexts = ({ step }) => texts.get(String(step.id)) ?? null;
1058
+ const changedCount = (candidate) => (resolvedTexts(candidate) ?? []).filter((text) => text !== candidate.edit.replace).length;
1059
+ const complete = candidates.filter((candidate) => resolvedTexts(candidate) !== null);
1111
1060
  return {
1112
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
1113
- intent: 'replace_text',
1114
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1115
- postSnapshot: { revision: post.revision, counts: post.counts },
1116
- selectedTargets,
1117
- executedOperations: [{ operationId: 'doc.mutations.apply', result }],
1118
- verification,
1061
+ candidates,
1062
+ unchanged: complete.filter((candidate) => resolvedTexts(candidate).length > 0 && changedCount(candidate) === 0),
1063
+ applied: complete
1064
+ .map((candidate) => ({ find: candidate.edit.find, occurrences: changedCount(candidate) }))
1065
+ .filter((edit) => edit.occurrences > 0),
1066
+ incomplete: candidates.filter((candidate) => resolvedTexts(candidate) === null),
1119
1067
  };
1120
1068
  }
1121
- const postIdentity = args.changeMode === 'tracked'
1122
- ? await readDocumentIdentity(doc)
1123
- : {
1124
- revision: revisionAfterOperation(result, pre.revision),
1125
- counts: pre.counts,
1126
- };
1127
- const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
1128
- const skippedEditRationale = args.selector == null && args.edits.length > 1 ? summarizeSkippedReplaceEdits(skippedEdits) : undefined;
1069
+ const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
1070
+ const failedCandidate = (failure) => candidates.find((candidate) => candidate.step.id === failure?.stepId);
1071
+ if (!absent)
1072
+ throw previewRefusal(failures[0], failedCandidate(failures[0])?.edit.find);
1073
+ const absentCandidate = failedCandidate(absent);
1074
+ if (!absentCandidate)
1075
+ throw new Error('replacement preview returned an unknown step id');
1076
+ skippedEdits.push({ find: absentCandidate.edit.find });
1077
+ candidates = candidates.filter((candidate) => candidate !== absentCandidate);
1078
+ }
1079
+ return { candidates, unchanged: [], applied: [], incomplete: [] };
1080
+ }
1081
+ function readBackFailure(error) {
1082
+ return { check: { kind: 'revision-changed' }, passed: false, detail: `read back failed: ${reasonOf(error)}` };
1083
+ }
1084
+ function readBackError(error) {
1085
+ return {
1086
+ code: 'ACTION_FAILED',
1087
+ message: `replace_text: the edit was applied but the document could not be read back (${reasonOf(error)}). Inspect before retrying; do not repeat the edit.`,
1088
+ recovery: { kind: 'reinspect' },
1089
+ };
1090
+ }
1091
+ function unchangedEditsEvidence(unchanged) {
1092
+ const finds = [...new Set(unchanged.map((span) => span.find))];
1093
+ return finds.length > 0
1094
+ ? { editsUnchanged: finds.map((find) => ({ find, reason: 'already reads as the replacement' })) }
1095
+ : {};
1096
+ }
1097
+ function granularGlobalEvidence({ applied }, skippedEdits, requested) {
1098
+ return {
1099
+ editsApplied: applied,
1100
+ ...(skippedEdits.length > 0
1101
+ ? {
1102
+ nextStep: `${skippedEdits.length} of ${requested} edits were NOT applied — the find text is not in the document. ` +
1103
+ 'Do not stop: inspect, then call replace_text again for the remaining edits with the text as it appears.',
1104
+ recovery: { kind: 'retry' },
1105
+ editsSkipped: skippedEdits.map((edit) => ({ find: edit.find, reason: 'not present in the document' })),
1106
+ }
1107
+ : {}),
1108
+ };
1109
+ }
1110
+ async function runGlobalReplace(doc, args, pre) {
1111
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
1112
+ assertReplacementTextPayloads(args.edits);
1113
+ if (args.edits.length === 0) {
1114
+ return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
1115
+ }
1116
+ const initial = globalReplaceCandidates(args);
1117
+ const skippedEdits = [];
1118
+ const granular = args.granularity !== undefined;
1119
+ const multiple = initial.length > 1;
1120
+ const preview = multiple || granular
1121
+ ? await previewGlobalCandidates(doc, initial, args.changeMode, pre.revision, skippedEdits)
1122
+ : { candidates: initial, unchanged: [], applied: [], incomplete: [] };
1123
+ const { candidates, unchanged } = preview;
1124
+ if (granular && preview.incomplete.length > 0) {
1125
+ throw new SuperDocCliError(`replace_text: the engine did not list every match of ${preview.incomplete.map((candidate) => JSON.stringify(candidate.edit.find)).join(', ')}, so the granular replacement cannot be verified; nothing was written. Scope the edit with a selector or a longer find.`, { code: 'INVALID_ARGUMENT' });
1126
+ }
1127
+ if (candidates.length === 0) {
1129
1128
  return {
1130
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
1129
+ status: 'failed',
1131
1130
  intent: 'replace_text',
1132
1131
  preSnapshot: { revision: pre.revision, counts: pre.counts },
1133
- postSnapshot: postIdentity,
1134
- selectedTargets,
1135
- executedOperations: [
1136
- {
1137
- operationId: 'doc.mutations.apply',
1138
- ...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
1139
- result,
1140
- },
1132
+ selectedTargets: [],
1133
+ executedOperations: [],
1134
+ verification: [],
1135
+ errors: [
1136
+ { code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
1141
1137
  ],
1142
- verification,
1143
1138
  };
1144
1139
  }
1145
- catch (err) {
1146
- return failedReceipt('replace_text', err, pre);
1140
+ const skippedEditRationale = args.edits.length > 1 ? summarizeSkippedReplaceEdits(skippedEdits) : undefined;
1141
+ const evidence = {
1142
+ ...(granular ? granularGlobalEvidence(preview, skippedEdits, args.edits.length) : {}),
1143
+ ...unchangedEditsEvidence(unchanged.map((candidate) => candidate.edit)),
1144
+ };
1145
+ const partial = granular && skippedEdits.length > 0;
1146
+ if (unchanged.length === candidates.length) {
1147
+ return {
1148
+ status: partial ? 'partial' : 'ok',
1149
+ intent: 'replace_text',
1150
+ note: ['The text already reads as requested; nothing was written.', skippedEditRationale]
1151
+ .filter(Boolean)
1152
+ .join(' '),
1153
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1154
+ selectedTargets: [],
1155
+ executedOperations: [],
1156
+ verification: [],
1157
+ ...evidence,
1158
+ };
1147
1159
  }
1160
+ const applyExpectedRevision = args.expectedRevision != null || multiple || granular ? pre.revision : undefined;
1161
+ const result = await executeMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, applyExpectedRevision);
1162
+ let postIdentity;
1163
+ let readError;
1164
+ try {
1165
+ postIdentity =
1166
+ args.changeMode === 'tracked'
1167
+ ? await readDocumentIdentity(doc)
1168
+ : { revision: revisionAfterOperation(result, pre.revision), counts: pre.counts };
1169
+ }
1170
+ catch (error) {
1171
+ readError = error;
1172
+ }
1173
+ const verification = postIdentity
1174
+ ? [revisionVerification(pre.revision, postIdentity.revision, true)]
1175
+ : [readBackFailure(readError)];
1176
+ const passed = verification.every((v) => v.passed);
1177
+ return {
1178
+ status: !passed ? 'failed' : partial ? 'partial' : 'ok',
1179
+ intent: 'replace_text',
1180
+ ...(readError !== undefined ? { errors: [readBackError(readError)] } : {}),
1181
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1182
+ ...(postIdentity ? { postSnapshot: postIdentity } : {}),
1183
+ selectedTargets: [],
1184
+ executedOperations: [
1185
+ {
1186
+ operationId: 'doc.mutations.apply',
1187
+ ...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
1188
+ result,
1189
+ },
1190
+ ],
1191
+ verification,
1192
+ ...evidence,
1193
+ };
1148
1194
  }
1149
1195
  async function runReplaceText(doc, args) {
1150
1196
  const changeMode = args.changeMode ?? 'direct';
1151
- return {
1152
- ...(await runReplaceTextUnchecked(doc, { ...args, changeMode })),
1153
- changeMode,
1154
- };
1197
+ const pre = snapshotFromIdentity(await readDocumentIdentity(doc));
1198
+ let receipt;
1199
+ try {
1200
+ receipt = await runGlobalReplace(doc, { ...args, changeMode }, pre);
1201
+ }
1202
+ catch (err) {
1203
+ receipt = failedReceipt('replace_text', err, pre);
1204
+ }
1205
+ return { ...receipt, changeMode };
1155
1206
  }
1156
1207
  async function runDeleteText(doc, args) {
1157
1208
  const caseSensitive = args.caseSensitive === true;
@@ -1973,14 +2024,14 @@ async function runAddListItems(doc, args, opts) {
1973
2024
  const sibling = formattingSourceForLevel(ci.targetLevel);
1974
2025
  if (!sibling?.nodeId)
1975
2026
  continue;
1976
- const node = (await getNodeFn({ id: sibling.nodeId }));
2027
+ const node = (await getNodeFn({ id: sibling.nodeId, story: { kind: 'story', storyType: 'body' } }));
1977
2028
  const indent = paragraphIndentOf(node);
1978
2029
  if (!indent)
1979
2030
  continue;
1980
2031
  // COMPARE rather than assume: a same-level item usually inherits the
1981
2032
  // anchor's indentation on creation, but not always — one real document
1982
2033
  // produced items with no `w:ind` at all beside siblings indented 851.
1983
- const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId }));
2034
+ const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId, story: { kind: 'story', storyType: 'body' } }));
1984
2035
  if (created && ['left', 'right', 'firstLine', 'hanging'].every((k) => created[k] === indent[k])) {
1985
2036
  continue;
1986
2037
  }
@@ -2028,14 +2079,13 @@ async function runAddListItems(doc, args, opts) {
2028
2079
  }
2029
2080
  // Format each new item in four layers, later layers overriding earlier on
2030
2081
  // overlapping keys (successive format.apply calls):
2031
- // 1. BASE — the sampled look of the existing item at the same level, over
2032
- // the whole item, so an item added with no explicit styling still
2033
- // matches its siblings' font/size/colour (fixes the plain-insert
2082
+ // 1. BASE — the sampled font and size of the existing item at the same
2083
+ // level, over the whole item, never its emphasis or colour (fixes the plain-insert
2034
2084
  // mismatch where the new item fell back to style defaults instead of
2035
2085
  // the list's direct run formatting).
2036
2086
  // 2. LEAD — the siblings' "bold label: plain sentence" convention over
2037
2087
  // the new item's own label span, when the item requested no styling of
2038
- // its own. Without it a plain add reads as the odd one out even though
2088
+ // its own and the sibling has a real emphasised label. Without it a plain add reads as the odd one out even though
2039
2089
  // every other property matches: the agent is only told the item TEXT of
2040
2090
  // its siblings, never that they open with a bold lead-in.
2041
2091
  // 3. ITEM — whole-item `marks` the agent supplied (e.g. Arial 16pt on the
@@ -2056,7 +2106,7 @@ async function runAddListItems(doc, args, opts) {
2056
2106
  // `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
2057
2107
  // style and indent, so the look follows the same source. Falls back to
2058
2108
  // the anchor only when the destination level has no sibling to sample.
2059
- const anchorBase = inlineLookFromRow(anchor);
2109
+ const anchorBase = baseRunLookOf(anchor);
2060
2110
  const formatFn = maybeMethod(doc, ['format', 'apply']);
2061
2111
  if (formatFn) {
2062
2112
  const applied = [];
@@ -2096,7 +2146,7 @@ async function runAddListItems(doc, args, opts) {
2096
2146
  const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
2097
2147
  // 1. sampled base over the whole item (row fields first, run-level
2098
2148
  // sample second: the row read omits run styling entirely).
2099
- const destinationBase = source ? inlineLookFromRow(source) : anchorBase;
2149
+ const destinationBase = source ? baseRunLookOf(source) : anchorBase;
2100
2150
  const merged = { ...destinationBase, ...pattern?.body };
2101
2151
  reportedBase = reportedBase ?? merged;
2102
2152
  await applyRange(ci.nodeId, 0, len, merged);
@@ -2143,47 +2193,6 @@ async function runAddListItems(doc, args, opts) {
2143
2193
  catch (err) {
2144
2194
  formattingMatched = { skipped: reasonOf(err) };
2145
2195
  }
2146
- // Word paints the LIST NUMBER from the paragraph mark's run properties
2147
- // (w:pPr/w:rPr), not from the item's text runs — so an item whose text is
2148
- // styled correctly still shows a default-black "4." next to bold blue
2149
- // "1./2./3." siblings. Mirror the item's leading run onto the mark, which
2150
- // is the relationship those siblings already encode. Best-effort and never
2151
- // fatal: the item and its text styling have already landed.
2152
- let markStyledItems = 0;
2153
- const markerSkipped = await bestEffort(async () => {
2154
- const setMarkFn = maybeMethod(doc, ['format', 'paragraph', 'setMarkRunProps']);
2155
- const getNodeFn = maybeMethod(doc, ['getNodeById']);
2156
- if (!setMarkFn)
2157
- return;
2158
- for (const ci of createdItems) {
2159
- // Explicit marks describe the intent — the whole-item ones AND the
2160
- // leading run's, merged in that order, so a marker never loses the
2161
- // item's colour just because the first run also asked for bold.
2162
- //
2163
- // With no marks at all, COPY the sibling's own paragraph mark instead of
2164
- // deriving one from the text runs: a marker must not gain bold its
2165
- // siblings' markers don't have (bullet lists routinely carry a bare mark
2166
- // next to bold-lead-in text), and must gain it where they do. A
2167
- // same-level add already inherits the anchor's mark on creation; this
2168
- // covers the level-CHANGING add, whose inherited mark came from the
2169
- // wrong level. Reads nothing today: `getNodeById` reports
2170
- // indent/alignment/spacing but not `markRunProps`, so an unstyled
2171
- // promotion keeps the plain marker until that read lands.
2172
- let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
2173
- if (!markProps && getNodeFn) {
2174
- const source = formattingSourceForLevel(ci.targetLevel);
2175
- if (source?.nodeId)
2176
- markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
2177
- }
2178
- if (!markProps)
2179
- continue;
2180
- await setMarkFn({
2181
- target: { kind: 'block', nodeType: 'paragraph', nodeId: ci.nodeId },
2182
- markRunProps: markProps,
2183
- });
2184
- markStyledItems += 1;
2185
- }
2186
- });
2187
2196
  const postIdentity = await readDocumentIdentity(doc);
2188
2197
  const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
2189
2198
  return {
@@ -2193,8 +2202,6 @@ async function runAddListItems(doc, args, opts) {
2193
2202
  ...(styleSkipped ? { styleSkipped } : {}),
2194
2203
  ...(indentedItems.length ? { indentMatchedFrom: indentedItems.map((i) => i.from) } : {}),
2195
2204
  ...(indentSkipped ? { indentSkipped } : {}),
2196
- ...(markStyledItems > 0 ? { markerStyled: markStyledItems } : {}),
2197
- ...(markerSkipped ? { markerSkipped } : {}),
2198
2205
  ...(landed.length
2199
2206
  ? {
2200
2207
  note: `landed at ${landed
@@ -2287,9 +2294,7 @@ async function resolveFactPlacement(placement, facts) {
2287
2294
  return { kind: 'documentStart' };
2288
2295
  const target = await facts.target(placement.selector);
2289
2296
  if (!target)
2290
- throw new SuperDocCliError('placement selector did not resolve to a unique body block', {
2291
- code: 'INVALID_ARGUMENT',
2292
- });
2297
+ throw new SuperDocCliError(placementMissMessage(placement.selector, (await facts.select(placement.selector)).length), { code: 'INVALID_ARGUMENT' });
2293
2298
  return { kind: placement.at, target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } };
2294
2299
  }
2295
2300
  function placementSelectors(placement) {
@@ -2665,18 +2670,11 @@ async function runRewriteBlock(doc, args) {
2665
2670
  }
2666
2671
  }
2667
2672
  const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
2668
- /**
2669
- * The format.apply inline bag reproducing a SnapshotRun's marks. Boolean marks
2670
- * listed in `normalize` are always emitted (true OR explicit false) so ranges
2671
- * that were plain actively CLEAR a mark the engine smeared across the block.
2672
- */
2673
- function inlineFromSnapshotRun(run, normalize) {
2673
+ function inlineFromSnapshotRun(run) {
2674
2674
  const inline = {};
2675
2675
  for (const key of RUN_BOOL_MARKS) {
2676
2676
  if (run[key])
2677
2677
  inline[key] = true;
2678
- else if (normalize.has(key))
2679
- inline[key] = false;
2680
2678
  }
2681
2679
  if (run.fontFamily)
2682
2680
  inline.fontFamily = run.fontFamily;
@@ -2768,24 +2766,27 @@ async function runTrackedChangeDecision(doc, intentLabel, decision, exactIds, au
2768
2766
  const sideForType = changeType === 'delete' ? 'deleted' : changeType === 'insert' ? 'inserted' : undefined;
2769
2767
  const isReplacement = (item) => asString(asRecord(item)?.type) === 'replacement';
2770
2768
  if (changeType) {
2771
- scoped = scoped.filter((item) => asString(item?.type) === changeType || (sideForType != null && isReplacement(item)));
2769
+ scoped = scoped.filter((item) => trackedChangeKindOf(asString(item?.type)) === changeType || (sideForType != null && isReplacement(item)));
2772
2770
  }
2773
2771
  const executedOperations = [];
2774
2772
  if (scoped.length === 0) {
2775
2773
  // Nothing to do is not a failure: report it honestly.
2776
2774
  const post = await buildDocumentSnapshot(doc);
2775
+ const pendingByType = countTrackedChangesByKind(items);
2776
+ const filters = [author ? `author=${author}` : '', changeType ? `changeType=${changeType}` : ''].filter(Boolean);
2777
+ const rationale = items.length === 0
2778
+ ? `no tracked changes${author ? ` for author=${author}` : ''}${changeType ? ` of type=${changeType}` : ''} to ${decision}`
2779
+ : `0 of ${items.length} pending tracked changes matched ${filters.join(', ')}; nothing was ${decision}ed. Pending by type: ${Object.entries(pendingByType)
2780
+ .map(([kind, count]) => `${kind}: ${count}`)
2781
+ .join(', ')}`;
2777
2782
  return {
2778
2783
  status: 'ok',
2779
2784
  intent: intentLabel,
2780
2785
  preSnapshot: { revision: pre.revision, counts: pre.counts },
2781
2786
  postSnapshot: { revision: post.revision, counts: post.counts },
2782
2787
  selectedTargets: [],
2783
- executedOperations: [
2784
- {
2785
- operationId: 'doc.trackChanges.list',
2786
- rationale: `no tracked changes${author ? ` for author=${author}` : ''}${changeType ? ` of type=${changeType}` : ''} to ${decision}`,
2787
- },
2788
- ],
2788
+ executedOperations: [{ operationId: 'doc.trackChanges.list', rationale }],
2789
+ pendingTrackedChanges: { total: items.length, byType: pendingByType },
2789
2790
  verification: [{ check: { kind: 'tracked-change-count-delta', delta: 0 }, passed: true, detail: 'no-op' }],
2790
2791
  };
2791
2792
  }
@@ -3846,47 +3847,204 @@ async function runInsertToc(doc, args) {
3846
3847
  });
3847
3848
  }
3848
3849
  const resolvedPlacement = resolvePlacement(placement, pre);
3850
+ const intent = `insert_toc${args.title ? `: ${args.title}` : ''}`;
3851
+ const preTocIds = await readTocIds(doc);
3852
+ const failure = (err, heading) => insertTocFailureReceipt({ doc, intent, err, pre, preTocIds, title: args.title, heading });
3853
+ const createToc = async (dryRun) => {
3854
+ const options = {
3855
+ ...(args.changeMode ? { changeMode: args.changeMode } : {}),
3856
+ ...(dryRun ? { dryRun: true } : {}),
3857
+ };
3858
+ return requireMutationSuccess(await tocFn({ at: resolvedPlacement, ...options }, Object.keys(options).length > 0 ? options : undefined));
3859
+ };
3860
+ const createTitle = async () => requireMutationSuccess(await executeCreateHeading(doc, args.title ?? '', 1, resolvedPlacement, args.changeMode));
3849
3861
  const executed = [];
3850
- let tocPlacement = resolvedPlacement;
3851
- if (args.title) {
3852
- const headingResult = await executeCreateHeading(doc, args.title, 1, resolvedPlacement, args.changeMode);
3853
- executed.push({ operationId: 'doc.create.heading', result: headingResult });
3854
- const headingNodeId = asString(asRecord(asRecord(headingResult)?.heading)?.nodeId);
3855
- if (headingNodeId) {
3856
- tocPlacement = {
3857
- kind: 'after',
3858
- target: { kind: 'block', nodeType: 'heading', nodeId: headingNodeId },
3859
- };
3862
+ let headingError = null;
3863
+ if (!args.title || resolvedPlacement.kind === 'after' || resolvedPlacement.kind === 'documentStart') {
3864
+ try {
3865
+ executed.push({ operationId: 'doc.create.tableOfContents', result: await createToc(false) });
3866
+ }
3867
+ catch (err) {
3868
+ return failure(err);
3869
+ }
3870
+ if (args.title) {
3871
+ try {
3872
+ executed.push({ operationId: 'doc.create.heading', result: await createTitle() });
3873
+ }
3874
+ catch (err) {
3875
+ headingError = err;
3876
+ }
3877
+ }
3878
+ }
3879
+ else {
3880
+ let headingResult;
3881
+ try {
3882
+ await createToc(true);
3883
+ headingResult = await createTitle();
3884
+ }
3885
+ catch (err) {
3886
+ return failure(err);
3887
+ }
3888
+ try {
3889
+ const tocResult = await createToc(false);
3890
+ executed.push({ operationId: 'doc.create.heading', result: headingResult }, { operationId: 'doc.create.tableOfContents', result: tocResult });
3891
+ }
3892
+ catch (err) {
3893
+ return failure(err, { result: headingResult, removedBy: await rollBackInsertedHeading(doc, headingResult) });
3860
3894
  }
3861
3895
  }
3862
- const tocParams = { at: tocPlacement };
3863
- if (args.changeMode)
3864
- tocParams.changeMode = args.changeMode;
3865
- // Dual dialect — see executeCreateParagraph.
3866
- const result = await tocFn(tocParams, args.changeMode ? { changeMode: args.changeMode } : undefined);
3867
- executed.push({ operationId: 'doc.create.tableOfContents', result });
3868
3896
  const post = await buildDocumentSnapshot(doc);
3869
3897
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3870
- return {
3898
+ const receipt = {
3871
3899
  status: verification.every((v) => v.passed) ? 'ok' : 'failed',
3872
- intent: `insert_toc${args.title ? `: ${args.title}` : ''}`,
3900
+ intent,
3873
3901
  preSnapshot: { revision: pre.revision, counts: pre.counts },
3874
3902
  postSnapshot: { revision: post.revision, counts: post.counts },
3875
3903
  selectedTargets: [],
3876
3904
  executedOperations: executed,
3877
3905
  verification,
3878
3906
  };
3907
+ if (!headingError)
3908
+ return receipt;
3909
+ return {
3910
+ ...receipt,
3911
+ status: 'partial',
3912
+ errors: [{ code: asString(asRecord(headingError)?.code) ?? 'ACTION_FAILED', message: reasonOf(headingError) }],
3913
+ note: `The table of contents was inserted without its ${JSON.stringify(args.title)} heading.`,
3914
+ nextStep: `Add the ${JSON.stringify(args.title)} heading directly above the table of contents, or leave it untitled.`,
3915
+ };
3879
3916
  }
3880
3917
  catch (err) {
3881
3918
  return failedReceipt('insert_toc', err, pre);
3882
3919
  }
3883
3920
  }
3921
+ async function rollBackInsertedHeading(doc, headingResult) {
3922
+ const unbound = withoutActionChangeMode(doc);
3923
+ const heading = asRecord(headingResult);
3924
+ const trackedIds = (Array.isArray(heading?.trackedChangeRefs) ? heading.trackedChangeRefs : [])
3925
+ .map((ref) => asString(asRecord(ref)?.entityId))
3926
+ .filter((id) => typeof id === 'string');
3927
+ try {
3928
+ if (trackedIds.length > 0) {
3929
+ const decideFn = maybeMethod(unbound, ['trackChanges', 'decide']);
3930
+ if (!decideFn)
3931
+ return null;
3932
+ const target = trackedIds.length === 1 ? { kind: 'id', id: trackedIds[0] } : { kind: 'ids', ids: trackedIds };
3933
+ requireMutationSuccess(await decideFn({ decision: 'reject', target }));
3934
+ return 'doc.trackChanges.decide';
3935
+ }
3936
+ const nodeId = asString(asRecord(heading?.heading)?.nodeId);
3937
+ const deleteFn = maybeMethod(unbound, ['blocks', 'delete']);
3938
+ if (!nodeId || !deleteFn)
3939
+ return null;
3940
+ requireMutationSuccess(await deleteFn({ target: { kind: 'block', nodeType: 'heading', nodeId } }, { changeMode: 'direct' }));
3941
+ return 'doc.blocks.delete';
3942
+ }
3943
+ catch {
3944
+ return null;
3945
+ }
3946
+ }
3947
+ async function readTocIds(doc) {
3948
+ const listFn = maybeMethod(doc, ['toc', 'list']);
3949
+ if (!listFn)
3950
+ return null;
3951
+ try {
3952
+ const ids = new Set();
3953
+ let offset = 0;
3954
+ for (;;) {
3955
+ const page = asRecord(await listFn({ offset, limit: 500 }));
3956
+ const items = Array.isArray(page?.items) ? page.items : [];
3957
+ for (const item of items) {
3958
+ const id = asString(asRecord(asRecord(item)?.address)?.nodeId) ?? asString(asRecord(item)?.id);
3959
+ if (id)
3960
+ ids.add(id);
3961
+ }
3962
+ offset += items.length;
3963
+ if (items.length === 0 || offset >= (asNumber(page?.total) ?? 0))
3964
+ return ids;
3965
+ }
3966
+ }
3967
+ catch {
3968
+ return null;
3969
+ }
3970
+ }
3971
+ function requireMutationSuccess(result) {
3972
+ const record = asRecord(result);
3973
+ if (record?.success !== false)
3974
+ return result;
3975
+ const failure = asRecord(record.failure);
3976
+ throw new SuperDocCliError(asString(failure?.message) ?? 'The operation was refused.', {
3977
+ code: asString(failure?.code) ?? 'COMMAND_FAILED',
3978
+ });
3979
+ }
3980
+ async function insertTocFailureReceipt(input) {
3981
+ const { pre, heading } = input;
3982
+ const failed = failedReceipt(input.intent, input.err, pre);
3983
+ const post = await buildDocumentSnapshot(input.doc).catch(() => null);
3984
+ const postTocIds = await readTocIds(input.doc);
3985
+ const preTocIds = input.preTocIds;
3986
+ const survivingTocs = postTocIds && preTocIds ? [...postTocIds].filter((id) => !preTocIds.has(id)) : null;
3987
+ const headingLeft = heading !== undefined && heading.removedBy === null;
3988
+ const title = JSON.stringify(input.title ?? '');
3989
+ const executed = [];
3990
+ const notes = [];
3991
+ if (heading) {
3992
+ executed.push({ operationId: 'doc.create.heading', result: heading.result });
3993
+ if (heading.removedBy) {
3994
+ executed.push({
3995
+ operationId: heading.removedBy,
3996
+ rationale: 'remove the heading written for the refused table of contents',
3997
+ });
3998
+ notes.push(`The ${title} heading was written and removed again.`);
3999
+ }
4000
+ else {
4001
+ notes.push(`The ${title} heading is still in the document; remove it or add the table of contents below it.`);
4002
+ }
4003
+ }
4004
+ if (survivingTocs && survivingTocs.length > 0) {
4005
+ executed.push({
4006
+ operationId: 'doc.create.tableOfContents',
4007
+ rationale: 'reported a failure after committing',
4008
+ result: { tocs: survivingTocs },
4009
+ });
4010
+ notes.push(`A table of contents is in the document despite the failure (${survivingTocs.join(', ')}); do not retry without removing it.`);
4011
+ }
4012
+ const partial = headingLeft || (survivingTocs?.length ?? 0) > 0;
4013
+ const revisionsKnown = post !== null && pre.revision !== 'unknown' && post.revision !== 'unknown';
4014
+ const unchanged = !partial && revisionsKnown && post.revision === pre.revision;
4015
+ const nothingAdded = !partial && survivingTocs !== null && post !== null && insertTocCountsMatch(pre.counts, post.counts);
4016
+ if (unchanged)
4017
+ notes.push('Nothing was inserted: the request was refused and the document is unchanged.');
4018
+ else if (nothingAdded) {
4019
+ notes.push('No content was added: no new table of contents remains and block counts match the pre-action snapshot.');
4020
+ }
4021
+ else if (post === null || survivingTocs === null) {
4022
+ notes.push('What survived could not be confirmed; re-inspect before retrying.');
4023
+ }
4024
+ else if (!partial) {
4025
+ notes.push('The document changed during the failed request; re-inspect before retrying.');
4026
+ }
4027
+ return {
4028
+ ...failed,
4029
+ status: partial ? 'partial' : 'failed',
4030
+ executedOperations: executed,
4031
+ ...(post ? { postSnapshot: { revision: post.revision, counts: post.counts } } : {}),
4032
+ ...(revisionsKnown ? { verification: [revisionVerification(pre.revision, post.revision, false)] } : {}),
4033
+ ...(unchanged || nothingAdded ? {} : { recovery: { kind: 'reinspect' } }),
4034
+ note: notes.join(' '),
4035
+ };
4036
+ }
4037
+ function insertTocCountsMatch(before, after) {
4038
+ return ['blocks', 'paragraphs', 'headings', 'tables'].every((key) => before[key] === after[key]);
4039
+ }
3884
4040
  /**
3885
4041
  * style_table — make a table look professional in one call: a filled accent
3886
- * header row (navy by default) with white bold header text, bold first-column
3887
- * labels, banded rows, and clean borders. Composes tables.applyPreset("accent")
3888
- * for the header fill + borders, then formats the header row (white + bold) and
3889
- * the first column (bold + accent) via cell formatting.
4042
+ * header row (navy by default) with contrasting bold header text, bold
4043
+ * first-column labels and accent top/bottom borders. tables.applyPreset("accent")
4044
+ * paints the header fill and header text atomically; the first column (body
4045
+ * rows) is then formatted bold + accent through paragraph-targeted
4046
+ * format.apply steps. The receipt reads the header back and reports any step
4047
+ * that did not land, so a half-styled table never reports ok.
3890
4048
  */
3891
4049
  async function runStyleTable(doc, args) {
3892
4050
  const preIdentity = await readDocumentIdentity(doc);
@@ -3900,61 +4058,61 @@ async function runStyleTable(doc, args) {
3900
4058
  const accent = normalizeTableColor(args.accentColor ?? '#1F3864') ?? '#1F3864';
3901
4059
  const target = { kind: 'block', nodeType: 'table', nodeId: table.nodeId };
3902
4060
  const executed = [];
3903
- // 1. Accent preset: filled header row + accent borders, in one call.
4061
+ const errors = [];
3904
4062
  const presetFn = maybeMethod(doc, ['tables', 'applyPreset']);
3905
4063
  if (!presetFn) {
3906
4064
  throw new SuperDocCliError('doc.tables.applyPreset is not available on the document handle.', {
3907
4065
  code: 'TOOL_DISPATCH_NOT_FOUND',
3908
4066
  });
3909
4067
  }
3910
- const presetResult = await presetFn({ target, preset: 'accent', accentColor: accent });
4068
+ const presetResult = requireMutationSuccess(await presetFn({ target, preset: 'accent', accentColor: accent }));
3911
4069
  executed.push({ operationId: 'doc.tables.applyPreset', result: compactOpResult(presetResult) });
3912
- // 2. Header row → white + bold; first column (body rows) → bold + accent.
3913
- // Best-effort: a failure to read/format cells must not fail the action,
3914
- // the header fill + borders already landed.
4070
+ let cellParagraphs = [];
3915
4071
  try {
3916
- const getCellsFn = maybeMethod(doc, ['tables', 'getCells']);
3917
- if (getCellsFn) {
3918
- const cellsRes = asRecord(await getCellsFn({ target }));
3919
- const cells = Array.isArray(cellsRes?.cells) ? cellsRes.cells : [];
3920
- const steps = [];
3921
- for (const cell of cells) {
3922
- const nodeId = asString(cell.nodeId);
3923
- const rowIndex = asNumber(cell.rowIndex);
3924
- const columnIndex = asNumber(cell.columnIndex);
3925
- if (!nodeId || rowIndex == null)
3926
- continue;
3927
- const where = { by: 'block', nodeType: 'tableCell', nodeId };
3928
- if (rowIndex === 0) {
3929
- steps.push({
3930
- id: `hdr-${nodeId}`,
3931
- op: 'format.apply',
3932
- where,
3933
- args: { inline: { bold: true, color: '#FFFFFF' }, scope: 'block' },
3934
- });
3935
- }
3936
- else if (columnIndex === 0) {
3937
- steps.push({
3938
- id: `col-${nodeId}`,
3939
- op: 'format.apply',
3940
- where,
3941
- args: { inline: { bold: true, color: accent }, scope: 'block' },
3942
- });
3943
- }
3944
- }
3945
- if (steps.length > 0) {
3946
- const fmtResult = await executeMutations(doc, steps, 'direct');
3947
- executed.push({ operationId: 'doc.mutations.apply', result: compactOpResult(fmtResult) });
3948
- }
3949
- }
4072
+ cellParagraphs = await readStyleTableParagraphs(doc, target);
3950
4073
  }
3951
- catch {
3952
- // header/first-column emphasis is cosmetic polish on top of the preset.
4074
+ catch (err) {
4075
+ errors.push({ code: 'TABLE_READBACK_FAILED', message: `could not read the styled table back: ${reasonOf(err)}` });
4076
+ }
4077
+ const labelParagraphs = cellParagraphs.filter((paragraph) => paragraph.rowIndex > 0 && paragraph.columnIndex === 0 && paragraph.text.trim().length > 0);
4078
+ const firstColumnSteps = [];
4079
+ for (const paragraph of labelParagraphs) {
4080
+ const color = (await accentReadsOnLabel(doc, paragraph, accent)) ? { color: accent } : {};
4081
+ firstColumnSteps.push({
4082
+ id: `col-${paragraph.nodeId}`,
4083
+ op: 'format.apply',
4084
+ where: { by: 'block', nodeType: 'paragraph', nodeId: paragraph.nodeId },
4085
+ args: { inline: { bold: true, ...color }, scope: 'block' },
4086
+ });
4087
+ }
4088
+ if (firstColumnSteps.length > 0) {
4089
+ try {
4090
+ const fmtResult = await executeMutations(doc, firstColumnSteps, args.changeMode);
4091
+ executed.push({ operationId: 'doc.mutations.apply', result: compactOpResult(fmtResult) });
4092
+ }
4093
+ catch (err) {
4094
+ errors.push({
4095
+ code: 'FIRST_COLUMN_FORMAT_FAILED',
4096
+ message: `first-column formatting did not apply: ${reasonOf(err)}`,
4097
+ });
4098
+ }
3953
4099
  }
3954
4100
  const postIdentity = await readDocumentIdentity(doc);
3955
- const verification = [revisionVerification(preIdentity.revision, postIdentity.revision, true)];
4101
+ const headerParagraphs = cellParagraphs.filter((paragraph) => paragraph.rowIndex === 0);
4102
+ let headerVerification;
4103
+ try {
4104
+ headerVerification = await verifyStyledTableHeader(doc, headerParagraphs, accent);
4105
+ }
4106
+ catch (err) {
4107
+ const message = `could not read the header back: ${reasonOf(err)}`;
4108
+ errors.push({ code: 'HEADER_VERIFICATION_FAILED', message });
4109
+ headerVerification = { check: { kind: 'table-header-styled', fill: accent }, passed: false, detail: message };
4110
+ }
4111
+ const verification = [revisionVerification(preIdentity.revision, postIdentity.revision, true), headerVerification];
4112
+ const allPassed = verification.every((entry) => entry.passed) && errors.length === 0;
4113
+ const anyApplied = preIdentity.revision !== postIdentity.revision;
3956
4114
  return {
3957
- status: verification.every((entry) => entry.passed) ? 'ok' : 'failed',
4115
+ status: allPassed ? 'ok' : anyApplied ? 'partial' : 'failed',
3958
4116
  intent: `style_table: accent ${accent}`,
3959
4117
  preSnapshot: preIdentity,
3960
4118
  postSnapshot: postIdentity,
@@ -3963,12 +4121,121 @@ async function runStyleTable(doc, args) {
3963
4121
  ],
3964
4122
  executedOperations: executed,
3965
4123
  verification,
4124
+ ...(errors.length > 0 ? { errors } : {}),
3966
4125
  };
3967
4126
  }
3968
4127
  catch (err) {
3969
4128
  return failedReceipt('style_table', err, pre);
3970
4129
  }
3971
4130
  }
4131
+ async function readStyleTableParagraphs(doc, target) {
4132
+ const extractFn = maybeMethod(doc, ['extract']);
4133
+ const getCellsFn = maybeMethod(doc, ['tables', 'getCells']);
4134
+ if (!extractFn || !getCellsFn)
4135
+ return [];
4136
+ const cellsResult = asRecord(await getCellsFn({ target }));
4137
+ const cells = Array.isArray(cellsResult?.cells) ? cellsResult.cells : [];
4138
+ const ownParagraphIds = new Map(cells.flatMap((cell) => {
4139
+ const record = asRecord(cell);
4140
+ const id = asString(record?.firstParagraphNodeId);
4141
+ return id ? [[id, `${asNumber(record?.rowIndex)}:${asNumber(record?.columnIndex)}`]] : [];
4142
+ }));
4143
+ const extracted = asRecord(await extractFn({}));
4144
+ const tableBlocks = (Array.isArray(extracted?.blocks) ? extracted.blocks : []).flatMap((entry) => {
4145
+ const block = asRecord(entry);
4146
+ const context = asRecord(block?.tableContext);
4147
+ const nodeId = asString(block?.nodeId);
4148
+ const extractOrdinal = asNumber(context?.tableOrdinal);
4149
+ if (!nodeId || extractOrdinal == null)
4150
+ return [];
4151
+ return [
4152
+ {
4153
+ extractOrdinal,
4154
+ paragraph: {
4155
+ nodeId,
4156
+ text: asString(block?.text) ?? '',
4157
+ rowIndex: asNumber(context?.rowIndex) ?? -1,
4158
+ columnIndex: asNumber(context?.columnIndex) ?? -1,
4159
+ },
4160
+ },
4161
+ ];
4162
+ });
4163
+ const ownOrdinal = tableBlocks.find((entry) => ownParagraphIds.get(entry.paragraph.nodeId) === `${entry.paragraph.rowIndex}:${entry.paragraph.columnIndex}`)?.extractOrdinal;
4164
+ if (ownOrdinal == null)
4165
+ return [];
4166
+ return tableBlocks.filter((entry) => entry.extractOrdinal === ownOrdinal).map((entry) => entry.paragraph);
4167
+ }
4168
+ async function verifyStyledTableHeader(doc, headerParagraphs, accent) {
4169
+ const check = { kind: 'table-header-styled', fill: accent };
4170
+ const withText = headerParagraphs.filter((paragraph) => paragraph.text.trim().length > 0);
4171
+ if (withText.length === 0) {
4172
+ return { check, passed: headerParagraphs.length > 0, detail: 'header row has no text to read back' };
4173
+ }
4174
+ const queryFn = maybeMethod(doc, ['query', 'match']);
4175
+ if (!queryFn)
4176
+ return { check, passed: false, detail: 'doc.query.match is unavailable; header styling unverified' };
4177
+ const unreadable = [];
4178
+ for (const paragraph of withText) {
4179
+ const runs = await readParagraphRunStyles(queryFn, paragraph);
4180
+ const readable = runs.length > 0 && runs.every((run) => run.bold && !!run.color && contrastRatio(run.color, accent) >= 4.5);
4181
+ if (!readable)
4182
+ unreadable.push(paragraph.text.trim());
4183
+ }
4184
+ return {
4185
+ check,
4186
+ passed: unreadable.length === 0,
4187
+ detail: unreadable.length === 0
4188
+ ? `${withText.length} header cell(s) bold with text contrasting ${accent}`
4189
+ : `header text not bold or not contrasting ${accent}: ${unreadable.join(', ')}`,
4190
+ };
4191
+ }
4192
+ async function readParagraphRunStyles(queryFn, paragraph) {
4193
+ const result = asRecord(await queryFn({ select: { type: 'text', pattern: paragraph.text.trim() } }));
4194
+ const items = Array.isArray(result?.items) ? result.items : [];
4195
+ return items.flatMap((item) => {
4196
+ const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
4197
+ const block = blocks.map((entry) => asRecord(entry)).find((entry) => asString(entry?.blockId) === paragraph.nodeId);
4198
+ const runs = Array.isArray(block?.runs) ? block.runs : [];
4199
+ return runs.map((run) => {
4200
+ const styles = asRecord(asRecord(run)?.styles);
4201
+ const effective = asRecord(styles?.effective);
4202
+ return { bold: effective?.bold === true, color: asString(effective?.color) ?? asString(styles?.color) };
4203
+ });
4204
+ });
4205
+ }
4206
+ const PAGE_WHITE = '#FFFFFF';
4207
+ const READABLE_CONTRAST = 4.5;
4208
+ async function accentReadsOnLabel(doc, paragraph, accent) {
4209
+ if (contrastRatio(accent, PAGE_WHITE) < READABLE_CONTRAST)
4210
+ return false;
4211
+ const queryFn = maybeMethod(doc, ['query', 'match']);
4212
+ if (!queryFn)
4213
+ return false;
4214
+ try {
4215
+ const runs = await readParagraphRunStyles(queryFn, paragraph);
4216
+ return runs.every((run) => !run.color || contrastRatio(run.color, PAGE_WHITE) >= READABLE_CONTRAST);
4217
+ }
4218
+ catch {
4219
+ return false;
4220
+ }
4221
+ }
4222
+ function contrastRatio(first, second) {
4223
+ const luminance = (hex) => {
4224
+ const raw = hex.replace(/^#/, '');
4225
+ if (!/^[0-9a-f]{6}$/i.test(raw))
4226
+ return null;
4227
+ const [red, green, blue] = [0, 2, 4].map((offset) => {
4228
+ const channel = Number.parseInt(raw.slice(offset, offset + 2), 16) / 255;
4229
+ return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
4230
+ });
4231
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
4232
+ };
4233
+ const left = luminance(first);
4234
+ const right = luminance(second);
4235
+ if (left == null || right == null)
4236
+ return 0;
4237
+ return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
4238
+ }
3972
4239
  /**
3973
4240
  * move_table — relocate a WHOLE table (with all its content) in ONE call.
3974
4241
  * Wraps doc.tables.move; `resolvePlacement` returns exactly the destination
@@ -4485,7 +4752,7 @@ function trackedChangeRefIds(result) {
4485
4752
  return entityId ? [entityId] : [];
4486
4753
  }));
4487
4754
  }
4488
- function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
4755
+ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision, granularity) {
4489
4756
  if (!isRecord(args.selector))
4490
4757
  return null;
4491
4758
  return {
@@ -4494,8 +4761,21 @@ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
4494
4761
  caseSensitive: args.caseSensitive === true,
4495
4762
  expectedRevision,
4496
4763
  changeMode,
4764
+ ...(granularity !== undefined ? { granularity } : {}),
4497
4765
  };
4498
4766
  }
4767
+ const REPLACEMENT_GRANULARITIES = ['word', 'phrase', 'range'];
4768
+ function parseReplacementGranularity(value) {
4769
+ if (value === undefined)
4770
+ return undefined;
4771
+ const granularity = REPLACEMENT_GRANULARITIES.find((candidate) => candidate === value);
4772
+ if (granularity === undefined) {
4773
+ throw new SuperDocCliError('replace_text: granularity must be "word", "phrase" or "range"', {
4774
+ code: 'INVALID_ARGUMENT',
4775
+ });
4776
+ }
4777
+ return granularity;
4778
+ }
4499
4779
  function assertExpectedReplacementRevision(expected, actual) {
4500
4780
  if (expected != null && expected !== actual) {
4501
4781
  throw new SuperDocCliError('replace_text: expectedRevision is stale. Inspect this live document again before editing.', {
@@ -4539,7 +4819,7 @@ async function fieldResultRanges(doc, nodeId, blockText) {
4539
4819
  }
4540
4820
  return ranges;
4541
4821
  }
4542
- async function runScopedReplaceUnchecked(doc, args) {
4822
+ async function resolveScopedReplaceTarget(doc, args) {
4543
4823
  assertReplacementTextPayloads(args.edits);
4544
4824
  const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4545
4825
  assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
@@ -4547,20 +4827,28 @@ async function runScopedReplaceUnchecked(doc, args) {
4547
4827
  if (matched.length !== 1) {
4548
4828
  const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
4549
4829
  return {
4550
- status: 'failed',
4551
- intent: 'replace_text',
4552
- errors: [{ code: 'SELECTOR_NOT_UNIQUE', message: failure.message, recovery: failure.recovery }],
4830
+ receipt: {
4831
+ status: 'failed',
4832
+ intent: 'replace_text',
4833
+ errors: [{ code: 'SELECTOR_NOT_UNIQUE', message: failure.message, recovery: failure.recovery }],
4834
+ },
4553
4835
  };
4554
4836
  }
4555
4837
  const nodeId = matched[0];
4556
4838
  const block = findTextTarget(pre, nodeId);
4557
4839
  if (!block) {
4558
4840
  return {
4559
- status: 'failed',
4560
- intent: 'replace_text',
4561
- errors: [{ code: 'TARGET_NOT_FOUND', message: `resolved block ${nodeId} missing from snapshot` }],
4841
+ receipt: {
4842
+ status: 'failed',
4843
+ intent: 'replace_text',
4844
+ errors: [{ code: 'TARGET_NOT_FOUND', message: `resolved block ${nodeId} missing from snapshot` }],
4845
+ },
4562
4846
  };
4563
4847
  }
4848
+ return { pre, nodeId, block };
4849
+ }
4850
+ const ENGINE_MAX_PLAN_STEPS = 200;
4851
+ function planScopedReplaceSpans(args, { pre, nodeId, block }) {
4564
4852
  const caseSensitive = args.caseSensitive === true;
4565
4853
  const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
4566
4854
  const perEdit = [];
@@ -4569,9 +4857,11 @@ async function runScopedReplaceUnchecked(doc, args) {
4569
4857
  const matches = findRanges(block.text, edit.find, caseSensitive);
4570
4858
  const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
4571
4859
  if (hasExplicitOccurrence && spans.length === 0) {
4572
- return failedReceipt('replace_text', new SuperDocCliError(edit.occurrence == null
4573
- ? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
4574
- : `replace_text: ${JSON.stringify(edit.find)} occurrence ${edit.occurrence} not found in the selected block (${matches.length} matches). Inspect and choose a valid target.`, { code: 'MATCH_NOT_FOUND' }), pre);
4860
+ return {
4861
+ receipt: failedReceipt('replace_text', new SuperDocCliError(edit.occurrence == null
4862
+ ? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
4863
+ : `replace_text: ${JSON.stringify(edit.find)} occurrence ${edit.occurrence} not found in the selected block (${matches.length} matches). Inspect and choose a valid target.`, { code: 'MATCH_NOT_FOUND' }), pre),
4864
+ };
4575
4865
  }
4576
4866
  perEdit.push({
4577
4867
  find: edit.find,
@@ -4582,22 +4872,23 @@ async function runScopedReplaceUnchecked(doc, args) {
4582
4872
  allSpans.push({ ...span, replace: edit.replace, find: edit.find, editIndex: perEdit.length - 1 });
4583
4873
  }
4584
4874
  }
4585
- const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
4586
4875
  const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
4587
4876
  if (allSpans.length === 0) {
4588
4877
  const cellHint = suggestTableCell(pre, args.edits.map((e) => e.find));
4589
4878
  return {
4590
- status: 'failed',
4591
- intent: 'replace_text',
4592
- selectedBlock: { nodeId, nodeType: block.nodeType, text: snippetAround(block.text) },
4593
- errors: [
4594
- {
4595
- code: 'MATCH_NOT_FOUND',
4596
- message: `none of the finds occur in the selected block. Block text: ${JSON.stringify(snippetAround(block.text, 160))}` +
4597
- (cellHint != null ? ` ${cellHint.message}` : ''),
4598
- ...(cellHint != null ? { recovery: { kind: 'retry', selector: cellHint.selector } } : {}),
4599
- },
4600
- ],
4879
+ receipt: {
4880
+ status: 'failed',
4881
+ intent: 'replace_text',
4882
+ selectedBlock: { nodeId, nodeType: block.nodeType, text: snippetAround(block.text) },
4883
+ errors: [
4884
+ {
4885
+ code: 'MATCH_NOT_FOUND',
4886
+ message: `none of the finds occur in the selected block. Block text: ${JSON.stringify(snippetAround(block.text, 160))}` +
4887
+ (cellHint != null ? ` ${cellHint.message}` : ''),
4888
+ ...(cellHint != null ? { recovery: { kind: 'retry', selector: cellHint.selector } } : {}),
4889
+ },
4890
+ ],
4891
+ },
4601
4892
  };
4602
4893
  }
4603
4894
  // Overlapping spans (e.g. finds that contain each other) cannot be applied
@@ -4608,8 +4899,16 @@ async function runScopedReplaceUnchecked(doc, args) {
4608
4899
  let lastEnd = -1;
4609
4900
  for (const span of allSpans) {
4610
4901
  if (span.start < lastEnd) {
4902
+ const covering = planned[planned.length - 1];
4903
+ if (args.granularity !== undefined && !hasExplicitOccurrence && covering) {
4904
+ return {
4905
+ receipt: failedReceipt('replace_text', new SuperDocCliError(`replace_text: the finds ${JSON.stringify(covering.find)} and ${JSON.stringify(span.find)} overlap in the same block; nothing was changed. Send non-overlapping finds, or one find covering both.`, { code: 'INVALID_ARGUMENT' }), pre),
4906
+ };
4907
+ }
4611
4908
  if (hasExplicitOccurrence) {
4612
- return failedReceipt('replace_text', new SuperDocCliError('replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.', { code: 'INVALID_ARGUMENT' }), pre);
4909
+ return {
4910
+ receipt: failedReceipt('replace_text', new SuperDocCliError('replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.', { code: 'INVALID_ARGUMENT' }), pre),
4911
+ };
4613
4912
  }
4614
4913
  droppedOverlaps.push(span);
4615
4914
  continue;
@@ -4634,10 +4933,27 @@ async function runScopedReplaceUnchecked(doc, args) {
4634
4933
  span.end += 1;
4635
4934
  perEdit[span.editIndex].collapsedSpace = true;
4636
4935
  });
4936
+ const replacements = planned.filter((span) => block.text.slice(span.start, span.end) !== span.replace).length;
4937
+ if (args.granularity !== undefined && !hasExplicitOccurrence && replacements > ENGINE_MAX_PLAN_STEPS) {
4938
+ return {
4939
+ receipt: failedReceipt('replace_text', new SuperDocCliError(`replace_text: ${replacements} granular replacements in one block exceed the ${ENGINE_MAX_PLAN_STEPS}-step plan limit; nothing was changed. Narrow the selector, or run the edit in parts, giving each part's edits explicit occurrence numbers.`, { code: 'INVALID_ARGUMENT' }), pre),
4940
+ };
4941
+ }
4637
4942
  // The block text once every planned span is replaced: the whole-block
4638
4943
  // rewrite writes it, and exact-target writes must produce it.
4639
4944
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4640
- const tracked = (args.changeMode ?? 'direct') === 'tracked';
4945
+ return {
4946
+ perEdit,
4947
+ skippedEdits,
4948
+ planned,
4949
+ droppedOverlaps,
4950
+ expected,
4951
+ hasExplicitOccurrence,
4952
+ caseSensitive,
4953
+ };
4954
+ }
4955
+ async function writesExactScopedTargets(doc, { nodeId, block }, plan, tracked, granular) {
4956
+ const { planned, caseSensitive, hasExplicitOccurrence } = plan;
4641
4957
  // Unique tracked finds can be resolved within the block. Repeated finds
4642
4958
  // use one whole-block rewrite, whose engine diff preserves unchanged runs.
4643
4959
  // Dedupe on the same basis the planner matched on: under a case-sensitive
@@ -4656,66 +4972,96 @@ async function runScopedReplaceUnchecked(doc, args) {
4656
4972
  const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc, nodeId, block.text) : [];
4657
4973
  const fieldDeletionRisk = fieldRanges == null ||
4658
4974
  deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
4659
- const exactTargets = !fieldDeletionRisk && (hasExplicitOccurrence || (tracked && uniqueFinds.size === planned.length));
4660
- let result = null;
4661
- let wroteExactTargets = false;
4662
- if (exactTargets) {
4663
- // A span already equal to its replacement has nothing to write, and the
4664
- // engine refuses a no-op target, which would sink its sibling edits.
4665
- const targetedSpans = [...planned]
4666
- .reverse()
4667
- .filter((span) => block.text.slice(span.start, span.end) !== span.replace);
4668
- const steps = targetedSpans.map((span, index) => ({
4669
- id: `scoped-replace-${index + 1}`,
4670
- op: 'text.rewrite',
4671
- where: {
4672
- by: 'target',
4673
- target: {
4674
- kind: 'selection',
4675
- start: { kind: 'text', blockId: nodeId, offset: span.start },
4676
- end: { kind: 'text', blockId: nodeId, offset: span.end },
4677
- },
4975
+ return !fieldDeletionRisk && (hasExplicitOccurrence || granular || (tracked && uniqueFinds.size === planned.length));
4976
+ }
4977
+ async function executeExactScopedTargets(doc, target, plan, { changeMode, granularity }) {
4978
+ const { pre, nodeId, block } = target;
4979
+ const targetedSpans = [...plan.planned]
4980
+ .reverse()
4981
+ .filter((span) => block.text.slice(span.start, span.end) !== span.replace);
4982
+ const steps = targetedSpans.map((span, index) => ({
4983
+ id: `scoped-replace-${index + 1}`,
4984
+ op: 'text.rewrite',
4985
+ where: {
4986
+ by: 'target',
4987
+ target: {
4988
+ kind: 'selection',
4989
+ start: { kind: 'text', blockId: nodeId, offset: span.start },
4990
+ end: { kind: 'text', blockId: nodeId, offset: span.end },
4678
4991
  },
4679
- args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
4680
- }));
4681
- if (steps.length === 0) {
4682
- result = { noop: true };
4683
- }
4684
- else {
4685
- // The engine resolves these offsets against its own paragraph text. Check
4686
- // that each one still covers the text matched in the snapshot, so a
4687
- // projection mismatch never rewrites other text.
4688
- const preview = await previewMutations(doc, steps, args.changeMode, pre.revision);
4689
- const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord) : [];
4690
- const aligned = preview.valid === true &&
4691
- steps.every((step, index) => {
4692
- const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
4693
- const resolved = Array.isArray(resolutions) && isRecord(resolutions[0]) ? resolutions[0].text : undefined;
4694
- const span = targetedSpans[index];
4695
- return resolved === block.text.slice(span.start, span.end);
4696
- });
4697
- if (aligned) {
4698
- result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4699
- wroteExactTargets = true;
4700
- }
4701
- else if (hasExplicitOccurrence) {
4702
- return failedReceipt('replace_text', new SuperDocCliError('replace_text: the chosen occurrence does not resolve to the same text in the document. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }), pre);
4703
- }
4704
- }
4992
+ },
4993
+ args: {
4994
+ replacement: { text: span.replace },
4995
+ style: preserveRewriteStyle(),
4996
+ ...replacementGranularityArg(granularity),
4997
+ },
4998
+ }));
4999
+ if (steps.length === 0)
5000
+ return { result: { noop: true }, wroteExactTargets: false };
5001
+ const preview = await previewMutations(doc, steps, changeMode, pre.revision);
5002
+ const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord) : [];
5003
+ const aligned = preview.valid === true &&
5004
+ steps.every((step, index) => {
5005
+ const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
5006
+ const resolved = Array.isArray(resolutions) && isRecord(resolutions[0]) ? resolutions[0].text : undefined;
5007
+ const span = targetedSpans[index];
5008
+ return resolved === block.text.slice(span.start, span.end);
5009
+ });
5010
+ if (aligned) {
5011
+ return { result: await executeMutations(doc, steps, changeMode, pre.revision), wroteExactTargets: true };
4705
5012
  }
4706
- if (result == null) {
4707
- // The engine preserves runs while applying the text diff. A separate
4708
- // formatting pass would restyle unchanged text and split the undo unit.
4709
- const steps = [
4710
- {
4711
- id: 'scoped-replace-1',
4712
- op: 'text.rewrite',
4713
- where: { by: 'block', nodeType: block.nodeType, nodeId },
4714
- args: { replacement: { text: expected }, style: preserveRewriteStyle() },
4715
- },
4716
- ];
4717
- result = await executeMutations(doc, steps, args.changeMode, pre.revision);
5013
+ const planLimit = previewFailuresOf(preview).find((failure) => failure.code === 'PLAN_LIMIT_EXCEEDED');
5014
+ if (planLimit && (plan.hasExplicitOccurrence || granularity !== undefined)) {
5015
+ return { receipt: failedReceipt('replace_text', previewRefusal(planLimit), pre) };
5016
+ }
5017
+ if (plan.hasExplicitOccurrence) {
5018
+ return {
5019
+ receipt: failedReceipt('replace_text', new SuperDocCliError('replace_text: the chosen occurrence does not resolve to the same text in the document. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }), pre),
5020
+ };
5021
+ }
5022
+ if (granularity !== undefined) {
5023
+ const refusal = paragraphCrossingError(target, plan) ??
5024
+ (preview.valid !== true
5025
+ ? previewRefusal(previewFailuresOf(preview)[0])
5026
+ : new SuperDocCliError('replace_text: a find does not resolve to the same text in the document, so nothing was written. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }));
5027
+ return { receipt: failedReceipt('replace_text', refusal, pre) };
5028
+ }
5029
+ return { result: null, wroteExactTargets: false };
5030
+ }
5031
+ function paragraphCrossingError({ pre, nodeId }, plan) {
5032
+ const cell = (pre.tables ?? []).flatMap((table) => table.cells).find((candidate) => candidate.nodeId === nodeId);
5033
+ const firstParagraphEnd = cell ? cell.text.indexOf('\n') : -1;
5034
+ if (firstParagraphEnd < 0 || plan.planned.every((span) => span.end <= firstParagraphEnd))
5035
+ return null;
5036
+ return new SuperDocCliError('replace_text: granularity writes inside one paragraph, and the selected table cell holds several. Call replace_text without a selector so each find resolves to its own paragraph.', { code: 'INVALID_ARGUMENT' });
5037
+ }
5038
+ async function executeScopedRewrite(doc, target, plan, args, tracked) {
5039
+ if (await writesExactScopedTargets(doc, target, plan, tracked, args.granularity !== undefined)) {
5040
+ const exact = await executeExactScopedTargets(doc, target, plan, args);
5041
+ if ('receipt' in exact)
5042
+ return exact;
5043
+ if (exact.result != null)
5044
+ return { result: exact.result, wroteExactTargets: exact.wroteExactTargets };
5045
+ }
5046
+ const steps = [
5047
+ {
5048
+ id: 'scoped-replace-1',
5049
+ op: 'text.rewrite',
5050
+ where: { by: 'block', nodeType: target.block.nodeType, nodeId: target.nodeId },
5051
+ args: { replacement: { text: plan.expected }, style: preserveRewriteStyle() },
5052
+ },
5053
+ ];
5054
+ return { result: await executeMutations(doc, steps, args.changeMode, target.pre.revision), wroteExactTargets: false };
5055
+ }
5056
+ async function readScopedReplaceBack(doc, target, plan, execution) {
5057
+ try {
5058
+ return await readScopedReplaceBackUnchecked(doc, target, plan, execution);
4718
5059
  }
5060
+ catch (readError) {
5061
+ return { post: null, postBlock: null, rolledBack: false, readError };
5062
+ }
5063
+ }
5064
+ async function readScopedReplaceBackUnchecked(doc, { nodeId, block }, plan, execution) {
4719
5065
  let post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4720
5066
  let postBlock = findTextTarget(post, nodeId);
4721
5067
  // Exact-target edits must leave exactly the planned visible text. If the
@@ -4723,10 +5069,11 @@ async function runScopedReplaceUnchecked(doc, args) {
4723
5069
  // atomic history step. Only do so while the document is still at the
4724
5070
  // revision this plan produced, so the undo cannot reach any other change.
4725
5071
  let rolledBack = false;
5072
+ const { result } = execution;
4726
5073
  const appliedRevision = isRecord(result) && isRecord(result.revision) ? result.revision.after : undefined;
4727
- if (wroteExactTargets &&
5074
+ if (execution.wroteExactTargets &&
4728
5075
  postBlock != null &&
4729
- postBlock.text !== expected &&
5076
+ postBlock.text !== plan.expected &&
4730
5077
  postBlock.text !== block.text &&
4731
5078
  typeof appliedRevision === 'string' &&
4732
5079
  appliedRevision === post.revision) {
@@ -4738,14 +5085,23 @@ async function runScopedReplaceUnchecked(doc, args) {
4738
5085
  rolledBack = postBlock?.text === block.text;
4739
5086
  }
4740
5087
  }
5088
+ return { post, postBlock, rolledBack };
5089
+ }
5090
+ function buildScopedReplaceReceipt({ pre, nodeId, block }, plan, execution, { post, postBlock, rolledBack, readError }, tracked) {
5091
+ const { perEdit, skippedEdits, planned, droppedOverlaps, expected, hasExplicitOccurrence } = plan;
4741
5092
  // Block text is the visible (final) text. Exact-offset edits must produce
4742
5093
  // exactly the planned text; tracked edits that fell back to a whole-block
4743
5094
  // rewrite only need each inserted text to be visible.
4744
5095
  const passed = postBlock != null &&
4745
5096
  !rolledBack &&
4746
- (tracked && !hasExplicitOccurrence && !wroteExactTargets
5097
+ (tracked && !hasExplicitOccurrence && !execution.wroteExactTargets
4747
5098
  ? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace))
4748
5099
  : postBlock.text === expected);
5100
+ const readsAsReplacement = (span) => block.text.slice(span.start, span.end) === span.replace;
5101
+ const replaced = planned.filter((span) => !readsAsReplacement(span));
5102
+ const editsApplied = perEdit
5103
+ .map((edit, index) => ({ ...edit, occurrences: replaced.filter((span) => span.editIndex === index).length }))
5104
+ .filter((edit) => edit.occurrences > 0);
4749
5105
  // Partially-applied requests must NOT read as success: a model that asked
4750
5106
  // for two edits and got one will stop if the receipt says "ok".
4751
5107
  const status = !passed ? 'failed' : skippedEdits.length > 0 ? 'partial' : 'ok';
@@ -4769,24 +5125,42 @@ async function runScopedReplaceUnchecked(doc, args) {
4769
5125
  ],
4770
5126
  }
4771
5127
  : {}),
5128
+ ...(post === null ? { errors: [readBackError(readError)] } : {}),
4772
5129
  preSnapshot: { revision: pre.revision },
4773
- postSnapshot: { revision: post.revision },
5130
+ ...(post ? { postSnapshot: { revision: post.revision } } : {}),
4774
5131
  selectedBlock: { nodeId, nodeType: block.nodeType },
4775
5132
  editsRequested: perEdit.length,
4776
- editsApplied: appliedEdits,
5133
+ editsApplied,
5134
+ ...unchangedEditsEvidence(planned.filter(readsAsReplacement)),
4777
5135
  ...(skippedEdits.length > 0
4778
5136
  ? { editsSkipped: skippedEdits.map((e) => ({ find: e.find, reason: 'not present in selected block' })) }
4779
5137
  : {}),
4780
5138
  ...(droppedOverlaps.length > 0 ? { overlapsDropped: droppedOverlaps.map((s) => s.find) } : {}),
4781
- replacedSpans: planned.length,
5139
+ replacedSpans: replaced.length,
4782
5140
  postText: snippetAround(postBlock?.text ?? ''),
4783
- executedOperations: [{ operationId: 'doc.mutations.apply', result: compactOpResult(result) }],
5141
+ executedOperations: [{ operationId: 'doc.mutations.apply', result: compactOpResult(execution.result) }],
4784
5142
  verification: [
4785
5143
  { check: { kind: 'block-text-matches-expectation' }, passed },
4786
- { check: { kind: 'revision-changed' }, passed: pre.revision !== post.revision },
5144
+ post
5145
+ ? { check: { kind: 'revision-changed' }, passed: pre.revision !== post.revision }
5146
+ : readBackFailure(readError),
4787
5147
  ],
4788
5148
  };
4789
5149
  }
5150
+ async function runScopedReplaceUnchecked(doc, args) {
5151
+ const target = await resolveScopedReplaceTarget(doc, args);
5152
+ if ('receipt' in target)
5153
+ return target.receipt;
5154
+ const plan = planScopedReplaceSpans(args, target);
5155
+ if ('receipt' in plan)
5156
+ return plan.receipt;
5157
+ const tracked = (args.changeMode ?? 'direct') === 'tracked';
5158
+ const execution = await executeScopedRewrite(doc, target, plan, args, tracked);
5159
+ if ('receipt' in execution)
5160
+ return execution.receipt;
5161
+ const readBack = await readScopedReplaceBack(doc, target, plan, execution);
5162
+ return buildScopedReplaceReceipt(target, plan, execution, readBack, tracked);
5163
+ }
4790
5164
  async function runScopedReplace(doc, args) {
4791
5165
  const changeMode = args.changeMode ?? 'direct';
4792
5166
  try {
@@ -4807,48 +5181,6 @@ async function runScopedReplace(doc, args) {
4807
5181
  */
4808
5182
  /** Points → twips: the node read side reports points, mutations take twips. */
4809
5183
  const TWIPS_PER_POINT = 20;
4810
- /**
4811
- * Build `format.paragraph.setMarkRunProps` input from agent run marks.
4812
- *
4813
- * The mark's colour is a discriminated model object, not the plain hex string
4814
- * `format.apply` takes, and the hex is stored without the leading `#`.
4815
- * Returns null when the marks say nothing about the mark's appearance.
4816
- */
4817
- function markRunPropsFromMarks(marks) {
4818
- if (!marks)
4819
- return null;
4820
- const props = {};
4821
- if (typeof marks.bold === 'boolean')
4822
- props.bold = marks.bold;
4823
- if (typeof marks.italic === 'boolean')
4824
- props.italic = marks.italic;
4825
- if (typeof marks.fontFamily === 'string' && marks.fontFamily)
4826
- props.fontFamily = marks.fontFamily;
4827
- if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
4828
- props.fontSize = marks.fontSize;
4829
- if (typeof marks.color === 'string' && marks.color) {
4830
- props.color = { model: 'rgb', value: marks.color.replace(/^#/, '') };
4831
- }
4832
- return Object.keys(props).length > 0 ? props : null;
4833
- }
4834
- /**
4835
- * Pull a paragraph's MARK run properties (`w:pPr/w:rPr`) out of a `getNodeById`
4836
- * payload, ready to hand straight back to `format.paragraph.setMarkRunProps`
4837
- * (same internal shape, so the values round-trip). Node-shape tolerant like
4838
- * paragraphIndentOf. Returns null when the paragraph mark carries nothing.
4839
- */
4840
- function paragraphMarkRunPropsOf(payload) {
4841
- const node = isRecord(payload) ? payload.node : null;
4842
- if (!isRecord(node))
4843
- return null;
4844
- const candidates = [isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
4845
- for (const props of candidates) {
4846
- const mark = isRecord(props) ? props.markRunProps : null;
4847
- if (isRecord(mark) && Object.keys(mark).length > 0)
4848
- return mark;
4849
- }
4850
- return null;
4851
- }
4852
5184
  /**
4853
5185
  * Delimiters that separate a list item's lead-in label from its body text.
4854
5186
  * Spaced dashes are listed separately from bare ones because the label run
@@ -5052,6 +5384,7 @@ function matchRunStyle(run) {
5052
5384
  return {
5053
5385
  bold: effective?.bold === true,
5054
5386
  italic: effective?.italic === true,
5387
+ underline: effective?.underline === true,
5055
5388
  ...(sizePt != null && Number.isFinite(sizePt) ? { fontSize: sizePt } : {}),
5056
5389
  ...(fontFamily ? { fontFamily } : {}),
5057
5390
  ...(color ? { color } : {}),
@@ -5063,15 +5396,15 @@ function matchRunStyle(run) {
5063
5396
  * A toggle that is OFF is dropped rather than sent as an explicit `false`: new
5064
5397
  * text starts unformatted, so "not bold" needs no write — and writing it would
5065
5398
  * stamp an explicit `w:b w:val="0"` the sampled siblings themselves don't carry.
5066
- * The exception is a toggle that must be CLEARED because an earlier layer
5067
- * (`against`) turned it on.
5068
5399
  */
5069
- function inlineFromMatchStyle(style, against) {
5400
+ function inlineFromMatchStyle(style) {
5070
5401
  const inline = {};
5071
- if (style.bold || against?.bold === true)
5072
- inline.bold = style.bold;
5073
- if (style.italic || against?.italic === true)
5074
- inline.italic = style.italic;
5402
+ if (style.bold)
5403
+ inline.bold = true;
5404
+ if (style.italic)
5405
+ inline.italic = true;
5406
+ if (style.underline)
5407
+ inline.underline = true;
5075
5408
  if (style.fontSize != null)
5076
5409
  inline.fontSize = style.fontSize;
5077
5410
  if (style.fontFamily)
@@ -5080,6 +5413,10 @@ function inlineFromMatchStyle(style, against) {
5080
5413
  inline.color = style.color;
5081
5414
  return inline;
5082
5415
  }
5416
+ function runPatternOf(bodyStyle, label) {
5417
+ const look = label ? emphasisedLookOf(inlineFromMatchStyle(label.style)) : null;
5418
+ return { body: baseRunLookOf(bodyStyle), ...(label && look ? { lead: { look, shape: label.shape } } : {}) };
5419
+ }
5083
5420
  /**
5084
5421
  * Read an existing list item's RUN PATTERN — the body look plus the "bold
5085
5422
  * label: plain sentence" lead-in convention its siblings share — through
@@ -5137,27 +5474,16 @@ async function listItemRunPattern(doc, text, blockId) {
5137
5474
  const label = runs[labelIndex];
5138
5475
  const labelText = runTextOf(label);
5139
5476
  const leadStyle = matchRunStyle(label);
5140
- // A source block that is ONLY a short emphasised label (a heading-ish "LLM
5141
- // Integration", a signature-block name) has no body look to copy — and
5142
- // copying its label look over a whole new block would emphasise the
5143
- // entire thing. Treat it as a lead-in whose span the new text decides.
5144
- if (runs.length === 1) {
5145
- const labelOnly = labelText.length <= LEAD_IN_MAX_CHARS && (leadStyle.bold || leadStyle.italic);
5146
- return labelOnly
5147
- ? { body: {}, lead: { look: inlineFromMatchStyle(leadStyle), shape: { kind: 'before', delimiter: null } } }
5148
- : { body: inlineFromMatchStyle(leadStyle) };
5149
- }
5477
+ if (runs.length === 1)
5478
+ return runPatternOf(leadStyle);
5150
5479
  // The body is the run the label INTRODUCES, not the longest run in the
5151
5480
  // block: a later emphasised phrase is part of the prose, and sampling it
5152
5481
  // would stamp its emphasis across the whole new block.
5153
5482
  const bodyStyle = matchRunStyle(runs[labelIndex + 1]);
5154
- const differs = ['bold', 'italic', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
5483
+ const differs = ['bold', 'italic', 'underline', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
5155
5484
  const shape = leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
5156
5485
  const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= LEAD_IN_MAX_CHARS;
5157
- return {
5158
- body: inlineFromMatchStyle(bodyStyle),
5159
- ...(isLabel ? { lead: { look: inlineFromMatchStyle(leadStyle, bodyStyle), shape: shape } } : {}),
5160
- };
5486
+ return runPatternOf(bodyStyle, isLabel && shape ? { style: leadStyle, shape } : undefined);
5161
5487
  }
5162
5488
  }
5163
5489
  return null;
@@ -5175,10 +5501,9 @@ function typedPrefixLabelOf(runs, runTextOf) {
5175
5501
  }
5176
5502
  if (covered < markerEnd || labelIndex + 1 >= runs.length)
5177
5503
  return null;
5178
- const underlined = (run) => asRecord(asRecord(asRecord(run)?.styles)?.effective)?.underline === true;
5179
5504
  const emphasisOf = (run) => {
5180
5505
  const style = matchRunStyle(run);
5181
- return `${style.bold}/${style.italic}/${underlined(run)}`;
5506
+ return `${style.bold}/${style.italic}/${style.underline}`;
5182
5507
  };
5183
5508
  const label = runs[labelIndex];
5184
5509
  if (emphasisOf(label) === 'false/false/false')
@@ -5205,15 +5530,10 @@ function typedPrefixLabelOf(runs, runTextOf) {
5205
5530
  const delimiter = ['.', ...LEAD_IN_DELIMITERS.map((d) => d.trim())].find((d) => labelText.endsWith(d) && labelText.length > d.length);
5206
5531
  if (!delimiter)
5207
5532
  return null;
5208
- const body = runs[bodyIndex];
5209
- const leadStyle = matchRunStyle(label);
5210
- const bodyStyle = matchRunStyle(body);
5211
- const leadUnderline = underlined(label);
5212
- const bodyUnderline = underlined(body);
5213
- const look = inlineFromMatchStyle(leadStyle, bodyStyle);
5214
- if (leadUnderline || bodyUnderline)
5215
- look.underline = leadUnderline;
5216
- return { body: inlineFromMatchStyle(bodyStyle), lead: { look, shape: { kind: 'typed', delimiter } } };
5533
+ return runPatternOf(matchRunStyle(runs[bodyIndex]), {
5534
+ style: matchRunStyle(label),
5535
+ shape: { kind: 'typed', delimiter },
5536
+ });
5217
5537
  }
5218
5538
  /**
5219
5539
  * Pull a paragraph's direct indentation out of a `getNodeById` payload and
@@ -5244,12 +5564,32 @@ function paragraphIndentOf(payload) {
5244
5564
  }
5245
5565
  return null;
5246
5566
  }
5247
- function inlineLookFromRow(row) {
5567
+ function emphasisedLookOf(look) {
5568
+ return RUN_BOOL_MARKS.some((key) => look[key] === true) ? look : null;
5569
+ }
5570
+ async function effectiveBaseLookOf(doc, text, block) {
5571
+ const queryFn = maybeMethod(doc, ['query', 'match']);
5572
+ if (!queryFn || !text.trim())
5573
+ return null;
5574
+ const runs = await matchRunsForBlock(queryFn, text, block.nodeId, { nodeType: block.nodeType, fonts: 'effective' });
5575
+ const looks = runs.filter((run) => run.text).map(baseRunLookOf);
5576
+ if (looks.length === 0)
5577
+ return null;
5578
+ const [first, ...rest] = looks;
5579
+ return rest.every((look) => look.fontFamily === first.fontFamily && look.fontSize === first.fontSize)
5580
+ ? first
5581
+ : 'mixed';
5582
+ }
5583
+ function baseRunLookOf(look) {
5248
5584
  const inline = {};
5249
- if (row.fontFamily)
5250
- inline.fontFamily = row.fontFamily;
5251
- if (typeof row.fontSize === 'number')
5252
- inline.fontSize = row.fontSize;
5585
+ if (typeof look.fontFamily === 'string' && look.fontFamily)
5586
+ inline.fontFamily = look.fontFamily;
5587
+ if (typeof look.fontSize === 'number' && Number.isFinite(look.fontSize))
5588
+ inline.fontSize = look.fontSize;
5589
+ return inline;
5590
+ }
5591
+ function inlineLookFromRow(row) {
5592
+ const inline = baseRunLookOf(row);
5253
5593
  if (row.color)
5254
5594
  inline.color = row.color;
5255
5595
  if (row.bold === true)
@@ -6144,17 +6484,11 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
6144
6484
  }
6145
6485
  }
6146
6486
  }
6147
- // Same "inline look" the style-copy paths use, minus properties the
6148
- // create already produced — contextual matching only applies the DELTA.
6149
- const inline = inlineLookFromRow(reference);
6487
+ const inline = baseRunLookOf(reference);
6150
6488
  if (inline.fontFamily === created.fontFamily)
6151
6489
  delete inline.fontFamily;
6152
6490
  if (inline.fontSize === created.fontSize)
6153
6491
  delete inline.fontSize;
6154
- if (inline.color === created.color)
6155
- delete inline.color;
6156
- if (created.bold === true)
6157
- delete inline.bold;
6158
6492
  if (Object.keys(inline).length > 0) {
6159
6493
  const formatApplyFn = maybeMethod(doc, ['format', 'apply']);
6160
6494
  if (formatApplyFn) {
@@ -6195,15 +6529,14 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
6195
6529
  // created block. Skipped when the caller supplied explicit runs/marks.
6196
6530
  if (allowLeadPattern) {
6197
6531
  const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
6198
- if (lead)
6532
+ if (lead?.changed)
6199
6533
  applied.leadMarker = lead;
6200
6534
  // matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
6201
6535
  // numbered tokens ("1.", "(m)"), and it needs the reference split across
6202
6536
  // two runs. Sample the reference's RUNS for everything else: a quoted
6203
6537
  // defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
6204
- // marker and label share a run, and — the case nothing covered — a
6205
- // UNIFORM reference, where the whole look transfers (a bold signature-block
6206
- // name beside which the new one landed plain).
6538
+ // marker and label share a run. A UNIFORM reference is not a split, so
6539
+ // its emphasis never transfers.
6207
6540
  else {
6208
6541
  const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
6209
6542
  // Assigning `undefined` would still create the key, and an `applied` with
@@ -6268,10 +6601,9 @@ const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
6268
6601
  * created text starts with the reference's exact lead text;
6269
6602
  * - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
6270
6603
  * the created lead is its OWN token matched by shape.
6271
- * Applies the reference lead's marks to the created lead span and the
6272
- * reference body's marks to the rest — with explicit false for boolean marks
6273
- * only one side has, so formatting inherited from a mis-styled anchor is
6274
- * corrected, not compounded.
6604
+ * Applies the reference lead's marks to the created lead span and only the
6605
+ * reference body's font and size to the rest: the body never gains emphasis or
6606
+ * an explicit mark-off it did not already carry.
6275
6607
  */
6276
6608
  async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6277
6609
  const createdPreview = created.textPreview ?? '';
@@ -6331,21 +6663,16 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6331
6663
  }
6332
6664
  if (createdLeadLen === 0)
6333
6665
  return null;
6334
- const normalize = new Set(RUN_BOOL_MARKS.filter((key) => leadRun[key] !== bodyRun[key]));
6335
- const markerInline = inlineFromSnapshotRun(leadRun, normalize);
6336
- const bodyInline = inlineFromSnapshotRun(bodyRun, normalize);
6337
- if (Object.keys(markerInline).length === 0 && Object.keys(bodyInline).length === 0)
6666
+ const markerInline = emphasisedLookOf(inlineFromSnapshotRun(leadRun));
6667
+ if (!markerInline)
6338
6668
  return null;
6339
- let changed = false;
6340
- if (Object.keys(markerInline).length > 0) {
6341
- changed =
6342
- (await applyInlineQuiet(formatFn, {
6343
- blockId: created.nodeId,
6344
- start: 0,
6345
- end: createdLeadLen,
6346
- inline: markerInline,
6347
- })) || changed;
6348
- }
6669
+ const bodyInline = baseRunLookOf(bodyRun);
6670
+ let changed = await applyInlineQuiet(formatFn, {
6671
+ blockId: created.nodeId,
6672
+ start: 0,
6673
+ end: createdLeadLen,
6674
+ inline: markerInline,
6675
+ });
6349
6676
  if (Object.keys(bodyInline).length > 0 && fullText.length > createdLeadLen) {
6350
6677
  changed =
6351
6678
  (await applyInlineQuiet(formatFn, {
@@ -6358,14 +6685,15 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6358
6685
  return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
6359
6686
  }
6360
6687
  /**
6361
- * Copy a reference block's RUN pattern onto a created block: the body look over
6362
- * the whole block, then the label look over the new block's own label span.
6688
+ * Copy a reference block's RUN pattern onto a created block: the body's font
6689
+ * and size over the whole block, then the label look over the new block's own
6690
+ * label span.
6363
6691
  *
6364
6692
  * Complements matchLeadMarkerPattern, which requires either a shared literal
6365
6693
  * lead word or a marker token AND a two-run reference. This one reads the
6366
6694
  * reference's runs (`query.match`, the only read that reports run styling) and
6367
- * handles the shapes that leaves out — including a reference with a single
6368
- * uniform run, whose look simply transfers.
6695
+ * handles the shapes that leaves out. A single uniform run has no label, so
6696
+ * nothing but its font transfers.
6369
6697
  */
6370
6698
  async function matchBlockRunPattern(doc, reference, created, createdText) {
6371
6699
  const formatFn = maybeMethod(doc, ['format', 'apply']);
@@ -6378,19 +6706,25 @@ async function matchBlockRunPattern(doc, reference, created, createdText) {
6378
6706
  return null;
6379
6707
  const end = Math.max(fullText.length, 1);
6380
6708
  const applied = {};
6381
- if (Object.keys(pattern.body).length > 0) {
6382
- if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: pattern.body })) {
6383
- applied.body = pattern.body;
6709
+ const sampled = pattern.body;
6710
+ const own = Object.keys(sampled).length > 0
6711
+ ? await effectiveBaseLookOf(doc, fullText, { nodeId: created.nodeId, nodeType: created.nodeType })
6712
+ : null;
6713
+ const body = own === 'mixed'
6714
+ ? {}
6715
+ : own
6716
+ ? Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value))
6717
+ : sampled;
6718
+ if (Object.keys(body).length > 0) {
6719
+ if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: body })) {
6720
+ applied.body = body;
6384
6721
  }
6385
6722
  }
6386
6723
  if (pattern.lead) {
6387
- const span = leadInSpanOf(fullText, pattern.lead.shape);
6388
- // A uniform reference has no body look and no delimiter of its own: its look
6389
- // belongs to the whole created block, not to a label span inside it.
6390
- const range = span ??
6391
- (Object.keys(pattern.body).length === 0 && pattern.lead.shape.kind !== 'typed' ? { start: 0, end } : null);
6392
- if (range && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...range, inline: pattern.lead.look }))) {
6393
- applied.lead = { ...pattern.lead.look, range: [range.start, range.end] };
6724
+ const { look, shape } = pattern.lead;
6725
+ const span = leadInSpanOf(fullText, shape);
6726
+ if (span && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...span, inline: look }))) {
6727
+ applied.lead = { ...look, range: [span.start, span.end] };
6394
6728
  }
6395
6729
  }
6396
6730
  return Object.keys(applied).length > 0 ? { matchedSibling: reference.nodeId, ...applied } : null;
@@ -6745,25 +7079,26 @@ async function dispatchSuperdocPerformAction(doc, args) {
6745
7079
  });
6746
7080
  }
6747
7081
  const changeMode = parseReplaceTextChangeMode(args.changeMode);
7082
+ const parsedGranularity = parseReplacementGranularity(args.granularity);
7083
+ const granularity = changeMode === 'tracked' ? parsedGranularity : undefined;
6748
7084
  // Selector-scoped replaces take the span-targeted path: per-find
6749
7085
  // text.rewrite spans inside the one selected block, so tabs/breaks/
6750
7086
  // images outside the spans survive and the receipt reports per-edit
6751
7087
  // applied/skipped truth.
6752
- const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
7088
+ const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision, granularity);
6753
7089
  if (scoped) {
6754
7090
  return runScopedReplace(doc, scoped);
6755
7091
  }
6756
- const selector = args.selector != null ? parseSelector(args.selector) : undefined;
6757
- if (args.selector != null && selector == null) {
7092
+ if (args.selector != null) {
6758
7093
  throw new SuperDocCliError('replace_text: invalid "selector"', { code: 'INVALID_ARGUMENT' });
6759
7094
  }
6760
7095
  return runReplaceText(doc, {
6761
7096
  action,
6762
7097
  edits,
6763
- selector,
6764
7098
  caseSensitive: args.caseSensitive === true,
6765
7099
  expectedRevision,
6766
7100
  changeMode,
7101
+ ...(granularity !== undefined ? { granularity } : {}),
6767
7102
  });
6768
7103
  }
6769
7104
  case 'delete_text': {
@@ -7407,9 +7742,30 @@ function parseReplaceTextChangeMode(value) {
7407
7742
  });
7408
7743
  }
7409
7744
  function parseTrackedChangeKind(value) {
7410
- if (value === 'insert' || value === 'delete' || value === 'replacement' || value === 'format')
7411
- return value;
7412
- return undefined;
7745
+ return typeof value === 'string' ? trackedChangeKindOf(value) : undefined;
7746
+ }
7747
+ const TRACKED_CHANGE_KIND_BY_LIST_TYPE = {
7748
+ insert: 'insert',
7749
+ insertion: 'insert',
7750
+ delete: 'delete',
7751
+ deletion: 'delete',
7752
+ replacement: 'replacement',
7753
+ format: 'format',
7754
+ formatting: 'format',
7755
+ };
7756
+ function trackedChangeKindOf(type) {
7757
+ return type !== undefined && Object.hasOwn(TRACKED_CHANGE_KIND_BY_LIST_TYPE, type)
7758
+ ? TRACKED_CHANGE_KIND_BY_LIST_TYPE[type]
7759
+ : undefined;
7760
+ }
7761
+ function countTrackedChangesByKind(items) {
7762
+ const counts = {};
7763
+ for (const item of items) {
7764
+ const type = asString(item?.type);
7765
+ const kind = trackedChangeKindOf(type) ?? (type || 'unknown');
7766
+ counts[kind] = (counts[kind] ?? 0) + 1;
7767
+ }
7768
+ return counts;
7413
7769
  }
7414
7770
  function parseStringArray(value) {
7415
7771
  if (!Array.isArray(value))