@aefree/pi-plastic 0.8.2 → 0.8.3
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,12 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.8.3] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
10
16
|
## [0.8.2] - 2026-10-13
|
|
11
17
|
|
|
12
18
|
- Add focused real-Pi codemode declaration coverage for existing Plastic input constraints, including explicit absence of schema patterns/defaults.
|
|
@@ -117,7 +117,7 @@ No source-link/head-exclusive/global/Xlink/rollback proof, retries, head unifica
|
|
|
117
117
|
|
|
118
118
|
### Agent conflict-resolution workflow
|
|
119
119
|
|
|
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,
|
|
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. 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
121
|
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
122
|
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
123
|
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
|
@@ -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
|
};
|
|
@@ -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 };
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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;
|