@aefree/pi-plastic 0.8.2 → 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,25 @@ 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
+
23
+ ## [0.8.3] - 2026-10-13
24
+
25
+ ### Fixed
26
+
27
+ - Accept strictly validated server analysis `APPLY ADD/FSPROT` and `DIS_OP_WARN CHG` records, expose discard-warning counts without inferring content equality, and distinguish malformed evidence from unsupported operations. Applying-merge verification remains unchanged.
28
+
10
29
  ## [0.8.2] - 2026-10-13
11
30
 
12
31
  - Add focused real-Pi codemode declaration coverage for existing Plastic input constraints, including explicit absence of schema patterns/defaults.
@@ -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
 
@@ -117,7 +118,7 @@ No source-link/head-exclusive/global/Xlink/rollback proof, retries, head unifica
117
118
 
118
119
  ### Agent conflict-resolution workflow
119
120
 
120
- 1. **Inspect:** call `mode="analyze"` to execute `cm merge <source> --to=<target>` without `--merge`. Unlike `preflight`, this contacts the server. Read `conflictPaths`, parse/capture completeness and omission counts. Empty, malformed, unknown-operation (including directory-conflict), warning or failed output remains uncertain, not a clean merge. `analyzed` means a supported observation, not absence of conflicts.
121
+ 1. **Inspect:** call `mode="analyze"` to execute `cm merge <source> --to=<target>` without `--merge`. Unlike `preflight`, this contacts the server. Read `conflictPaths`, parse/capture completeness and omission counts. Analysis supports the captured three-field `APPLY ADD/FSPROT` and `DIS_OP_WARN CHG` profiles with bounded repository-absolute paths. `parse.warningsObserved` counts supported CHG discard warnings; it does not prove identical contributor content. Empty, malformed, unsupported-operation/subtype (including directory-conflict), other warning or failed output remains uncertain, not a clean merge. These analysis profiles do not broaden applying-success verification. `analyzed` means a supported observation, not absence of conflicts.
121
122
  2. **Understand contributors:** independently inspect base/source/destination for each conflict. The exact destination conflict path is usable as a resolution key, not a workspace path. This tool does not retrieve content or assign meanings to the numeric FILE_CONFLICT fields. If needed, use a read-only `cm merge <qualified-source> --to=<qualified-target> --printcontributors` inspection, without `--merge`, to establish contributor identities. Use `plastic_diff(mode="revisions", ...)` for deliberate comparisons after verifying file/repository/server-qualified selectors. For full bytes unavailable through typed tools, follow the [historical export fallback](../skills/using-plastic/references/changeset-operations.md#historical-export-fallback): fresh external temporary destinations only, never canonical workspace files or guessed revision IDs. Writable xlinks need independently verified linked-repository identities; do not qualify their revision IDs against the root repository by assumption.
122
123
  3. **Resolve and validate:** write merged content into new local result files outside the workspace; preserve immutable contributor baselines. Review the result and run relevant validation before submission. Binary files may use `keep`, but choosing a side deliberately discards the other contributor's conflicting changes. Directory conflicts are not solved by this JSON feature; stop and choose a separately authorized workflow.
123
124
  4. **Submit:** create a UTF-8 JSON file as below and pass its absolute path as `fileConflictsResolutionsFile` with a merge message. Each `path` must exactly match its observed FILE_CONFLICT destination path, including any writable-xlink prefix. Each entry requires exactly one of `keep` (`source`/`destination`) or an absolute readable regular `resultFile`. Duplicate paths and unknown fields are rejected. Input validation caps JSON at 1 MiB / 500 entries and records its SHA-256 plus counts, not file contents. Applying rechecks the JSON hash and result-file readability after help, before dispatch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.8.2",
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 }));
@@ -209,9 +209,10 @@ export type ServerMergeParse = {
209
209
  changesets: ServerChangeset[];
210
210
  isAlreadyConnected: boolean;
211
211
  hasConflict: boolean;
212
+ warningsObserved: number;
212
213
  };
213
214
 
214
- export const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }): ServerMergeParse =>
215
+ export const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }, mode: "apply" | "analyze" = "apply"): ServerMergeParse =>
215
216
  {
216
217
  const records: ServerMergeRecord[] = [];
217
218
  const unknownOperations = new Set<string>();
@@ -258,10 +259,9 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
258
259
  else
259
260
  {
260
261
  records.push({ operation, fields });
261
- if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT"].includes(operation))
262
+ if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT", "APPLY", "DIS_OP_WARN"].includes(operation))
262
263
  {
263
264
  unknownOperations.add(operation);
264
- malformed = true;
265
265
  }
266
266
  }
267
267
  }
@@ -273,6 +273,7 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
273
273
  const safe = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
274
274
  let isAlreadyConnected = false;
275
275
  let hasConflict = false;
276
+ let warningsObserved = 0;
276
277
  for (const record of records)
277
278
  {
278
279
  switch (record.operation)
@@ -283,6 +284,28 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
283
284
  malformed = true;
284
285
  }
285
286
  break;
287
+ case "APPLY":
288
+ case "DIS_OP_WARN":
289
+ {
290
+ // Only captured analysis profiles are supported. CHG is an observed
291
+ // discard warning, not proof of identical contributor content.
292
+ const subtype = record.fields[1]!;
293
+ const supported = record.operation === "APPLY" ? ["ADD", "FSPROT"].includes(subtype) : subtype === "CHG";
294
+ const validFields = record.fields.length === 3 && record.fields[2]!.startsWith("/") && safe(record.fields[2]!);
295
+ if (!validFields)
296
+ {
297
+ malformed = true;
298
+ }
299
+ if (!supported || mode !== "analyze")
300
+ {
301
+ unknownOperations.add(record.operation + "/" + subtype);
302
+ }
303
+ else if (record.operation === "DIS_OP_WARN" && validFields)
304
+ {
305
+ warningsObserved++;
306
+ }
307
+ break;
308
+ }
286
309
  case "CHANGESET":
287
310
  {
288
311
  if (record.fields.length !== 2)
@@ -323,5 +346,5 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
323
346
  break;
324
347
  }
325
348
  }
326
- return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict };
349
+ return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict, warningsObserved };
327
350
  };
@@ -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 type MergeReceiptData = {
13
13
  capability: { state: "not-checked" | "advertised" | "missing" | "unavailable"; missingTokens: string[] };
14
14
  attempt: MergeAttempt;
15
15
  capture: { help: MergeCapture | null; merge: MergeCapture | null };
16
- parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number } | null;
16
+ parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number } | null;
17
17
  observedChangesets: ServerChangeset[];
18
18
  conflictPaths: string[];
19
19
  counts: { changesetsReturned: number; changesetsOmitted: number; conflictsReturned: number; conflictsOmitted: number };
@@ -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");
@@ -88,9 +88,9 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
88
88
  const obs = await captureServerMergeCommand(command, SERVER_MERGE_COMMAND_TIMEOUT_MS); data.attempt = obs.attempt; data.capture.merge = obs.capture;
89
89
  completeness.capture = obs.capture.complete ? "complete" : "incomplete";
90
90
  if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
91
- const parsed = parseServerMergeOutput(obs.stdout ?? "", seps);
91
+ const parsed = parseServerMergeOutput(obs.stdout ?? "", seps, data.mode);
92
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 };
93
+ data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved };
94
94
  // One aggregate evidence budget, not one budget per collection.
95
95
  data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
96
96
  data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
@@ -104,7 +104,8 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
104
104
  data.remoteAnalysis = "performed";
105
105
  return finish("analyzed", "merge");
106
106
  }
107
- return finish("uncertain", "merge", "uncertain", "Server conflict analysis could not be verified; no applying command was requested.");
107
+ 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
+ return finish("uncertain", "merge", "uncertain", reason + " No applying command was requested.");
108
109
  }
109
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"); }
110
111
  if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
@@ -119,7 +120,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
119
120
  }
120
121
  export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
121
122
  const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
122
- const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- Do not retry automatically; inspect server state if an attempt may have started."}`;
123
+ const 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."}`;
123
124
  const rendered = await formatServerMergeResult(format, dto.outcome, text, dto.data);
124
125
  return rendered.length <= 24000 ? rendered : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; use the registered tool structuredContent for the bounded receipt." });
125
126
  }
@@ -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;}
@@ -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) })),
21
+ parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: 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"]),
@@ -39,6 +39,7 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
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
41
  if (!d.conflictPaths.every(s => s.startsWith("/") && safeText(s))) return false;
42
+ if (p && (p.warningsObserved > p.records || d.mode === "apply" && p.warningsObserved > 0 && ["completed", "no-op", "conflict"].includes(dto.outcome))) return false;
42
43
  if (!p && (d.observedChangesets.length || d.conflictPaths.length || Object.values(d.counts).some(Boolean))) return false;
43
44
  if (dto.completeness.capture === "complete" && !(d.capture.merge?.complete ?? d.capture.help?.complete)) return false;
44
45
  if (dto.outcome === "preflight" && dto.completeness.capture !== "unknown") 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;}