@aefree/pi-plastic 0.5.2 → 0.5.4

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
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows semantic versioning for public package releases.
7
7
 
8
+ ## [0.5.4] - 2026-08-13
9
+
10
+ ### Fixed
11
+
12
+ - Recognize both `smartbranch` and `br` branch forms in Plastic workspace selectors, including quoted values produced by different checkout workflows.
13
+ - Use a valid local selector as the footer's authoritative branch source instead of launching an unnecessary `cm status` process; malformed or missing branch selectors retain the bounded CLI fallback.
14
+
15
+ ## [0.5.3] - 2026-08-09
16
+
17
+ ### Changed
18
+
19
+ - Clarified executable routing: macOS bare `diff` is PATH-dependent and may be GNU Diffutils or Apple BSD `diff`; text-diff selection does not auto-select `gdiff`. Plastic 11 macOS validation confirmed that Apple BSD `/usr/bin/diff` supports text diffs but rejects `cm patch`'s `--binary` argument, so `plastic_patch` now identifies that contract failure and directs callers to the separate GNU Diffutils-compatible patch executable policy.
20
+
21
+ ### Fixed
22
+
23
+ - Made historical and workspace text diffs Unicode-safe for the configured Windows backend: package-owned ASCII materializations are the only operands passed to `diff`, while returned unified headers retain stable logical Unicode labels.
24
+ - Report an added empty pending file as `added-empty` with explicit text/JSON/workspace semantics instead of generic unchanged.
25
+ - Clean package-owned `cm cat --file` outputs after retrieval failures, including failure-created zero-byte files; public guidance now treats `cm cat --raw` as unsupported and names typed retrieval or `cm cat --file` as the byte-preserving fallback.
26
+ - Documented patch moved-item encoding as backend-determined (move-aware or delete/add) rather than claiming unverified runtime behavior.
27
+ - Hardened `plastic_patch` on Windows: it now uses the dedicated `PI_PLASTIC_PATCH_EXECUTABLE` policy (or an explicit `toolPath`) instead of silently reusing the incompatible GnuWin32 text-diff backend, qualifies unqualified branch selectors against the exact current workspace repository, and rejects ambiguous selectors before `cm patch`.
28
+ - Made requested patch output transactional: existing paths are rejected before generation; package-owned sibling staging is validated and atomically published only on success, with failed and zero-byte staging artifacts removed.
29
+ - Reject branch-switch and merge-closeout success when the workspace is not actually on the requested target branch, including checks after target update, before merge checkin, and after checkin.
30
+ - Clarify that Plastic merge checkins belong to the branch loaded in the workspace and cannot be integrated by switching branches afterward like a Git fast-forward.
31
+
8
32
  ## [0.5.2] - 2026-08-05
9
33
 
10
34
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@ Pi tools, footer status, and skill guidance for Plastic SCM / Unity Version Cont
4
4
 
5
5
  ## Plastic branch footer status
6
6
 
7
- When Pi starts inside a Plastic workspace, this package adds a themed `Plastic <branch>` status to Pi's built-in footer. It discovers the nearest enclosing `.plastic/plastic.workspace`, reads the local selector as a fast credential-free fallback, and confirms the branch with a bounded `cm status` call. Selector changes and successful same-workspace `plastic_*` tools refresh the status; sibling workspaces are ignored.
7
+ When Pi starts inside a Plastic workspace, this package adds a themed `Plastic <branch>` status to Pi's built-in footer. It discovers the nearest enclosing `.plastic/plastic.workspace` and reads either the selector's `smartbranch` or `br` form as the credential-free branch source. A bounded `cm status` call is used only when the selector has no valid branch. Selector changes and successful same-workspace `plastic_*` tools refresh the status; sibling workspaces are ignored.
8
8
 
9
9
  The extension owns only the `plastic-branch` status key. It does not replace Pi's footer or suppress Pi's Git branch display, so Git and Plastic information can appear together in nested workspaces. If the Plastic marker exists but neither the selector nor `cm` yields a branch, the footer shows `Plastic branch unavailable`. Non-Plastic directories show no Plastic status.
10
10
 
@@ -73,12 +73,12 @@ Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package
73
73
  The Bash guards run only in Pi processes that actually load this package. A project-local install does not protect a delegated child process whose working directory resolves different project settings. Install `pi-plastic` at user scope (the default `pi install`, without `-l`) when subagents and sessions in arbitrary workspaces must inherit the guards. Restart existing Pi processes after installation or source changes.
74
74
 
75
75
  - `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
- - `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.
76
+ - `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. 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
77
  - 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.
78
- - `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply.
78
+ - `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>`.
79
79
  - Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
80
80
  - Merge tooling surfaces Plastic `FILE_CONFLICT` records and merge-state metadata from `cm status`.
81
- - `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.
81
+ - `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.
82
82
  - `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
83
83
 
84
84
  ## Patch generation examples
@@ -90,7 +90,7 @@ plastic_patch(source="<left-spec>", destination="<right-spec>")
90
90
  plastic_patch(source="<branch-spec>", toolPath="<path-to-diff-tool>")
91
91
  ```
92
92
 
93
- If `output` is omitted, Plastic prints patch content to stdout. If `output` is provided, Plastic writes a new patch file and refuses to overwrite an existing file. Inspect patches before sharing them because they can contain source code, binary content, local paths, or secrets that were present in the changed files.
93
+ If `output` is omitted, the package returns patch content. If `output` is provided, it must be a new path: the package generates into a package-owned sibling staging file, validates it, and atomically publishes it without overwriting an existing path. Plastic/server patch output may encode a moved item either as a move or as delete/add records; `pi-plastic` does not claim a representation without a live backend fixture. Inspect patches before sharing them because they can contain source code, binary content, local paths, or secrets that were present in the changed files.
94
94
 
95
95
  ## Included skill
96
96
 
@@ -107,13 +107,13 @@ pi install npm:@aefree/pi-plastic
107
107
  Install a pinned GitHub release over HTTPS:
108
108
 
109
109
  ```bash
110
- pi install git:github.com/aefreedman/pi-plastic@v0.5.1
110
+ pi install git:github.com/aefreedman/pi-plastic@v0.5.4
111
111
  ```
112
112
 
113
113
  Equivalent SSH install:
114
114
 
115
115
  ```bash
116
- pi install git:git@github.com:aefreedman/pi-plastic@v0.5.1
116
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.5.4
117
117
  ```
118
118
 
119
119
  To intentionally track the moving default branch instead of a release tag:
@@ -141,8 +141,9 @@ Project-local installation protects only Pi processes that load those project se
141
141
  - Node.js 22.19.0 or newer
142
142
  - Pi 0.82.0 or newer; the current development and eval baseline is Pi 0.83
143
143
  - Plastic SCM / Unity Version Control CLI (`cm`) available on `PATH`, or `PI_PLASTIC_CM_EXECUTABLE` set to its full executable path
144
- - GNU/POSIX-compatible `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff and patch tools. Pi does not discover Git Bash paths automatically.
145
- - A configured Plastic workspace for workspace-scoped operations
144
+ - A GNU/POSIX-compatible text `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff tools. Pi invokes that resolved executable as-is and does not discover Git Bash paths automatically. On macOS, bare `diff` is PATH-dependent: it may be GNU Diffutils (often installed as `diff` ahead of system paths) or Apple’s BSD `/usr/bin/diff`; the package does not substitute one for the other. Configure the intended text executable explicitly when that distinction matters.
145
+ - Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract.
146
+ - A configured Plastic workspace for workspace-scoped operations. Text diffs require an ASCII-safe temporary directory; if the configured backend cannot accept Unicode paths, set `TEMP` and `TMP` to a writable ASCII-only location.
146
147
  - `pi-file-discovery` is an optional independently loaded integration. When its `discover_candidate_files` tool is active, it receives the advisory Plastic ignore/cloak filter through the shared global capability protocol; when absent, `pi-plastic` loads without file-discovery filtering. The tarball does not embed linked sibling workspaces or `node_modules` paths.
147
148
 
148
149
  ## Testing
@@ -219,7 +219,8 @@ export function createPlasticBranchStatusExtension(overrides: Partial<BranchStat
219
219
  if (!active || generation !== ownedGeneration || refreshSequence !== ownedRefresh) return;
220
220
 
221
221
  const hasSelectorBranch = selectorBranch ? displayBranch(selectorBranch) : false;
222
- if (!hasSelectorBranch) displayUnavailable();
222
+ if (hasSelectorBranch) return;
223
+ displayUnavailable();
223
224
 
224
225
  const controller = new AbortController();
225
226
  confirmationController = controller;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -15,10 +15,8 @@
15
15
  "index.ts",
16
16
  "extensions/",
17
17
  "src/",
18
- "references/",
19
18
  "skills/",
20
19
  "CHANGELOG.md",
21
- "CONTRIBUTING.md",
22
20
  "LICENSE",
23
21
  "README.md",
24
22
  "SECURITY.md"
@@ -16,9 +16,13 @@ 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
- `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. 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.
19
+ 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
20
 
21
- `plastic_workspaceDiff` requires selected paths or explicit `allPending=true`, runs status once, has small default file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. Selected private paths are included; whole-workspace private review additionally requires `includePrivate=true`. Keep `maxChars` small unless more output is intentional. Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. It uses `PI_PLASTIC_DIFF_EXECUTABLE` when set, otherwise GNU/POSIX `diff` on PATH; paths containing spaces are supported, but Pi does not discover Git Bash paths automatically. Pass `toolPath` only for an intentional one-call override. For a large patch, pass an intentional new `output` path because Plastic will not overwrite it.
21
+ `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.
22
+
23
+ `plastic_workspaceDiff` requires selected paths or explicit `allPending=true`, runs status once, has small default file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. Selected private paths are included; whole-workspace private review additionally requires `includePrivate=true`. Keep `maxChars` small unless more output is intentional. Text-diff selection and patch-backend selection are distinct: text tools use `PI_PLASTIC_DIFF_EXECUTABLE` or bare PATH `diff`. On macOS, bare `diff` can resolve either to GNU Diffutils or Apple’s BSD `/usr/bin/diff`; Pi uses the resolved executable and does not auto-select `gdiff`.
24
+
25
+ Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
22
26
 
23
27
  Prefer runtime `plastic_*` tools first. Keep `cm` shell commands as manual fallback.
24
28
 
@@ -82,7 +82,7 @@ plastic_diffFile(path="<workspace-path>", revision="cs:<number>")
82
82
  plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
83
83
  ```
84
84
 
85
- The typed diff tools own historical `cm cat --file` materialization and cleanup; do not create agent-authored temporary files for ordinary diffs.
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
87
  ## Commit Message Format
88
88
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  ## Autonomous Merge Workflow
11
11
 
12
+ Plastic does not perform a Git-style fast-forward. It applies the merge into the branch currently loaded in the workspace, and the resulting merge changeset belongs to the branch where checkin runs. Switching branches afterward does not move an incorrectly placed changeset.
13
+
12
14
  - Switch to the target branch/workspace and ensure you understand the current pending state.
13
15
  - Run `plastic_merge(...)`.
14
16
  - If Plastic stops on a delete/change directory conflict and source deletion is the intended resolution, run `plastic_resolveDeleteChangeConflict(...)`, then rerun `plastic_merge(...)`.
@@ -17,6 +19,7 @@
17
19
  - If `plastic_status()` still shows `Pending merge links`, merge-in-progress state, or conflict-like pending items, stop and follow troubleshooting before any checkin.
18
20
  - After compile/tests, run `plastic_status()` again so Unity-generated private artifacts are not mistaken for merge fallout.
19
21
  - Only then create the merge checkin.
22
+ - Verify the merge checkin was created on the target branch. If the workspace or changeset is on the source branch, stop: do not switch to the target and claim completion. Inspect target history and rerun the merge while the target is actually loaded.
20
23
 
21
24
  ## Review Workflow
22
25
 
@@ -31,9 +34,11 @@ cm find changeset "where branch = '<branch-name>' order by changesetid desc limi
31
34
 
32
35
  ```text
33
36
  plastic_patch(source="<branch-spec>", integration=true)
34
- plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")
37
+ plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<new-patch-file>")
35
38
  ```
36
39
 
40
+ On Windows configure `PI_PLASTIC_PATCH_EXECUTABLE` (or one-call `toolPath`) to a verified patch-capable non-GUI executable; do not rely on the text-diff/GnuWin32 setting. Use a repository-qualified `br:/...@<repository>@<server>` selector when the current workspace selector cannot supply the exact repository. Requested output is staged and atomically published only when successful.
41
+
37
42
  - When change-boundary evidence is needed, inspect specific files first and opt into multi-file output explicitly:
38
43
 
39
44
  ```text
@@ -16,7 +16,7 @@ Note: command examples target current `cm` 11.x CLI syntax; legacy aliases may d
16
16
  | Merge | `plastic_merge(source="<source-branch-spec>")` | `cm merge <source-branch-spec> --merge --nointeractiveresolution --mergetype=try` | Surfaces `FILE_CONFLICT`; run `plastic_status()` before checkin |
17
17
  | Finalize merge metadata | `plastic_finalizeMerge(source="<source-branch-spec>", strategy="destination")` | `cm merge <source-branch-spec> --merge --nointeractiveresolution --mergetype=forced --keepdestination` | Use after manual/reviewed resolution when checkin says merge is still in progress |
18
18
  | Shelve | `plastic_shelvesetCreate(comment="description")` | `cm shelveset create -c="description"` | Save work temporarily |
19
- | Patch for review | `plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")` | `cm patch <branch-spec> --clean --integration --output=<patch-file>` | Focused branch-review patch; inspect before sharing |
19
+ | Patch for review | `plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<new-patch-file>")` | `cm patch <branch-spec> --clean --integration --output=<patch-file>` | Uses `toolPath` then `PI_PLASTIC_PATCH_EXECUTABLE`; Windows requires a verified patch-capable non-GUI executable. New output is staged and atomically published without overwrite; use repository-qualified `br:/...@<repository>@<server>` if selector resolution is unavailable |
20
20
  | Changed-file listing | `plastic_status(machineReadable=true)` | `cm status --all` | Lists changed, added, moved, deleted, and private items without GUI diff |
21
21
  | Diff (one workspace file) | `plastic_diffFile(path="<workspace-path>", maxChars=4000)` | None recommended | Intentional focused comparison only; not routine validation |
22
22
  | Diff (pending review) | `plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000)` | None recommended | Requires selected paths or explicit `allPending=true`; use status for changed-path listing |
@@ -25,9 +25,12 @@ Note: command examples target current `cm` 11.x CLI syntax; legacy aliases may d
25
25
 
26
26
  ## Merge verification checklist
27
27
 
28
+ Plastic merges into the branch currently loaded in the workspace; switching afterward does not move a source-branch changeset to the target.
29
+
28
30
  - Run `plastic_merge(...)`.
29
31
  - If `plastic_merge(...)` reports `FILE_CONFLICT`, inspect/resolve the listed paths and validate before finalizing.
30
32
  - Immediately run `plastic_status()`.
31
33
  - Pending merge links are expected until the merge result is checked in; merge-in-progress hints are blockers.
32
34
  - If validation/tests were run after the merge, run `plastic_status()` again before checkin so generated private artifacts do not muddy the result.
33
35
  - If `plastic_checkin(...)` says a merge is still in progress after files are resolved and validated, run `plastic_finalizeMerge(source=..., strategy="destination")`, then retry checkin.
36
+ - Confirm the merge changeset and final workspace branch are the intended target. A source/target mismatch is failure, even when the command returned success or the workspace is clean.
@@ -13,11 +13,10 @@ Use `plastic_patch` when you need a branch-review patch for AI assistance, code
13
13
  ```text
14
14
  plastic_patch(source="<branch-spec>", integration=true)
15
15
  plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")
16
- plastic_patch(source="<left-spec>", destination="<right-spec>")
17
16
  plastic_patch(source="<branch-spec>", toolPath="<path-to-diff-tool>")
18
17
  ```
19
18
 
20
- `integration=true` shows changes pending merge into the parent branch. `clean=true` strips content that arrived via merges. Use `output` for large patches; Plastic refuses to overwrite an existing output file. Inspect patch contents before sharing because patches can contain source code, binary data, local paths, or secrets present in changed files.
19
+ `integration=true` shows changes pending merge into the parent branch. `clean=true` strips content that arrived via merges. Plastic/server output may encode moved items as moves or as delete/add records, so inspect the generated patch rather than assuming a move-aware representation. `toolPath` is the highest-priority patch backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` is validated for text-only diffs but rejects the `--binary` argument passed by `cm patch`; configure a verified GNU Diffutils-compatible non-GUI executable through the patch-specific setting or `toolPath`. Text-diff configuration is independent. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` is suitable only when that executable accepts the local Plastic patch arguments. This macOS observation does not validate a Windows backend; verify an explicit Windows patch backend locally. `destination` passes a second spec to `cm patch`, but its two-spec behavior is not yet an established review workflow. Unqualified `br:/...` selectors are qualified only from the exact current workspace repository, otherwise use `br:/<branch>@<repository>@<server>`. Use `output` for large patches and choose a new path: the package stages, validates, and atomically publishes it without overwriting an existing file. Inspect patch contents before sharing because patches can contain source code, binary data, local paths, or secrets present in changed files.
21
20
 
22
21
  ## Text-Only Diff Options
23
22
 
@@ -32,7 +31,7 @@ plastic_diffFile(path="<workspace-path>", revision="cs:<number>") # explicit his
32
31
  plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
33
32
  ```
34
33
 
35
- The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base; 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. All tools use bounded GNU/POSIX text diff, treat valid Unity YAML as text, and report genuine binary content explicitly. Do not construct `cm cat` temporary-file recipes for ordinary review.
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.
36
35
 
37
36
  ## Metadata Listing (No GUI Diff)
38
37
 
@@ -20,6 +20,13 @@ cm switch --silent --noinput <branch-spec>
20
20
 
21
21
  Agent preference: use runtime `plastic_*` methods first; keep shell commands as manual fallback.
22
22
 
23
+ ## Patch Backend Rejects `--binary` on macOS
24
+
25
+ - Symptom: `plastic_patch(...)` reports that its diff executable rejected `--binary` and names `PI_PLASTIC_PATCH_EXECUTABLE`.
26
+ - With Plastic 11 on macOS, this is the observed result when `cm patch` uses Apple BSD `/usr/bin/diff`: that executable works for text-only diffs but rejects the patch backend's `--binary` argument.
27
+ - Set `PI_PLASTIC_PATCH_EXECUTABLE` to a verified GNU Diffutils-compatible non-GUI executable, or pass that executable as `toolPath` for one call. Keep `PI_PLASTIC_DIFF_EXECUTABLE` configured separately for text-only review tools.
28
+ - This is a macOS diagnostic, not validation of Windows patch backends or two-spec patch behavior; verify those locally.
29
+
23
30
  ## Code Review Creation Failed
24
31
 
25
32
  - Use GUI or document changeset in plan/issue tracker.
@@ -4,8 +4,8 @@ import { spawn } from "node:child_process";
4
4
  import { tool } from "./pi-tool-compat";
5
5
  import { promises as fs, realpathSync, statSync } from "node:fs";
6
6
  import { tmpdir } from "os";
7
- import { dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
8
- import { parsePlasticStatusBranch } from "./plastic-workspace";
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
8
+ import { discoverPlasticWorkspace, parsePlasticSelector, parsePlasticStatusBranch } from "./plastic-workspace";
9
9
 
10
10
  type SpawnResult = {
11
11
  stdout: string;
@@ -31,6 +31,10 @@ const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC
31
31
  // `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
32
32
  // Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
33
33
  const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
34
+ const PLASTIC_PATCH_EXECUTABLE_ENV = "PI_PLASTIC_PATCH_EXECUTABLE";
35
+ // Plastic's server/backend determines whether a moved item is encoded as a
36
+ // move or as delete/add records. Do not claim either without a live fixture.
37
+ const PATCH_MOVE_REPRESENTATION = "backend-determined" as const;
34
38
 
35
39
  const executableNotFoundError = (command: string, error: Error): Error =>
36
40
  {
@@ -1070,6 +1074,22 @@ const isSameBranchSpec = (left: string, right: string): boolean =>
1070
1074
  return normalizeBranchSpecForComparison(left) === normalizeBranchSpecForComparison(right);
1071
1075
  };
1072
1076
 
1077
+ const assertWorkspaceOnBranch = (actualBranch: string, expectedBranch: string, phase: string): void =>
1078
+ {
1079
+ if (isSameBranchSpec(actualBranch, expectedBranch))
1080
+ {
1081
+ return;
1082
+ }
1083
+
1084
+ throw new Error([
1085
+ `Plastic workspace branch mismatch after ${phase}.`,
1086
+ `Expected target: ${expectedBranch}`,
1087
+ `Actual branch: ${actualBranch}`,
1088
+ "The requested merge/checkin was not completed on the target branch.",
1089
+ "Do not switch branches and assume the changeset moved; Plastic changesets remain on the branch where they were created. Inspect branch history and retry the merge from the actual target branch.",
1090
+ ].join("\n"));
1091
+ };
1092
+
1073
1093
  const getBranchLeafName = (branch: string): string =>
1074
1094
  {
1075
1095
  const normalized = normalizeBranchSpecForComparison(branch);
@@ -1178,16 +1198,219 @@ const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string |
1178
1198
  }
1179
1199
  };
1180
1200
 
1181
- const resolvePatchToolPath = (toolPath?: string): string =>
1201
+ const resolvePatchToolPath = (
1202
+ toolPath?: string,
1203
+ environment: ExecutableEnvironment = process.env,
1204
+ platform: NodeJS.Platform = process.platform,
1205
+ ): string =>
1182
1206
  {
1207
+ // A per-call path is deliberately the strongest override: callers may
1208
+ // select a known patch-capable executable without changing process policy.
1183
1209
  if (toolPath?.trim())
1184
1210
  {
1185
1211
  return toolPath.trim();
1186
1212
  }
1187
1213
 
1188
- // Keep cm patch on the same configurable portable diff executable used by
1189
- // text-only file diffs. `diff` remains a PATH lookup on both platforms.
1190
- return getDiffExecutable();
1214
+ const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
1215
+ if (configuredPatchExecutable)
1216
+ {
1217
+ return configuredPatchExecutable;
1218
+ }
1219
+
1220
+ if (platform === "win32")
1221
+ {
1222
+ // GnuWin32 diff 2.8.7 is known to reject cm patch directory operands.
1223
+ // Do not inherit PI_PLASTIC_DIFF_EXECUTABLE here: that setting remains
1224
+ // for text diffs and may name the incompatible executable.
1225
+ throw new Error(
1226
+ "cm patch requires a patch-capable non-GUI diff executable on Windows. Set PI_PLASTIC_PATCH_EXECUTABLE to a verified tool (for example Git's diff.exe), or pass toolPath for this one call. PI_PLASTIC_DIFF_EXECUTABLE is used only for text diffs and is not a safe Windows patch default.",
1227
+ );
1228
+ }
1229
+
1230
+ // Retain the pre-existing non-Windows fallback while keeping the patch-specific
1231
+ // environment variable authoritative. The caller must verify that the resolved
1232
+ // executable supports the local Plastic client's patch argument contract.
1233
+ return resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
1234
+ };
1235
+
1236
+ type ResolvedPatchBranchSpecs = Pick<PatchCommandArgs, "source" | "destination">;
1237
+
1238
+ // Repository selectors are a single cm argv value, so spaces within a repository
1239
+ // name are not shell separators. Require non-empty @-separated components without
1240
+ // edge whitespace, while retaining Plastic's valid internal spaces and punctuation.
1241
+ const isSafePatchRepositorySelector = (repository: string | undefined): repository is string =>
1242
+ {
1243
+ if (!repository || repository !== repository.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(repository))
1244
+ {
1245
+ return false;
1246
+ }
1247
+
1248
+ return repository.split("@").every((component) => /^[^@\s](?:[^@]*[^@\s])?$/.test(component));
1249
+ };
1250
+
1251
+ const qualifyPatchBranchSpec = (branchSpec: string, repository?: string): string =>
1252
+ {
1253
+ const normalizedBranchSpec = branchSpec.trim();
1254
+ if (!/^br:\/.+/i.test(normalizedBranchSpec) || normalizedBranchSpec.includes("@"))
1255
+ {
1256
+ return normalizedBranchSpec;
1257
+ }
1258
+
1259
+ if (!isSafePatchRepositorySelector(repository))
1260
+ {
1261
+ throw new Error(
1262
+ `Cannot safely qualify unqualified branch selector '${normalizedBranchSpec}' against this workspace. Use the exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.`,
1263
+ );
1264
+ }
1265
+
1266
+ return `${normalizedBranchSpec}@${repository}`;
1267
+ };
1268
+
1269
+ const resolvePatchBranchSpecs = async (args: PatchCommandArgs, cwd: string): Promise<ResolvedPatchBranchSpecs> =>
1270
+ {
1271
+ const branchSpecs = [args.source, args.destination].filter((value): value is string => Boolean(value && /^br:\/.+/i.test(value.trim())));
1272
+ const needsQualification = branchSpecs.some((value) => !value.includes("@"));
1273
+ if (!needsQualification)
1274
+ {
1275
+ return { source: args.source.trim(), ...(args.destination ? { destination: args.destination.trim() } : {}) };
1276
+ }
1277
+
1278
+ const workspace = await discoverPlasticWorkspace(cwd);
1279
+ if (workspace.kind !== "found")
1280
+ {
1281
+ throw new Error("Cannot safely qualify an unqualified branch selector because the current Plastic workspace repository is unavailable. Use exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.");
1282
+ }
1283
+
1284
+ const selectorText = await fs.readFile(join(workspace.value.plasticDir, "plastic.selector"), "utf8").catch(() => undefined);
1285
+ const selector = selectorText === undefined ? { kind: "not_found" as const } : parsePlasticSelector(selectorText);
1286
+ const repository = selector.kind === "found" ? selector.value.repository : undefined;
1287
+ return {
1288
+ source: qualifyPatchBranchSpec(args.source, repository),
1289
+ ...(args.destination ? { destination: qualifyPatchBranchSpec(args.destination, repository) } : {}),
1290
+ };
1291
+ };
1292
+
1293
+ type PatchOutputStaging = {
1294
+ requestedOutput: string;
1295
+ stagingOutput: string;
1296
+ cleanup: () => Promise<void>;
1297
+ };
1298
+
1299
+ const ensurePatchOutputDoesNotExist = async (requestedOutput: string): Promise<void> =>
1300
+ {
1301
+ const existing = await fs.lstat(requestedOutput).catch((error: unknown) =>
1302
+ {
1303
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1304
+ throw error;
1305
+ });
1306
+ if (existing)
1307
+ {
1308
+ throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
1309
+ }
1310
+ };
1311
+
1312
+ const createPatchOutputStaging = async (requestedOutput: string): Promise<PatchOutputStaging> =>
1313
+ {
1314
+ await ensurePatchOutputDoesNotExist(requestedOutput);
1315
+ let stagingDirectory: string;
1316
+ try
1317
+ {
1318
+ stagingDirectory = await fs.mkdtemp(join(dirname(requestedOutput), ".pi-plastic-patch-"));
1319
+ }
1320
+ catch (error)
1321
+ {
1322
+ throw new Error(`Cannot create package-owned patch staging beside '${requestedOutput}'. Ensure its parent directory exists and is writable. ${error instanceof Error ? error.message : String(error)}`);
1323
+ }
1324
+
1325
+ return {
1326
+ requestedOutput,
1327
+ stagingOutput: join(stagingDirectory, "patch-output"),
1328
+ cleanup: async () => fs.rm(stagingDirectory, { recursive: true, force: true }),
1329
+ };
1330
+ };
1331
+
1332
+ const validatePatchStagingOutput = async (stagingOutput: string, requireContent = false): Promise<void> =>
1333
+ {
1334
+ const patchStat = await fs.lstat(stagingOutput).catch(() => null);
1335
+ if (!patchStat?.isFile())
1336
+ {
1337
+ throw new Error("Plastic reported patch generation success but did not create package-owned staging output.");
1338
+ }
1339
+ if (requireContent && patchStat.size === 0)
1340
+ {
1341
+ // An omitted output may intentionally report an empty patch. A caller
1342
+ // who requested a file, however, must never receive a zero-byte file
1343
+ // that is indistinguishable from a failed/truncated backend outcome.
1344
+ throw new Error("Refusing to publish an empty patch output. Plastic produced no patch bytes; rerun without output to inspect the empty result or verify the patch backend.");
1345
+ }
1346
+ };
1347
+
1348
+ // Creating a hard link is atomic and fails if the requested path appeared
1349
+ // after our preflight. Unlike rename(), it can never replace an existing file.
1350
+ const publishPatchOutput = async (stagingOutput: string, requestedOutput: string): Promise<void> =>
1351
+ {
1352
+ await validatePatchStagingOutput(stagingOutput, true);
1353
+ try
1354
+ {
1355
+ await fs.link(stagingOutput, requestedOutput);
1356
+ }
1357
+ catch (error)
1358
+ {
1359
+ const code = (error as NodeJS.ErrnoException).code;
1360
+ if (code === "EEXIST")
1361
+ {
1362
+ throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
1363
+ }
1364
+ throw new Error(`Could not atomically publish patch output '${requestedOutput}'. ${error instanceof Error ? error.message : String(error)}`);
1365
+ }
1366
+ };
1367
+
1368
+ const runPatchOutputTransaction = async (requestedOutput: string, generate: (stagingOutput: string) => Promise<void>): Promise<void> =>
1369
+ {
1370
+ const staging = await createPatchOutputStaging(requestedOutput);
1371
+ try
1372
+ {
1373
+ await generate(staging.stagingOutput);
1374
+ await publishPatchOutput(staging.stagingOutput, staging.requestedOutput);
1375
+ }
1376
+ finally
1377
+ {
1378
+ await staging.cleanup();
1379
+ }
1380
+ };
1381
+
1382
+ const redactPatchStagingOutput = (message: string, stagingOutput: string): string =>
1383
+ {
1384
+ const stagingPaths = new Set([stagingOutput]);
1385
+ try
1386
+ {
1387
+ // macOS commonly exposes /var through the canonical /private/var path.
1388
+ // cm may report the latter even though it received the former.
1389
+ stagingPaths.add(join(realpathSync.native(dirname(stagingOutput)), basename(stagingOutput)));
1390
+ }
1391
+ catch
1392
+ {
1393
+ // The staging parent normally exists; retain raw-path redaction if it does not.
1394
+ }
1395
+
1396
+ return Array.from(stagingPaths)
1397
+ .sort((left, right) => right.length - left.length)
1398
+ .reduce((redacted, path) => redacted.split(path).join("<package-owned-staging-file>"), message);
1399
+ };
1400
+
1401
+ const withPatchBackendContractDiagnostic = (error: unknown, toolPath: string, stagingOutput: string): Error =>
1402
+ {
1403
+ const message = redactPatchStagingOutput(error instanceof Error ? error.message : String(error), stagingOutput);
1404
+ // Plastic 11 on macOS invokes its patch backend with --binary. Apple's BSD
1405
+ // /usr/bin/diff rejects that flag, while GNU diffutils accepts it. Detect
1406
+ // the observed contract failure instead of guessing from a path or OS.
1407
+ if (/unrecognized option [`']?--binary/i.test(message))
1408
+ {
1409
+ return new Error(
1410
+ `${message}\n\nPlastic patch requires a diff executable that accepts --binary. '${toolPath}' rejected that argument. Configure PI_PLASTIC_PATCH_EXECUTABLE (or this call's toolPath) to a verified GNU diffutils-compatible non-GUI diff executable. Text diff configuration is independent; Apple BSD diff may still be used for text-only diffs.`,
1411
+ );
1412
+ }
1413
+ return new Error(message);
1191
1414
  };
1192
1415
 
1193
1416
  const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
@@ -1238,6 +1461,13 @@ export const __plasticProcessInternals = {
1238
1461
  export const __plasticPatchInternals = {
1239
1462
  buildPatchCommandArgs,
1240
1463
  resolvePatchToolPath,
1464
+ qualifyPatchBranchSpec,
1465
+ resolvePatchBranchSpecs,
1466
+ createPatchOutputStaging,
1467
+ publishPatchOutput,
1468
+ runPatchOutputTransaction,
1469
+ withPatchBackendContractDiagnostic,
1470
+ PATCH_MOVE_REPRESENTATION,
1241
1471
  };
1242
1472
 
1243
1473
  export const __plasticCheckinInternals = {
@@ -1265,6 +1495,7 @@ export const __plasticSwitchInternals = {
1265
1495
  toLegacyPendingSummary,
1266
1496
  normalizeBranchSpecForComparison,
1267
1497
  isSameBranchSpec,
1498
+ assertWorkspaceOnBranch,
1268
1499
  buildSwitchPendingProfile,
1269
1500
  isSwitchBringBlockedForUnattended,
1270
1501
  canSwitchDirectWithPrivateOnlyPending,
@@ -1333,7 +1564,7 @@ const boundDiffOutput = (output: string): Pick<TextDiffResult, "output" | "trunc
1333
1564
  const runPortableTextDiff = async (
1334
1565
  leftPath: string,
1335
1566
  rightPath: string,
1336
- cwd: string,
1567
+ _cwd: string,
1337
1568
  leftLabel: string,
1338
1569
  rightLabel: string,
1339
1570
  ): Promise<TextDiffResult> =>
@@ -1344,36 +1575,54 @@ const runPortableTextDiff = async (
1344
1575
  return { backend: "diff", changed: !leftContent.equals(rightContent), binary: true, output: "", truncated: false, totalChars: 0 };
1345
1576
  }
1346
1577
 
1347
- const { stdout, stderr, exitCode, aborted, stdoutTruncated, stdoutTotalChars } = await spawnAndCollect(
1348
- getDiffExecutable(),
1349
- ["-u", leftPath, rightPath],
1350
- cwd,
1351
- undefined,
1352
- getActiveAbortSignal(),
1353
- { ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
1354
- );
1355
- if (aborted)
1578
+ // Do not pass either workspace or historical source paths to diff: a
1579
+ // Unicode workspace path reproduces an Invalid argument failure in the
1580
+ // configured Windows GnuWin32 backend. Labels intentionally stay out of
1581
+ // argv and are restored by stableDiffHeaders below.
1582
+ const materializationDir = await createAsciiTempDirectory("pi-plastic-diff-");
1583
+ const materializedLeftPath = join(materializationDir, `left${safeTempExtension(leftPath)}`);
1584
+ const materializedRightPath = join(materializationDir, `right${safeTempExtension(rightPath)}`);
1585
+ try
1356
1586
  {
1357
- throw new Error("Text diff was aborted.");
1587
+ await Promise.all([
1588
+ fs.writeFile(materializedLeftPath, leftContent),
1589
+ fs.writeFile(materializedRightPath, rightContent),
1590
+ ]);
1591
+ const { stdout, stderr, exitCode, aborted, stdoutTruncated, stdoutTotalChars } = await spawnAndCollect(
1592
+ getDiffExecutable(),
1593
+ ["-u", materializedLeftPath, materializedRightPath],
1594
+ materializationDir,
1595
+ undefined,
1596
+ getActiveAbortSignal(),
1597
+ { ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
1598
+ );
1599
+ if (aborted)
1600
+ {
1601
+ throw new Error("Text diff was aborted.");
1602
+ }
1603
+ if (exitCode > 1)
1604
+ {
1605
+ const diagnostic = [stdout, stderr].filter(Boolean).join("\n").trim();
1606
+ const sanitized = diagnostic
1607
+ .split(materializedLeftPath).join(leftLabel)
1608
+ .split(materializedRightPath).join(rightLabel);
1609
+ throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
1610
+ }
1611
+
1612
+ const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
1613
+ const bounded = stdoutTruncated
1614
+ ? {
1615
+ output: `${normalized}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or generate a review patch for the complete change.]`,
1616
+ truncated: true,
1617
+ totalChars: stdoutTotalChars ?? normalized.length,
1618
+ }
1619
+ : boundDiffOutput(normalized);
1620
+ return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
1358
1621
  }
1359
- if (exitCode > 1)
1622
+ finally
1360
1623
  {
1361
- const diagnostic = [stdout, stderr].filter(Boolean).join("\n").trim();
1362
- const sanitized = diagnostic
1363
- .split(leftPath).join(leftLabel)
1364
- .split(rightPath).join(rightLabel);
1365
- throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
1624
+ await fs.rm(materializationDir, { recursive: true, force: true });
1366
1625
  }
1367
-
1368
- const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
1369
- const bounded = stdoutTruncated
1370
- ? {
1371
- output: `${normalized}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or generate a review patch for the complete change.]`,
1372
- truncated: true,
1373
- totalChars: stdoutTotalChars ?? normalized.length,
1374
- }
1375
- : boundDiffOutput(normalized);
1376
- return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
1377
1626
  };
1378
1627
 
1379
1628
  const safeTempExtension = (pathValue: string): string =>
@@ -1382,6 +1631,20 @@ const safeTempExtension = (pathValue: string): string =>
1382
1631
  return /^\.[A-Za-z0-9]{1,12}$/.test(extension) ? extension : ".tmp";
1383
1632
  };
1384
1633
 
1634
+ const isAsciiPath = (pathValue: string): boolean => /^[\x20-\x7e]*$/.test(pathValue);
1635
+
1636
+ // GnuWin32 diff cannot reliably accept Unicode operands. Keep every package
1637
+ // materialization path ASCII-only; labels are restored after the backend exits.
1638
+ const createAsciiTempDirectory = async (prefix: string): Promise<string> =>
1639
+ {
1640
+ const temporaryRoot = tmpdir();
1641
+ if (!isAsciiPath(temporaryRoot))
1642
+ {
1643
+ throw new Error("Text diff requires an ASCII-safe temporary directory because the configured backend cannot reliably accept Unicode paths. Set TEMP and TMP to a writable ASCII-only path.");
1644
+ }
1645
+ return fs.mkdtemp(join(temporaryRoot, prefix));
1646
+ };
1647
+
1385
1648
  const materializeRevision = async (revision: string, destination: string, workdir?: string): Promise<void> =>
1386
1649
  {
1387
1650
  // --file keeps historical bytes out of the decoded stdout path. This is
@@ -1389,17 +1652,20 @@ const materializeRevision = async (revision: string, destination: string, workdi
1389
1652
  try
1390
1653
  {
1391
1654
  await runCm(["cat", revision, `--file=${destination}`], workdir);
1655
+ const destinationStat = await fs.stat(destination).catch(() => null);
1656
+ if (!destinationStat?.isFile())
1657
+ {
1658
+ throw new Error("Plastic did not materialize the requested historical file content.");
1659
+ }
1392
1660
  }
1393
1661
  catch (error)
1394
1662
  {
1663
+ // cm cat --file can leave a zero-byte output on failure. The destination
1664
+ // is package-owned, so remove it before surfacing a sanitized diagnostic.
1665
+ await fs.rm(destination, { force: true }).catch(() => undefined);
1395
1666
  const message = error instanceof Error ? error.message : String(error);
1396
1667
  throw new Error(message.split(destination).join("<package-owned-temp-file>"));
1397
1668
  }
1398
- const destinationStat = await fs.stat(destination).catch(() => null);
1399
- if (!destinationStat?.isFile())
1400
- {
1401
- throw new Error("Plastic did not materialize the requested historical file content.");
1402
- }
1403
1669
  };
1404
1670
 
1405
1671
  const normalizeDiffResponseMaxChars = (value: unknown): number =>
@@ -1422,10 +1688,10 @@ const formatTextDiff = async (
1422
1688
  ): Promise<string> =>
1423
1689
  {
1424
1690
  const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
1425
- const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
1691
+ const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : (comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
1426
1692
  const text = result.binary
1427
1693
  ? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
1428
- : (result.changed ? result.output : "No differences.");
1694
+ : (result.changed ? result.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
1429
1695
  const data = {
1430
1696
  comparisonKind,
1431
1697
  ...comparisonMetadata,
@@ -1497,6 +1763,8 @@ export const __plasticDiffInternals = {
1497
1763
  boundDiffOutput,
1498
1764
  runPortableTextDiff,
1499
1765
  safeTempExtension,
1766
+ isAsciiPath,
1767
+ createAsciiTempDirectory,
1500
1768
  materializeRevision,
1501
1769
  isNoDataError,
1502
1770
  withWorkspaceBaseUnavailableDiagnostic,
@@ -2675,44 +2943,76 @@ export const diff = tool({
2675
2943
  });
2676
2944
 
2677
2945
  export const patch = tool({
2678
- description: "Generate a Plastic SCM patch with the configured portable diff backend and optional clean/integration filtering (cm patch).",
2946
+ description: "Generate a Plastic SCM review patch with a patch-specific non-GUI diff backend; branch selectors are qualified to the current workspace repository and requested outputs publish atomically without overwrite (cm patch).",
2679
2947
  args: {
2680
- source: tool.schema.string().min(1).describe("Source changeset or branch spec, for example a changeset spec or branch spec selected from the current workspace."),
2948
+ source: tool.schema.string().min(1).describe("Source changeset or branch spec. Unqualified br:/ selectors are qualified only from the exact current workspace repository."),
2681
2949
  destination: tool.schema.string().optional().describe("Optional second changeset or branch spec for two-spec patch generation."),
2682
- output: tool.schema.string().optional().describe("Optional output file path. If omitted, patch content is printed to stdout."),
2683
- toolPath: tool.schema.string().optional().describe("Optional path to the diff executable used by cm patch. Defaults to PI_PLASTIC_DIFF_EXECUTABLE or diff on PATH."),
2950
+ output: tool.schema.string().optional().describe("Optional new output file path. The package stages then atomically publishes it and refuses any existing path. If omitted, patch content is returned."),
2951
+ toolPath: tool.schema.string().optional().describe("Optional patch-capable non-GUI diff executable for this call (highest priority). Otherwise use PI_PLASTIC_PATCH_EXECUTABLE; non-Windows falls back to PI_PLASTIC_DIFF_EXECUTABLE/diff, which must support the local Plastic patch argument contract."),
2684
2952
  clean: tool.schema.boolean().optional().describe("Exclude content that arrived via merges and include only direct checkins."),
2685
2953
  integration: tool.schema.boolean().optional().describe("Show branch changes pending integration into the parent branch."),
2686
2954
  workdir: workdirArg,
2687
2955
  },
2688
2956
  async execute(args)
2689
2957
  {
2958
+ // Validate before staging or launching any process, including direct
2959
+ // callers that bypass the TypeBox schema layer.
2960
+ assertRequiredPatchValue("source", args.source);
2961
+ assertNonBlankPatchValue("destination", args.destination);
2962
+ assertNonBlankPatchValue("output", args.output);
2963
+ assertNonBlankPatchValue("toolPath", args.toolPath);
2964
+
2690
2965
  const cwd = args.workdir ?? process.cwd();
2691
- const tempDir = args.output ? null : await fs.mkdtemp(join(tmpdir(), "plastic-patch-"));
2692
- const requestedOutput = args.output ?? join(tempDir!, "review.patch");
2693
- const resolvedOutput = isAbsolute(requestedOutput) ? requestedOutput : resolve(cwd, requestedOutput);
2966
+ const toolPath = resolvePatchToolPath(args.toolPath);
2967
+ const branchSpecs = await resolvePatchBranchSpecs(args, cwd);
2968
+ const temporaryOutputDirectory = args.output ? null : await fs.mkdtemp(join(tmpdir(), "plastic-patch-"));
2969
+ const requestedOutput = args.output
2970
+ ? (isAbsolute(args.output) ? args.output : resolve(cwd, args.output))
2971
+ : join(temporaryOutputDirectory!, "review.patch");
2972
+ let commandOutput = "";
2973
+
2974
+ const generate = async (stagingOutput: string): Promise<void> =>
2975
+ {
2976
+ const cmdArgs = buildPatchCommandArgs({
2977
+ ...args,
2978
+ ...branchSpecs,
2979
+ output: stagingOutput,
2980
+ toolPath,
2981
+ });
2982
+ try
2983
+ {
2984
+ commandOutput = await runCm(cmdArgs, args.workdir);
2985
+ }
2986
+ catch (error)
2987
+ {
2988
+ throw withPatchBackendContractDiagnostic(error, toolPath, stagingOutput);
2989
+ }
2990
+ await validatePatchStagingOutput(stagingOutput);
2991
+ };
2694
2992
 
2695
2993
  try
2696
2994
  {
2697
- const cmdArgs = buildPatchCommandArgs({ ...args, output: requestedOutput, toolPath: resolvePatchToolPath(args.toolPath) });
2698
- const commandOutput = await runCm(cmdArgs, args.workdir);
2699
- const patchStat = await fs.stat(resolvedOutput).catch(() => null);
2700
- if (!patchStat?.isFile())
2995
+ if (args.output)
2701
2996
  {
2702
- throw new Error(`Plastic reported patch generation success but did not create '${requestedOutput}'.`);
2997
+ await runPatchOutputTransaction(requestedOutput, generate);
2998
+ }
2999
+ else
3000
+ {
3001
+ await generate(requestedOutput);
2703
3002
  }
2704
3003
 
2705
- const patchBytes = await fs.readFile(resolvedOutput);
3004
+ const patchBytes = await fs.readFile(requestedOutput);
2706
3005
  const isText = isUtf8(patchBytes);
2707
3006
  const patchText = isText ? patchBytes.toString("utf8") : "";
2708
3007
  const bounded = boundDiffOutput(patchText);
2709
3008
  const binaryLimited = /Binary files? .* differ|cannot diff|binary (?:content|file) (?:is )?not supported/i.test(`${patchText}\n${commandOutput}`);
2710
3009
  const metadata = {
2711
3010
  status: patchBytes.length === 0 ? "empty" : (binaryLimited ? "generated-with-binary-warning" : "generated"),
2712
- output: args.output ? requestedOutput : null,
3011
+ output: args.output ? args.output : null,
2713
3012
  bytes: patchBytes.length,
2714
3013
  empty: patchBytes.length === 0,
2715
3014
  binaryLimited: !isText || binaryLimited,
3015
+ moveRepresentation: PATCH_MOVE_REPRESENTATION,
2716
3016
  truncated: !args.output && bounded.truncated,
2717
3017
  content: args.output ? null : bounded.output,
2718
3018
  };
@@ -2720,9 +3020,9 @@ export const patch = tool({
2720
3020
  }
2721
3021
  finally
2722
3022
  {
2723
- if (tempDir)
3023
+ if (temporaryOutputDirectory)
2724
3024
  {
2725
- await fs.rm(tempDir, { recursive: true, force: true });
3025
+ await fs.rm(temporaryOutputDirectory, { recursive: true, force: true });
2726
3026
  }
2727
3027
  }
2728
3028
  },
@@ -2747,7 +3047,7 @@ export const diffRevisions = tool({
2747
3047
  const cwd = args.workdir ?? process.cwd();
2748
3048
  const leftLabel = args.leftRevision.includes("#") ? args.leftRevision.replace("#", "@") : args.leftRevision;
2749
3049
  const rightLabel = args.rightRevision.includes("#") ? args.rightRevision.replace("#", "@") : args.rightRevision;
2750
- const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
3050
+ const tempDir = await createAsciiTempDirectory("plastic-core-");
2751
3051
  const leftPath = join(tempDir, `left${safeTempExtension(args.leftRevision)}`);
2752
3052
  const rightPath = join(tempDir, `right${safeTempExtension(args.rightRevision)}`);
2753
3053
 
@@ -2807,7 +3107,7 @@ const diffPendingWorkspaceFile = async (
2807
3107
  throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
2808
3108
  }
2809
3109
 
2810
- const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
3110
+ const tempDir = await createAsciiTempDirectory("plastic-core-");
2811
3111
  const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
2812
3112
  const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
2813
3113
  try
@@ -2876,7 +3176,7 @@ export const diffFile = tool({
2876
3176
  }
2877
3177
  const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
2878
3178
  const revision = resolveDiffFileRevision(displayPath || args.path, args.revision);
2879
- const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
3179
+ const tempDir = await createAsciiTempDirectory("plastic-core-");
2880
3180
  const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
2881
3181
  try
2882
3182
  {
@@ -3123,10 +3423,10 @@ export const workspaceDiff = tool({
3123
3423
  const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3124
3424
  const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3125
3425
  const bounded = boundWorkspaceDiffResult(perFile, remaining);
3126
- const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
3426
+ const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
3127
3427
  const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
3128
- outcomes.push({ path: displayPath, kind: item.kind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
3129
- const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : "No differences.");
3428
+ outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
3429
+ const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
3130
3430
  const section = `### ${displayPath} (${item.kind})\n${body}`;
3131
3431
  textSections.push(section);
3132
3432
  totalOutputChars += section.length;
@@ -3338,6 +3638,7 @@ export const switchBranch = tool({
3338
3638
 
3339
3639
  const output = await runCm(switchCmd, args.workdir);
3340
3640
  const branchAfter = await resolveCurrentBranchName(args.workdir);
3641
+ assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch");
3341
3642
  return toStructuredResult(
3342
3643
  "switch-branch",
3343
3644
  format,
@@ -3398,6 +3699,7 @@ export const switchBranch = tool({
3398
3699
 
3399
3700
  const output = await runCm(switchCmd, args.workdir);
3400
3701
  const branchAfter = await resolveCurrentBranchName(args.workdir);
3702
+ assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch with private-only pending items");
3401
3703
  return toStructuredResult(
3402
3704
  "switch-branch",
3403
3705
  format,
@@ -3569,6 +3871,7 @@ export const switchBranch = tool({
3569
3871
 
3570
3872
  const switchOutput = await runCm(switchCmd, args.workdir);
3571
3873
  const branchAfter = await resolveCurrentBranchName(args.workdir).catch(() => branchBefore);
3874
+ assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch after shelving");
3572
3875
 
3573
3876
  return toStructuredResult(
3574
3877
  "switch-branch",
@@ -3860,6 +4163,8 @@ export const mergeToBranch = tool({
3860
4163
  workdir: args.workdir,
3861
4164
  });
3862
4165
  const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
4166
+ const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
4167
+ assertWorkspaceOnBranch(branchBeforeMerge, targetBranch, "target update and before merge");
3863
4168
  const mergeResult = await merge.execute({
3864
4169
  source: sourceBranch,
3865
4170
  strategy,
@@ -3898,6 +4203,9 @@ export const mergeToBranch = tool({
3898
4203
  );
3899
4204
  }
3900
4205
 
4206
+ const branchBeforeCheckin = await resolveCurrentBranchName(args.workdir);
4207
+ assertWorkspaceOnBranch(branchBeforeCheckin, targetBranch, "merge and before checkin");
4208
+
3901
4209
  const preflightResult = await checkin.execute({
3902
4210
  message: checkinMessage,
3903
4211
  includeAll: true,
@@ -3914,6 +4222,7 @@ export const mergeToBranch = tool({
3914
4222
  workdir: args.workdir,
3915
4223
  });
3916
4224
  const finalBranch = await resolveCurrentBranchName(args.workdir);
4225
+ assertWorkspaceOnBranch(finalBranch, targetBranch, "merge checkin");
3917
4226
  const finalShortStatus = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
3918
4227
  const finalPendingSummary = summarizeShortStatus(finalShortStatus);
3919
4228
 
@@ -33,7 +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_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["Confirm source and target branches before the non-interactive merge closeout flow."] },
36
+ { 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
37
  { 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
38
  { 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."] },
39
39
  { name: "plastic_branchList", aliases: ["list branches", "find branch", "branches"], tags: ["branch", "inspect", "query"], guidance: ["Use the returned full branch paths when selecting a branch for another operation."] },
@@ -78,7 +78,8 @@ const parseSelectorValue = (line: string, key: string): string | undefined => {
78
78
  /** Parse the local selector without treating it as proof that a workspace exists. */
79
79
  export function parsePlasticSelector(content: string): ParseOutcome<PlasticSelector> {
80
80
  let repository: string | undefined;
81
- let branch: string | undefined;
81
+ let smartBranch: string | undefined;
82
+ let explicitBranch: string | undefined;
82
83
  let sawBranchKey = false;
83
84
 
84
85
  for (const line of content.split(/\r?\n/)) {
@@ -86,13 +87,18 @@ export function parsePlasticSelector(content: string): ParseOutcome<PlasticSelec
86
87
  if (/^\s*repository(?:\s|$)/i.test(line)) repository = parseSelectorValue(line, "repository");
87
88
  if (/^\s*smartbranch(?:\s|$)/i.test(line)) {
88
89
  sawBranchKey = true;
89
- branch = parseSelectorValue(line, "smartbranch");
90
+ smartBranch = parseSelectorValue(line, "smartbranch");
91
+ }
92
+ if (/^\s*br(?:\s|$)/i.test(line)) {
93
+ sawBranchKey = true;
94
+ explicitBranch = parseSelectorValue(line, "br");
90
95
  }
91
96
  }
92
97
 
93
98
  if (!sawBranchKey) return { kind: "not_found" };
99
+ const branch = smartBranch ?? explicitBranch;
94
100
  if (!branch || /[\u0000-\u001f\u007f]/.test(branch)) {
95
- return { kind: "malformed", reason: "Selector smartbranch is blank or contains control characters." };
101
+ return { kind: "malformed", reason: "Selector branch is blank or contains control characters." };
96
102
  }
97
103
 
98
104
  return {
package/CONTRIBUTING.md DELETED
@@ -1,88 +0,0 @@
1
- # Contributing
2
-
3
- Thanks for improving Pi Plastic.
4
-
5
- ## Prerequisites
6
-
7
- - Node.js 22.19.0 or newer
8
- - npm
9
- - Plastic SCM / Unity Version Control CLI for opt-in live validation
10
- - Git for the text-only diff tools
11
-
12
- ## Setup
13
-
14
- ```bash
15
- npm ci --ignore-scripts --no-audit --no-fund
16
- npm test
17
- ```
18
-
19
- The default suite is credential-free. Keep it that way. Tests that require Plastic access must remain explicitly opt-in and target a dedicated disposable workspace.
20
-
21
- ## Making changes
22
-
23
- - Keep public tool names and output shapes stable unless the change intentionally documents a breaking release.
24
- - Preserve built-in and foreign-extension tools when changing active-tool composition.
25
- - Do not weaken confirmation, preflight, path, process, or mutation safeguards.
26
- - Add focused regression coverage for behavior changes.
27
- - Update README documentation when user-facing behavior changes.
28
- - Keep unreleased user-visible changes under `## Unreleased` in `CHANGELOG.md` during ordinary development.
29
- - Do not bump the package version for every implementation commit. Convert the accumulated Unreleased section into one dated SemVer release only when preparing a release.
30
-
31
- ## Validation
32
-
33
- Run:
34
-
35
- ```bash
36
- npm test
37
- npm run eval:tool-loading -- --dry-run
38
- npm pack --dry-run
39
- ```
40
-
41
- Review the tarball list for machine-specific paths, credentials, raw provider payloads, generated results, and unrelated files.
42
-
43
- Plastic live tests are intentionally local-only. This project has no shared Plastic Cloud account or CI credentials, and contributors must authenticate `cm` with their own account and use a repository they are authorized to modify. Never add Plastic credentials, tokens, cloud organization details, or private repository data to tests, fixtures, logs, pull requests, or GitHub Actions.
44
-
45
- For an opt-in read-only Plastic smoke test:
46
-
47
- ```bash
48
- PI_PLASTIC_TEST_WORKSPACE=/absolute/path/to/dedicated/sandbox npm run test:live
49
- ```
50
-
51
- For branch-creation behavior, use the opt-in mutation test only against a clean, dedicated disposable repository:
52
-
53
- ```bash
54
- PI_PLASTIC_TEST_WORKSPACE=/absolute/path/to/dedicated/sandbox \
55
- PI_PLASTIC_ALLOW_MUTATION_TESTS=true \
56
- npm run test:live:branch-create
57
- ```
58
-
59
- The mutation test creates uniquely named hierarchical branches, verifies explicit-parent behavior and the top-level guard, does not switch the workspace, and removes temporary branches in cleanup.
60
-
61
- ### Behavior completion gate
62
-
63
- When a change modifies how a `plastic_*` tool invokes or interprets real Plastic behavior:
64
-
65
- 1. Add or update credential-free regression coverage for policy, parsing, and command construction.
66
- 2. Locally run the narrowest relevant opt-in live test in a clean disposable Plastic repository using the contributor's own authenticated account.
67
- 3. Verify the workspace state and temporary-resource cleanup afterward.
68
- 4. Report the exact live test and that an authenticated disposable repository was used, without exposing account, organization, server, credential, or private repository details.
69
-
70
- GitHub Actions is not expected to run Plastic live tests. If local authenticated validation is unavailable, describe the work as implemented with live validation pending; do not claim the behavior is done or verified. Documentation-only and purely internal changes may mark live validation as not applicable with a reason.
71
-
72
- Follow `evals/tool-loading/README.md` before any live model evaluation. Never use an ordinary production workspace for mutation testing.
73
-
74
- ## Pull requests
75
-
76
- Keep pull requests focused. Describe:
77
-
78
- - the behavior changed;
79
- - safety or compatibility considerations;
80
- - tests and environments actually run;
81
- - checks intentionally skipped;
82
- - relevant before/after metadata or latency measurements for loader changes.
83
-
84
- Do not include secrets, account data, private repository content, or raw provider captures.
85
-
86
- ## Security reports
87
-
88
- Follow [SECURITY.md](SECURITY.md) instead of opening a public issue for vulnerabilities.