@aefree/pi-plastic 0.5.6 → 0.5.8

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,19 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.5.8] - 2026-09-15
11
+
12
+ ### Fixed
13
+
14
+ - Resolve pending-diff workspace containment and Xlink ownership by filesystem identity when a workspace is accessed through a real filesystem alias, while retaining the caller's lexical path for Plastic commands and displayed paths. Missing deleted or moved ownership ancestors still fail closed.
15
+ - Retry only npm registry E404 availability checks for a bounded period after trusted publishing; a mismatched, malformed, or unauthorized release identity still fails immediately.
16
+
17
+ ## [0.5.7] - 2026-09-14
18
+
19
+ ### Fixed
20
+
21
+ - 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.
22
+
10
23
  ## [0.5.6] - 2026-08-27
11
24
 
12
25
  ### Fixed
package/README.md CHANGED
@@ -73,8 +73,9 @@ 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
+ - Pending-diff ownership containment uses filesystem identity, so a workspace reached through a symlink, junction, or platform filesystem alias still resolves its workspace and nested Xlink bases safely. Plastic commands and returned labels retain the caller's original lexical path; missing deleted or moved ownership ancestors remain unavailable rather than falling back to a parent repository.
78
79
  - 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
80
  - `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>`.
80
81
  - Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
@@ -108,13 +109,13 @@ pi install npm:@aefree/pi-plastic
108
109
  Install a pinned GitHub release over HTTPS:
109
110
 
110
111
  ```bash
111
- pi install git:github.com/aefreedman/pi-plastic@v0.5.4
112
+ pi install git:github.com/aefreedman/pi-plastic@v0.5.7
112
113
  ```
113
114
 
114
115
  Equivalent SSH install:
115
116
 
116
117
  ```bash
117
- pi install git:git@github.com/aefreedman/pi-plastic@v0.5.4
118
+ pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
118
119
  ```
119
120
 
120
121
  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.8",
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 = {
@@ -405,6 +406,37 @@ const findCanonicalExistingAncestor = (absolutePath: string): string | null =>
405
406
  }
406
407
  };
407
408
 
409
+ const toFilesystemIdentityPath = async (absolutePath: string): Promise<string> =>
410
+ {
411
+ let candidate = absolutePath;
412
+ const missingSegments: string[] = [];
413
+ while (true)
414
+ {
415
+ try
416
+ {
417
+ const canonicalAncestor = (await fs.realpath(candidate)).replace(/\\/g, "/");
418
+ return missingSegments.reduce((identityPath, segment) => join(identityPath, segment), canonicalAncestor).replace(/\\/g, "/");
419
+ }
420
+ catch (error)
421
+ {
422
+ const code = (error as NodeJS.ErrnoException).code;
423
+ if (code !== "ENOENT" && code !== "ENOTDIR")
424
+ {
425
+ throw error;
426
+ }
427
+
428
+ const parent = dirname(candidate);
429
+ if (parent === candidate)
430
+ {
431
+ throw error;
432
+ }
433
+
434
+ missingSegments.unshift(basename(candidate));
435
+ candidate = parent;
436
+ }
437
+ }
438
+ };
439
+
408
440
  const toggleFirstAsciiLetterCase = (value: string): string | null =>
409
441
  {
410
442
  const index = value.search(/[A-Za-z]/);
@@ -1743,6 +1775,162 @@ const materializeRevision = async (revision: string, destination: string, workdi
1743
1775
  }
1744
1776
  };
1745
1777
 
1778
+ type PendingBaseIdentityResolver = {
1779
+ resolve: (item: PendingItem) => Promise<string>;
1780
+ };
1781
+
1782
+ type RepositoryIdentity = {
1783
+ repository: string;
1784
+ server: string;
1785
+ };
1786
+
1787
+ const parseRepositoryIdentity = (selector: string | undefined): RepositoryIdentity | null =>
1788
+ {
1789
+ if (!selector || /[\u0000-\u001f\u007f]/.test(selector))
1790
+ {
1791
+ return null;
1792
+ }
1793
+ // Repository names cannot contain @; cloud server names can (for example,
1794
+ // an organization-qualified cloud endpoint), so split only at the first @.
1795
+ const separator = selector.indexOf("@");
1796
+ if (separator <= 0 || separator === selector.length - 1)
1797
+ {
1798
+ return null;
1799
+ }
1800
+ return { repository: selector.slice(0, separator), server: selector.slice(separator + 1) };
1801
+ };
1802
+
1803
+ const parseRepositorySelector = (output: string): RepositoryIdentity | null =>
1804
+ {
1805
+ const match = output.match(/^\s*repository\s+(?:"([^"]+)"|(\S+))\s*$/mi);
1806
+ return parseRepositoryIdentity(match?.[1] ?? match?.[2]);
1807
+ };
1808
+
1809
+ const parseXlinkRepositorySelector = (output: string): RepositoryIdentity | null =>
1810
+ {
1811
+ // `cm xlink --show` reports wxlink:<serverpath>@<loaded-revision>@<repository>@<server>.
1812
+ const match = output.match(/^.+?\s+-->\s+wxlink:.+?@(?:\d+|cs:\d+)@(.+)\s*$/mi);
1813
+ return parseRepositoryIdentity(match?.[1]?.trim());
1814
+ };
1815
+
1816
+ const isNotXlinkError = (error: unknown): boolean => /\bis not an xlink\.?\s*$/i.test(error instanceof Error ? error.message.trim() : String(error).trim());
1817
+
1818
+ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): PendingBaseIdentityResolver =>
1819
+ {
1820
+ let workspaceRoot: Promise<string> | undefined;
1821
+ const getWorkspaceRoot = (): Promise<string> =>
1822
+ {
1823
+ workspaceRoot ??= discoverPlasticWorkspace(cwd).then((outcome) =>
1824
+ {
1825
+ if (outcome.kind !== "found")
1826
+ {
1827
+ throw new Error(`Plastic cannot determine the workspace root for pending base resolution: ${outcome.kind === "unavailable" ? outcome.reason : "no workspace marker found"}.`);
1828
+ }
1829
+ return toNormalizedAbsolutePath(outcome.value.root, cwd);
1830
+ });
1831
+ return workspaceRoot;
1832
+ };
1833
+ const xlinkLookups = new Map<string, Promise<RepositoryIdentity | null>>();
1834
+ let workspaceRepository: Promise<RepositoryIdentity> | undefined;
1835
+ const getWorkspaceRepository = (): Promise<RepositoryIdentity> =>
1836
+ {
1837
+ workspaceRepository ??= runCmRaw(["showselector"], workdir).then((output) =>
1838
+ {
1839
+ const repository = parseRepositorySelector(output);
1840
+ if (!repository)
1841
+ {
1842
+ throw new Error("Plastic returned no repository/server identity for this workspace.");
1843
+ }
1844
+ return repository;
1845
+ });
1846
+ return workspaceRepository;
1847
+ };
1848
+ const getXlinkRepository = async (candidate: string): Promise<RepositoryIdentity | null> =>
1849
+ {
1850
+ // Use the physical identity for cache keys, but keep candidate lexical for cm.
1851
+ const key = toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate));
1852
+ let lookup = xlinkLookups.get(key);
1853
+ if (!lookup)
1854
+ {
1855
+ lookup = runCmRaw(["xlink", "--show", candidate], workdir)
1856
+ .then((output) =>
1857
+ {
1858
+ const repository = parseXlinkRepositorySelector(output);
1859
+ if (!repository)
1860
+ {
1861
+ throw new Error(`Plastic returned malformed Xlink ownership for '${candidate}'.`);
1862
+ }
1863
+ return repository;
1864
+ })
1865
+ .catch((error) =>
1866
+ {
1867
+ if (isNotXlinkError(error))
1868
+ {
1869
+ return null;
1870
+ }
1871
+ throw error;
1872
+ });
1873
+ xlinkLookups.set(key, lookup);
1874
+ }
1875
+ return lookup;
1876
+ };
1877
+
1878
+ return {
1879
+ async resolve(item)
1880
+ {
1881
+ if (!item.revisionId)
1882
+ {
1883
+ throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
1884
+ }
1885
+ const root = await getWorkspaceRoot();
1886
+ const rootIdentity = await toFilesystemIdentityPath(root);
1887
+ const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
1888
+ ? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
1889
+ : item.normalizedPath;
1890
+ if (!isWithinPathScope(
1891
+ toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(ownershipPath)),
1892
+ toPathComparisonKeyFromAbsolutePath(rootIdentity),
1893
+ ))
1894
+ {
1895
+ throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
1896
+ }
1897
+ let candidate = dirname(ownershipPath);
1898
+ while (isWithinPathScope(
1899
+ toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate)),
1900
+ toPathComparisonKeyFromAbsolutePath(rootIdentity),
1901
+ ))
1902
+ {
1903
+ // A missing ancestor can be a removed Xlink mount. Status does not
1904
+ // retain ownership, so parent-repository fallback would be unsafe.
1905
+ if ((item.kind === "deleted" || item.kind === "moved") && !(await fs.stat(candidate).catch(() => null)))
1906
+ {
1907
+ throw new Error(`Plastic cannot resolve the owning repository for '${item.workspacePath}' because an ownership ancestor is missing.`);
1908
+ }
1909
+ const repository = await getXlinkRepository(candidate);
1910
+ if (repository)
1911
+ {
1912
+ item.baseRepository = `${repository.repository}@${repository.server}`;
1913
+ return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
1914
+ }
1915
+ if (toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate))
1916
+ === toPathComparisonKeyFromAbsolutePath(rootIdentity))
1917
+ {
1918
+ break;
1919
+ }
1920
+ const parent = dirname(candidate);
1921
+ if (parent === candidate)
1922
+ {
1923
+ break;
1924
+ }
1925
+ candidate = parent;
1926
+ }
1927
+ const repository = await getWorkspaceRepository();
1928
+ item.baseRepository = `${repository.repository}@${repository.server}`;
1929
+ return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
1930
+ },
1931
+ };
1932
+ };
1933
+
1746
1934
  const normalizeDiffResponseMaxChars = (value: unknown): number =>
1747
1935
  {
1748
1936
  if (typeof value !== "number" || !Number.isFinite(value))
@@ -3172,6 +3360,7 @@ type WorkspacePendingDiff = {
3172
3360
  const diffPendingWorkspaceFile = async (
3173
3361
  args: { path: string; workdir?: string },
3174
3362
  pendingItem?: PendingItem,
3363
+ baseIdentityResolver?: PendingBaseIdentityResolver,
3175
3364
  ): Promise<WorkspacePendingDiff> =>
3176
3365
  {
3177
3366
  const cwd = args.workdir ?? process.cwd();
@@ -3195,11 +3384,19 @@ const diffPendingWorkspaceFile = async (
3195
3384
  ? `${revisionPath} (empty before private/new file)`
3196
3385
  : workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
3197
3386
  const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
3198
- const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
3199
3387
  if (isDeleted && !pendingItem?.revisionId)
3200
3388
  {
3201
3389
  throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
3202
3390
  }
3391
+ let materializeSpec = workspaceBase.resolved;
3392
+ if (pendingItem?.revisionId && !isAdded && !isPrivate)
3393
+ {
3394
+ if (!baseIdentityResolver)
3395
+ {
3396
+ throw new Error(`Plastic cannot resolve the owning repository for '${args.path}'.`);
3397
+ }
3398
+ materializeSpec = await baseIdentityResolver.resolve(pendingItem);
3399
+ }
3203
3400
 
3204
3401
  const tempDir = await createAsciiTempDirectory("plastic-core-");
3205
3402
  const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
@@ -3304,14 +3501,14 @@ export const diffFile = tool({
3304
3501
  const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
3305
3502
  const pendingItems = await getMachineReadablePendingItems(args.workdir);
3306
3503
  const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
3307
- const compared = await diffPendingWorkspaceFile(args, pendingItem);
3504
+ const compared = await diffPendingWorkspaceFile(args, pendingItem, createPendingBaseIdentityResolver(cwd, args.workdir));
3308
3505
  return formatTextDiff(
3309
3506
  "diffFile",
3310
3507
  args.format,
3311
3508
  compared.comparisonKind,
3312
3509
  compared.result,
3313
3510
  args.workdir,
3314
- { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
3511
+ { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId, baseRepositoryResolved: Boolean(pendingItem?.baseRepository) },
3315
3512
  args.maxChars,
3316
3513
  );
3317
3514
  },
@@ -3490,6 +3687,7 @@ export const workspaceDiff = tool({
3490
3687
  const skippedByLimit = Math.max(0, candidates.length - maxFiles);
3491
3688
  candidates = candidates.slice(0, maxFiles);
3492
3689
 
3690
+ const baseIdentityResolver = createPendingBaseIdentityResolver(cwd, args.workdir);
3493
3691
  const outcomes: Array<Record<string, unknown>> = [];
3494
3692
  const textSections: string[] = [];
3495
3693
  let totalOutputChars = 0;
@@ -3513,13 +3711,13 @@ export const workspaceDiff = tool({
3513
3711
  }
3514
3712
  try
3515
3713
  {
3516
- const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
3714
+ const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item, baseIdentityResolver);
3517
3715
  const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3518
3716
  const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3519
3717
  const bounded = boundWorkspaceDiffResult(perFile, remaining);
3520
3718
  const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
3521
3719
  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 } : {}) });
3720
+ 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
3721
  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
3722
  const section = `### ${displayPath} (${item.kind})\n${body}`;
3525
3723
  textSections.push(section);