@aefree/pi-plastic 0.5.7 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.6.0] - 2026-09-14
11
+
12
+ ### Added
13
+
14
+ - 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.
15
+
16
+ ### Changed
17
+
18
+ - 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.
19
+ - Update README's pinned GitHub installation examples to `v0.6.0`.
20
+
21
+ ### Fixed
22
+
23
+ - 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.
24
+ - 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`.
25
+ - 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.
26
+
27
+ ## [0.5.8] - 2026-09-15
28
+
29
+ ### Fixed
30
+
31
+ - Resolve pending-diff workspace containment and Xlink ownership by filesystem identity when a workspace is accessed through a real filesystem alias, while retaining the caller's lexical path for Plastic commands and displayed paths. Missing deleted or moved ownership ancestors still fail closed.
32
+ - Retry only npm registry E404 availability checks for a bounded period after trusted publishing; a mismatched, malformed, or unauthorized release identity still fails immediately.
33
+
10
34
  ## [0.5.7] - 2026-09-14
11
35
 
12
36
  ### 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
 
@@ -75,10 +76,12 @@ The Bash guards run only in Pi processes that actually load this package. A proj
75
76
  - `plastic_branchCreate` supports an explicit parent branch independent of the loaded workspace branch, defaults relative names to the current branch when no parent is supplied, and rejects top-level paths unless `allowRootBranch=true` is explicit.
76
77
  - `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Automatic pending-file bases bind each status revision ID to its owning workspace or Xlink repository/server, including mixed-Xlink reviews. If ownership cannot be resolved, that comparison is reported unavailable rather than retrieving a bare revision from the caller repository. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
77
78
  - `plastic_status(machineReadable=true, format="json")` returns parsed `items`, aggregate `summary`, and `itemCount` (`total`, `returned`, `omitted`) rather than requiring agents to parse Plastic rows. It returns at most 100 items by default; set `maxItems` (up to 500) when more are intentional. `includeRaw=true` adds the original unbounded Plastic output for diagnostics, so omit it for normal agent use to conserve context tokens.
79
+ - Pending-diff ownership containment uses filesystem identity, so a workspace reached through a symlink, junction, or platform filesystem alias still resolves its workspace and nested Xlink bases safely. Plastic commands and returned labels retain the caller's original lexical path; missing deleted or moved ownership ancestors remain unavailable rather than falling back to a parent repository.
78
80
  - Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
79
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>`.
80
82
  - Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
81
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.
82
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.
83
86
  - `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
84
87
 
@@ -108,13 +111,13 @@ pi install npm:@aefree/pi-plastic
108
111
  Install a pinned GitHub release over HTTPS:
109
112
 
110
113
  ```bash
111
- pi install git:github.com/aefreedman/pi-plastic@v0.5.7
114
+ pi install git:github.com/aefreedman/pi-plastic@v0.6.0
112
115
  ```
113
116
 
114
117
  Equivalent SSH install:
115
118
 
116
119
  ```bash
117
- pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
120
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.6.0
118
121
  ```
119
122
 
120
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.7",
3
+ "version": "0.6.0",
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": {
@@ -16,6 +16,8 @@ Treat `plastic_merge` success as provisional until `plastic_status` confirms the
16
16
 
17
17
  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
18
 
19
+ 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.
20
+
19
21
  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
22
 
21
23
  `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.
@@ -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
 
@@ -406,6 +439,37 @@ const findCanonicalExistingAncestor = (absolutePath: string): string | null =>
406
439
  }
407
440
  };
408
441
 
442
+ const toFilesystemIdentityPath = async (absolutePath: string): Promise<string> =>
443
+ {
444
+ let candidate = absolutePath;
445
+ const missingSegments: string[] = [];
446
+ while (true)
447
+ {
448
+ try
449
+ {
450
+ const canonicalAncestor = (await fs.realpath(candidate)).replace(/\\/g, "/");
451
+ return missingSegments.reduce((identityPath, segment) => join(identityPath, segment), canonicalAncestor).replace(/\\/g, "/");
452
+ }
453
+ catch (error)
454
+ {
455
+ const code = (error as NodeJS.ErrnoException).code;
456
+ if (code !== "ENOENT" && code !== "ENOTDIR")
457
+ {
458
+ throw error;
459
+ }
460
+
461
+ const parent = dirname(candidate);
462
+ if (parent === candidate)
463
+ {
464
+ throw error;
465
+ }
466
+
467
+ missingSegments.unshift(basename(candidate));
468
+ candidate = parent;
469
+ }
470
+ }
471
+ };
472
+
409
473
  const toggleFirstAsciiLetterCase = (value: string): string | null =>
410
474
  {
411
475
  const index = value.search(/[A-Za-z]/);
@@ -1186,6 +1250,47 @@ const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "
1186
1250
  return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
1187
1251
  };
1188
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
+
1189
1294
  const canSwitchDirectWithPrivateOnlyPending = (
1190
1295
  pendingChoice: "shelve" | "bring" | "cancel",
1191
1296
  defaultedPolicy: boolean,
@@ -1814,9 +1919,10 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
1814
1919
  });
1815
1920
  return workspaceRepository;
1816
1921
  };
1817
- const getXlinkRepository = (candidate: string): Promise<RepositoryIdentity | null> =>
1922
+ const getXlinkRepository = async (candidate: string): Promise<RepositoryIdentity | null> =>
1818
1923
  {
1819
- const key = toPathComparisonKeyFromAbsolutePath(candidate);
1924
+ // Use the physical identity for cache keys, but keep candidate lexical for cm.
1925
+ const key = toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate));
1820
1926
  let lookup = xlinkLookups.get(key);
1821
1927
  if (!lookup)
1822
1928
  {
@@ -1851,15 +1957,22 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
1851
1957
  throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
1852
1958
  }
1853
1959
  const root = await getWorkspaceRoot();
1960
+ const rootIdentity = await toFilesystemIdentityPath(root);
1854
1961
  const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
1855
1962
  ? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
1856
1963
  : item.normalizedPath;
1857
- if (!isWithinPathScope(toPathComparisonKeyFromAbsolutePath(ownershipPath), toPathComparisonKeyFromAbsolutePath(root)))
1964
+ if (!isWithinPathScope(
1965
+ toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(ownershipPath)),
1966
+ toPathComparisonKeyFromAbsolutePath(rootIdentity),
1967
+ ))
1858
1968
  {
1859
1969
  throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
1860
1970
  }
1861
1971
  let candidate = dirname(ownershipPath);
1862
- while (isWithinPathScope(toPathComparisonKeyFromAbsolutePath(candidate), toPathComparisonKeyFromAbsolutePath(root)))
1972
+ while (isWithinPathScope(
1973
+ toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate)),
1974
+ toPathComparisonKeyFromAbsolutePath(rootIdentity),
1975
+ ))
1863
1976
  {
1864
1977
  // A missing ancestor can be a removed Xlink mount. Status does not
1865
1978
  // retain ownership, so parent-repository fallback would be unsafe.
@@ -1873,7 +1986,8 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
1873
1986
  item.baseRepository = `${repository.repository}@${repository.server}`;
1874
1987
  return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
1875
1988
  }
1876
- if (candidate === root)
1989
+ if (toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate))
1990
+ === toPathComparisonKeyFromAbsolutePath(rootIdentity))
1877
1991
  {
1878
1992
  break;
1879
1993
  }
@@ -2526,30 +2640,130 @@ const buildMergeInProgressCheckinMessage = async (originalMessage: string, workd
2526
2640
  return lines.join("\n");
2527
2641
  };
2528
2642
 
2529
- 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 =>
2530
2675
  {
2531
- const normalizedBranch = normalizeBranchSpecForComparison(branch);
2532
- const candidates = Array.from(new Set([branch.trim(), normalizedBranch, `br:${normalizedBranch}`]
2533
- .filter((candidate) => candidate.length > 0)));
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> =>
2695
+ {
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
+ : "";
2534
2710
 
2535
2711
  for (const candidate of candidates)
2536
2712
  {
2537
- const output = await runCmRaw([
2538
- "find",
2539
- "branch",
2540
- `where ${cmWhereEquals("name", candidate)}`,
2541
- "--format={parent}",
2542
- "--nototal",
2543
- ], workdir).catch(() => "");
2544
- const lines = normalizeFindOutputLines(output);
2545
- const parent = lines[0]?.trim();
2546
- if (parent)
2713
+ try
2547
2714
  {
2548
- 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));
2549
2758
  }
2550
2759
  }
2551
2760
 
2552
- 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 };
2553
2767
  };
2554
2768
 
2555
2769
  const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
@@ -4007,11 +4221,17 @@ export const switchBranch = tool({
4007
4221
  throw new Error(reason);
4008
4222
  }
4009
4223
 
4010
- if (pendingChoice === "cancel")
4224
+ const canceledOutcome = createCanceledSwitchOutcome(
4225
+ branchBefore,
4226
+ args.branch,
4227
+ pendingSummary,
4228
+ pendingSummaryDetailed,
4229
+ pendingChoice,
4230
+ defaulted,
4231
+ );
4232
+ if (canceledOutcome)
4011
4233
  {
4012
- const reason = defaulted
4013
- ? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
4014
- : "Switch canceled because pending changes were detected and pendingChanges was set to cancel.";
4234
+ const reason = canceledOutcome.reason;
4015
4235
  if (preflight)
4016
4236
  {
4017
4237
  return toStructuredResult(
@@ -4320,6 +4540,340 @@ export const merge = tool({
4320
4540
  },
4321
4541
  });
4322
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
+
4323
4877
  export const mergeToBranch = tool({
4324
4878
  description: "Merge a source branch into a target branch using safe Plastic update, switch, merge, and checkin steps.",
4325
4879
  args: {
@@ -4339,11 +4893,19 @@ export const mergeToBranch = tool({
4339
4893
  const format = args.format ?? "text";
4340
4894
  const startingBranch = await resolveCurrentBranchName(args.workdir);
4341
4895
  const sourceBranch = args.source ?? startingBranch;
4342
- const resolvedParentBranch = args.target ? undefined : await resolveBranchParentName(sourceBranch, args.workdir);
4343
- if (!args.target && !resolvedParentBranch)
4344
- {
4345
- throw new Error(`Unable to resolve the parent branch for ${sourceBranch}. Pass target explicitly.`);
4346
- }
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;
4347
4909
  const targetBranch = args.target ?? resolvedParentBranch!;
4348
4910
  const strategy: MergeConflictStrategy = args.strategy ?? "auto";
4349
4911
  const updateTarget = args.updateTarget ?? true;
@@ -4408,12 +4970,50 @@ export const mergeToBranch = tool({
4408
4970
  );
4409
4971
  }
4410
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
+
4411
5009
  const switchResult = await switchBranch.execute({
4412
5010
  branch: targetBranch,
4413
5011
  pendingChanges: "cancel",
4414
5012
  format: "json",
4415
5013
  workdir: args.workdir,
4416
5014
  });
5015
+ const branchAfterSwitch = await resolveCurrentBranchName(args.workdir);
5016
+ assertWorkspaceOnBranch(branchAfterSwitch, targetBranch, "branch switch and before target update");
4417
5017
  const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
4418
5018
  const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
4419
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."] },