@aefree/pi-plastic 0.7.6 → 0.8.0

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +48 -174
  3. package/docs/2026-10-06-development.md +101 -0
  4. package/docs/2026-10-06-diffs-and-patches.md +136 -0
  5. package/docs/2026-10-06-discovery.md +135 -0
  6. package/docs/2026-10-06-integrations.md +49 -0
  7. package/docs/2026-10-06-merging.md +153 -0
  8. package/docs/2026-10-06-safety-and-receipts.md +95 -0
  9. package/docs/2026-10-06-setup.md +101 -0
  10. package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
  11. package/docs/2026-10-06-status.md +116 -0
  12. package/docs/2026-10-06-tools-and-loading.md +64 -0
  13. package/docs/2026-10-06-workspace-operations.md +142 -0
  14. package/docs/README.md +65 -0
  15. package/extensions/bash-cm-diff-guard.ts +1 -1
  16. package/index.ts +4 -687
  17. package/package.json +10 -4
  18. package/skills/using-plastic/SKILL.md +11 -5
  19. package/skills/using-plastic/references/branch-management.md +5 -1
  20. package/skills/using-plastic/references/changeset-operations.md +9 -8
  21. package/skills/using-plastic/references/code-review-creation.md +25 -18
  22. package/skills/using-plastic/references/integration.md +9 -5
  23. package/skills/using-plastic/references/quick-reference.md +4 -4
  24. package/skills/using-plastic/references/reviewing-changes.md +10 -10
  25. package/skills/using-plastic/references/shelving.md +26 -11
  26. package/skills/using-plastic/references/troubleshooting.md +4 -4
  27. package/src/diff/local-snapshot.ts +74 -0
  28. package/src/diff/text.ts +187 -0
  29. package/src/domain/add-contract.ts +24 -0
  30. package/src/domain/branch-create-contract.ts +38 -0
  31. package/src/domain/branch-delete-contract.ts +15 -0
  32. package/src/domain/branches.ts +263 -0
  33. package/src/domain/checkin-contract.ts +70 -0
  34. package/src/domain/closeout-contract.ts +30 -0
  35. package/src/domain/copied-merge.ts +42 -0
  36. package/src/domain/diff-base-xml.ts +65 -0
  37. package/src/domain/diff-pending-xml.ts +34 -0
  38. package/src/domain/merge-output.ts +327 -0
  39. package/src/domain/paths.ts +253 -0
  40. package/src/domain/pending.ts +499 -0
  41. package/src/domain/removal-contract.ts +25 -0
  42. package/src/domain/revisions.ts +301 -0
  43. package/src/domain/server-merge-contract.ts +45 -0
  44. package/src/domain/server-merge-resolutions.ts +61 -0
  45. package/src/domain/status-xml.ts +122 -0
  46. package/src/domain/switch-contract.ts +60 -0
  47. package/src/domain/switch-policy.ts +95 -0
  48. package/src/domain/undo-contract.ts +24 -0
  49. package/src/domain/update-contract.ts +16 -0
  50. package/src/domain/workspace-merge-contract.ts +41 -0
  51. package/src/execution/add-command.ts +118 -0
  52. package/src/execution/branch-create-command.ts +118 -0
  53. package/src/execution/branch-list-command.ts +75 -0
  54. package/src/execution/checkin-command.ts +116 -0
  55. package/src/execution/cli-version.ts +31 -0
  56. package/src/execution/cm.ts +59 -0
  57. package/src/execution/code-review-find-command.ts +75 -0
  58. package/src/execution/context.ts +20 -0
  59. package/src/execution/diff-revisions-command.ts +74 -0
  60. package/src/execution/process.ts +260 -0
  61. package/src/execution/removal-command.ts +118 -0
  62. package/src/execution/server-merge-command.ts +92 -0
  63. package/src/execution/shelveset-list-command.ts +75 -0
  64. package/src/execution/status-command.ts +29 -0
  65. package/src/execution/status-xml-command.ts +72 -0
  66. package/src/execution/status-xml-limits.ts +1 -0
  67. package/src/execution/switch-command.ts +118 -0
  68. package/src/execution/test-seams.ts +10 -0
  69. package/src/execution/undo-command.ts +118 -0
  70. package/src/execution/update-command.ts +118 -0
  71. package/src/execution/workspace-list-command.ts +75 -0
  72. package/src/execution/workspace-merge-command.ts +118 -0
  73. package/src/operations/add-receipt.ts +37 -0
  74. package/src/operations/arguments.ts +3 -0
  75. package/src/operations/branch-create.ts +74 -0
  76. package/src/operations/branch-delete-receipt.ts +25 -0
  77. package/src/operations/branch-list.ts +94 -0
  78. package/src/operations/branch-reads.ts +94 -0
  79. package/src/operations/branches.ts +111 -0
  80. package/src/operations/checkin-receipt.ts +140 -0
  81. package/src/operations/checkin.ts +102 -0
  82. package/src/operations/closeout-receipt.ts +74 -0
  83. package/src/operations/closeout.ts +19 -0
  84. package/src/operations/code-review-find.ts +97 -0
  85. package/src/operations/consolidated-diff.ts +144 -0
  86. package/src/operations/diff-revisions.ts +157 -0
  87. package/src/operations/diff.ts +53 -0
  88. package/src/operations/merge.ts +26 -0
  89. package/src/operations/object-delete-receipt.ts +21 -0
  90. package/src/operations/object-write-receipt.ts +105 -0
  91. package/src/operations/patch-receipt.ts +96 -0
  92. package/src/operations/patch-support.ts +375 -0
  93. package/src/operations/patch.ts +25 -0
  94. package/src/operations/removal-receipt.ts +42 -0
  95. package/src/operations/reviews.ts +90 -0
  96. package/src/operations/server-merge.ts +130 -0
  97. package/src/operations/shelveset-list.ts +90 -0
  98. package/src/operations/shelvesets.ts +90 -0
  99. package/src/operations/status.ts +129 -0
  100. package/src/operations/switch-receipt.ts +85 -0
  101. package/src/operations/switch.ts +25 -0
  102. package/src/operations/undo-receipt.ts +37 -0
  103. package/src/operations/update-receipt.ts +35 -0
  104. package/src/operations/workspace-list.ts +92 -0
  105. package/src/operations/workspace-merge-receipt.ts +50 -0
  106. package/src/operations/workspace.ts +120 -0
  107. package/src/pi/add-output.ts +52 -0
  108. package/src/pi/arguments.ts +298 -0
  109. package/src/pi/branch-create-output.ts +83 -0
  110. package/src/pi/branch-delete-output.ts +46 -0
  111. package/src/pi/branch-list-output.ts +59 -0
  112. package/src/pi/branch-output.ts +63 -0
  113. package/src/pi/capability-diagnostics.ts +23 -0
  114. package/src/pi/checkin-output.ts +68 -0
  115. package/src/pi/closeout-output.ts +87 -0
  116. package/src/pi/code-review-find-output.ts +50 -0
  117. package/src/pi/diff-output.ts +66 -0
  118. package/src/pi/diff-revisions-output.ts +55 -0
  119. package/src/pi/object-delete-output.ts +15 -0
  120. package/src/pi/object-write-output.ts +54 -0
  121. package/src/pi/patch-output.ts +69 -0
  122. package/src/pi/register.ts +188 -0
  123. package/src/pi/removal-output.ts +59 -0
  124. package/src/pi/schemas.ts +146 -0
  125. package/src/pi/server-merge-output.ts +83 -0
  126. package/src/pi/shelveset-list-output.ts +50 -0
  127. package/src/pi/status-output.ts +149 -0
  128. package/src/pi/switch-output.ts +80 -0
  129. package/src/pi/tool-registry.ts +72 -0
  130. package/src/pi/undo-output.ts +52 -0
  131. package/src/pi/update-output.ts +48 -0
  132. package/src/pi/workspace-list-output.ts +53 -0
  133. package/src/pi/workspace-merge-output.ts +46 -0
  134. package/src/plastic-core.ts +14 -5775
  135. package/src/plastic-renderers.ts +102 -22
  136. package/src/plastic-tool-loading.ts +7 -12
  137. package/src/presentation/add-results.ts +5 -0
  138. package/src/presentation/branch-create.ts +7 -0
  139. package/src/presentation/branch-delete-results.ts +7 -0
  140. package/src/presentation/checkin-results.ts +14 -0
  141. package/src/presentation/closeout-results.ts +2 -0
  142. package/src/presentation/diff-results.ts +237 -0
  143. package/src/presentation/removal-results.ts +7 -0
  144. package/src/presentation/results.ts +84 -0
  145. package/src/presentation/switch-results.ts +7 -0
  146. package/src/presentation/undo-results.ts +5 -0
  147. package/src/presentation/update-results.ts +5 -0
  148. package/src/presentation/workspace-merge-results.ts +6 -0
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type MergeAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export type MergeCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
7
+ export const emptyMergeAttempt = (): MergeAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
8
+ export type ServerCommandObservation = { attempt: MergeAttempt; capture: MergeCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
10
+ export async function captureServerMergeCommand(args: string[], timeoutMs: number): Promise<ServerCommandObservation> {
11
+ const attempt = emptyMergeAttempt();
12
+ const capture: MergeCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
13
+ const result: ServerCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
14
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
15
+ if (signal?.aborted) { attempt.aborted = true; return result; }
16
+ let child: ReturnType<typeof spawn>;
17
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
18
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
19
+ attempt.state = "unknown";
20
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
21
+ let settled = false, failed = false, started = false, killTimer: NodeJS.Timeout | undefined;
22
+ const terminate = () => {
23
+ if (settled) return;
24
+ try { child.kill("SIGTERM"); } catch { /* completion race */ }
25
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* completion race */ } } }, deps.abortKillDelayMs ?? 5000);
26
+ };
27
+ const disposers: Array<() => void> = [];
28
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
29
+ let length = 0; const parts: Buffer[] = [];
30
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
31
+ if (stream.readableEncoding) { failed = true; terminate(); }
32
+ return new Promise<Buffer>(resolve => {
33
+ let done = false;
34
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(parts, length)); } };
35
+ const data = (chunk: unknown) => {
36
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
37
+ capture[name === "stdout" ? "stdoutBytes" : "stderrBytes"] += chunk.byteLength;
38
+ const available = cap - length;
39
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
40
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
41
+ };
42
+ const error = () => { failed = true; terminate(); finish(); };
43
+ const close = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
44
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", close);
45
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
46
+ });
47
+ };
48
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
49
+ const onSpawn = () => { started = true; attempt.state = "started"; };
50
+ const exit = new Promise<void>(resolve => {
51
+ onClose = (code, terminalSignal) => {
52
+ if (settled) return; settled = true; attempt.terminal = "observed";
53
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 ? code : null;
54
+ if (!failed && attempt.exitCode !== null && !terminalSignal) attempt.state = "started";
55
+ if (attempt.exitCode === null || terminalSignal) failed = true;
56
+ resolve();
57
+ };
58
+ onError = error => {
59
+ failed = true;
60
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
61
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
62
+ } else { terminate(); }
63
+ };
64
+ child.once("spawn", onSpawn); child.once("close", onClose); child.once("error", onError);
65
+ });
66
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
67
+ const onAbort = () => { attempt.aborted = true; terminate(); };
68
+ signal?.addEventListener("abort", onAbort, { once: true });
69
+ const timer = schedule(() => { attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
70
+ if (signal?.aborted) onAbort();
71
+ try {
72
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
73
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
74
+ result.failed = failed;
75
+ try {
76
+ result.stdout = new TextDecoder("utf-8", { fatal: true }).decode(stdout);
77
+ result.stderr = new TextDecoder("utf-8", { fatal: true }).decode(stderr);
78
+ capture.validUtf8 = true;
79
+ // Updated CM help includes the resolution-file contract (~19 KiB).
80
+ // Keep a bounded 32 KiB decoded help gate; merge evidence uses source bytes.
81
+ if (timeoutMs === 3000 && (result.stdout.length > 32768 || result.stderr.length > 16384)) { result.failed = true; }
82
+ } catch { result.stdout = null; result.stderr = null; }
83
+ if (signal?.aborted) attempt.aborted = true;
84
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
85
+ return result;
86
+ } finally {
87
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer);
88
+ signal?.removeEventListener("abort", onAbort);
89
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
90
+ for (const dispose of disposers) dispose();
91
+ }
92
+ }
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ export const SHELVESET_LIST_CAPTURE_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, timeoutMs: 30000, records: 20000 } as const;
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
5
+ import { getCmExecutable } from "./process";
6
+ export class ShelvesetListCommandError extends Error {
7
+ constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") { super(`Shelveset list read failed (${code}).`); }
8
+ }
9
+
10
+ // Shelveset-list-only strict byte transport. Native/default and other read routes are unchanged.
11
+ export async function runShelvesetListIdsCommand(args: string[], workdir?: string): Promise<Buffer> {
12
+ const signal = getActiveAbortSignal();
13
+ if (signal?.aborted) throw new ShelvesetListCommandError("aborted");
14
+ const dependencies = commandExecutionStorage.getStore() ?? {};
15
+ let child;
16
+ try { child = (dependencies.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
17
+ catch { throw new ShelvesetListCommandError("command_failed"); }
18
+ const schedule = dependencies.setTimeout ?? setTimeout;
19
+ const cancel = dependencies.clearTimeout ?? clearTimeout;
20
+ let aborted = false, incomplete = false, failed = false, settled = false;
21
+ let killTimer: NodeJS.Timeout | undefined;
22
+ const terminate = () => {
23
+ if (settled) return;
24
+ try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
25
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
26
+ };
27
+ const collectors: Array<{ dispose(): void }> = [];
28
+ const collect = (stream: Readable | null, cap: number) => {
29
+ let length = 0;
30
+ const chunks: Buffer[] = [];
31
+ if (!stream) { failed = true; return Promise.resolve(Buffer.alloc(0)); }
32
+ if (stream.readableEncoding) { failed = true; terminate(); }
33
+ return new Promise<Buffer>(resolve => {
34
+ let done = false;
35
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
36
+ const data = (chunk: unknown) => {
37
+ // Already-decoded streams cannot attest exact source bytes.
38
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
39
+ const available = cap - length;
40
+ if (chunk.byteLength > available) { incomplete = true; terminate(); }
41
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
42
+ // Keep draining beyond the cap until process/streams settle.
43
+ };
44
+ const error = () => { failed = true; terminate(); finish(); };
45
+ const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
46
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
47
+ collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
48
+ });
49
+ };
50
+ let onClose: (code: number | null) => void;
51
+ let onError: () => void;
52
+ const exit = new Promise<number>(resolve => {
53
+ onClose = code => { settled = true; resolve(code ?? 1); };
54
+ onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
55
+ child.once("close", onClose); child.once("error", onError);
56
+ });
57
+ const stdout = collect(child.stdout, SHELVESET_LIST_CAPTURE_LIMITS.stdoutBytes);
58
+ const stderr = collect(child.stderr, SHELVESET_LIST_CAPTURE_LIMITS.stderrBytes);
59
+ const onAbort = () => { aborted = true; terminate(); };
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? SHELVESET_LIST_CAPTURE_LIMITS.timeoutMs, SHELVESET_LIST_CAPTURE_LIMITS.timeoutMs));
62
+ if (signal?.aborted) onAbort();
63
+ try {
64
+ const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
65
+ if (aborted || signal?.aborted) throw new ShelvesetListCommandError("aborted");
66
+ if (incomplete) throw new ShelvesetListCommandError("capture_incomplete");
67
+ if (failed || code !== 0 || errors.length) throw new ShelvesetListCommandError("command_failed");
68
+ return bytes;
69
+ } finally {
70
+ cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
71
+ signal?.removeEventListener("abort", onAbort);
72
+ child.removeListener("close", onClose!); child.removeListener("error", onError!);
73
+ for (const collector of collectors) collector.dispose();
74
+ }
75
+ }
@@ -0,0 +1,29 @@
1
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
2
+ import { getCmExecutable, spawnAndCollect } from "./process";
3
+
4
+ export class StatusCommandError extends Error {
5
+ constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") {
6
+ super(code === "aborted" ? "Status read was cancelled." : code === "capture_incomplete" ? "Status capture was incomplete." : "Status command failed; CLI support or availability is not established.");
7
+ }
8
+ }
9
+
10
+ // Status-only transport evidence. No raw command/error text crosses this seam on failure.
11
+ export const runStatusCommand = async (args: string[], workdir?: string, raw = false) => {
12
+ const signal = getActiveAbortSignal();
13
+ if (signal?.aborted) throw new StatusCommandError("aborted");
14
+ let result;
15
+ try {
16
+ result = await spawnAndCollect(getCmExecutable(), args, workdir ?? process.cwd(), undefined, signal, commandExecutionStorage.getStore());
17
+ } catch {
18
+ throw new StatusCommandError(signal?.aborted ? "aborted" : "command_failed");
19
+ }
20
+ if (result.aborted) throw new StatusCommandError("aborted");
21
+ if (result.timedOut || result.stdoutTruncated || result.stderrTruncated) throw new StatusCommandError("capture_incomplete");
22
+ if (result.exitCode !== 0) throw new StatusCommandError("command_failed");
23
+ const combined = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
24
+ return {
25
+ output: raw ? result.stdout : combined || "(no output)",
26
+ // Successful stderr is not machine status evidence. Do not expose its content.
27
+ capture: raw && result.stderr.length > 0 ? "unknown" as const : "complete" as const,
28
+ };
29
+ };
@@ -0,0 +1,72 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { STATUS_XML_LIMITS } from "./status-xml-limits";
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
5
+ import { getCmExecutable } from "./process";
6
+ import { StatusCommandError } from "./status-command";
7
+
8
+ // Status-only raw byte capture. Generic command adapters retain their existing semantics.
9
+ export async function runStatusXmlCommand(workdir?: string): Promise<Buffer> {
10
+ const signal = getActiveAbortSignal();
11
+ if (signal?.aborted) throw new StatusCommandError("aborted");
12
+ const dependencies = commandExecutionStorage.getStore() ?? {};
13
+ let child;
14
+ try { child = (dependencies.spawn ?? spawn)(getCmExecutable(), ["status", "--xml", "--encoding=utf-8", "--fullpaths"], { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
15
+ catch { throw new StatusCommandError("command_failed"); }
16
+ const schedule = dependencies.setTimeout ?? setTimeout;
17
+ const cancel = dependencies.clearTimeout ?? clearTimeout;
18
+ let aborted = false, incomplete = false, failed = false, settled = false;
19
+ let killTimer: NodeJS.Timeout | undefined;
20
+ const terminate = () => {
21
+ if (settled) return;
22
+ try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
23
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
24
+ };
25
+ const collectors: Array<{ dispose(): void }> = [];
26
+ const collect = (stream: Readable | null, cap: number) => {
27
+ let length = 0;
28
+ const chunks: Buffer[] = [];
29
+ if (!stream) { failed = true; return Promise.resolve(Buffer.alloc(0)); }
30
+ return new Promise<Buffer>(resolve => {
31
+ let done = false;
32
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
33
+ const data = (chunk: unknown) => {
34
+ // Already-decoded streams cannot attest exact source bytes.
35
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
36
+ const available = cap - length;
37
+ if (chunk.byteLength > available) { incomplete = true; terminate(); }
38
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
39
+ // Keep draining beyond the cap until process/streams settle.
40
+ };
41
+ const error = () => { failed = true; terminate(); finish(); };
42
+ const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
43
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
44
+ collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
45
+ });
46
+ };
47
+ let onClose: (code: number | null) => void;
48
+ let onError: () => void;
49
+ const exit = new Promise<number>(resolve => {
50
+ onClose = code => { settled = true; resolve(code ?? 1); };
51
+ onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
52
+ child.once("close", onClose); child.once("error", onError);
53
+ });
54
+ const stdout = collect(child.stdout, STATUS_XML_LIMITS.stdoutBytes);
55
+ const stderr = collect(child.stderr, STATUS_XML_LIMITS.stderrBytes);
56
+ const onAbort = () => { aborted = true; terminate(); };
57
+ signal?.addEventListener("abort", onAbort, { once: true });
58
+ const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? STATUS_XML_LIMITS.timeoutMs, STATUS_XML_LIMITS.timeoutMs));
59
+ if (signal?.aborted) onAbort();
60
+ try {
61
+ const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
62
+ if (aborted || signal?.aborted) throw new StatusCommandError("aborted");
63
+ if (incomplete) throw new StatusCommandError("capture_incomplete");
64
+ if (failed || code !== 0 || errors.length) throw new StatusCommandError("command_failed");
65
+ return bytes;
66
+ } finally {
67
+ cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
68
+ signal?.removeEventListener("abort", onAbort);
69
+ child.removeListener("close", onClose!); child.removeListener("error", onError!);
70
+ for (const collector of collectors) collector.dispose();
71
+ }
72
+ }
@@ -0,0 +1 @@
1
+ export const STATUS_XML_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, depth: 16, records: 20000, path: 4096, code: 64, text: 8192, timeoutMs: 30000 } as const;
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type SwitchAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export const emptySwitchAttempt = (): SwitchAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
7
+ export type SwitchCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
8
+ export type SwitchCommandObservation = { attempt: SwitchAttempt; capture: SwitchCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureSwitchCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<SwitchCommandObservation> {
13
+ const attempt = emptySwitchAttempt();
14
+ const capture: SwitchCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
15
+ const result: SwitchCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
16
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
17
+ if (signal?.aborted) { attempt.aborted = true; return result; }
18
+ let child: ReturnType<typeof spawn>;
19
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
20
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
21
+ attempt.state = "unknown";
22
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
23
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
24
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
25
+ let settleExit!: () => void;
26
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
27
+ const retire = () => {
28
+ if (retired || finalized) return;
29
+ retired = true; failed = true;
30
+ // Retirement is not an observed terminal event or proof that the child stopped.
31
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
32
+ settleExit();
33
+ for (const finish of finishers) finish();
34
+ child.stdout?.destroy(); child.stderr?.destroy();
35
+ };
36
+ const terminate = () => {
37
+ if (terminationRequested || retired || finalized) return;
38
+ terminationRequested = true;
39
+ const grace = deps.abortKillDelayMs ?? 5000;
40
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
41
+ killTimer = schedule(() => {
42
+ if (finalized || retired) return;
43
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
44
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
45
+ retireTimer = schedule(retire, grace);
46
+ }, grace);
47
+ };
48
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
49
+ let length = 0; const parts: Buffer[] = [];
50
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
51
+ if (stream.readableEncoding) { failed = true; terminate(); }
52
+ return new Promise<Buffer>(resolve => {
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
55
+ finishers.push(finish);
56
+ const data = (chunk: unknown) => {
57
+ if (done || retired || finalized) return;
58
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
59
+ const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
60
+ capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
78
+ // A terminal event is not a substitute for the native start event.
79
+ if (!started) failed = true;
80
+ if (attempt.exitCode === null || terminalSignal) failed = true;
81
+ resolve();
82
+ };
83
+ onError = error => {
84
+ if (retired || finalized) return;
85
+ failed = true;
86
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
87
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
88
+ } else { terminate(); }
89
+ };
90
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
91
+ });
92
+ const out = collect(child.stdout, args.includes("--machinereadable") ? 1048576 : 65536, "stdout"), err = collect(child.stderr, args.includes("--machinereadable") ? 65536 : 16384, "stderr");
93
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
96
+ if (signal?.aborted) onAbort();
97
+ try {
98
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
99
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
100
+ result.failed = failed;
101
+ try {
102
+ // Retain a literal UTF-8 BOM rather than silently removing source bytes.
103
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
104
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
105
+ capture.validUtf8 = true;
106
+ } catch { result.stdout = null; result.stderr = null; }
107
+ if (signal?.aborted) attempt.aborted = true;
108
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
109
+ return result;
110
+ } finally {
111
+ finalized = true;
112
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
115
+ for (const dispose of disposers) dispose();
116
+ finishers.length = 0; disposers.length = 0;
117
+ }
118
+ }
@@ -0,0 +1,10 @@
1
+ import { ExecutableEnvironment, resolveExecutable, spawnAndCollect } from "./process";
2
+ import { runCm, runCmRaw } from "./cm";
3
+
4
+ export const __plasticProcessInternals = {
5
+ resolveCmExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_CM_EXECUTABLE", "cm"),
6
+ resolveDiffExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff"),
7
+ spawnAndCollect,
8
+ runCm,
9
+ runCmRaw,
10
+ };
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type UndoAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export const emptyUndoAttempt = (): UndoAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
7
+ export type UndoCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
8
+ export type UndoCommandObservation = { attempt: UndoAttempt; capture: UndoCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureUndoCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<UndoCommandObservation> {
13
+ const attempt = emptyUndoAttempt();
14
+ const capture: UndoCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
15
+ const result: UndoCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
16
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
17
+ if (signal?.aborted) { attempt.aborted = true; return result; }
18
+ let child: ReturnType<typeof spawn>;
19
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
20
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
21
+ attempt.state = "unknown";
22
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
23
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
24
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
25
+ let settleExit!: () => void;
26
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
27
+ const retire = () => {
28
+ if (retired || finalized) return;
29
+ retired = true; failed = true;
30
+ // Retirement is not an observed terminal event or proof that the child stopped.
31
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
32
+ settleExit();
33
+ for (const finish of finishers) finish();
34
+ child.stdout?.destroy(); child.stderr?.destroy();
35
+ };
36
+ const terminate = () => {
37
+ if (terminationRequested || retired || finalized) return;
38
+ terminationRequested = true;
39
+ const grace = deps.abortKillDelayMs ?? 5000;
40
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
41
+ killTimer = schedule(() => {
42
+ if (finalized || retired) return;
43
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
44
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
45
+ retireTimer = schedule(retire, grace);
46
+ }, grace);
47
+ };
48
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
49
+ let length = 0; const parts: Buffer[] = [];
50
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
51
+ if (stream.readableEncoding) { failed = true; terminate(); }
52
+ return new Promise<Buffer>(resolve => {
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
55
+ finishers.push(finish);
56
+ const data = (chunk: unknown) => {
57
+ if (done || retired || finalized) return;
58
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
59
+ const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
60
+ capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
78
+ // A terminal event is not a substitute for the native start event.
79
+ if (!started) failed = true;
80
+ if (attempt.exitCode === null || terminalSignal) failed = true;
81
+ resolve();
82
+ };
83
+ onError = error => {
84
+ if (retired || finalized) return;
85
+ failed = true;
86
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
87
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
88
+ } else { terminate(); }
89
+ };
90
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
91
+ });
92
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
93
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
96
+ if (signal?.aborted) onAbort();
97
+ try {
98
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
99
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
100
+ result.failed = failed;
101
+ try {
102
+ // Retain a literal UTF-8 BOM rather than silently removing source bytes.
103
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
104
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
105
+ capture.validUtf8 = true;
106
+ } catch { result.stdout = null; result.stderr = null; }
107
+ if (signal?.aborted) attempt.aborted = true;
108
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
109
+ return result;
110
+ } finally {
111
+ finalized = true;
112
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
115
+ for (const dispose of disposers) dispose();
116
+ finishers.length = 0; disposers.length = 0;
117
+ }
118
+ }