@aefree/pi-plastic 0.5.0 → 0.5.2

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,20 @@ 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.2] - 2026-08-05
9
+
10
+ ### Fixed
11
+
12
+ - Hardened the Bash GUI-diff guard for Plastic's `differences` alias, quoted subcommands, common CMD and PowerShell wrappers, `call`/`start`, `Start-Process`, CMD caret escaping, and direct `!`/`!!` shell commands; the internal process guard now rejects the alias too.
13
+ - Added runtime-handler coverage and documented that user-scope installation is required to protect delegated Pi processes whose working directory does not load the originating project's package settings.
14
+
15
+ ## [0.5.1] - 2026-08-05
16
+
17
+ ### Changed
18
+
19
+ - Clarified that preflight is situational rather than routine and that status, focused tests, and direct reads should precede intentional diff output.
20
+ - Reduced agent-facing diff defaults, added caller-controlled body bounds and complete JSON response bounds, required selected paths or explicit `allPending=true` for workspace diffs (including direct-call guards), and limited generic diff discovery to one focused tool.
21
+
8
22
  ## [0.5.0] - 2026-08-05
9
23
 
10
24
  ### Added
package/README.md CHANGED
@@ -70,8 +70,11 @@ Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package
70
70
 
71
71
  ## Safety behavior
72
72
 
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
+
73
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.
74
- - `plastic_diff` remains a disabled alias by design; use `plastic_status` for changed-path listing, `plastic_diffFile` for one file, `plastic_workspaceDiff` for pending review, or `plastic_diffRevisions` for an explicit historical pair.
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.
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.
75
78
  - `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply.
76
79
  - Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
77
80
  - Merge tooling surfaces Plastic `FILE_CONFLICT` records and merge-state metadata from `cm status`.
@@ -95,16 +98,22 @@ If `output` is omitted, Plastic prints patch content to stdout. If `output` is p
95
98
 
96
99
  ## Install
97
100
 
98
- Install the stable GitHub release over HTTPS:
101
+ Install the latest stable npm release at user scope so the safety guards also load for delegated child processes in other workspaces:
102
+
103
+ ```bash
104
+ pi install npm:@aefree/pi-plastic
105
+ ```
106
+
107
+ Install a pinned GitHub release over HTTPS:
99
108
 
100
109
  ```bash
101
- pi install git:github.com/aefreedman/pi-plastic@v0.4.0
110
+ pi install git:github.com/aefreedman/pi-plastic@v0.5.1
102
111
  ```
103
112
 
104
113
  Equivalent SSH install:
105
114
 
106
115
  ```bash
107
- pi install git:git@github.com:aefreedman/pi-plastic@v0.4.0
116
+ pi install git:git@github.com:aefreedman/pi-plastic@v0.5.1
108
117
  ```
109
118
 
110
119
  To intentionally track the moving default branch instead of a release tag:
@@ -125,6 +134,8 @@ Project-local install:
125
134
  pi install -l <path-to-pi-plastic>
126
135
  ```
127
136
 
137
+ Project-local installation protects only Pi processes that load those project settings. It is insufficient as a global Bash safety rail when a subagent launches with another working directory.
138
+
128
139
  ## Requirements
129
140
 
130
141
  - Node.js 22.19.0 or newer
@@ -6,17 +6,26 @@ import {
6
6
  } from "./shared/bash-command-guards";
7
7
 
8
8
  const BLOCK_MESSAGE =
9
- "`cm diff` is blocked in Pi because it can launch a GUI window and hang CLI automation. Use `plastic_status` to list changed paths, `plastic_diffFile` for one file, `plastic_workspaceDiff` for pending review, or `plastic_diffRevisions` for two explicit revisions.";
9
+ "`cm diff` is blocked in Pi because it can launch a GUI window and hang CLI automation. Use `plastic_status` when only changed paths are needed. Do not diff routinely; when change-boundary evidence is necessary, use a focused `plastic_diffFile`, explicitly scoped `plastic_workspaceDiff`, or `plastic_diffRevisions`.";
10
10
 
11
- const DIRECT_CM_DIFF = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:(?:"[^"]*[\\/]cm(?:\.exe)?"|'[^']*[\\/]cm(?:\.exe)?')|(?:[^"'\s]*[\\/])?cm(?:\.exe)?)\s+diff(?:\s|$)/i;
11
+ const CM_EXECUTABLE = String.raw`(?:"(?:[^"]*[\\/])?cm(?:\.exe)?"|'(?:[^']*[\\/])?cm(?:\.exe)?'|(?:[^"'\s]*[\\/])?cm(?:\.exe)?)`;
12
+ const DIFF_SUBCOMMAND = String.raw`(?:diff|differences)`;
13
+ const DIRECT_CM_DIFF = new RegExp(String.raw`^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*${CM_EXECUTABLE}\s+["']?${DIFF_SUBCOMMAND}["']?(?:\s|$)`, "i");
14
+ const POWERSHELL_START_PROCESS_CM_DIFF = new RegExp(String.raw`^(?:start-process|saps|start)\s+(?:-filepath\s+)?${CM_EXECUTABLE}(?=[\s;]|$)[\s\S]*?-argumentlist\s+(?:["'][^"']*${DIFF_SUBCOMMAND}[^"']*["']|${DIFF_SUBCOMMAND}(?:\s|,|$))`, "i");
15
+ const POWERSHELL_INVOKE_EXPRESSION_CM_DIFF = new RegExp(String.raw`^(?:invoke-expression|iex)\s+["'][^"']*${CM_EXECUTABLE}\s+${DIFF_SUBCOMMAND}(?:\s|["'])`, "i");
12
16
 
13
17
  function isBashToolCall(event: { toolName?: string }): event is { toolName: "bash"; input: { command?: string } } {
14
18
  return event.toolName === "bash";
15
19
  }
16
20
 
17
21
  function segmentRunsCmDiff(segment: string): boolean {
18
- const stripped = stripLeadingWrappers(stripAssignments(segment));
19
- return DIRECT_CM_DIFF.test(stripped);
22
+ // cmd.exe removes caret escaping before dispatching the command.
23
+ const normalized = segment.replace(/\^(?=.)/g, "");
24
+ const stripped = stripLeadingWrappers(stripAssignments(normalized));
25
+ return DIRECT_CM_DIFF.test(stripped)
26
+ || POWERSHELL_START_PROCESS_CM_DIFF.test(normalized)
27
+ || POWERSHELL_START_PROCESS_CM_DIFF.test(stripped)
28
+ || POWERSHELL_INVOKE_EXPRESSION_CM_DIFF.test(stripped);
20
29
  }
21
30
 
22
31
  function commandRunsCmDiff(command: string): boolean {
@@ -47,4 +56,21 @@ export default function bashCmDiffGuard(pi: ExtensionAPI) {
47
56
  reason: BLOCK_MESSAGE,
48
57
  };
49
58
  });
59
+
60
+ pi.on("user_bash", async (event, ctx) => {
61
+ if (!event.command || !commandRunsCmDiff(event.command)) return;
62
+
63
+ if (ctx.hasUI) {
64
+ ctx.ui.notify("Blocked user shell command invoking cm diff", "warning");
65
+ }
66
+
67
+ return {
68
+ result: {
69
+ output: BLOCK_MESSAGE,
70
+ exitCode: 1,
71
+ cancelled: false,
72
+ truncated: false,
73
+ },
74
+ };
75
+ });
50
76
  }
@@ -1,10 +1,10 @@
1
1
  export const WRAPPED_COMMAND_PATTERNS = [
2
2
  /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:bash|sh|zsh)(?:\.exe)?\s+-lc\s+(["'])([\s\S]*)\1$/i,
3
3
  /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:bash|sh|zsh)(?:\.exe)?\s+-c\s+(["'])([\s\S]*)\1$/i,
4
- /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?\s+\/[ck]\s+(["'])([\s\S]*)\1$/i,
5
- /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?\s+\/[ck]\s+()([\s\S]+)$/i,
6
- /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?\s+-command\s+(["'])([\s\S]*)\1$/i,
7
- /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?\s+-command\s+()([\s\S]+)$/i,
4
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?(?:\s+\/[dqsvu](?::\S+)*)*\s+\/[ck]\s+(["'])([\s\S]*)\1$/i,
5
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)?(?:\s+\/[dqsvu](?::\S+)*)*\s+\/[ck]\s+()([\s\S]+)$/i,
6
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?(?:(?:\s+-(?:noprofile|noninteractive|nologo|sta|mta))|(?:\s+-executionpolicy\s+\S+))*\s+-(?:command|c)\s+(["'])([\s\S]*)\1$/i,
7
+ /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)?(?:(?:\s+-(?:noprofile|noninteractive|nologo|sta|mta))|(?:\s+-executionpolicy\s+\S+))*\s+-(?:command|c)\s+()([\s\S]+)$/i,
8
8
  ];
9
9
 
10
10
  export function splitSegments(command: string): string[] {
@@ -25,6 +25,8 @@ export function stripLeadingWrappers(segment: string): string {
25
25
  const next = value
26
26
  .replace(/^sudo\s+/i, "")
27
27
  .replace(/^command\s+/i, "")
28
+ .replace(/^call\s+/i, "")
29
+ .replace(/^start\s+(?:["'][^"']*["']\s+)?(?:(?:\/wait|\/b|\/min|\/max|\/i|\/low|\/normal|\/high|\/realtime|\/abovenormal|\/belownormal)\s+)*/i, "")
28
30
  .replace(/^time\s+/i, "")
29
31
  .replace(/^nice(?:\s+(?:-n\s+)?-?\d+)?\s+/i, "");
30
32
 
package/index.ts CHANGED
@@ -172,6 +172,7 @@ const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
172
172
  normalizeWorkdirAliases(input);
173
173
  assignAlias(input, "leftRevision", ["left_revision"]);
174
174
  assignAlias(input, "rightRevision", ["right_revision"]);
175
+ assignAlias(input, "maxChars", ["max_chars"]);
175
176
  return input;
176
177
  },
177
178
  },
@@ -179,6 +180,7 @@ const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
179
180
  prepareArguments(args) {
180
181
  const input = normalizeArgs(args);
181
182
  normalizeWorkdirAliases(input);
183
+ assignAlias(input, "maxChars", ["max_chars"]);
182
184
  return input;
183
185
  },
184
186
  },
@@ -188,8 +190,10 @@ const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
188
190
  normalizeWorkdirAliases(input);
189
191
  promoteSinglePath(input);
190
192
  assignAlias(input, "paths", ["items", "files"]);
193
+ assignAlias(input, "allPending", ["all_pending"]);
191
194
  assignAlias(input, "includePrivate", ["include_private"]);
192
195
  assignAlias(input, "maxFiles", ["max_files"]);
196
+ assignAlias(input, "maxChars", ["max_chars"]);
193
197
  return input;
194
198
  },
195
199
  },
@@ -708,12 +712,12 @@ export default function plasticTools(pi: ExtensionAPI) {
708
712
  description: "Search and enable Plastic SCM / Unity Version Control tools for workspace status and sync, changes and checkins, text-only diffs and patches, branches and merges, shelvesets, code reviews, and workspaces.",
709
713
  promptSnippet: "Use plastic_tool_search to find and enable Plastic SCM capabilities that are not active.",
710
714
  promptGuidelines: [
711
- "Use plastic_tool_search before a Plastic SCM action when an appropriate plastic_* tool is not active. Inspect exact targets and use each tool's preflight and safety options before mutations.",
715
+ "Use plastic_tool_search before a Plastic SCM action when an appropriate plastic_* tool is not active. Inspect exact mutation targets. Do not preflight routinely: use preflight for ambiguous or broad scope, moved/deleted path rewriting, compound operations, or an explicit preview request.",
712
716
  ],
713
717
  parameters: Type.Object({
714
718
  query: Type.Optional(Type.String({ description: "Capability or workflow to search for." })),
715
719
  toolNames: Type.Optional(Type.Array(Type.String({ description: "Exact public Plastic tool name." }), { maxItems: 4, description: "Optional exact tool names to enable." })),
716
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 4, description: "Maximum matching tools to enable. Defaults to the single best keyword match, except generic diff loads both safe text-only alternatives; exact toolNames requests may enable up to four." })),
720
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 4, description: "Maximum matching tools to enable. Defaults to the single best keyword match; exact toolNames requests may enable up to four." })),
717
721
  }),
718
722
  async execute(_toolCallId, params) {
719
723
  if (isPlasticToolBrowseRequest(params)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -16,13 +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 workspace file to `plastic_diffFile(path="<workspace-path>")`, pending review to `plastic_workspaceDiff()`, and an explicit historical pair to `plastic_diffRevisions`. `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_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.
20
20
 
21
- `plastic_workspaceDiff` runs status once, has intentional file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. It excludes private files by default; select their paths explicitly or pass `includePrivate=true` only when 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_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.
22
22
 
23
23
  Prefer runtime `plastic_*` tools first. Keep `cm` shell commands as manual fallback.
24
24
 
25
- Directly invoked mutating `plastic_*` tools run without package-owned approval tokens or UI confirmation. Inspect exact targets, use available preflight modes, and rely on the tools' command, workspace, path-containment, and non-interactive process guards. A command process is attempted once without implicit retry; after an ambiguous failure, inspect Plastic status before deciding whether to retry manually.
25
+ Directly invoked mutating `plastic_*` tools run without package-owned approval tokens or UI confirmation. Inspect exact targets and rely on the tools' command, workspace, path-containment, and non-interactive process guards. Do not call preflight and then the same operation as routine ceremony. Use preflight when mutation scope is ambiguous or broad, moved/deleted paths may need rewriting, a compound operation needs preview, or the user explicitly asks for one. A command process is attempted once without implicit retry; after an ambiguous failure, inspect Plastic status before deciding whether to retry manually.
26
26
 
27
27
  Create normal work branches beneath an intended parent: use `<parent-branch>/<new-branch>`, not `/<new-branch>`. The parent does not need to be the branch loaded in the workspace. Prefer `plastic_branchCreate(branch="<new-branch>", parent="<parent-branch>")`; omitting `parent` uses the current branch only as a convenience. Rare top-level branch creation requires the explicit `allowRootBranch=true` override.
28
28
 
@@ -53,7 +53,7 @@ cm status --all
53
53
 
54
54
  Safe checklist:
55
55
  - Confirm deleted items appear in status output before checkin.
56
- - Prefer `plastic_checkin(preflight=true, paths=[...])` so the tool can rewrite unstable file scopes (moved/deleted) to stable parent scopes.
56
+ - For path-scoped checkins containing moved/deleted items, use `plastic_checkin(preflight=true, paths=[...])` so the tool can preview rewriting unstable file scopes to stable parent scopes. Do not preflight ordinary exact-scope checkins routinely.
57
57
  - `plastic_checkin` now inspects `cm status --machinereadable` and auto-enables `--applychanged` when moved/deleted items are in scope.
58
58
  - Re-run `cm status --all` after add/scope changes, then checkin.
59
59
 
@@ -71,14 +71,13 @@ cm checkin -c="<commit-message>"
71
71
 
72
72
  ## Safe Diff Usage
73
73
 
74
- Never run `cm diff` in Pi.
75
-
76
- Use one of these safe alternatives:
74
+ Never run `cm diff` in Pi. Do not replace it with a routine safe-diff call: use `plastic_status` for pending scope, focused tests for behavior, and direct reads for current content. Request diff output only when change-boundary evidence is needed.
77
75
 
78
76
  ```text
79
77
  plastic_status(machineReadable=true) # list pending paths/statuses
80
- plastic_diffFile(path="<workspace-path>") # one workspace file
81
- plastic_workspaceDiff() # bounded pending review; excludes private unless selected/opted in
78
+ plastic_diffFile(path="<workspace-path>", maxChars=4000) # one intentional file comparison
79
+ plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000) # explicitly scoped review
80
+ plastic_workspaceDiff(allPending=true) # explicit small whole-workspace review
82
81
  plastic_diffFile(path="<workspace-path>", revision="cs:<number>")
83
82
  plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
84
83
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  - Create feature branch following detected conventions.
6
6
  - Use incremental checkins.
7
- - Never use `cm diff`; use text-only diff tools.
7
+ - Never use `cm diff`. Do not diff routinely; use a text-only diff tool only when review needs change-boundary evidence.
8
8
  - Use `plastic_merge` (or `cm merge ... --nointeractiveresolution --mergetype=try`) for autonomous merges.
9
9
 
10
10
  ## Autonomous Merge Workflow
@@ -34,11 +34,12 @@ plastic_patch(source="<branch-spec>", integration=true)
34
34
  plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")
35
35
  ```
36
36
 
37
- - Inspect specific file content changes with the common workspace-base path first:
37
+ - When change-boundary evidence is needed, inspect specific files first and opt into multi-file output explicitly:
38
38
 
39
39
  ```text
40
- plastic_diffFile(path="<workspace-path>") # one workspace file
41
- plastic_workspaceDiff() # bounded pending review
40
+ plastic_diffFile(path="<workspace-path>", maxChars=4000) # one intentional file comparison
41
+ plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000) # explicitly scoped pending review
42
+ plastic_workspaceDiff(allPending=true) # explicit small whole-workspace review
42
43
  plastic_diffFile(path="<workspace-path>", revision="cs:<number>")
43
44
  plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
44
45
  ```
@@ -18,8 +18,8 @@ Note: command examples target current `cm` 11.x CLI syntax; legacy aliases may d
18
18
  | Shelve | `plastic_shelvesetCreate(comment="description")` | `cm shelveset create -c="description"` | Save work temporarily |
19
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 |
20
20
  | Changed-file listing | `plastic_status(machineReadable=true)` | `cm status --all` | Lists changed, added, moved, deleted, and private items without GUI diff |
21
- | Diff (one workspace file) | `plastic_diffFile(path="<workspace-path>")` | None recommended | Uses status base materialization; added/private files use an empty base |
22
- | Diff (pending review) | `plastic_workspaceDiff()` | None recommended | Runs status once; bounded per-file outcomes; private files excluded unless selected/opted in |
21
+ | Diff (one workspace file) | `plastic_diffFile(path="<workspace-path>", maxChars=4000)` | None recommended | Intentional focused comparison only; not routine validation |
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 |
23
23
  | Diff (workspace vs supported revision) | `plastic_diffFile(path="<workspace-path>", revision="cs:<number>")` | None recommended | Also accepts a branch, label, file-qualified, or global revision spec |
24
24
  | Diff (revision vs revision) | `plastic_diffRevisions(leftRevision="<left-revspec>", rightRevision="<right-revspec>")` | None recommended | Requires two file-qualified revisions; avoids GUI and package temp-file recipes |
25
25
 
@@ -21,17 +21,18 @@ plastic_patch(source="<branch-spec>", toolPath="<path-to-diff-tool>")
21
21
 
22
22
  ## Text-Only Diff Options
23
23
 
24
- Prefer tool-first:
24
+ 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 content. Use a diff only when change-boundary evidence is needed, such as reviewing unfamiliar changes or confirming a specific risky hunk.
25
25
 
26
26
  ```text
27
27
  plastic_status(machineReadable=true) # “what changed?” / changed-path listing
28
- plastic_diffFile(path="<workspace-path>") # one workspace file
29
- plastic_workspaceDiff() # bounded pending review; private files excluded by default
28
+ plastic_diffFile(path="<workspace-path>", maxChars=4000) # one intentional file comparison
29
+ plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000) # explicitly scoped pending review
30
+ plastic_workspaceDiff(allPending=true) # explicit small whole-workspace review
30
31
  plastic_diffFile(path="<workspace-path>", revision="cs:<number>") # explicit historical comparison
31
32
  plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
32
33
  ```
33
34
 
34
- The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base; 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` 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.
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.
35
36
 
36
37
  ## Metadata Listing (No GUI Diff)
37
38
 
@@ -50,9 +51,9 @@ Use the Read tool for full context instead of GUI diffs.
50
51
  Use the common no-revision path first; `base`, `head`, and `cs:head` are rejected rather than guessed:
51
52
 
52
53
  ```text
53
- plastic_diffFile(path="<workspace-path>")
54
- plastic_diffFile(path="<workspace-path>", revision="br:/<branch>")
55
- plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
54
+ plastic_diffFile(path="<workspace-path>", maxChars=4000)
55
+ plastic_diffFile(path="<workspace-path>", revision="br:/<branch>", maxChars=4000)
56
+ plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>", maxChars=4000)
56
57
  ```
57
58
 
58
59
  ## Pending Changes
@@ -41,8 +41,9 @@ cm codereview -e <review-id> --reviewer="reviewer-name"
41
41
  - `cm diff` is blocked in Pi because it can launch GUI windows and hang the CLI.
42
42
  - Use text-only alternatives:
43
43
  - `plastic_status(machineReadable=true)` for changed paths/statuses.
44
- - `plastic_diffFile(path="<workspace-path>")` for one workspace file.
45
- - `plastic_workspaceDiff()` for bounded pending review (private files are excluded unless selected/opted in).
44
+ - `plastic_diffFile(path="<workspace-path>", maxChars=4000)` when one intentional file comparison is needed.
45
+ - `plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000)` for an explicitly scoped pending review, or `allPending=true` for an intentional small whole-workspace review.
46
+ - Use `plastic_status` instead when only pending paths are needed; do not diff routinely.
46
47
  - `plastic_diffFile(path="<workspace-path>", revision="cs:<number>")` for an explicit supported revision.
47
48
  - `plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")` for two historical files.
48
49
 
@@ -96,12 +97,12 @@ plastic_resolveDeleteChangeConflict(paths=["<workspace-path>"], keepOnDisk=true)
96
97
 
97
98
  - Do not use `cm diff --summary`; this form is invalid.
98
99
  - Also avoid all other `cm diff` forms in Pi.
99
- - Use the same text-only alternatives listed above.
100
+ - Use `plastic_status` unless change-boundary evidence is actually needed; then use the focused text-only alternatives above.
100
101
 
101
102
  ## Deleted Files Missing From Checkin
102
103
 
103
104
  - Run `cm status --all` and confirm deleted items are listed before checkin.
104
- - Use `plastic_checkin(preflight=true, paths=[...])` first; the tool can auto-rewrite moved/deleted file scopes to parent directories.
105
+ - For a path-scoped checkin containing moved/deleted items, use `plastic_checkin(preflight=true, paths=[...])`; the tool can preview rewriting unstable file scopes to parent directories. Do not add preflight to unrelated routine checkins.
105
106
  - If a path-scoped checkin is rejected as "not changed", rerun with `applyChanged=true` and a parent-directory scope.
106
107
  - Re-run `cm status --all` just before checkin to validate scope.
107
108
 
@@ -245,12 +245,12 @@ const spawnAndCollect = async (
245
245
  }
246
246
  };
247
247
 
248
- const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` to list changed paths, `plastic_diffFile` for one file, `plastic_workspaceDiff` for pending review, or `plastic_diffRevisions` for two explicit revision specs.";
248
+ const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` when only changed paths are needed. Do not diff routinely; when change-boundary evidence is necessary, use a focused `plastic_diffFile`, explicitly scoped `plastic_workspaceDiff`, or `plastic_diffRevisions`.";
249
249
 
250
250
  const ensureCmCommandAllowed = (args: string[]): void =>
251
251
  {
252
252
  const command = (args[0] ?? "").trim().toLowerCase();
253
- if (command === "diff")
253
+ if (command === "diff" || command === "differences")
254
254
  {
255
255
  throw new Error(BLOCKED_CM_DIFF_MESSAGE);
256
256
  }
@@ -1276,7 +1276,14 @@ export const __plasticBranchInternals = {
1276
1276
  resolveBranchCreationTarget,
1277
1277
  };
1278
1278
 
1279
+ // Capture enough subprocess output for diagnostics while keeping the default
1280
+ // agent-facing response intentionally small. Callers may opt into a larger
1281
+ // focused response, but never the full capture bound.
1279
1282
  const DIFF_OUTPUT_MAX_CHARS = 60_000;
1283
+ const DIFF_RESPONSE_DEFAULT_MAX_CHARS = 8_000;
1284
+ const DIFF_RESPONSE_MAX_CHARS = 20_000;
1285
+ const DIFF_RESPONSE_MIN_CHARS = 500;
1286
+ const DIFF_RESPONSE_TOTAL_MAX_CHARS = 24_000;
1280
1287
 
1281
1288
  type TextDiffResult = {
1282
1289
  backend: "diff";
@@ -1395,37 +1402,47 @@ const materializeRevision = async (revision: string, destination: string, workdi
1395
1402
  }
1396
1403
  };
1397
1404
 
1405
+ const normalizeDiffResponseMaxChars = (value: unknown): number =>
1406
+ {
1407
+ if (typeof value !== "number" || !Number.isFinite(value))
1408
+ {
1409
+ return DIFF_RESPONSE_DEFAULT_MAX_CHARS;
1410
+ }
1411
+ return Math.min(DIFF_RESPONSE_MAX_CHARS, Math.max(DIFF_RESPONSE_MIN_CHARS, Math.trunc(value)));
1412
+ };
1413
+
1398
1414
  const formatTextDiff = async (
1399
1415
  action: "diffFile" | "diffRevisions",
1400
1416
  format: OutputFormat | undefined,
1401
1417
  comparisonKind: string,
1402
- result: TextDiffResult,
1418
+ unboundedResult: TextDiffResult,
1403
1419
  workdir: string | undefined,
1404
1420
  comparisonMetadata: Record<string, unknown> = {},
1421
+ maxChars?: number,
1405
1422
  ): Promise<string> =>
1406
1423
  {
1424
+ const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
1407
1425
  const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
1408
1426
  const text = result.binary
1409
1427
  ? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
1410
1428
  : (result.changed ? result.output : "No differences.");
1411
- return toStructuredResult(
1412
- action,
1413
- format ?? "text",
1414
- text,
1415
- {
1416
- comparisonKind,
1417
- ...comparisonMetadata,
1418
- backend: result.backend,
1419
- status,
1420
- changed: result.changed,
1421
- binary: result.binary,
1422
- truncated: result.truncated,
1423
- totalChars: result.totalChars,
1424
- diff: result.changed && !result.binary ? result.output : undefined,
1425
- },
1426
- workdir,
1427
- result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined,
1428
- );
1429
+ const data = {
1430
+ comparisonKind,
1431
+ ...comparisonMetadata,
1432
+ backend: result.backend,
1433
+ status,
1434
+ changed: result.changed,
1435
+ binary: result.binary,
1436
+ truncated: result.truncated,
1437
+ totalChars: result.totalChars,
1438
+ diff: result.changed && !result.binary ? result.output : undefined,
1439
+ };
1440
+ const warnings = result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined;
1441
+ if (format === "json")
1442
+ {
1443
+ return toBoundedDiffStructuredResult(action, data, workdir, warnings);
1444
+ }
1445
+ return text;
1429
1446
  };
1430
1447
 
1431
1448
  const isNoDataError = (message: string): boolean =>
@@ -1459,9 +1476,10 @@ const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiff
1459
1476
  return result;
1460
1477
  }
1461
1478
 
1479
+ const suffix = `\n\n[Diff output truncated at the requested ${maxChars}-character response bound.]`;
1462
1480
  return {
1463
1481
  ...result,
1464
- output: `${result.output.slice(0, maxChars)}\n\n[Per-file diff output truncated at ${maxChars} characters.]`,
1482
+ output: suffix.length >= maxChars ? suffix.slice(0, maxChars) : `${result.output.slice(0, maxChars - suffix.length)}${suffix}`,
1465
1483
  truncated: true,
1466
1484
  totalChars: result.totalChars,
1467
1485
  };
@@ -1469,6 +1487,11 @@ const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiff
1469
1487
 
1470
1488
  export const __plasticDiffInternals = {
1471
1489
  DIFF_OUTPUT_MAX_CHARS,
1490
+ DIFF_RESPONSE_DEFAULT_MAX_CHARS,
1491
+ DIFF_RESPONSE_MAX_CHARS,
1492
+ DIFF_RESPONSE_MIN_CHARS,
1493
+ DIFF_RESPONSE_TOTAL_MAX_CHARS,
1494
+ normalizeDiffResponseMaxChars,
1472
1495
  isBinaryContent,
1473
1496
  stableDiffHeaders,
1474
1497
  boundDiffOutput,
@@ -1730,6 +1753,60 @@ const toStructuredResult = async (
1730
1753
  return `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
1731
1754
  };
1732
1755
 
1756
+ const toBoundedDiffStructuredResult = async (
1757
+ action: "diffFile" | "diffRevisions",
1758
+ data: Record<string, unknown>,
1759
+ workdir?: string,
1760
+ warnings?: string[],
1761
+ ): Promise<string> =>
1762
+ {
1763
+ const wrap = (payload: Record<string, unknown>): string => `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
1764
+ const basePayload = {
1765
+ ok: true,
1766
+ action,
1767
+ toolVersion: TOOL_VERSION,
1768
+ cliVersion: (await getCmVersion(workdir)).slice(0, 256),
1769
+ };
1770
+ const initial = wrap({ ...basePayload, data, ...(warnings?.length ? { warnings } : {}) });
1771
+ if (initial.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
1772
+ {
1773
+ return initial;
1774
+ }
1775
+
1776
+ const originalDiff = typeof data.diff === "string" ? data.diff : "";
1777
+ const responseWarning = `Diff content was further truncated after JSON escaping to keep the complete response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.`;
1778
+ const boundedWarnings = [...(warnings ?? []), responseWarning];
1779
+ const suffix = `\n\n[Diff output truncated to keep the complete JSON response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.]`;
1780
+ const render = (prefixChars: number): string => wrap({
1781
+ ...basePayload,
1782
+ data: {
1783
+ ...data,
1784
+ truncated: true,
1785
+ diff: `${originalDiff.slice(0, prefixChars)}${suffix}`,
1786
+ },
1787
+ warnings: boundedWarnings,
1788
+ });
1789
+
1790
+ let low = 0;
1791
+ let high = originalDiff.length;
1792
+ let best = render(0);
1793
+ while (low <= high)
1794
+ {
1795
+ const middle = Math.floor((low + high) / 2);
1796
+ const candidate = render(middle);
1797
+ if (candidate.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
1798
+ {
1799
+ best = candidate;
1800
+ low = middle + 1;
1801
+ }
1802
+ else
1803
+ {
1804
+ high = middle - 1;
1805
+ }
1806
+ }
1807
+ return best;
1808
+ };
1809
+
1733
1810
  type MergeConflictStrategy = "auto" | "source" | "destination";
1734
1811
 
1735
1812
  type MergeMachineReadableRecord = {
@@ -2656,6 +2733,7 @@ export const diffRevisions = tool({
2656
2733
  args: {
2657
2734
  leftRevision: tool.schema.string().min(1).describe("Left file-qualified Plastic revision spec for cm cat."),
2658
2735
  rightRevision: tool.schema.string().min(1).describe("Right file-qualified Plastic revision spec for cm cat."),
2736
+ maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
2659
2737
  format: outputFormatArg,
2660
2738
  workdir: workdirArg,
2661
2739
  },
@@ -2678,7 +2756,7 @@ export const diffRevisions = tool({
2678
2756
  await materializeRevision(args.leftRevision, leftPath, args.workdir);
2679
2757
  await materializeRevision(args.rightRevision, rightPath, args.workdir);
2680
2758
  const result = await runPortableTextDiff(leftPath, rightPath, cwd, leftLabel, rightLabel);
2681
- return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir);
2759
+ return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir, {}, args.maxChars);
2682
2760
  }
2683
2761
  finally
2684
2762
  {
@@ -2779,6 +2857,7 @@ export const diffFile = tool({
2779
2857
  args: {
2780
2858
  path: tool.schema.string().min(1).describe("Workspace file path to diff."),
2781
2859
  revision: tool.schema.string().optional().describe("Optional revision: changeset number/cs:<number>, br:/<branch>, lb:<label>, file-qualified '<path>#<selector>', or global revid:/rev:. Omit for the workspace base."),
2860
+ maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
2782
2861
  format: outputFormatArg,
2783
2862
  workdir: workdirArg,
2784
2863
  },
@@ -2819,7 +2898,7 @@ export const diffFile = tool({
2819
2898
  throw error;
2820
2899
  }
2821
2900
  const result = await runPortableTextDiff(basePath, workspacePath, cwd, revision.resolved.replace("#", "@"), `${displayPath} (workspace)`);
2822
- return formatTextDiff("diffFile", args.format, revision.kind, result, args.workdir, { pendingKind: null, isNew: false });
2901
+ return formatTextDiff("diffFile", args.format, revision.kind, result, args.workdir, { pendingKind: null, isNew: false }, args.maxChars);
2823
2902
  }
2824
2903
  finally
2825
2904
  {
@@ -2839,21 +2918,24 @@ export const diffFile = tool({
2839
2918
  compared.result,
2840
2919
  args.workdir,
2841
2920
  { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
2921
+ args.maxChars,
2842
2922
  );
2843
2923
  },
2844
2924
  });
2845
2925
 
2846
- const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 12;
2926
+ const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 3;
2847
2927
  const WORKSPACE_DIFF_MAX_FILES = 20;
2848
2928
  const WORKSPACE_DIFF_MAX_PATHS = 20;
2849
2929
  const WORKSPACE_DIFF_PATH_MAX_CHARS = 1_024;
2850
2930
  const WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS = 256;
2851
2931
  const WORKSPACE_DIFF_ERROR_MAX_CHARS = 1_024;
2852
- const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 12_000;
2932
+ const WORKSPACE_DIFF_DEFAULT_MAX_CHARS = 3_000;
2933
+ const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 8_000;
2934
+ const WORKSPACE_DIFF_MIN_CHARS = 500;
2853
2935
  // This is a response bound, not just a diff-body bound. Reserve space for
2854
2936
  // framing and an omission summary so both text and JSON remain useful.
2855
- const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 60_000;
2856
- const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 48_000;
2937
+ const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 20_000;
2938
+ const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 16_000;
2857
2939
 
2858
2940
  const boundWorkspaceValue = (value: string, maxChars: number): string =>
2859
2941
  {
@@ -2945,11 +3027,13 @@ const formatWorkspaceDiffResult = async (
2945
3027
  };
2946
3028
 
2947
3029
  export const workspaceDiff = tool({
2948
- description: "Review bounded pending workspace file diffs in one status pass. Private files are excluded unless explicitly selected or includePrivate=true; unavailable files are reported without aborting the batch.",
3030
+ description: "Review explicitly selected pending workspace file diffs, or opt into a small bounded whole-workspace review with allPending=true. Use plastic_status when only changed paths are needed.",
2949
3031
  args: {
2950
- paths: tool.schema.array(tool.schema.string().min(1).max(WORKSPACE_DIFF_PATH_MAX_CHARS)).max(WORKSPACE_DIFF_MAX_PATHS).optional().describe(`Optional pending workspace paths to review (maximum ${WORKSPACE_DIFF_MAX_PATHS}, ${WORKSPACE_DIFF_PATH_MAX_CHARS} characters each). Selecting paths includes matching private files.`),
2951
- includePrivate: tool.schema.boolean().optional().describe("Include private pending files when paths are omitted. Defaults to false."),
2952
- maxFiles: tool.schema.number().int().min(1).max(WORKSPACE_DIFF_MAX_FILES).optional().describe(`Maximum files to compare (default ${WORKSPACE_DIFF_DEFAULT_MAX_FILES}, maximum ${WORKSPACE_DIFF_MAX_FILES}).`),
3032
+ paths: tool.schema.array(tool.schema.string().min(1).max(WORKSPACE_DIFF_PATH_MAX_CHARS)).max(WORKSPACE_DIFF_MAX_PATHS).optional().describe(`Pending workspace paths to review (maximum ${WORKSPACE_DIFF_MAX_PATHS}, ${WORKSPACE_DIFF_PATH_MAX_CHARS} characters each). Selecting paths includes matching private files.`),
3033
+ allPending: tool.schema.boolean().optional().describe("Explicitly review pending non-private files when paths are omitted. Defaults to false; required for an unscoped workspace diff."),
3034
+ includePrivate: tool.schema.boolean().optional().describe("Include private pending files with allPending=true. Selected private paths do not require this flag."),
3035
+ maxFiles: tool.schema.number().int().min(1).max(WORKSPACE_DIFF_MAX_FILES).optional().describe(`Maximum files to compare for allPending review (default ${WORKSPACE_DIFF_DEFAULT_MAX_FILES}, maximum ${WORKSPACE_DIFF_MAX_FILES}); selected paths are all considered unless this is set.`),
3036
+ maxChars: tool.schema.number().int().min(WORKSPACE_DIFF_MIN_CHARS).max(WORKSPACE_DIFF_PER_FILE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned per file (default ${WORKSPACE_DIFF_DEFAULT_MAX_CHARS}, maximum ${WORKSPACE_DIFF_PER_FILE_MAX_CHARS}).`),
2953
3037
  format: outputFormatArg,
2954
3038
  workdir: workdirArg,
2955
3039
  },
@@ -2957,20 +3041,58 @@ export const workspaceDiff = tool({
2957
3041
  {
2958
3042
  const cwd = args.workdir ?? process.cwd();
2959
3043
  // Runtime guards also cover direct execute() callers that bypass schema validation.
2960
- const rawPaths = Array.isArray(args.paths) ? args.paths.filter((path): path is string => typeof path === "string") : [];
3044
+ if (args.paths !== undefined && !Array.isArray(args.paths))
3045
+ {
3046
+ throw new Error("paths must be an array of non-blank workspace paths.");
3047
+ }
3048
+ if (args.allPending !== undefined && typeof args.allPending !== "boolean")
3049
+ {
3050
+ throw new Error("allPending must be a boolean; whole-workspace review requires allPending=true explicitly.");
3051
+ }
3052
+ if (args.includePrivate !== undefined && typeof args.includePrivate !== "boolean")
3053
+ {
3054
+ throw new Error("includePrivate must be a boolean and only applies with allPending=true.");
3055
+ }
3056
+ const rawPaths = Array.isArray(args.paths) ? args.paths : [];
3057
+ if (rawPaths.some((path) => typeof path !== "string" || path.trim().length === 0))
3058
+ {
3059
+ throw new Error("paths must contain only non-blank workspace paths.");
3060
+ }
2961
3061
  const selectedPaths = rawPaths.length > 0
2962
3062
  ? rawPaths.slice(0, WORKSPACE_DIFF_MAX_PATHS).map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_PATH_MAX_CHARS))
2963
3063
  : undefined;
3064
+ const allPending = args.allPending === true;
3065
+ const includePrivate = args.includePrivate === true;
3066
+ const workspaceRoot = toNormalizedAbsolutePath(".", cwd);
3067
+ if (selectedPaths?.some((path) => toPathComparisonKeyFromAbsolutePath(toNormalizedAbsolutePath(path, cwd)) === toPathComparisonKeyFromAbsolutePath(workspaceRoot)))
3068
+ {
3069
+ throw new Error("A workspace-root path is a whole-workspace review; use allPending=true instead of selecting the workspace root.");
3070
+ }
3071
+ if (selectedPaths && allPending)
3072
+ {
3073
+ throw new Error("Choose either explicit paths or allPending=true, not both.");
3074
+ }
3075
+ if (!selectedPaths && !allPending)
3076
+ {
3077
+ throw new Error("plastic_workspaceDiff requires explicit paths or allPending=true. Use plastic_status when only pending paths are needed.");
3078
+ }
3079
+ if (selectedPaths && includePrivate)
3080
+ {
3081
+ throw new Error("includePrivate is only used with allPending=true; explicitly selected private paths are already included.");
3082
+ }
2964
3083
  const ignoredPathInputs = Math.max(0, rawPaths.length - (selectedPaths?.length ?? 0));
2965
3084
  const pendingItems = await getMachineReadablePendingItems(args.workdir); // exactly one status command per batch
2966
3085
  const selectedScopes = selectedPaths?.map((path) => toNormalizedAbsolutePath(path, cwd)) ?? [];
2967
3086
  let candidates = selectedPaths ? filterPendingItemsByScope(pendingItems, selectedScopes) : pendingItems.filter((item) => item.kind !== "private");
2968
- if (!selectedPaths && args.includePrivate)
3087
+ if (!selectedPaths && includePrivate)
2969
3088
  {
2970
3089
  candidates = pendingItems;
2971
3090
  }
2972
- const requestedMaxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles) ? Math.trunc(args.maxFiles) : WORKSPACE_DIFF_DEFAULT_MAX_FILES;
3091
+ const defaultMaxFiles = selectedPaths ? selectedPaths.length : WORKSPACE_DIFF_DEFAULT_MAX_FILES;
3092
+ const requestedMaxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles) ? Math.trunc(args.maxFiles) : defaultMaxFiles;
2973
3093
  const maxFiles = Math.min(WORKSPACE_DIFF_MAX_FILES, Math.max(1, requestedMaxFiles));
3094
+ const requestedMaxChars = typeof args.maxChars === "number" && Number.isFinite(args.maxChars) ? Math.trunc(args.maxChars) : WORKSPACE_DIFF_DEFAULT_MAX_CHARS;
3095
+ const maxChars = Math.min(WORKSPACE_DIFF_PER_FILE_MAX_CHARS, Math.max(WORKSPACE_DIFF_MIN_CHARS, requestedMaxChars));
2974
3096
  const skippedByLimit = Math.max(0, candidates.length - maxFiles);
2975
3097
  candidates = candidates.slice(0, maxFiles);
2976
3098
 
@@ -2999,7 +3121,7 @@ export const workspaceDiff = tool({
2999
3121
  {
3000
3122
  const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
3001
3123
  const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3002
- const perFile = boundWorkspaceDiffResult(compared.result, WORKSPACE_DIFF_PER_FILE_MAX_CHARS);
3124
+ const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3003
3125
  const bounded = boundWorkspaceDiffResult(perFile, remaining);
3004
3126
  const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
3005
3127
  const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
@@ -3025,14 +3147,14 @@ export const workspaceDiff = tool({
3025
3147
  ...(skippedByLimit > 0 ? [`Skipped ${skippedByLimit} pending item(s) after the maxFiles bound of ${maxFiles}.`] : []),
3026
3148
  ...(unmatchedPreviews.length > 0 ? [`${unmatchedPreviews.length} selected path(s) had no pending status record: ${unmatchedPreviews.slice(0, 5).join(", ")}${unmatchedPreviews.length > 5 ? `; ${unmatchedPreviews.length - 5} more omitted` : ""}.`] : []),
3027
3149
  ...(ignoredPathInputs > 0 ? [`Ignored ${ignoredPathInputs} path input(s) after the ${WORKSPACE_DIFF_MAX_PATHS}-path bound.`] : []),
3028
- ...(!selectedPaths && !args.includePrivate && pendingItems.some((item) => item.kind === "private") ? ["Private pending files were excluded; select their paths explicitly or pass includePrivate=true."] : []),
3150
+ ...(!selectedPaths && !includePrivate && pendingItems.some((item) => item.kind === "private") ? ["Private pending files were excluded; select their paths explicitly or pass includePrivate=true."] : []),
3029
3151
  ].map((warning) => boundWorkspaceValue(warning, WORKSPACE_DIFF_ERROR_MAX_CHARS));
3030
3152
  const textPrefix = [
3031
3153
  "## Workspace Diff",
3032
3154
  "",
3033
3155
  `- Status records inspected once: ${pendingItems.length}`,
3034
3156
  `- Files considered: ${candidates.length}`,
3035
- `- Per-file output bound: ${WORKSPACE_DIFF_PER_FILE_MAX_CHARS} characters; complete response bound: ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.`,
3157
+ `- Per-file output bound: ${maxChars} characters; complete response bound: ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.`,
3036
3158
  ...warnings.map((warning) => `- Warning: ${warning}`),
3037
3159
  ...(textSections.length > 0 ? [] : ["", "No eligible pending files."]),
3038
3160
  ].join("\n");
@@ -3040,9 +3162,10 @@ export const workspaceDiff = tool({
3040
3162
  statusRecords: pendingItems.length,
3041
3163
  selectedPaths: selectedPaths ? workspacePathPreview(selectedPaths) : null,
3042
3164
  selectedPathCount: selectedPaths?.length ?? 0,
3043
- includePrivate: args.includePrivate ?? false,
3165
+ allPending,
3166
+ includePrivate,
3044
3167
  maxFiles,
3045
- perFileMaxChars: WORKSPACE_DIFF_PER_FILE_MAX_CHARS,
3168
+ perFileMaxChars: maxChars,
3046
3169
  totalMaxChars: WORKSPACE_DIFF_TOTAL_MAX_CHARS,
3047
3170
  skippedByLimit,
3048
3171
  unmatchedPaths: unmatchedPreviews,
@@ -27,8 +27,8 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
27
27
  { name: "plastic_undo", aliases: ["undo changes", "revert changes", "discard changes"], tags: ["changes", "mutation", "destructive"], guidance: ["Confirm the exact paths before discarding pending changes."] },
28
28
  { name: "plastic_resolveDeleteChangeConflict", aliases: ["resolve delete conflict", "deleted conflict"], tags: ["merge", "conflict", "resolution"], guidance: ["Inspect the conflict and choose whether files should remain on disk before resolving it."] },
29
29
  { name: "plastic_patch", aliases: ["patch", "generate patch", "review patch"], tags: ["diff", "patch", "export"], guidance: ["Inspect generated patches before sharing because they can include source, paths, or secrets."] },
30
- { name: "plastic_diffFile", aliases: ["diff", "diff file", "file diff", "compare one file", "compare file"], tags: ["diff", "file", "workspace", "inspect"], guidance: ["For one workspace file, pass path and omit revision; use a documented changeset, branch, label, file-qualified, or global revision only when needed."] },
31
- { name: "plastic_workspaceDiff", aliases: ["workspace diff", "pending review", "review pending changes", "review workspace changes", "diff pending files"], tags: ["diff", "workspace", "review", "inspect"], guidance: ["For pending-change review, runs status once and returns bounded per-file outcomes. Private files are excluded unless explicitly selected or includePrivate=true."] },
30
+ { name: "plastic_diffFile", aliases: ["diff", "diff file", "file diff", "compare one file", "compare file"], tags: ["diff", "file", "workspace", "inspect"], guidance: ["Use only when change-boundary evidence is needed. Pass one exact workspace path, omit revision for its Plastic base, and keep maxChars small unless more output is intentional."] },
31
+ { name: "plastic_workspaceDiff", aliases: ["workspace diff", "pending review", "review pending changes", "review workspace changes", "diff pending files"], tags: ["diff", "workspace", "review", "inspect"], guidance: ["Use only for an intentional multi-file content review, not routine validation. Select exact paths or pass allPending=true explicitly; use plastic_status when only changed paths are needed."] },
32
32
  { name: "plastic_diffRevisions", aliases: ["diff revisions", "compare revisions", "revision diff"], tags: ["diff", "revision", "inspect"], guidance: ["Use this only for two explicit file-qualified revision specifications."] },
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."] },
@@ -168,8 +168,7 @@ function safetyPriority(entry: PlasticSearchCatalogEntry): number {
168
168
  export function searchPlasticTools(input: PlasticToolSearchInput, descriptions: ReadonlyMap<string, string> = new Map()): PlasticToolSearchMatch[] {
169
169
  const requestedNames = exactRequestedNames(input.toolNames);
170
170
  const terms = typeof input.query === "string" ? tokenize(input.query) : [];
171
- const normalizedLimit = normalizeLimit(input.limit, requestedNames);
172
- const limit = input.limit === undefined && requestedNames.size === 0 && terms.length === 1 && terms[0] === "diff" ? 2 : normalizedLimit;
171
+ const limit = normalizeLimit(input.limit, requestedNames);
173
172
  const domainTerms = terms.filter((term) => SEARCH_DOMAIN_TERMS.has(term));
174
173
  const primaryDomainTerm = domainTerms[0];
175
174
  const mutationIntent = terms.some((term) => MUTATION_INTENT_TERMS.has(term));