@aefree/pi-plastic 0.7.5 → 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 +51 -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 +12 -6
  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,187 @@
1
+ import { isUtf8 } from "node:buffer";
2
+ import { promises as fs } from "node:fs";
3
+ import { join, extname } from "path";
4
+ import { spawnAndCollect, getDiffExecutable } from "../execution/process";
5
+ import { getActiveAbortSignal, commandExecutionStorage } from "../execution/context";
6
+ import { tmpdir } from "os";
7
+ import { runCm } from "../execution/cm";
8
+
9
+ export const DIFF_OUTPUT_MAX_CHARS = 60_000;
10
+
11
+ export type TextDiffResult = {
12
+ backend: "diff";
13
+ changed: boolean;
14
+ binary: boolean;
15
+ output: string;
16
+ truncated: boolean;
17
+ totalChars: number;
18
+ };
19
+
20
+ // Do not classify serialized Unity YAML by its extension or importer metadata:
21
+ // valid UTF-8 YAML is text, while NUL-containing or invalid UTF-8 bytes are binary.
22
+
23
+ export const isBinaryContent = (content: Buffer): boolean => content.includes(0) || !isUtf8(content);
24
+
25
+ export const stableDiffHeaders = (output: string, leftLabel: string, rightLabel: string): string =>
26
+ {
27
+ const lines = output.split(/\r?\n/);
28
+ // Both GNU and BSD `diff -u` put the source paths in the first two header
29
+ // lines. Replacing only those lines keeps hunk content verbatim while never
30
+ // leaking package-owned temporary paths or host-specific timestamps.
31
+ if (lines[0]?.startsWith("--- "))
32
+ {
33
+ lines[0] = `--- ${leftLabel}`;
34
+ }
35
+ if (lines[1]?.startsWith("+++ "))
36
+ {
37
+ lines[1] = `+++ ${rightLabel}`;
38
+ }
39
+ return lines.join("\n").replace(/\n+$/, "");
40
+ };
41
+
42
+ export const boundDiffOutput = (output: string): Pick<TextDiffResult, "output" | "truncated" | "totalChars"> =>
43
+ {
44
+ const totalChars = output.length;
45
+ if (totalChars <= DIFF_OUTPUT_MAX_CHARS)
46
+ {
47
+ return { output, truncated: false, totalChars };
48
+ }
49
+
50
+ return {
51
+ output: `${output.slice(0, DIFF_OUTPUT_MAX_CHARS)}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or use a review patch for the complete change.]`,
52
+ truncated: true,
53
+ totalChars,
54
+ };
55
+ };
56
+
57
+ export const runPortableTextDiff = async (
58
+ leftPath: string,
59
+ rightPath: string,
60
+ _cwd: string,
61
+ leftLabel: string,
62
+ rightLabel: string,
63
+ ): Promise<TextDiffResult> =>
64
+ {
65
+ const [leftContent, rightContent] = await Promise.all([fs.readFile(leftPath), fs.readFile(rightPath)]);
66
+ if (isBinaryContent(leftContent) || isBinaryContent(rightContent))
67
+ {
68
+ return { backend: "diff", changed: !leftContent.equals(rightContent), binary: true, output: "", truncated: false, totalChars: 0 };
69
+ }
70
+
71
+ // Do not pass either workspace or historical source paths to diff: a
72
+ // Unicode workspace path reproduces an Invalid argument failure in the
73
+ // configured Windows GnuWin32 backend. Labels intentionally stay out of
74
+ // argv and are restored by stableDiffHeaders below.
75
+ const materializationDir = await createAsciiTempDirectory("pi-plastic-diff-");
76
+ const materializedLeftPath = join(materializationDir, `left${safeTempExtension(leftPath)}`);
77
+ const materializedRightPath = join(materializationDir, `right${safeTempExtension(rightPath)}`);
78
+ try
79
+ {
80
+ await Promise.all([
81
+ fs.writeFile(materializedLeftPath, leftContent),
82
+ fs.writeFile(materializedRightPath, rightContent),
83
+ ]);
84
+ const { stdout, stderr, exitCode, aborted, stdoutTruncated, stdoutTotalChars } = await spawnAndCollect(
85
+ getDiffExecutable(),
86
+ ["-u", materializedLeftPath, materializedRightPath],
87
+ materializationDir,
88
+ undefined,
89
+ getActiveAbortSignal(),
90
+ { ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
91
+ );
92
+ if (aborted)
93
+ {
94
+ throw new Error("Text diff was aborted.");
95
+ }
96
+ if (exitCode > 1)
97
+ {
98
+ const diagnostic = [stdout, stderr].filter(Boolean).join("\n").trim();
99
+ const sanitized = diagnostic
100
+ .split(materializedLeftPath).join(leftLabel)
101
+ .split(materializedRightPath).join(rightLabel);
102
+ throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
103
+ }
104
+
105
+ const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
106
+ const bounded = stdoutTruncated
107
+ ? {
108
+ output: `${normalized}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or generate a review patch for the complete change.]`,
109
+ truncated: true,
110
+ totalChars: stdoutTotalChars ?? normalized.length,
111
+ }
112
+ : boundDiffOutput(normalized);
113
+ return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
114
+ }
115
+ finally
116
+ {
117
+ await fs.rm(materializationDir, { recursive: true, force: true });
118
+ }
119
+ };
120
+
121
+ export const safeTempExtension = (pathValue: string): string =>
122
+ {
123
+ const extension = extname(pathValue);
124
+ return /^\.[A-Za-z0-9]{1,12}$/.test(extension) ? extension : ".tmp";
125
+ };
126
+
127
+ export const isAsciiPath = (pathValue: string): boolean => /^[\x20-\x7e]*$/.test(pathValue);
128
+
129
+ // GnuWin32 diff cannot reliably accept Unicode operands. Keep every package
130
+ // materialization path ASCII-only; labels are restored after the backend exits.
131
+
132
+ export const createAsciiTempDirectory = async (prefix: string): Promise<string> =>
133
+ {
134
+ const temporaryRoot = tmpdir();
135
+ if (!isAsciiPath(temporaryRoot))
136
+ {
137
+ throw new Error("Text diff requires an ASCII-safe temporary directory because the configured backend cannot reliably accept Unicode paths. Set TEMP and TMP to a writable ASCII-only path.");
138
+ }
139
+ return fs.mkdtemp(join(temporaryRoot, prefix));
140
+ };
141
+
142
+ export const materializeRevision = async (revision: string, destination: string, workdir?: string): Promise<void> =>
143
+ {
144
+ // --file keeps historical bytes out of the decoded stdout path. This is
145
+ // required for both reliable binary detection and Unity YAML text handling.
146
+ try
147
+ {
148
+ await runCm(["cat", revision, `--file=${destination}`], workdir);
149
+ const destinationStat = await fs.stat(destination).catch(() => null);
150
+ if (!destinationStat?.isFile())
151
+ {
152
+ throw new Error("Plastic did not materialize the requested historical file content.");
153
+ }
154
+ }
155
+ catch (error)
156
+ {
157
+ // cm cat --file can leave a zero-byte output on failure. The destination
158
+ // is package-owned, so remove it before surfacing a sanitized diagnostic.
159
+ await fs.rm(destination, { force: true }).catch(() => undefined);
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ throw new Error(message.split(destination).join("<package-owned-temp-file>"));
162
+ }
163
+ };
164
+
165
+ export const isNoDataError = (message: string): boolean =>
166
+ {
167
+ const normalized = message.toLowerCase();
168
+ return normalized.includes("--nodata")
169
+ || normalized.includes("no data available")
170
+ || normalized.includes("data is not available")
171
+ || normalized.includes("cannot retrieve data")
172
+ || normalized.includes("could not retrieve data");
173
+ };
174
+
175
+ export const withWorkspaceBaseUnavailableDiagnostic = (path: string, error: unknown): Error =>
176
+ {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ if (!isNoDataError(message))
179
+ {
180
+ return error instanceof Error ? error : new Error(message);
181
+ }
182
+
183
+ return new Error(
184
+ `Plastic cannot supply historical/base bytes for '${path}' because this workspace/status record is --nodata. `
185
+ + "A workspace diff cannot be calculated without those bytes. Run plastic_status(machineReadable=true, includeRevId=true) to verify the item, then update/refresh the workspace or use plastic_diff with mode=revisions and two known file-qualified revisions when historical content is available.",
186
+ );
187
+ };
@@ -0,0 +1,24 @@
1
+ import type { AddAttempt, AddCapture } from "../execution/add-command";
2
+ export type { AddAttempt, AddCapture } from "../execution/add-command";
3
+ export const ADD_COMMAND = "add" as const;
4
+ export const safeAddText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && value.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
5
+ export function validAddOperands(paths: unknown): paths is string[] {
6
+ if (!Array.isArray(paths) || !paths.length || paths.length > 256) return false;
7
+ let bytes = 0;
8
+ for (let index = 0; index < paths.length; index++) {
9
+ const path = paths[index];
10
+ if (!safeAddText(path) || path.trimStart().startsWith("-")) return false;
11
+ bytes += Buffer.byteLength(path, "utf8");
12
+ if (bytes > 32768) return false;
13
+ }
14
+ return true;
15
+ }
16
+ export type AddData = {
17
+ intendedArgv: readonly string[]; requestedOperandCount: number | null; workingDirectory: string | null;
18
+ attempt: AddAttempt; capture: AddCapture | null;
19
+ observedWorkspaceIdentity: null; observedAddedItems: null;
20
+ verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
21
+ };
22
+ type Header = { schemaVersion: 1; action: "add"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: AddData };
23
+ export type AddError = { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; message: string };
24
+ export type AddReceipt = Header & ({ ok: true; outcome: "command-completed" } | { ok: false; outcome: "failed" | "uncertain"; error: AddError });
@@ -0,0 +1,38 @@
1
+ export type BranchCreateRequest = { branch: string; parent?: string; changeset?: string; label?: string; comment?: string; commentsFile?: string; allowRootBranch?: boolean; workdir?: string };
2
+ import type { BranchCreateAttempt, BranchCreateCapture } from "../execution/branch-create-command";
3
+ export { emptyBranchCreateAttempt } from "../execution/branch-create-command";
4
+ export type { BranchCreateAttempt, BranchCreateCapture } from "../execution/branch-create-command";
5
+ export type BranchCreateData = {
6
+ requested: { branch: string; parent: string | null; changeset: string | null; label: string | null; comment: string | null; commentsFile: string | null; allowRootBranch: boolean } | null;
7
+ resolvedTarget: string | null;
8
+ parentResolution: { basis: "not-required" | "explicit" | "status" | "unresolved"; observedBranch: string | null; repositoryVerification: "unverified" };
9
+ parentCommands: Array<{ argv: string[]; attempt: BranchCreateAttempt; capture: BranchCreateCapture }>;
10
+ command: string[] | null;
11
+ attempt: BranchCreateAttempt;
12
+ capture: BranchCreateCapture | null;
13
+ observedCreatedIdentity: null;
14
+ repositoryVerification: "unverified";
15
+ workspaceSwitch: "not-requested";
16
+ };
17
+ type Base = { schemaVersion: 1; action: "branch-create"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true } };
18
+ export type BranchCreateError = { code: "invalid_request" | "unsupported_source" | "ambiguous_qualifier" | "parent_unresolved" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; stage: "input" | "source" | "parent" | "create" | "producer"; message: string };
19
+ export type BranchCreateReceipt = Base & (
20
+ { ok: true; outcome: "command-completed"; data: BranchCreateData & { effect: "not-proven" } } |
21
+ { ok: false; outcome: "failed" | "unsupported"; data: BranchCreateData & { effect: "not-attempted" }; error: BranchCreateError } |
22
+ { ok: false; outcome: "uncertain"; data: BranchCreateData & { effect: "uncertain" }; error: BranchCreateError }
23
+ );
24
+ export const branchCreateUnsafeText = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
25
+ export const safeBranchCreateText = (s: string): boolean => s.length <= 4096 && !branchCreateUnsafeText.test(s);
26
+ /**
27
+ * Direct standard-status source only, admitted against known-original Unicode
28
+ * on Windows cm 11.0.16.10371. No runtime version query or compact grammar is
29
+ * claimed. Changeset ownership is not the loaded branch. '?' and U+FFFD remain
30
+ * unsupported/ambiguous source identities, not proof of character degradation.
31
+ */
32
+ export function parseBranchCreateParent(output: string): string | null {
33
+ const lines = output.split(/\r?\n/).filter(line => line.length > 0);
34
+ if (lines.length !== 1) return null;
35
+ const match = /^(\/[^@]+)@[^@]+@[^\s]+ \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u.exec(lines[0]!);
36
+ if (!match || !safeBranchCreateText(match[1]!) || !/^\/(?:[^/@\\]+)(?:\/[^/@\\]+)*$/u.test(match[1]!) || match[1] !== match[1]!.trim() || /[?\uFFFD]/u.test(match[1]!)) return null;
37
+ return match[1]!;
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
2
+ export type BranchDeleteAttempt = WorkspaceMergeAttempt;
3
+ export type BranchDeleteCapture = WorkspaceMergeCapture;
4
+ export const safeBranchDeleteText = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 4096 && v.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(v);
5
+ export const validBranchDeleteOperand = (v: unknown): v is string => safeBranchDeleteText(v) && !v.trimStart().startsWith("-");
6
+ export type BranchDeleteData = {
7
+ intendedArgv: readonly string[]; requestedBranch: string | null; workingDirectory: string | null;
8
+ deleteChangesetsRequested: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
9
+ attempt: BranchDeleteAttempt; capture: BranchDeleteCapture | null;
10
+ observedDeletedBranch: null; observedDeletedChangesets: null; observedRepositoryIdentity: null; observedWorkspaceIdentity: null;
11
+ verification: "unverified"; rollback: "not-proven"; effect: "not-attempted" | "not-proven" | "uncertain";
12
+ };
13
+ export type BranchDeleteError = { code: "invalid_request" | "launch_failed" | "aborted" | "uncertain" | "producer_failed"; message: string };
14
+ export type BranchDeleteReceipt = { schemaVersion: 1; action: "branch-delete"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: BranchDeleteData } & ({ ok: true; outcome: "preflight" | "command-completed" } | { ok: false; outcome: "failed" | "uncertain"; error: BranchDeleteError });
15
+ export const branchDeleteArgv = (branch: string, deleteChangesets: boolean): string[] => ["branch", "delete", branch, ...(deleteChangesets ? ["--delete-changesets"] : [])];
@@ -0,0 +1,263 @@
1
+ import { runCmRaw, normalizeFindOutputLines } from "../execution/cm";
2
+ import { parsePlasticStatusBranch } from "../plastic-workspace";
3
+ import { normalizeErrorMessage } from "./merge-output";
4
+
5
+ export const normalizeBranchSpecForComparison = (branch: string): string =>
6
+ {
7
+ const trimmed = branch.trim();
8
+ const withoutPrefix = trimmed.replace(/^br:/i, "");
9
+ const withoutRepository = withoutPrefix.split("@")[0] ?? withoutPrefix;
10
+ return withoutRepository.replace(/\\/g, "/");
11
+ };
12
+
13
+ export const isSameBranchSpec = (left: string, right: string): boolean =>
14
+ {
15
+ return normalizeBranchSpecForComparison(left) === normalizeBranchSpecForComparison(right);
16
+ };
17
+
18
+ export const assertWorkspaceOnBranch = (actualBranch: string, expectedBranch: string, phase: string): void =>
19
+ {
20
+ if (isSameBranchSpec(actualBranch, expectedBranch))
21
+ {
22
+ return;
23
+ }
24
+
25
+ throw new Error([
26
+ `Plastic workspace branch mismatch after ${phase}.`,
27
+ `Expected target: ${expectedBranch}`,
28
+ `Actual branch: ${actualBranch}`,
29
+ "The requested merge/checkin was not completed on the target branch.",
30
+ "Do not switch branches and assume the changeset moved; Plastic changesets remain on the branch where they were created. Inspect branch history and retry the merge from the actual target branch.",
31
+ ].join("\n"));
32
+ };
33
+
34
+ export const getBranchLeafName = (branch: string): string =>
35
+ {
36
+ const normalized = normalizeBranchSpecForComparison(branch);
37
+ const segments = normalized.split("/").filter((segment) => segment.length > 0);
38
+ return segments.at(-1) ?? normalized;
39
+ };
40
+
41
+ export const resolveBranchCreationTarget = (
42
+ requestedBranch: string,
43
+ parentBranch?: string,
44
+ allowRootBranch = false,
45
+ ): string =>
46
+ {
47
+ const requested = requestedBranch.trim();
48
+ const normalizedRequested = normalizeBranchSpecForComparison(requested);
49
+
50
+ if (normalizedRequested.length === 0)
51
+ {
52
+ throw new Error("Branch must be non-empty.");
53
+ }
54
+
55
+ if (!normalizedRequested.startsWith("/"))
56
+ {
57
+ if (!parentBranch)
58
+ {
59
+ throw new Error("A relative branch name requires a parent branch.");
60
+ }
61
+
62
+ const normalizedParent = normalizeBranchSpecForComparison(parentBranch).replace(/\/$/, "");
63
+ if (!normalizedParent.startsWith("/") || normalizedParent === "/")
64
+ {
65
+ throw new Error(`Parent branch must be a hierarchical branch path, received ${parentBranch}.`);
66
+ }
67
+ return `${normalizedParent}/${normalizedRequested}`;
68
+ }
69
+
70
+ const segments = normalizedRequested.split("/").filter((segment) => segment.length > 0);
71
+ if (segments.length === 1 && !allowRootBranch)
72
+ {
73
+ throw new Error(
74
+ `Refusing to create top-level branch ${requested}. ` +
75
+ "Create <parent-branch>/<new-branch>, pass parent with a relative branch name, " +
76
+ "or set allowRootBranch=true when top-level branch creation is intentional.",
77
+ );
78
+ }
79
+
80
+ return requested;
81
+ };
82
+
83
+ export const escapeCmWhereValue = (value: string): string => value.replace(/\\/g, "\\\\").replace(/'/g, "''");
84
+ export const cmWhereEquals = (field: string, value: string): string => `${field} = '${escapeCmWhereValue(value)}'`;
85
+ export const cmWhereLike = (field: string, value: string): string => `${field} like '${escapeCmWhereValue(value)}'`;
86
+ export const listRecentBranchNames = async (workdir?: string, limit = 200): Promise<string[]> =>
87
+ {
88
+ const output = await runCmRaw([
89
+ "find",
90
+ "branch",
91
+ `order by date desc limit ${limit}`,
92
+ "--format={name}",
93
+ "--nototal",
94
+ ], workdir);
95
+ return normalizeFindOutputLines(output);
96
+ };
97
+
98
+ export type BranchParentLookupOutcome =
99
+ | { kind: "resolved"; branch: string; parent: string }
100
+ | { kind: "root"; branch: string; matchedBranch: string }
101
+ | { kind: "not-found"; branch: string; attemptedCandidates: string[] }
102
+ | { kind: "malformed-output"; branch: string; attemptedCandidates: string[]; diagnostics: string[] }
103
+ | { kind: "command-failed"; branch: string; attemptedCandidates: string[]; diagnostics: string[] };
104
+
105
+ export type BranchParentRow = { name: string; parent?: string };
106
+
107
+ export const parseBranchParentRows = (output: string): BranchParentRow[] | undefined =>
108
+ {
109
+ const lines = normalizeFindOutputLines(output);
110
+ const rows: BranchParentRow[] = [];
111
+ for (const line of lines)
112
+ {
113
+ const separator = line.indexOf("|");
114
+ if (separator <= 0 || line.indexOf("|", separator + 1) !== -1)
115
+ {
116
+ return undefined;
117
+ }
118
+ const name = line.slice(0, separator).trim();
119
+ const parent = line.slice(separator + 1).trim();
120
+ if (!name)
121
+ {
122
+ return undefined;
123
+ }
124
+ rows.push({ name, ...(parent ? { parent } : {}) });
125
+ }
126
+ return rows;
127
+ };
128
+
129
+ export const branchRepositoryQualifier = (branch: string): string | undefined =>
130
+ {
131
+ const match = branch.trim().match(/^(?:br:)?[^@]+(@.+)$/);
132
+ return match?.[1];
133
+ };
134
+
135
+ export const branchRepositorySpec = (branch: string): string | undefined =>
136
+ {
137
+ const qualifier = branchRepositoryQualifier(branch);
138
+ return qualifier?.slice(1);
139
+ };
140
+
141
+ export const branchNameForLookup = (branch: string): string => normalizeBranchSpecForComparison(branch);
142
+
143
+ export const qualifyBranchName = (branchName: string, requestedBranch: string): string =>
144
+ {
145
+ const qualifier = branchRepositoryQualifier(requestedBranch);
146
+ return qualifier && !branchName.includes("@") ? `${branchName}${qualifier}` : branchName;
147
+ };
148
+
149
+ export const resolveBranchParentName = async (branch: string, workdir?: string): Promise<BranchParentLookupOutcome> =>
150
+ {
151
+ const requestedBranch = branch.trim();
152
+ // A qualified selector is identity-bearing input; never strip its repository/server.
153
+ const lookupBranchName = branchNameForLookup(requestedBranch);
154
+ const candidates = Array.from(new Set(
155
+ (requestedBranch.includes("@")
156
+ ? [lookupBranchName]
157
+ : [requestedBranch, lookupBranchName, `br:${lookupBranchName}`])
158
+ .filter((candidate) => candidate.length > 0),
159
+ ));
160
+ const diagnostics: string[] = [];
161
+ const repositorySpec = branchRepositorySpec(requestedBranch);
162
+ const repositoryClause = repositorySpec
163
+ ? ` on repository '${repositorySpec.replace(/'/g, "''")}'`
164
+ : "";
165
+
166
+ for (const candidate of candidates)
167
+ {
168
+ try
169
+ {
170
+ const output = await runCmRaw([
171
+ "find",
172
+ "branch",
173
+ `where ${cmWhereEquals("name", candidate)}${repositoryClause}`,
174
+ "--format={name}|{parent}",
175
+ "--nototal",
176
+ ], workdir);
177
+ const rows = parseBranchParentRows(output);
178
+ if (!rows || rows.length > 1)
179
+ {
180
+ return {
181
+ kind: "malformed-output",
182
+ branch: requestedBranch,
183
+ attemptedCandidates: candidates,
184
+ diagnostics: [`Expected one name|parent row, received ${rows ? rows.length : "malformed"}.`],
185
+ };
186
+ }
187
+ const row = rows[0];
188
+ if (row && branchNameForLookup(row.name) !== lookupBranchName)
189
+ {
190
+ return {
191
+ kind: "malformed-output",
192
+ branch: requestedBranch,
193
+ attemptedCandidates: candidates,
194
+ diagnostics: ["Returned branch identity did not match the requested branch name."],
195
+ };
196
+ }
197
+ if (row?.parent)
198
+ {
199
+ return {
200
+ kind: "resolved",
201
+ branch: qualifyBranchName(row.name, requestedBranch),
202
+ parent: qualifyBranchName(row.parent, requestedBranch),
203
+ };
204
+ }
205
+ if (row)
206
+ {
207
+ return { kind: "root", branch: requestedBranch, matchedBranch: qualifyBranchName(row.name, requestedBranch) };
208
+ }
209
+ }
210
+ catch (error)
211
+ {
212
+ diagnostics.push(normalizeErrorMessage(error).slice(0, 256));
213
+ }
214
+ }
215
+
216
+ if (diagnostics.length > 0)
217
+ {
218
+ return { kind: "command-failed", branch: requestedBranch, attemptedCandidates: candidates, diagnostics: diagnostics.slice(0, 3) };
219
+ }
220
+
221
+ return { kind: "not-found", branch: requestedBranch, attemptedCandidates: candidates };
222
+ };
223
+
224
+ export const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
225
+ {
226
+ const statusOutput = await runCmRaw(["status"], workdir);
227
+ const statusBranch = parsePlasticStatusBranch(statusOutput);
228
+ if (statusBranch.kind === "found" && statusBranch.value.branch)
229
+ {
230
+ return statusBranch.value.branch;
231
+ }
232
+
233
+ const compactOutput = await runCmRaw(["status", "--compact"], workdir);
234
+ const compactBranch = parsePlasticStatusBranch(compactOutput);
235
+ if (compactBranch.kind === "found" && compactBranch.value.branch)
236
+ {
237
+ return compactBranch.value.branch;
238
+ }
239
+
240
+ if (compactBranch.kind === "found" && compactBranch.value.changesetId)
241
+ {
242
+ const changesetBranchOutput = await runCmRaw([
243
+ "find",
244
+ "changeset",
245
+ `where changesetid=${compactBranch.value.changesetId}`,
246
+ "--format={branch}",
247
+ "--nototal",
248
+ ], workdir);
249
+ const branchLines = normalizeFindOutputLines(changesetBranchOutput);
250
+ if (branchLines.length > 0)
251
+ {
252
+ return branchLines[0];
253
+ }
254
+ }
255
+
256
+ const trimmed = compactOutput.trim();
257
+ if (trimmed.length === 0)
258
+ {
259
+ throw new Error("Unable to determine current branch from empty status output.");
260
+ }
261
+
262
+ throw new Error(`Unable to parse current branch from status output: ${trimmed}`);
263
+ };
@@ -0,0 +1,70 @@
1
+ import { parseMachineReadablePendingItems, type PendingItem } from "./pending";
2
+ import { parseCopiedPending } from "./copied-merge";
3
+ export type CheckinRequest = { message: string; paths?: string[]; applyChanged?: boolean; includePrivate?: boolean; includeAll?: boolean; updateAfter?: boolean; preflight?: boolean; format?: "text" | "json"; workdir?: string };
4
+ import type { CheckinAttempt, CheckinCapture } from "../execution/checkin-command";
5
+ export type { CheckinAttempt, CheckinCapture } from "../execution/checkin-command";
6
+ export type CheckinChangeset = { id: string; branch: string; repository: string; server: string; mount: "/" };
7
+ export type CheckinSummary = { totalPending: number; tracked: number; private: number; added: number; changed: number; moved: number; deleted: number; other: number };
8
+ export type CheckinStep = { name: "pending-before" | "initial-checkin" | "private-add" | "private-retry" | "pending-recovery" | "fallback-checkin" | "pending-after" | "merge-diagnostic"; operation: "status" | "checkin" | "add"; attempt: CheckinAttempt; capture: CheckinCapture; effect: "not-attempted" | "uncertain" | "command-completed" | "changeset-created" | "read-observed"; evidence: "unadmitted" | "admitted"; records: number; summary: CheckinSummary | null; changeset: CheckinChangeset | null };
9
+ export type CheckinItemEvent = { operation: "CO" | "AD" | "DE" | "CP"; path: string } | { operation: "MV"; sourcePath: string; path: string };
10
+ export type CheckinData = { sourceAdmission: "windows-cm11.0.16.10371-observed"; requestedPaths: string[]; includedPaths: string[]; fallbackPaths: string[]; excludedPaths: Array<{ path: string; reason: string }>; privateAddPaths: string[]; blockedPrivatePaths: Array<{ path: string; reason: string }>; itemEvents: CheckinItemEvent[]; observedChangesets: CheckinChangeset[]; command: string[] | null; steps: CheckinStep[]; autoEnabledApplyChanged: boolean; usedFallbackRetry: boolean; usedPrivateAutoAddRecovery: boolean; pendingBefore: CheckinSummary | null; pendingAfter: CheckinSummary | null; wouldRun: boolean; omittedReferences: number; scopeExhaustion: "unverified"; branchHead: "unverified"; serverAliasEquivalence: "unverified"; xlinkEffects: "unverified"; mergeLinkIdentity: "unverified" };
11
+ type Base = { schemaVersion: 1; action: "checkin"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: boolean } };
12
+ export type CheckinReceipt = Base & (
13
+ { ok: true; outcome: "completed"; data: CheckinData & { effect: "changeset-created"; createdChangeset: CheckinChangeset } } |
14
+ { ok: true; outcome: "preflight"; data: CheckinData & { effect: "not-attempted"; createdChangeset: null } } |
15
+ { ok: false; outcome: "failed" | "unsupported" | "uncertain"; data: CheckinData & { effect: "not-attempted" | "uncertain" | "command-completed" | "changeset-created"; createdChangeset: CheckinChangeset | null }; error: { code: string; stage: "input" | "pending" | "checkin" | "producer"; message: string } }
16
+ );
17
+ export const checkinUnsafeText = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
18
+ export const checkinSafeValue = (s: unknown): s is string => typeof s === "string" && s.length > 0 && s.length <= 4096 && !checkinUnsafeText.test(s);
19
+ // Comments alone admit CR/LF; all other controls, bounds and surrogate rules stay unchanged.
20
+ export const checkinSafeComment = (s: unknown): s is string => typeof s === "string" && s.length > 0 && s.length <= 4096 && !checkinUnsafeText.test(s.replace(/[\r\n]/g, " "));
21
+ // Loss-marker rejection is a conservative admission policy, not proof of original Unicode fidelity.
22
+ const identity = (s: string) => checkinSafeValue(s) && s === s.trim() && !/[?\uFFFD]/.test(s);
23
+ const absolute = (s: string) => identity(s) && /^(?:[A-Za-z]:[\\/]|\\\\)/.test(s);
24
+ export const checkinDecimal = /^(0|[1-9][0-9]{0,19})$/;
25
+ export function parseCheckinChangeset(payload: string): CheckinChangeset | null {
26
+ const m = /^cs:(0|[1-9][0-9]{0,19})@br:(\/[^@]+)@([^@]+)@([^@]+(?:@(cloud|unity))?) \(mount:'\/'\)$/.exec(payload);
27
+ if (!m || !m.slice(2, 5).every(identity) || m[2].endsWith("/")) return null;
28
+ return { id: m[1], branch: m[2], repository: m[3], server: m[4], mount: "/" };
29
+ }
30
+ export type CheckinSeparators = { start: string; end: string; field: string };
31
+ export function parseCheckinEvidence(text: string, s: CheckinSeparators) {
32
+ const changesets: CheckinChangeset[] = [], events: CheckinItemEvent[] = [];
33
+ const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
34
+ let malformed = !lines.length || lines.length > 500, started = 0;
35
+ for (const [index, line] of lines.entries()) {
36
+ if (!line.startsWith(s.start) || !line.endsWith(s.end)) { malformed = true; continue; }
37
+ const fields = line.slice(s.start.length, -s.end.length).split(s.field), [op, value] = fields;
38
+ if (op === "CI_START" && fields.length === 1) { started++; if (index !== 0) malformed = true; }
39
+ else if (op === "STAGE" && fields.length === 2 && value.length <= 4096 && !checkinUnsafeText.test(value)) { if (changesets.length) malformed = true; }
40
+ else if ((op === "CO" || op === "AD" || op === "DE" || op === "CP") && fields.length === 2 && absolute(value)) { events.push({ operation: op, path: value }); if (changesets.length) malformed = true; }
41
+ else if (op === "MV" && fields.length === 3 && absolute(value) && absolute(fields[2])) { events.push({ operation: "MV", sourcePath: value, path: fields[2] }); if (changesets.length) malformed = true; }
42
+ else if (op === "CHANGESET" && fields.length === 2) { const cs = parseCheckinChangeset(value); if (cs) changesets.push(cs); else malformed = true; }
43
+ else malformed = true;
44
+ }
45
+ if (started !== 1 || changesets.length > 1) malformed = true;
46
+ return { admitted: !malformed, records: Math.min(lines.length, 501), changesets, events };
47
+ }
48
+ export function parseCheckinPending(text: string, cwd: string) {
49
+ // statusCode CP retained; added kind is local checkin selection, not legacy migration.
50
+ const copied = parseCopiedPending(text, cwd);
51
+ if (copied) {
52
+ const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: "added" as const }));
53
+ return { admitted: items.length === copied.paths.length, records: copied.paths.length + 1, items, repository: copied.repository, server: copied.server };
54
+ }
55
+ const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
56
+ let admitted = lines.length > 0 && lines.length <= 500;
57
+ const h = (lines[0] ?? "").split("\x1f");
58
+ if (h.length !== 4 || h[0] !== "STATUS" || !checkinDecimal.test(h[1]) || !identity(h[2] ?? "") || !identity(h[3] ?? "")) admitted = false;
59
+ for (const line of lines.slice(1)) {
60
+ const f = line.split("\x1f");
61
+ // Exact observed shapes only; unfamiliar status/metadata fails closed.
62
+ // CO uses -1 (no reusable revision identity), even for unselected paths.
63
+ if (f[0] === "MV") {
64
+ if (f.length !== 7 || f[1] !== "100%" || !absolute(f[2]) || !absolute(f[3]) || f[4] !== "False" || !checkinDecimal.test(f[5]) || f[5] === "0" || f[6] !== "NO_MERGES") admitted = false;
65
+ } else if (f.length !== 5 || !/^(PR|AD|CO|CH|LD)$/.test(f[0]) || !absolute(f[1]) || !/^(True|False)$/.test(f[2]) || !(/^(PR|AD|CO)$/.test(f[0]) ? f[3] === "-1" : checkinDecimal.test(f[3]) && f[3] !== "0") || f[4] !== "NO_MERGES") admitted = false;
66
+ }
67
+ const items: PendingItem[] = admitted ? parseMachineReadablePendingItems(text, cwd) : [];
68
+ if (admitted && items.length !== lines.length - 1) admitted = false;
69
+ return { admitted, records: Math.min(lines.length, 501), items, repository: admitted ? h[2] : null, server: admitted ? h[3] : null };
70
+ }
@@ -0,0 +1,30 @@
1
+ import { parseSwitchTarget } from "./switch-contract";
2
+ import { cmWhereEquals } from "./branches";
3
+ /** Name is a leaf filter; emitted full name, not the filter, is identity.
4
+ * Two returned rows are ambiguity, not a reason to choose the first or retry. */
5
+ export function buildCloseoutParentArgv(source: string): string[] {
6
+ const spec=parseSwitchTarget(source);if(!spec)throw Error("Invalid parent lookup source.");
7
+ const leaf=spec.branch.slice(spec.branch.lastIndexOf("/")+1);
8
+ const clause=spec.repository===null?"":` on repository '${(spec.repository+"@"+spec.server).replace(/'/g,"''")}'`;
9
+ return ["find","branch",`where ${cmWhereEquals("name",leaf)} order by branchname asc limit 2${clause}`,"--format={name}|{parent}","--nototal"];
10
+ }
11
+ import type { WorkspaceMergeAttempt, WorkspaceMergeCapture, MergeStrategy, WorkspaceMergeReceipt } from "./workspace-merge-contract";
12
+ import type { SwitchBranchObservation, SwitchSummary, SwitchReceipt } from "./switch-contract";
13
+ import type { CheckinChangeset, CheckinReceipt } from "./checkin-contract";
14
+ import type { UpdateReceipt } from "./update-contract";
15
+ export type CloseoutRequest = { source?: string; target?: string; cardRef?: string; message?: string; strategy?: MergeStrategy; updateTarget?: boolean; includePrivate?: boolean; preflight?: boolean; format?: "text" | "json"; workdir?: string };
16
+ export const CLOSEOUT_STAGE_NAMES = ["loaded-before", "parent", "pending-before", "switch", "loaded-after-switch", "update", "loaded-before-merge", "merge", "readiness", "loaded-before-checkin", "checkin-preflight", "checkin", "loaded-final", "pending-final"] as const;
17
+ export type CloseoutStageName = typeof CLOSEOUT_STAGE_NAMES[number];
18
+ export type CloseoutRead = { argv: string[]; attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture | null; admission: "unsupported" | "admitted"; branch: SwitchBranchObservation | null; parentPath: string | null; emptyParentResult: boolean; summary: SwitchSummary | null };
19
+ export type CloseoutStage = { name: CloseoutStageName } & (
20
+ { kind: "read"; result: CloseoutRead } | { kind: "switch"; result: SwitchReceipt } | { kind: "update"; result: UpdateReceipt } | { kind: "merge"; result: WorkspaceMergeReceipt } | { kind: "checkin"; result: CheckinReceipt }
21
+ );
22
+ export type CloseoutData = { requested: { source: string | null; target: string | null; message: string | null; cardRef: string | null; strategy: MergeStrategy; updateTarget: boolean; includePrivate: boolean; preflight: boolean; outputFormat: "text" | "json"; workdir: string } | null; sourceBranch: string | null; targetBranch: string | null; targetSource: "unresolved" | "explicit" | "parent-read"; checkinMessage: string | null; stages: CloseoutStage[]; unattemptedStages: CloseoutStageName[]; unobservedChild: "switch" | "update" | "merge" | "checkin-preflight" | "checkin" | null; createdChangeset: CheckinChangeset | null; pendingAfter: SwitchSummary | null; targetVerification: "unverified" | "name-only" | "exact-qualifier-spelling"; serverAliasEquivalence: "unverified"; sourceLink: "unverified"; branchHead: "unverified"; exclusiveScope: "unverified"; rollback: "not-proven"; effect: "not-attempted" | "command-completed" | "uncertain" | "changeset-created" };
23
+ export type CloseoutReceipt = { schemaVersion: 1; action: "merge-to-branch"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: boolean }; data: CloseoutData } & (
24
+ { ok: true; outcome: "preflight" | "completed" } | { ok: false; outcome: "blocked" | "failed" | "uncertain"; error: { code: "invalid_request" | "unsupported_source" | "observation_failed" | "target_unverified" | "policy_blocked" | "child_failed" | "producer_failed"; stage: "input" | CloseoutStageName | "producer"; message: string } }
25
+ );
26
+ export const CLOSEOUT_DTO_BYTES = 1048576;
27
+ /** Ledger keeps the five complete bounded child receipts and at most eleven compact reads.
28
+ * No child text/stdout/stderr is duplicated, no positive changeset is projected away. */
29
+ export function closeoutChildEffect(s: CloseoutStage): CloseoutData["effect"] { if(s.kind==="read")return "not-attempted";const e=s.result.data.effect;return e==="not-proven"?"command-completed":e; }
30
+ export function closeoutCaptures(stages: CloseoutStage[]) { return stages.flatMap(s=>s.kind==="read"?[s.result.capture]:s.kind==="update"?[s.result.data.capture]:s.kind==="merge"?[s.result.data.apply.capture,s.result.data.shortStatus.capture,s.result.data.fullStatus.capture]:s.result.data.steps.map(x=>x.capture)).filter((x):x is WorkspaceMergeCapture=>x!==null); }