@aefree/pi-plastic 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +48 -174
  3. package/docs/2026-10-06-development.md +101 -0
  4. package/docs/2026-10-06-diffs-and-patches.md +136 -0
  5. package/docs/2026-10-06-discovery.md +135 -0
  6. package/docs/2026-10-06-integrations.md +49 -0
  7. package/docs/2026-10-06-merging.md +153 -0
  8. package/docs/2026-10-06-safety-and-receipts.md +95 -0
  9. package/docs/2026-10-06-setup.md +101 -0
  10. package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
  11. package/docs/2026-10-06-status.md +116 -0
  12. package/docs/2026-10-06-tools-and-loading.md +64 -0
  13. package/docs/2026-10-06-workspace-operations.md +142 -0
  14. package/docs/README.md +65 -0
  15. package/extensions/bash-cm-diff-guard.ts +1 -1
  16. package/index.ts +4 -687
  17. package/package.json +12 -6
  18. package/skills/using-plastic/SKILL.md +11 -5
  19. package/skills/using-plastic/references/branch-management.md +5 -1
  20. package/skills/using-plastic/references/changeset-operations.md +9 -8
  21. package/skills/using-plastic/references/code-review-creation.md +25 -18
  22. package/skills/using-plastic/references/integration.md +9 -5
  23. package/skills/using-plastic/references/quick-reference.md +4 -4
  24. package/skills/using-plastic/references/reviewing-changes.md +10 -10
  25. package/skills/using-plastic/references/shelving.md +26 -11
  26. package/skills/using-plastic/references/troubleshooting.md +4 -4
  27. package/src/diff/local-snapshot.ts +74 -0
  28. package/src/diff/text.ts +187 -0
  29. package/src/domain/add-contract.ts +24 -0
  30. package/src/domain/branch-create-contract.ts +38 -0
  31. package/src/domain/branch-delete-contract.ts +15 -0
  32. package/src/domain/branches.ts +263 -0
  33. package/src/domain/checkin-contract.ts +70 -0
  34. package/src/domain/closeout-contract.ts +30 -0
  35. package/src/domain/copied-merge.ts +42 -0
  36. package/src/domain/diff-base-xml.ts +65 -0
  37. package/src/domain/diff-pending-xml.ts +34 -0
  38. package/src/domain/merge-output.ts +327 -0
  39. package/src/domain/paths.ts +253 -0
  40. package/src/domain/pending.ts +499 -0
  41. package/src/domain/removal-contract.ts +25 -0
  42. package/src/domain/revisions.ts +301 -0
  43. package/src/domain/server-merge-contract.ts +45 -0
  44. package/src/domain/server-merge-resolutions.ts +61 -0
  45. package/src/domain/status-xml.ts +122 -0
  46. package/src/domain/switch-contract.ts +60 -0
  47. package/src/domain/switch-policy.ts +95 -0
  48. package/src/domain/undo-contract.ts +24 -0
  49. package/src/domain/update-contract.ts +16 -0
  50. package/src/domain/workspace-merge-contract.ts +41 -0
  51. package/src/execution/add-command.ts +118 -0
  52. package/src/execution/branch-create-command.ts +118 -0
  53. package/src/execution/branch-list-command.ts +75 -0
  54. package/src/execution/checkin-command.ts +116 -0
  55. package/src/execution/cli-version.ts +31 -0
  56. package/src/execution/cm.ts +59 -0
  57. package/src/execution/code-review-find-command.ts +75 -0
  58. package/src/execution/context.ts +20 -0
  59. package/src/execution/diff-revisions-command.ts +74 -0
  60. package/src/execution/process.ts +260 -0
  61. package/src/execution/removal-command.ts +118 -0
  62. package/src/execution/server-merge-command.ts +92 -0
  63. package/src/execution/shelveset-list-command.ts +75 -0
  64. package/src/execution/status-command.ts +29 -0
  65. package/src/execution/status-xml-command.ts +72 -0
  66. package/src/execution/status-xml-limits.ts +1 -0
  67. package/src/execution/switch-command.ts +118 -0
  68. package/src/execution/test-seams.ts +10 -0
  69. package/src/execution/undo-command.ts +118 -0
  70. package/src/execution/update-command.ts +118 -0
  71. package/src/execution/workspace-list-command.ts +75 -0
  72. package/src/execution/workspace-merge-command.ts +118 -0
  73. package/src/operations/add-receipt.ts +37 -0
  74. package/src/operations/arguments.ts +3 -0
  75. package/src/operations/branch-create.ts +74 -0
  76. package/src/operations/branch-delete-receipt.ts +25 -0
  77. package/src/operations/branch-list.ts +94 -0
  78. package/src/operations/branch-reads.ts +94 -0
  79. package/src/operations/branches.ts +111 -0
  80. package/src/operations/checkin-receipt.ts +140 -0
  81. package/src/operations/checkin.ts +102 -0
  82. package/src/operations/closeout-receipt.ts +74 -0
  83. package/src/operations/closeout.ts +19 -0
  84. package/src/operations/code-review-find.ts +97 -0
  85. package/src/operations/consolidated-diff.ts +144 -0
  86. package/src/operations/diff-revisions.ts +157 -0
  87. package/src/operations/diff.ts +53 -0
  88. package/src/operations/merge.ts +26 -0
  89. package/src/operations/object-delete-receipt.ts +21 -0
  90. package/src/operations/object-write-receipt.ts +105 -0
  91. package/src/operations/patch-receipt.ts +96 -0
  92. package/src/operations/patch-support.ts +375 -0
  93. package/src/operations/patch.ts +25 -0
  94. package/src/operations/removal-receipt.ts +42 -0
  95. package/src/operations/reviews.ts +90 -0
  96. package/src/operations/server-merge.ts +130 -0
  97. package/src/operations/shelveset-list.ts +90 -0
  98. package/src/operations/shelvesets.ts +90 -0
  99. package/src/operations/status.ts +129 -0
  100. package/src/operations/switch-receipt.ts +85 -0
  101. package/src/operations/switch.ts +25 -0
  102. package/src/operations/undo-receipt.ts +37 -0
  103. package/src/operations/update-receipt.ts +35 -0
  104. package/src/operations/workspace-list.ts +92 -0
  105. package/src/operations/workspace-merge-receipt.ts +50 -0
  106. package/src/operations/workspace.ts +120 -0
  107. package/src/pi/add-output.ts +52 -0
  108. package/src/pi/arguments.ts +298 -0
  109. package/src/pi/branch-create-output.ts +83 -0
  110. package/src/pi/branch-delete-output.ts +46 -0
  111. package/src/pi/branch-list-output.ts +59 -0
  112. package/src/pi/branch-output.ts +63 -0
  113. package/src/pi/capability-diagnostics.ts +23 -0
  114. package/src/pi/checkin-output.ts +68 -0
  115. package/src/pi/closeout-output.ts +87 -0
  116. package/src/pi/code-review-find-output.ts +50 -0
  117. package/src/pi/diff-output.ts +66 -0
  118. package/src/pi/diff-revisions-output.ts +55 -0
  119. package/src/pi/object-delete-output.ts +15 -0
  120. package/src/pi/object-write-output.ts +54 -0
  121. package/src/pi/patch-output.ts +69 -0
  122. package/src/pi/register.ts +188 -0
  123. package/src/pi/removal-output.ts +59 -0
  124. package/src/pi/schemas.ts +146 -0
  125. package/src/pi/server-merge-output.ts +83 -0
  126. package/src/pi/shelveset-list-output.ts +50 -0
  127. package/src/pi/status-output.ts +149 -0
  128. package/src/pi/switch-output.ts +80 -0
  129. package/src/pi/tool-registry.ts +72 -0
  130. package/src/pi/undo-output.ts +52 -0
  131. package/src/pi/update-output.ts +48 -0
  132. package/src/pi/workspace-list-output.ts +53 -0
  133. package/src/pi/workspace-merge-output.ts +46 -0
  134. package/src/plastic-core.ts +14 -5775
  135. package/src/plastic-renderers.ts +102 -22
  136. package/src/plastic-tool-loading.ts +7 -12
  137. package/src/presentation/add-results.ts +5 -0
  138. package/src/presentation/branch-create.ts +7 -0
  139. package/src/presentation/branch-delete-results.ts +7 -0
  140. package/src/presentation/checkin-results.ts +14 -0
  141. package/src/presentation/closeout-results.ts +2 -0
  142. package/src/presentation/diff-results.ts +237 -0
  143. package/src/presentation/removal-results.ts +7 -0
  144. package/src/presentation/results.ts +84 -0
  145. package/src/presentation/switch-results.ts +7 -0
  146. package/src/presentation/undo-results.ts +5 -0
  147. package/src/presentation/update-results.ts +5 -0
  148. package/src/presentation/workspace-merge-results.ts +6 -0
@@ -13,8 +13,7 @@ const separator = " \u00b7 ";
13
13
 
14
14
  const titles: Record<string, string> = {
15
15
  status: "Status", currentBranch: "Current branch", update: "Update", add: "Add", checkin: "Check in", undo: "Undo",
16
- resolveDeleteChangeConflict: "Resolve deletion", diff: "Diff (disabled)", patch: "Review patch", diffRevisions: "Revision diff",
17
- diffFile: "File diff", workspaceDiff: "Workspace diff", branchCreate: "Create branch", switchBranch: "Switch branch",
16
+ resolveDeleteChangeConflict: "Resolve deletion", diff: "Diff", patch: "Review patch", branchCreate: "Create branch", switchBranch: "Switch branch",
18
17
  merge: "Merge", mergeBranches: "Server merge", mergeToBranch: "Merge and check in", finalizeMerge: "Finalize merge",
19
18
  branchList: "Branches", branchExists: "Branch exists", branchDelete: "Delete branch", shelvesetCreate: "Create shelveset",
20
19
  shelvesetApply: "Apply shelveset", shelvesetDelete: "Delete shelveset", shelvesetList: "Shelvesets",
@@ -60,10 +59,10 @@ function list(value: unknown): string {
60
59
  }
61
60
  function target(name: string, args: Args): string {
62
61
  if (name === "tool_search") return list(args.toolNames) || scalar(args.query) || "Browse capabilities";
63
- if (name === "diffRevisions") return `${scalar(args.leftRevision) || "..."} -> ${scalar(args.rightRevision) || "..."}`;
62
+ if (name === "diff" && args.mode === "revisions") return `${scalar(args.leftRevision) || "..."} -> ${scalar(args.rightRevision) || "..."}`;
64
63
  if (name === "mergeBranches" || name === "mergeToBranch") return `${scalar(args.source) || "..."} -> ${scalar(args.target) || "parent branch"}`;
65
- if (name === "patch" || name === "diff") return [scalar(args.source), scalar(args.destination)].filter(Boolean).join(" -> ");
66
- if (name === "diffFile") return [scalar(args.path), args.revision ? `vs ${scalar(args.revision)}` : "vs workspace base"].join(" ");
64
+ if (name === "patch") return [scalar(args.source), scalar(args.destination)].filter(Boolean).join(" -> ");
65
+ if (name === "diff" && args.mode === "file") return [scalar(args.path), args.revision ? `vs ${scalar(args.revision)}` : "vs workspace base"].join(" ");
67
66
  return scalar(args.branch) || scalar(args.source) || scalar(args.path) || list(args.paths) || scalar(args.shelveset)
68
67
  || list(args.ids) || scalar(args.id) || scalar(args.target) || scalar(args.nameLike) || scalar(args.titleLike)
69
68
  || (args.allPending === true ? "All pending files" : "");
@@ -73,7 +72,8 @@ export function renderPlasticCall(name: string, input: unknown, theme: RenderThe
73
72
  const args = record(input);
74
73
  const workdir = scalar(args.workdir) || context?.cwd || "";
75
74
  const workspace = name === "mergeBranches" ? "server" : workdir.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
76
- let text = theme.fg("toolTitle", theme.bold(`Plastic${separator}${titles[name] ?? name}`));
75
+ const title=name==="diff" ? args.mode==="file"?"File diff":args.mode==="revisions"?"Revision diff":args.mode==="workspace"?"Workspace diff":"Diff" : titles[name] ?? name;
76
+ let text = theme.fg("toolTitle", theme.bold(`Plastic${separator}${title}`));
77
77
  if (workspace && name !== "tool_search") text += theme.fg("dim", ` ${compact(workspace, 60)}`);
78
78
  if (args.preflight === true) text += theme.fg("warning", `${separator}preview`);
79
79
  const subtitle = target(name, args);
@@ -82,6 +82,8 @@ export function renderPlasticCall(name: string, input: unknown, theme: RenderThe
82
82
  }
83
83
 
84
84
  function payload(result: Result, raw: string): Record<string, unknown> {
85
+ const structured = record((result as Result & {structuredContent?:unknown}).structuredContent);
86
+ if (["shelveset-create","shelveset-apply","code-review-create","code-review-update"].includes(scalar(structured.action)) || structured.action === "patch" || structured.action === "diff" || structured.action === "merge-branches" || structured.action === "checkin" || structured.action === "branch-create" || structured.action === "switch-branch" || structured.action === "update" || structured.action === "add" || structured.action === "undo" || structured.action === "resolve-delete-change-conflict" || structured.action === "merge" || structured.action === "finalize-merge" || structured.action === "merge-to-branch" || structured.action === "branch-delete" || structured.action === "shelveset-delete" || structured.action === "code-review-delete") return structured;
85
87
  const rawResult = record(result.details).rawResult;
86
88
  if (rawResult !== null && typeof rawResult === "object") return record(rawResult);
87
89
  // Only decode the package's whole JSON envelope, never a JSON fragment in CLI or diff output.
@@ -109,33 +111,104 @@ function summarize(name: string, result: Result, raw: string, context?: Context)
109
111
  const notices = strings(envelope.warnings);
110
112
  const summary: Summary = { label: "Result returned", tone: "toolOutput", notices, rows: [] };
111
113
  const textLines = raw.split(/\r?\n/).filter(line => line.trim());
112
- const preview = record(context?.args).preflight === true || action.endsWith("-preflight") || envelope.outcome === "preflight";
113
- if (context?.isError) {
114
+ const preview = !["update", "add", "undo", "resolve-delete-change-conflict", "merge", "finalize-merge", "merge-to-branch", "branch-delete", "shelveset-delete", "code-review-delete", "patch","shelveset-create","shelveset-apply","code-review-create","code-review-update"].includes(action) && (record(context?.args).preflight === true || action.endsWith("-preflight") || envelope.outcome === "preflight");
115
+ if (context?.isError && !(name === "diff" && Array.isArray(data.outcomes)) && !(name === "mergeBranches" && action === "merge-branches") && !["checkin", "branch-create", "switch-branch", "update", "add", "undo", "resolve-delete-change-conflict", "merge", "finalize-merge", "merge-to-branch", "branch-delete", "shelveset-delete", "code-review-delete", "patch","shelveset-create","shelveset-apply","code-review-create","code-review-update"].includes(action)) {
114
116
  summary.label = "Failed";
115
117
  summary.tone = "error";
116
118
  summary.rows = textLines.slice(0, 3);
117
119
  return summary;
118
120
  }
119
- if (preview) {
121
+ if (["shelveset-create","shelveset-apply","code-review-create","code-review-update"].includes(action)) {
122
+ summary.label = `Object write: ${scalar(envelope.outcome)}`;
123
+ summary.tone = envelope.ok === false ? "error" : "warning";
124
+ summary.rows = [`Attempt: ${scalar(record(data.attempt).state)}`, `Effect: ${scalar(data.effect)}`, `Emitted identity: ${scalar(record(data.observedCreatedIdentity).value) || "unobserved"}`];
125
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
126
+ notices.push(envelope.outcome === "preflight" ? "Command-only preview; no conflict/state analysis." : "Applied items, requested review state, repository scope and rollback remain unverified.");
127
+ } else if (name === "patch" && action === "patch") {
128
+ summary.label = `Patch: ${scalar(envelope.outcome)}`;
129
+ summary.tone = envelope.ok === false ? "error" : "warning";
130
+ summary.rows = [`Effect: ${scalar(data.effect)}`, `Publication: ${scalar(data.publication)}`, `Cleanup: ${scalar(data.cleanup)}`, `Observed bytes: ${count(record(data.artifact).bytes) ?? "unknown"}`];
131
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
132
+ notices.push("Generation only; workspace effects, move encoding and rollback unverified.");
133
+ } else if ((name === "shelvesetDelete" && action === "shelveset-delete") || (name === "codeReviewDelete" && action === "code-review-delete")) {
134
+ summary.label = `Object deletion: ${scalar(envelope.outcome)}`;
135
+ summary.tone = envelope.ok === false ? "error" : "warning";
136
+ summary.rows = [`Attempt: ${scalar(record(data.attempt).state)}`, `Effects: ${scalar(data.effect)}`];
137
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
138
+ notices.push(envelope.outcome === "preflight" ? "Command-only preview; no existence/readiness analysis." : "Deleted identities, repository scope and rollback remain unverified.");
139
+ } else if (name === "branchDelete" && action === "branch-delete") {
140
+ summary.label = `Branch deletion: ${scalar(envelope.outcome)}`;
141
+ summary.tone = envelope.ok === false ? "error" : "warning";
142
+ summary.rows = [`Attempt: ${scalar(record(data.attempt).state)}`, `Effects: ${scalar(data.effect)}`, `Delete changesets requested: ${scalar(data.deleteChangesetsRequested)}`];
143
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
144
+ notices.push(envelope.outcome === "preflight" ? "Command-only preview; no existence or readiness analysis." : "Deletion identity, history effects and repository scope remain unverified.");
145
+ } else if (name === "mergeToBranch" && action === "merge-to-branch") {
146
+ summary.label = `Closeout: ${scalar(envelope.outcome)}`;
147
+ summary.tone = envelope.ok === false ? "error" : envelope.outcome === "completed" ? "success" : "warning";
148
+ summary.rows = [`Effect: ${scalar(data.effect)}`, `Recorded stages: ${records(data.stages).length}`, `Created changeset: ${scalar(record(data.createdChangeset).id) || "unobserved"}`, `Target verification: ${scalar(data.targetVerification)}`];
149
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
150
+ notices.push("Earlier child effects remain recorded. Source links, branch head, aliases, exclusive scope and rollback unverified; no automatic replay.");
151
+ } else if ((name === "merge" || name === "finalizeMerge") && (action === "merge" || action === "finalize-merge")) {
152
+ summary.label = `Workspace merge: ${scalar(envelope.outcome)}`;
153
+ summary.tone = envelope.ok === false ? "error" : "warning";
154
+ summary.rows = [`Applying command completed: ${String(data.commandCompleted)}`, `Checkin readiness: ${scalar(data.checkinReadiness)}`, `Effect: ${scalar(data.effect)}`];
155
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
156
+ notices.push(envelope.outcome === "preflight" ? "Command-only preview; no readiness analysis." : "Conflict resolution, finalized metadata, preservation and complete scope remain unverified.");
157
+ } else if (name === "resolveDeleteChangeConflict" && action === "resolve-delete-change-conflict") {
158
+ summary.label = `Removal: ${scalar(envelope.outcome)}`;
159
+ summary.tone = envelope.ok === false ? "error" : "warning";
160
+ summary.rows = [`Attempt: ${scalar(record(data.attempt).state)}`, `Effect: ${scalar(data.effect)}`];
161
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
162
+ notices.push(envelope.outcome === "preflight" ? "Command-only preview; no conflict or execution readiness analysis." : "Conflict resolution, preservation, pending state and affected scope remain unverified.");
163
+ } else if (preview) {
120
164
  const wouldRun = typeof data.wouldRun === "boolean" ? data.wouldRun : /^- Would run: no$/m.test(raw) ? false : /^- Would run: yes$/m.test(raw) ? true : undefined;
121
165
  summary.label = wouldRun === false ? "Preview: would not run" : wouldRun === true ? "Preview: would run" : "Preview returned";
122
166
  summary.tone = "warning";
123
167
  summary.rows = action ? [scalar(data.reason) || scalar(data.errorCode), scalar(data.strategy)].filter(Boolean) : textLines.filter(line => /^- (?:Reason|Strategy|Command):/.test(line));
168
+ } else if ((name === "update" && action === "update" || name === "add" && action === "add" || name === "undo" && action === "undo") && typeof envelope.outcome === "string") {
169
+ summary.label = `${name === "update" ? "Update" : name === "add" ? "Add" : "Undo"}: ${envelope.outcome}`;
170
+ summary.tone = envelope.ok === false ? "error" : "warning";
171
+ summary.rows = [`Attempt: ${scalar(record(data.attempt).state)}`, `Effect: ${scalar(data.effect)}`];
172
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
173
+ notices.push(name === "update" ? "Workspace identity, changed items, preservation, cleanliness and merge readiness remain unverified." : name === "add" ? "Requested operands are not verified added items. Workspace identity, expanded scope and preservation remain unverified." : "Requested operands are not verified undone items. Workspace identity, restored content, expanded scope and preservation remain unverified.");
174
+ } else if (name === "switchBranch" && action === "switch-branch" && typeof envelope.outcome === "string") {
175
+ summary.label = `Switch: ${envelope.outcome}`;
176
+ summary.tone = envelope.ok === false ? "error" : envelope.outcome === "switched" ? "success" : "warning";
177
+ summary.rows = records(data.steps).map(step => `${scalar(step.name)}: ${scalar(step.effect)}`);
178
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
179
+ notices.push(`Effect: ${scalar(data.effect)}. File preservation, alias equivalence and Xlink effects remain unverified.`);
180
+ } else if ((name === "checkin" && action === "checkin" || name === "branchCreate" && action === "branch-create") && typeof envelope.outcome === "string") {
181
+ summary.label = `${name === "checkin" ? "Checkin" : "Branch create"}: ${envelope.outcome}`;
182
+ summary.tone = envelope.ok === false ? "error" : name === "checkin" ? "success" : "warning";
183
+ const changeset = scalar(record(data.createdChangeset).id);
184
+ if (changeset) summary.label += `${separator}cs:${changeset}`;
185
+ summary.rows = records(data.steps).map(step => `${scalar(step.name)}: ${scalar(step.effect)}`);
186
+ if (envelope.ok === false) notices.unshift(scalar(record(envelope.error).message));
187
+ if (name === "branchCreate") notices.push("Created identity and repository effects unverified; no workspace switch requested.");
188
+ else {
189
+ notices.push("Scope exhaustion, branch head, alias equivalence, merge links and Xlink effects unverified.");
190
+ if (data.pendingAfter === null) notices.push("Pending after not verified.");
191
+ if (count(data.omittedReferences)) notices.unshift(`${count(data.omittedReferences)} references omitted.`);
192
+ }
124
193
  } else if (name === "mergeBranches" && typeof envelope.outcome === "string") {
125
194
  summary.label = `Server merge: ${envelope.outcome}`;
126
- summary.tone = envelope.outcome === "completed" ? "success" : "warning";
195
+ summary.tone = envelope.ok === false ? "error" : envelope.outcome === "completed" || envelope.outcome === "analyzed" ? "success" : "warning";
196
+ if (data.mode === "analyze") notices.unshift("Read-only analysis; no applying merge requested.");
197
+ summary.rows = Array.isArray(data.conflictPaths) ? data.conflictPaths.map(path => scalar(path)) : [];
198
+ if (count(record(data.counts).conflictsOmitted)) notices.unshift(`${count(record(data.counts).conflictsOmitted)} conflicts omitted.`);
127
199
  const changeset = scalar(record(data.createdChangeset).id);
128
200
  if (changeset) summary.label += `${separator}cs:${changeset}`;
129
201
  if (data.mergeLinkIdentity === "unverified" || data.xlinkEffects === "unverified") notices.push("Merge-link identity and Xlink effects remain unverified.");
130
202
  if (data.effect === "uncertain" || data.effect === "not-proven") notices.unshift("Effects not proven; inspect server state before any retry.");
131
- if (data.timedOut === true) notices.unshift("Command timed out; effects uncertain.");
132
- if (data.aborted === true) notices.unshift("Command cancelled; effects uncertain.");
203
+ if (data.serverAliasEquivalence === "unverified") notices.push("Requested and emitted server names remain separate; alias equivalence is unverified.");
204
+ if (data.timedOut === true || record(data.attempt).timedOut === true) notices.unshift("Command timed out; effects uncertain.");
205
+ if (data.aborted === true || record(data.attempt).aborted === true) notices.unshift("Command cancelled; effects uncertain.");
133
206
  } else if (data.checkedIn === false || record(data.switchOutcome).kind === "canceled" || data.kind === "canceled" || data.strategy === "cancel-with-pending") {
134
207
  summary.label = data.checkedIn === false ? "Checkin not performed" : "Switch cancelled";
135
208
  summary.tone = "warning";
136
209
  const reason = scalar(record(data.switchOutcome).reason) || scalar(data.reason);
137
210
  if (reason) notices.unshift(reason);
138
- } else if (envelope.ok === false) {
211
+ } else if (envelope.ok === false && !(name === "diff" && Array.isArray(data.outcomes))) {
139
212
  summary.label = "Operation not successful";
140
213
  summary.tone = "error";
141
214
  } else if (name === "status" && count(record(data.summary).totalPending) !== undefined) {
@@ -147,20 +220,24 @@ function summarize(name: string, result: Result, raw: string, context?: Context)
147
220
  summary.label = data.branch;
148
221
  } else if (name === "branchExists" && /^(true|false)$/.test(raw.trim())) {
149
222
  summary.label = raw.trim() === "true" ? "Branch found" : "Branch not found";
150
- } else if ((name === "diffFile" || name === "diffRevisions") && typeof data.status === "string") {
223
+ } else if (name === "diff" && typeof data.status === "string") {
151
224
  summary.label = data.status === "unchanged" ? "No differences" : data.status === "binary-different" ? "Binary content differs" : data.status === "added-empty" ? "Added file is empty" : "Text differences";
152
225
  if (data.binary === true) notices.push("Binary comparison; no text hunks.");
153
- } else if (name === "workspaceDiff" && Array.isArray(data.outcomes)) {
226
+ } else if (name === "diff" && Array.isArray(data.outcomes)) {
154
227
  const outcomes = records(data.outcomes);
228
+ const counts = record(data.counts);
155
229
  const unavailable = outcomes.filter(item => item.status === "unavailable");
156
230
  const skipped = outcomes.filter(item => item.status === "skipped-directory");
157
- summary.label = `${outcomes.length} results${separator}${outcomes.filter(item => item.changed === true).length} changed${separator}${unavailable.length} unavailable`;
158
- if (unavailable.length) summary.tone = "warning";
231
+ summary.label = `${outcomes.length} returned results${separator}${outcomes.filter(item => record(item.comparison).changed === true).length} changed${separator}${count(counts.failed) ?? unavailable.length} unavailable`;
232
+ if (unavailable.length || envelope.ok === false) summary.tone = "warning";
159
233
  if (skipped.length) notices.push(`${skipped.length} directories skipped.`);
160
- summary.rows = [...unavailable, ...outcomes.filter(item => item.status !== "unavailable" && item.status !== "skipped-directory"), ...skipped].map(item => `${scalar(item.path)}: ${scalar(item.status)}${item.error ? ` - ${scalar(item.error)}` : ""}`);
161
- if (outcomes.some(item => item.truncated === true)) notices.push("Some file diffs were truncated by the tool.");
162
- if (count(data.omittedOutcomes)) notices.push(`${data.omittedOutcomes} outcomes omitted by the tool.`);
163
- if (count(data.skippedByLimit)) notices.push(`${data.skippedByLimit} pending items skipped by the file limit.`);
234
+ summary.rows = [...unavailable, ...outcomes.filter(item => item.status !== "unavailable" && item.status !== "skipped-directory"), ...skipped].map(item => `${scalar(item.path)}: ${scalar(item.status)}${item.error ? ` - ${scalar(record(item.error).code)}` : ""}`);
235
+ if (outcomes.some(item => record(record(item.comparison).excerpt).truncated === true)) notices.push("Some file diffs were truncated by the tool.");
236
+ if (count(counts.omitted)) notices.push(`${counts.omitted} outcomes omitted by the tool.`);
237
+ if (count(counts.limited)) notices.push(`${counts.limited} pending items skipped by the file limit.`);
238
+ if (count(counts.unattempted)) notices.push(`${counts.unattempted} selected items not attempted.`);
239
+ if (count(data.excludedPrivate)) notices.push(`${data.excludedPrivate} private items excluded.`);
240
+ if (strings(data.unmatched).length) notices.push(`${strings(data.unmatched).length} selected paths had no pending match.`);
164
241
  } else if (name === "patch" && typeof envelope.status === "string") {
165
242
  summary.label = `Patch ${envelope.status}${count(envelope.bytes) !== undefined ? `${separator}${envelope.bytes} bytes` : ""}`;
166
243
  if (envelope.binaryLimited === true) notices.push("Binary content could not be fully represented.");
@@ -189,7 +266,7 @@ function summarize(name: string, result: Result, raw: string, context?: Context)
189
266
  notices.unshift(...urgent.map(line => line.replace(/^#+\s*/, "")));
190
267
  notices.push(...warnings);
191
268
  }
192
- if (data.truncated === true || data.outputTruncated === true) notices.push("Output truncated by the tool.");
269
+ if (data.truncated === true || data.outputTruncated === true || record(data.excerpt).truncated === true) notices.push("Output truncated by the tool.");
193
270
  const limited = data.truncated === true || data.outputTruncated === true || envelope.truncated === true
194
271
  || Boolean(count(record(data.itemCount).omitted) || count(data.omittedOutcomes) || count(data.skippedByLimit))
195
272
  || records(data.outcomes).some(item => item.truncated === true);
@@ -234,8 +311,11 @@ export function renderPlasticResult(name: string, result: Result, options: Optio
234
311
  const envelope = payload(result, raw);
235
312
  const data = record(envelope.data);
236
313
  if (typeof data.diff === "string") section("Diff", evidence(data.diff, theme));
314
+ if (typeof record(data.excerpt).text === "string") section("Diff", evidence(scalar(record(data.excerpt).text), theme));
237
315
  for (const item of records(data.outcomes)) {
238
316
  if (typeof item.diff === "string") section(safe(scalar(item.path)), evidence(item.diff, theme));
317
+ const excerpt=record(record(item.comparison).excerpt);
318
+ if (typeof excerpt.text === "string") section(safe(scalar(item.path)), evidence(excerpt.text, theme));
239
319
  }
240
320
  if (name === "patch" && typeof envelope.content === "string") section("Patch", evidence(envelope.content, theme));
241
321
  // Keep original content, including JSON numeric lexemes and duplicate keys, as the evidence.
@@ -14,11 +14,7 @@ export type PlasticSearchCatalogEntry = {
14
14
  guidance: readonly string[];
15
15
  };
16
16
 
17
- /**
18
- * Package-owned executable capability vocabulary. `plastic_diff` deliberately
19
- * remains outside this catalog: it is registered for compatibility, but is a
20
- * disabled alias rather than a safe executable diff capability.
21
- */
17
+ /** Canonical executable capabilities; there are no compatibility diff tools. */
22
18
  export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
23
19
  { name: "plastic_status", aliases: ["status", "pending changes", "working changes", "what changed", "changed files", "list changes"], tags: ["workspace", "inspect", "changes"], guidance: ["Use machineReadable=true to list changed, added, moved, deleted, and private paths without invoking GUI-capable cm diff."] },
24
20
  { name: "plastic_update", aliases: ["update", "sync", "pull latest"], tags: ["workspace", "synchronize", "safe"], guidance: ["Review pending changes before updating a workspace."] },
@@ -27,13 +23,11 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
27
23
  { name: "plastic_undo", aliases: ["undo changes", "revert changes", "discard changes"], tags: ["changes", "mutation", "destructive"], guidance: ["Confirm the exact paths before discarding pending changes."] },
28
24
  { name: "plastic_resolveDeleteChangeConflict", aliases: ["resolve delete conflict", "deleted conflict"], tags: ["merge", "conflict", "resolution"], guidance: ["Inspect the conflict and choose whether files should remain on disk before resolving it."] },
29
25
  { name: "plastic_patch", aliases: ["patch", "generate patch", "review patch"], tags: ["diff", "patch", "export"], guidance: ["Inspect generated patches before sharing because they can include source, paths, or secrets."] },
30
- { name: "plastic_diffFile", aliases: ["diff", "diff file", "file diff", "compare one file", "compare file"], tags: ["diff", "file", "workspace", "inspect"], guidance: ["Use only when change-boundary evidence is needed. Pass one exact workspace path, omit revision for its Plastic base, and keep maxChars small unless more output is intentional."] },
31
- { name: "plastic_workspaceDiff", aliases: ["workspace diff", "pending review", "review pending changes", "review workspace changes", "diff pending files"], tags: ["diff", "workspace", "review", "inspect"], guidance: ["Use only for an intentional multi-file content review, not routine validation. Select exact paths or pass allPending=true explicitly; use plastic_status when only changed paths are needed."] },
32
- { name: "plastic_diffRevisions", aliases: ["diff revisions", "compare revisions", "revision diff"], tags: ["diff", "revision", "inspect"], guidance: ["Use this only for two explicit file-qualified revision specifications."] },
26
+ { name: "plastic_diff", aliases: ["diff", "diff file", "compare file", "workspace diff", "pending review", "diff revisions", "compare revisions"], tags: ["diff", "file", "workspace", "revision", "inspect"], guidance: ["Require mode=file, revisions, or workspace. Use explicit paths or allPending=true for pending review; raw GUI cm diff remains blocked."] },
33
27
  { name: "plastic_branchCreate", aliases: ["create branch", "new branch"], tags: ["branch", "mutation", "workflow"], guidance: ["Create normal work branches beneath an intended parent, which may differ from the loaded branch. Use allowRootBranch only for intentional top-level branches."] },
34
28
  { name: "plastic_switchBranch", aliases: ["switch branch", "checkout branch", "change branch"], tags: ["branch", "workspace", "mutation"], guidance: ["Check pending changes and choose how to handle them before switching branches."] },
35
29
  { name: "plastic_merge", aliases: ["merge branch", "merge changes"], tags: ["merge", "conflict", "workflow"], guidance: ["Inspect merge state and resolve conflicts before checkin."] },
36
- { name: "plastic_mergeBranches", aliases: ["server merge", "workspace-free merge", "merge qualified branches"], tags: ["merge", "branch", "server", "mutation"], guidance: ["Require explicit same-repository/server qualified source and target plus a nonempty message. Preflight only renders the command; completed output verifies a target changeset, not a merge link, exclusive target ownership, or xlink effects."] },
30
+ { name: "plastic_mergeBranches", aliases: ["server merge", "workspace-free merge", "merge qualified branches", "server merge conflicts", "resolve server conflicts"], tags: ["merge", "branch", "server", "conflict", "resolutions", "mutation"], guidance: ["Require explicit same-repository/server qualified source and target. mode=analyze performs read-only remote conflict discovery without a message; apply requires a nonempty message and can submit a reviewed fileConflictsResolutionsFile. Keep resolution inputs immutable until completion. Preflight only renders the command and validates local inputs; completed output verifies a target changeset, not a merge link, exclusive target ownership, or xlink effects."] },
37
31
  { name: "plastic_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["Plastic merges into the branch loaded in the workspace; confirm source, target, final branch, and target-branch checkin rather than assuming a Git-style fast-forward."] },
38
32
  { name: "plastic_finalizeMerge", aliases: ["finalize merge", "complete merge", "resolve merge"], tags: ["merge", "conflict", "finalize"], guidance: ["Use after reviewed manual conflict resolution when Plastic still needs merge metadata finalized."] },
39
33
  { name: "plastic_currentBranch", aliases: ["current branch", "which branch", "branch name"], tags: ["branch", "workspace", "inspect"], guidance: ["Inspect the current branch before branch, merge, or checkin operations."] },
@@ -51,9 +45,8 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
51
45
  { name: "plastic_workspaceList", aliases: ["list workspaces", "find workspace", "workspaces"], tags: ["workspace", "inspect", "query"], guidance: ["Inspect workspace paths and repository identity before using a workspace in a mutation."] },
52
46
  ] as const;
53
47
 
54
- export const PLASTIC_COMPATIBILITY_TOOL_NAMES = ["plastic_diff"] as const;
55
48
  export const PLASTIC_SEARCHABLE_TOOL_NAMES = new Set(PLASTIC_SEARCH_CATALOG.map((entry) => entry.name));
56
- export const PLASTIC_TOOL_NAMES = new Set([...PLASTIC_SEARCHABLE_TOOL_NAMES, ...PLASTIC_COMPATIBILITY_TOOL_NAMES]);
49
+ export const PLASTIC_TOOL_NAMES = new Set(PLASTIC_SEARCHABLE_TOOL_NAMES);
57
50
 
58
51
  export type ToolSourceInfo = {
59
52
  path: string;
@@ -168,6 +161,8 @@ function safetyPriority(entry: PlasticSearchCatalogEntry): number {
168
161
  /** Search the executable catalog with deterministic scoring and safe bare-domain ordering. */
169
162
  export function searchPlasticTools(input: PlasticToolSearchInput, descriptions: ReadonlyMap<string, string> = new Map()): PlasticToolSearchMatch[] {
170
163
  const requestedNames = exactRequestedNames(input.toolNames);
164
+ const exactQuery = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
165
+ if (requestedNames.size === 0 && /^plastic_[a-z0-9]+$/.test(exactQuery) && !PLASTIC_SEARCH_CATALOG.some(entry => entry.name.toLowerCase() === exactQuery)) return [];
171
166
  const terms = typeof input.query === "string" ? tokenize(input.query) : [];
172
167
  const limit = normalizeLimit(input.limit, requestedNames);
173
168
  const domainTerms = terms.filter((term) => SEARCH_DOMAIN_TERMS.has(term));
@@ -231,5 +226,5 @@ export function getRestoredPlasticToolNames(branchEntries: readonly unknown[], e
231
226
  if (typeof name === "string" && effectiveToolNames.has(name)) restored.add(name);
232
227
  }
233
228
  }
234
- return [...PLASTIC_COMPATIBILITY_TOOL_NAMES, ...PLASTIC_SEARCH_CATALOG.map((entry) => entry.name)].filter((name) => restored.has(name));
229
+ return PLASTIC_SEARCH_CATALOG.map((entry) => entry.name).filter((name) => restored.has(name));
235
230
  }
@@ -0,0 +1,5 @@
1
+ import type { AddReceipt } from "../domain/add-contract";
2
+ export function presentAddReceipt(dto: AddReceipt): string {
3
+ return dto.ok ? `Add command completed for ${dto.data.requestedOperandCount} requested operands, not a verified added-item count. Workspace identity, added items, expanded scope and preservation remain unverified.` :
4
+ `Add ${dto.outcome}: ${dto.error.message} Effects: ${dto.data.effect}. Do not retry automatically.`;
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { BranchCreateReceipt } from "../domain/branch-create-contract";
2
+ export function presentBranchCreateReceipt(dto: BranchCreateReceipt): string {
3
+ const text = dto.ok
4
+ ? "Branch create command completed. Created branch identity, repository and effects are unverified; no workspace switch was requested."
5
+ : `Branch create ${dto.outcome}. ${dto.error.message} Effect: ${dto.data.effect}.`;
6
+ return text.length <= 24000 ? text : "Branch create evidence is unavailable; inspect structuredContent and do not retry automatically.";
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { BranchDeleteReceipt } from "../domain/branch-delete-contract";
2
+ export function presentBranchDeleteReceipt(dto:BranchDeleteReceipt):string {
3
+ if(dto.data.outputFormatRequested==="json")return JSON.stringify(dto);
4
+ if(!dto.ok)return `Branch deletion ${dto.outcome}: ${dto.error.message} Effects: ${dto.data.effect}. Do not retry automatically.`;
5
+ if(dto.outcome==="preflight")return `Branch-delete command-only preview (not executed): ${JSON.stringify(dto.data.intendedArgv)}. No branch existence, permissions or deletion readiness analysis.`;
6
+ return "Branch-delete command completed. Deleted branch identity, changeset-history effects, repository/workspace scope and rollback remain unverified.";
7
+ }
@@ -0,0 +1,14 @@
1
+ import type { CheckinReceipt } from "../domain/checkin-contract";
2
+ export function presentCheckinReceipt(dto: CheckinReceipt, format: "text" | "json" = "text"): string {
3
+ const text = format === "json" ? JSON.stringify(dto) : [
4
+ `## Checkin ${dto.outcome}`,
5
+ `- Effect: ${dto.data.effect}`,
6
+ ...(dto.outcome === "preflight" ? [`- Would run: ${dto.data.wouldRun ? "yes" : "no"}`, `- Command argv: ${JSON.stringify(dto.data.command)}`] : []),
7
+ ...(dto.data.createdChangeset ? [`- Observed root changeset: cs:${dto.data.createdChangeset.id}@br:${dto.data.createdChangeset.branch}@${dto.data.createdChangeset.repository}@${dto.data.createdChangeset.server}`] : []),
8
+ `- Steps: ${dto.data.steps.map(s => `${s.name} (${s.effect})`).join(", ") || "none"}`,
9
+ `- Pending after: ${dto.data.pendingAfter ? dto.data.pendingAfter.totalPending : "not verified"}`,
10
+ "- Requested scope exhaustion, branch head, alias equivalence, merge links and Xlink effects: unverified",
11
+ ...(!dto.ok ? [dto.error.message] : []),
12
+ ].join("\n");
13
+ return Buffer.byteLength(text, "utf8") <= 24000 ? text : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; inspect structuredContent for the bounded receipt." });
14
+ }
@@ -0,0 +1,2 @@
1
+ import type { CloseoutReceipt } from "../domain/closeout-contract";
2
+ export function presentCloseoutReceipt(dto:CloseoutReceipt,format?:"text"|"json"):string {if(format==="json")return JSON.stringify(dto);return [`## Merge To Branch ${dto.ok?dto.outcome==="preflight"?"Preview":"Receipt":"Paused"}`,`- Outcome: ${dto.outcome}; effect: ${dto.data.effect}`,`- Recorded stages: ${dto.data.stages.length}; target verification: ${dto.data.targetVerification}`,`- Source: ${dto.data.sourceBranch??"unobserved"}; target: ${dto.data.targetBranch??"unobserved"}`,`- Created changeset: ${dto.data.createdChangeset?`cs:${dto.data.createdChangeset.id}@br:${dto.data.createdChangeset.branch}@${dto.data.createdChangeset.repository}@${dto.data.createdChangeset.server}`:"unobserved"}`,`- Final pending count: ${dto.data.pendingAfter?.totalPending??"unobserved"}`,...(!dto.ok?[`- ${dto.error.message}`]:[]),"- Point-in-time evidence only; source links, branch head, aliases, exclusive scope and rollback are unverified. No automatic replay or compensation."].join("\n");}
@@ -0,0 +1,237 @@
1
+ import { OutputFormat, TOOL_VERSION } from "./results";
2
+ import { TextDiffResult } from "../diff/text";
3
+ import { getCmVersion } from "../execution/cli-version";
4
+
5
+ export const DIFF_RESPONSE_DEFAULT_MAX_CHARS = 8_000;
6
+
7
+ export const DIFF_RESPONSE_MAX_CHARS = 20_000;
8
+
9
+ export const DIFF_RESPONSE_MIN_CHARS = 500;
10
+
11
+ export const DIFF_RESPONSE_TOTAL_MAX_CHARS = 24_000;
12
+
13
+ export const normalizeDiffResponseMaxChars = (value: unknown): number =>
14
+ {
15
+ if (typeof value !== "number" || !Number.isFinite(value))
16
+ {
17
+ return DIFF_RESPONSE_DEFAULT_MAX_CHARS;
18
+ }
19
+ return Math.min(DIFF_RESPONSE_MAX_CHARS, Math.max(DIFF_RESPONSE_MIN_CHARS, Math.trunc(value)));
20
+ };
21
+
22
+ export const formatTextDiff = async (
23
+ action: "diffFile" | "diffRevisions",
24
+ format: OutputFormat | undefined,
25
+ comparisonKind: string,
26
+ unboundedResult: TextDiffResult,
27
+ workdir: string | undefined,
28
+ comparisonMetadata: Record<string, unknown> = {},
29
+ maxChars?: number,
30
+ ): Promise<string> =>
31
+ {
32
+ const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
33
+ const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : (comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
34
+ const text = result.binary
35
+ ? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
36
+ : (result.changed ? result.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
37
+ const data = {
38
+ comparisonKind,
39
+ ...comparisonMetadata,
40
+ backend: result.backend,
41
+ status,
42
+ changed: result.changed,
43
+ binary: result.binary,
44
+ truncated: result.truncated,
45
+ totalChars: result.totalChars,
46
+ diff: result.changed && !result.binary ? result.output : undefined,
47
+ };
48
+ const warnings = result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined;
49
+ if (format === "json")
50
+ {
51
+ return toBoundedDiffStructuredResult(action, data, workdir, warnings);
52
+ }
53
+ return text;
54
+ };
55
+
56
+ export const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
57
+ {
58
+ if (!result.output || result.output.length <= maxChars)
59
+ {
60
+ return result;
61
+ }
62
+
63
+ const suffix = `\n\n[Diff output truncated at the requested ${maxChars}-character response bound.]`;
64
+ return {
65
+ ...result,
66
+ output: suffix.length >= maxChars ? suffix.slice(0, maxChars) : `${result.output.slice(0, maxChars - suffix.length)}${suffix}`,
67
+ truncated: true,
68
+ totalChars: result.totalChars,
69
+ };
70
+ };
71
+
72
+ export const toBoundedDiffStructuredResult = async (
73
+ action: "diffFile" | "diffRevisions",
74
+ data: Record<string, unknown>,
75
+ workdir?: string,
76
+ warnings?: string[],
77
+ ): Promise<string> =>
78
+ {
79
+ const wrap = (payload: Record<string, unknown>): string => `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
80
+ const basePayload = {
81
+ ok: true,
82
+ action,
83
+ toolVersion: TOOL_VERSION,
84
+ cliVersion: (await getCmVersion(workdir)).slice(0, 256),
85
+ };
86
+ const initial = wrap({ ...basePayload, data, ...(warnings?.length ? { warnings } : {}) });
87
+ if (initial.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
88
+ {
89
+ return initial;
90
+ }
91
+
92
+ const originalDiff = typeof data.diff === "string" ? data.diff : "";
93
+ const responseWarning = `Diff content was further truncated after JSON escaping to keep the complete response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.`;
94
+ const boundedWarnings = [...(warnings ?? []), responseWarning];
95
+ const suffix = `\n\n[Diff output truncated to keep the complete JSON response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.]`;
96
+ const render = (prefixChars: number): string => wrap({
97
+ ...basePayload,
98
+ data: {
99
+ ...data,
100
+ truncated: true,
101
+ diff: `${originalDiff.slice(0, prefixChars)}${suffix}`,
102
+ },
103
+ warnings: boundedWarnings,
104
+ });
105
+
106
+ let low = 0;
107
+ let high = originalDiff.length;
108
+ let best = render(0);
109
+ while (low <= high)
110
+ {
111
+ const middle = Math.floor((low + high) / 2);
112
+ const candidate = render(middle);
113
+ if (candidate.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
114
+ {
115
+ best = candidate;
116
+ low = middle + 1;
117
+ }
118
+ else
119
+ {
120
+ high = middle - 1;
121
+ }
122
+ }
123
+ return best;
124
+ };
125
+
126
+ export const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 3;
127
+
128
+ export const WORKSPACE_DIFF_MAX_FILES = 20;
129
+
130
+ export const WORKSPACE_DIFF_MAX_PATHS = 20;
131
+
132
+ export const WORKSPACE_DIFF_PATH_MAX_CHARS = 1_024;
133
+
134
+ export const WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS = 256;
135
+
136
+ export const WORKSPACE_DIFF_ERROR_MAX_CHARS = 1_024;
137
+
138
+ export const WORKSPACE_DIFF_DEFAULT_MAX_CHARS = 3_000;
139
+
140
+ export const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 8_000;
141
+
142
+ export const WORKSPACE_DIFF_MIN_CHARS = 500;
143
+ // This is a response bound, not just a diff-body bound. Reserve space for
144
+ // framing and an omission summary so both text and JSON remain useful.
145
+
146
+ export const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 20_000;
147
+
148
+ export const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 16_000;
149
+
150
+ export const boundWorkspaceValue = (value: string, maxChars: number): string =>
151
+ {
152
+ if (maxChars <= 0)
153
+ {
154
+ return "";
155
+ }
156
+ if (value.length <= maxChars)
157
+ {
158
+ return value;
159
+ }
160
+ const suffix = `… [truncated; original ${value.length} characters]`;
161
+ return suffix.length >= maxChars ? value.slice(0, maxChars) : `${value.slice(0, maxChars - suffix.length)}${suffix}`;
162
+ };
163
+
164
+ export const boundWorkspaceDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
165
+ {
166
+ if (result.output.length <= maxChars)
167
+ {
168
+ return result;
169
+ }
170
+ return { ...result, output: boundWorkspaceValue(result.output, maxChars), truncated: true };
171
+ };
172
+
173
+ export const workspacePathPreview = (paths: string[]): string[] => paths.map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS));
174
+
175
+ export const appendBoundedWorkspaceText = (prefix: string, sections: string[], omitted: number): string =>
176
+ {
177
+ const omission = (count: number): string => `\n\n[${count} file outcome(s) omitted to keep this response within ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.]`;
178
+ let text = prefix;
179
+ let omittedCount = omitted;
180
+ for (let index = 0; index < sections.length; index += 1)
181
+ {
182
+ const candidate = `${text}\n\n${sections[index]}`;
183
+ if (candidate.length + omission(omittedCount + sections.length - index - 1).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
184
+ {
185
+ omittedCount += sections.length - index;
186
+ break;
187
+ }
188
+ text = candidate;
189
+ }
190
+ return omittedCount > 0 ? `${text}${omission(omittedCount)}` : text;
191
+ };
192
+
193
+ export const formatWorkspaceDiffResult = async (
194
+ format: OutputFormat,
195
+ textPrefix: string,
196
+ textSections: string[],
197
+ data: Record<string, unknown>,
198
+ warnings: string[],
199
+ outcomes: Array<Record<string, unknown>>,
200
+ preOmittedOutcomes: number,
201
+ workdir?: string,
202
+ ): Promise<string> =>
203
+ {
204
+ if (format !== "json")
205
+ {
206
+ return appendBoundedWorkspaceText(textPrefix, textSections, preOmittedOutcomes);
207
+ }
208
+
209
+ const cliVersion = boundWorkspaceValue(await getCmVersion(workdir), WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
210
+ const wrap = (payload: Record<string, unknown>): string => `## workspaceDiff\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
211
+ const retained: Array<Record<string, unknown>> = [];
212
+ let omittedOutcomes = preOmittedOutcomes;
213
+ const payloadFor = (): Record<string, unknown> => ({
214
+ ok: true,
215
+ action: "workspaceDiff",
216
+ toolVersion: TOOL_VERSION,
217
+ cliVersion,
218
+ data: { ...data, outcomes: retained, omittedOutcomes },
219
+ ...(warnings.length > 0 ? { warnings } : {}),
220
+ });
221
+
222
+ for (let index = 0; index < outcomes.length; index += 1)
223
+ {
224
+ retained.push(outcomes[index]);
225
+ const remainingCount = outcomes.length - index - 1;
226
+ // Keep an omission field in the serialized payload before deciding to
227
+ // retain another result; JSON escaping can otherwise exceed the bound.
228
+ omittedOutcomes = preOmittedOutcomes + remainingCount;
229
+ if (wrap(payloadFor()).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
230
+ {
231
+ retained.pop();
232
+ omittedOutcomes = preOmittedOutcomes + outcomes.length - index;
233
+ break;
234
+ }
235
+ }
236
+ return wrap(payloadFor());
237
+ };
@@ -0,0 +1,7 @@
1
+ import type { RemovalReceipt } from "../domain/removal-contract";
2
+ export function presentRemovalReceipt(dto: RemovalReceipt): string {
3
+ if (dto.data.outputFormatRequested === "json") return `## resolve-delete-change-conflict\n\n\`\`\`json\n${JSON.stringify(dto, null, 2)}\n\`\`\``;
4
+ if (!dto.ok) return `Removal ${dto.outcome}: ${dto.error.message} Effects: ${dto.data.effect}. Do not retry automatically.`;
5
+ if (dto.outcome === "preflight") return `Removal command-only preview (not executed): ${JSON.stringify(dto.data.intendedArgv)}. Requested --nodisk: ${dto.data.keepOnDiskRequested}. No conflict or execution readiness analysis.`;
6
+ return `Remove command completed for ${dto.data.requestedOperandCount} requested operands, not a verified resolved-item count. Conflict resolution, disk preservation, pending state, workspace identity and affected scope remain unverified.`;
7
+ }