proofpress 0.1.0 → 0.2.0
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/README.md +87 -215
- package/assets/logo-on-dark.svg +6 -0
- package/assets/logo-on-light.svg +6 -0
- package/package.json +6 -2
- package/proofpress.py +679 -68
- package/skills/README.md +34 -71
- package/skills/claude-code/proofpress/SKILL.md +14 -18
- package/skills/codex/AGENTS-snippet.md +14 -18
- package/skills/cursor/proofpress/SKILL.md +14 -18
- package/skills/pi/proofpress-skill.md +14 -18
package/proofpress.py
CHANGED
|
@@ -11,10 +11,12 @@ Commands:
|
|
|
11
11
|
log <file> [--json]
|
|
12
12
|
diff <file> [<vA> <vB>] [--json]
|
|
13
13
|
show <file-or-version> [--json]
|
|
14
|
-
verify <file> [<version>] [--json] claim
|
|
15
|
-
ingest <file>
|
|
16
|
-
merge-
|
|
17
|
-
|
|
14
|
+
verify <file> [<version>] [--json] claim check: exit 0 ✓ / 1 ⚠ / 2 no claims
|
|
15
|
+
ingest <file> backfill ledger versions from Git history
|
|
16
|
+
merge-plan <file> --from copy.md [...] analyze parallel portable copies
|
|
17
|
+
merge <file> --from copy.md [...] record a multi-parent document merge
|
|
18
|
+
merge-lineage <file> --from a.md --from b.md record other documents as ingredients
|
|
19
|
+
identify <file> recover identity after capsule stripping
|
|
18
20
|
policy / inspect / import / clean / capture
|
|
19
21
|
anchor / blocks / init / sync
|
|
20
22
|
"""
|
|
@@ -22,17 +24,19 @@ Commands:
|
|
|
22
24
|
import argparse, base64, difflib, hashlib, html, json, os, re, secrets, subprocess, sys, tempfile, zlib
|
|
23
25
|
from datetime import datetime, timezone
|
|
24
26
|
|
|
25
|
-
__version__ = "0.
|
|
27
|
+
__version__ = "0.2.0"
|
|
26
28
|
LEDGER_REF = "refs/proofpress/ledger"
|
|
27
29
|
|
|
28
30
|
# ---------- terminal rendering ----------
|
|
29
|
-
# Dark-theme palette from docs/DESIGN.md, emitted as 24-bit truecolor
|
|
30
|
-
#
|
|
31
|
+
# Dark-theme palette from docs/internal/DESIGN.md, emitted as 24-bit truecolor
|
|
32
|
+
# so the film, terminal, and future UI share one semantic system:
|
|
33
|
+
# accent = edit/modified, add = verified/added, del = rejected/removed,
|
|
34
|
+
# move = why/moved.
|
|
31
35
|
# Non-tty / NO_COLOR output stays plain text, so piped (agent) consumption is
|
|
32
36
|
# identical to the historical format.
|
|
33
37
|
|
|
34
|
-
TERM_HEX = {"accent": "5FB3C4", "add": "6FBF8E", "del": "
|
|
35
|
-
"move": "
|
|
38
|
+
TERM_HEX = {"accent": "5FB3C4", "add": "6FBF8E", "del": "C87E82",
|
|
39
|
+
"move": "D7B56D", "dim": "787B87"}
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
def _color_on():
|
|
@@ -144,19 +148,16 @@ CAPSULE_DISCOVERY = {
|
|
|
144
148
|
"dist_tag": "latest",
|
|
145
149
|
"requires_user_consent": True,
|
|
146
150
|
}
|
|
147
|
-
LEGACY_CAPSULE_DISCOVERIES = (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"package": "proofpress",
|
|
152
|
-
"dist_tag": "next",
|
|
153
|
-
"requires_user_consent": True,
|
|
154
|
-
},
|
|
155
|
-
)
|
|
151
|
+
LEGACY_CAPSULE_DISCOVERIES = ({
|
|
152
|
+
**CAPSULE_DISCOVERY,
|
|
153
|
+
"dist_tag": "next",
|
|
154
|
+
},)
|
|
156
155
|
|
|
157
156
|
|
|
158
|
-
def
|
|
159
|
-
|
|
157
|
+
def valid_capsule_discovery(discovery):
|
|
158
|
+
"""Accept canonical discovery plus exact historical Proofpress values."""
|
|
159
|
+
return (discovery == CAPSULE_DISCOVERY or
|
|
160
|
+
discovery in LEGACY_CAPSULE_DISCOVERIES)
|
|
160
161
|
|
|
161
162
|
|
|
162
163
|
def _b64e(data):
|
|
@@ -476,15 +477,55 @@ def parse_blocks(text, carrier="markdown"):
|
|
|
476
477
|
continue
|
|
477
478
|
m = ANCHOR.match(ln)
|
|
478
479
|
if m:
|
|
480
|
+
# Older ``anchor`` releases treated an indented continuation of a
|
|
481
|
+
# Markdown list item as a separate paragraph. They consequently
|
|
482
|
+
# inserted a redundant anchor between the item and its wrapped
|
|
483
|
+
# continuation. Fold that shape back into the list block so one
|
|
484
|
+
# more ``anchor`` pass repairs the source without changing prose.
|
|
485
|
+
if kind == "list":
|
|
486
|
+
j = i + 1
|
|
487
|
+
while j < len(lines) and not lines[j].strip():
|
|
488
|
+
j += 1
|
|
489
|
+
if (j < len(lines) and
|
|
490
|
+
re.match(r"^\s*([-*+]|\d+\.)\s", lines[j])):
|
|
491
|
+
i = j
|
|
492
|
+
continue
|
|
493
|
+
if (j < len(lines) and
|
|
494
|
+
not lines[j].strip().startswith("```") and
|
|
495
|
+
re.match(r"^\s+\S", lines[j]) and
|
|
496
|
+
not re.match(r"^\s*([-*+]|\d+\.)\s", lines[j])):
|
|
497
|
+
i = j
|
|
498
|
+
continue
|
|
479
499
|
flush(); pending_anchor = m.group(1) or m.group(2); i += 1; continue
|
|
480
500
|
if ln.strip().startswith("```"):
|
|
481
501
|
flush(); kind = "code"; cur = [ln]; i += 1; continue
|
|
482
502
|
if re.match(r"^#{1,6} ", ln):
|
|
483
503
|
flush(); emit({"type": "heading", "text": ln.rstrip()}); i += 1; continue
|
|
484
504
|
if not ln.strip():
|
|
505
|
+
if kind == "list":
|
|
506
|
+
j = i + 1
|
|
507
|
+
while j < len(lines) and not lines[j].strip():
|
|
508
|
+
j += 1
|
|
509
|
+
marker = ANCHOR.match(lines[j]) if j < len(lines) else None
|
|
510
|
+
if marker:
|
|
511
|
+
j += 1
|
|
512
|
+
while j < len(lines) and not lines[j].strip():
|
|
513
|
+
j += 1
|
|
514
|
+
if (marker and j < len(lines) and
|
|
515
|
+
re.match(r"^\s*([-*+]|\d+\.)\s", lines[j])):
|
|
516
|
+
i = j
|
|
517
|
+
continue
|
|
518
|
+
if (j < len(lines) and
|
|
519
|
+
not lines[j].strip().startswith("```") and
|
|
520
|
+
re.match(r"^\s+\S", lines[j]) and
|
|
521
|
+
not re.match(r"^\s*([-*+]|\d+\.)\s", lines[j])):
|
|
522
|
+
i = j
|
|
523
|
+
continue
|
|
485
524
|
flush(); i += 1; continue
|
|
486
525
|
this = "table" if ln.lstrip().startswith("|") else (
|
|
487
|
-
"list" if re.match(r"^\s*([-*+]|\d+\.)\s", ln)
|
|
526
|
+
"list" if (re.match(r"^\s*([-*+]|\d+\.)\s", ln) or
|
|
527
|
+
(kind == "list" and re.match(r"^\s+\S", ln)))
|
|
528
|
+
else "para")
|
|
488
529
|
if kind not in (None, this):
|
|
489
530
|
flush()
|
|
490
531
|
kind = kind or this
|
|
@@ -514,7 +555,7 @@ def version_id(version):
|
|
|
514
555
|
ensure_ascii=False).encode()).hexdigest()[:8]
|
|
515
556
|
|
|
516
557
|
|
|
517
|
-
# ---------- soft binding(
|
|
558
|
+
# ---------- soft binding (deterministic identity after metadata stripping) ----------
|
|
518
559
|
|
|
519
560
|
_FP_RAWTEXT = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
|
|
520
561
|
_FP_TAG = re.compile(r"<[^>]+>")
|
|
@@ -538,7 +579,7 @@ def soft_fingerprint(blocks):
|
|
|
538
579
|
return "ppsb1:" + hashlib.sha256(skeleton.encode()).hexdigest()
|
|
539
580
|
|
|
540
581
|
|
|
541
|
-
# ----------
|
|
582
|
+
# ---------- block identity matching (similarity fallback without anchors) ----------
|
|
542
583
|
|
|
543
584
|
def assign_ids(new_blocks, old_version):
|
|
544
585
|
"""Give each new block an id: exact-hash match → inherit; else best
|
|
@@ -580,7 +621,7 @@ def assign_ids(new_blocks, old_version):
|
|
|
580
621
|
return new_blocks
|
|
581
622
|
|
|
582
623
|
|
|
583
|
-
# ----------
|
|
624
|
+
# ---------- semantic diff (structure plus numeric extraction) ----------
|
|
584
625
|
|
|
585
626
|
# The lookbehind rejects date/range separators ("2026-07-18") and identifier
|
|
586
627
|
# digits ("v0", "sha1") — a data number never starts mid-word.
|
|
@@ -597,7 +638,8 @@ def heading_context(blocks, idx):
|
|
|
597
638
|
for j in range(idx, -1, -1):
|
|
598
639
|
if blocks[j]["type"] == "heading":
|
|
599
640
|
return re.sub(r"^#+\s*", "", blocks[j]["text"])[:40]
|
|
600
|
-
|
|
641
|
+
# Preserve the legacy wire label used by existing capsule change records.
|
|
642
|
+
return "(\u6587\u9996)"
|
|
601
643
|
|
|
602
644
|
|
|
603
645
|
def lis_ids(seq):
|
|
@@ -674,7 +716,7 @@ def word_diff(a, b, width=100, color=False):
|
|
|
674
716
|
return s if color else s[:width * 3]
|
|
675
717
|
|
|
676
718
|
|
|
677
|
-
# ----------
|
|
719
|
+
# ---------- ledger I/O (commit chain at refs/proofpress/ledger) ----------
|
|
678
720
|
|
|
679
721
|
def ledger_events():
|
|
680
722
|
try:
|
|
@@ -685,6 +727,12 @@ def ledger_events():
|
|
|
685
727
|
for sha in shas: # newest first
|
|
686
728
|
ev = json.loads(git("show", f"{sha}:event.json"))
|
|
687
729
|
ev["_commit"] = sha
|
|
730
|
+
if ev.get("event") == "version_created" and not ev.get("event_id"):
|
|
731
|
+
try:
|
|
732
|
+
ev["event_id"] = stable_event_id(
|
|
733
|
+
ev, json.loads(git("show", f"{sha}:version.json")))
|
|
734
|
+
except RuntimeError:
|
|
735
|
+
pass
|
|
688
736
|
evs.append(ev)
|
|
689
737
|
return evs
|
|
690
738
|
|
|
@@ -788,7 +836,8 @@ def check_attribution_basis(basis):
|
|
|
788
836
|
def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
789
837
|
text=None, ts=None, source_commit=None, artifact_id=None,
|
|
790
838
|
policy="local", actors=None, attribution_basis="unknown",
|
|
791
|
-
ingredients=None
|
|
839
|
+
ingredients=None, force_event=False,
|
|
840
|
+
prev_event_override=None, prev_version_override=None):
|
|
792
841
|
"""Core snapshot: file → block tree → ledger. Returns event or None (no change).
|
|
793
842
|
|
|
794
843
|
`claims` is the author's structured self-description of the change
|
|
@@ -805,12 +854,14 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
805
854
|
check_attribution_basis(attribution_basis)
|
|
806
855
|
if text is None:
|
|
807
856
|
text = open(path).read()
|
|
808
|
-
prev_ev =
|
|
809
|
-
|
|
857
|
+
prev_ev = (prev_event_override if prev_event_override is not None
|
|
858
|
+
else latest_for(path))
|
|
859
|
+
prev_v = (prev_version_override if prev_version_override is not None
|
|
860
|
+
else (read_version(prev_ev["_commit"]) if prev_ev else None))
|
|
810
861
|
blocks = assign_ids(parse_blocks(text, carrier_for_path(path)), prev_v)
|
|
811
862
|
version = {"artifact": path, "artifact_id": artifact_id, "blocks": blocks}
|
|
812
863
|
vid = version_id(version)
|
|
813
|
-
if prev_ev and prev_ev["version"] == vid:
|
|
864
|
+
if prev_ev and prev_ev["version"] == vid and not force_event:
|
|
814
865
|
return None
|
|
815
866
|
changes, stats = semantic_diff(prev_v, version)
|
|
816
867
|
event = {
|
|
@@ -841,6 +892,7 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
841
892
|
# engine can check WHAT changed against claims, but WHY is the
|
|
842
893
|
# author's account, recorded at submit time while it is still hot.
|
|
843
894
|
event["context"] = context
|
|
895
|
+
event["event_id"] = stable_event_id(event, version)
|
|
844
896
|
event["_commit_new"] = write_event(event, version)
|
|
845
897
|
event["_version_new"] = version
|
|
846
898
|
return event
|
|
@@ -850,6 +902,93 @@ def _public_event(event):
|
|
|
850
902
|
return {k: v for k, v in event.items() if not k.startswith("_")}
|
|
851
903
|
|
|
852
904
|
|
|
905
|
+
_EVENT_ID_EXCLUDED = {
|
|
906
|
+
"event_id", "artifact", "parent", "parents", "changes", "stats",
|
|
907
|
+
"policy", "portable_lineage_id", "imported_from_capsule",
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def stable_event_id(event, version):
|
|
912
|
+
"""Return a path- and graph-independent identity for public testimony.
|
|
913
|
+
|
|
914
|
+
Version IDs identify body states. Event IDs identify admissions of those
|
|
915
|
+
states, so two collaborators who independently reach identical text still
|
|
916
|
+
retain their distinct actors, reasons and timestamps after a DAG merge.
|
|
917
|
+
Graph edges and computed diffs are excluded because portable projection can
|
|
918
|
+
legitimately recompute them relative to a different public parent.
|
|
919
|
+
"""
|
|
920
|
+
testimony = {
|
|
921
|
+
k: v for k, v in _public_event(event).items()
|
|
922
|
+
if k not in _EVENT_ID_EXCLUDED
|
|
923
|
+
}
|
|
924
|
+
payload = {
|
|
925
|
+
"testimony": testimony,
|
|
926
|
+
"version": version_id(version),
|
|
927
|
+
"body_digest": body_digest(version),
|
|
928
|
+
}
|
|
929
|
+
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True,
|
|
930
|
+
separators=(",", ":")).encode()
|
|
931
|
+
return "ppe_" + hashlib.sha256(raw).hexdigest()[:24]
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _parent_edge(parent_event, parent_version, child_version):
|
|
935
|
+
changes, stats = semantic_diff(parent_version, child_version)
|
|
936
|
+
return {
|
|
937
|
+
"event_id": parent_event["event_id"],
|
|
938
|
+
"version": parent_event["version"],
|
|
939
|
+
"changes": changes,
|
|
940
|
+
"stats": stats,
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _v2_record(record, prior=None):
|
|
945
|
+
"""Normalize a legacy/v2 record without mutating the carrier payload."""
|
|
946
|
+
event = dict(record["event"])
|
|
947
|
+
version = dict(record["version"])
|
|
948
|
+
event["event_id"] = event.get("event_id") or stable_event_id(event, version)
|
|
949
|
+
if "parents" not in event:
|
|
950
|
+
event["parents"] = []
|
|
951
|
+
if prior is not None:
|
|
952
|
+
event["parents"].append(_parent_edge(
|
|
953
|
+
prior["event"], prior["version"], version))
|
|
954
|
+
if event["parents"]:
|
|
955
|
+
event["parent"] = event["parents"][0]["version"]
|
|
956
|
+
event["changes"] = event["parents"][0]["changes"]
|
|
957
|
+
event["stats"] = event["parents"][0]["stats"]
|
|
958
|
+
else:
|
|
959
|
+
event["parent"] = None
|
|
960
|
+
return {"event": event, "version": version}
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def capsule_v2(capsule):
|
|
964
|
+
"""Return a deterministic DAG projection of either capsule generation."""
|
|
965
|
+
records = []
|
|
966
|
+
prior = None
|
|
967
|
+
for raw in capsule.get("records") or []:
|
|
968
|
+
record = _v2_record(raw, prior)
|
|
969
|
+
records.append(record)
|
|
970
|
+
prior = record
|
|
971
|
+
out = dict(capsule)
|
|
972
|
+
out["proofpress_capsule"] = 2
|
|
973
|
+
out["records"] = records
|
|
974
|
+
if records:
|
|
975
|
+
event_ids = {r["event"]["event_id"] for r in records}
|
|
976
|
+
requested = capsule.get("head_event")
|
|
977
|
+
out["head_event"] = (requested if requested in event_ids
|
|
978
|
+
else records[-1]["event"]["event_id"])
|
|
979
|
+
by_event = {r["event"]["event_id"]: r for r in records}
|
|
980
|
+
out["head"] = by_event[out["head_event"]]["event"]["version"]
|
|
981
|
+
return out
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _record_map(capsule):
|
|
985
|
+
normalized = capsule_v2(capsule)
|
|
986
|
+
return {
|
|
987
|
+
r["event"]["event_id"]: r
|
|
988
|
+
for r in normalized.get("records", [])
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
|
|
853
992
|
def _actors_from_args(a):
|
|
854
993
|
actors = {}
|
|
855
994
|
for field in ("requested_by", "produced_by", "edited_by", "recorded_by"):
|
|
@@ -886,12 +1025,15 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
|
886
1025
|
"soft_fingerprint": soft_fingerprint(blocks),
|
|
887
1026
|
"portable_checkpoint": True,
|
|
888
1027
|
"portable_lineage_id": lineage,
|
|
1028
|
+
"parents": [],
|
|
889
1029
|
}
|
|
1030
|
+
event["event_id"] = stable_event_id(event, version)
|
|
890
1031
|
return {
|
|
891
|
-
"proofpress_capsule":
|
|
1032
|
+
"proofpress_capsule": 2,
|
|
892
1033
|
"artifact_id": meta["artifact_id"],
|
|
893
1034
|
"portable_lineage_id": lineage,
|
|
894
1035
|
"head": vid,
|
|
1036
|
+
"head_event": event["event_id"],
|
|
895
1037
|
"body_digest": body_digest(version),
|
|
896
1038
|
"discovery": dict(CAPSULE_DISCOVERY),
|
|
897
1039
|
"records": [{"event": event, "version": version}],
|
|
@@ -904,10 +1046,11 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
904
1046
|
return ["missing_meta"]
|
|
905
1047
|
if not capsule:
|
|
906
1048
|
return ["missing_capsule"]
|
|
907
|
-
|
|
1049
|
+
schema = capsule.get("proofpress_capsule")
|
|
1050
|
+
if schema not in (1, 2):
|
|
908
1051
|
errors.append("unsupported_capsule")
|
|
909
1052
|
if ("discovery" in capsule and
|
|
910
|
-
not
|
|
1053
|
+
not valid_capsule_discovery(capsule.get("discovery"))):
|
|
911
1054
|
errors.append("invalid_capsule_discovery")
|
|
912
1055
|
if capsule.get("artifact_id") != meta.get("artifact_id"):
|
|
913
1056
|
errors.append("artifact_id_mismatch")
|
|
@@ -916,7 +1059,27 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
916
1059
|
records = capsule.get("records")
|
|
917
1060
|
if not isinstance(records, list) or not records:
|
|
918
1061
|
return errors + ["missing_records"]
|
|
919
|
-
|
|
1062
|
+
malformed = [
|
|
1063
|
+
i for i, record in enumerate(records)
|
|
1064
|
+
if (not isinstance(record, dict) or
|
|
1065
|
+
not isinstance(record.get("event"), dict) or
|
|
1066
|
+
not isinstance(record.get("version"), dict))
|
|
1067
|
+
]
|
|
1068
|
+
if malformed:
|
|
1069
|
+
return errors + [f"invalid_record:{i}" for i in malformed]
|
|
1070
|
+
if schema == 1:
|
|
1071
|
+
prev_id = None
|
|
1072
|
+
for i, record in enumerate(records):
|
|
1073
|
+
event = record.get("event") if isinstance(record, dict) else None
|
|
1074
|
+
if isinstance(event, dict) and event.get("parent") != prev_id:
|
|
1075
|
+
errors.append(f"chain_mismatch:{i}")
|
|
1076
|
+
version = record.get("version") if isinstance(record, dict) else None
|
|
1077
|
+
if isinstance(version, dict):
|
|
1078
|
+
prev_id = version_id(version)
|
|
1079
|
+
|
|
1080
|
+
normalized = capsule_v2(capsule)
|
|
1081
|
+
records = normalized["records"]
|
|
1082
|
+
seen, referenced = {}, set()
|
|
920
1083
|
for i, record in enumerate(records):
|
|
921
1084
|
event, version = record.get("event"), record.get("version")
|
|
922
1085
|
if not isinstance(event, dict) or not isinstance(version, dict):
|
|
@@ -926,20 +1089,55 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
926
1089
|
errors.append(f"version_id_mismatch:{i}")
|
|
927
1090
|
if event.get("artifact_id") != capsule.get("artifact_id"):
|
|
928
1091
|
errors.append(f"record_artifact_mismatch:{i}")
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if event.get("
|
|
933
|
-
errors.append(f"
|
|
934
|
-
|
|
935
|
-
|
|
1092
|
+
expected_event_id = stable_event_id(event, version)
|
|
1093
|
+
if event.get("event_id") != expected_event_id:
|
|
1094
|
+
errors.append(f"event_id_mismatch:{i}")
|
|
1095
|
+
if event.get("event_id") in seen:
|
|
1096
|
+
errors.append(f"duplicate_event:{i}")
|
|
1097
|
+
parents = event.get("parents")
|
|
1098
|
+
if not isinstance(parents, list):
|
|
1099
|
+
errors.append(f"invalid_parents:{i}")
|
|
1100
|
+
parents = []
|
|
1101
|
+
if not parents:
|
|
1102
|
+
computed, stats = semantic_diff(None, version)
|
|
1103
|
+
if event.get("changes") != computed or event.get("stats") != stats:
|
|
1104
|
+
errors.append(f"change_mismatch:{i}")
|
|
1105
|
+
for j, edge in enumerate(parents):
|
|
1106
|
+
parent = seen.get(edge.get("event_id")) if isinstance(edge, dict) else None
|
|
1107
|
+
if parent is None:
|
|
1108
|
+
errors.append(f"missing_or_late_parent:{i}:{j}")
|
|
1109
|
+
continue
|
|
1110
|
+
referenced.add(edge["event_id"])
|
|
1111
|
+
if edge.get("version") != parent["event"]["version"]:
|
|
1112
|
+
errors.append(f"parent_version_mismatch:{i}:{j}")
|
|
1113
|
+
computed, stats = semantic_diff(parent["version"], version)
|
|
1114
|
+
if edge.get("changes") != computed or edge.get("stats") != stats:
|
|
1115
|
+
errors.append(f"parent_change_mismatch:{i}:{j}")
|
|
1116
|
+
if parents:
|
|
1117
|
+
primary = parents[0]
|
|
1118
|
+
if (event.get("parent") != primary.get("version") or
|
|
1119
|
+
event.get("changes") != primary.get("changes") or
|
|
1120
|
+
event.get("stats") != primary.get("stats")):
|
|
1121
|
+
errors.append(f"primary_parent_mismatch:{i}")
|
|
1122
|
+
seen[event.get("event_id")] = record
|
|
1123
|
+
|
|
1124
|
+
tips = set(seen) - referenced
|
|
1125
|
+
head_event = normalized.get("head_event")
|
|
1126
|
+
if len(tips) != 1:
|
|
1127
|
+
errors.append("multiple_heads")
|
|
1128
|
+
if head_event not in tips:
|
|
1129
|
+
errors.append("head_event_mismatch")
|
|
1130
|
+
head_record = seen.get(head_event)
|
|
1131
|
+
head_version = head_record["version"] if head_record else None
|
|
1132
|
+
head_id = head_record["event"]["version"] if head_record else None
|
|
1133
|
+
if capsule.get("head") != head_id:
|
|
936
1134
|
errors.append("head_mismatch")
|
|
937
|
-
if
|
|
1135
|
+
if head_version and capsule.get("body_digest") != body_digest(head_version):
|
|
938
1136
|
errors.append("capsule_body_digest_mismatch")
|
|
939
|
-
current_blocks = assign_ids(parse_blocks(body, carrier),
|
|
940
|
-
current_v = {"artifact":
|
|
1137
|
+
current_blocks = assign_ids(parse_blocks(body, carrier), head_version)
|
|
1138
|
+
current_v = {"artifact": head_version.get("artifact") if head_version else "",
|
|
941
1139
|
"artifact_id": meta.get("artifact_id"), "blocks": current_blocks}
|
|
942
|
-
if
|
|
1140
|
+
if head_version and body_digest(current_v) != capsule.get("body_digest"):
|
|
943
1141
|
errors.append("body_mismatch")
|
|
944
1142
|
return errors
|
|
945
1143
|
|
|
@@ -957,7 +1155,9 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
957
1155
|
if blocking:
|
|
958
1156
|
raise SystemExit("cannot extend invalid capsule: " + ", ".join(blocking))
|
|
959
1157
|
capsule["discovery"] = dict(CAPSULE_DISCOVERY)
|
|
960
|
-
|
|
1158
|
+
normalized = capsule_v2(capsule)
|
|
1159
|
+
prior_record = _record_map(normalized)[normalized["head_event"]]
|
|
1160
|
+
prior_v = prior_record["version"]
|
|
961
1161
|
if version_id(version) == capsule["head"]:
|
|
962
1162
|
capsule["body_digest"] = body_digest(version)
|
|
963
1163
|
return capsule
|
|
@@ -968,8 +1168,14 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
968
1168
|
portable_event["stats"] = stats
|
|
969
1169
|
portable_event["policy"] = "portable"
|
|
970
1170
|
portable_event["portable_lineage_id"] = capsule["portable_lineage_id"]
|
|
1171
|
+
if capsule.get("proofpress_capsule") == 2:
|
|
1172
|
+
edge = _parent_edge(prior_record["event"], prior_v, version)
|
|
1173
|
+
portable_event["parents"] = [edge]
|
|
1174
|
+
portable_event["event_id"] = stable_event_id(portable_event, version)
|
|
971
1175
|
capsule["records"].append({"event": portable_event, "version": version})
|
|
972
1176
|
capsule["head"] = portable_event["version"]
|
|
1177
|
+
if capsule.get("proofpress_capsule") == 2:
|
|
1178
|
+
capsule["head_event"] = portable_event["event_id"]
|
|
973
1179
|
capsule["body_digest"] = body_digest(version)
|
|
974
1180
|
return capsule
|
|
975
1181
|
|
|
@@ -993,6 +1199,13 @@ def cmd_snapshot(a):
|
|
|
993
1199
|
raise SystemExit(
|
|
994
1200
|
f"stale base: expected {a.base_version}, current "
|
|
995
1201
|
f"{current or '∅'}; inspect and reconcile before snapshot")
|
|
1202
|
+
if getattr(a, "base_event", None):
|
|
1203
|
+
current_event = (capsule_v2(capsule).get("head_event")
|
|
1204
|
+
if capsule else None)
|
|
1205
|
+
if current_event != a.base_event:
|
|
1206
|
+
raise SystemExit(
|
|
1207
|
+
f"stale base event: expected {a.base_event}, current "
|
|
1208
|
+
f"{current_event or '∅'}; inspect and reconcile before snapshot")
|
|
996
1209
|
# Persist identity even for local artifacts; this is not a portable capsule.
|
|
997
1210
|
if artifact_id_at(a.file) is None:
|
|
998
1211
|
write_artifact(a.file, body, meta, capsule)
|
|
@@ -1010,11 +1223,24 @@ def cmd_snapshot(a):
|
|
|
1010
1223
|
if a.rejected:
|
|
1011
1224
|
context["rejected"] = a.rejected
|
|
1012
1225
|
actors = _actors_from_args(a)
|
|
1226
|
+
force_event = False
|
|
1227
|
+
portable_prev_event = portable_prev_version = None
|
|
1228
|
+
if meta["policy"] == "portable" and capsule is not None:
|
|
1229
|
+
cap_state = validate_capsule(
|
|
1230
|
+
body, meta, capsule, carrier_for_path(a.file))
|
|
1231
|
+
force_event = "body_mismatch" in cap_state
|
|
1232
|
+
normalized = capsule_v2(capsule)
|
|
1233
|
+
head_record = _record_map(normalized)[normalized["head_event"]]
|
|
1234
|
+
portable_prev_event = head_record["event"]
|
|
1235
|
+
portable_prev_version = head_record["version"]
|
|
1013
1236
|
ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
|
|
1014
1237
|
claims=claims, context=context, text=body,
|
|
1015
1238
|
artifact_id=meta["artifact_id"], policy=meta["policy"],
|
|
1016
1239
|
actors=actors, attribution_basis=a.attribution_basis,
|
|
1017
|
-
ingredients=getattr(a, "ingredients", None)
|
|
1240
|
+
ingredients=getattr(a, "ingredients", None),
|
|
1241
|
+
force_event=force_event,
|
|
1242
|
+
prev_event_override=portable_prev_event,
|
|
1243
|
+
prev_version_override=portable_prev_version)
|
|
1018
1244
|
if not ev:
|
|
1019
1245
|
if meta["policy"] == "portable" and capsule is None and recovered:
|
|
1020
1246
|
capsule = make_checkpoint_capsule(
|
|
@@ -1022,10 +1248,21 @@ def cmd_snapshot(a):
|
|
|
1022
1248
|
recorded_by=(actors.get("recorded_by") or [a.author])[0],
|
|
1023
1249
|
attribution_basis=a.attribution_basis)
|
|
1024
1250
|
meta["portable_head"] = capsule["head"]
|
|
1251
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1025
1252
|
write_artifact(a.file, body, meta, capsule)
|
|
1026
1253
|
print(f"repaired capsule: {a.file} -> {capsule['head']}")
|
|
1027
1254
|
return
|
|
1028
1255
|
if meta["policy"] == "portable" and capsule is not None:
|
|
1256
|
+
discovery = capsule.get("discovery")
|
|
1257
|
+
if (discovery is None or
|
|
1258
|
+
(valid_capsule_discovery(discovery) and
|
|
1259
|
+
discovery != CAPSULE_DISCOVERY)):
|
|
1260
|
+
capsule["discovery"] = dict(CAPSULE_DISCOVERY)
|
|
1261
|
+
write_artifact(a.file, body, meta, capsule)
|
|
1262
|
+
print(f"upgraded capsule discovery: {a.file} -> "
|
|
1263
|
+
f"{CAPSULE_DISCOVERY['package']}@"
|
|
1264
|
+
f"{CAPSULE_DISCOVERY['dist_tag']}")
|
|
1265
|
+
return
|
|
1029
1266
|
latest = latest_for(a.file)
|
|
1030
1267
|
if latest and capsule.get("head") != latest.get("version"):
|
|
1031
1268
|
version = read_version(latest["_commit"])
|
|
@@ -1036,6 +1273,7 @@ def cmd_snapshot(a):
|
|
|
1036
1273
|
capsule = append_capsule(a.file, body, meta, capsule,
|
|
1037
1274
|
latest, version)
|
|
1038
1275
|
meta["portable_head"] = capsule["head"]
|
|
1276
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1039
1277
|
write_artifact(a.file, body, meta, capsule)
|
|
1040
1278
|
print(f"repaired capsule: {a.file} -> {capsule['head']}")
|
|
1041
1279
|
return
|
|
@@ -1044,6 +1282,7 @@ def cmd_snapshot(a):
|
|
|
1044
1282
|
capsule = append_capsule(a.file, body, meta, capsule, ev,
|
|
1045
1283
|
ev["_version_new"])
|
|
1046
1284
|
meta["portable_head"] = capsule["head"]
|
|
1285
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1047
1286
|
write_artifact(a.file, body, meta, capsule)
|
|
1048
1287
|
n = f"v{len(versions_of(a.file))}"
|
|
1049
1288
|
print(f"✓ {n} ({ev['version']}) ← {ev['parent'] or '∅'} [{ev['_commit_new'][:8]} @ {LEDGER_REF}]")
|
|
@@ -1079,6 +1318,8 @@ def cmd_log(a):
|
|
|
1079
1318
|
f'{C(color, txt.ljust(statw))}')
|
|
1080
1319
|
if ev.get("note"):
|
|
1081
1320
|
line += f" {ev['note']}"
|
|
1321
|
+
if len(ev.get("parents") or []) > 1:
|
|
1322
|
+
line += C("dim", f" merge={len(ev['parents'])} parents")
|
|
1082
1323
|
if who.get("session"):
|
|
1083
1324
|
line += C("dim", f" session={who['session']}")
|
|
1084
1325
|
print(line)
|
|
@@ -1086,11 +1327,21 @@ def cmd_log(a):
|
|
|
1086
1327
|
|
|
1087
1328
|
def _find(evs, prefix):
|
|
1088
1329
|
for ev in evs:
|
|
1089
|
-
if ev
|
|
1330
|
+
if ((ev.get("event_id") or "").startswith(prefix) or
|
|
1331
|
+
ev["version"].startswith(prefix)):
|
|
1090
1332
|
return ev
|
|
1091
1333
|
raise SystemExit(f"version not found: {prefix}")
|
|
1092
1334
|
|
|
1093
1335
|
|
|
1336
|
+
def _primary_parent_event(event, events):
|
|
1337
|
+
parents = event.get("parents") or []
|
|
1338
|
+
if parents:
|
|
1339
|
+
parent_id = parents[0].get("event_id")
|
|
1340
|
+
return next((e for e in events if e.get("event_id") == parent_id), None)
|
|
1341
|
+
parent_version = event.get("parent")
|
|
1342
|
+
return next((e for e in events if e.get("version") == parent_version), None)
|
|
1343
|
+
|
|
1344
|
+
|
|
1094
1345
|
def change_label(c):
|
|
1095
1346
|
"""Row title + secondary context for a change — one formatter shared by
|
|
1096
1347
|
the tty and markdown renderers (the Action must never re-implement this)."""
|
|
@@ -1129,11 +1380,12 @@ def cmd_diff(a):
|
|
|
1129
1380
|
base_note = ""
|
|
1130
1381
|
if a.base_commit and not a.va:
|
|
1131
1382
|
hit = next((e for e in evs if (e.get("source_commit") or "").startswith(a.base_commit)), None)
|
|
1132
|
-
ev_a = hit or evs[1]
|
|
1383
|
+
ev_a = hit or _primary_parent_event(ev_b, evs) or evs[1]
|
|
1133
1384
|
if not hit:
|
|
1134
1385
|
base_note = "base commit not in ledger — showing last recorded change"
|
|
1135
1386
|
else:
|
|
1136
|
-
ev_a = _find(evs, a.va) if a.va else
|
|
1387
|
+
ev_a = (_find(evs, a.va) if a.va else
|
|
1388
|
+
(_primary_parent_event(ev_b, evs) or evs[1]))
|
|
1137
1389
|
va, vb = read_version(ev_a["_commit"]), read_version(ev_b["_commit"])
|
|
1138
1390
|
changes, stats = semantic_diff(va, vb)
|
|
1139
1391
|
if a.json:
|
|
@@ -1202,7 +1454,9 @@ def cmd_diff(a):
|
|
|
1202
1454
|
print(" " + ln)
|
|
1203
1455
|
# this version's self-description travels with the diff (provenance layer v0)
|
|
1204
1456
|
if ev_b.get("note") or ev_b.get("context"):
|
|
1205
|
-
n_b = len(evs) - next(
|
|
1457
|
+
n_b = len(evs) - next(
|
|
1458
|
+
i for i, e in enumerate(evs)
|
|
1459
|
+
if e.get("event_id") == ev_b.get("event_id"))
|
|
1206
1460
|
who = ev_b["author"]
|
|
1207
1461
|
print()
|
|
1208
1462
|
if ev_b.get("note"):
|
|
@@ -1210,9 +1464,9 @@ def cmd_diff(a):
|
|
|
1210
1464
|
+ ev_b["note"])
|
|
1211
1465
|
ctx_b = ev_b.get("context") or {}
|
|
1212
1466
|
if ctx_b.get("why"):
|
|
1213
|
-
print(" " + C("
|
|
1467
|
+
print(" " + C("move", "why: ") + ctx_b["why"])
|
|
1214
1468
|
for r in ctx_b.get("rejected", []):
|
|
1215
|
-
print(" " + C("
|
|
1469
|
+
print(" " + C("del", "rejected: ") + r)
|
|
1216
1470
|
if ev_b.get("claims") is not None:
|
|
1217
1471
|
print(" " + C("dim", f'claims: {len(ev_b["claims"])} recorded — `proofpress verify {a.file} {ev_b["version"]}`'))
|
|
1218
1472
|
|
|
@@ -1266,6 +1520,296 @@ def cmd_ingest(a):
|
|
|
1266
1520
|
print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
|
|
1267
1521
|
|
|
1268
1522
|
|
|
1523
|
+
def _portable_merge_input(path, allow_body_mismatch=False):
|
|
1524
|
+
if not os.path.isfile(path):
|
|
1525
|
+
raise SystemExit(f"merge input not found: {path}")
|
|
1526
|
+
body, meta, capsule, errors = read_artifact(path)
|
|
1527
|
+
if errors:
|
|
1528
|
+
raise SystemExit(f"merge input {path}: invalid metadata: " + ", ".join(errors))
|
|
1529
|
+
if not meta or meta.get("policy") != "portable" or not capsule:
|
|
1530
|
+
raise SystemExit(f"merge input {path} must be a portable Proofpress artifact")
|
|
1531
|
+
cap_errors = validate_capsule(body, meta, capsule, carrier_for_path(path))
|
|
1532
|
+
blocking = [e for e in cap_errors
|
|
1533
|
+
if not (allow_body_mismatch and e == "body_mismatch")]
|
|
1534
|
+
if blocking:
|
|
1535
|
+
raise SystemExit(f"merge input {path}: invalid capsule: " + ", ".join(blocking))
|
|
1536
|
+
normalized = capsule_v2(capsule)
|
|
1537
|
+
records = _record_map(normalized)
|
|
1538
|
+
return {
|
|
1539
|
+
"path": path, "body": body, "meta": meta, "capsule": normalized,
|
|
1540
|
+
"records": records, "head_event": normalized["head_event"],
|
|
1541
|
+
"head_record": records[normalized["head_event"]],
|
|
1542
|
+
"body_mismatch": "body_mismatch" in cap_errors,
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _merge_inputs(target, sources, target_may_drift=True):
|
|
1547
|
+
paths = [target] + list(sources)
|
|
1548
|
+
if len(set(os.path.abspath(p) for p in paths)) != len(paths):
|
|
1549
|
+
raise SystemExit("merge inputs must be distinct files")
|
|
1550
|
+
items = [_portable_merge_input(target, target_may_drift)]
|
|
1551
|
+
items.extend(_portable_merge_input(p) for p in sources)
|
|
1552
|
+
artifact_ids = {x["meta"]["artifact_id"] for x in items}
|
|
1553
|
+
lineages = {x["meta"].get("portable_lineage_id") for x in items}
|
|
1554
|
+
carriers = {carrier_for_path(x["path"]) for x in items}
|
|
1555
|
+
if len(artifact_ids) != 1:
|
|
1556
|
+
raise SystemExit("merge inputs are different artifacts; use merge-lineage "
|
|
1557
|
+
"when one document incorporates another")
|
|
1558
|
+
if len(lineages) != 1 or None in lineages:
|
|
1559
|
+
raise SystemExit("merge inputs have different portable lineages; "
|
|
1560
|
+
"use merge-lineage or start from a shared portable copy")
|
|
1561
|
+
if len(carriers) != 1:
|
|
1562
|
+
raise SystemExit("merge inputs must use the same carrier type")
|
|
1563
|
+
heads = [x["head_event"] for x in items]
|
|
1564
|
+
if len(set(heads)) != len(heads):
|
|
1565
|
+
raise SystemExit("merge inputs contain duplicate heads")
|
|
1566
|
+
return items
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _ancestor_ids(records, head):
|
|
1570
|
+
out, stack = set(), [head]
|
|
1571
|
+
while stack:
|
|
1572
|
+
event_id = stack.pop()
|
|
1573
|
+
if event_id in out:
|
|
1574
|
+
continue
|
|
1575
|
+
record = records.get(event_id)
|
|
1576
|
+
if record is None:
|
|
1577
|
+
continue
|
|
1578
|
+
out.add(event_id)
|
|
1579
|
+
stack.extend(edge["event_id"]
|
|
1580
|
+
for edge in record["event"].get("parents", []))
|
|
1581
|
+
return out
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _merge_base(items):
|
|
1585
|
+
combined = {}
|
|
1586
|
+
for item in items:
|
|
1587
|
+
for event_id, record in item["records"].items():
|
|
1588
|
+
previous = combined.get(event_id)
|
|
1589
|
+
if previous and previous != record:
|
|
1590
|
+
raise SystemExit(f"event {event_id} differs between capsules")
|
|
1591
|
+
combined[event_id] = record
|
|
1592
|
+
common = set.intersection(*(
|
|
1593
|
+
_ancestor_ids(combined, item["head_event"]) for item in items))
|
|
1594
|
+
if not common:
|
|
1595
|
+
raise SystemExit("merge inputs have no common portable ancestor")
|
|
1596
|
+
lowest = []
|
|
1597
|
+
ancestry = {event_id: _ancestor_ids(combined, event_id)
|
|
1598
|
+
for event_id in common}
|
|
1599
|
+
for candidate in common:
|
|
1600
|
+
if not any(candidate != other and candidate in ancestry[other]
|
|
1601
|
+
for other in common):
|
|
1602
|
+
lowest.append(candidate)
|
|
1603
|
+
if len(lowest) != 1:
|
|
1604
|
+
raise SystemExit("merge inputs have multiple merge bases; merge a smaller "
|
|
1605
|
+
"set of copies first")
|
|
1606
|
+
return lowest[0], combined
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def _block_state(version):
|
|
1610
|
+
return {b["id"]: b for b in version["blocks"]}
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _merge_plan_data(target, sources):
|
|
1614
|
+
items = _merge_inputs(target, sources)
|
|
1615
|
+
base_event, records = _merge_base(items)
|
|
1616
|
+
base_record = records[base_event]
|
|
1617
|
+
base_states = _block_state(base_record["version"])
|
|
1618
|
+
heads = [_block_state(x["head_record"]["version"]) for x in items]
|
|
1619
|
+
all_ids = set(base_states)
|
|
1620
|
+
for states in heads:
|
|
1621
|
+
all_ids.update(states)
|
|
1622
|
+
compatible, conflicts = [], []
|
|
1623
|
+
for block_id in sorted(all_ids):
|
|
1624
|
+
base = base_states.get(block_id)
|
|
1625
|
+
changed = []
|
|
1626
|
+
for item, states in zip(items, heads):
|
|
1627
|
+
state = states.get(block_id)
|
|
1628
|
+
if state != base:
|
|
1629
|
+
changed.append({"path": item["path"], "state": state})
|
|
1630
|
+
if not changed:
|
|
1631
|
+
continue
|
|
1632
|
+
distinct = {
|
|
1633
|
+
json.dumps(c["state"], ensure_ascii=False, sort_keys=True)
|
|
1634
|
+
for c in changed
|
|
1635
|
+
}
|
|
1636
|
+
entry = {
|
|
1637
|
+
"block": block_id,
|
|
1638
|
+
"base": base,
|
|
1639
|
+
"branches": changed,
|
|
1640
|
+
}
|
|
1641
|
+
if len(changed) == 1 or len(distinct) == 1:
|
|
1642
|
+
entry["reason"] = ("single_branch_change" if len(changed) == 1
|
|
1643
|
+
else "identical_result")
|
|
1644
|
+
compatible.append(entry)
|
|
1645
|
+
else:
|
|
1646
|
+
entry["reason"] = "divergent_block_change"
|
|
1647
|
+
conflicts.append(entry)
|
|
1648
|
+
|
|
1649
|
+
# Distinct concurrently-added blocks are content-compatible, but their
|
|
1650
|
+
# relative placement cannot be inferred safely from the block tree alone.
|
|
1651
|
+
additions = {}
|
|
1652
|
+
for item in items:
|
|
1653
|
+
changes, _ = semantic_diff(base_record["version"],
|
|
1654
|
+
item["head_record"]["version"])
|
|
1655
|
+
for change in changes:
|
|
1656
|
+
if change["kind"] == "added":
|
|
1657
|
+
additions.setdefault(change.get("context", ""), []).append(
|
|
1658
|
+
{"path": item["path"], "block": change["block"]})
|
|
1659
|
+
for context, entries in additions.items():
|
|
1660
|
+
if len({e["path"] for e in entries}) > 1:
|
|
1661
|
+
conflicts.append({
|
|
1662
|
+
"kind": "parallel_insert_order",
|
|
1663
|
+
"context": context,
|
|
1664
|
+
"branches": entries,
|
|
1665
|
+
"reason": "parallel_insert_order",
|
|
1666
|
+
})
|
|
1667
|
+
|
|
1668
|
+
return {
|
|
1669
|
+
"status": "conflicts" if conflicts else "clean",
|
|
1670
|
+
"artifact_id": items[0]["meta"]["artifact_id"],
|
|
1671
|
+
"portable_lineage_id": items[0]["meta"]["portable_lineage_id"],
|
|
1672
|
+
"base_event": base_event,
|
|
1673
|
+
"base_version": base_record["event"]["version"],
|
|
1674
|
+
"branches": [
|
|
1675
|
+
{"path": x["path"], "head_event": x["head_event"],
|
|
1676
|
+
"head": x["head_record"]["event"]["version"]}
|
|
1677
|
+
for x in items
|
|
1678
|
+
],
|
|
1679
|
+
"compatible": compatible,
|
|
1680
|
+
"conflicts": conflicts,
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def cmd_merge_plan(a):
|
|
1685
|
+
plan = _merge_plan_data(a.file, a.source)
|
|
1686
|
+
if a.json:
|
|
1687
|
+
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
1688
|
+
return
|
|
1689
|
+
print(f"merge-plan {a.file}: {plan['status']}")
|
|
1690
|
+
print(f" common ancestor: {plan['base_version']} "
|
|
1691
|
+
f"({plan['base_event']})")
|
|
1692
|
+
for branch in plan["branches"]:
|
|
1693
|
+
print(f" branch: {branch['path']} -> {branch['head']} "
|
|
1694
|
+
f"({branch['head_event']})")
|
|
1695
|
+
print(f" compatible changes: {len(plan['compatible'])}")
|
|
1696
|
+
print(f" conflicts: {len(plan['conflicts'])}")
|
|
1697
|
+
for conflict in plan["conflicts"]:
|
|
1698
|
+
if conflict.get("kind") == "parallel_insert_order":
|
|
1699
|
+
print(f" insertion order under {conflict.get('context') or '(document start)'}")
|
|
1700
|
+
else:
|
|
1701
|
+
print(f" block {conflict['block']}: divergent changes")
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _topological_records(records):
|
|
1705
|
+
pending = dict(records)
|
|
1706
|
+
emitted, out = set(), []
|
|
1707
|
+
while pending:
|
|
1708
|
+
ready = [
|
|
1709
|
+
record for record in pending.values()
|
|
1710
|
+
if all(edge["event_id"] in emitted
|
|
1711
|
+
for edge in record["event"].get("parents", []))
|
|
1712
|
+
]
|
|
1713
|
+
if not ready:
|
|
1714
|
+
raise SystemExit("cannot order merged capsule records (missing parent or cycle)")
|
|
1715
|
+
ready.sort(key=lambda r: (
|
|
1716
|
+
r["event"].get("ts", ""), r["event"]["event_id"]))
|
|
1717
|
+
for record in ready:
|
|
1718
|
+
event_id = record["event"]["event_id"]
|
|
1719
|
+
out.append(record)
|
|
1720
|
+
emitted.add(event_id)
|
|
1721
|
+
pending.pop(event_id)
|
|
1722
|
+
return out
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def cmd_merge(a):
|
|
1726
|
+
items = _merge_inputs(a.file, a.source)
|
|
1727
|
+
_merge_base(items) # Validate the shared history before writing anything.
|
|
1728
|
+
target = items[0]
|
|
1729
|
+
primary = target["head_record"]
|
|
1730
|
+
blocks = assign_ids(parse_blocks(target["body"], carrier_for_path(a.file)),
|
|
1731
|
+
primary["version"])
|
|
1732
|
+
version = {
|
|
1733
|
+
"artifact": a.file,
|
|
1734
|
+
"artifact_id": target["meta"]["artifact_id"],
|
|
1735
|
+
"blocks": blocks,
|
|
1736
|
+
}
|
|
1737
|
+
vid = version_id(version)
|
|
1738
|
+
parent_edges = [
|
|
1739
|
+
_parent_edge(item["head_record"]["event"],
|
|
1740
|
+
item["head_record"]["version"], version)
|
|
1741
|
+
for item in items
|
|
1742
|
+
]
|
|
1743
|
+
claims = None
|
|
1744
|
+
if a.claims:
|
|
1745
|
+
claims = json.load(open(a.claims))
|
|
1746
|
+
if not isinstance(claims, list):
|
|
1747
|
+
raise SystemExit("--claims file must be a JSON array")
|
|
1748
|
+
context = None
|
|
1749
|
+
if a.why or a.rejected:
|
|
1750
|
+
context = {}
|
|
1751
|
+
if a.why:
|
|
1752
|
+
context["why"] = a.why
|
|
1753
|
+
if a.rejected:
|
|
1754
|
+
context["rejected"] = a.rejected
|
|
1755
|
+
event = {
|
|
1756
|
+
"event": "version_created",
|
|
1757
|
+
"merge": True,
|
|
1758
|
+
"artifact": a.file,
|
|
1759
|
+
"artifact_id": target["meta"]["artifact_id"],
|
|
1760
|
+
"version": vid,
|
|
1761
|
+
"parent": parent_edges[0]["version"],
|
|
1762
|
+
"parents": parent_edges,
|
|
1763
|
+
"author": {"name": a.author, "kind": a.kind, "session": a.session},
|
|
1764
|
+
"note": a.note or "merged parallel portable copies",
|
|
1765
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
1766
|
+
"stats": parent_edges[0]["stats"],
|
|
1767
|
+
"changes": parent_edges[0]["changes"],
|
|
1768
|
+
"policy": "portable",
|
|
1769
|
+
"portable_lineage_id": target["meta"]["portable_lineage_id"],
|
|
1770
|
+
"soft_fingerprint": soft_fingerprint(blocks),
|
|
1771
|
+
"actors": _actors_from_args(a),
|
|
1772
|
+
"attribution_basis": a.attribution_basis,
|
|
1773
|
+
}
|
|
1774
|
+
check_attribution_basis(a.attribution_basis)
|
|
1775
|
+
if claims is not None:
|
|
1776
|
+
event["claims"] = claims
|
|
1777
|
+
if context is not None:
|
|
1778
|
+
event["context"] = context
|
|
1779
|
+
event["event_id"] = stable_event_id(event, version)
|
|
1780
|
+
|
|
1781
|
+
union = {}
|
|
1782
|
+
for item in items:
|
|
1783
|
+
for event_id, record in item["records"].items():
|
|
1784
|
+
if event_id in union and union[event_id] != record:
|
|
1785
|
+
raise SystemExit(f"event {event_id} differs between capsules")
|
|
1786
|
+
union[event_id] = record
|
|
1787
|
+
if event["event_id"] in union:
|
|
1788
|
+
raise SystemExit("this merge event is already present")
|
|
1789
|
+
union[event["event_id"]] = {"event": event, "version": version}
|
|
1790
|
+
records = _topological_records(union)
|
|
1791
|
+
capsule = {
|
|
1792
|
+
"proofpress_capsule": 2,
|
|
1793
|
+
"artifact_id": target["meta"]["artifact_id"],
|
|
1794
|
+
"portable_lineage_id": target["meta"]["portable_lineage_id"],
|
|
1795
|
+
"head": vid,
|
|
1796
|
+
"head_event": event["event_id"],
|
|
1797
|
+
"body_digest": body_digest(version),
|
|
1798
|
+
"discovery": dict(CAPSULE_DISCOVERY),
|
|
1799
|
+
"records": records,
|
|
1800
|
+
}
|
|
1801
|
+
meta = dict(target["meta"])
|
|
1802
|
+
meta["portable_head"] = vid
|
|
1803
|
+
meta["portable_head_event"] = event["event_id"]
|
|
1804
|
+
write_event(event, version)
|
|
1805
|
+
write_artifact(a.file, target["body"], meta, capsule)
|
|
1806
|
+
print(f"✓ merged {len(parent_edges)} parents -> {vid} "
|
|
1807
|
+
f"({event['event_id']})")
|
|
1808
|
+
for edge, item in zip(parent_edges, items):
|
|
1809
|
+
print(f" parent: {item['path']} @ {edge['version']} "
|
|
1810
|
+
f"({edge['event_id']})")
|
|
1811
|
+
|
|
1812
|
+
|
|
1269
1813
|
def _ingredient_ref(path):
|
|
1270
1814
|
"""Reference + digest for an upstream artifact — never its history."""
|
|
1271
1815
|
if not os.path.isfile(path):
|
|
@@ -1297,8 +1841,8 @@ def cmd_merge_lineage(a):
|
|
|
1297
1841
|
|
|
1298
1842
|
C2PA-ingredient style: each --from source is stored as a reference
|
|
1299
1843
|
(artifact_id + head version + body digest) on the merge event only.
|
|
1300
|
-
Upstream history is never copied, and the
|
|
1301
|
-
|
|
1844
|
+
Upstream history is never copied, and the target artifact's parent graph
|
|
1845
|
+
is untouched — ingredients are additive provenance."""
|
|
1302
1846
|
a.ingredients = [_ingredient_ref(p) for p in a.source]
|
|
1303
1847
|
if a.note is None:
|
|
1304
1848
|
a.note = "merged lineage from " + ", ".join(
|
|
@@ -1395,8 +1939,13 @@ def anchor_file(path, quiet=False):
|
|
|
1395
1939
|
body, meta, capsule, errors = read_artifact(path)
|
|
1396
1940
|
if errors:
|
|
1397
1941
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
1398
|
-
|
|
1399
|
-
|
|
1942
|
+
if capsule:
|
|
1943
|
+
normalized = capsule_v2(capsule)
|
|
1944
|
+
head_record = _record_map(normalized)[normalized["head_event"]]
|
|
1945
|
+
prev_v = head_record["version"]
|
|
1946
|
+
else:
|
|
1947
|
+
prev_ev = latest_for(path)
|
|
1948
|
+
prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
|
|
1400
1949
|
carrier = carrier_for_path(path)
|
|
1401
1950
|
blocks = assign_ids(parse_blocks(body, carrier), prev_v)
|
|
1402
1951
|
if carrier == "html":
|
|
@@ -1418,7 +1967,24 @@ def anchor_file(path, quiet=False):
|
|
|
1418
1967
|
out = []
|
|
1419
1968
|
for i, b in enumerate(blocks):
|
|
1420
1969
|
if not is_leading_yaml_frontmatter(b, i):
|
|
1421
|
-
|
|
1970
|
+
# Keep anchors for nested list content at the same indentation
|
|
1971
|
+
# as the block. An unindented marker would close the list in
|
|
1972
|
+
# CommonMark/GitHub rendering even though it is invisible.
|
|
1973
|
+
indent = re.match(r"^(\s*)", b["text"]).group(1)
|
|
1974
|
+
if b["type"] == "list" and not indent and i:
|
|
1975
|
+
j = i - 1
|
|
1976
|
+
while (j >= 0 and
|
|
1977
|
+
re.match(r"^\s+", blocks[j]["text"])):
|
|
1978
|
+
j -= 1
|
|
1979
|
+
if j >= 0 and blocks[j]["type"] == "list":
|
|
1980
|
+
previous_indent = re.match(
|
|
1981
|
+
r"^(\s*)", blocks[i - 1]["text"]).group(1)
|
|
1982
|
+
if previous_indent:
|
|
1983
|
+
# This is the next outer item after nested content.
|
|
1984
|
+
# Keep its marker inside the previous item so the
|
|
1985
|
+
# ordered/unordered list remains open.
|
|
1986
|
+
indent = previous_indent
|
|
1987
|
+
out.append(indent + format_anchor(b["id"]))
|
|
1422
1988
|
out.append(b["text"])
|
|
1423
1989
|
out.append("")
|
|
1424
1990
|
anchored_body = "\n".join(out).rstrip() + "\n"
|
|
@@ -1468,12 +2034,14 @@ def cmd_policy(a):
|
|
|
1468
2034
|
a.file, body, meta, recorded_by=a.author,
|
|
1469
2035
|
attribution_basis=a.attribution_basis)
|
|
1470
2036
|
meta["portable_head"] = capsule["head"]
|
|
2037
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1471
2038
|
write_artifact(a.file, body, meta, capsule)
|
|
1472
2039
|
print(f"portable: {a.file} ({meta['artifact_id']}, head {capsule['head']})")
|
|
1473
2040
|
return
|
|
1474
2041
|
meta["policy"] = a.policy
|
|
1475
2042
|
meta.pop("portable_lineage_id", None)
|
|
1476
2043
|
meta.pop("portable_head", None)
|
|
2044
|
+
meta.pop("portable_head_event", None)
|
|
1477
2045
|
write_artifact(a.file, body, meta, None)
|
|
1478
2046
|
print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
|
|
1479
2047
|
|
|
@@ -1540,8 +2108,9 @@ def inspect_result(path):
|
|
|
1540
2108
|
result["errors"].extend(validate_capsule(body, meta, capsule, carrier_for_path(path)))
|
|
1541
2109
|
if capsule:
|
|
1542
2110
|
result["head"] = capsule.get("head")
|
|
2111
|
+
result["head_event"] = capsule_v2(capsule).get("head_event")
|
|
1543
2112
|
result["versions"] = len(capsule.get("records", []))
|
|
1544
|
-
if
|
|
2113
|
+
if valid_capsule_discovery(capsule.get("discovery")):
|
|
1545
2114
|
result["discovery"] = capsule["discovery"]
|
|
1546
2115
|
elif capsule is not None:
|
|
1547
2116
|
result["errors"].append("capsule_on_nonportable_artifact")
|
|
@@ -1570,6 +2139,8 @@ def cmd_inspect(a):
|
|
|
1570
2139
|
print(f" artifact: {result['artifact_id']}")
|
|
1571
2140
|
if result.get("head"):
|
|
1572
2141
|
print(f" capsule: {result['versions']} version(s), head {result['head']}")
|
|
2142
|
+
if result.get("head_event"):
|
|
2143
|
+
print(f" head event: {result['head_event']}")
|
|
1573
2144
|
if result.get("ledger_head"):
|
|
1574
2145
|
observed = result.get("observed_version") or "unrecorded/unknown"
|
|
1575
2146
|
print(f" ledger head: {result['ledger_head']} (worktree: {observed})")
|
|
@@ -1597,18 +2168,21 @@ def cmd_import(a):
|
|
|
1597
2168
|
cap_errors = validate_capsule(body, meta, capsule, carrier_for_path(a.file))
|
|
1598
2169
|
if cap_errors:
|
|
1599
2170
|
raise SystemExit("invalid capsule: " + ", ".join(cap_errors))
|
|
1600
|
-
known = {
|
|
2171
|
+
known = {
|
|
2172
|
+
e.get("event_id") or stable_event_id(e, read_version(e["_commit"]))
|
|
2173
|
+
for e in versions_of(a.file)
|
|
2174
|
+
}
|
|
1601
2175
|
made = skipped = 0
|
|
1602
|
-
for record in capsule["records"]:
|
|
2176
|
+
for record in capsule_v2(capsule)["records"]:
|
|
1603
2177
|
event = dict(record["event"])
|
|
1604
2178
|
version = dict(record["version"])
|
|
1605
|
-
if event["
|
|
2179
|
+
if event["event_id"] in known:
|
|
1606
2180
|
skipped += 1; continue
|
|
1607
2181
|
event["artifact"] = a.file
|
|
1608
2182
|
event["imported_from_capsule"] = True
|
|
1609
2183
|
version["artifact"] = a.file
|
|
1610
2184
|
write_event(event, version)
|
|
1611
|
-
known.add(event["
|
|
2185
|
+
known.add(event["event_id"]); made += 1
|
|
1612
2186
|
print(f"imported {made}, skipped {skipped} -> {a.file} ({meta['artifact_id']})")
|
|
1613
2187
|
|
|
1614
2188
|
|
|
@@ -1755,10 +2329,12 @@ def cmd_show(a):
|
|
|
1755
2329
|
if a.json:
|
|
1756
2330
|
e = {k: v for k, v in ev.items() if k != "_commit"}
|
|
1757
2331
|
print(json.dumps(e, ensure_ascii=False, indent=2)); return
|
|
1758
|
-
idx = next(i for i, e in enumerate(evs)
|
|
2332
|
+
idx = next(i for i, e in enumerate(evs)
|
|
2333
|
+
if e.get("event_id") == ev.get("event_id"))
|
|
1759
2334
|
n = len(evs) - idx
|
|
1760
2335
|
v = read_version(ev["_commit"])
|
|
1761
|
-
|
|
2336
|
+
parent_ev = _primary_parent_event(ev, evs)
|
|
2337
|
+
parent_v = read_version(parent_ev["_commit"]) if parent_ev else None
|
|
1762
2338
|
changes, stats = semantic_diff(parent_v, v) if parent_v else ([], {})
|
|
1763
2339
|
chg_by_id = {c["block"]: c for c in changes if c["kind"] != "removed"}
|
|
1764
2340
|
who = ev["author"]
|
|
@@ -1774,13 +2350,16 @@ def cmd_show(a):
|
|
|
1774
2350
|
print(C("dim", ev["note"]))
|
|
1775
2351
|
ctx_ev = ev.get("context") or {}
|
|
1776
2352
|
if ctx_ev.get("why"):
|
|
1777
|
-
print(C("
|
|
2353
|
+
print(C("move", "why: ") + md_term(ctx_ev["why"]))
|
|
1778
2354
|
for r in ctx_ev.get("rejected", []):
|
|
1779
|
-
print(C("
|
|
2355
|
+
print(C("del", "rejected: ") + md_term(r))
|
|
1780
2356
|
for ing in ev.get("ingredients", []):
|
|
1781
2357
|
print(C("dim", "ingredient: ")
|
|
1782
2358
|
+ f'{ing.get("artifact_id", "?")} @ {ing.get("version", "?")}'
|
|
1783
2359
|
+ C("dim", f' ({ing.get("artifact", "")})'))
|
|
2360
|
+
for edge in ev.get("parents", [])[1:]:
|
|
2361
|
+
print(C("dim", "parent: ")
|
|
2362
|
+
+ f'{edge.get("version", "?")} ({edge.get("event_id", "?")})')
|
|
1784
2363
|
print()
|
|
1785
2364
|
tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
|
|
1786
2365
|
for b in v["blocks"]:
|
|
@@ -1819,7 +2398,9 @@ def cmd_verify(a):
|
|
|
1819
2398
|
if not evs:
|
|
1820
2399
|
raise SystemExit("artifact not in ledger")
|
|
1821
2400
|
ev = _find(evs, a.version) if a.version else evs[0]
|
|
1822
|
-
n = len(evs) - next(
|
|
2401
|
+
n = len(evs) - next(
|
|
2402
|
+
i for i, e in enumerate(evs)
|
|
2403
|
+
if e.get("event_id") == ev.get("event_id"))
|
|
1823
2404
|
# ``verify file`` means the current worktree as well as the recorded
|
|
1824
2405
|
# claims. ``verify file <version>`` remains an historical claim check.
|
|
1825
2406
|
if (os.path.isfile(a.file) and not a.version and
|
|
@@ -1920,6 +2501,8 @@ def main():
|
|
|
1920
2501
|
s.add_argument("--session", default=None); s.add_argument("--note", default=None)
|
|
1921
2502
|
s.add_argument("--base-version", default=None,
|
|
1922
2503
|
help="refuse the snapshot unless this is the current head")
|
|
2504
|
+
s.add_argument("--base-event", default=None,
|
|
2505
|
+
help="refuse the snapshot unless this is the exact DAG head event")
|
|
1923
2506
|
s.add_argument("--claims", default=None,
|
|
1924
2507
|
help='JSON file: [{"block", "kind": added|removed|modified|moved|unchanged, "note"?}]')
|
|
1925
2508
|
s.add_argument("--why", default=None,
|
|
@@ -1951,6 +2534,34 @@ def main():
|
|
|
1951
2534
|
vf.set_defaults(f=cmd_verify)
|
|
1952
2535
|
an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
|
|
1953
2536
|
ig = sub.add_parser("ingest"); ig.add_argument("file"); ig.set_defaults(f=cmd_ingest)
|
|
2537
|
+
mp = sub.add_parser(
|
|
2538
|
+
"merge-plan",
|
|
2539
|
+
help="analyze parallel copies of one portable artifact without writing")
|
|
2540
|
+
mp.add_argument("file", help="primary/target copy")
|
|
2541
|
+
mp.add_argument("--from", dest="source", action="append", required=True,
|
|
2542
|
+
metavar="COPY", help="parallel copy (repeatable)")
|
|
2543
|
+
mp.add_argument("--json", action="store_true")
|
|
2544
|
+
mp.set_defaults(f=cmd_merge_plan)
|
|
2545
|
+
mg = sub.add_parser(
|
|
2546
|
+
"merge",
|
|
2547
|
+
help="record a resolved multi-parent merge of one portable artifact")
|
|
2548
|
+
mg.add_argument("file", help="resolved target; its embedded head is primary")
|
|
2549
|
+
mg.add_argument("--from", dest="source", action="append", required=True,
|
|
2550
|
+
metavar="COPY", help="other parallel copy (repeatable)")
|
|
2551
|
+
mg.add_argument("--author", default="unknown")
|
|
2552
|
+
mg.add_argument("--kind", default="human",
|
|
2553
|
+
choices=["human", "agent", "system"])
|
|
2554
|
+
mg.add_argument("--session", default=None); mg.add_argument("--note", default=None)
|
|
2555
|
+
mg.add_argument("--claims", default=None)
|
|
2556
|
+
mg.add_argument("--why", default=None)
|
|
2557
|
+
mg.add_argument("--rejected", action="append", default=None)
|
|
2558
|
+
mg.add_argument("--requested-by", action="append", default=None)
|
|
2559
|
+
mg.add_argument("--produced-by", action="append", default=None)
|
|
2560
|
+
mg.add_argument("--edited-by", action="append", default=None)
|
|
2561
|
+
mg.add_argument("--recorded-by", action="append", default=None)
|
|
2562
|
+
mg.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
|
|
2563
|
+
default="self_asserted")
|
|
2564
|
+
mg.set_defaults(f=cmd_merge)
|
|
1954
2565
|
ml = sub.add_parser("merge-lineage",
|
|
1955
2566
|
help="snapshot a file that merges other Proofpress artifacts, recording ingredient references")
|
|
1956
2567
|
ml.add_argument("file")
|