@aefree/pi-plastic 0.5.8 → 0.6.1

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.6.1] - 2026-09-14
11
+
12
+ ### Fixed
13
+
14
+ - Clarify that `cm cat --file` writes to and can overwrite its destination, including when stdout is redirected. Route ordinary baseline reviews through typed diff tools, require new external temporary destinations for necessary manual exports, and distinguish changeset numbers from file revision IDs.
15
+
16
+ ## [0.6.0] - 2026-09-14
17
+
18
+ ### Added
19
+
20
+ - Add `plastic_mergeBranches` for one bounded, workspace-free server-side merge between explicitly same-repository/server qualified branches. The command-only preflight never contacts Plastic; completed results require one emitted root-mount target changeset and retain unverified merge-link/xlink effects and uncertain no-op/conflict effects.
21
+
22
+ ### Changed
23
+
24
+ - Pin the test runner as local devDependency `tsx@4.23.5` and make all package test, live-test, and eval scripts use that local executable instead of an opportunistic `npx` download.
25
+ - Update README's pinned GitHub installation examples to `v0.6.0`.
26
+
27
+ ### Fixed
28
+
29
+ - Make `plastic_mergeBranches` JSON formatting side-effect free, enforce exact bounded machine-readable record framing/schema, and bound both local capability and merge process lifetimes without retrying uncertain dispatches.
30
+ - Stop workspace merge closeout before target-side mutation when its required switch is canceled for pending changes; the blocked result now includes typed pending context and `checkedIn: false`.
31
+ - Preserve qualified parent-lookup repository/server scope while matching documented local branch-name fields, and report bounded `cm find` failures instead of silently treating them as a missing parent; root and missing branches now remain distinguishable.
32
+
10
33
  ## [0.5.8] - 2026-09-15
11
34
 
12
35
  ### Fixed
package/README.md CHANGED
@@ -36,6 +36,7 @@ Removing the approval layer does not relax Plastic safety guards. Command allowl
36
36
  - `plastic_branchCreate`
37
37
  - `plastic_switchBranch`
38
38
  - `plastic_merge`
39
+ - `plastic_mergeBranches`
39
40
  - `plastic_mergeToBranch`
40
41
  - `plastic_finalizeMerge`
41
42
  - `plastic_currentBranch`
@@ -54,7 +55,7 @@ Removing the approval layer does not relax Plastic safety guards. Command allowl
54
55
 
55
56
  ## Dynamic tool loading
56
57
 
57
- The package exposes 29 public `plastic_*` tools. `plastic_workspaceCreate` is intentionally not registered or discoverable until the package provides a paired, safe workspace-cleanup capability. `plastic_tool_search` is a package-owned loader that searches the explicit Plastic capability catalog, reports bounded matches and safety guidance, and additively enables selected tools for the next model request.
58
+ The package exposes 30 public `plastic_*` tools. `plastic_workspaceCreate` is intentionally not registered or discoverable until the package provides a paired, safe workspace-cleanup capability. `plastic_tool_search` is a package-owned loader that searches the explicit Plastic capability catalog, reports bounded matches and safety guidance, and additively enables selected tools for the next model request.
58
59
 
59
60
  The default **balanced** session set keeps `plastic_tool_search`, `plastic_status`, and `plastic_currentBranch` active. The other Plastic tools remain registered but inactive until selected; built-in and other-extension tools are not removed. Previous loader additions on the active session branch are restored on startup, resume, fork, and reload.
60
61
 
@@ -80,6 +81,7 @@ The Bash guards run only in Pi processes that actually load this package. A proj
80
81
  - `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
81
82
  - Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
82
83
  - Merge tooling surfaces Plastic `FILE_CONFLICT` records and merge-state metadata from `cm status`.
84
+ - `plastic_mergeBranches` is a separate workspace-free server-side route. It requires fully repository/server-qualified source and target branches in the same exact repository/server and a nonempty message. Its optional preflight only renders the command; it does not analyze remote conflicts. A completed result proves one emitted root-mount target changeset for this dispatch, not source merge-link identity, exclusive target-head ownership, server capability beyond the dispatch, rollback, or xlink effects. No-op and conflict records do not independently prove that no server effect occurred. The route has no workspace fallback, retries, target-head unification, xlink policy, shelve mode, or conflict-policy override; undetected server-side xlink effects remain possible.
83
85
  - `plastic_mergeToBranch` performs the common safe closeout flow: resolve the source branch's parent as the default target, switch to the target branch, optionally update, merge a source branch non-interactively, verify merge state, and check in the merge result. It verifies the loaded target before merge/checkin and rejects final-branch mismatches; Plastic checkins stay on the branch where they were created, so switching afterward is not a Git-style integration.
84
86
  - `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
85
87
 
@@ -109,13 +111,13 @@ pi install npm:@aefree/pi-plastic
109
111
  Install a pinned GitHub release over HTTPS:
110
112
 
111
113
  ```bash
112
- pi install git:github.com/aefreedman/pi-plastic@v0.5.7
114
+ pi install git:github.com/aefreedman/pi-plastic@v0.6.1
113
115
  ```
114
116
 
115
117
  Equivalent SSH install:
116
118
 
117
119
  ```bash
118
- pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
120
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.6.1
119
121
  ```
120
122
 
121
123
  To intentionally track the moving default branch instead of a release tag:
package/index.ts CHANGED
@@ -68,6 +68,7 @@ const PLASTIC_EXPORTS = [
68
68
  "branchCreate",
69
69
  "switchBranch",
70
70
  "merge",
71
+ "mergeBranches",
71
72
  "mergeToBranch",
72
73
  "finalizeMerge",
73
74
  "currentBranch",
@@ -223,6 +224,11 @@ const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
223
224
  return input;
224
225
  },
225
226
  },
227
+ mergeBranches: {
228
+ prepareArguments(args) {
229
+ return normalizeOutputFormatAlias(normalizeArgs(args));
230
+ },
231
+ },
226
232
  mergeToBranch: {
227
233
  prepareArguments(args) {
228
234
  const input = normalizeOutputFormatAlias(normalizeArgs(args));
@@ -714,7 +720,7 @@ export default function plasticTools(pi: ExtensionAPI) {
714
720
  },
715
721
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
716
722
  const normalizedParams = normalizeArgs(params);
717
- if (normalizedParams.workdir === undefined && ctx?.cwd) {
723
+ if (exportName !== "mergeBranches" && normalizedParams.workdir === undefined && ctx?.cwd) {
718
724
  normalizedParams.workdir = ctx.cwd;
719
725
  }
720
726
  const result = await core.runWithAbortSignal(signal, async () => coreTool.execute(normalizedParams));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.5.8",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -33,14 +33,15 @@
33
33
  ]
34
34
  },
35
35
  "scripts": {
36
- "test": "npx --yes tsx tests/plastic-tool-validation.ts && npx --yes tsx tests/plastic-status-machine-readable.test.ts && npx --yes tsx tests/plastic-checkin-path-resolution.ts && npx --yes tsx tests/plastic-diff-workflow.test.ts && npx --yes tsx tests/plastic-workspace-diff.test.ts && npx --yes tsx tests/plastic-process-lifecycle.test.ts && npx --yes tsx tests/plastic-patch-command.test.ts && npx --yes tsx tests/plastic-capability-diagnostics.test.ts && npx --yes tsx tests/plastic-extension-registration.test.ts && npx --yes tsx tests/plastic-dynamic-tool-loading.test.ts && npx --yes tsx tests/plastic-tool-rendering.test.ts && npx --yes tsx tests/plastic-branch-query.test.ts && npx --yes tsx tests/plastic-workspace.test.ts && npx --yes tsx tests/plastic-mutation-execution.test.ts && npx --yes tsx tests/file-discovery-filter.test.ts && npx --yes tsx tests/file-discovery-filter-registration.test.ts && npx --yes tsx tests/packed-copy-owner-conflict.test.ts && npx --yes tsx tests/plastic-branch-status.test.ts && npx --yes tsx tests/plastic-constrained-sampling.test.ts && npx --yes tsx tests/bash-cm-diff-guard-validation.ts",
37
- "test:live": "npx --yes tsx tests/plastic-live-smoke.test.ts",
38
- "test:live:branch-create": "npx --yes tsx tests/plastic-branch-create-live.test.ts",
39
- "eval:tool-loading": "npx --yes tsx evals/tool-loading/run-eval.ts"
36
+ "test": "tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
37
+ "test:live": "tsx tests/plastic-live-smoke.test.ts",
38
+ "test:live:branch-create": "tsx tests/plastic-branch-create-live.test.ts",
39
+ "eval:tool-loading": "tsx evals/tool-loading/run-eval.ts"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@aefree/pi-file-discovery": "^0.1.1",
43
43
  "@earendil-works/pi-coding-agent": "0.83.0",
44
+ "tsx": "4.23.5",
44
45
  "typebox": "^1.3.8"
45
46
  },
46
47
  "peerDependencies": {
@@ -10,12 +10,16 @@ Purpose: PlasticSCM operations for Unity workflows.
10
10
 
11
11
  Never run `cm diff` in Pi. It may launch GUI windows and block the CLI agent.
12
12
 
13
+ `cm cat --file=<path>` writes to that path and can overwrite existing files; `--file` is never an input selector. Never point it at a canonical workspace file during inspection or review, even when piping stdout elsewhere. Use typed diff tools for baseline comparisons. If a typed tool cannot represent a required export, read the [historical export rules](references/changeset-operations.md#historical-export-fallback) first. A changeset number is not a file revision ID; never turn `cs:<number>` into `revid:<number>`.
14
+
13
15
  Never run interactive `cm merge --merge` flows. Use `plastic_merge` (preferred) or explicitly pass `--nointeractiveresolution --mergetype=try` (safe default). Use source/destination policy flags only as explicit overrides.
14
16
 
15
17
  Treat `plastic_merge` success as provisional until `plastic_status` confirms there are no merge-in-progress hints. Pending merge links are expected until the merge result is checked in; merge-in-progress hints are not. If files are manually resolved and validated but checkin is blocked by Plastic merge metadata, use `plastic_finalizeMerge` with an explicit source/destination policy before retrying checkin.
16
18
 
17
19
  For the common closeout flow of merging a finished source branch into its parent branch, prefer `plastic_mergeToBranch` when available. When `target` is omitted, it resolves the source branch's Plastic parent branch instead of assuming `/dev`. It switches to the target, optionally updates, runs the safe merge, checks merge state, and checks in the merge result. Pass `target` only when the user explicitly wants a different integration branch.
18
20
 
21
+ Use `plastic_mergeBranches` only for the separate workspace-free server-side route. It requires explicit same-repository/server `br:/...@<repository>@<server>` source and target selectors plus a nonempty message. Its preflight renders the command only; it does not contact Plastic or analyze conflicts. A completed result proves one emitted root-mount target changeset for the dispatch, but does not verify the source merge link, target-head exclusivity, rollback, or xlink effects. No-op and conflict output do not prove that no effect occurred. Do not use it for xlink handling, conflict-policy selection, shelve merges, retries, target-head unification, or a workspace fallback; undetected server-side xlink effects remain possible.
22
+
19
23
  Plastic merges are workspace- and branch-oriented, not Git fast-forwards: the merge is applied to the branch currently loaded in the workspace, and the merge checkin must be created on that target branch. Switching to the target after a checkin on the source branch does not move or integrate that changeset. Treat any source/target/final-branch mismatch as a failed closeout; inspect target-branch history and rerun the merge from the actual target rather than merely switching branches.
20
24
 
21
25
  `plastic_diff` is intentionally disabled. Route changed-path listing to `plastic_status()`, one intentional workspace-file comparison to `plastic_diffFile(path="<workspace-path>")`, an explicitly scoped pending review to `plastic_workspaceDiff(paths=[...])`, and an explicit historical pair to `plastic_diffRevisions`. Do not use diffs as routine post-edit validation or checkin preflight. Prefer focused tests, `plastic_status` for pending scope, and direct reads for current file content; diff only when change-boundary evidence is needed. `plastic_diffFile` uses machine-readable status: added and explicitly selected private/new files compare against an empty base; changed, moved, and deleted items use their reported base revision when available. An added empty file reports `status="added-empty"`, not generic unchanged. A `--nodata` base failure means Plastic cannot provide historical bytes; refresh/update the workspace or use two known file-qualified revisions. Explicit `revision` behavior remains a separate historical request. Binary content is reported rather than decoded as text. Text backends receive ASCII-safe package materializations while returned headers preserve logical Unicode labels.
@@ -84,6 +84,20 @@ plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevisio
84
84
 
85
85
  The typed diff tools own historical `cm cat --file` materialization and cleanup; do not create agent-authored temporary files for ordinary diffs. `cm cat --raw` is unsupported for this workflow. Use typed retrieval first; `cm cat --file` is the byte-preserving low-level fallback only when a typed tool cannot represent the case.
86
86
 
87
+ ## Historical Export Fallback
88
+
89
+ Use this fallback only when typed retrieval cannot represent the required case. For an ordinary workspace-base comparison, omit `revision` from `plastic_diffFile`; for a changeset comparison, supply its `revision="cs:<number>"` argument.
90
+
91
+ `cm cat <revspec> --file=<output-file>` writes historical bytes to the output file and can overwrite it. `--file` does not select the source. Piping this command to `Set-Content` or redirecting stdout does not change the destination or protect it: with `--file`, the file contents are written there rather than to stdout.
92
+
93
+ Before a necessary manual export:
94
+
95
+ - Verify the source selector. `revid:<id>` means an actual file revision ID, not a workspace changeset number. Use a verified repository/server-qualified revision ID, or a file-qualified `<path>#cs:<number>` selector for a file at a changeset.
96
+ - Create a new unique temporary directory outside the workspace and select a new absolute output path within it. Confirm that the destination does not exist. Never use a canonical workspace path or an existing baseline as the export destination.
97
+ - Export directly to that temporary destination; do not combine `--file` with a stdout pipeline to capture the content. Check command success and the exported content before comparison. Keep comparison baselines immutable.
98
+
99
+ If an export overwrites a workspace file, stop further exports and edits, record the exact command and destination, and inspect pending status. A clean status after exporting base revisions can mean pending work was erased; it does not prove successful review. Report the incident before attempting recovery, and preserve unrelated user changes.
100
+
87
101
  ## Commit Message Format
88
102
 
89
103
  Use conventional commits from ../../_shared/references/conventional-commits.md.
@@ -33,6 +33,8 @@ plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevisio
33
33
 
34
34
  The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base; an added empty file is reported as `added-empty`, not generic unchanged. Changed, moved, and deleted records use their status revision ID when available. A `--nodata` item cannot supply historical/base bytes: refresh the workspace or use two known file-qualified revisions. `plastic_workspaceDiff` requires selected paths or `allPending=true` and returns per-file unavailable outcomes instead of aborting the batch. Text-diff tools use bounded GNU/POSIX text diff, materialize backend operands to ASCII-safe package paths while preserving logical Unicode output labels, treat valid Unity YAML as text, and report genuine binary content explicitly. Do not construct `cm cat` temporary-file recipes for ordinary review. `cm cat --raw` is unsupported in this workflow; typed retrieval owns cleanup, and `cm cat --file` is the byte-preserving low-level fallback only when needed.
35
35
 
36
+ `cm cat --file` is a filesystem write: its argument is the output destination, never the source file. Pointing it at a working file overwrites pending changes even if stdout is piped to a temporary file. Reviewers must use typed comparisons for ordinary baseline review; a necessary unsupported export must follow the [historical export rules](changeset-operations.md#historical-export-fallback), with a new absolute temporary destination outside the workspace and a verified file revision selector.
37
+
36
38
  ## Metadata Listing (No GUI Diff)
37
39
 
38
40
  Use changeset metadata to understand branch activity:
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { isUtf8 } from "node:buffer";
3
3
  import { spawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { tool } from "./pi-tool-compat";
5
6
  import { promises as fs, realpathSync, statSync } from "node:fs";
6
7
  import { tmpdir } from "os";
@@ -12,8 +13,11 @@ type SpawnResult = {
12
13
  stderr: string;
13
14
  exitCode: number;
14
15
  aborted: boolean;
16
+ timedOut?: boolean;
15
17
  stdoutTruncated?: boolean;
16
18
  stdoutTotalChars?: number;
19
+ stderrTruncated?: boolean;
20
+ stderrTotalChars?: number;
17
21
  };
18
22
 
19
23
  const abortSignalStorage = new AsyncLocalStorage<AbortSignal | undefined>();
@@ -63,7 +67,14 @@ export const runWithAbortSignal = async <T>(
63
67
  signal: AbortSignal | undefined,
64
68
  fn: () => Promise<T>,
65
69
  commandExecution?: SpawnAndCollectDependencies,
66
- ): Promise<T> => abortSignalStorage.run(signal, () => commandExecutionStorage.run(commandExecution, fn));
70
+ ): Promise<T> =>
71
+ {
72
+ const inheritedSignal = getActiveAbortSignal();
73
+ const inheritedExecution = commandExecutionStorage.getStore();
74
+ // Nested public-tool execution must retain an injected process seam and the
75
+ // outer abort signal instead of silently dropping either boundary.
76
+ return abortSignalStorage.run(signal ?? inheritedSignal, () => commandExecutionStorage.run(commandExecution ?? inheritedExecution, fn));
77
+ };
67
78
 
68
79
  const writeInput = async (stdin: NodeJS.WritableStream | null | undefined, input: string): Promise<void> =>
69
80
  {
@@ -114,6 +125,7 @@ type SpawnAndCollectDependencies = {
114
125
  setTimeout?: (callback: () => void, delay: number) => NodeJS.Timeout;
115
126
  clearTimeout?: (timeout: NodeJS.Timeout) => void;
116
127
  abortKillDelayMs?: number;
128
+ timeoutMs?: number;
117
129
  outputLimitChars?: number;
118
130
  };
119
131
 
@@ -150,8 +162,10 @@ const spawnAndCollect = async (
150
162
  const stdoutPromise = readStream(proc.stdout, dependencies.outputLimitChars);
151
163
  const stderrPromise = readStream(proc.stderr, dependencies.outputLimitChars);
152
164
  let aborted = false;
165
+ let timedOut = false;
153
166
  let terminalSettled = false;
154
167
  let killTimeout: NodeJS.Timeout | undefined;
168
+ let operationTimeout: NodeJS.Timeout | undefined;
155
169
 
156
170
  let resolveExit: (exitCode: number) => void;
157
171
  let rejectExit: (error: Error) => void;
@@ -185,14 +199,13 @@ const spawnAndCollect = async (
185
199
  proc.once("error", onProcessError);
186
200
  proc.once("close", onProcessClose);
187
201
 
188
- const onAbort = () =>
202
+ const terminate = () =>
189
203
  {
190
204
  if (terminalSettled)
191
205
  {
192
206
  return;
193
207
  }
194
208
 
195
- aborted = true;
196
209
  try
197
210
  {
198
211
  proc.kill("SIGTERM");
@@ -217,8 +230,21 @@ const spawnAndCollect = async (
217
230
  }
218
231
  }, dependencies.abortKillDelayMs ?? 5000);
219
232
  };
233
+ const onAbort = () =>
234
+ {
235
+ aborted = true;
236
+ terminate();
237
+ };
220
238
 
221
239
  signal?.addEventListener("abort", onAbort, { once: true });
240
+ if (dependencies.timeoutMs !== undefined)
241
+ {
242
+ operationTimeout = scheduleTimeout(() =>
243
+ {
244
+ timedOut = true;
245
+ terminate();
246
+ }, dependencies.timeoutMs);
247
+ }
222
248
 
223
249
  try
224
250
  {
@@ -234,8 +260,11 @@ const spawnAndCollect = async (
234
260
  stderr: stderrResult.output,
235
261
  exitCode,
236
262
  aborted,
263
+ timedOut,
237
264
  stdoutTruncated: stdoutResult.truncated,
238
265
  stdoutTotalChars: stdoutResult.totalChars,
266
+ stderrTruncated: stderrResult.truncated,
267
+ stderrTotalChars: stderrResult.totalChars,
239
268
  };
240
269
  }
241
270
  finally
@@ -247,6 +276,10 @@ const spawnAndCollect = async (
247
276
  {
248
277
  cancelTimeout(killTimeout);
249
278
  }
279
+ if (operationTimeout)
280
+ {
281
+ cancelTimeout(operationTimeout);
282
+ }
250
283
  }
251
284
  };
252
285
 
@@ -1217,6 +1250,47 @@ const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "
1217
1250
  return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
1218
1251
  };
1219
1252
 
1253
+ type CanceledSwitchOutcome = {
1254
+ kind: "canceled";
1255
+ strategy: "cancel-with-pending";
1256
+ branchBefore: string;
1257
+ branchTarget: string;
1258
+ pendingSummary: PendingSummary;
1259
+ pendingSummaryDetailed: PendingItemSummary;
1260
+ pendingPolicy: "cancel";
1261
+ defaultedPolicy: boolean;
1262
+ reason: string;
1263
+ };
1264
+
1265
+ const createCanceledSwitchOutcome = (
1266
+ branchBefore: string,
1267
+ branchTarget: string,
1268
+ pendingSummary: PendingSummary,
1269
+ pendingSummaryDetailed: PendingItemSummary,
1270
+ pendingChoice: "shelve" | "bring" | "cancel",
1271
+ defaultedPolicy: boolean,
1272
+ ): CanceledSwitchOutcome | undefined =>
1273
+ {
1274
+ if (pendingChoice !== "cancel" || pendingSummaryDetailed.totalPending === 0 || isSameBranchSpec(branchBefore, branchTarget))
1275
+ {
1276
+ return undefined;
1277
+ }
1278
+
1279
+ return {
1280
+ kind: "canceled",
1281
+ strategy: "cancel-with-pending",
1282
+ branchBefore,
1283
+ branchTarget,
1284
+ pendingSummary,
1285
+ pendingSummaryDetailed,
1286
+ pendingPolicy: "cancel",
1287
+ defaultedPolicy,
1288
+ reason: defaultedPolicy
1289
+ ? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
1290
+ : "Switch canceled because pending changes were detected and pendingChanges was set to cancel.",
1291
+ };
1292
+ };
1293
+
1220
1294
  const canSwitchDirectWithPrivateOnlyPending = (
1221
1295
  pendingChoice: "shelve" | "bring" | "cancel",
1222
1296
  defaultedPolicy: boolean,
@@ -2566,30 +2640,130 @@ const buildMergeInProgressCheckinMessage = async (originalMessage: string, workd
2566
2640
  return lines.join("\n");
2567
2641
  };
2568
2642
 
2569
- const resolveBranchParentName = async (branch: string, workdir?: string): Promise<string | undefined> =>
2643
+ type BranchParentLookupOutcome =
2644
+ | { kind: "resolved"; branch: string; parent: string }
2645
+ | { kind: "root"; branch: string; matchedBranch: string }
2646
+ | { kind: "not-found"; branch: string; attemptedCandidates: string[] }
2647
+ | { kind: "malformed-output"; branch: string; attemptedCandidates: string[]; diagnostics: string[] }
2648
+ | { kind: "command-failed"; branch: string; attemptedCandidates: string[]; diagnostics: string[] };
2649
+
2650
+ type BranchParentRow = { name: string; parent?: string };
2651
+
2652
+ const parseBranchParentRows = (output: string): BranchParentRow[] | undefined =>
2653
+ {
2654
+ const lines = normalizeFindOutputLines(output);
2655
+ const rows: BranchParentRow[] = [];
2656
+ for (const line of lines)
2657
+ {
2658
+ const separator = line.indexOf("|");
2659
+ if (separator <= 0 || line.indexOf("|", separator + 1) !== -1)
2660
+ {
2661
+ return undefined;
2662
+ }
2663
+ const name = line.slice(0, separator).trim();
2664
+ const parent = line.slice(separator + 1).trim();
2665
+ if (!name)
2666
+ {
2667
+ return undefined;
2668
+ }
2669
+ rows.push({ name, ...(parent ? { parent } : {}) });
2670
+ }
2671
+ return rows;
2672
+ };
2673
+
2674
+ const branchRepositoryQualifier = (branch: string): string | undefined =>
2675
+ {
2676
+ const match = branch.trim().match(/^(?:br:)?[^@]+(@.+)$/);
2677
+ return match?.[1];
2678
+ };
2679
+
2680
+ const branchRepositorySpec = (branch: string): string | undefined =>
2681
+ {
2682
+ const qualifier = branchRepositoryQualifier(branch);
2683
+ return qualifier?.slice(1);
2684
+ };
2685
+
2686
+ const branchNameForLookup = (branch: string): string => normalizeBranchSpecForComparison(branch);
2687
+
2688
+ const qualifyBranchName = (branchName: string, requestedBranch: string): string =>
2689
+ {
2690
+ const qualifier = branchRepositoryQualifier(requestedBranch);
2691
+ return qualifier && !branchName.includes("@") ? `${branchName}${qualifier}` : branchName;
2692
+ };
2693
+
2694
+ const resolveBranchParentName = async (branch: string, workdir?: string): Promise<BranchParentLookupOutcome> =>
2570
2695
  {
2571
- const normalizedBranch = normalizeBranchSpecForComparison(branch);
2572
- const candidates = Array.from(new Set([branch.trim(), normalizedBranch, `br:${normalizedBranch}`]
2573
- .filter((candidate) => candidate.length > 0)));
2696
+ const requestedBranch = branch.trim();
2697
+ // A qualified selector is identity-bearing input; never strip its repository/server.
2698
+ const lookupBranchName = branchNameForLookup(requestedBranch);
2699
+ const candidates = Array.from(new Set(
2700
+ (requestedBranch.includes("@")
2701
+ ? [lookupBranchName]
2702
+ : [requestedBranch, lookupBranchName, `br:${lookupBranchName}`])
2703
+ .filter((candidate) => candidate.length > 0),
2704
+ ));
2705
+ const diagnostics: string[] = [];
2706
+ const repositorySpec = branchRepositorySpec(requestedBranch);
2707
+ const repositoryClause = repositorySpec
2708
+ ? ` on repository '${repositorySpec.replace(/'/g, "''")}'`
2709
+ : "";
2574
2710
 
2575
2711
  for (const candidate of candidates)
2576
2712
  {
2577
- const output = await runCmRaw([
2578
- "find",
2579
- "branch",
2580
- `where ${cmWhereEquals("name", candidate)}`,
2581
- "--format={parent}",
2582
- "--nototal",
2583
- ], workdir).catch(() => "");
2584
- const lines = normalizeFindOutputLines(output);
2585
- const parent = lines[0]?.trim();
2586
- if (parent)
2713
+ try
2587
2714
  {
2588
- return parent;
2715
+ const output = await runCmRaw([
2716
+ "find",
2717
+ "branch",
2718
+ `where ${cmWhereEquals("name", candidate)}${repositoryClause}`,
2719
+ "--format={name}|{parent}",
2720
+ "--nototal",
2721
+ ], workdir);
2722
+ const rows = parseBranchParentRows(output);
2723
+ if (!rows || rows.length > 1)
2724
+ {
2725
+ return {
2726
+ kind: "malformed-output",
2727
+ branch: requestedBranch,
2728
+ attemptedCandidates: candidates,
2729
+ diagnostics: [`Expected one name|parent row, received ${rows ? rows.length : "malformed"}.`],
2730
+ };
2731
+ }
2732
+ const row = rows[0];
2733
+ if (row && branchNameForLookup(row.name) !== lookupBranchName)
2734
+ {
2735
+ return {
2736
+ kind: "malformed-output",
2737
+ branch: requestedBranch,
2738
+ attemptedCandidates: candidates,
2739
+ diagnostics: ["Returned branch identity did not match the requested branch name."],
2740
+ };
2741
+ }
2742
+ if (row?.parent)
2743
+ {
2744
+ return {
2745
+ kind: "resolved",
2746
+ branch: qualifyBranchName(row.name, requestedBranch),
2747
+ parent: qualifyBranchName(row.parent, requestedBranch),
2748
+ };
2749
+ }
2750
+ if (row)
2751
+ {
2752
+ return { kind: "root", branch: requestedBranch, matchedBranch: qualifyBranchName(row.name, requestedBranch) };
2753
+ }
2754
+ }
2755
+ catch (error)
2756
+ {
2757
+ diagnostics.push(normalizeErrorMessage(error).slice(0, 256));
2589
2758
  }
2590
2759
  }
2591
2760
 
2592
- return undefined;
2761
+ if (diagnostics.length > 0)
2762
+ {
2763
+ return { kind: "command-failed", branch: requestedBranch, attemptedCandidates: candidates, diagnostics: diagnostics.slice(0, 3) };
2764
+ }
2765
+
2766
+ return { kind: "not-found", branch: requestedBranch, attemptedCandidates: candidates };
2593
2767
  };
2594
2768
 
2595
2769
  const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
@@ -4047,11 +4221,17 @@ export const switchBranch = tool({
4047
4221
  throw new Error(reason);
4048
4222
  }
4049
4223
 
4050
- if (pendingChoice === "cancel")
4224
+ const canceledOutcome = createCanceledSwitchOutcome(
4225
+ branchBefore,
4226
+ args.branch,
4227
+ pendingSummary,
4228
+ pendingSummaryDetailed,
4229
+ pendingChoice,
4230
+ defaulted,
4231
+ );
4232
+ if (canceledOutcome)
4051
4233
  {
4052
- const reason = defaulted
4053
- ? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
4054
- : "Switch canceled because pending changes were detected and pendingChanges was set to cancel.";
4234
+ const reason = canceledOutcome.reason;
4055
4235
  if (preflight)
4056
4236
  {
4057
4237
  return toStructuredResult(
@@ -4360,6 +4540,340 @@ export const merge = tool({
4360
4540
  },
4361
4541
  });
4362
4542
 
4543
+ type QualifiedServerBranch = {
4544
+ raw: string;
4545
+ branch: string;
4546
+ repository: string;
4547
+ server: string;
4548
+ };
4549
+
4550
+ type ServerMergeRecord = {
4551
+ operation: string;
4552
+ fields: string[];
4553
+ };
4554
+
4555
+ type ServerMergeParse = {
4556
+ records: ServerMergeRecord[];
4557
+ malformed: boolean;
4558
+ unknownOperations: string[];
4559
+ changesets: Array<{ id: string; branch: string; repository: string; mount: string }>;
4560
+ isAlreadyConnected: boolean;
4561
+ hasConflict: boolean;
4562
+ };
4563
+
4564
+ const SERVER_MERGE_OUTPUT_LIMIT = 16_384;
4565
+ const SERVER_MERGE_HELP_TIMEOUT_MS = 3_000;
4566
+ const SERVER_MERGE_COMMAND_TIMEOUT_MS = 30_000;
4567
+ const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]/;
4568
+ const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
4569
+
4570
+ const assertSafeServerMergeValue = (name: string, value: string): string =>
4571
+ {
4572
+ const trimmed = value.trim();
4573
+ if (!trimmed || serverMergeControlPattern.test(value))
4574
+ {
4575
+ throw new Error(`${name} must be non-empty and must not contain control characters.`);
4576
+ }
4577
+
4578
+ return trimmed;
4579
+ };
4580
+
4581
+ const parseQualifiedServerBranch = (name: string, value: string): QualifiedServerBranch =>
4582
+ {
4583
+ const raw = assertSafeServerMergeValue(name, value);
4584
+ if (!raw.startsWith("br:/"))
4585
+ {
4586
+ throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
4587
+ }
4588
+
4589
+ const lastAt = raw.lastIndexOf("@");
4590
+ const previousAt = raw.lastIndexOf("@", lastAt - 1);
4591
+ if (previousAt <= 3 || lastAt <= previousAt + 1 || lastAt === raw.length - 1)
4592
+ {
4593
+ throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
4594
+ }
4595
+
4596
+ const branch = raw.slice(3, previousAt);
4597
+ const repository = raw.slice(previousAt + 1, lastAt);
4598
+ const server = raw.slice(lastAt + 1);
4599
+ if (!branch.startsWith("/") || branch.endsWith("/") || [branch, repository, server].some((part) => !part || part !== part.trim() || serverMergeControlPattern.test(part)))
4600
+ {
4601
+ throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
4602
+ }
4603
+
4604
+ return { raw, branch, repository, server };
4605
+ };
4606
+
4607
+ const createServerMergeSeparators = (): { start: string; end: string; field: string } =>
4608
+ {
4609
+ const nonce = randomBytes(16).toString("hex");
4610
+ return {
4611
+ start: `__PI_PLASTIC_MERGE_START_${nonce}__`,
4612
+ end: `__PI_PLASTIC_MERGE_END_${nonce}__`,
4613
+ field: `__PI_PLASTIC_MERGE_FIELD_${nonce}__`,
4614
+ };
4615
+ };
4616
+
4617
+ const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }): ServerMergeParse =>
4618
+ {
4619
+ const records: ServerMergeRecord[] = [];
4620
+ const unknownOperations = new Set<string>();
4621
+ let malformed = false;
4622
+ let cursor = 0;
4623
+
4624
+ while (true)
4625
+ {
4626
+ const start = output.indexOf(separators.start, cursor);
4627
+ if (start < 0)
4628
+ {
4629
+ if (output.slice(cursor).includes(separators.end) || output.slice(cursor).includes(separators.field))
4630
+ {
4631
+ malformed = true;
4632
+ }
4633
+ break;
4634
+ }
4635
+ const prefix = output.slice(cursor, start);
4636
+ if (prefix.includes(separators.end) || prefix.includes(separators.field))
4637
+ {
4638
+ malformed = true;
4639
+ }
4640
+ const payloadStart = start + separators.start.length;
4641
+ const end = output.indexOf(separators.end, payloadStart);
4642
+ if (end < 0)
4643
+ {
4644
+ malformed = true;
4645
+ break;
4646
+ }
4647
+ const payload = output.slice(payloadStart, end);
4648
+ if (!payload || payload.includes(separators.start) || payload.includes(separators.end))
4649
+ {
4650
+ malformed = true;
4651
+ }
4652
+ else
4653
+ {
4654
+ const fields = payload.split(separators.field);
4655
+ const operation = fields[0] ?? "";
4656
+ if (!operation || fields.some((field) => field.length === 0))
4657
+ {
4658
+ malformed = true;
4659
+ }
4660
+ else
4661
+ {
4662
+ records.push({ operation, fields });
4663
+ if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT"].includes(operation))
4664
+ {
4665
+ unknownOperations.add(operation);
4666
+ malformed = true;
4667
+ }
4668
+ }
4669
+ }
4670
+ cursor = end + separators.end.length;
4671
+ }
4672
+
4673
+ const changesets: Array<{ id: string; branch: string; repository: string; mount: string }> = [];
4674
+ let isAlreadyConnected = false;
4675
+ let hasConflict = false;
4676
+ for (const record of records)
4677
+ {
4678
+ switch (record.operation)
4679
+ {
4680
+ case "FILE_SRC":
4681
+ if (record.fields.length !== 5 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every((field) => /^\d+$/.test(field)))
4682
+ {
4683
+ malformed = true;
4684
+ }
4685
+ break;
4686
+ case "CHANGESET":
4687
+ {
4688
+ if (record.fields.length !== 2)
4689
+ {
4690
+ malformed = true;
4691
+ break;
4692
+ }
4693
+ const match = record.fields[1]?.match(/^cs:(\d+)@(?<branch>\/[^@]+)@(?<repository>[^@]+) \(mount:'(?<mount>[^']+)'\)$/);
4694
+ if (!match?.groups)
4695
+ {
4696
+ malformed = true;
4697
+ break;
4698
+ }
4699
+ changesets.push({
4700
+ id: match[1]!,
4701
+ branch: match.groups.branch!,
4702
+ repository: match.groups.repository!,
4703
+ mount: match.groups.mount!,
4704
+ });
4705
+ break;
4706
+ }
4707
+ case "STATUS":
4708
+ if (record.fields.length !== 3 || record.fields[1] !== "ALREADY_CONNECTED" || record.fields[2] !== "No merges detected" || isAlreadyConnected)
4709
+ {
4710
+ malformed = true;
4711
+ }
4712
+ isAlreadyConnected = true;
4713
+ break;
4714
+ case "FILE_CONFLICT":
4715
+ if (record.fields.length !== 6 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every((field) => /^\d+$/.test(field)))
4716
+ {
4717
+ malformed = true;
4718
+ }
4719
+ hasConflict = true;
4720
+ break;
4721
+ default:
4722
+ // Unknown operations were recorded above and make the response uncertain.
4723
+ break;
4724
+ }
4725
+ }
4726
+ return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict };
4727
+ };
4728
+
4729
+ const formatServerMergeResult = async (
4730
+ format: OutputFormat,
4731
+ outcome: "preflight" | "completed" | "no-op" | "conflict" | "uncertain" | "unsupported",
4732
+ text: string,
4733
+ data: Record<string, unknown>,
4734
+ ): Promise<string> =>
4735
+ {
4736
+ if (format === "text")
4737
+ {
4738
+ return text;
4739
+ }
4740
+ return `## merge-branches\n\n\`\`\`json\n${JSON.stringify({
4741
+ ok: outcome === "completed" || outcome === "preflight" || outcome === "no-op",
4742
+ action: "merge-branches",
4743
+ outcome,
4744
+ toolVersion: TOOL_VERSION,
4745
+ data,
4746
+ }, null, 2)}\n\`\`\``;
4747
+ };
4748
+
4749
+ const getServerMergeCapability = async (): Promise<{ supported: boolean; diagnostics: string }> =>
4750
+ {
4751
+ const result = await spawnAndCollect(getCmExecutable(), ["help", "merge"], process.cwd(), undefined, getActiveAbortSignal(), {
4752
+ ...(commandExecutionStorage.getStore() ?? {}),
4753
+ timeoutMs: SERVER_MERGE_HELP_TIMEOUT_MS,
4754
+ outputLimitChars: SERVER_MERGE_OUTPUT_LIMIT,
4755
+ });
4756
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
4757
+ const missing = serverMergeCapabilityTokens.filter((token) => !output.includes(token));
4758
+ if (result.aborted || result.timedOut || result.exitCode !== 0 || result.stdoutTruncated || result.stderrTruncated || missing.length > 0)
4759
+ {
4760
+ return { supported: false, diagnostics: `Local cm help merge capability check did not prove required syntax${missing.length > 0 ? `: missing ${missing.join(", ")}` : "."}` };
4761
+ }
4762
+ return { supported: true, diagnostics: "Local cm help merge advertised the required merge-to and machine-readable syntax. Server capability remains unverified until dispatch." };
4763
+ };
4764
+
4765
+ export const mergeBranches = tool({
4766
+ description: "Perform one bounded workspace-free server-side merge between explicitly qualified branches; merge-link and xlink effects remain unverified.",
4767
+ args: {
4768
+ source: tool.schema.string().min(1).describe("Fully qualified source branch: br:/<branch>@<repository>@<server>."),
4769
+ target: tool.schema.string().min(1).describe("Fully qualified target branch in the same repository/server as source."),
4770
+ message: tool.schema.string().min(1).describe("Non-empty changeset comment; no editor fallback is allowed."),
4771
+ preflight: tool.schema.boolean().optional().describe("Render the exact command only; does not contact Plastic or analyze remote conflicts."),
4772
+ format: outputFormatArg,
4773
+ },
4774
+ async execute(args)
4775
+ {
4776
+ const format = args.format ?? "text";
4777
+ const source = parseQualifiedServerBranch("source", args.source);
4778
+ const target = parseQualifiedServerBranch("target", args.target);
4779
+ const message = assertSafeServerMergeValue("message", args.message);
4780
+ if (source.repository !== target.repository || source.server !== target.server)
4781
+ {
4782
+ throw new Error("source and target must identify the same exact repository and server.");
4783
+ }
4784
+ if (source.branch === target.branch)
4785
+ {
4786
+ throw new Error("source and target must identify different branches.");
4787
+ }
4788
+
4789
+ const separators = createServerMergeSeparators();
4790
+ const command = [
4791
+ "merge", source.raw, `--to=${target.raw}`, "--merge", `-c=${message}`, "--nointeractiveresolution", "--machinereadable",
4792
+ `--startlineseparator=${separators.start}`, `--endlineseparator=${separators.end}`, `--fieldseparator=${separators.field}`,
4793
+ ];
4794
+ const requestedIdentity = { source: source.raw, target: target.raw, repository: source.repository, server: source.server };
4795
+ if (args.preflight)
4796
+ {
4797
+ return formatServerMergeResult(format, "preflight", [
4798
+ "## Server Merge Preflight",
4799
+ "",
4800
+ "- Would run: yes",
4801
+ "- Remote analysis: not performed",
4802
+ `- Command: cm ${command.join(" ")}`,
4803
+ ].join("\n"), { wouldRun: true, requestedIdentity, command: ["cm", ...command] });
4804
+ }
4805
+
4806
+ const capability = await getServerMergeCapability();
4807
+ if (!capability.supported)
4808
+ {
4809
+ return formatServerMergeResult(format, "unsupported", "## Server Merge Unsupported\n\n- No merge command was dispatched because local client syntax could not be proven.", {
4810
+ requestedIdentity,
4811
+ capability,
4812
+ dispatched: false,
4813
+ });
4814
+ }
4815
+
4816
+ const attempt = await spawnAndCollect(getCmExecutable(), command, process.cwd(), undefined, getActiveAbortSignal(), {
4817
+ ...(commandExecutionStorage.getStore() ?? {}),
4818
+ timeoutMs: SERVER_MERGE_COMMAND_TIMEOUT_MS,
4819
+ outputLimitChars: SERVER_MERGE_OUTPUT_LIMIT,
4820
+ });
4821
+ const output = [attempt.stdout, attempt.stderr].filter(Boolean).join("\n");
4822
+ const parsed = parseServerMergeOutput(output, separators);
4823
+ const matchingChangesets = parsed.changesets.filter((changeset) => changeset.branch === target.branch && changeset.repository === target.repository && changeset.mount === "/");
4824
+ const effect = "not-proven";
4825
+ const diagnostics = output.slice(0, 4_000);
4826
+ const baseData = {
4827
+ requestedIdentity,
4828
+ capability,
4829
+ command: ["cm", ...command],
4830
+ dispatched: true,
4831
+ exitCode: attempt.exitCode,
4832
+ aborted: attempt.aborted,
4833
+ timedOut: attempt.timedOut,
4834
+ outputTruncated: Boolean(attempt.stdoutTruncated || attempt.stderrTruncated),
4835
+ records: parsed.records,
4836
+ observedChangesets: parsed.changesets,
4837
+ diagnostics,
4838
+ mergeLinkIdentity: "unverified",
4839
+ xlinkEffects: "unverified",
4840
+ effect,
4841
+ };
4842
+
4843
+ const ambiguous = attempt.aborted || attempt.timedOut || attempt.stdoutTruncated || attempt.stderrTruncated || parsed.malformed
4844
+ || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1
4845
+ || (parsed.changesets.length > 0 && matchingChangesets.length !== 1)
4846
+ || (parsed.hasConflict && parsed.changesets.length > 0)
4847
+ || (parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict));
4848
+ if (!ambiguous && attempt.exitCode === 0 && matchingChangesets.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected)
4849
+ {
4850
+ const changeset = matchingChangesets[0]!;
4851
+ return formatServerMergeResult(format, "completed", `## Server Merge Completed\n\n- Created target changeset: cs:${changeset.id}\n- Merge-link identity: unverified\n- Xlink effects: unverified`, {
4852
+ ...baseData,
4853
+ createdChangeset: changeset,
4854
+ effect: "changeset-created",
4855
+ });
4856
+ }
4857
+ if (!ambiguous && attempt.exitCode === 0 && parsed.isAlreadyConnected && parsed.changesets.length === 0 && !parsed.hasConflict)
4858
+ {
4859
+ return formatServerMergeResult(format, "no-op", "## Server Merge No-op\n\n- Plastic reported ALREADY_CONNECTED.\n- Effect: not independently verified.", baseData);
4860
+ }
4861
+ if (!ambiguous && attempt.exitCode !== 0 && parsed.hasConflict && parsed.changesets.length === 0)
4862
+ {
4863
+ return formatServerMergeResult(format, "conflict", "## Server Merge Conflict\n\n- Plastic reported a file conflict.\n- Effect: uncertain; inspect the server before any retry.", { ...baseData, effect: "uncertain" });
4864
+ }
4865
+ return formatServerMergeResult(format, "uncertain", "## Server Merge Uncertain\n\n- The command may have had an effect, but its bounded output does not prove a safe classification.\n- Do not retry automatically; inspect server state first.", {
4866
+ ...baseData,
4867
+ effect: "uncertain",
4868
+ parseWarnings: {
4869
+ malformed: parsed.malformed,
4870
+ unknownOperations: parsed.unknownOperations,
4871
+ matchingChangesetCount: matchingChangesets.length,
4872
+ },
4873
+ });
4874
+ },
4875
+ });
4876
+
4363
4877
  export const mergeToBranch = tool({
4364
4878
  description: "Merge a source branch into a target branch using safe Plastic update, switch, merge, and checkin steps.",
4365
4879
  args: {
@@ -4379,11 +4893,19 @@ export const mergeToBranch = tool({
4379
4893
  const format = args.format ?? "text";
4380
4894
  const startingBranch = await resolveCurrentBranchName(args.workdir);
4381
4895
  const sourceBranch = args.source ?? startingBranch;
4382
- const resolvedParentBranch = args.target ? undefined : await resolveBranchParentName(sourceBranch, args.workdir);
4383
- if (!args.target && !resolvedParentBranch)
4384
- {
4385
- throw new Error(`Unable to resolve the parent branch for ${sourceBranch}. Pass target explicitly.`);
4386
- }
4896
+ const parentLookup = args.target ? undefined : await resolveBranchParentName(sourceBranch, args.workdir);
4897
+ if (!args.target && parentLookup?.kind !== "resolved")
4898
+ {
4899
+ const details = parentLookup?.kind === "command-failed"
4900
+ ? ` Plastic lookup failed: ${parentLookup.diagnostics.join(" | ")}`
4901
+ : parentLookup?.kind === "malformed-output"
4902
+ ? ` Plastic lookup returned unusable output: ${parentLookup.diagnostics.join(" | ")}`
4903
+ : parentLookup?.kind === "root"
4904
+ ? ` ${parentLookup.matchedBranch} is a root branch with no parent.`
4905
+ : " Plastic found no matching branch row.";
4906
+ throw new Error(`Unable to resolve the parent branch for ${sourceBranch}. Pass target explicitly.${details}`);
4907
+ }
4908
+ const resolvedParentBranch = parentLookup?.kind === "resolved" ? parentLookup.parent : undefined;
4387
4909
  const targetBranch = args.target ?? resolvedParentBranch!;
4388
4910
  const strategy: MergeConflictStrategy = args.strategy ?? "auto";
4389
4911
  const updateTarget = args.updateTarget ?? true;
@@ -4448,12 +4970,50 @@ export const mergeToBranch = tool({
4448
4970
  );
4449
4971
  }
4450
4972
 
4973
+ // Inspect the cancel policy as typed data before any target-side mutation.
4974
+ const pendingBeforeSwitchItems = await getMachineReadablePendingItems(args.workdir);
4975
+ const pendingBeforeSwitchDetailed = summarizePendingItems(pendingBeforeSwitchItems, args.workdir ?? process.cwd());
4976
+ const canceledSwitchOutcome = createCanceledSwitchOutcome(
4977
+ startingBranch,
4978
+ targetBranch,
4979
+ toLegacyPendingSummary(pendingBeforeSwitchDetailed),
4980
+ pendingBeforeSwitchDetailed,
4981
+ "cancel",
4982
+ false,
4983
+ );
4984
+ if (canceledSwitchOutcome)
4985
+ {
4986
+ return toStructuredResult(
4987
+ "merge-to-branch",
4988
+ format,
4989
+ [
4990
+ "## Merge To Branch Blocked",
4991
+ "",
4992
+ `- Source branch: ${sourceBranch}`,
4993
+ `- Target branch: ${targetBranch}`,
4994
+ `- ${canceledSwitchOutcome.reason}`,
4995
+ "- No switch, update, merge, checkin, or shelveset command was run.",
4996
+ ].join("\n"),
4997
+ {
4998
+ sourceBranch,
4999
+ targetBranch,
5000
+ switchOutcome: canceledSwitchOutcome,
5001
+ checkedIn: false,
5002
+ },
5003
+ args.workdir,
5004
+ [canceledSwitchOutcome.reason],
5005
+ "Resolve or shelve pending changes before retrying the merge closeout.",
5006
+ );
5007
+ }
5008
+
4451
5009
  const switchResult = await switchBranch.execute({
4452
5010
  branch: targetBranch,
4453
5011
  pendingChanges: "cancel",
4454
5012
  format: "json",
4455
5013
  workdir: args.workdir,
4456
5014
  });
5015
+ const branchAfterSwitch = await resolveCurrentBranchName(args.workdir);
5016
+ assertWorkspaceOnBranch(branchAfterSwitch, targetBranch, "branch switch and before target update");
4457
5017
  const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
4458
5018
  const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
4459
5019
  assertWorkspaceOnBranch(branchBeforeMerge, targetBranch, "target update and before merge");
@@ -33,6 +33,7 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
33
33
  { 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
34
  { 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
35
  { 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."] },
36
37
  { 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."] },
37
38
  { 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."] },
38
39
  { 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."] },