proofpress 0.1.0 → 0.2.0

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