@aefree/pi-plastic 0.8.3 → 0.8.5

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.
@@ -1,10 +1,12 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { lstat, open, realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { randomBytes, createHash } from "node:crypto";
2
4
  import { assertAbsoluteResolutionPath, inspectResolutionFile } from "../domain/server-merge-resolutions";
3
5
  import { getActiveAbortSignal } from "../execution/context";
4
6
  import { captureServerMergeCommand } from "../execution/server-merge-command";
5
7
  import { tool } from "../tool-definition";
6
8
  import { outputFormatArg, formatServerMergeResult } from "../presentation/results";
7
- import { parseServerMergeOutput } from "../domain/merge-output";
9
+ import { isSafeServerMergePath, parseServerMergeOutput } from "../domain/merge-output";
8
10
  import { emptyMergeAttempt, splitServerBranch, type MergeReceipt, type MergeReceiptData, type ServerMergeRequest } from "../domain/server-merge-contract";
9
11
  export { splitServerBranch };
10
12
  export type { MergeReceipt } from "../domain/server-merge-contract";
@@ -14,7 +16,7 @@ export const SERVER_MERGE_OUTPUT_LIMIT = 32768, SERVER_MERGE_HELP_TIMEOUT_MS = 3
14
16
  export const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
15
17
  export const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
16
18
  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.`);
19
+ if (typeof value !== "string" || !value.trim() || value.length > 4096 || (name === "message" ? /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) : serverMergeControlPattern.test(value))) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of unsupported controls or malformed Unicode; only comments may contain CR/LF.`);
18
20
  return value.trim();
19
21
  }
20
22
  export function parseQualifiedServerBranch(name: string, value: string): QualifiedServerBranch {
@@ -40,6 +42,41 @@ export function validateServerMergeRequest(input: unknown): asserts input is Ser
40
42
  if (s.repository !== t.repository || s.server !== t.server) throw new ServerMergeInputError("source and target must identify the same exact repository and server.");
41
43
  if (s.branch === t.branch) throw new ServerMergeInputError("source and target must identify different branches.");
42
44
  }
45
+ // Only the observed resultFile-only candidate closeout uses this bounded snapshot.
46
+ // It verifies caller inputs, not the bytes stored by the server; callers still own
47
+ // immutability during dispatch and point-in-time races remain possible.
48
+ type ResolutionSnapshot = { digest: string; paths: string[] };
49
+ async function snapshotResolutionInputs(path: string): Promise<ResolutionSnapshot> {
50
+ const bounded = async (path: string, cap: number): Promise<Buffer> => {
51
+ const st = await lstat(path);
52
+ const lexical = resolve(path), physical = await realpath(path);
53
+ const key = (s: string) => process.platform === "win32" ? s.toLowerCase() : s;
54
+ if (!st.isFile() || st.isSymbolicLink() || key(lexical) !== key(physical) || st.size > cap) throw Error("invalid");
55
+ const handle = await open(path, "r");
56
+ try {
57
+ const before = await handle.stat();
58
+ if (!before.isFile() || before.dev !== st.dev || before.ino !== st.ino || before.size !== st.size || before.mtimeMs !== st.mtimeMs || before.ctimeMs !== st.ctimeMs) throw Error("unstable");
59
+ const bytes = Buffer.alloc(cap + 1); let length = 0;
60
+ while (length < bytes.length) { const r = await handle.read(bytes, length, bytes.length - length, null); if (!r.bytesRead) break; length += r.bytesRead; }
61
+ const after = await handle.stat(), final = await lstat(path);
62
+ if (length > cap || length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || final.isSymbolicLink() || final.ino !== before.ino || final.dev !== before.dev || final.size !== before.size || final.mtimeMs !== before.mtimeMs || final.ctimeMs !== before.ctimeMs) throw Error("unstable");
63
+ return bytes.subarray(0, length);
64
+ } finally { await handle.close(); }
65
+ };
66
+ const bytes = await bounded(path, 1024 * 1024);
67
+ const json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
68
+ if (!Array.isArray(json.resolutions) || json.resolutions.length > 500) throw Error("invalid");
69
+ const hash = createHash("sha256").update(bytes), paths: string[] = [], keys = new Set<string>();
70
+ let remaining = 4 * 1024 * 1024;
71
+ for (const entry of json.resolutions) {
72
+ if (typeof entry.path !== "string" || !isSafeServerMergePath(entry.path) || keys.has(entry.path.toLowerCase()) || typeof entry.resultFile !== "string" || Object.keys(entry).some(k => !["path", "resultFile"].includes(k))) throw Error("invalid");
73
+ assertAbsoluteResolutionPath(entry.resultFile);
74
+ keys.add(entry.path.toLowerCase()); paths.push(entry.path);
75
+ const result = await bounded(entry.resultFile, remaining); remaining -= result.length;
76
+ hash.update(createHash("sha256").update(result).digest());
77
+ }
78
+ return { digest: hash.digest("hex"), paths };
79
+ }
43
80
  export async function assembleServerMergeReceipt(input: unknown): Promise<MergeReceipt> {
44
81
  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
82
  const completeness: MergeReceipt["completeness"] = { capture: "unknown", projection: true };
@@ -54,6 +91,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
54
91
  }
55
92
  return dto;
56
93
  };
94
+ let resolutionSnapshot: ResolutionSnapshot | null = null;
57
95
  let stage: "input" | "help" | "merge" = "input";
58
96
  try {
59
97
  validateServerMergeRequest(input);
@@ -64,6 +102,10 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
64
102
  try { data.resolutionsFile = await inspectResolutionFile(input.fileConflictsResolutionsFile); }
65
103
  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
104
  }
105
+ if (data.resolutionsFile && data.resolutionsFile.resultFiles === data.resolutionsFile.entries && !data.unlistedConflictPolicy) {
106
+ try { resolutionSnapshot = await snapshotResolutionInputs(data.resolutionsFile.path); }
107
+ catch { throw new ServerMergeInputError("Resolution result inputs must be stable regular nonsymlink files with canonical unique conflict paths and at most 4 MiB aggregate result bytes."); }
108
+ }
67
109
  const seps = createServerMergeSeparators();
68
110
  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
111
  data.requestedIdentity = { source: source.raw, target: target.raw, branch: target.branch, repository: target.repository, server: target.server }; data.command = ["cm", ...command]; data.wouldRun = true;
@@ -81,7 +123,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
81
123
  if (data.resolutionsFile) {
82
124
  try {
83
125
  const current = await inspectResolutionFile(data.resolutionsFile.path);
84
- if (current.sha256 !== data.resolutionsFile.sha256) throw new Error("changed");
126
+ if (current.sha256 !== data.resolutionsFile.sha256 || resolutionSnapshot && (await snapshotResolutionInputs(data.resolutionsFile.path)).digest !== resolutionSnapshot.digest) throw new Error("changed");
85
127
  } catch { return finish("failed", "input", "invalid_request", "Resolution inputs changed or became unavailable before merge dispatch."); }
86
128
  }
87
129
  stage = "merge";
@@ -89,16 +131,24 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
89
131
  completeness.capture = obs.capture.complete ? "complete" : "incomplete";
90
132
  if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
91
133
  const parsed = parseServerMergeOutput(obs.stdout ?? "", seps, data.mode);
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, warningsObserved: parsed.warningsObserved };
134
+ const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && isSafeServerMergePath(r.fields[1]!)).map(r => r.fields[1]!);
135
+ data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved, unknownOperationClasses: parsed.unknownOperations, resolvedConflictsVerified: 0 };
94
136
  // One aggregate evidence budget, not one budget per collection.
95
137
  data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
96
138
  data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
97
139
  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 === "/");
140
+ const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.server === target.server && c.mount === "/");
141
+ let resolvedCandidates = false;
142
+ if (resolutionSnapshot && parsed.hasConflict && data.resolutionsFile) {
143
+ try {
144
+ const current = await snapshotResolutionInputs(data.resolutionsFile.path);
145
+ resolvedCandidates = current.digest === resolutionSnapshot.digest && paths.length === resolutionSnapshot.paths.length && new Set(paths).size === paths.length && paths.every(p => resolutionSnapshot!.paths.includes(p));
146
+ } catch { /* Changed/unavailable inputs retain effect uncertainty. */ }
147
+ }
148
+ if (resolvedCandidates) data.parse.resolvedConflictsVerified = paths.length;
99
149
  // Emitted server aliases are kept verbatim. Matching uses dispatch context,
100
150
  // 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);
151
+ 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 && !resolvedCandidates || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
102
152
  if (data.mode === "analyze") {
103
153
  if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && !parsed.changesets.length && parsed.records.length > 0) {
104
154
  data.remoteAnalysis = "performed";
@@ -107,7 +157,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
107
157
  const reason = parsed.malformed ? "Malformed server analysis framing or fields." : parsed.unknownOperations.length ? "Unsupported server analysis operation or subtype." : "Server conflict analysis could not be verified.";
108
158
  return finish("uncertain", "merge", "uncertain", reason + " No applying command was requested.");
109
159
  }
110
- if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
160
+ if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && (!parsed.hasConflict || resolvedCandidates) && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
111
161
  if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
112
162
  if (!ambiguous && obs.attempt.exitCode !== 0 && parsed.hasConflict && !parsed.changesets.length) return finish("conflict", "merge", "conflict");
113
163
  return finish("uncertain", "merge", "uncertain");
@@ -121,11 +171,12 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
121
171
  export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
122
172
  const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
123
173
  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- Discard CHG warnings observed: ${dto.data.parse?.warningsObserved ?? "unknown"} (content equality unverified)\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 ? "" : "- " + dto.error.message + "\n- Do not retry automatically; inspect server state if an attempt may have started."}`;
124
- const rendered = await formatServerMergeResult(format, dto.outcome, text, dto.data);
174
+ const diagnostics = `\n- Unsupported operation classes: ${dto.data.parse?.unknownOperationClasses.join(", ") || "none"}\n- Conflict candidates with verified result inputs: ${dto.data.parse?.resolvedConflictsVerified ?? 0} (not independent server-byte verification)`;
175
+ const rendered = await formatServerMergeResult(format, dto.outcome, text + diagnostics, dto.data);
125
176
  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." });
126
177
  }
127
178
  export const mergeBranches = tool({
128
179
  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.",
129
- 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 },
180
+ 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; CR/LF allowed, other controls rejected. 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 },
130
181
  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); },
131
182
  });
@@ -1,3 +1,4 @@
1
+ import { parseMergedPending, correlateMergedApply, admitMergedStandardStatus } from "../domain/merged-workspace";
1
2
  import { randomBytes } from "node:crypto";
2
3
  import { win32 } from "node:path";
3
4
  import { parseCopiedPending, admitCopiedStandardStatus, copiedPendingArgv, copiedPathKey } from "../domain/copied-merge";
@@ -22,21 +23,23 @@ export async function assembleWorkspaceMergeReceipt(action:MergeAction,args:unkn
22
23
  active="apply";const apply=await captureWorkspaceMergeCommand([...data.intendedArgv],cwd);data.apply={attempt:apply.attempt,capture:apply.capture,admission:"unsupported"};active=null;data.effect=["started","unknown"].includes(apply.attempt.state)?"uncertain":"not-attempted";
23
24
  if(!apply.capture.complete||apply.failed||apply.attempt.state!=="started")return workspaceMergeFailure(action,data,apply.attempt.aborted?"aborted":data.effect==="not-attempted"?"launch_failed":"uncertain","Merge capture/start/completion is not established; do not retry automatically.");
24
25
  data.commandCompleted=apply.attempt.exitCode===0;
25
- const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
26
+ const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="merged-candidate"?"merged-machine":parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
26
27
  if(parsed.protocol==="file-conflict"){data.checkinReadiness="blocked";return workspaceMergeFailure(action,data,"conflict","Observed file-conflict records; checkin blocked. Earlier effects are not disproven.");}
27
28
  if(!data.commandCompleted)return workspaceMergeFailure(action,data,"uncertain","Merge exited nonzero; workspace effects may already exist.");
28
29
  if(parsed.protocol==="unsupported")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge completed; applying profile unadmitted. Inspect apply.admission/protocol; verify loaded package revision and retain sanitized producer evidence. Readiness unknown; do not reapply automatically or infer rollback.");
29
- const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=copied?"copied-machine":"short";
30
- if(copied&&process.platform!=="win32")return workspaceMergeFailure(action,data,"unsupported_readiness","Copied readiness is sourced only on the observed Windows client.");
31
- active="shortStatus";const short=await captureWorkspaceMergeCommand(copied?[...copiedPendingArgv]:["status","--short"],cwd);data.shortStatus={attempt:short.attempt,capture:short.capture,admission:"unsupported"};active=null;
30
+ const merged=parsed.protocol==="merged-candidate";const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=merged?"merged-machine":copied?"copied-machine":"short";
31
+ if(merged&&(strategy!=="auto"||cherry||forced))return workspaceMergeFailure(action,data,"unsupported_readiness","Merged readiness is sourced only for automatic non-forced, non-cherrypicked apply; do not reapply automatically.");
32
+ if((copied||merged)&&process.platform!=="win32")return workspaceMergeFailure(action,data,"unsupported_readiness","Copied readiness is sourced only on the observed Windows client.");
33
+ active="shortStatus";const short=await captureWorkspaceMergeCommand((copied||merged)?[...copiedPendingArgv]:["status","--short"],cwd);data.shortStatus={attempt:short.attempt,capture:short.capture,admission:"unsupported"};active=null;
32
34
  if(!short.capture.complete||short.failed||short.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; pending read failed/incomplete. Checkin readiness unknown.");
35
+ const mergedPending=merged?parseMergedPending(short.stdout!,cwd):null;
33
36
  const pending=copied?parseCopiedPending(short.stdout!,cwd):null;
34
37
  const copiedKeys=pending?.paths.map(copiedPathKey)??[];
35
38
  const coherent=!!pending&&parsed.copiedPaths.length===copiedKeys.length&&new Set(parsed.copiedPaths.map(copiedPathKey)).size===copiedKeys.length&&parsed.copiedPaths.every(p=>copiedKeys.includes(copiedPathKey(p)))&&new Set(parsed.addedPaths.map(p=>copiedPathKey(win32.join(cwd,p)))).size===copiedKeys.length&&parsed.addedPaths.every(p=>p.startsWith("/")&&!p.includes("\\")&&p.slice(1).split("/").every(v=>v!==""&&v!=="."&&v!==".."&&!/[?\uFFFD]/u.test(v))&&copiedKeys.includes(copiedPathKey(win32.join(cwd,p))));
36
- data.shortStatus.admission=(copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
39
+ data.shortStatus.admission=(merged?!!mergedPending&&correlateMergedApply(parsed.declarations!,mergedPending,cwd):copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
37
40
  active="fullStatus";const full=await captureWorkspaceMergeCommand(["status"],cwd);data.fullStatus={attempt:full.attempt,capture:full.capture,admission:"unsupported"};active=null;
38
41
  if(!full.capture.complete||full.failed||full.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; merge-state read failed/incomplete. Checkin readiness unknown.");
39
- data.fullStatus.admission=(copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
42
+ data.fullStatus.admission=(merged?!!mergedPending&&admitMergedStandardStatus(full.stdout!,mergedPending,source,cwd):copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
40
43
  // No warning/unknown stderr can authorize compound checkin.
41
44
  if(apply.stderr!=="")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; applying stderr is outside the admitted warning profile. Checkin paused; prior effects remain possible. Do not reapply automatically.");
42
45
  if(data.shortStatus.admission!=="admitted")return workspaceMergeFailure(action,data,"unsupported_readiness",copied?"Merge command completed; copied pending/ADD/path cardinality correlation is unadmitted (shortStatus). Checkin paused; inspect separately authorized pending evidence without reapplying merge.":"Merge command completed; shortStatus pending/warning evidence is unadmitted. Checkin paused; inspect separately authorized pending evidence without reapplying merge.");
@@ -10,10 +10,10 @@ const cs = object({ id: Type.String({ pattern: "^(0|[1-9][0-9]{0,19})$" }), bran
10
10
  const summary = object({ totalPending: uint(499), tracked: uint(499), private: uint(499), added: uint(499), changed: uint(499), moved: uint(499), deleted: uint(499), other: uint(499) });
11
11
  const attempt = object({ state: enumOf(["not-attempted", "not-started", "started", "unknown"]), terminal: enumOf(["not-observed", "observed"]), exitCode: Type.Union([uint(2147483647), Type.Null()]), aborted: Type.Boolean(), timedOut: Type.Boolean() });
12
12
  const capture = object({ stdoutBytes: uint(), stderrBytes: uint(), stdoutRetainedBytes: uint(65536), stderrRetainedBytes: uint(16384), truncated: Type.Boolean(), complete: Type.Boolean(), validUtf8: Type.Boolean() });
13
- const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
13
+ const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic", "merge-readiness"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
14
14
  const paths = Type.Array(str(), { maxItems: 100 });
15
15
  const reasons = Type.Array(object({ path: str(), reason: str(256) }), { maxItems: 100 });
16
- const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
16
+ const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP", "RP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
17
17
  const header = { schemaVersion: Type.Literal(1), action: Type.Literal("checkin"), provenance: object({ source: Type.Literal("plastic"), producer: Type.Literal("@aefree/pi-plastic"), contentTrust: Type.Literal("external") }), completeness: object({ capture: enumOf(["complete", "incomplete", "unknown"]), projection: Type.Boolean() }) };
18
18
  const error = object({ code: str(64), stage: enumOf(["input", "pending", "checkin", "producer"]), message: str(512) });
19
19
  export const checkinOutputSchema = Type.Unsafe<CheckinReceipt>(Type.Union([
@@ -38,6 +38,7 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
38
38
  for (const s of d.steps) {
39
39
  const a = s.attempt, c = s.capture;
40
40
  if (a.exitCode !== null && a.terminal !== "observed" || c.stdoutRetainedBytes > c.stdoutBytes || c.stderrRetainedBytes > c.stderrBytes || c.complete && (!c.validUtf8 || c.truncated || a.terminal !== "observed" || a.aborted || a.timedOut)) return false;
41
+ if (s.name === "merge-readiness" && (s.operation !== "status" || s.effect !== "not-attempted" || s.summary || s.evidence === "admitted" && (!normal(s) || a.exitCode !== 0 || c.stderrBytes))) return false;
41
42
  if (s.summary && (!summaryValid(s.summary) || s.operation !== "status" || s.effect !== "read-observed")) return false;
42
43
  if (s.operation === "status" && !["not-attempted", "read-observed"].includes(s.effect)) return false;
43
44
  if (s.operation !== "status" && s.summary) return false;
@@ -47,11 +48,13 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
47
48
  if (s.changeset && s.effect !== "changeset-created") return false;
48
49
  if (s.operation !== "status" && s.effect === "not-attempted" && !["not-attempted", "not-started"].includes(a.state)) return false;
49
50
  }
51
+ const readiness = d.steps.filter(s => s.name === "merge-readiness");
52
+ if (readiness.length > 1 || readiness.length && (d.steps[0]?.name !== "pending-before" || d.steps[1] !== readiness[0] || (dto.ok || d.steps.length > 2) && readiness[0].evidence !== "admitted")) return false;
50
53
  const mutations = d.steps.filter(s => s.operation !== "status"), created = mutations.filter(s => s.effect === "changeset-created");
51
54
  const expected = created.length ? "changeset-created" : mutations.some(s => s.effect === "uncertain") ? "uncertain" : mutations.some(s => s.effect === "command-completed") ? "command-completed" : "not-attempted";
52
55
  if (d.effect !== expected || created.length > 1 || !!d.createdChangeset !== !!created.length || d.createdChangeset && !same(d.createdChangeset, created[0].changeset)) return false;
53
56
  if (dto.outcome === "completed" && (!d.createdChangeset || !d.pendingBefore || !d.wouldRun)) return false;
54
- if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.length !== 1)) return false;
57
+ if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.some(s => !["pending-before", "merge-readiness"].includes(s.name)))) return false;
55
58
  if ((dto.outcome === "failed" || dto.outcome === "unsupported") && mutations.length) return false;
56
59
  if (d.usedFallbackRetry !== d.steps.some(s => s.name === "fallback-checkin")) return false;
57
60
  if (d.usedPrivateAutoAddRecovery !== d.steps.some(s => s.name === "private-add" && s.effect === "command-completed")) return false;
@@ -58,7 +58,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
58
58
  for(const [index,s] of stages.entries()) {
59
59
  if(s.kind==="read") {
60
60
  if(!validateRead(s))return false;
61
- if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,merge.result.data.protocol==="apply-add-copied"?[...switchPendingArgv]:["status"]))return false;}
61
+ if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,["apply-add-copied","merged-candidate"].includes(merge.result.data.protocol)?[...switchPendingArgv]:["status"]))return false;}
62
62
  if(s.name==="parent"){if(!d.sourceBranch||!same(s.result.argv,buildCloseoutParentArgv(d.sourceBranch)))return false;const src=parseSwitchTarget(d.sourceBranch)!;if(s.result.parentPath&&(s.result.parentPath!==src.branch.slice(0,src.branch.lastIndexOf("/"))||!s.result.capture?.stdoutBytes))return false;if(s.result.emptyParentResult&&s.result.capture?.stdoutBytes!==0)return false;if(index<stages.length-1&&(s.result.emptyParentResult||!s.result.parentPath||stages[index+1].name!=="pending-before"))return false;}
63
63
  if(index<stages.length-1&&s.result.admission!=="admitted")return false;
64
64
  if(s.name.startsWith("loaded-")&&s.name!=="loaded-before"&&s.result.branch){if(!d.targetBranch)return false;lastVerification=compareSwitchTarget(s.result.branch,d.targetBranch);if(index<stages.length-1&&lastVerification==="unverified")return false;}
@@ -67,7 +67,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
67
67
  if(s.kind==="switch"){if(s.name!=="switch"||!validateSwitchOutput(s.result)||s.result.data.requestedTarget!==d.targetBranch||s.result.data.pendingPolicy!=="cancel"||s.result.data.preflight||s.result.data.workdir!==resolve(r.workdir))return false;}
68
68
  else if(s.kind==="update"){if(s.name!=="update"||!validateUpdateOutput(s.result)||s.result.data.workingDirectory!==r.workdir)return false;}
69
69
  else if(s.kind==="merge"){if(s.name!=="merge"||!validateWorkspaceMergeOutput(s.result)||s.result.action!=="merge"||s.result.data.requestedSource!==d.sourceBranch||s.result.data.strategy!==r.strategy||s.result.data.workingDirectory!==r.workdir||s.result.data.previewRequested!==false||s.result.data.cherrypicking||s.result.data.forced)return false;}
70
- else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[])];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
70
+ else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[]),...s.result.data.includedPaths];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
71
71
  if(index<stages.length-1&&!s.result.ok)return false;
72
72
  }
73
73
  }
@@ -58,9 +58,9 @@ export async function executeDiffOutput(args:unknown){
58
58
  const json=(args as {format?:unknown})?.format==="json";
59
59
  let text=JSON.stringify(dto);
60
60
  if(!json){
61
- if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+")":"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
61
+ if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+"/"+o.error.stage+"): "+o.error.message:"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
62
62
  else text=dto.error.message;
63
63
  }
64
- if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,note:"Presentation omitted; use structuredContent for the bounded producer observation."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
64
+ if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,...(!dto.ok?{error:dto.error}:{}),...(dto.mode==="workspace"&&"data"in dto?{counts:dto.data.counts,failures:dto.data.outcomes.flatMap((o,index)=>o.status==="unavailable"?[{index,error:o.error}]:[])}:{}),note:"Presentation omitted; use structuredContent for the bounded producer observation. Failure indices refer only to returned outcomes."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
65
65
  return {content:[{type:"text" as const,text}],details:{},structuredContent:dto,isError:!dto.ok};
66
66
  }
@@ -18,7 +18,7 @@ const data = {
18
18
  command: nullable(Type.Array(str(8192), { maxItems: 16 })),
19
19
  capability: object({ state: enumOf(["not-checked", "advertised", "missing", "unavailable"]), missingTokens: Type.Array(str(64), { maxItems: 9 }) }),
20
20
  attempt, capture: object({ help: nullable(capture), merge: nullable(capture) }),
21
- parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500) })),
21
+ parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500), unknownOperationClasses: Type.Array(enumOf(["unsupported-operation", "APPLY/unsupported-subtype", "DIS_OP_WARN/unsupported-subtype"]), { maxItems: 3, uniqueItems: true }), resolvedConflictsVerified: uint(500) })),
22
22
  observedChangesets: Type.Array(changeset, { maxItems: 100 }), conflictPaths: Type.Array(str(), { maxItems: 100 }),
23
23
  counts: object({ changesetsReturned: uint(100), changesetsOmitted: uint(500), conflictsReturned: uint(100), conflictsOmitted: uint(500) }),
24
24
  serverAliasEquivalence: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), wouldRun: Type.Boolean(), remoteAnalysis: enumOf(["not-performed", "performed"]),
@@ -38,7 +38,13 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
38
38
  const dto = value as MergeReceipt, d = dto.data, a = d.attempt, p = d.parse;
39
39
  const safeText = (s: string) => !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
40
40
  for (const cs of [...d.observedChangesets, ...(d.createdChangeset ? [d.createdChangeset] : [])]) if (!splitServerBranch("br:" + cs.branch + "@" + cs.repository + "@" + cs.server) || ![cs.branch, cs.repository, cs.server, cs.mount].every(safeText)) return false;
41
- if (!d.conflictPaths.every(s => s.startsWith("/") && safeText(s))) return false;
41
+ const canonicalPath = (s: string) => s.length > 1 && s.startsWith("/") && safeText(s) && !s.includes(String.fromCharCode(92)) && s.split("/").slice(1).every(part => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part));
42
+ if (!d.conflictPaths.every(canonicalPath)) return false;
43
+ const safeComment = (s: string) => s.length <= 4096 && !!s.trim() && safeText(s.replace(/[\r\n]/g, ""));
44
+ if (d.command?.some(s => s.startsWith("-c=") ? !safeComment(s.slice(3)) : !safeText(s))) return false;
45
+ if (d.requestedIdentity && !Object.values(d.requestedIdentity).every(safeText)) return false;
46
+ if (p && (p.conflictsObserved + p.changesetsObserved > p.records || p.resolvedConflictsVerified > 0 && new Set(d.conflictPaths.map(s => s.toLowerCase())).size !== d.conflictPaths.length)) return false;
47
+ if (p && (p.unknownOperationClasses.length !== p.unknownOperations || p.resolvedConflictsVerified > p.conflictsObserved || p.resolvedConflictsVerified > 0 && (d.mode !== "apply" || !d.resolutionsFile || d.unlistedConflictPolicy !== null || d.resolutionsFile.keepSource !== 0 || d.resolutionsFile.keepDestination !== 0 || d.resolutionsFile.resultFiles !== p.resolvedConflictsVerified || d.resolutionsFile.entries !== p.resolvedConflictsVerified))) return false;
42
48
  if (p && (p.warningsObserved > p.records || d.mode === "apply" && p.warningsObserved > 0 && ["completed", "no-op", "conflict"].includes(dto.outcome))) return false;
43
49
  if (!p && (d.observedChangesets.length || d.conflictPaths.length || Object.values(d.counts).some(Boolean))) return false;
44
50
  if (dto.completeness.capture === "complete" && !(d.capture.merge?.complete ?? d.capture.help?.complete)) return false;
@@ -60,7 +66,9 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
60
66
  if (c.changesetsReturned + c.conflictsReturned > 100) return false;
61
67
  if (c.changesetsReturned !== d.observedChangesets.length || c.conflictsReturned !== d.conflictPaths.length || p && (c.changesetsReturned + c.changesetsOmitted !== p.changesetsObserved || c.conflictsReturned + c.conflictsOmitted !== p.conflictsObserved)) return false;
62
68
  if ((c.changesetsOmitted || c.conflictsOmitted) && dto.completeness.projection) return false;
63
- if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !== 0)) return false;
69
+ if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.server !== d.requestedIdentity.server || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !== p.resolvedConflictsVerified)) return false;
70
+ if (["completed", "no-op", "analyzed"].includes(dto.outcome) && d.capture.merge?.stderrBytes) return false;
71
+ if (dto.outcome === "completed" && (!d.command || d.command[0] !== "cm" || d.command[1] !== "merge" || d.command[2] !== d.requestedIdentity?.source || !d.command.includes("--to=" + d.requestedIdentity?.target) || !d.command.includes("--merge") || !d.command.includes("--nointeractiveresolution") || !d.command.some(a => a.startsWith("-c=") && a.slice(3).trim()) || !d.wouldRun || d.capability.state !== "advertised" || d.capability.missingTokens.length > 0 || !d.capture.help?.complete || d.capture.help.stderrBytes || dto.completeness.capture !== "complete" || d.resolutionsFile && !d.command.includes("--fileconflictsresolutionsfile=" + d.resolutionsFile.path) || p?.resolvedConflictsVerified && d.command.some(a => a === "--keepsource" || a === "--keepdestination") || !d.observedChangesets.some(c => c.id === d.createdChangeset?.id && c.branch === d.createdChangeset.branch && c.repository === d.createdChangeset.repository && c.server === d.createdChangeset.server && c.mount === d.createdChangeset.mount))) return false;
64
72
  if (dto.outcome === "no-op" && (a.exitCode !== 0 || p?.changesetsObserved !== 0 || p.conflictsObserved !== 0)) return false;
65
73
  if (dto.outcome === "conflict" && (a.exitCode === 0 || p?.changesetsObserved !== 0 || !p.conflictsObserved)) return false;
66
74
  for (const cap of [d.capture.help, d.capture.merge]) if (cap && (cap.stdoutRetainedBytes > cap.stdoutBytes || cap.stderrRetainedBytes > cap.stderrBytes || cap.complete && (!cap.validUtf8 || cap.truncated))) return false;
@@ -25,15 +25,17 @@ const item = object({
25
25
  path: Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern }), isDirectory: Type.Boolean(),
26
26
  revisionId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, pattern: "^[0-9]+$" })),
27
27
  sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern })),
28
+ similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
28
29
  });
29
30
  const xmlCount = Type.Integer({ minimum: 0, maximum: 20000 });
30
31
  const xmlSummary = { totalPending: xmlCount, added: xmlCount, changed: xmlCount, moved: xmlCount, deleted: xmlCount, other: Type.Literal(0), private: xmlCount, tracked: xmlCount };
31
32
  const xmlPathPattern = `^(?:/|[A-Za-z]:\\\\|\\\\\\\\)[^\\u0000-\\u001f${process.platform === "win32" ? "?" : ""}]*$`;
32
33
  const xmlItem = object({
33
- statusCode: Type.Union([Type.Literal("CH"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("PR")]),
34
+ statusCode: Type.Union([Type.Literal("CH"), Type.Literal("CO"), Type.Literal("CP"), Type.Literal("RP"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("LM"), Type.Literal("PR")]),
34
35
  kind: Type.Union([Type.Literal("added"), Type.Literal("changed"), Type.Literal("moved"), Type.Literal("deleted"), Type.Literal("private")]),
35
36
  path: Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern }), isDirectory: Type.Boolean(),
36
37
  sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern })),
38
+ similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
37
39
  });
38
40
  export const statusOutputSchema = Type.Union([
39
41
  object({ ...common, schemaVersion: Type.Literal(2), ok: Type.Literal(true), data: object({
@@ -72,18 +74,49 @@ const envelope = { schemaVersion: 1, action: "status", provenance } as const;
72
74
  type StatusErrorCode = Extract<StatusOutput, { ok: false; schemaVersion: 1 }>["error"]["code"];
73
75
  const errorOutput = (code: StatusErrorCode, message: string, schemaVersion: 1 | 2 = 1): StatusOutput => ({ ...envelope, schemaVersion, ok: false, completeness: { read: "incomplete", capture: "unknown", projection: false }, error: { code, message } });
74
76
 
77
+ // Independent DTO trust-boundary validation mirrors the observation identity rules.
78
+ const statusObservationPathKey = (value: string): string | null => {
79
+ if (!value || value.length > 4096 || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) return null;
80
+ const path = value.replace(/\\/g, "/");
81
+ if (!/^(?:\/|[A-Za-z]:\/)/.test(path) || path.split("/").some(part => part === "." || part === "..")) return null;
82
+ const windows = /^[A-Za-z]:\//.test(path) || path.startsWith("//");
83
+ const tail = path.slice(path.startsWith("//") ? 2 : windows ? 3 : 1);
84
+ if (tail.includes("//") || (windows && (/[<>:"|?*]/.test(tail) || tail.split("/").some(part => /[. ]$/.test(part))))) return null;
85
+ if (path.startsWith("//") && !/^\/\/[^/]+\/[^/]+\//.test(path)) return null;
86
+ const key = windows ? path.toLowerCase() : path;
87
+ return key.length > (windows ? 3 : 1) ? key.replace(/\/$/, "") : key;
88
+ };
89
+ const statusObservationPathRoot = (value: string): string => {
90
+ const path = value.replace(/\\/g, "/").toLowerCase();
91
+ return path.match(/^[a-z]:/)?.[0] ?? (path.startsWith("//") ? path.split("/").slice(0, 4).join("/") : "/");
92
+ };
93
+
75
94
  export function validateStatusOutput(value: unknown, schemaVersion: 1 | 2 = 1): StatusOutput {
76
95
  if (!Check(statusOutputSchema, value)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
77
96
  const checked = value as StatusOutput;
97
+ if (checked.ok && checked.data.mode === "machine") {
98
+ const invalidMove = checked.data.items.some(row => {
99
+ if (row.statusCode !== "LM") return row.similarity !== undefined;
100
+ const destination = statusObservationPathKey(row.path);
101
+ const source = statusObservationPathKey(row.sourcePath ?? "");
102
+ return row.kind !== "moved" || row.similarity === undefined || !source || !destination || source === destination ||
103
+ statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") || !/^[1-9][0-9]{0,19}$/.test(row.revisionId ?? "");
104
+ });
105
+ if (invalidMove) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
106
+ }
78
107
  if (checked.ok && checked.data.mode === "xml") {
79
108
  const data = checked.data;
80
109
  const { parsed, returned, omitted, excluded, overCap } = data.itemCount;
81
110
  const seen = new Set<string>();
82
- const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
111
+ const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", LM: "moved", PR: "private" };
83
112
  const invalidRows = data.items.some(row => {
84
113
  const unsafe = (path: string) => /[\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path);
85
- const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path || seen.has(row.path);
86
- seen.add(row.path); return invalid;
114
+ const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path;
115
+ const keys = [row.path, ...(row.sourcePath ? [row.sourcePath] : [])].map(statusObservationPathKey);
116
+ const invalidIdentity = keys.some(key => !key || seen.has(key)) || new Set(keys).size !== keys.length;
117
+ for (const key of keys) if (key) seen.add(key);
118
+ const invalidSimilarity = row.statusCode === "LM" ? row.similarity === undefined || statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") : row.similarity !== undefined;
119
+ return invalid || invalidIdentity || invalidSimilarity;
87
120
  });
88
121
  if (data.summary.added + data.summary.changed + data.summary.deleted + data.summary.moved + data.summary.private + data.summary.other !== parsed || data.summary.other !== 0 || data.summary.tracked !== parsed - data.summary.private || invalidRows || returned !== data.items.length || parsed !== data.parse.records || parsed !== data.summary.totalPending || omitted !== parsed - returned || excluded !== 0 || overCap !== omitted || checked.completeness.read !== "complete" || checked.completeness.capture !== "complete" || checked.completeness.projection !== (returned === parsed)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", 2);
89
122
  }
@@ -110,6 +143,7 @@ export function projectStatusOutput(observation: StatusObservation, maxItems = 1
110
143
  statusCode: row.statusCode, kind: row.kind, path: row.workspacePath, isDirectory: row.isDirectory,
111
144
  ...(row.revisionId !== undefined ? { revisionId: row.revisionId } : {}),
112
145
  ...(row.sourceWorkspacePath !== undefined ? { sourcePath: row.sourceWorkspacePath } : {}),
146
+ ...(row.similarity !== undefined ? { similarity: row.similarity } : {}),
113
147
  })).filter(row => Check(item, row));
114
148
  const items = eligible.slice(0, maxItems);
115
149
  const excluded = pendingItems.length - eligible.length;
@@ -6,7 +6,7 @@ const attempt=obj({state:enm(["not-attempted","not-started","started","unknown"]
6
6
  const capture=obj({stdoutBytes:uint(),stderrBytes:uint(),stdoutRetainedBytes:uint(65536),stderrRetainedBytes:uint(16384),truncated:Type.Boolean(),complete:Type.Boolean(),validUtf8:Type.Boolean()});
7
7
  const stage=obj({attempt,capture:nil(capture),admission:enm(["not-observed","unsupported","admitted"])});
8
8
  const text=Type.String({minLength:1,maxLength:4096});
9
- const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
9
+ const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine","merged-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","merged-candidate","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
10
10
  export const workspaceMergeOutputSchema=Type.Unsafe<WorkspaceMergeReceipt>(Type.Union([
11
11
  obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(true),outcome:enm(["preflight","command-completed"])}),
12
12
  obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(false),outcome:enm(["failed","uncertain","blocked"]),error:obj({code:enm(["invalid_request","launch_failed","aborted","uncertain","unsupported_readiness","conflict","producer_failed"]),message:Type.String({minLength:1,maxLength:256})})})
@@ -31,12 +31,15 @@ export function validateWorkspaceMergeOutput(v:unknown):v is WorkspaceMergeRecei
31
31
  if(d.commandCompleted!==Boolean(d.apply.capture?.complete&&d.apply.attempt.state==="started"&&d.apply.attempt.exitCode===0))return false;
32
32
  if(d.shortStatus.admission==="admitted"&&((d.pendingReadMode==="short"?d.shortStatus.capture?.stdoutBytes!==0:!d.shortStatus.capture?.stdoutBytes)||d.shortStatus.capture?.stderrBytes!==0)||d.fullStatus.admission==="admitted"&&(!d.fullStatus.capture?.stdoutBytes||d.fullStatus.capture?.stderrBytes!==0))return false;
33
33
  if(d.pendingReadMode==="copied-machine"&&d.protocol!=="apply-add-copied"||d.protocol==="apply-add-copied"&&d.pendingReadMode!=="copied-machine")return false;
34
- const later=stages.slice(1).some(s=>s.admission!=="not-observed");if(later&&(!d.commandCompleted||!["already-connected","apply-add","apply-add-copied"].includes(d.protocol)))return false;
34
+ if((d.pendingReadMode==="merged-machine")!==(d.protocol==="merged-candidate"))return false;
35
+ const later=stages.slice(1).some(s=>s.admission!=="not-observed");if(later&&(!d.commandCompleted||!["already-connected","apply-add","apply-add-copied","merged-candidate"].includes(d.protocol)))return false;
35
36
  if(d.fullStatus.admission!=="not-observed"&&(!d.shortStatus.capture?.complete||d.shortStatus.attempt.exitCode!==0))return false;
36
37
  if(d.protocol==="not-observed"&&(d.apply.admission==="admitted"||d.fileConflictCount!==null)||d.protocol==="unsupported"&&(d.fileConflictCount!==null||d.apply.admission!=="unsupported"))return false;
37
- if(["already-connected","apply-add","apply-add-copied","file-conflict"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
38
+ if(["already-connected","apply-add","apply-add-copied","file-conflict","merged-candidate"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
39
+ if(d.protocol==="merged-candidate"&&(d.fileConflictCount===null||d.fileConflictCount>128||d.checkinReadiness==="blocked"))return false;
38
40
  if(d.protocol==="file-conflict"?(d.fileConflictCount===null||d.fileConflictCount===0||d.checkinReadiness!=="blocked"):d.checkinReadiness==="blocked")return false;
39
41
  if(["already-connected","apply-add","apply-add-copied"].includes(d.protocol)&&d.fileConflictCount!==0)return false;
42
+ if(d.protocol==="merged-candidate"&&d.checkinReadiness==="supported-no-unresolved-signals"&&(d.strategy!=="auto"||d.forced!==false||d.cherrypicking!==false))return false;
40
43
  if(d.checkinReadiness==="supported-no-unresolved-signals"&&(!d.commandCompleted||!stages.every(s=>s.admission==="admitted"&&s.capture?.complete&&s.attempt.exitCode===0&&s.capture.stderrBytes===0)))return false;
41
44
  if(d.previewRequested===true&&(!dto.ok||dto.outcome!=="preflight"||stages.some(s=>s.admission!=="not-observed")||d.effect!=="not-attempted"||d.commandCompleted||d.checkinReadiness!=="unknown"))return false;
42
45
  if(dto.ok){if(dto.outcome==="preflight"&&d.previewRequested!==true)return false;if(dto.outcome==="command-completed"&&(d.previewRequested!==false||d.effect!=="not-proven"||d.checkinReadiness!=="supported-no-unresolved-signals"))return false;}