@aefree/pi-plastic 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.8.4] - 2026-10-20
11
+
12
+ ### Changed
13
+
14
+ - Validate against exact Pi 1.3.0 development dependencies; retain manifest-derived installed-version checks.
15
+
16
+ ### Fixed
17
+
18
+ - Admit observed behind-head branch headers for switching and relative branch creation without treating branch head as loaded content.
19
+ - Support observed binary and CO/CP/RP XML status records while retaining whole-document completeness, original status codes and unavailable base-revision identity.
20
+ - Correlate observed automatic resolved replacement/text/mixed merges across applying records, machine pending and source-linked full status before checkin readiness. Revalidate independent closeout observations, keep unresolved text merges stopped before checkin, and never replay uncertain mutations.
21
+ - Admit native RP checkin item events from the captured successful root profile; unknown framing, multiple changesets and unproven Xlink identity remain unsupported.
22
+
10
23
  ## [0.8.3] - 2026-10-13
11
24
 
12
25
  ### Fixed
@@ -28,19 +28,20 @@ The closed producer receipts distinguish applying-command completion from overal
28
28
 
29
29
  Strict bounded inputs reject source-as-option/stdin, unsafe Unicode and unknown controls. Source/workdir cap 4,096 code units; aliases keep canonical precedence.
30
30
 
31
- At most one applying merge. Supported output can be followed by separately recorded short/full status reads. File-conflict records block. Exit 0 followed by failed/unsupported reads can retain commandCompleted=true with overall ok=false/uncertain and unknown readiness.
31
+ At most one applying merge, followed by short/full or machine/full reads. Conflict records alone block; declaration/action pairs remain candidates until resolved pending/link/path observations agree. Exit 0 with unsupported reads retains commandCompleted=true but ok=false/uncertain and unknown readiness.
32
32
 
33
33
  No swallowed read failure, raw diagnostic facts, retry, strategy switch, export/backup/version query or rollback. Possible-start failure never proves absent effects or safe replay. Unobserved source/workspace identity, pending items, finalized metadata and preservation stay null.
34
34
 
35
35
  ### Admitted readiness profiles
36
36
 
37
- Windows CM 11.0.16.10371 source admission is narrow:
37
+ Windows CM admission is narrow: the 11.0.16.10371 contract label is retained; fixtures additionally attest 11.0.16.10468, not client-family support:
38
38
 
39
39
  - Exact nonce-framed STATUS/ALREADY_CONNECTED, FILE_CONFLICT, APPLY/ADD and paired DO_COPIED records.
40
40
  - Empty profile: successful complete empty short status plus exact header-only standard status and no stderr.
41
41
  - Copied-add profile: paired applying paths, CP-only pending records and machine/full-status correlation; up to 128 files under unchanged byte/frame limits and one merge changeset.
42
+ - Resolved merged profile: FILE_SRC/DO_COPIED replacement, FILE_CONFLICT/DO_MERGE text merge, or observed mixed copies/replacements/text. Automatic Windows merges require unique scoped apply/pending paths, resolved merge metadata and matching full-status source link/namespace/rows. Identical opcodes and exit zero occurred in unresolved experiments too: CO/NO_MERGES cannot prove readiness.
42
43
 
43
- Unknown/mixed/malformed/empty applying output, other rich operations, unsupported localization, unrelated pending changes or merge links stop as unknown—not zero conflicts or rollback. Core rejection and independent closeout post-read gates prevent checkin on unknown readiness.
44
+ Unknown profiles/cardinalities, unrelated pending rows or extra links remain unknown—not zero conflicts or rollback. Forced/cherrypicked policies, merged moves/deletes/directories, binary conflicts, Xlinks and localization remain unsupported. Core rejection and independent closeout post-read gates prevent checkin on unknown readiness.
44
45
 
45
46
  `supported-no-unresolved-signals` is point-in-time admitted evidence, not transaction finality, target-head/source-link/exclusivity/other-workspace/Xlink proof. The agent may inspect and choose a supported resolution; the tool doesn't force one.
46
47
 
@@ -70,11 +71,11 @@ Only one matching full-source/direct-parent row is admitted. Two rows mean ambig
70
71
 
71
72
  `unsupported_readiness` is not an applying-command failure or rollback receipt. Inspect `commandCompleted`, capture completeness, `protocol` and each stage admission. Diagnostics distinguish unsupported applying output, applying warnings, pending/path correlation and full-status correlation. Stop checkin and do not automatically reapply the merge. Use separately authorized read-only inspection to establish current pending/conflict state; retain sanitized producer evidence for unsupported-profile analysis and verify the package revision actually loaded by the process (a source checkout alone is not proof).
72
73
 
73
- Copied-add admission does not admit mixed replace/delete profiles. A synthetic historical-shape regression proves the supported DO_COPIED/ADD/CP path, not historical recovery: unavailable historical producer bytes and uncertain/stale runtime loads remain unresolved.
74
+ Copied-only admission excludes replacements/deletions; correlated profiles add sourced replacement/text/copy combinations. Synthetic tests prove neither historical recovery nor loaded revision.
74
75
 
75
76
  ### Readiness and message admission
76
77
 
77
- Independent readiness requires complete successful capture, empty stderr, and an admitted header-only standard snapshot or the strict copied profile. Copied admission uses machine pending instead of the first standalone short read, then correlates full pending-link/Added/copied sections with requested source, namespace, changeset/merge metadata and paths. Volatile size/time display is opaque. Unknown/mixed/directory/duplicate/out-of-workspace records remain unsupported; CP selection keeps its CP code while using added kind locally in checkin. Final pending observations are typed and admitted, never swallowed into zero.
78
+ Independent readiness requires complete capture, empty stderr and an admitted clean, copied or resolved-merged profile. Merged closeout re-reads machine pending and correlates resolved rows/source link with the subsequent full loaded-target observation. Copied readiness correlates machine/full status with source, namespace, metadata and paths. Volatile display is opaque. Unknown/directory/duplicate/out-of-scope rows stop; CP retains its code but maps locally to added for checkin. Final pending reads never turn failure into zero.
78
79
 
79
80
  Existing message/card trimming and explicit-message precedence remain. Derived messages must satisfy the checkin owner's text/4, 096-unit bounds before target mutation. A card-only default inserts newlines rejected by that owner; supply an admitted explicit message instead. This is a current restriction, not permission to widen checkin text admission or silently drop the card.
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"
@@ -37,7 +37,7 @@
37
37
  "typecheck": "tsc -p tsconfig.json",
38
38
  "pretest": "npm run typecheck",
39
39
  "test:docs": "tsx tests/plastic-documentation.test.ts",
40
- "test": "tsx tests/pi-version-selection.test.ts && tsx tests/plastic-documentation.test.ts && tsx tests/eval-mutation-guard.test.ts && tsx tests/tool-definition.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-status-output.test.ts && tsx tests/plastic-status-xml.test.ts && tsx tests/plastic-branch-output.test.ts && tsx tests/plastic-checkin-output.test.ts && tsx tests/plastic-branch-create-output.test.ts && tsx tests/plastic-switch-output.test.ts && tsx tests/plastic-update-output.test.ts && tsx tests/plastic-add-output.test.ts && tsx tests/plastic-undo-output.test.ts && tsx tests/plastic-removal-output.test.ts && tsx tests/plastic-workspace-merge-output.test.ts && tsx tests/plastic-workspace-merge-closeout.test.ts && tsx tests/plastic-closeout-output.test.ts && tsx tests/plastic-closeout-parent.test.ts && tsx tests/plastic-copied-closeout.test.ts && tsx tests/plastic-copied-cardinality.test.ts && tsx tests/plastic-branch-delete-output.test.ts && tsx tests/plastic-object-delete-output.test.ts && tsx tests/plastic-object-write-output.test.ts && tsx tests/plastic-receipt-integration.test.ts && tsx tests/plastic-checkin-core-closeout.test.ts && tsx tests/plastic-selected-collector-retirement.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-diff-revisions-output.test.ts && tsx tests/plastic-local-snapshot.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-module-boundaries.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-patch-output.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-rendering-runtime-isolation.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-workspace-list-output.test.ts && tsx tests/plastic-shelveset-list-output.test.ts && tsx tests/plastic-code-review-find-output.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/plastic-server-merge-output.test.ts && tsx tests/plastic-server-merge-resolutions.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
40
+ "test": "tsx tests/pi-version-selection.test.ts && tsx tests/plastic-documentation.test.ts && tsx tests/eval-mutation-guard.test.ts && tsx tests/tool-definition.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-status-output.test.ts && tsx tests/plastic-status-xml.test.ts && tsx tests/plastic-branch-output.test.ts && tsx tests/plastic-checkin-output.test.ts && tsx tests/plastic-branch-create-output.test.ts && tsx tests/plastic-switch-output.test.ts && tsx tests/plastic-update-output.test.ts && tsx tests/plastic-add-output.test.ts && tsx tests/plastic-undo-output.test.ts && tsx tests/plastic-removal-output.test.ts && tsx tests/plastic-workspace-merge-output.test.ts && tsx tests/plastic-workspace-merge-closeout.test.ts && tsx tests/plastic-merged-protocol.test.ts && tsx tests/plastic-merged-closeout.test.ts && tsx tests/plastic-closeout-output.test.ts && tsx tests/plastic-closeout-parent.test.ts && tsx tests/plastic-copied-closeout.test.ts && tsx tests/plastic-copied-cardinality.test.ts && tsx tests/plastic-branch-delete-output.test.ts && tsx tests/plastic-object-delete-output.test.ts && tsx tests/plastic-object-write-output.test.ts && tsx tests/plastic-receipt-integration.test.ts && tsx tests/plastic-checkin-core-closeout.test.ts && tsx tests/plastic-selected-collector-retirement.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-diff-revisions-output.test.ts && tsx tests/plastic-local-snapshot.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-module-boundaries.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-patch-output.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-rendering-runtime-isolation.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-workspace-list-output.test.ts && tsx tests/plastic-shelveset-list-output.test.ts && tsx tests/plastic-code-review-find-output.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/plastic-server-merge-output.test.ts && tsx tests/plastic-server-merge-resolutions.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
41
41
  "preview:tools": "tsx tests/plastic-tool-rendering.test.ts --preview",
42
42
  "test:host:status": "tsx tests/pi-host-status-output.test.ts",
43
43
  "test:host:declarations": "tsx tests/pi-host-declarations.test.ts",
@@ -47,8 +47,8 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@aefree/pi-file-discovery": "^0.1.1",
50
- "@earendil-works/pi-coding-agent": "1.2.0",
51
- "@earendil-works/pi-tui": "1.2.0",
50
+ "@earendil-works/pi-coding-agent": "1.3.0",
51
+ "@earendil-works/pi-tui": "1.3.0",
52
52
  "@types/node": "22.20.4",
53
53
  "tsx": "4.23.15",
54
54
  "typebox": "^1.3.8",
@@ -32,7 +32,8 @@ export const safeBranchCreateText = (s: string): boolean => s.length <= 4096 &&
32
32
  export function parseBranchCreateParent(output: string): string | null {
33
33
  const lines = output.split(/\r?\n/).filter(line => line.length > 0);
34
34
  if (lines.length !== 1) return null;
35
- const match = /^(\/[^@]+)@[^@]+@[^\s]+ \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u.exec(lines[0]!);
35
+ // Resolve the loaded branch only; a numeric head does not change its identity.
36
+ const match = /^(\/[^@]+)@[^@]+@[^\s]+ \(cs:(?:0|[1-9][0-9]{0,19}) - head(?::(?:0|[1-9][0-9]{0,19}))?\)$/u.exec(lines[0]!);
36
37
  if (!match || !safeBranchCreateText(match[1]!) || !/^\/(?:[^/@\\]+)(?:\/[^/@\\]+)*$/u.test(match[1]!) || match[1] !== match[1]!.trim() || /[?\uFFFD]/u.test(match[1]!)) return null;
37
38
  return match[1]!;
38
39
  }
@@ -1,3 +1,4 @@
1
+ import { parseMergedPending } from "./merged-workspace";
1
2
  import { parseMachineReadablePendingItems, type PendingItem } from "./pending";
2
3
  import { parseCopiedPending } from "./copied-merge";
3
4
  export type CheckinRequest = { message: string; paths?: string[]; applyChanged?: boolean; includePrivate?: boolean; includeAll?: boolean; updateAfter?: boolean; preflight?: boolean; format?: "text" | "json"; workdir?: string };
@@ -6,7 +7,7 @@ export type { CheckinAttempt, CheckinCapture } from "../execution/checkin-comman
6
7
  export type CheckinChangeset = { id: string; branch: string; repository: string; server: string; mount: "/" };
7
8
  export type CheckinSummary = { totalPending: number; tracked: number; private: number; added: number; changed: number; moved: number; deleted: number; other: number };
8
9
  export type CheckinStep = { name: "pending-before" | "initial-checkin" | "private-add" | "private-retry" | "pending-recovery" | "fallback-checkin" | "pending-after" | "merge-diagnostic"; operation: "status" | "checkin" | "add"; attempt: CheckinAttempt; capture: CheckinCapture; effect: "not-attempted" | "uncertain" | "command-completed" | "changeset-created" | "read-observed"; evidence: "unadmitted" | "admitted"; records: number; summary: CheckinSummary | null; changeset: CheckinChangeset | null };
9
- export type CheckinItemEvent = { operation: "CO" | "AD" | "DE" | "CP"; path: string } | { operation: "MV"; sourcePath: string; path: string };
10
+ export type CheckinItemEvent = { operation: "CO" | "AD" | "DE" | "CP" | "RP"; path: string } | { operation: "MV"; sourcePath: string; path: string };
10
11
  export type CheckinData = { sourceAdmission: "windows-cm11.0.16.10371-observed"; requestedPaths: string[]; includedPaths: string[]; fallbackPaths: string[]; excludedPaths: Array<{ path: string; reason: string }>; privateAddPaths: string[]; blockedPrivatePaths: Array<{ path: string; reason: string }>; itemEvents: CheckinItemEvent[]; observedChangesets: CheckinChangeset[]; command: string[] | null; steps: CheckinStep[]; autoEnabledApplyChanged: boolean; usedFallbackRetry: boolean; usedPrivateAutoAddRecovery: boolean; pendingBefore: CheckinSummary | null; pendingAfter: CheckinSummary | null; wouldRun: boolean; omittedReferences: number; scopeExhaustion: "unverified"; branchHead: "unverified"; serverAliasEquivalence: "unverified"; xlinkEffects: "unverified"; mergeLinkIdentity: "unverified" };
11
12
  type Base = { schemaVersion: 1; action: "checkin"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: boolean } };
12
13
  export type CheckinReceipt = Base & (
@@ -37,7 +38,7 @@ export function parseCheckinEvidence(text: string, s: CheckinSeparators) {
37
38
  const fields = line.slice(s.start.length, -s.end.length).split(s.field), [op, value] = fields;
38
39
  if (op === "CI_START" && fields.length === 1) { started++; if (index !== 0) malformed = true; }
39
40
  else if (op === "STAGE" && fields.length === 2 && value.length <= 4096 && !checkinUnsafeText.test(value)) { if (changesets.length) malformed = true; }
40
- else if ((op === "CO" || op === "AD" || op === "DE" || op === "CP") && fields.length === 2 && absolute(value)) { events.push({ operation: op, path: value }); if (changesets.length) malformed = true; }
41
+ else if ((op === "CO" || op === "AD" || op === "DE" || op === "CP" || op === "RP") && fields.length === 2 && absolute(value)) { events.push({ operation: op, path: value }); if (changesets.length) malformed = true; }
41
42
  else if (op === "MV" && fields.length === 3 && absolute(value) && absolute(fields[2])) { events.push({ operation: "MV", sourcePath: value, path: fields[2] }); if (changesets.length) malformed = true; }
42
43
  else if (op === "CHANGESET" && fields.length === 2) { const cs = parseCheckinChangeset(value); if (cs) changesets.push(cs); else malformed = true; }
43
44
  else malformed = true;
@@ -47,6 +48,11 @@ export function parseCheckinEvidence(text: string, s: CheckinSeparators) {
47
48
  }
48
49
  export function parseCheckinPending(text: string, cwd: string) {
49
50
  // statusCode CP retained; added kind is local checkin selection, not legacy migration.
51
+ const merged = parseMergedPending(text, cwd);
52
+ if (merged) {
53
+ const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: item.statusCode === "CP" ? "added" as const : "changed" as const }));
54
+ return { admitted: items.length === merged.rows.length, records: merged.rows.length + 1, items, repository: merged.repository, server: merged.server };
55
+ }
50
56
  const copied = parseCopiedPending(text, cwd);
51
57
  if (copied) {
52
58
  const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: "added" as const }));
@@ -0,0 +1,85 @@
1
+ import { win32 } from "node:path";
2
+ import { copiedPathKey, type CopiedPending } from "./copied-merge";
3
+ import { parseSwitchLoadedBranch, parseSwitchTarget, safeSwitchValue } from "./switch-contract";
4
+
5
+ const decimal = /^(0|[1-9][0-9]{0,19})$/;
6
+ const positive = /^[1-9][0-9]{0,19}$/;
7
+ const safe = (s: string) => safeSwitchValue(s) && !/[?\uFFFD]/u.test(s);
8
+ const component = (part: string) => part !== "" && part !== "." && part !== ".." && part === part.trim() && !part.endsWith(".") && !/[\\/:*"<>|]/.test(part) && !/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part);
9
+ const absolute = (s: string) => safe(s) && /^[A-Za-z]:\\/.test(s) && s.slice(3).split("\\").every(component);
10
+ const relative = (s: string) => safe(s) && s.startsWith("/") && s.slice(1).split("/").every(component);
11
+ export type MergedDeclaration = { path: string; actionPath: string; status: "CP" | "RP" | "CO" };
12
+ export type MergedPending = CopiedPending & { rows: Array<{ path: string; status: "CP" | "RP" | "CO" }> };
13
+ /** A declaration/action pair is only a candidate, never resolution proof. Numeric
14
+ * FILE fields are bounded producer tokens, not asserted revision identities. */
15
+ export function parseMergedApply(text: string, token: string): MergedDeclaration[] | null {
16
+ if (Buffer.byteLength(text, "utf8") > 65536) return null;
17
+ const start = token + "S__", end = token + "E__", field = token + "F__";
18
+ const declarations: Array<{ path: string; status: MergedDeclaration["status"] }> = [], actions: Array<{ path: string; status: "copy" | "merge" }> = [];
19
+ let rest = text, count = 0;
20
+ while (rest.trim() !== "") {
21
+ if (++count > 256 || !rest.startsWith(start)) return null;
22
+ const e = rest.indexOf(end, start.length); if (e < 0) return null;
23
+ const f = rest.slice(start.length, e).split(field); rest = rest.slice(e + end.length).replace(/^\r?\n/, "");
24
+ if (f.length === 2 && /^(DO_COPIED|DO_MERGE)$/.test(f[0]) && absolute(f[1])) actions.push({ path: f[1], status: f[0] === "DO_MERGE" ? "merge" : "copy" });
25
+ else if (!actions.length && f.length === 3 && f[0] === "APPLY" && f[1] === "ADD" && relative(f[2])) declarations.push({ path: f[2], status: "CP" });
26
+ else if (!actions.length && ((f[0] === "FILE_SRC" && f.length === 5) || (f[0] === "FILE_CONFLICT" && f.length === 6)) && relative(f[1]) && f.slice(2).every(v => decimal.test(v))) declarations.push({ path: f[1], status: f[0] === "FILE_SRC" ? "RP" : "CO" });
27
+ else return null;
28
+ }
29
+ if (!declarations.length || declarations.length !== actions.length || new Set(declarations.map(d => d.path.toLowerCase())).size !== declarations.length || new Set(actions.map(a => copiedPathKey(a.path))).size !== actions.length) return null;
30
+ // Only the three captured declaration combinations are sourced here. Copied-only
31
+ // cardinalities continue through the existing independent route.
32
+ if (!["RP", "CO", "CO,CP,RP"].includes(declarations.map(d => d.status).sort().join(","))) return null;
33
+ // Absolute workspace scope is established by the pending observation below.
34
+ const result: MergedDeclaration[] = [];
35
+ for (const d of declarations) {
36
+ const matches = actions.filter(a => a.path.toLowerCase().endsWith(d.path.replaceAll("/", "\\").toLowerCase()) && a.status === (d.status === "CO" ? "merge" : "copy"));
37
+ if (matches.length !== 1) return null;
38
+ result.push({ ...d, actionPath: matches[0].path });
39
+ }
40
+ return new Set(result.map(d => copiedPathKey(d.actionPath))).size === actions.length ? result : null;
41
+ }
42
+ export function parseMergedPending(text: string, cwd: string): MergedPending | null {
43
+ const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
44
+ if (!absolute(cwd) || lines.length < 2 || lines.length > 129 || Buffer.byteLength(text, "utf8") > 65536) return null;
45
+ const h = lines[0].split("\x1f");
46
+ if (h.length !== 4 || h[0] !== "STATUS" || !decimal.test(h[1]) || !h.slice(2).every(safe)) return null;
47
+ const rows: MergedPending["rows"] = []; let mergeChangeset = "";
48
+ for (const line of lines.slice(1)) {
49
+ const f = line.split("\x1f"), m = /^Merge from ([1-9][0-9]{0,19})$/.exec(f[4] ?? "");
50
+ if (f.length !== 5 || !/^(CO|RP|CP)$/.test(f[0]) || !absolute(f[1]) || f[2] !== "False" || !(f[0] === "CO" ? f[3] === "-1" : positive.test(f[3])) || !m) return null;
51
+ const rel = win32.relative(cwd, f[1]);
52
+ if (!rel || rel.startsWith("..") || win32.isAbsolute(rel) || rows.some(r => copiedPathKey(r.path) === copiedPathKey(f[1])) || mergeChangeset && mergeChangeset !== m[1]) return null;
53
+ mergeChangeset = m[1]; rows.push({ path: f[1], status: f[0] as MergedDeclaration["status"] });
54
+ }
55
+ if (!["CP", "RP", "CO", "CO,CP,RP"].includes(rows.map(r => r.status).sort().join(","))) return null;
56
+ return { changeset: h[1], repository: h[2], server: h[3], mergeChangeset, paths: rows.map(r => r.path), rows };
57
+ }
58
+ export function correlateMergedApply(declarations: MergedDeclaration[], pending: MergedPending, cwd: string): boolean {
59
+ return declarations.length === pending.rows.length && declarations.every(d => {
60
+ const key = copiedPathKey(win32.join(cwd, d.path));
61
+ return copiedPathKey(d.actionPath) === key && pending.rows.some(r => copiedPathKey(r.path) === key && r.status === d.status);
62
+ });
63
+ }
64
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65
+ export function admitMergedStandardStatus(text: string, pending: MergedPending, source: string, cwd: string): boolean {
66
+ const lines = text.split(/\r?\n/).filter(l => l.trim() !== ""), branch = parseSwitchLoadedBranch(text), requested = parseSwitchTarget(source);
67
+ if (!branch || !requested || branch.repository !== pending.repository || branch.server !== pending.server || !lines[0].endsWith(`(cs:${pending.changeset} - head)`) || lines[1] !== "Pending merge links") return false;
68
+ const link = /^ Merge from cs:([1-9][0-9]{0,19}) at (.+)$/.exec(lines[2] ?? ""), linked = link && parseSwitchTarget(link[2]);
69
+ if (!link || !linked || link[1] !== pending.mergeChangeset || linked.branch !== requested.branch || linked.repository !== pending.repository || linked.server !== pending.server || requested.repository !== null && (requested.repository !== linked.repository || requested.server !== linked.server)) return false;
70
+ let index = 3;
71
+ for (const section of ["Changed", "Added"]) {
72
+ const rows = pending.rows.filter(r => (r.status === "CP") === (section === "Added")); if (!rows.length) continue;
73
+ if (lines[index++] !== section || !/^ Status +Size +Last Modified +Path *$/.test(lines[index++] ?? "")) return false;
74
+ const remaining = new Set(rows);
75
+ for (let n = 0; n < rows.length; n++) {
76
+ const row = lines[index++] ?? "";
77
+ const matches = [...remaining].filter(r => {
78
+ const label = r.status === "CO" ? "Checked-out (changed)" : r.status === "RP" ? "Replaced" : "Copied (new)";
79
+ return new RegExp(`^ ${escape(label)} \\(Merge from ${pending.mergeChangeset}\\) {2,}[0-9]+ bytes {2,}[^\\r\\n\\x00-\\x1f\\x7f-\\x9f]{1,128}? {2,}${escape(win32.relative(cwd, r.path))} *$`).test(row);
80
+ });
81
+ if (matches.length !== 1) return false; remaining.delete(matches[0]);
82
+ }
83
+ }
84
+ return index === lines.length;
85
+ }
@@ -13,7 +13,7 @@ export class StatusXmlError extends Error {
13
13
  }
14
14
  function invalid(): never { throw new StatusXmlError(); }
15
15
  function limit(): never { throw new StatusXmlError("output_overflow"); }
16
- const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
16
+ const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
17
17
  const fields = new Set(["Type", "TypeVerbose", "Path", "OldPath", "PrintableMovedPath", "MergesInfo", "SimilarityPerUnit", "Similarity", "Size", "PrintableSize", "PrintableLastModified", "RevisionType", "LastModified"]);
18
18
  const isAbsolute = (path: string): boolean => path.startsWith("/") || /^[A-Za-z]:\\/.test(path) || /^\\\\[^\\]+\\[^\\]+\\/.test(path);
19
19
  const identity = (value: string): string => {
@@ -95,6 +95,7 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
95
95
  if (change.tagName !== "Change") invalid();
96
96
  const row = children(change, fields);
97
97
  // All recognized fields are scalar in the verified configuration.
98
+ // TypeVerbose/MergesInfo are display metadata, never merge-readiness evidence.
98
99
  for (const value of row.values()) leaf(value);
99
100
  const statusCode = leaf(row.get("Type"));
100
101
  if (!/^[A-Z][A-Z0-9_]*$/.test(statusCode)) invalid();
@@ -104,7 +105,7 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
104
105
  const oldPath = leaf(row.get("OldPath"));
105
106
  const revisionType = leaf(row.get("RevisionType"));
106
107
  if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
107
- const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile";
108
+ const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile" || revisionType === "enBinaryFile";
108
109
  if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
109
110
  if (oldPath && identity(oldPath) === path) invalid();
110
111
  if (seen.has(path)) invalid();
@@ -21,7 +21,8 @@ export function parseSwitchTarget(value: string) {
21
21
  }
22
22
  export function parseSwitchLoadedBranch(text: string): SwitchBranchObservation | null {
23
23
  const lines = text.split(/\r?\n/);
24
- const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u;
24
+ // A numeric head is an annotation, not the loaded changeset or branch owner.
25
+ const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head(?::(?:0|[1-9][0-9]{0,19}))?\)$/u;
25
26
  const match = grammar.exec(lines[0] ?? "");
26
27
  if (!match || lines.slice(1).some(line => grammar.test(line)) || !hierarchy(match[1]) || !identity(match[2]) || !identity(match[3]) || !safeSwitchValue(`${match[1]}@${match[2]}@${match[3]}`) || !parseSwitchTarget(`${match[1]}@${match[2]}@${match[3]}`)) return null;
27
28
  return { branch: match[1], repository: match[2], server: match[3], basis: "standard-status", scope: "loaded-workspace" };
@@ -1,3 +1,4 @@
1
+ import { parseMergedApply, type MergedDeclaration } from "./merged-workspace";
1
2
  import type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
2
3
  export type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
3
4
  export type MergeAction = "merge" | "finalize-merge";
@@ -6,9 +7,9 @@ export const safeMergeText = (v: unknown): v is string => typeof v === "string"
6
7
  export type MergeStage = { attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture | null; admission: "not-observed" | "unsupported" | "admitted" };
7
8
  export type WorkspaceMergeData = {
8
9
  intendedArgv: readonly string[]; workingDirectory: string | null; requestedSource: string | null; strategy: MergeStrategy | null; cherrypicking: boolean | null; forced: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
9
- apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine";
10
+ apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine" | "merged-machine";
10
11
  commandCompleted: boolean; observedWorkspaceIdentity: null; observedSourceIdentity: null; observedFinalizedMetadata: null; observedPreservation: null; observedPendingItems: null;
11
- fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "unsupported";
12
+ fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "merged-candidate" | "unsupported";
12
13
  checkinReadiness: "unknown" | "blocked" | "supported-no-unresolved-signals"; verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
13
14
  };
14
15
  type Header = { schemaVersion: 1; action: MergeAction; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: WorkspaceMergeData };
@@ -19,7 +20,9 @@ export function buildWorkspaceMergeArgv(source: string, strategy: MergeStrategy,
19
20
  if(cherry)result.push("--cherrypicking");if(forced)result.push("--forced");return result;
20
21
  }
21
22
  /** Narrow observed CM profile. Unknown records/text are not missing-conflict proof. */
22
- export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[] } {
23
+ export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[]; declarations?: MergedDeclaration[] } {
24
+ const declarations = parseMergedApply(text, token);
25
+ if (declarations?.some(d => d.status !== "CP")) return { protocol: "merged-candidate", fileConflictCount: declarations.filter(d => d.status === "CO").length, copiedPaths: [], addedPaths: [], declarations };
23
26
  const start=token+"S__",end=token+"E__",field=token+"F__";let rest=text,count=0,conflicts=0,connected=0,adds=0; const copiedPaths:string[]=[], addedPaths:string[]=[];
24
27
  while(rest.trim()!==""){
25
28
  if(++count>256)return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};const s=rest.indexOf(start),e=rest.indexOf(end,s+start.length);
@@ -3,6 +3,7 @@ import { parseSwitchTarget, parseSwitchLoadedBranch, compareSwitchTarget, parseS
3
3
  import { checkinSafeComment, checkinSafeValue } from "../domain/checkin-contract";
4
4
  import { admitCleanStandardStatus } from "../domain/workspace-merge-contract";
5
5
  import { parseCopiedPending } from "../domain/copied-merge";
6
+ import { parseMergedPending, admitMergedStandardStatus, type MergedPending } from "../domain/merged-workspace";
6
7
  import { cmWhereEquals } from "../domain/branches";
7
8
  import { buildCloseoutParentArgv, CLOSEOUT_STAGE_NAMES, closeoutChildEffect, closeoutCaptures, type CloseoutData, type CloseoutReceipt, type CloseoutRequest, type CloseoutStageName, type CloseoutRead, type CloseoutStage } from "../domain/closeout-contract";
8
9
  import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
@@ -29,7 +30,7 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
29
30
  };
30
31
  const push=(s:CloseoutStage)=>{if(data.stages.length>=16)throw Error();data.stages.push(s);data.unattemptedStages=data.unattemptedStages.filter(n=>n!==s.name);};
31
32
  const read=async(name:CloseoutStageName,argv:string[])=>{stage=name;const s:CloseoutRead={argv:[...argv],attempt:{...emptyWorkspaceMergeAttempt(),state:"unknown"},capture:null,admission:"unsupported",branch:null,parentPath:null,emptyParentResult:false,summary:null};push({name,kind:"read",result:s});const o=await captureWorkspaceMergeCommand([...argv],data.requested!.workdir);s.attempt=o.attempt;s.capture=o.capture;const success=o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.terminal==="observed"&&o.attempt.exitCode===0&&!o.attempt.aborted&&!o.attempt.timedOut&&o.stderr===""&&o.stdout!==null;return {s,o,success};};
32
- const loaded=async(name:CloseoutStageName,verify=false)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch)s.admission="admitted";}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
33
+ const loaded=async(name:CloseoutStageName,verify=false,mergedPending?:MergedPending|null)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch&&(!mergedPending||admitMergedStandardStatus(o.stdout!,mergedPending,data.sourceBranch!,data.requested!.workdir)))s.admission="admitted";else s.branch=null;}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
33
34
  const pending=async(name:"pending-before"|"pending-final")=>{const {s,o,success}=await read(name,[...switchPendingArgv]);if(success){const p=parseSwitchPending(o.stdout!,data.requested!.workdir);if(p.summary){s.admission="admitted";s.summary=p.summary;}}return s.summary;};
34
35
  try{
35
36
  const submitted=input&&typeof input==="object"&&!Array.isArray(input)?Object.freeze({...input}):input;admitRequest(submitted);const cwd=submitted.workdir??process.cwd();if(!safeSwitchValue(cwd)||!safeSwitchValue(resolve(cwd)))return finish("failed","invalid_request","Closeout needs a bounded workdir.");data.requested={source:submitted.source??null,target:submitted.target??null,message:submitted.message??null,cardRef:submitted.cardRef??null,strategy:submitted.strategy??"auto",updateTarget:submitted.updateTarget??true,includePrivate:submitted.includePrivate??false,preflight:submitted.preflight??false,outputFormat:submitted.format??"text",workdir:cwd};
@@ -60,9 +61,13 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
60
61
  if(data.requested.updateTarget){stage="update";childEntered=true;const updated=await assembleUpdateReceipt({workdir:cwd});push({name:"update",kind:"update",result:updated});childEntered=false;if(!updated.ok)return finish("failed","child_failed");}
61
62
  if(!await loaded("loaded-before-merge",true))return finish("failed","target_unverified");
62
63
  stage="merge";childEntered=true;const merged=await assembleWorkspaceMergeReceipt("merge",{source:data.sourceBranch!,strategy:data.requested.strategy,workdir:cwd});push({name:"merge",kind:"merge",result:merged});childEntered=false;if(!merged.ok)return finish("failed","child_failed");
63
- const copiedReady=merged.data.protocol==="apply-add-copied";
64
- const ready=await read("readiness",copiedReady?[...switchPendingArgv]:["status"]);if(!ready.success||ready.o.stderr!==""||!(copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
65
- if(!await loaded("loaded-before-checkin",true))return finish("failed","target_unverified");
64
+ const copiedReady=merged.data.protocol==="apply-add-copied",mergedReady=merged.data.protocol==="merged-candidate";
65
+ const ready=await read("readiness",copiedReady||mergedReady?[...switchPendingArgv]:["status"]);
66
+ const mergedPending=mergedReady&&ready.success?parseMergedPending(ready.o.stdout!,cwd):null;
67
+ if(!ready.success||ready.o.stderr!==""||!(mergedReady?!!mergedPending:copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
68
+ // The independent machine snapshot is not readiness proof by itself: the next
69
+ // full status must still correlate its source link, namespace and resolved rows.
70
+ if(!await loaded("loaded-before-checkin",true,mergedPending))return finish("failed","target_unverified");
66
71
  const checkinArgs={message:data.checkinMessage,includeAll:true,includePrivate:data.requested.includePrivate,workdir:cwd};
67
72
  stage="checkin-preflight";childEntered=true;const preview=await assembleCheckinReceipt({...checkinArgs,preflight:true});push({name:"checkin-preflight",kind:"checkin",result:preview});childEntered=false;if(!preview.ok)return finish("failed","child_failed");
68
73
  stage="checkin";childEntered=true;const checked=await assembleCheckinReceipt(checkinArgs);push({name:"checkin",kind:"checkin",result:checked});childEntered=false;data.createdChangeset=checked.data.createdChangeset;if(!checked.ok)return finish("failed","child_failed");
@@ -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.");
@@ -13,7 +13,7 @@ const capture = object({ stdoutBytes: uint(), stderrBytes: uint(), stdoutRetaine
13
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) });
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([
@@ -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;}
@@ -30,7 +30,7 @@ const xmlCount = Type.Integer({ minimum: 0, maximum: 20000 });
30
30
  const xmlSummary = { totalPending: xmlCount, added: xmlCount, changed: xmlCount, moved: xmlCount, deleted: xmlCount, other: Type.Literal(0), private: xmlCount, tracked: xmlCount };
31
31
  const xmlPathPattern = `^(?:/|[A-Za-z]:\\\\|\\\\\\\\)[^\\u0000-\\u001f${process.platform === "win32" ? "?" : ""}]*$`;
32
32
  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")]),
33
+ 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("PR")]),
34
34
  kind: Type.Union([Type.Literal("added"), Type.Literal("changed"), Type.Literal("moved"), Type.Literal("deleted"), Type.Literal("private")]),
35
35
  path: Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern }), isDirectory: Type.Boolean(),
36
36
  sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern })),
@@ -79,7 +79,7 @@ export function validateStatusOutput(value: unknown, schemaVersion: 1 | 2 = 1):
79
79
  const data = checked.data;
80
80
  const { parsed, returned, omitted, excluded, overCap } = data.itemCount;
81
81
  const seen = new Set<string>();
82
- const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
82
+ const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
83
83
  const invalidRows = data.items.some(row => {
84
84
  const unsafe = (path: string) => /[\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path);
85
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);
@@ -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>1||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;}