@aefree/pi-plastic 0.7.6 → 0.8.1

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 +43 -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 +130 -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 +204 -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,42 @@
1
+ import { captureRemovalCommand, emptyRemovalAttempt } from "../execution/removal-command";
2
+ import { REMOVAL_COMMAND, safeRemovalText, validRemovalOperands, type RemovalData, type RemovalError, type RemovalReceipt } from "../domain/removal-contract";
3
+ export { REMOVAL_COMMAND, safeRemovalText, validRemovalOperands } from "../domain/removal-contract";
4
+ export type { RemovalData, RemovalError, RemovalReceipt, RemovalAttempt, RemovalCapture } from "../domain/removal-contract";
5
+ export { presentRemovalReceipt } from "../presentation/removal-results";
6
+ export function emptyRemovalData(): RemovalData {
7
+ return { intendedArgv: [REMOVAL_COMMAND], requestedOperandCount: null, workingDirectory: null, keepOnDiskRequested: null, previewRequested: null, outputFormatRequested: null, resolutionIntent: "accept-source-deletion", attempt: emptyRemovalAttempt(), capture: null, observedWorkspaceIdentity: null, observedResolvedItems: null, observedResolution: null, observedPreservation: null, observedPendingState: null, verification: "unverified", effect: "not-attempted" };
8
+ }
9
+ function removalHeader(data: RemovalData) {
10
+ return { schemaVersion: 1 as const, action: "resolve-delete-change-conflict" as const, provenance: { source: "plastic" as const, producer: "@aefree/pi-plastic" as const, contentTrust: "external" as const }, completeness: { capture: data.capture ? data.capture.complete ? "complete" as const : "incomplete" as const : "unknown" as const, projection: true as const }, data };
11
+ }
12
+ export function removalFailure(data: RemovalData, code: RemovalError["code"], message: string): RemovalReceipt {
13
+ return { ...removalHeader(data), ok: false, outcome: data.effect === "uncertain" ? "uncertain" : "failed", error: { code, message } };
14
+ }
15
+ export async function assembleRemovalReceipt(args: unknown): Promise<RemovalReceipt> {
16
+ const data = emptyRemovalData(); let dispatched = false;
17
+ try {
18
+ if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(key => !["paths", "workdir", "keepOnDisk", "preflight", "format"].includes(key))) return removalFailure(data, "invalid_request", "Removal accepts paths, workdir, keepOnDisk, preflight and text/json format only.");
19
+ const input = args as Record<string, unknown>;
20
+ const cwd = input.workdir === undefined ? process.cwd() : input.workdir;
21
+ const keep = input.keepOnDisk === undefined ? true : input.keepOnDisk;
22
+ const preview = input.preflight === undefined ? false : input.preflight;
23
+ const format = input.format === undefined ? "text" : input.format;
24
+ const paths = Array.isArray(input.paths) && input.paths.length <= 256 ? Array.from(input.paths) : input.paths;
25
+ if (!safeRemovalText(cwd) || !validRemovalOperands(paths) || typeof keep !== "boolean" || typeof preview !== "boolean" || !["text", "json"].includes(format as string)) return removalFailure(data, "invalid_request", "Use bounded path operands, strict booleans and text/json format; flags, stdin and bare private/controlled modes are rejected.");
26
+ data.workingDirectory = cwd; data.intendedArgv = [REMOVAL_COMMAND, ...(keep ? ["--nodisk"] : []), ...paths]; data.requestedOperandCount = paths.length;
27
+ data.keepOnDiskRequested = keep; data.previewRequested = preview; data.outputFormatRequested = format as "text" | "json";
28
+ if (preview) return { ...removalHeader(data), ok: true, outcome: "preflight" };
29
+ dispatched = true;
30
+ const observation = await captureRemovalCommand([...data.intendedArgv], cwd);
31
+ data.attempt = observation.attempt; data.capture = observation.capture;
32
+ if (observation.capture.complete && !observation.failed && observation.attempt.state === "started" && observation.attempt.exitCode === 0) {
33
+ data.effect = "not-proven";
34
+ return { ...removalHeader(data), ok: true, outcome: "command-completed" };
35
+ }
36
+ data.effect = ["started", "unknown"].includes(data.attempt.state) ? "uncertain" : "not-attempted";
37
+ return removalFailure(data, data.attempt.aborted ? "aborted" : data.effect === "uncertain" ? "uncertain" : "launch_failed", data.effect === "uncertain" ? "Removal completion is not proven; partial removal or workspace/disk effects may exist." : "Removal did not establish a process start.");
38
+ } catch {
39
+ if (dispatched) { data.attempt = { ...emptyRemovalAttempt(), state: "unknown" }; data.capture = null; data.effect = "uncertain"; }
40
+ return removalFailure(data, "producer_failed", "Removal observation failed; no rollback or safe retry is proven.");
41
+ }
42
+ }
@@ -0,0 +1,90 @@
1
+ import { tool } from "../tool-definition";
2
+ import { workdirArg } from "./arguments";
3
+ import { assembleObjectWriteReceipt, presentObjectWriteReceipt } from "./object-write-receipt";
4
+ import { outputFormatArg } from "../presentation/results";
5
+ import { executeCodeReviewFind } from "./code-review-find";
6
+ import { assembleObjectDeleteReceipt, presentObjectDeleteReceipt } from "./object-delete-receipt";
7
+
8
+ export const codeReviewCreate = tool({
9
+ description: "Create a code review (cm codereview).",
10
+ args: {
11
+ target: tool.schema.string().min(1).describe("Review target spec (branch, changeset, or shelveset spec)."),
12
+ title: tool.schema.string().min(1).describe("Code review title."),
13
+ status: tool.schema.string().optional().describe("Initial review status."),
14
+ assignee: tool.schema.string().optional().describe("Initial review assignee."),
15
+ repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
16
+ format: tool.schema.string().optional().describe("Format string for creation output."),
17
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/conflict/state analysis."),
18
+ output: outputFormatArg,
19
+ workdir: workdirArg,
20
+ },
21
+ async execute(args) {
22
+ const dto = await assembleObjectWriteReceipt("code-review-create", Object.freeze({...args}));
23
+ const result = presentObjectWriteReceipt(dto);
24
+ if (!dto.ok) throw new Error(result);
25
+ return result;
26
+ },
27
+ });
28
+
29
+ export const codeReviewUpdate = tool({
30
+ description: "Update an existing code review (cm codereview -e).",
31
+ args: {
32
+ id: tool.schema.string().min(1).describe("Code review id or GUID."),
33
+ status: tool.schema.string().optional().describe("Updated review status."),
34
+ assignee: tool.schema.string().optional().describe("Updated review assignee."),
35
+ repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
36
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/conflict/state analysis."),
37
+ output: outputFormatArg,
38
+ workdir: workdirArg,
39
+ },
40
+ async execute(args) {
41
+ const dto = await assembleObjectWriteReceipt("code-review-update", Object.freeze({...args}));
42
+ const result = presentObjectWriteReceipt(dto);
43
+ if (!dto.ok) throw new Error(result);
44
+ return result;
45
+ },
46
+ });
47
+
48
+ export const codeReviewDelete = tool({
49
+ description: "Delete one or more code reviews (cm codereview -d).",
50
+ args: {
51
+ ids: tool.schema.array(tool.schema.string()).min(1).describe("Code review IDs or GUIDs to delete."),
52
+ repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
53
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/readiness analysis."),
54
+ format: outputFormatArg,
55
+ workdir: workdirArg,
56
+ },
57
+ async execute(args)
58
+ {
59
+ const request = Object.freeze({...args});
60
+ const dto = await assembleObjectDeleteReceipt("code-review-delete",request);
61
+ const result = presentObjectDeleteReceipt(dto);
62
+ if (!dto.ok && request.preflight !== true) throw new Error(result);
63
+ return result;
64
+ },
65
+ });
66
+
67
+ export const codeReviewFind = tool({
68
+ description: "Find code reviews with filters using cm find review.",
69
+ args: {
70
+ status: tool.schema.string().optional().describe("Filter by review status."),
71
+ assignee: tool.schema.string().optional().describe("Filter by review assignee."),
72
+ owner: tool.schema.string().optional().describe("Filter by review owner."),
73
+ target: tool.schema.string().optional().describe("Filter by review target branch/changeset spec."),
74
+ targetType: tool.schema.enum(["branch", "changeset"]).optional().describe("Filter by review target type."),
75
+ titleLike: tool.schema.string().optional().describe("Filter by title pattern (supports % wildcard)."),
76
+ source: tool.schema.enum(["native", "ids"]).optional().describe("Native output (default) or one canonical review-ID query."),
77
+ maxItems: tool.schema.number().int().min(1).max(500).optional().describe("Projected record cap (default 100); separate from CLI limit."),
78
+ limit: tool.schema.number().int().min(1).optional().describe("Maximum number of reviews to return."),
79
+ orderBy: tool.schema.enum(["date", "modifieddate", "status"]).optional().describe("Sort field for review queries."),
80
+ descending: tool.schema.boolean().optional().describe("Sort descending when true."),
81
+ format: tool.schema.string().optional().describe("Format string for query output."),
82
+ dateFormat: tool.schema.string().optional().describe("Date format for query output."),
83
+ output: outputFormatArg,
84
+ workdir: workdirArg,
85
+ },
86
+ async execute(args)
87
+ {
88
+ return executeCodeReviewFind(args);
89
+ },
90
+ });
@@ -0,0 +1,130 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { assertAbsoluteResolutionPath, inspectResolutionFile } from "../domain/server-merge-resolutions";
3
+ import { getActiveAbortSignal } from "../execution/context";
4
+ import { captureServerMergeCommand } from "../execution/server-merge-command";
5
+ import { tool } from "../tool-definition";
6
+ import { outputFormatArg, formatServerMergeResult } from "../presentation/results";
7
+ import { parseServerMergeOutput } from "../domain/merge-output";
8
+ import { emptyMergeAttempt, splitServerBranch, type MergeReceipt, type MergeReceiptData, type ServerMergeRequest } from "../domain/server-merge-contract";
9
+ export { splitServerBranch };
10
+ export type { MergeReceipt } from "../domain/server-merge-contract";
11
+ export type QualifiedServerBranch = { raw: string; branch: string; repository: string; server: string };
12
+ class ServerMergeInputError extends Error {}
13
+ export const SERVER_MERGE_OUTPUT_LIMIT = 32768, SERVER_MERGE_HELP_TIMEOUT_MS = 3000, SERVER_MERGE_COMMAND_TIMEOUT_MS = 30000;
14
+ export const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
15
+ export const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
16
+ export function assertSafeServerMergeValue(name: string, value: string): string {
17
+ if (typeof value !== "string" || !value.trim() || value.length > 4096 || serverMergeControlPattern.test(value)) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of control/malformed characters.`);
18
+ return value.trim();
19
+ }
20
+ export function parseQualifiedServerBranch(name: string, value: string): QualifiedServerBranch {
21
+ const raw = assertSafeServerMergeValue(name, value), parts = splitServerBranch(raw);
22
+ if (!parts) throw new ServerMergeInputError(`${name} must use unambiguous fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
23
+ return { raw, ...parts };
24
+ }
25
+ export const createServerMergeSeparators = () => {
26
+ const n = randomBytes(16).toString("hex");
27
+ return { start: `__PI_PLASTIC_MERGE_START_${n}__`, end: `__PI_PLASTIC_MERGE_END_${n}__`, field: `__PI_PLASTIC_MERGE_FIELD_${n}__` };
28
+ };
29
+ export function validateServerMergeRequest(input: unknown): asserts input is ServerMergeRequest {
30
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new ServerMergeInputError("Merge input must be an object.");
31
+ const r = input as Record<string, unknown>;
32
+ if (Object.keys(r).some(k => !["source", "target", "message", "mode", "fileConflictsResolutionsFile", "unlistedConflictPolicy", "preflight", "format"].includes(k)) || r.preflight !== undefined && typeof r.preflight !== "boolean" || r.format !== undefined && r.format !== "text" && r.format !== "json" || r.mode !== undefined && r.mode !== "apply" && r.mode !== "analyze" || r.unlistedConflictPolicy !== undefined && r.unlistedConflictPolicy !== "source" && r.unlistedConflictPolicy !== "destination") throw new ServerMergeInputError("Merge input contains unsupported fields or options.");
33
+ if (r.mode === "analyze" && (r.message !== undefined || r.fileConflictsResolutionsFile !== undefined || r.unlistedConflictPolicy !== undefined)) throw new ServerMergeInputError("Analyze mode does not accept a message, resolutions file or conflict policy.");
34
+ if (r.fileConflictsResolutionsFile !== undefined) {
35
+ try { assertAbsoluteResolutionPath(r.fileConflictsResolutionsFile); }
36
+ catch { throw new ServerMergeInputError("fileConflictsResolutionsFile must be a bounded absolute local path."); }
37
+ }
38
+ const s = parseQualifiedServerBranch("source", r.source as string), t = parseQualifiedServerBranch("target", r.target as string);
39
+ if (r.mode !== "analyze") assertSafeServerMergeValue("message", r.message as string);
40
+ if (s.repository !== t.repository || s.server !== t.server) throw new ServerMergeInputError("source and target must identify the same exact repository and server.");
41
+ if (s.branch === t.branch) throw new ServerMergeInputError("source and target must identify different branches.");
42
+ }
43
+ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeReceipt> {
44
+ const data: MergeReceiptData = { mode: "apply", resolutionsFile: null, unlistedConflictPolicy: null, requestedIdentity: null, command: null, capability: { state: "not-checked", missingTokens: [] }, attempt: emptyMergeAttempt(), capture: { help: null, merge: null }, parse: null, observedChangesets: [], conflictPaths: [], counts: { changesetsReturned: 0, changesetsOmitted: 0, conflictsReturned: 0, conflictsOmitted: 0 }, createdChangeset: null, effect: "not-attempted", serverAliasEquivalence: "unverified", mergeLinkIdentity: "unverified", xlinkEffects: "unverified", wouldRun: false, remoteAnalysis: "not-performed" };
45
+ const completeness: MergeReceipt["completeness"] = { capture: "unknown", projection: true };
46
+ const finish = (outcome: MergeReceipt["outcome"], stage: "input" | "help" | "merge" | "producer", code?: string, message = "The server merge evidence could not be verified."): MergeReceipt => {
47
+ data.effect = outcome === "completed" ? "changeset-created" : outcome === "no-op" ? "not-proven" : outcome === "conflict" || outcome === "uncertain" ? "uncertain" : "not-attempted";
48
+ const ok = ["preflight", "analyzed", "completed", "no-op"].includes(outcome);
49
+ const dto = { schemaVersion: 1, action: "merge-branches", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok, outcome, completeness, data, ...(!ok ? { error: { code: code ?? outcome, stage, message } } : {}) } as MergeReceipt;
50
+ while (Buffer.byteLength(JSON.stringify(dto), "utf8") > 131072 && (data.conflictPaths.length || data.observedChangesets.length)) {
51
+ if (data.conflictPaths.length) { data.conflictPaths.pop(); data.counts.conflictsReturned--; data.counts.conflictsOmitted++; }
52
+ else { data.observedChangesets.pop(); data.counts.changesetsReturned--; data.counts.changesetsOmitted++; }
53
+ completeness.projection = false;
54
+ }
55
+ return dto;
56
+ };
57
+ let stage: "input" | "help" | "merge" = "input";
58
+ try {
59
+ validateServerMergeRequest(input);
60
+ const source = parseQualifiedServerBranch("source", input.source), target = parseQualifiedServerBranch("target", input.target);
61
+ data.mode = input.mode ?? "apply";
62
+ data.unlistedConflictPolicy = input.unlistedConflictPolicy ?? null;
63
+ if (input.fileConflictsResolutionsFile !== undefined) {
64
+ try { data.resolutionsFile = await inspectResolutionFile(input.fileConflictsResolutionsFile); }
65
+ catch (error) { throw new ServerMergeInputError(error instanceof Error && /^(Invalid resolution entry|Resolutions|Resolution)/.test(error.message) ? error.message : "Resolutions file or a result file is unavailable or invalid."); }
66
+ }
67
+ const seps = createServerMergeSeparators();
68
+ const command = ["merge", source.raw, `--to=${target.raw}`, ...(data.mode === "apply" ? ["--merge", `-c=${assertSafeServerMergeValue("message", input.message!)}`] : []), ...(data.resolutionsFile ? [`--fileconflictsresolutionsfile=${data.resolutionsFile.path}`] : []), ...(data.unlistedConflictPolicy ? [data.unlistedConflictPolicy === "source" ? "--keepsource" : "--keepdestination"] : []), "--nointeractiveresolution", "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
69
+ data.requestedIdentity = { source: source.raw, target: target.raw, branch: target.branch, repository: target.repository, server: target.server }; data.command = ["cm", ...command]; data.wouldRun = true;
70
+ if (input.preflight) return finish("preflight", "input");
71
+ if (getActiveAbortSignal()?.aborted) { data.attempt.aborted = true; return finish("failed", "merge", "aborted"); }
72
+ stage = "help";
73
+ const help = await captureServerMergeCommand(["help", "merge"], SERVER_MERGE_HELP_TIMEOUT_MS); data.capture.help = help.capture;
74
+ if (!help.capture.complete || help.attempt.exitCode !== 0 || help.attempt.aborted || help.attempt.timedOut || help.capture.stderrBytes) { data.capability.state = "unavailable"; return finish("failed", "help", "capability_unavailable"); }
75
+ const requiredTokens = [...serverMergeCapabilityTokens, ...(data.resolutionsFile ? ["--fileconflictsresolutionsfile"] : []), ...(data.unlistedConflictPolicy ? [data.unlistedConflictPolicy === "source" ? "--keepsource" : "--keepdestination"] : [])];
76
+ data.capability.missingTokens = requiredTokens.filter(t => !help.stdout!.includes(t));
77
+ if (data.capability.missingTokens.length) { data.capability.state = "missing"; completeness.capture = "complete"; return finish("unsupported", "help", "unsupported"); }
78
+ data.capability.state = "advertised";
79
+ // Revalidate the JSON after help so edited instructions are not silently submitted.
80
+ // Result content is caller-owned; callers must keep all inputs immutable until cm exits.
81
+ if (data.resolutionsFile) {
82
+ try {
83
+ const current = await inspectResolutionFile(data.resolutionsFile.path);
84
+ if (current.sha256 !== data.resolutionsFile.sha256) throw new Error("changed");
85
+ } catch { return finish("failed", "input", "invalid_request", "Resolution inputs changed or became unavailable before merge dispatch."); }
86
+ }
87
+ stage = "merge";
88
+ const obs = await captureServerMergeCommand(command, SERVER_MERGE_COMMAND_TIMEOUT_MS); data.attempt = obs.attempt; data.capture.merge = obs.capture;
89
+ completeness.capture = obs.capture.complete ? "complete" : "incomplete";
90
+ if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
91
+ const parsed = parseServerMergeOutput(obs.stdout ?? "", seps);
92
+ const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && r.fields[1]!.length <= 4096 && !serverMergeControlPattern.test(r.fields[1]!)).map(r => r.fields[1]!);
93
+ data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length };
94
+ // One aggregate evidence budget, not one budget per collection.
95
+ data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
96
+ data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
97
+ if (data.counts.changesetsOmitted || data.counts.conflictsOmitted) completeness.projection = false;
98
+ const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.mount === "/");
99
+ // Emitted server aliases are kept verbatim. Matching uses dispatch context,
100
+ // not an invented equivalence between requested and emitted server names.
101
+ const ambiguous = !obs.capture.complete || obs.failed || obs.attempt.aborted || obs.attempt.timedOut || obs.attempt.terminal !== "observed" || obs.attempt.exitCode === null || parsed.malformed || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1 || parsed.changesets.length > 0 && matching.length !== 1 || parsed.hasConflict && parsed.changesets.length > 0 || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
102
+ if (data.mode === "analyze") {
103
+ if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && !parsed.changesets.length && parsed.records.length > 0) {
104
+ data.remoteAnalysis = "performed";
105
+ return finish("analyzed", "merge");
106
+ }
107
+ return finish("uncertain", "merge", "uncertain", "Server conflict analysis could not be verified; no applying command was requested.");
108
+ }
109
+ if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
110
+ if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
111
+ if (!ambiguous && obs.attempt.exitCode !== 0 && parsed.hasConflict && !parsed.changesets.length) return finish("conflict", "merge", "conflict");
112
+ return finish("uncertain", "merge", "uncertain");
113
+ } catch (error) {
114
+ if (stage === "input") return finish("failed", "input", "invalid_request", error instanceof ServerMergeInputError ? error.message : "Invalid merge input.");
115
+ // Once merge collection is entered, an unexpected failure cannot prove nonstart.
116
+ if (stage === "merge") { if (data.attempt.state === "not-attempted") data.attempt.state = "unknown"; return finish("uncertain", "producer", "producer_failed"); }
117
+ data.capability.state = "unavailable"; return finish("failed", "help", "capability_unavailable");
118
+ }
119
+ }
120
+ export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
121
+ const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
122
+ const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- Do not retry automatically; inspect server state if an attempt may have started."}`;
123
+ const rendered = await formatServerMergeResult(format, dto.outcome, text, dto.data);
124
+ return rendered.length <= 24000 ? rendered : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; use the registered tool structuredContent for the bounded receipt." });
125
+ }
126
+ export const mergeBranches = tool({
127
+ description: "Inspect server merge conflicts with mode=analyze, then apply reviewed per-file JSON resolutions without a workspace or UI. Server aliases, merge links and Xlink effects remain unverified.",
128
+ args: { source: tool.schema.string().min(1).describe("Fully qualified source branch; branch/repository may not contain @; full server tail retained."), target: tool.schema.string().min(1).describe("Fully qualified distinct target in the same exact requested repository/server."), message: tool.schema.string().min(1).optional().describe("Required for apply; omit in analyze mode. Bounded nonempty comment, no editor fallback."), mode: tool.schema.enum(["apply", "analyze"]).optional().describe("Default apply creates a merge changeset. Analyze performs read-only remote conflict discovery without --merge; not command-only preflight."), fileConflictsResolutionsFile: tool.schema.string().min(1).optional().describe("Apply only: absolute existing UTF-8 JSON file with resolutions entries (exact conflict path plus keep or absolute readable resultFile). Inputs must remain immutable until completion."), unlistedConflictPolicy: tool.schema.enum(["source", "destination"]).optional().describe("Apply only: explicitly keep this contributor for conflicts absent from the JSON. Omit to retain noninteractive default behavior; never selected automatically."), preflight: tool.schema.boolean().optional().describe("Render intent only, zero commands; does not analyze remote conflicts."), format: outputFormatArg },
129
+ async execute(args) { const dto = await assembleServerMergeReceipt(args); if (!dto.ok && dto.error.stage === "input") throw new Error(dto.error.message); return presentServerMergeReceipt(dto, args.format); },
130
+ });
@@ -0,0 +1,90 @@
1
+ import { cmWhereEquals, cmWhereLike, escapeCmWhereValue } from "../domain/branches";
2
+ import { runCm } from "../execution/cm";
3
+ import { spawnAndCollect, getCmExecutable } from "../execution/process";
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "../execution/context";
5
+ import { runShelvesetListIdsCommand, ShelvesetListCommandError, SHELVESET_LIST_CAPTURE_LIMITS } from "../execution/shelveset-list-command";
6
+
7
+ export type ShelvesetListArgs = { owner?: string; commentLike?: string; dateFrom?: string; limit?: number; format?: string; dateFormat?: string; source?: "native" | "ids"; output?: "text" | "json"; maxItems?: number; workdir?: string };
8
+ export type ShelvesetListQuery = { owner: string | null; commentLike: string | null; dateFrom: string | null; limit: number | null };
9
+ export class ShelvesetListError extends Error {
10
+ constructor(readonly code: "invalid_identity" | "malformed_output" | "unsupported_query" | "invalid_producer_data" | "output_overflow") { super(`Shelveset list read failed (${code}); no reusable rows are available.`); }
11
+ }
12
+ export const SHELVESET_LIST_MAX_BYTES = 131072;
13
+ export const validShelvesetQueryString = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(s);
14
+ export const validShelvesetTemplate = (s: string) => s.length <= 4096 && !/[\u0000\uD800-\uDFFF]/u.test(s);
15
+ // Decimal strings retain exact integers beyond JavaScript's safe-number range.
16
+ export const validShelvesetId = (s: string) => s.length <= 20 && /^(?:0|[1-9][0-9]*)$/.test(s);
17
+ export function shelvesetListRequest(args: ShelvesetListArgs) {
18
+ for (const value of [args.owner, args.commentLike, args.dateFrom])
19
+ if (value !== undefined && (typeof value !== "string" || !validShelvesetQueryString(value))) throw new ShelvesetListError("invalid_identity");
20
+ for (const value of [args.format, args.dateFormat])
21
+ if (value !== undefined && (typeof value !== "string" || !validShelvesetTemplate(value))) throw new ShelvesetListError("invalid_producer_data");
22
+ if ((args.source !== undefined && args.source !== "native" && args.source !== "ids")
23
+ || (args.output !== undefined && args.output !== "text" && args.output !== "json")
24
+ || (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1))
25
+ || (args.maxItems !== undefined && (!Number.isSafeInteger(args.maxItems) || args.maxItems < 1 || args.maxItems > 500))) throw new ShelvesetListError("invalid_producer_data");
26
+ const source = args.source ?? "native";
27
+ if (source === "ids" && (args.format || args.dateFormat)) throw new ShelvesetListError("unsupported_query");
28
+ return { source, maxItems: args.maxItems ?? 100, query: { owner: args.owner ?? null, commentLike: args.commentLike ?? null, dateFrom: args.dateFrom ?? null, limit: args.limit ?? null } };
29
+ }
30
+ export function shelvesetListCommand(args: ShelvesetListArgs): string[] {
31
+ const { source, query } = shelvesetListRequest(args);
32
+ const where: string[] = [];
33
+ if (query.owner) where.push(cmWhereEquals("owner", query.owner));
34
+ if (query.commentLike) where.push(cmWhereLike("comment", query.commentLike));
35
+ if (query.dateFrom) where.push(`date >= '${escapeCmWhereValue(query.dateFrom)}'`);
36
+ return ["find", "shelve", ...(where.length ? [`where ${where.join(" and ")}`] : []),
37
+ ...(query.limit ? [`limit ${query.limit}`] : []),
38
+ ...(source === "native" && args.format ? [`--format=${args.format}`] : []),
39
+ ...(source === "native" && args.dateFormat ? [`--dateformat=${args.dateFormat}`] : []),
40
+ "--nototal", ...(source === "ids" ? ["--format={shelveid}", "--encoding=utf-8"] : [])];
41
+ }
42
+ export function parseShelvesetListIds(bytes: Uint8Array): { ids: string[]; duplicateRecords: number } {
43
+ if (bytes.byteLength > SHELVESET_LIST_CAPTURE_LIMITS.stdoutBytes) throw new ShelvesetListCommandError("capture_incomplete");
44
+ if (bytes.some(byte => byte > 127)) throw new ShelvesetListError("invalid_identity");
45
+ if (!bytes.length) return { ids: [], duplicateRecords: 0 };
46
+ const ids = Buffer.from(bytes).toString("ascii").split(/\r?\n/);
47
+ if (ids.at(-1) === "") ids.pop();
48
+ if (ids.length > SHELVESET_LIST_CAPTURE_LIMITS.records) throw new ShelvesetListCommandError("capture_incomplete");
49
+ if (ids.some(id => !validShelvesetId(id))) throw new ShelvesetListError("invalid_identity");
50
+ return { ids, duplicateRecords: ids.length - new Set(ids).size };
51
+ }
52
+ export type ShelvesetListObservation = ReturnType<typeof shelvesetListRequest> & ({ source: "native"; rawResult: string } | { source: "ids"; ids: string[]; duplicateRecords: number });
53
+ export async function assembleShelvesetListObservation(args: ShelvesetListArgs, typedNative = false): Promise<ShelvesetListObservation> {
54
+ const request = shelvesetListRequest(args), command = shelvesetListCommand(args);
55
+ if (request.source === "ids") {
56
+ const bytes = await runShelvesetListIdsCommand(command, args.workdir);
57
+ if (getActiveAbortSignal()?.aborted) throw new ShelvesetListCommandError("aborted");
58
+ const parsed = parseShelvesetListIds(bytes);
59
+ if (getActiveAbortSignal()?.aborted) throw new ShelvesetListCommandError("aborted");
60
+ return { ...request, source: "ids", ...parsed };
61
+ }
62
+ if (!typedNative) return { ...request, source: "native", rawResult: await runCm(command, args.workdir) };
63
+ const signal = getActiveAbortSignal();
64
+ if (signal?.aborted) throw new ShelvesetListCommandError("aborted");
65
+ let result;
66
+ try { result = await spawnAndCollect(getCmExecutable(), command, args.workdir ?? process.cwd(), undefined, signal, commandExecutionStorage.getStore()); }
67
+ catch { throw new ShelvesetListCommandError(signal?.aborted ? "aborted" : "command_failed"); }
68
+ if (result.aborted || signal?.aborted) throw new ShelvesetListCommandError("aborted");
69
+ if (result.timedOut || result.stdoutTruncated || result.stderrTruncated) throw new ShelvesetListCommandError("capture_incomplete");
70
+ if (result.exitCode !== 0) throw new ShelvesetListCommandError("command_failed");
71
+ return { ...request, source: "native", rawResult: [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || "(no output)" };
72
+ }
73
+ export function shelvesetListPayload(observation: ShelvesetListObservation) {
74
+ const common = { schemaVersion: 1, action: "shelveset-list", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok: true } as const;
75
+ const data = { scope: "workspace_repository", qualifierVerified: false, query: observation.query, limits: { maxItems: observation.maxItems, idDigits: 20, queryCodeUnits: 4096, compactUtf8Bytes: SHELVESET_LIST_MAX_BYTES } } as const;
76
+ const payload = observation.source === "native"
77
+ ? { ...common, completeness: { read: "unknown", capture: "unknown", projection: false }, data: { ...data, mode: "native", basis: "find_shelve_native", rows: null, counts: null, countBasis: "unavailable" } }
78
+ : { ...common, completeness: { read: "complete", capture: "complete", projection: observation.ids.length <= observation.maxItems }, data: { ...data, mode: "ids", basis: "find_shelve_id_utf8", diagnostics: { duplicateRecords: observation.duplicateRecords }, rows: observation.ids.slice(0, observation.maxItems).map(id => ({ id, shelveset: `sh:${id}` })), countBasis: "observed_query_rows", counts: { observed: observation.ids.length, returned: Math.min(observation.ids.length, observation.maxItems), omitted: Math.max(0, observation.ids.length - observation.maxItems), excluded: 0 } } };
79
+ if (Buffer.byteLength(JSON.stringify(payload), "utf8") > SHELVESET_LIST_MAX_BYTES) throw new ShelvesetListError("output_overflow");
80
+ return payload;
81
+ }
82
+ export function presentShelvesetListObservation(observation: ShelvesetListObservation): string {
83
+ if (observation.source === "native") return observation.rawResult;
84
+ const ids = observation.ids.slice(0, observation.maxItems), omitted = observation.ids.length - ids.length;
85
+ return [...(ids.length ? ids.map(id => `sh:${id}`) : ["(no observed query rows)"]), ...(omitted ? [`(${omitted} observed query rows omitted from presentation.)`] : [])].join("\n");
86
+ }
87
+ export async function executeShelvesetList(args: ShelvesetListArgs): Promise<string> {
88
+ const observation = await assembleShelvesetListObservation(args);
89
+ return args.output === "json" ? JSON.stringify(shelvesetListPayload(observation)) : presentShelvesetListObservation(observation);
90
+ }
@@ -0,0 +1,90 @@
1
+ import { tool } from "../tool-definition";
2
+ import { workdirArg } from "./arguments";
3
+ import { assembleObjectWriteReceipt, presentObjectWriteReceipt } from "./object-write-receipt";
4
+ import { executeShelvesetList } from "./shelveset-list";
5
+ import { assembleObjectDeleteReceipt, presentObjectDeleteReceipt } from "./object-delete-receipt";
6
+ import { outputFormatArg } from "../presentation/results";
7
+
8
+ export const shelvesetCreate = tool({
9
+ description: "Create a shelveset (cm shelveset create).",
10
+ args: {
11
+ comment: tool.schema.string().optional().describe("Shelveset comment."),
12
+ commentsFile: tool.schema.string().optional().describe("File path containing shelveset comment."),
13
+ paths: tool.schema.array(tool.schema.string()).optional().describe("Optional item paths to shelve."),
14
+ all: tool.schema.boolean().optional().describe("Include changed, moved, and deleted items."),
15
+ dependencies: tool.schema.boolean().optional().describe("Include local change dependencies."),
16
+ summaryFormat: tool.schema.boolean().optional().describe("Print only created shelveset spec for automation."),
17
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/conflict/state analysis."),
18
+ output: outputFormatArg,
19
+ workdir: workdirArg,
20
+ },
21
+ async execute(args) {
22
+ const dto = await assembleObjectWriteReceipt("shelveset-create", Object.freeze({...args}));
23
+ const result = presentObjectWriteReceipt(dto);
24
+ if (!dto.ok) throw new Error(result);
25
+ return result;
26
+ },
27
+ });
28
+
29
+ export const shelvesetApply = tool({
30
+ description: "Apply a shelveset (cm shelveset apply).",
31
+ args: {
32
+ shelveset: tool.schema.string().min(1).describe("Shelveset spec to apply (for example, sh:3)."),
33
+ changePaths: tool.schema.array(tool.schema.string()).optional().describe("Optional shelve server paths to apply."),
34
+ preview: tool.schema.boolean().optional().describe("Preview changes without applying them."),
35
+ dontCheckout: tool.schema.boolean().optional().describe("Keep applied changes as local modifications without checkout."),
36
+ comparisonMethod: tool.schema.enum([
37
+ "ignoreeol",
38
+ "ignorewhitespaces",
39
+ "ignoreeolandwhitespaces",
40
+ "recognizeall",
41
+ ]).optional().describe("Comparison method used when applying changes."),
42
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/conflict/state analysis."),
43
+ output: outputFormatArg,
44
+ workdir: workdirArg,
45
+ },
46
+ async execute(args) {
47
+ const dto = await assembleObjectWriteReceipt("shelveset-apply", Object.freeze({...args}));
48
+ const result = presentObjectWriteReceipt(dto);
49
+ if (!dto.ok) throw new Error(result);
50
+ return result;
51
+ },
52
+ });
53
+
54
+ export const shelvesetDelete = tool({
55
+ description: "Delete a shelveset (cm shelveset delete).",
56
+ args: {
57
+ shelveset: tool.schema.string().min(1).describe("Shelveset spec to delete (for example, sh:3)."),
58
+ preflight: tool.schema.boolean().optional().describe("Command-only zero-CLI preview; no existence/readiness analysis."),
59
+ format: outputFormatArg,
60
+ workdir: workdirArg,
61
+ },
62
+ async execute(args)
63
+ {
64
+ const request = Object.freeze({...args});
65
+ const dto = await assembleObjectDeleteReceipt("shelveset-delete",request);
66
+ const result = presentObjectDeleteReceipt(dto);
67
+ if (!dto.ok && request.preflight !== true) throw new Error(result);
68
+ return result;
69
+ },
70
+ });
71
+
72
+ export const shelvesetList = tool({
73
+ description: "List shelvesets using cm find shelve.",
74
+ args: {
75
+ owner: tool.schema.string().optional().describe("Filter by shelveset owner."),
76
+ commentLike: tool.schema.string().optional().describe("Filter by shelveset comment pattern (supports % wildcard)."),
77
+ limit: tool.schema.number().int().min(1).optional().describe("Maximum number of shelvesets to return."),
78
+ dateFrom: tool.schema.string().optional().describe("Filter shelvesets created on or after this date/date constant."),
79
+ source: tool.schema.enum(["native", "ids"]).optional().describe("Native output (default) or one canonical shelveset-ID query."),
80
+ output: tool.schema.enum(["text", "json"]).optional().describe("Presentation only; does not change query source."),
81
+ maxItems: tool.schema.number().int().min(1).max(500).optional().describe("Projected record cap (default 100); separate from CLI limit."),
82
+ format: tool.schema.string().optional().describe("Format string for query output; conflicts with ids source."),
83
+ dateFormat: tool.schema.string().optional().describe("Date format for query output."),
84
+ workdir: workdirArg,
85
+ },
86
+ async execute(args)
87
+ {
88
+ return executeShelvesetList(args);
89
+ },
90
+ });
@@ -0,0 +1,129 @@
1
+ import { tool } from "../tool-definition";
2
+ import { workdirArg } from "./arguments";
3
+ import { MACHINE_READABLE_STATUS_MAX_ITEMS, MACHINE_READABLE_STATUS_DEFAULT_MAX_ITEMS, STATUS_FIELD_SEPARATOR, diagnoseMachineReadablePendingItems, toMachineReadableStatusItems, toMachineReadableStatusSummary, summarizeShortStatus } from "../domain/pending";
4
+ import { outputFormatArg, toStructuredResult, formatStatusText } from "../presentation/results";
5
+ import { runStatusCommand, StatusCommandError } from "../execution/status-command";
6
+ import { getActiveAbortSignal } from "../execution/context";
7
+ import { analyzeMergeStatusOutput } from "../domain/merge-output";
8
+ import { parseStatusXml } from "../domain/status-xml";
9
+ export { StatusXmlError } from "../domain/status-xml";
10
+ import { runStatusXmlCommand } from "../execution/status-xml-command";
11
+
12
+ export class StatusOptionError extends Error {
13
+ constructor() { super("Status source options are conflicting or unsupported. XML does not support short or base revision requests."); }
14
+ }
15
+ export const resolveStatusSource = (args: StatusObservationOptions): "text" | "machine" | "xml" => {
16
+ const source = args.source ?? (args.machineReadable ? "machine" : "text");
17
+ if (!["text", "machine", "xml"].includes(source) || (source === "machine" && args.machineReadable === false) || (source !== "machine" && args.machineReadable === true) || (source === "xml" && (args.short || args.includeRevId))) throw new StatusOptionError();
18
+ return source;
19
+ };
20
+
21
+ export type StatusObservationOptions = {
22
+ workdir?: string;
23
+ short?: boolean;
24
+ includeRevId?: boolean;
25
+ machineReadable?: boolean;
26
+ source?: "text" | "machine" | "xml";
27
+ };
28
+
29
+ // Internal observations precede presentation; no version lookup or rendered-text parsing.
30
+ export const assembleStatusObservation = async (args: StatusObservationOptions) =>
31
+ {
32
+ const source = resolveStatusSource(args);
33
+ if (source === "xml") {
34
+ const bytes = await runStatusXmlCommand(args.workdir);
35
+ if (getActiveAbortSignal()?.aborted) throw new StatusCommandError("aborted");
36
+ const parsed = parseStatusXml(bytes);
37
+ // Whole-document DOM parsing is synchronous; cancellation is checked at its boundaries.
38
+ if (getActiveAbortSignal()?.aborted) throw new StatusCommandError("aborted");
39
+ return { kind: "xml" as const, ...parsed, capture: "complete" as const, requestedShort: false as const };
40
+ }
41
+ const cmdArgs: string[] = ["status"];
42
+ if (args.short) cmdArgs.push("--short");
43
+ if (args.includeRevId) cmdArgs.push("--includeRevId");
44
+ if (source === "machine")
45
+ {
46
+ // Package-owned separators make paths with whitespace unambiguous, and
47
+ // revision IDs let agents safely identify the base of moved/deleted items.
48
+ if (!cmdArgs.includes("--includeRevId")) cmdArgs.push("--includeRevId");
49
+ cmdArgs.push("--machinereadable", `--fieldseparator=${STATUS_FIELD_SEPARATOR}`);
50
+ const { output, capture } = await runStatusCommand(cmdArgs, args.workdir, true);
51
+ const cwd = args.workdir ?? process.cwd();
52
+ return { kind: "machine" as const, output, capture, cwd, ...diagnoseMachineReadablePendingItems(output, cwd), requestedShort: args.short ?? false };
53
+ }
54
+ const primary = await runStatusCommand(cmdArgs, args.workdir);
55
+ const secondary = await runStatusCommand(["status", "--short"], args.workdir, true);
56
+ const output = primary.output;
57
+ const shortOutput = secondary.output;
58
+ return {
59
+ kind: "standard" as const,
60
+ capture: secondary.capture,
61
+ output,
62
+ shortOutput,
63
+ summary: summarizeShortStatus(shortOutput),
64
+ mergeState: analyzeMergeStatusOutput(output),
65
+ usedShortFlag: args.short ?? false,
66
+ };
67
+ };
68
+ export type StatusObservation = Awaited<ReturnType<typeof assembleStatusObservation>>;
69
+
70
+ export const presentStatusObservation = async (observation: StatusObservation, args: { format?: "text" | "json"; maxItems?: number; includeRaw?: boolean; workdir?: string }) =>
71
+ {
72
+ const format = args.format ?? "text";
73
+ if (observation.kind === "xml") {
74
+ const items = observation.items.slice(0, args.maxItems ?? MACHINE_READABLE_STATUS_DEFAULT_MAX_ITEMS);
75
+ const source = { transport: "status_xml", encoding: "utf-8", pathBasis: "absolute", scope: "workspace", baseRevisionAvailability: "unavailable" };
76
+ const warning = "Raw XML contains private workspace/repository/server metadata.";
77
+ const text = [`Status XML (synthesized; workspace scope; base revisions unavailable): ${observation.items.length} records.`, ...items.map(row => `${row.statusCode} ${row.sourcePath ? `${row.sourcePath} -> ` : ""}${row.path}${row.isDirectory ? " [directory]" : ""}`), ...(items.length < observation.items.length ? [`${observation.items.length - items.length} records omitted.`] : [])].join("\n");
78
+ if (format === "json") return JSON.stringify({ ok: true, action: "status", data: { mode: "xml", presentation: "synthesized", source, items, summary: observation.summary, itemCount: { parsed: observation.items.length, returned: items.length, omitted: observation.items.length - items.length }, ...(args.includeRaw ? { rawXml: observation.output, rawWarning: warning } : {}) } });
79
+ return args.includeRaw ? `${text}\n\n${warning}\nRaw status_xml (utf-8):\n${observation.output}` : text;
80
+ }
81
+ if (observation.kind === "machine")
82
+ {
83
+ const { output: machineOutput, pendingItems, cwd } = observation;
84
+ const maxItems = args.maxItems ?? MACHINE_READABLE_STATUS_DEFAULT_MAX_ITEMS;
85
+ const returnedItems = pendingItems.slice(0, maxItems);
86
+ return toStructuredResult(
87
+ "status",
88
+ format,
89
+ machineOutput,
90
+ {
91
+ machineReadable: true,
92
+ items: toMachineReadableStatusItems(returnedItems),
93
+ itemCount: {
94
+ total: pendingItems.length,
95
+ returned: returnedItems.length,
96
+ omitted: pendingItems.length - returnedItems.length,
97
+ },
98
+ summary: toMachineReadableStatusSummary(pendingItems, cwd),
99
+ ...(args.includeRaw ? { rawOutput: machineOutput } : {}),
100
+ },
101
+ args.workdir,
102
+ );
103
+ }
104
+ const { output, shortOutput, summary, mergeState, usedShortFlag } = observation;
105
+ const textOutput = formatStatusText(output, mergeState);
106
+ return toStructuredResult(
107
+ "status", format, textOutput,
108
+ { rawOutput: output, shortOutput, summary, mergeState, usedShortFlag },
109
+ args.workdir,
110
+ );
111
+ };
112
+
113
+ export const status = tool({
114
+ description: "Show Plastic SCM workspace status (cm status).",
115
+ args: {
116
+ workdir: workdirArg,
117
+ includeRevId: tool.schema.boolean().optional().describe("Include revision IDs in the status output when supported."),
118
+ machineReadable: tool.schema.boolean().optional().describe("Return parsed, machine-readable pending status records when supported."),
119
+ source: tool.schema.enum(["text", "machine", "xml"]).optional().describe("Explicit status source; omitted preserves machineReadable. XML is UTF-8, workspace-scoped, absolute paths, no short/base revisions, with synthesized presentation."),
120
+ maxItems: tool.schema.number().int().min(1).max(MACHINE_READABLE_STATUS_MAX_ITEMS).optional().describe(`Maximum parsed items in machine-readable JSON (default ${MACHINE_READABLE_STATUS_DEFAULT_MAX_ITEMS}, maximum ${MACHINE_READABLE_STATUS_MAX_ITEMS}).`),
121
+ includeRaw: tool.schema.boolean().optional().describe("Include raw machine output in JSON diagnostics, or bounded XML with a private metadata warning. Never included in the compact native DTO."),
122
+ short: tool.schema.boolean().optional().describe("Use short status output."),
123
+ format: outputFormatArg,
124
+ },
125
+ async execute(args)
126
+ {
127
+ return presentStatusObservation(await assembleStatusObservation(args), args);
128
+ },
129
+ });