@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 +14 -0
- package/README.md +15 -4
- package/extensions/bash-cm-diff-guard.ts +30 -4
- package/extensions/shared/bash-command-guards.ts +6 -4
- package/index.ts +6 -2
- package/package.json +1 -1
- package/skills/using-plastic/SKILL.md +3 -3
- package/skills/using-plastic/references/changeset-operations.md +5 -6
- package/skills/using-plastic/references/integration.md +5 -4
- package/skills/using-plastic/references/quick-reference.md +2 -2
- package/skills/using-plastic/references/reviewing-changes.md +8 -7
- package/skills/using-plastic/references/troubleshooting.md +5 -4
- package/src/plastic-core.ts +163 -40
- package/src/plastic-tool-loading.ts +3 -4
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
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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)
|
|
5
|
-
/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cmd(?:\.exe)
|
|
6
|
-
/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)
|
|
7
|
-
/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?(?:pwsh|powershell)(?:\.exe)
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
81
|
-
plastic_workspaceDiff() #
|
|
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
|
|
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
|
-
-
|
|
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
|
|
41
|
-
plastic_workspaceDiff() #
|
|
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 |
|
|
22
|
-
| Diff (pending review) | `plastic_workspaceDiff()` | None recommended |
|
|
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
|
|
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
|
|
29
|
-
plastic_workspaceDiff() #
|
|
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>")`
|
|
45
|
-
- `plastic_workspaceDiff()` for
|
|
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
|
|
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
|
-
-
|
|
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
|
|
package/src/plastic-core.ts
CHANGED
|
@@ -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`
|
|
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
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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 =
|
|
2856
|
-
const WORKSPACE_DIFF_CONTENT_MAX_CHARS =
|
|
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
|
|
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(`
|
|
2951
|
-
|
|
2952
|
-
|
|
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
|
-
|
|
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 &&
|
|
3087
|
+
if (!selectedPaths && includePrivate)
|
|
2969
3088
|
{
|
|
2970
3089
|
candidates = pendingItems;
|
|
2971
3090
|
}
|
|
2972
|
-
const
|
|
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,
|
|
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 && !
|
|
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: ${
|
|
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
|
-
|
|
3165
|
+
allPending,
|
|
3166
|
+
includePrivate,
|
|
3044
3167
|
maxFiles,
|
|
3045
|
-
perFileMaxChars:
|
|
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: ["
|
|
31
|
-
{ name: "plastic_workspaceDiff", aliases: ["workspace diff", "pending review", "review pending changes", "review workspace changes", "diff pending files"], tags: ["diff", "workspace", "review", "inspect"], guidance: ["
|
|
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
|
|
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));
|