@aefree/pi-plastic 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.5.7] - 2026-09-14
11
+
12
+ ### Fixed
13
+
14
+ - Bind automatic pending-file diff bases to the owning workspace or Xlink repository/server before materialization, preventing colliding revision IDs from another repository from producing plausible but unrelated diffs. Unresolved ownership is reported as unavailable.
15
+
16
+ ## [0.5.6] - 2026-08-27
17
+
18
+ ### Fixed
19
+
20
+ - Warn once per interactive Pi runtime when a configured `PI_PLASTIC_PATCH_EXECUTABLE` cannot launch or Windows lacks its required patch backend setting. Warnings redact configured paths; `plastic_patch` retains its existing tool-time validation.
21
+
10
22
  ## [0.5.5] - 2026-08-14
11
23
 
12
24
  ### Changed
package/README.md CHANGED
@@ -73,7 +73,7 @@ Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package
73
73
  The Bash guards run only in Pi processes that actually load this package. A project-local install does not protect a delegated child process whose working directory resolves different project settings. Install `pi-plastic` at user scope (the default `pi install`, without `-l`) when subagents and sessions in arbitrary workspaces must inherit the guards. Restart existing Pi processes after installation or source changes.
74
74
 
75
75
  - `plastic_branchCreate` supports an explicit parent branch independent of the loaded workspace branch, defaults relative names to the current branch when no parent is supplied, and rejects top-level paths unless `allowRootBranch=true` is explicit.
76
- - `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
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. Automatic pending-file bases bind each status revision ID to its owning workspace or Xlink repository/server, including mixed-Xlink reviews. If ownership cannot be resolved, that comparison is reported unavailable rather than retrieving a bare revision from the caller repository. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
77
77
  - `plastic_status(machineReadable=true, format="json")` returns parsed `items`, aggregate `summary`, and `itemCount` (`total`, `returned`, `omitted`) rather than requiring agents to parse Plastic rows. It returns at most 100 items by default; set `maxItems` (up to 500) when more are intentional. `includeRaw=true` adds the original unbounded Plastic output for diagnostics, so omit it for normal agent use to conserve context tokens.
78
78
  - Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
79
79
  - `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
@@ -108,13 +108,13 @@ pi install npm:@aefree/pi-plastic
108
108
  Install a pinned GitHub release over HTTPS:
109
109
 
110
110
  ```bash
111
- pi install git:github.com/aefreedman/pi-plastic@v0.5.4
111
+ pi install git:github.com/aefreedman/pi-plastic@v0.5.7
112
112
  ```
113
113
 
114
114
  Equivalent SSH install:
115
115
 
116
116
  ```bash
117
- pi install git:git@github.com/aefreedman/pi-plastic@v0.5.4
117
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
118
118
  ```
119
119
 
120
120
  To intentionally track the moving default branch instead of a release tag:
@@ -143,7 +143,7 @@ Project-local installation protects only Pi processes that load those project se
143
143
  - Pi 0.82.0 or newer; the current development and eval baseline is Pi 0.83
144
144
  - Plastic SCM / Unity Version Control CLI (`cm`) available on `PATH`, or `PI_PLASTIC_CM_EXECUTABLE` set to its full executable path
145
145
  - A GNU/POSIX-compatible text `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff tools. Pi invokes that resolved executable as-is and does not discover Git Bash paths automatically. On macOS, bare `diff` is PATH-dependent: it may be GNU Diffutils (often installed as `diff` ahead of system paths) or Apple’s BSD `/usr/bin/diff`; the package does not substitute one for the other. Configure the intended text executable explicitly when that distinction matters.
146
- - Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract.
146
+ - Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract. In an interactive Pi session, an invalid configured patch override or missing Windows patch setting produces one package capability warning per Pi runtime; the warning omits configured paths, and `plastic_patch` retains its tool-time validation.
147
147
  - A configured Plastic workspace for workspace-scoped operations. Text diffs require an ASCII-safe temporary directory; if the configured backend cannot accept Unicode paths, set `TEMP` and `TMP` to a writable ASCII-only location.
148
148
  - `pi-file-discovery` is an optional independently loaded integration. When its `discover_candidate_files` tool is active, it receives the advisory Plastic ignore/cloak filter through the shared global capability protocol; when absent, `pi-plastic` loads without file-discovery filtering. The tarball does not embed linked sibling workspaces or `node_modules` paths.
149
149
 
package/index.ts CHANGED
@@ -656,6 +656,28 @@ function metadataDescription(node: CoreSchemaNode): string {
656
656
  return node.metadata?.description ?? "Allowed values.";
657
657
  }
658
658
 
659
+ type CapabilityDiagnosticContext = {
660
+ hasUI?: boolean;
661
+ ui?: { notify?: (message: string, level: "warning") => void };
662
+ };
663
+
664
+ type PatchCapabilityWarningGetter = () => Promise<string | undefined>;
665
+
666
+ export const __plasticCapabilityDiagnosticInternals = {
667
+ createPatchCapabilityWarningNotifier(getWarning: PatchCapabilityWarningGetter = () => core.getPatchBackendCapabilityWarning()) {
668
+ let emitted = false;
669
+ return async (ctx: CapabilityDiagnosticContext): Promise<void> => {
670
+ if (emitted || !ctx.hasUI || typeof ctx.ui?.notify !== "function") return;
671
+ const warning = await getWarning();
672
+ if (!warning) return;
673
+ emitted = true;
674
+ ctx.ui.notify(warning, "warning");
675
+ };
676
+ },
677
+ };
678
+
679
+ const notifyPatchCapabilityWarning = __plasticCapabilityDiagnosticInternals.createPatchCapabilityWarningNotifier();
680
+
659
681
  function buildParameters(args: Record<string, unknown> | undefined): TSchema {
660
682
  if (!args || Object.keys(args).length === 0) {
661
683
  return EMPTY_PARAMETERS;
@@ -770,7 +792,8 @@ export default function plasticTools(pi: ExtensionAPI) {
770
792
  },
771
793
  });
772
794
 
773
- pi.on("session_start", (_event, ctx) => {
795
+ pi.on("session_start", async (_event, ctx) => {
796
+ await notifyPatchCapabilityWarning(ctx as CapabilityDiagnosticContext);
774
797
  const ownership = getEffectivePlasticToolOwnership(pi.getAllTools(), EXTENSION_SOURCE_PATH);
775
798
  const active = pi.getActiveTools();
776
799
  const mode = getPlasticToolLoadingMode();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -18,8 +18,7 @@
18
18
  "skills/",
19
19
  "CHANGELOG.md",
20
20
  "LICENSE",
21
- "README.md",
22
- "SECURITY.md"
21
+ "README.md"
23
22
  ],
24
23
  "pi": {
25
24
  "extensions": [
@@ -34,7 +33,7 @@
34
33
  ]
35
34
  },
36
35
  "scripts": {
37
- "test": "npx --yes tsx tests/plastic-tool-validation.ts && npx --yes tsx tests/plastic-status-machine-readable.test.ts && npx --yes tsx tests/plastic-checkin-path-resolution.ts && npx --yes tsx tests/plastic-diff-workflow.test.ts && npx --yes tsx tests/plastic-workspace-diff.test.ts && npx --yes tsx tests/plastic-process-lifecycle.test.ts && npx --yes tsx tests/plastic-patch-command.test.ts && npx --yes tsx tests/plastic-extension-registration.test.ts && npx --yes tsx tests/plastic-dynamic-tool-loading.test.ts && npx --yes tsx tests/plastic-tool-rendering.test.ts && npx --yes tsx tests/plastic-branch-query.test.ts && npx --yes tsx tests/plastic-workspace.test.ts && npx --yes tsx tests/plastic-mutation-execution.test.ts && npx --yes tsx tests/file-discovery-filter.test.ts && npx --yes tsx tests/file-discovery-filter-registration.test.ts && npx --yes tsx tests/packed-copy-owner-conflict.test.ts && npx --yes tsx tests/plastic-branch-status.test.ts && npx --yes tsx tests/plastic-constrained-sampling.test.ts && npx --yes tsx tests/bash-cm-diff-guard-validation.ts",
36
+ "test": "npx --yes tsx tests/plastic-tool-validation.ts && npx --yes tsx tests/plastic-status-machine-readable.test.ts && npx --yes tsx tests/plastic-checkin-path-resolution.ts && npx --yes tsx tests/plastic-diff-workflow.test.ts && npx --yes tsx tests/plastic-workspace-diff.test.ts && npx --yes tsx tests/plastic-process-lifecycle.test.ts && npx --yes tsx tests/plastic-patch-command.test.ts && npx --yes tsx tests/plastic-capability-diagnostics.test.ts && npx --yes tsx tests/plastic-extension-registration.test.ts && npx --yes tsx tests/plastic-dynamic-tool-loading.test.ts && npx --yes tsx tests/plastic-tool-rendering.test.ts && npx --yes tsx tests/plastic-branch-query.test.ts && npx --yes tsx tests/plastic-workspace.test.ts && npx --yes tsx tests/plastic-mutation-execution.test.ts && npx --yes tsx tests/file-discovery-filter.test.ts && npx --yes tsx tests/file-discovery-filter-registration.test.ts && npx --yes tsx tests/packed-copy-owner-conflict.test.ts && npx --yes tsx tests/plastic-branch-status.test.ts && npx --yes tsx tests/plastic-constrained-sampling.test.ts && npx --yes tsx tests/bash-cm-diff-guard-validation.ts",
38
37
  "test:live": "npx --yes tsx tests/plastic-live-smoke.test.ts",
39
38
  "test:live:branch-create": "npx --yes tsx tests/plastic-branch-create-live.test.ts",
40
39
  "eval:tool-loading": "npx --yes tsx evals/tool-loading/run-eval.ts"
@@ -22,7 +22,7 @@ Plastic merges are workspace- and branch-oriented, not Git fast-forwards: the me
22
22
 
23
23
  `plastic_workspaceDiff` requires selected paths or explicit `allPending=true`, runs status once, has small default file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. Selected private paths are included; whole-workspace private review additionally requires `includePrivate=true`. Keep `maxChars` small unless more output is intentional. Text-diff selection and patch-backend selection are distinct: text tools use `PI_PLASTIC_DIFF_EXECUTABLE` or bare PATH `diff`. On macOS, bare `diff` can resolve either to GNU Diffutils or Apple’s BSD `/usr/bin/diff`; Pi uses the resolved executable and does not auto-select `gdiff`.
24
24
 
25
- Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
25
+ Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Interactive Pi sessions issue one path-redacted package capability warning per runtime for an invalid configured patch override or missing Windows setting; fix `PI_PLASTIC_PATCH_EXECUTABLE` and retry, while headless sessions retain `plastic_patch` tool-time validation. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
26
26
 
27
27
  Prefer runtime `plastic_*` tools first. Keep `cm` shell commands as manual fallback.
28
28
 
@@ -32,6 +32,7 @@ const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC
32
32
  // Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
33
33
  const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
34
34
  const PLASTIC_PATCH_EXECUTABLE_ENV = "PI_PLASTIC_PATCH_EXECUTABLE";
35
+ type PatchExecutableProbe = (command: string) => Promise<boolean>;
35
36
  // Plastic's server/backend determines whether a moved item is encoded as a
36
37
  // move or as delete/add records. Do not claim either without a live fixture.
37
38
  const PATCH_MOVE_REPRESENTATION = "backend-determined" as const;
@@ -312,6 +313,7 @@ type PendingItem = {
312
313
  kind: PendingItemKind;
313
314
  revisionId?: string;
314
315
  sourceWorkspacePath?: string;
316
+ baseRepository?: string;
315
317
  };
316
318
 
317
319
  type PendingItemSummary = {
@@ -1228,6 +1230,49 @@ const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string |
1228
1230
  }
1229
1231
  };
1230
1232
 
1233
+ const probePatchExecutable = async (command: string): Promise<boolean> =>
1234
+ {
1235
+ try
1236
+ {
1237
+ // This establishes only that the configured executable can launch.
1238
+ // `cm patch` remains the authoritative verifier for its --binary contract.
1239
+ const result = await spawnAndCollect(command, ["--version"], process.cwd(), undefined, AbortSignal.timeout(3000), {
1240
+ abortKillDelayMs: 100,
1241
+ outputLimitChars: 1024,
1242
+ });
1243
+ return !result.aborted;
1244
+ }
1245
+ catch
1246
+ {
1247
+ return false;
1248
+ }
1249
+ };
1250
+
1251
+ export const getPatchBackendCapabilityWarning = async (
1252
+ environment: ExecutableEnvironment = process.env,
1253
+ platform: NodeJS.Platform = process.platform,
1254
+ probe: PatchExecutableProbe = probePatchExecutable,
1255
+ ): Promise<string | undefined> =>
1256
+ {
1257
+ const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
1258
+ if (configuredPatchExecutable)
1259
+ {
1260
+ if (await probe(configuredPatchExecutable))
1261
+ {
1262
+ return undefined;
1263
+ }
1264
+
1265
+ return "Pi Plastic capability warning: the configured PI_PLASTIC_PATCH_EXECUTABLE could not be launched. Set it to a verified patch-capable non-GUI diff executable, or remove the override to use this platform's documented fallback. The configured path is not shown for privacy.";
1266
+ }
1267
+
1268
+ if (platform === "win32")
1269
+ {
1270
+ return "Pi Plastic capability warning: plastic_patch requires PI_PLASTIC_PATCH_EXECUTABLE on Windows. Set it to a verified patch-capable non-GUI diff executable (for example Git's diff.exe). PI_PLASTIC_DIFF_EXECUTABLE is only for text diffs.";
1271
+ }
1272
+
1273
+ return undefined;
1274
+ };
1275
+
1231
1276
  const resolvePatchToolPath = (
1232
1277
  toolPath?: string,
1233
1278
  environment: ExecutableEnvironment = process.env,
@@ -1491,6 +1536,7 @@ export const __plasticProcessInternals = {
1491
1536
  export const __plasticPatchInternals = {
1492
1537
  buildPatchCommandArgs,
1493
1538
  resolvePatchToolPath,
1539
+ getPatchBackendCapabilityWarning,
1494
1540
  qualifyPatchBranchSpec,
1495
1541
  resolvePatchBranchSpecs,
1496
1542
  createPatchOutputStaging,
@@ -1698,6 +1744,153 @@ const materializeRevision = async (revision: string, destination: string, workdi
1698
1744
  }
1699
1745
  };
1700
1746
 
1747
+ type PendingBaseIdentityResolver = {
1748
+ resolve: (item: PendingItem) => Promise<string>;
1749
+ };
1750
+
1751
+ type RepositoryIdentity = {
1752
+ repository: string;
1753
+ server: string;
1754
+ };
1755
+
1756
+ const parseRepositoryIdentity = (selector: string | undefined): RepositoryIdentity | null =>
1757
+ {
1758
+ if (!selector || /[\u0000-\u001f\u007f]/.test(selector))
1759
+ {
1760
+ return null;
1761
+ }
1762
+ // Repository names cannot contain @; cloud server names can (for example,
1763
+ // an organization-qualified cloud endpoint), so split only at the first @.
1764
+ const separator = selector.indexOf("@");
1765
+ if (separator <= 0 || separator === selector.length - 1)
1766
+ {
1767
+ return null;
1768
+ }
1769
+ return { repository: selector.slice(0, separator), server: selector.slice(separator + 1) };
1770
+ };
1771
+
1772
+ const parseRepositorySelector = (output: string): RepositoryIdentity | null =>
1773
+ {
1774
+ const match = output.match(/^\s*repository\s+(?:"([^"]+)"|(\S+))\s*$/mi);
1775
+ return parseRepositoryIdentity(match?.[1] ?? match?.[2]);
1776
+ };
1777
+
1778
+ const parseXlinkRepositorySelector = (output: string): RepositoryIdentity | null =>
1779
+ {
1780
+ // `cm xlink --show` reports wxlink:<serverpath>@<loaded-revision>@<repository>@<server>.
1781
+ const match = output.match(/^.+?\s+-->\s+wxlink:.+?@(?:\d+|cs:\d+)@(.+)\s*$/mi);
1782
+ return parseRepositoryIdentity(match?.[1]?.trim());
1783
+ };
1784
+
1785
+ const isNotXlinkError = (error: unknown): boolean => /\bis not an xlink\.?\s*$/i.test(error instanceof Error ? error.message.trim() : String(error).trim());
1786
+
1787
+ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): PendingBaseIdentityResolver =>
1788
+ {
1789
+ let workspaceRoot: Promise<string> | undefined;
1790
+ const getWorkspaceRoot = (): Promise<string> =>
1791
+ {
1792
+ workspaceRoot ??= discoverPlasticWorkspace(cwd).then((outcome) =>
1793
+ {
1794
+ if (outcome.kind !== "found")
1795
+ {
1796
+ throw new Error(`Plastic cannot determine the workspace root for pending base resolution: ${outcome.kind === "unavailable" ? outcome.reason : "no workspace marker found"}.`);
1797
+ }
1798
+ return toNormalizedAbsolutePath(outcome.value.root, cwd);
1799
+ });
1800
+ return workspaceRoot;
1801
+ };
1802
+ const xlinkLookups = new Map<string, Promise<RepositoryIdentity | null>>();
1803
+ let workspaceRepository: Promise<RepositoryIdentity> | undefined;
1804
+ const getWorkspaceRepository = (): Promise<RepositoryIdentity> =>
1805
+ {
1806
+ workspaceRepository ??= runCmRaw(["showselector"], workdir).then((output) =>
1807
+ {
1808
+ const repository = parseRepositorySelector(output);
1809
+ if (!repository)
1810
+ {
1811
+ throw new Error("Plastic returned no repository/server identity for this workspace.");
1812
+ }
1813
+ return repository;
1814
+ });
1815
+ return workspaceRepository;
1816
+ };
1817
+ const getXlinkRepository = (candidate: string): Promise<RepositoryIdentity | null> =>
1818
+ {
1819
+ const key = toPathComparisonKeyFromAbsolutePath(candidate);
1820
+ let lookup = xlinkLookups.get(key);
1821
+ if (!lookup)
1822
+ {
1823
+ lookup = runCmRaw(["xlink", "--show", candidate], workdir)
1824
+ .then((output) =>
1825
+ {
1826
+ const repository = parseXlinkRepositorySelector(output);
1827
+ if (!repository)
1828
+ {
1829
+ throw new Error(`Plastic returned malformed Xlink ownership for '${candidate}'.`);
1830
+ }
1831
+ return repository;
1832
+ })
1833
+ .catch((error) =>
1834
+ {
1835
+ if (isNotXlinkError(error))
1836
+ {
1837
+ return null;
1838
+ }
1839
+ throw error;
1840
+ });
1841
+ xlinkLookups.set(key, lookup);
1842
+ }
1843
+ return lookup;
1844
+ };
1845
+
1846
+ return {
1847
+ async resolve(item)
1848
+ {
1849
+ if (!item.revisionId)
1850
+ {
1851
+ throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
1852
+ }
1853
+ const root = await getWorkspaceRoot();
1854
+ const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
1855
+ ? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
1856
+ : item.normalizedPath;
1857
+ if (!isWithinPathScope(toPathComparisonKeyFromAbsolutePath(ownershipPath), toPathComparisonKeyFromAbsolutePath(root)))
1858
+ {
1859
+ throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
1860
+ }
1861
+ let candidate = dirname(ownershipPath);
1862
+ while (isWithinPathScope(toPathComparisonKeyFromAbsolutePath(candidate), toPathComparisonKeyFromAbsolutePath(root)))
1863
+ {
1864
+ // A missing ancestor can be a removed Xlink mount. Status does not
1865
+ // retain ownership, so parent-repository fallback would be unsafe.
1866
+ if ((item.kind === "deleted" || item.kind === "moved") && !(await fs.stat(candidate).catch(() => null)))
1867
+ {
1868
+ throw new Error(`Plastic cannot resolve the owning repository for '${item.workspacePath}' because an ownership ancestor is missing.`);
1869
+ }
1870
+ const repository = await getXlinkRepository(candidate);
1871
+ if (repository)
1872
+ {
1873
+ item.baseRepository = `${repository.repository}@${repository.server}`;
1874
+ return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
1875
+ }
1876
+ if (candidate === root)
1877
+ {
1878
+ break;
1879
+ }
1880
+ const parent = dirname(candidate);
1881
+ if (parent === candidate)
1882
+ {
1883
+ break;
1884
+ }
1885
+ candidate = parent;
1886
+ }
1887
+ const repository = await getWorkspaceRepository();
1888
+ item.baseRepository = `${repository.repository}@${repository.server}`;
1889
+ return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
1890
+ },
1891
+ };
1892
+ };
1893
+
1701
1894
  const normalizeDiffResponseMaxChars = (value: unknown): number =>
1702
1895
  {
1703
1896
  if (typeof value !== "number" || !Number.isFinite(value))
@@ -3127,6 +3320,7 @@ type WorkspacePendingDiff = {
3127
3320
  const diffPendingWorkspaceFile = async (
3128
3321
  args: { path: string; workdir?: string },
3129
3322
  pendingItem?: PendingItem,
3323
+ baseIdentityResolver?: PendingBaseIdentityResolver,
3130
3324
  ): Promise<WorkspacePendingDiff> =>
3131
3325
  {
3132
3326
  const cwd = args.workdir ?? process.cwd();
@@ -3150,11 +3344,19 @@ const diffPendingWorkspaceFile = async (
3150
3344
  ? `${revisionPath} (empty before private/new file)`
3151
3345
  : workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
3152
3346
  const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
3153
- const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
3154
3347
  if (isDeleted && !pendingItem?.revisionId)
3155
3348
  {
3156
3349
  throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
3157
3350
  }
3351
+ let materializeSpec = workspaceBase.resolved;
3352
+ if (pendingItem?.revisionId && !isAdded && !isPrivate)
3353
+ {
3354
+ if (!baseIdentityResolver)
3355
+ {
3356
+ throw new Error(`Plastic cannot resolve the owning repository for '${args.path}'.`);
3357
+ }
3358
+ materializeSpec = await baseIdentityResolver.resolve(pendingItem);
3359
+ }
3158
3360
 
3159
3361
  const tempDir = await createAsciiTempDirectory("plastic-core-");
3160
3362
  const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
@@ -3259,14 +3461,14 @@ export const diffFile = tool({
3259
3461
  const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
3260
3462
  const pendingItems = await getMachineReadablePendingItems(args.workdir);
3261
3463
  const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
3262
- const compared = await diffPendingWorkspaceFile(args, pendingItem);
3464
+ const compared = await diffPendingWorkspaceFile(args, pendingItem, createPendingBaseIdentityResolver(cwd, args.workdir));
3263
3465
  return formatTextDiff(
3264
3466
  "diffFile",
3265
3467
  args.format,
3266
3468
  compared.comparisonKind,
3267
3469
  compared.result,
3268
3470
  args.workdir,
3269
- { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
3471
+ { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId, baseRepositoryResolved: Boolean(pendingItem?.baseRepository) },
3270
3472
  args.maxChars,
3271
3473
  );
3272
3474
  },
@@ -3445,6 +3647,7 @@ export const workspaceDiff = tool({
3445
3647
  const skippedByLimit = Math.max(0, candidates.length - maxFiles);
3446
3648
  candidates = candidates.slice(0, maxFiles);
3447
3649
 
3650
+ const baseIdentityResolver = createPendingBaseIdentityResolver(cwd, args.workdir);
3448
3651
  const outcomes: Array<Record<string, unknown>> = [];
3449
3652
  const textSections: string[] = [];
3450
3653
  let totalOutputChars = 0;
@@ -3468,13 +3671,13 @@ export const workspaceDiff = tool({
3468
3671
  }
3469
3672
  try
3470
3673
  {
3471
- const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
3674
+ const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item, baseIdentityResolver);
3472
3675
  const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3473
3676
  const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3474
3677
  const bounded = boundWorkspaceDiffResult(perFile, remaining);
3475
3678
  const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
3476
3679
  const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
3477
- outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
3680
+ outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, baseRepositoryResolved: Boolean(item.baseRepository), ...(diffOutput ? { diff: diffOutput } : {}) });
3478
3681
  const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
3479
3682
  const section = `### ${displayPath} (${item.kind})\n${body}`;
3480
3683
  textSections.push(section);
package/SECURITY.md DELETED
@@ -1,31 +0,0 @@
1
- # Security Policy
2
-
3
- ## Supported versions
4
-
5
- Security fixes are provided for the latest tagged release. Upgrade to the newest release before reporting an issue that may already be fixed.
6
-
7
- ## Reporting a vulnerability
8
-
9
- Do not open a public issue for a suspected vulnerability, credential exposure, or unsafe command-execution path.
10
-
11
- Use GitHub's private vulnerability reporting for this repository:
12
-
13
- <https://github.com/aefreedman/pi-plastic/security/advisories/new>
14
-
15
- Include:
16
-
17
- - the affected package version or commit;
18
- - the Pi, Node.js, Git, and Plastic CLI versions;
19
- - the operating system;
20
- - reproduction steps and expected versus observed behavior;
21
- - the affected tool or extension;
22
- - impact and any known mitigations;
23
- - whether credentials, repository content, or remote Plastic objects may have been exposed or changed.
24
-
25
- Avoid including real credentials or sensitive repository content. Use a disposable Plastic workspace when a reproduction requires mutation.
26
-
27
- You should receive an acknowledgement within seven days. Release timing depends on severity, reproducibility, and coordination needs.
28
-
29
- ## Scope
30
-
31
- Security-sensitive areas include command construction, executable-path overrides, workspace/path targeting, destructive Plastic operations, provider payload capture, dynamic tool ownership, and package-install behavior. General support questions and ordinary bugs belong in the public issue tracker.