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/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 校验:exit 0 ✓ / 1 ⚠ / 2 claims
15
- ingest <file> git commit 历史回填账本版本
16
- merge-lineage <file> --from a.md --from b.md 多父溯源:记录 ingredient 引用
17
- identify <file> 软绑定反查:capsule 被剥离后认回身份
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.1.0-alpha.2"
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 so the
30
- # exact same hex values drive both the review artifacts and the terminal.
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": "E08B80",
35
- "move": "D3A94F", "dim": "787B87"}
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": "next",
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) else "para")
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
- # ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
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
- # ---------- 语义 diff(结构层 + 数字抽取) ----------
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
- return "(文首)"
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
- # ---------- 账本读写(refs/proofpress/ledger 上的 commit 链) ----------
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 = latest_for(path)
796
- prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
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": 1,
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
- if capsule.get("proofpress_capsule") != 1:
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") != CAPSULE_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
- prev_v, prev_id = None, None
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
- if event.get("parent") != prev_id:
917
- errors.append(f"chain_mismatch:{i}")
918
- computed, stats = semantic_diff(prev_v, version)
919
- if event.get("changes") != computed or event.get("stats") != stats:
920
- errors.append(f"change_mismatch:{i}")
921
- prev_v, prev_id = version, vid
922
- if capsule.get("head") != prev_id:
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 prev_v and capsule.get("body_digest") != body_digest(prev_v):
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), prev_v)
927
- current_v = {"artifact": prev_v.get("artifact") if prev_v else "",
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 prev_v and body_digest(current_v) != capsule.get("body_digest"):
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
- prior_v = capsule["records"][-1]["version"]
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["version"].startswith(prefix):
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 evs[1]
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(i for i, e in enumerate(evs) if e["version"] == ev_b["version"])
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("dim", "why: ") + ctx_b["why"])
1467
+ print(" " + C("move", "why: ") + ctx_b["why"])
1201
1468
  for r in ctx_b.get("rejected", []):
1202
- print(" " + C("dim", "rejected: ") + r)
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 linear parent chain of the
1288
- target artifact is untouched — ingredients are additive provenance."""
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
- prev_ev = latest_for(path)
1386
- prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
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
- out.append(format_anchor(b["id"]))
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") == CAPSULE_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 = {e["version"] for e in versions_of(a.file)}
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["version"] in known:
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["version"]); made += 1
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) if e["version"] == ev["version"])
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
- parent_v = read_version(evs[idx + 1]["_commit"]) if idx + 1 < len(evs) else None
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("dim", "why: ") + md_term(ctx_ev["why"]))
2353
+ print(C("move", "why: ") + md_term(ctx_ev["why"]))
1765
2354
  for r in ctx_ev.get("rejected", []):
1766
- print(C("dim", "rejected: ") + md_term(r))
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(i for i, e in enumerate(evs) if e["version"] == ev["version"])
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")