@superdoc/sdk 2.25.0-next.4 → 2.25.0-next.5

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