@aefree/pi-plastic 0.5.6 → 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,12 @@ 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
+
10
16
  ## [0.5.6] - 2026-08-27
11
17
 
12
18
  ### Fixed
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -313,6 +313,7 @@ type PendingItem = {
313
313
  kind: PendingItemKind;
314
314
  revisionId?: string;
315
315
  sourceWorkspacePath?: string;
316
+ baseRepository?: string;
316
317
  };
317
318
 
318
319
  type PendingItemSummary = {
@@ -1743,6 +1744,153 @@ const materializeRevision = async (revision: string, destination: string, workdi
1743
1744
  }
1744
1745
  };
1745
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
+
1746
1894
  const normalizeDiffResponseMaxChars = (value: unknown): number =>
1747
1895
  {
1748
1896
  if (typeof value !== "number" || !Number.isFinite(value))
@@ -3172,6 +3320,7 @@ type WorkspacePendingDiff = {
3172
3320
  const diffPendingWorkspaceFile = async (
3173
3321
  args: { path: string; workdir?: string },
3174
3322
  pendingItem?: PendingItem,
3323
+ baseIdentityResolver?: PendingBaseIdentityResolver,
3175
3324
  ): Promise<WorkspacePendingDiff> =>
3176
3325
  {
3177
3326
  const cwd = args.workdir ?? process.cwd();
@@ -3195,11 +3344,19 @@ const diffPendingWorkspaceFile = async (
3195
3344
  ? `${revisionPath} (empty before private/new file)`
3196
3345
  : workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
3197
3346
  const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
3198
- const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
3199
3347
  if (isDeleted && !pendingItem?.revisionId)
3200
3348
  {
3201
3349
  throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
3202
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
+ }
3203
3360
 
3204
3361
  const tempDir = await createAsciiTempDirectory("plastic-core-");
3205
3362
  const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
@@ -3304,14 +3461,14 @@ export const diffFile = tool({
3304
3461
  const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
3305
3462
  const pendingItems = await getMachineReadablePendingItems(args.workdir);
3306
3463
  const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
3307
- const compared = await diffPendingWorkspaceFile(args, pendingItem);
3464
+ const compared = await diffPendingWorkspaceFile(args, pendingItem, createPendingBaseIdentityResolver(cwd, args.workdir));
3308
3465
  return formatTextDiff(
3309
3466
  "diffFile",
3310
3467
  args.format,
3311
3468
  compared.comparisonKind,
3312
3469
  compared.result,
3313
3470
  args.workdir,
3314
- { 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) },
3315
3472
  args.maxChars,
3316
3473
  );
3317
3474
  },
@@ -3490,6 +3647,7 @@ export const workspaceDiff = tool({
3490
3647
  const skippedByLimit = Math.max(0, candidates.length - maxFiles);
3491
3648
  candidates = candidates.slice(0, maxFiles);
3492
3649
 
3650
+ const baseIdentityResolver = createPendingBaseIdentityResolver(cwd, args.workdir);
3493
3651
  const outcomes: Array<Record<string, unknown>> = [];
3494
3652
  const textSections: string[] = [];
3495
3653
  let totalOutputChars = 0;
@@ -3513,13 +3671,13 @@ export const workspaceDiff = tool({
3513
3671
  }
3514
3672
  try
3515
3673
  {
3516
- const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
3674
+ const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item, baseIdentityResolver);
3517
3675
  const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3518
3676
  const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3519
3677
  const bounded = boundWorkspaceDiffResult(perFile, remaining);
3520
3678
  const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
3521
3679
  const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
3522
- 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 } : {}) });
3523
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."));
3524
3682
  const section = `### ${displayPath} (${item.kind})\n${body}`;
3525
3683
  textSections.push(section);