proofpress 0.1.0-alpha.2 → 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 +93 -199
- package/assets/logo-on-dark.svg +6 -0
- package/assets/logo-on-light.svg +6 -0
- package/package.json +7 -3
- package/proofpress.py +682 -58
- package/skills/README.md +34 -71
- package/skills/claude-code/proofpress/SKILL.md +15 -19
- package/skills/codex/AGENTS-snippet.md +15 -19
- package/skills/cursor/proofpress/SKILL.md +15 -19
- package/skills/pi/proofpress-skill.md +15 -19
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():
|
|
@@ -141,9 +145,19 @@ CAPSULE_DISCOVERY = {
|
|
|
141
145
|
"label": DISCOVERY_LABEL,
|
|
142
146
|
"project_url": DISCOVERY_URL,
|
|
143
147
|
"package": "proofpress",
|
|
144
|
-
"dist_tag": "
|
|
148
|
+
"dist_tag": "latest",
|
|
145
149
|
"requires_user_consent": True,
|
|
146
150
|
}
|
|
151
|
+
LEGACY_CAPSULE_DISCOVERIES = ({
|
|
152
|
+
**CAPSULE_DISCOVERY,
|
|
153
|
+
"dist_tag": "next",
|
|
154
|
+
},)
|
|
155
|
+
|
|
156
|
+
|
|
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)
|
|
147
161
|
|
|
148
162
|
|
|
149
163
|
def _b64e(data):
|
|
@@ -463,15 +477,55 @@ def parse_blocks(text, carrier="markdown"):
|
|
|
463
477
|
continue
|
|
464
478
|
m = ANCHOR.match(ln)
|
|
465
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
|
|
466
499
|
flush(); pending_anchor = m.group(1) or m.group(2); i += 1; continue
|
|
467
500
|
if ln.strip().startswith("```"):
|
|
468
501
|
flush(); kind = "code"; cur = [ln]; i += 1; continue
|
|
469
502
|
if re.match(r"^#{1,6} ", ln):
|
|
470
503
|
flush(); emit({"type": "heading", "text": ln.rstrip()}); i += 1; continue
|
|
471
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
|
|
472
524
|
flush(); i += 1; continue
|
|
473
525
|
this = "table" if ln.lstrip().startswith("|") else (
|
|
474
|
-
"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")
|
|
475
529
|
if kind not in (None, this):
|
|
476
530
|
flush()
|
|
477
531
|
kind = kind or this
|
|
@@ -501,7 +555,7 @@ def version_id(version):
|
|
|
501
555
|
ensure_ascii=False).encode()).hexdigest()[:8]
|
|
502
556
|
|
|
503
557
|
|
|
504
|
-
# ---------- soft binding(
|
|
558
|
+
# ---------- soft binding (deterministic identity after metadata stripping) ----------
|
|
505
559
|
|
|
506
560
|
_FP_RAWTEXT = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
|
|
507
561
|
_FP_TAG = re.compile(r"<[^>]+>")
|
|
@@ -525,7 +579,7 @@ def soft_fingerprint(blocks):
|
|
|
525
579
|
return "ppsb1:" + hashlib.sha256(skeleton.encode()).hexdigest()
|
|
526
580
|
|
|
527
581
|
|
|
528
|
-
# ----------
|
|
582
|
+
# ---------- block identity matching (similarity fallback without anchors) ----------
|
|
529
583
|
|
|
530
584
|
def assign_ids(new_blocks, old_version):
|
|
531
585
|
"""Give each new block an id: exact-hash match → inherit; else best
|
|
@@ -567,7 +621,7 @@ def assign_ids(new_blocks, old_version):
|
|
|
567
621
|
return new_blocks
|
|
568
622
|
|
|
569
623
|
|
|
570
|
-
# ----------
|
|
624
|
+
# ---------- semantic diff (structure plus numeric extraction) ----------
|
|
571
625
|
|
|
572
626
|
# The lookbehind rejects date/range separators ("2026-07-18") and identifier
|
|
573
627
|
# digits ("v0", "sha1") — a data number never starts mid-word.
|
|
@@ -584,7 +638,8 @@ def heading_context(blocks, idx):
|
|
|
584
638
|
for j in range(idx, -1, -1):
|
|
585
639
|
if blocks[j]["type"] == "heading":
|
|
586
640
|
return re.sub(r"^#+\s*", "", blocks[j]["text"])[:40]
|
|
587
|
-
|
|
641
|
+
# Preserve the legacy wire label used by existing capsule change records.
|
|
642
|
+
return "(\u6587\u9996)"
|
|
588
643
|
|
|
589
644
|
|
|
590
645
|
def lis_ids(seq):
|
|
@@ -661,7 +716,7 @@ def word_diff(a, b, width=100, color=False):
|
|
|
661
716
|
return s if color else s[:width * 3]
|
|
662
717
|
|
|
663
718
|
|
|
664
|
-
# ----------
|
|
719
|
+
# ---------- ledger I/O (commit chain at refs/proofpress/ledger) ----------
|
|
665
720
|
|
|
666
721
|
def ledger_events():
|
|
667
722
|
try:
|
|
@@ -672,6 +727,12 @@ def ledger_events():
|
|
|
672
727
|
for sha in shas: # newest first
|
|
673
728
|
ev = json.loads(git("show", f"{sha}:event.json"))
|
|
674
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
|
|
675
736
|
evs.append(ev)
|
|
676
737
|
return evs
|
|
677
738
|
|
|
@@ -775,7 +836,8 @@ def check_attribution_basis(basis):
|
|
|
775
836
|
def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
776
837
|
text=None, ts=None, source_commit=None, artifact_id=None,
|
|
777
838
|
policy="local", actors=None, attribution_basis="unknown",
|
|
778
|
-
ingredients=None
|
|
839
|
+
ingredients=None, force_event=False,
|
|
840
|
+
prev_event_override=None, prev_version_override=None):
|
|
779
841
|
"""Core snapshot: file → block tree → ledger. Returns event or None (no change).
|
|
780
842
|
|
|
781
843
|
`claims` is the author's structured self-description of the change
|
|
@@ -792,12 +854,14 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
792
854
|
check_attribution_basis(attribution_basis)
|
|
793
855
|
if text is None:
|
|
794
856
|
text = open(path).read()
|
|
795
|
-
prev_ev =
|
|
796
|
-
|
|
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))
|
|
797
861
|
blocks = assign_ids(parse_blocks(text, carrier_for_path(path)), prev_v)
|
|
798
862
|
version = {"artifact": path, "artifact_id": artifact_id, "blocks": blocks}
|
|
799
863
|
vid = version_id(version)
|
|
800
|
-
if prev_ev and prev_ev["version"] == vid:
|
|
864
|
+
if prev_ev and prev_ev["version"] == vid and not force_event:
|
|
801
865
|
return None
|
|
802
866
|
changes, stats = semantic_diff(prev_v, version)
|
|
803
867
|
event = {
|
|
@@ -828,6 +892,7 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
828
892
|
# engine can check WHAT changed against claims, but WHY is the
|
|
829
893
|
# author's account, recorded at submit time while it is still hot.
|
|
830
894
|
event["context"] = context
|
|
895
|
+
event["event_id"] = stable_event_id(event, version)
|
|
831
896
|
event["_commit_new"] = write_event(event, version)
|
|
832
897
|
event["_version_new"] = version
|
|
833
898
|
return event
|
|
@@ -837,6 +902,93 @@ def _public_event(event):
|
|
|
837
902
|
return {k: v for k, v in event.items() if not k.startswith("_")}
|
|
838
903
|
|
|
839
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
|
+
|
|
840
992
|
def _actors_from_args(a):
|
|
841
993
|
actors = {}
|
|
842
994
|
for field in ("requested_by", "produced_by", "edited_by", "recorded_by"):
|
|
@@ -873,12 +1025,15 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
|
873
1025
|
"soft_fingerprint": soft_fingerprint(blocks),
|
|
874
1026
|
"portable_checkpoint": True,
|
|
875
1027
|
"portable_lineage_id": lineage,
|
|
1028
|
+
"parents": [],
|
|
876
1029
|
}
|
|
1030
|
+
event["event_id"] = stable_event_id(event, version)
|
|
877
1031
|
return {
|
|
878
|
-
"proofpress_capsule":
|
|
1032
|
+
"proofpress_capsule": 2,
|
|
879
1033
|
"artifact_id": meta["artifact_id"],
|
|
880
1034
|
"portable_lineage_id": lineage,
|
|
881
1035
|
"head": vid,
|
|
1036
|
+
"head_event": event["event_id"],
|
|
882
1037
|
"body_digest": body_digest(version),
|
|
883
1038
|
"discovery": dict(CAPSULE_DISCOVERY),
|
|
884
1039
|
"records": [{"event": event, "version": version}],
|
|
@@ -891,10 +1046,11 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
891
1046
|
return ["missing_meta"]
|
|
892
1047
|
if not capsule:
|
|
893
1048
|
return ["missing_capsule"]
|
|
894
|
-
|
|
1049
|
+
schema = capsule.get("proofpress_capsule")
|
|
1050
|
+
if schema not in (1, 2):
|
|
895
1051
|
errors.append("unsupported_capsule")
|
|
896
1052
|
if ("discovery" in capsule and
|
|
897
|
-
capsule.get("discovery")
|
|
1053
|
+
not valid_capsule_discovery(capsule.get("discovery"))):
|
|
898
1054
|
errors.append("invalid_capsule_discovery")
|
|
899
1055
|
if capsule.get("artifact_id") != meta.get("artifact_id"):
|
|
900
1056
|
errors.append("artifact_id_mismatch")
|
|
@@ -903,7 +1059,27 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
903
1059
|
records = capsule.get("records")
|
|
904
1060
|
if not isinstance(records, list) or not records:
|
|
905
1061
|
return errors + ["missing_records"]
|
|
906
|
-
|
|
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()
|
|
907
1083
|
for i, record in enumerate(records):
|
|
908
1084
|
event, version = record.get("event"), record.get("version")
|
|
909
1085
|
if not isinstance(event, dict) or not isinstance(version, dict):
|
|
@@ -913,20 +1089,55 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
913
1089
|
errors.append(f"version_id_mismatch:{i}")
|
|
914
1090
|
if event.get("artifact_id") != capsule.get("artifact_id"):
|
|
915
1091
|
errors.append(f"record_artifact_mismatch:{i}")
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
if event.get("
|
|
920
|
-
errors.append(f"
|
|
921
|
-
|
|
922
|
-
|
|
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:
|
|
923
1134
|
errors.append("head_mismatch")
|
|
924
|
-
if
|
|
1135
|
+
if head_version and capsule.get("body_digest") != body_digest(head_version):
|
|
925
1136
|
errors.append("capsule_body_digest_mismatch")
|
|
926
|
-
current_blocks = assign_ids(parse_blocks(body, carrier),
|
|
927
|
-
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 "",
|
|
928
1139
|
"artifact_id": meta.get("artifact_id"), "blocks": current_blocks}
|
|
929
|
-
if
|
|
1140
|
+
if head_version and body_digest(current_v) != capsule.get("body_digest"):
|
|
930
1141
|
errors.append("body_mismatch")
|
|
931
1142
|
return errors
|
|
932
1143
|
|
|
@@ -944,7 +1155,9 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
944
1155
|
if blocking:
|
|
945
1156
|
raise SystemExit("cannot extend invalid capsule: " + ", ".join(blocking))
|
|
946
1157
|
capsule["discovery"] = dict(CAPSULE_DISCOVERY)
|
|
947
|
-
|
|
1158
|
+
normalized = capsule_v2(capsule)
|
|
1159
|
+
prior_record = _record_map(normalized)[normalized["head_event"]]
|
|
1160
|
+
prior_v = prior_record["version"]
|
|
948
1161
|
if version_id(version) == capsule["head"]:
|
|
949
1162
|
capsule["body_digest"] = body_digest(version)
|
|
950
1163
|
return capsule
|
|
@@ -955,8 +1168,14 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
955
1168
|
portable_event["stats"] = stats
|
|
956
1169
|
portable_event["policy"] = "portable"
|
|
957
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)
|
|
958
1175
|
capsule["records"].append({"event": portable_event, "version": version})
|
|
959
1176
|
capsule["head"] = portable_event["version"]
|
|
1177
|
+
if capsule.get("proofpress_capsule") == 2:
|
|
1178
|
+
capsule["head_event"] = portable_event["event_id"]
|
|
960
1179
|
capsule["body_digest"] = body_digest(version)
|
|
961
1180
|
return capsule
|
|
962
1181
|
|
|
@@ -980,6 +1199,13 @@ def cmd_snapshot(a):
|
|
|
980
1199
|
raise SystemExit(
|
|
981
1200
|
f"stale base: expected {a.base_version}, current "
|
|
982
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")
|
|
983
1209
|
# Persist identity even for local artifacts; this is not a portable capsule.
|
|
984
1210
|
if artifact_id_at(a.file) is None:
|
|
985
1211
|
write_artifact(a.file, body, meta, capsule)
|
|
@@ -997,11 +1223,24 @@ def cmd_snapshot(a):
|
|
|
997
1223
|
if a.rejected:
|
|
998
1224
|
context["rejected"] = a.rejected
|
|
999
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"]
|
|
1000
1236
|
ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
|
|
1001
1237
|
claims=claims, context=context, text=body,
|
|
1002
1238
|
artifact_id=meta["artifact_id"], policy=meta["policy"],
|
|
1003
1239
|
actors=actors, attribution_basis=a.attribution_basis,
|
|
1004
|
-
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)
|
|
1005
1244
|
if not ev:
|
|
1006
1245
|
if meta["policy"] == "portable" and capsule is None and recovered:
|
|
1007
1246
|
capsule = make_checkpoint_capsule(
|
|
@@ -1009,10 +1248,21 @@ def cmd_snapshot(a):
|
|
|
1009
1248
|
recorded_by=(actors.get("recorded_by") or [a.author])[0],
|
|
1010
1249
|
attribution_basis=a.attribution_basis)
|
|
1011
1250
|
meta["portable_head"] = capsule["head"]
|
|
1251
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1012
1252
|
write_artifact(a.file, body, meta, capsule)
|
|
1013
1253
|
print(f"repaired capsule: {a.file} -> {capsule['head']}")
|
|
1014
1254
|
return
|
|
1015
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
|
|
1016
1266
|
latest = latest_for(a.file)
|
|
1017
1267
|
if latest and capsule.get("head") != latest.get("version"):
|
|
1018
1268
|
version = read_version(latest["_commit"])
|
|
@@ -1023,6 +1273,7 @@ def cmd_snapshot(a):
|
|
|
1023
1273
|
capsule = append_capsule(a.file, body, meta, capsule,
|
|
1024
1274
|
latest, version)
|
|
1025
1275
|
meta["portable_head"] = capsule["head"]
|
|
1276
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1026
1277
|
write_artifact(a.file, body, meta, capsule)
|
|
1027
1278
|
print(f"repaired capsule: {a.file} -> {capsule['head']}")
|
|
1028
1279
|
return
|
|
@@ -1031,6 +1282,7 @@ def cmd_snapshot(a):
|
|
|
1031
1282
|
capsule = append_capsule(a.file, body, meta, capsule, ev,
|
|
1032
1283
|
ev["_version_new"])
|
|
1033
1284
|
meta["portable_head"] = capsule["head"]
|
|
1285
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1034
1286
|
write_artifact(a.file, body, meta, capsule)
|
|
1035
1287
|
n = f"v{len(versions_of(a.file))}"
|
|
1036
1288
|
print(f"✓ {n} ({ev['version']}) ← {ev['parent'] or '∅'} [{ev['_commit_new'][:8]} @ {LEDGER_REF}]")
|
|
@@ -1066,6 +1318,8 @@ def cmd_log(a):
|
|
|
1066
1318
|
f'{C(color, txt.ljust(statw))}')
|
|
1067
1319
|
if ev.get("note"):
|
|
1068
1320
|
line += f" {ev['note']}"
|
|
1321
|
+
if len(ev.get("parents") or []) > 1:
|
|
1322
|
+
line += C("dim", f" merge={len(ev['parents'])} parents")
|
|
1069
1323
|
if who.get("session"):
|
|
1070
1324
|
line += C("dim", f" session={who['session']}")
|
|
1071
1325
|
print(line)
|
|
@@ -1073,11 +1327,21 @@ def cmd_log(a):
|
|
|
1073
1327
|
|
|
1074
1328
|
def _find(evs, prefix):
|
|
1075
1329
|
for ev in evs:
|
|
1076
|
-
if ev
|
|
1330
|
+
if ((ev.get("event_id") or "").startswith(prefix) or
|
|
1331
|
+
ev["version"].startswith(prefix)):
|
|
1077
1332
|
return ev
|
|
1078
1333
|
raise SystemExit(f"version not found: {prefix}")
|
|
1079
1334
|
|
|
1080
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
|
+
|
|
1081
1345
|
def change_label(c):
|
|
1082
1346
|
"""Row title + secondary context for a change — one formatter shared by
|
|
1083
1347
|
the tty and markdown renderers (the Action must never re-implement this)."""
|
|
@@ -1116,11 +1380,12 @@ def cmd_diff(a):
|
|
|
1116
1380
|
base_note = ""
|
|
1117
1381
|
if a.base_commit and not a.va:
|
|
1118
1382
|
hit = next((e for e in evs if (e.get("source_commit") or "").startswith(a.base_commit)), None)
|
|
1119
|
-
ev_a = hit or evs[1]
|
|
1383
|
+
ev_a = hit or _primary_parent_event(ev_b, evs) or evs[1]
|
|
1120
1384
|
if not hit:
|
|
1121
1385
|
base_note = "base commit not in ledger — showing last recorded change"
|
|
1122
1386
|
else:
|
|
1123
|
-
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]))
|
|
1124
1389
|
va, vb = read_version(ev_a["_commit"]), read_version(ev_b["_commit"])
|
|
1125
1390
|
changes, stats = semantic_diff(va, vb)
|
|
1126
1391
|
if a.json:
|
|
@@ -1189,7 +1454,9 @@ def cmd_diff(a):
|
|
|
1189
1454
|
print(" " + ln)
|
|
1190
1455
|
# this version's self-description travels with the diff (provenance layer v0)
|
|
1191
1456
|
if ev_b.get("note") or ev_b.get("context"):
|
|
1192
|
-
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"))
|
|
1193
1460
|
who = ev_b["author"]
|
|
1194
1461
|
print()
|
|
1195
1462
|
if ev_b.get("note"):
|
|
@@ -1197,9 +1464,9 @@ def cmd_diff(a):
|
|
|
1197
1464
|
+ ev_b["note"])
|
|
1198
1465
|
ctx_b = ev_b.get("context") or {}
|
|
1199
1466
|
if ctx_b.get("why"):
|
|
1200
|
-
print(" " + C("
|
|
1467
|
+
print(" " + C("move", "why: ") + ctx_b["why"])
|
|
1201
1468
|
for r in ctx_b.get("rejected", []):
|
|
1202
|
-
print(" " + C("
|
|
1469
|
+
print(" " + C("del", "rejected: ") + r)
|
|
1203
1470
|
if ev_b.get("claims") is not None:
|
|
1204
1471
|
print(" " + C("dim", f'claims: {len(ev_b["claims"])} recorded — `proofpress verify {a.file} {ev_b["version"]}`'))
|
|
1205
1472
|
|
|
@@ -1253,6 +1520,296 @@ def cmd_ingest(a):
|
|
|
1253
1520
|
print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
|
|
1254
1521
|
|
|
1255
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
|
+
|
|
1256
1813
|
def _ingredient_ref(path):
|
|
1257
1814
|
"""Reference + digest for an upstream artifact — never its history."""
|
|
1258
1815
|
if not os.path.isfile(path):
|
|
@@ -1284,8 +1841,8 @@ def cmd_merge_lineage(a):
|
|
|
1284
1841
|
|
|
1285
1842
|
C2PA-ingredient style: each --from source is stored as a reference
|
|
1286
1843
|
(artifact_id + head version + body digest) on the merge event only.
|
|
1287
|
-
Upstream history is never copied, and the
|
|
1288
|
-
|
|
1844
|
+
Upstream history is never copied, and the target artifact's parent graph
|
|
1845
|
+
is untouched — ingredients are additive provenance."""
|
|
1289
1846
|
a.ingredients = [_ingredient_ref(p) for p in a.source]
|
|
1290
1847
|
if a.note is None:
|
|
1291
1848
|
a.note = "merged lineage from " + ", ".join(
|
|
@@ -1382,8 +1939,13 @@ def anchor_file(path, quiet=False):
|
|
|
1382
1939
|
body, meta, capsule, errors = read_artifact(path)
|
|
1383
1940
|
if errors:
|
|
1384
1941
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
|
1387
1949
|
carrier = carrier_for_path(path)
|
|
1388
1950
|
blocks = assign_ids(parse_blocks(body, carrier), prev_v)
|
|
1389
1951
|
if carrier == "html":
|
|
@@ -1405,7 +1967,24 @@ def anchor_file(path, quiet=False):
|
|
|
1405
1967
|
out = []
|
|
1406
1968
|
for i, b in enumerate(blocks):
|
|
1407
1969
|
if not is_leading_yaml_frontmatter(b, i):
|
|
1408
|
-
|
|
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"]))
|
|
1409
1988
|
out.append(b["text"])
|
|
1410
1989
|
out.append("")
|
|
1411
1990
|
anchored_body = "\n".join(out).rstrip() + "\n"
|
|
@@ -1455,12 +2034,14 @@ def cmd_policy(a):
|
|
|
1455
2034
|
a.file, body, meta, recorded_by=a.author,
|
|
1456
2035
|
attribution_basis=a.attribution_basis)
|
|
1457
2036
|
meta["portable_head"] = capsule["head"]
|
|
2037
|
+
meta["portable_head_event"] = capsule.get("head_event")
|
|
1458
2038
|
write_artifact(a.file, body, meta, capsule)
|
|
1459
2039
|
print(f"portable: {a.file} ({meta['artifact_id']}, head {capsule['head']})")
|
|
1460
2040
|
return
|
|
1461
2041
|
meta["policy"] = a.policy
|
|
1462
2042
|
meta.pop("portable_lineage_id", None)
|
|
1463
2043
|
meta.pop("portable_head", None)
|
|
2044
|
+
meta.pop("portable_head_event", None)
|
|
1464
2045
|
write_artifact(a.file, body, meta, None)
|
|
1465
2046
|
print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
|
|
1466
2047
|
|
|
@@ -1527,8 +2108,9 @@ def inspect_result(path):
|
|
|
1527
2108
|
result["errors"].extend(validate_capsule(body, meta, capsule, carrier_for_path(path)))
|
|
1528
2109
|
if capsule:
|
|
1529
2110
|
result["head"] = capsule.get("head")
|
|
2111
|
+
result["head_event"] = capsule_v2(capsule).get("head_event")
|
|
1530
2112
|
result["versions"] = len(capsule.get("records", []))
|
|
1531
|
-
if capsule.get("discovery")
|
|
2113
|
+
if valid_capsule_discovery(capsule.get("discovery")):
|
|
1532
2114
|
result["discovery"] = capsule["discovery"]
|
|
1533
2115
|
elif capsule is not None:
|
|
1534
2116
|
result["errors"].append("capsule_on_nonportable_artifact")
|
|
@@ -1557,6 +2139,8 @@ def cmd_inspect(a):
|
|
|
1557
2139
|
print(f" artifact: {result['artifact_id']}")
|
|
1558
2140
|
if result.get("head"):
|
|
1559
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']}")
|
|
1560
2144
|
if result.get("ledger_head"):
|
|
1561
2145
|
observed = result.get("observed_version") or "unrecorded/unknown"
|
|
1562
2146
|
print(f" ledger head: {result['ledger_head']} (worktree: {observed})")
|
|
@@ -1584,18 +2168,21 @@ def cmd_import(a):
|
|
|
1584
2168
|
cap_errors = validate_capsule(body, meta, capsule, carrier_for_path(a.file))
|
|
1585
2169
|
if cap_errors:
|
|
1586
2170
|
raise SystemExit("invalid capsule: " + ", ".join(cap_errors))
|
|
1587
|
-
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
|
+
}
|
|
1588
2175
|
made = skipped = 0
|
|
1589
|
-
for record in capsule["records"]:
|
|
2176
|
+
for record in capsule_v2(capsule)["records"]:
|
|
1590
2177
|
event = dict(record["event"])
|
|
1591
2178
|
version = dict(record["version"])
|
|
1592
|
-
if event["
|
|
2179
|
+
if event["event_id"] in known:
|
|
1593
2180
|
skipped += 1; continue
|
|
1594
2181
|
event["artifact"] = a.file
|
|
1595
2182
|
event["imported_from_capsule"] = True
|
|
1596
2183
|
version["artifact"] = a.file
|
|
1597
2184
|
write_event(event, version)
|
|
1598
|
-
known.add(event["
|
|
2185
|
+
known.add(event["event_id"]); made += 1
|
|
1599
2186
|
print(f"imported {made}, skipped {skipped} -> {a.file} ({meta['artifact_id']})")
|
|
1600
2187
|
|
|
1601
2188
|
|
|
@@ -1742,10 +2329,12 @@ def cmd_show(a):
|
|
|
1742
2329
|
if a.json:
|
|
1743
2330
|
e = {k: v for k, v in ev.items() if k != "_commit"}
|
|
1744
2331
|
print(json.dumps(e, ensure_ascii=False, indent=2)); return
|
|
1745
|
-
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"))
|
|
1746
2334
|
n = len(evs) - idx
|
|
1747
2335
|
v = read_version(ev["_commit"])
|
|
1748
|
-
|
|
2336
|
+
parent_ev = _primary_parent_event(ev, evs)
|
|
2337
|
+
parent_v = read_version(parent_ev["_commit"]) if parent_ev else None
|
|
1749
2338
|
changes, stats = semantic_diff(parent_v, v) if parent_v else ([], {})
|
|
1750
2339
|
chg_by_id = {c["block"]: c for c in changes if c["kind"] != "removed"}
|
|
1751
2340
|
who = ev["author"]
|
|
@@ -1761,13 +2350,16 @@ def cmd_show(a):
|
|
|
1761
2350
|
print(C("dim", ev["note"]))
|
|
1762
2351
|
ctx_ev = ev.get("context") or {}
|
|
1763
2352
|
if ctx_ev.get("why"):
|
|
1764
|
-
print(C("
|
|
2353
|
+
print(C("move", "why: ") + md_term(ctx_ev["why"]))
|
|
1765
2354
|
for r in ctx_ev.get("rejected", []):
|
|
1766
|
-
print(C("
|
|
2355
|
+
print(C("del", "rejected: ") + md_term(r))
|
|
1767
2356
|
for ing in ev.get("ingredients", []):
|
|
1768
2357
|
print(C("dim", "ingredient: ")
|
|
1769
2358
|
+ f'{ing.get("artifact_id", "?")} @ {ing.get("version", "?")}'
|
|
1770
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", "?")})')
|
|
1771
2363
|
print()
|
|
1772
2364
|
tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
|
|
1773
2365
|
for b in v["blocks"]:
|
|
@@ -1806,7 +2398,9 @@ def cmd_verify(a):
|
|
|
1806
2398
|
if not evs:
|
|
1807
2399
|
raise SystemExit("artifact not in ledger")
|
|
1808
2400
|
ev = _find(evs, a.version) if a.version else evs[0]
|
|
1809
|
-
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"))
|
|
1810
2404
|
# ``verify file`` means the current worktree as well as the recorded
|
|
1811
2405
|
# claims. ``verify file <version>`` remains an historical claim check.
|
|
1812
2406
|
if (os.path.isfile(a.file) and not a.version and
|
|
@@ -1907,6 +2501,8 @@ def main():
|
|
|
1907
2501
|
s.add_argument("--session", default=None); s.add_argument("--note", default=None)
|
|
1908
2502
|
s.add_argument("--base-version", default=None,
|
|
1909
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")
|
|
1910
2506
|
s.add_argument("--claims", default=None,
|
|
1911
2507
|
help='JSON file: [{"block", "kind": added|removed|modified|moved|unchanged, "note"?}]')
|
|
1912
2508
|
s.add_argument("--why", default=None,
|
|
@@ -1938,6 +2534,34 @@ def main():
|
|
|
1938
2534
|
vf.set_defaults(f=cmd_verify)
|
|
1939
2535
|
an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
|
|
1940
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)
|
|
1941
2565
|
ml = sub.add_parser("merge-lineage",
|
|
1942
2566
|
help="snapshot a file that merges other Proofpress artifacts, recording ingredient references")
|
|
1943
2567
|
ml.add_argument("file")
|