proofpress 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,7 +136,8 @@ python3 proofpress.py verify launch-plan.html
136
136
  This is a static-HTML carrier MVP, not a framework or CMS integration. Proofpress
137
137
  does not yet promise round-trip preservation through React/Vue builds, HTML
138
138
  sanitizers, editors, or CMS pipelines; if they strip transport data, the file
139
- degrades to an ordinary HTML artifact.
139
+ degrades to an ordinary HTML artifact — `identify` can still recognize it
140
+ locally, but its provenance does not come back.
140
141
 
141
142
  [//]: # (ob:226a29fd)
142
143
  ## What gets recorded
@@ -149,9 +150,12 @@ against the actual artifact diff.
149
150
 
150
151
  [//]: # (ob:5e0b34ed)
151
152
  It does not automatically store raw prompts, transcripts, tool traces, casual
152
- brainstorming, or every save. A fallback hook can preserve an otherwise missed
153
- version, but identifies itself only as `recorded_by`; it does not guess who
154
- wrote the content or why.
153
+ brainstorming, or every save. A fallback hook checks Git candidates and current
154
+ paths already admitted to the ledger, including Git-ignored artifacts. It can
155
+ preserve an otherwise missed version, but identifies itself only as
156
+ `recorded_by`; it does not guess who wrote the content or why. Harness skills
157
+ can also capture a specific existing file before an agent edits it, keeping
158
+ unattributed human drift separate from the agent's revision.
155
159
 
156
160
  [//]: # (ob:d8387b0b)
157
161
  ## Privacy modes
@@ -201,6 +205,35 @@ Use `--rejected` only for consequential dead branches that future collaborators
201
205
  should not repeat. Source code stays in Git; Proofpress is for Markdown and
202
206
  static HTML knowledge artifacts.
203
207
 
208
+ [//]: # (ob:59769c11)
209
+ ## Merged lineage and stripped copies
210
+
211
+ [//]: # (ob:91a8deb1)
212
+ When one document merges several Proofpress-managed sources, record the
213
+ upstream references — identity, head version, and digest, never copied
214
+ history:
215
+
216
+ [//]: # (ob:100c5aa7)
217
+ ```sh
218
+ python3 proofpress.py merge-lineage proposal.md \
219
+ --from research-a.md --from research-b.md
220
+ ```
221
+
222
+ [//]: # (ob:144c3d60)
223
+ When a copy lost its metadata and capsule (pasted as plain text, reformatted,
224
+ sanitized), the ledger can still recognize it by a deterministic content
225
+ fingerprint:
226
+
227
+ [//]: # (ob:28dec45f)
228
+ ```sh
229
+ python3 proofpress.py identify pasted-copy.md
230
+ ```
231
+
232
+ [//]: # (ob:b705ac38)
233
+ `identify` answers identity — "this is that artifact" — on a machine holding
234
+ the ledger. It does not restore history or prove the copy was never altered,
235
+ and a copy with wording changes intentionally does not match.
236
+
204
237
  [//]: # (ob:2a955c60)
205
238
  ## Surfaces
206
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proofpress",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Verifiable revision provenance for Markdown and static HTML knowledge artifacts",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
package/proofpress.py CHANGED
@@ -13,14 +13,16 @@ Commands:
13
13
  show <file-or-version> [--json]
14
14
  verify <file> [<version>] [--json] claim 校验:exit 0 ✓ / 1 ⚠ / 2 无 claims
15
15
  ingest <file> 从 git commit 历史回填账本版本
16
+ merge-lineage <file> --from a.md --from b.md 多父溯源:记录 ingredient 引用
17
+ identify <file> 软绑定反查:capsule 被剥离后认回身份
16
18
  policy / inspect / import / clean / capture
17
19
  anchor / blocks / init / sync
18
20
  """
19
21
 
20
- import argparse, base64, difflib, hashlib, json, os, re, secrets, subprocess, sys, tempfile, zlib
22
+ import argparse, base64, difflib, hashlib, html, json, os, re, secrets, subprocess, sys, tempfile, zlib
21
23
  from datetime import datetime, timezone
22
24
 
23
- __version__ = "0.1.0-alpha.1"
25
+ __version__ = "0.1.0-alpha.2"
24
26
  LEDGER_REF = "refs/proofpress/ledger"
25
27
 
26
28
  # ---------- terminal rendering ----------
@@ -499,6 +501,30 @@ def version_id(version):
499
501
  ensure_ascii=False).encode()).hexdigest()[:8]
500
502
 
501
503
 
504
+ # ---------- soft binding(剥离元数据后仍可识别的确定性指纹) ----------
505
+
506
+ _FP_RAWTEXT = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
507
+ _FP_TAG = re.compile(r"<[^>]+>")
508
+ _FP_MD_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
509
+
510
+
511
+ def soft_fingerprint(blocks):
512
+ """Tier-1 exact soft binding: hash of the visible text skeleton.
513
+
514
+ Deterministic, model-free. Normalization is deliberately harsh — strip
515
+ HTML tags, markdown link targets and all syntax/punctuation, collapse
516
+ whitespace — so the fingerprint survives formatting drift (plain-text
517
+ copy, re-rendering, whitespace mangling) but flips on any wording
518
+ change. It proves identity ("this is pp_xxx"), never tamper-freedom."""
519
+ text = "\n".join(b["text"] for b in blocks)
520
+ text = _FP_RAWTEXT.sub(" ", text)
521
+ text = _FP_TAG.sub(" ", text)
522
+ text = _FP_MD_LINK.sub(r"\1", text)
523
+ text = html.unescape(text)
524
+ skeleton = " ".join(re.findall(r"[^\W_]+", text))
525
+ return "ppsb1:" + hashlib.sha256(skeleton.encode()).hexdigest()
526
+
527
+
502
528
  # ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
503
529
 
504
530
  def assign_ids(new_blocks, old_version):
@@ -677,6 +703,40 @@ def versions_of(path):
677
703
  return out
678
704
 
679
705
 
706
+ def latest_at_recorded_path(path):
707
+ """Return the newest event projected at path, even if file metadata is gone.
708
+
709
+ Normal edits carry artifact identity in the file. A whole-file replace can
710
+ strip that transport, so path continuity is the deterministic local
711
+ fallback: the replacement is another version of the artifact last recorded
712
+ at that path.
713
+ """
714
+ target = os.path.normcase(os.path.abspath(path))
715
+ for event in ledger_events():
716
+ if event.get("event") != "version_created":
717
+ continue
718
+ recorded = event.get("artifact")
719
+ if recorded and os.path.normcase(os.path.abspath(recorded)) == target:
720
+ return event
721
+ return None
722
+
723
+
724
+ def read_artifact_for_update(path):
725
+ """Read a carrier and recover stripped identity from its recorded path."""
726
+ body, meta, capsule, errors = read_artifact(path)
727
+ recovered = False
728
+ if meta is None and "invalid_meta" not in errors:
729
+ prior = latest_at_recorded_path(path)
730
+ if prior and prior.get("artifact_id"):
731
+ policy = prior.get("policy", "local")
732
+ meta = new_meta(policy if policy in POLICIES else "local")
733
+ meta["artifact_id"] = prior["artifact_id"]
734
+ recovered = True
735
+ else:
736
+ meta = new_meta()
737
+ return body, meta, capsule, errors, recovered
738
+
739
+
680
740
  def latest_for(path):
681
741
  vs = versions_of(path)
682
742
  return vs[0] if vs else None
@@ -703,9 +763,19 @@ def write_event(event, version=None):
703
763
 
704
764
  # ---------- commands ----------
705
765
 
766
+ def check_attribution_basis(basis):
767
+ # `signed` is the reserved top rung of the attribution ladder. Until
768
+ # cryptographic signing lands, accepting it would grade attribution
769
+ # above what is actually attested — a false claim by construction.
770
+ if basis == "signed":
771
+ raise SystemExit("attribution_basis 'signed' is reserved until "
772
+ "signing is implemented; use harness_attested or below")
773
+
774
+
706
775
  def do_snapshot(path, author, kind, session, note, claims=None, context=None,
707
776
  text=None, ts=None, source_commit=None, artifact_id=None,
708
- policy="local", actors=None, attribution_basis="unknown"):
777
+ policy="local", actors=None, attribution_basis="unknown",
778
+ ingredients=None):
709
779
  """Core snapshot: file → block tree → ledger. Returns event or None (no change).
710
780
 
711
781
  `claims` is the author's structured self-description of the change
@@ -714,7 +784,12 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
714
784
  verified, never trusted.
715
785
 
716
786
  `text`/`ts`/`source_commit` let `ingest` replay historical git commits
717
- into the ledger with their original content, author date and commit sha."""
787
+ into the ledger with their original content, author date and commit sha.
788
+
789
+ `ingredients` is a list of upstream-artifact references (id + head +
790
+ digest, never copied history) recorded when this version merges other
791
+ Proofpress artifacts; the linear `parent` chain is unaffected."""
792
+ check_attribution_basis(attribution_basis)
718
793
  if text is None:
719
794
  text = open(path).read()
720
795
  prev_ev = latest_for(path)
@@ -737,7 +812,10 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
737
812
  "stats": stats,
738
813
  "changes": changes,
739
814
  "policy": policy,
815
+ "soft_fingerprint": soft_fingerprint(blocks),
740
816
  }
817
+ if ingredients:
818
+ event["ingredients"] = ingredients
741
819
  if actors:
742
820
  event["actors"] = actors
743
821
  event["attribution_basis"] = attribution_basis
@@ -772,6 +850,7 @@ def _actors_from_args(a):
772
850
 
773
851
  def make_checkpoint_capsule(path, body, meta, recorded_by=None,
774
852
  attribution_basis="self_asserted"):
853
+ check_attribution_basis(attribution_basis)
775
854
  prev_ev = latest_for(path)
776
855
  prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
777
856
  blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
@@ -791,6 +870,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
791
870
  "note": "portable lineage checkpoint", "policy": "portable",
792
871
  "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
793
872
  "stats": stats, "changes": changes,
873
+ "soft_fingerprint": soft_fingerprint(blocks),
794
874
  "portable_checkpoint": True,
795
875
  "portable_lineage_id": lineage,
796
876
  }
@@ -882,12 +962,15 @@ def append_capsule(path, body, meta, capsule, event, version):
882
962
 
883
963
 
884
964
  def cmd_snapshot(a):
885
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
965
+ body, meta, capsule, errors, recovered = read_artifact_for_update(a.file)
886
966
  if errors:
887
967
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
888
968
  if meta["policy"] == "ignored":
889
969
  print(f"skipped: {a.file} policy is ignored")
890
970
  return
971
+ if recovered:
972
+ print(f"recovered identity: {a.file} -> {meta['artifact_id']} "
973
+ "(matched previous ledger path)")
891
974
  if getattr(a, "base_version", None):
892
975
  current = (capsule or {}).get("head")
893
976
  if current is None:
@@ -917,8 +1000,18 @@ def cmd_snapshot(a):
917
1000
  ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
918
1001
  claims=claims, context=context, text=body,
919
1002
  artifact_id=meta["artifact_id"], policy=meta["policy"],
920
- actors=actors, attribution_basis=a.attribution_basis)
1003
+ actors=actors, attribution_basis=a.attribution_basis,
1004
+ ingredients=getattr(a, "ingredients", None))
921
1005
  if not ev:
1006
+ if meta["policy"] == "portable" and capsule is None and recovered:
1007
+ capsule = make_checkpoint_capsule(
1008
+ a.file, body, meta,
1009
+ recorded_by=(actors.get("recorded_by") or [a.author])[0],
1010
+ attribution_basis=a.attribution_basis)
1011
+ meta["portable_head"] = capsule["head"]
1012
+ write_artifact(a.file, body, meta, capsule)
1013
+ print(f"repaired capsule: {a.file} -> {capsule['head']}")
1014
+ return
922
1015
  if meta["policy"] == "portable" and capsule is not None:
923
1016
  latest = latest_for(a.file)
924
1017
  if latest and capsule.get("head") != latest.get("version"):
@@ -996,6 +1089,23 @@ def change_label(c):
996
1089
  return (ctx or c.get("preview", "")[:48]), ""
997
1090
 
998
1091
 
1092
+ def verify_label(change, fallback):
1093
+ """Make a claim target distinguishable from its heading context.
1094
+
1095
+ A Markdown heading and every block beneath it share ``context``. Verify
1096
+ output therefore needs the block type and, for non-headings, a short
1097
+ preview so adjacent rows are not mistaken for duplicate blocks.
1098
+ """
1099
+ if not change:
1100
+ return fallback
1101
+ ctx = change.get("context", "")
1102
+ block_type = change.get("type", "block")
1103
+ preview = (change.get("preview") or "").strip().replace("\n", " ")[:56]
1104
+ if block_type == "heading" or not preview:
1105
+ return f"{ctx or fallback} / {block_type}"
1106
+ return f"{ctx + ' / ' if ctx else ''}{block_type}: {preview}"
1107
+
1108
+
999
1109
  def cmd_diff(a):
1000
1110
  evs = versions_of(a.file)
1001
1111
  if len(evs) < 2 and not (a.va and a.vb):
@@ -1143,6 +1253,98 @@ def cmd_ingest(a):
1143
1253
  print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
1144
1254
 
1145
1255
 
1256
+ def _ingredient_ref(path):
1257
+ """Reference + digest for an upstream artifact — never its history."""
1258
+ if not os.path.isfile(path):
1259
+ raise SystemExit(f"ingredient not found: {path}")
1260
+ _, meta, capsule, errors = read_artifact(path)
1261
+ if errors:
1262
+ raise SystemExit(f"ingredient {path}: invalid metadata: " + ", ".join(errors))
1263
+ if not meta:
1264
+ raise SystemExit(f"ingredient {path} is not proofpress-managed "
1265
+ "(snapshot or import it first)")
1266
+ ref = {"artifact": path, "artifact_id": meta["artifact_id"]}
1267
+ latest = latest_for(path)
1268
+ if latest:
1269
+ ref["version"] = latest["version"]
1270
+ ref["body_digest"] = body_digest(read_version(latest["_commit"]))
1271
+ elif capsule:
1272
+ ref["version"] = capsule.get("head")
1273
+ ref["body_digest"] = capsule.get("body_digest")
1274
+ else:
1275
+ raise SystemExit(f"ingredient {path} has no ledger history or capsule "
1276
+ "to reference")
1277
+ if meta.get("portable_lineage_id"):
1278
+ ref["portable_lineage_id"] = meta["portable_lineage_id"]
1279
+ return ref
1280
+
1281
+
1282
+ def cmd_merge_lineage(a):
1283
+ """Record that this version merges multiple upstream Proofpress artifacts.
1284
+
1285
+ C2PA-ingredient style: each --from source is stored as a reference
1286
+ (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."""
1289
+ a.ingredients = [_ingredient_ref(p) for p in a.source]
1290
+ if a.note is None:
1291
+ a.note = "merged lineage from " + ", ".join(
1292
+ r["artifact_id"] for r in a.ingredients)
1293
+ cmd_snapshot(a)
1294
+ latest = latest_for(a.file)
1295
+ if not latest or latest.get("ingredients") != a.ingredients:
1296
+ raise SystemExit(
1297
+ "merge-lineage records ingredients on a new version, but the file "
1298
+ "content is unchanged from the ledger tip — write the merged "
1299
+ "content into the file first, then re-run")
1300
+ for r in a.ingredients:
1301
+ print(f" ingredient: {r['artifact_id']} @ {r.get('version', '?')}"
1302
+ f" ({r['artifact']})")
1303
+
1304
+
1305
+ def cmd_identify(a):
1306
+ """Soft-binding lookup: recognize a file whose Proofpress metadata and
1307
+ capsule were stripped, using the local ledger as the fingerprint index.
1308
+
1309
+ Answers identity ("this is pp_xxx"), not integrity — a match does not
1310
+ prove the content was never altered, only that this exact visible text
1311
+ skeleton was admitted before. Exit 0 found, 1 not found."""
1312
+ carrier = carrier_for_path(a.file)
1313
+ body, meta, _, _ = split_transport(open(a.file).read(), carrier)
1314
+ fp = soft_fingerprint(parse_blocks(body, carrier))
1315
+ matches = {}
1316
+ for ev in ledger_events(): # newest first — keep first hit per artifact
1317
+ if ev.get("event") != "version_created":
1318
+ continue
1319
+ evfp = ev.get("soft_fingerprint") or soft_fingerprint(
1320
+ read_version(ev["_commit"])["blocks"]) # pre-fingerprint events
1321
+ if evfp == fp:
1322
+ matches.setdefault(ev.get("artifact_id") or ev.get("artifact"), ev)
1323
+ found = list(matches.values())
1324
+ if a.json:
1325
+ print(json.dumps({
1326
+ "file": a.file, "soft_fingerprint": fp,
1327
+ "status": "identified" if found else "not_found",
1328
+ "matches": [{"artifact_id": e.get("artifact_id"),
1329
+ "artifact": e.get("artifact"),
1330
+ "version": e["version"], "ts": e["ts"]}
1331
+ for e in found]}, ensure_ascii=False, indent=2))
1332
+ sys.exit(0 if found else 1)
1333
+ if not found:
1334
+ print(f"identify {a.file}: no ledger version matches this content "
1335
+ "(rewritten, or never admitted here)")
1336
+ sys.exit(1)
1337
+ print(f"identify {B(a.file)} " + C("dim", f"({fp[:22]}…)"))
1338
+ for e in found:
1339
+ marker = ""
1340
+ if meta and meta.get("artifact_id") == e.get("artifact_id"):
1341
+ marker = C("dim", " (identity intact in file)")
1342
+ print(f' {C("add", "✓")} {e.get("artifact_id") or "?"} @ {e["version"]}'
1343
+ f' {C("dim", e["ts"][:16].replace("T", " "))}'
1344
+ f' {e.get("artifact", "")}{marker}')
1345
+ sys.exit(0)
1346
+
1347
+
1146
1348
  def cmd_export(a):
1147
1349
  """Write an artifact's ledger chain as one self-contained JSON bundle.
1148
1350
 
@@ -1233,7 +1435,7 @@ def cmd_anchor(a):
1233
1435
 
1234
1436
 
1235
1437
  def cmd_policy(a):
1236
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
1438
+ body, meta, capsule, errors, _ = read_artifact_for_update(a.file)
1237
1439
  if errors:
1238
1440
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1239
1441
  if a.policy is None:
@@ -1263,6 +1465,58 @@ def cmd_policy(a):
1263
1465
  print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
1264
1466
 
1265
1467
 
1468
+ def _matching_ledger_version(events, digest):
1469
+ """Return the newest recorded version with this visible-body digest."""
1470
+ for event in events:
1471
+ if body_digest(read_version(event["_commit"])) == digest:
1472
+ return event
1473
+ return None
1474
+
1475
+
1476
+ def _worktree_ledger_state(path, body, meta):
1477
+ """Compare a local carrier with the ledger head, even without transport.
1478
+
1479
+ Portable artifacts already have a capsule body digest. Local artifacts do
1480
+ not, which used to let ``verify`` validate an old event while silently
1481
+ ignoring an overwritten worktree. A stripped carrier falls back to the
1482
+ last recorded path so the warning remains useful after whole-file replace.
1483
+ """
1484
+ latest = latest_for(path) if meta else latest_at_recorded_path(path)
1485
+ if not latest:
1486
+ return None
1487
+ expected = read_version(latest["_commit"])
1488
+ current_blocks = parse_blocks(body, carrier_for_path(path))
1489
+ actual_digest = body_digest({"blocks": current_blocks})
1490
+ expected_digest = body_digest(expected)
1491
+ expected_fp = latest.get("soft_fingerprint") or soft_fingerprint(expected["blocks"])
1492
+ actual_fp = soft_fingerprint(current_blocks)
1493
+ events = (versions_of(path) if meta else
1494
+ [e for e in ledger_events()
1495
+ if e.get("event") == "version_created" and
1496
+ e.get("artifact_id") == latest.get("artifact_id")])
1497
+ matched = _matching_ledger_version(events, actual_digest)
1498
+ errors = []
1499
+ if meta is None:
1500
+ errors.append("unmanaged_worktree")
1501
+ elif meta.get("artifact_id") != latest.get("artifact_id"):
1502
+ errors.append("ledger_identity_mismatch")
1503
+ # Exact digest intentionally sees carrier formatting. The soft fingerprint
1504
+ # is the documented formatting-tolerant boundary: different Markdown table
1505
+ # spacing or emphasis placement is not a content drift.
1506
+ formatting_drift = actual_digest != expected_digest and actual_fp == expected_fp
1507
+ if actual_digest != expected_digest and not formatting_drift:
1508
+ errors.append("worktree_not_at_ledger_head")
1509
+ if not errors and not formatting_drift:
1510
+ return None
1511
+ return {
1512
+ "ledger_head": latest["version"],
1513
+ "observed_version": (matched.get("version") if matched else
1514
+ (latest["version"] if formatting_drift else None)),
1515
+ "errors": errors,
1516
+ "formatting_drift": formatting_drift,
1517
+ }
1518
+
1519
+
1266
1520
  def inspect_result(path):
1267
1521
  body, meta, capsule, errors = read_artifact(path)
1268
1522
  result = {"artifact": path, "managed": meta is not None,
@@ -1278,6 +1532,15 @@ def inspect_result(path):
1278
1532
  result["discovery"] = capsule["discovery"]
1279
1533
  elif capsule is not None:
1280
1534
  result["errors"].append("capsule_on_nonportable_artifact")
1535
+ # A portable mismatch is already reported by capsule validation. For local
1536
+ # and stripped carriers, compare the visible worktree to the ledger head.
1537
+ if not (meta and meta.get("policy") == "portable"):
1538
+ worktree = _worktree_ledger_state(path, body, meta)
1539
+ if worktree:
1540
+ result["ledger_head"] = worktree["ledger_head"]
1541
+ result["observed_version"] = worktree["observed_version"]
1542
+ result["formatting_drift"] = worktree["formatting_drift"]
1543
+ result["errors"].extend(worktree["errors"])
1281
1544
  if result["errors"]:
1282
1545
  result["status"] = "mismatch"
1283
1546
  return result
@@ -1294,6 +1557,11 @@ def cmd_inspect(a):
1294
1557
  print(f" artifact: {result['artifact_id']}")
1295
1558
  if result.get("head"):
1296
1559
  print(f" capsule: {result['versions']} version(s), head {result['head']}")
1560
+ if result.get("ledger_head"):
1561
+ observed = result.get("observed_version") or "unrecorded/unknown"
1562
+ print(f" ledger head: {result['ledger_head']} (worktree: {observed})")
1563
+ if result.get("formatting_drift"):
1564
+ print(" note: formatting differs from the ledger head; visible content matches")
1297
1565
  if result.get("discovery"):
1298
1566
  print(f" provenance: {result['discovery']['label']}")
1299
1567
  print(f" learn more: {result['discovery']['project_url']}")
@@ -1301,6 +1569,10 @@ def cmd_inspect(a):
1301
1569
  f"{result['discovery']['dist_tag']} (user consent required)")
1302
1570
  for error in result["errors"]:
1303
1571
  print(f" error: {error}")
1572
+ if "unmanaged_worktree" in result["errors"]:
1573
+ print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
1574
+ elif "worktree_not_at_ledger_head" in result["errors"]:
1575
+ print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
1304
1576
  if result["status"] != "ok":
1305
1577
  sys.exit(1)
1306
1578
 
@@ -1336,7 +1608,12 @@ def cmd_clean(a):
1336
1608
 
1337
1609
 
1338
1610
  def changed_artifact_files():
1339
- """Find changed carriers that the fallback hook can safely snapshot."""
1611
+ """Find carriers that the fallback hook can safely reconcile.
1612
+
1613
+ Git remains useful for discovering never-admitted files. Already-admitted
1614
+ artifacts also come from the ledger, so ignored files and manual edits are
1615
+ still checked when a hook runs.
1616
+ """
1340
1617
  paths = set()
1341
1618
  for pattern in ("*.md", "*.html", "*.htm"):
1342
1619
  commands = [
@@ -1349,20 +1626,44 @@ def changed_artifact_files():
1349
1626
  paths.update(p for p in git(*args).splitlines() if p)
1350
1627
  except RuntimeError:
1351
1628
  continue
1629
+ seen = set()
1630
+ for event in ledger_events():
1631
+ if event.get("event") != "version_created":
1632
+ continue
1633
+ identity = event.get("artifact_id") or event.get("artifact")
1634
+ if identity in seen:
1635
+ continue
1636
+ seen.add(identity)
1637
+ path = event.get("artifact")
1638
+ if path and os.path.isfile(path):
1639
+ paths.add(path)
1352
1640
  return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
1353
1641
 
1354
1642
 
1643
+ def matches_latest(path, body, meta):
1644
+ latest = latest_for(path)
1645
+ if not latest:
1646
+ return False
1647
+ previous = read_version(latest["_commit"])
1648
+ blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), previous)
1649
+ current = {"artifact": path, "artifact_id": meta["artifact_id"],
1650
+ "blocks": blocks}
1651
+ return version_id(current) == latest["version"]
1652
+
1653
+
1355
1654
  def cmd_capture(a):
1356
- paths = changed_artifact_files()
1655
+ paths = sorted(set(a.files)) if a.files else changed_artifact_files()
1357
1656
  captured = skipped = 0
1358
1657
  for path in paths:
1359
- body, meta, _, errors = read_artifact(path, create_meta=True)
1658
+ body, meta, _, errors, recovered = read_artifact_for_update(path)
1360
1659
  if errors:
1361
1660
  print(f"capture warning: {path}: " + ", ".join(errors))
1362
1661
  skipped += 1; continue
1363
1662
  if meta["policy"] == "ignored":
1364
1663
  print(f"capture skipped: {path} is ignored")
1365
1664
  skipped += 1; continue
1665
+ if not recovered and matches_latest(path, body, meta):
1666
+ skipped += 1; continue
1366
1667
  anchor_file(path, quiet=True)
1367
1668
  ns = argparse.Namespace(
1368
1669
  file=path, author=a.recorder, kind="system", session=a.session,
@@ -1463,6 +1764,10 @@ def cmd_show(a):
1463
1764
  print(C("dim", "why: ") + md_term(ctx_ev["why"]))
1464
1765
  for r in ctx_ev.get("rejected", []):
1465
1766
  print(C("dim", "rejected: ") + md_term(r))
1767
+ for ing in ev.get("ingredients", []):
1768
+ print(C("dim", "ingredient: ")
1769
+ + f'{ing.get("artifact_id", "?")} @ {ing.get("version", "?")}'
1770
+ + C("dim", f' ({ing.get("artifact", "")})'))
1466
1771
  print()
1467
1772
  tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
1468
1773
  for b in v["blocks"]:
@@ -1502,6 +1807,30 @@ def cmd_verify(a):
1502
1807
  raise SystemExit("artifact not in ledger")
1503
1808
  ev = _find(evs, a.version) if a.version else evs[0]
1504
1809
  n = len(evs) - next(i for i, e in enumerate(evs) if e["version"] == ev["version"])
1810
+ # ``verify file`` means the current worktree as well as the recorded
1811
+ # claims. ``verify file <version>`` remains an historical claim check.
1812
+ if (os.path.isfile(a.file) and not a.version and
1813
+ inspection["status"] != "ok"):
1814
+ if a.json:
1815
+ print(json.dumps({"artifact": a.file, "version": ev["version"],
1816
+ "status": "worktree_mismatch",
1817
+ "errors": inspection["errors"],
1818
+ "ledger_head": inspection.get("ledger_head"),
1819
+ "observed_version": inspection.get("observed_version")},
1820
+ ensure_ascii=False, indent=2))
1821
+ elif a.md:
1822
+ print("⚠️ **worktree mismatch** — " + ", ".join(inspection["errors"]))
1823
+ else:
1824
+ print(f"verify {a.file} @ {ev['version']} (v{n})")
1825
+ print(" worktree mismatch: " + ", ".join(inspection["errors"]))
1826
+ if inspection.get("ledger_head"):
1827
+ observed = inspection.get("observed_version") or "unrecorded/unknown"
1828
+ print(f" ledger head: {inspection['ledger_head']} (worktree: {observed})")
1829
+ if "unmanaged_worktree" in inspection["errors"]:
1830
+ print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
1831
+ else:
1832
+ print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
1833
+ sys.exit(1)
1505
1834
  computed = {c["block"]: c for c in ev.get("changes", [])}
1506
1835
  claims = ev.get("claims")
1507
1836
  if claims is None:
@@ -1551,15 +1880,16 @@ def cmd_verify(a):
1551
1880
  note = f' — "{cl["note"]}"' if cl.get("note") else ""
1552
1881
  if r["ok"]:
1553
1882
  what = cl.get("kind")
1554
- print(f' {C("add", "✓")} [{what}] {ctx or cl.get("block", "?")} '
1883
+ label = verify_label(comp, cl.get("block", "?"))
1884
+ print(f' {C("add", "✓")} [{what}] {label} '
1555
1885
  f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1556
1886
  else:
1557
1887
  actual = comp["kind"] if comp else "unchanged"
1558
1888
  print(f' {C("move", "⚠")} claimed {B(cl.get("kind", "?"))} but computed '
1559
- f'{B(actual)}: {ctx or "?"} {C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1889
+ f'{B(actual)}: {verify_label(comp, "?")} {C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1560
1890
  for c in undisclosed:
1561
1891
  print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
1562
- f'{c.get("context") or c.get("preview", "")[:40]} {C("dim", "(" + c["block"] + ")")}')
1892
+ f'{verify_label(c, "?")} {C("dim", "(" + c["block"] + ")")}')
1563
1893
  print()
1564
1894
  verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
1565
1895
  else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
@@ -1608,6 +1938,29 @@ def main():
1608
1938
  vf.set_defaults(f=cmd_verify)
1609
1939
  an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
1610
1940
  ig = sub.add_parser("ingest"); ig.add_argument("file"); ig.set_defaults(f=cmd_ingest)
1941
+ ml = sub.add_parser("merge-lineage",
1942
+ help="snapshot a file that merges other Proofpress artifacts, recording ingredient references")
1943
+ ml.add_argument("file")
1944
+ ml.add_argument("--from", dest="source", action="append", required=True,
1945
+ metavar="ARTIFACT", help="upstream artifact merged into this file (repeatable)")
1946
+ ml.add_argument("--author", default="unknown")
1947
+ ml.add_argument("--kind", default="human", choices=["human", "agent", "system"])
1948
+ ml.add_argument("--session", default=None); ml.add_argument("--note", default=None)
1949
+ ml.add_argument("--base-version", default=None)
1950
+ ml.add_argument("--claims", default=None)
1951
+ ml.add_argument("--why", default=None)
1952
+ ml.add_argument("--rejected", action="append", default=None)
1953
+ ml.add_argument("--requested-by", action="append", default=None)
1954
+ ml.add_argument("--produced-by", action="append", default=None)
1955
+ ml.add_argument("--edited-by", action="append", default=None)
1956
+ ml.add_argument("--recorded-by", action="append", default=None)
1957
+ ml.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
1958
+ default="self_asserted")
1959
+ ml.set_defaults(f=cmd_merge_lineage)
1960
+ idn = sub.add_parser("identify",
1961
+ help="recognize a stripped file by its soft-binding fingerprint")
1962
+ idn.add_argument("file")
1963
+ idn.add_argument("--json", action="store_true"); idn.set_defaults(f=cmd_identify)
1611
1964
  ex = sub.add_parser("export"); ex.add_argument("file")
1612
1965
  ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
1613
1966
  ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
@@ -1629,6 +1982,9 @@ def main():
1629
1982
  cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
1630
1983
  ca = sub.add_parser("capture")
1631
1984
  ca.add_argument("--recorder", required=True); ca.add_argument("--session", default=None)
1985
+ ca.add_argument("files", nargs="*",
1986
+ help="specific Markdown/HTML files to reconcile; "
1987
+ "default: Git candidates plus admitted ledger paths")
1632
1988
  ca.set_defaults(f=cmd_capture)
1633
1989
  a = p.parse_args(); a.f(a)
1634
1990
 
package/skills/README.md CHANGED
@@ -11,13 +11,18 @@ Git.
11
11
  | Layer | What it knows | Attribution rule |
12
12
  |---|---|---|
13
13
  | Skill | accepted version, claims, why, consequential rejection | supplies known actor roles and basis |
14
- | Hook / `capture` | changed, staged, and untracked Markdown or static HTML | supplies only `recorded_by` |
14
+ | Hook / `capture` | Git candidates, admitted ledger paths, or explicit files | supplies only `recorded_by` |
15
15
  | `ingest` | committed content and Git author | attributes from Git metadata |
16
16
  | Portable capsule | admitted records attached to the artifact | preserves recorded fields; invents none |
17
17
 
18
18
  [//]: # (ob:50c32349)
19
19
  ## Shared contract
20
20
 
21
+ [//]: # (ob:666c5555)
22
+ Before editing an existing target, run `capture --recorder
23
+ <harness>-preflight <file>` so any human drift becomes a separate unattributed
24
+ version. Then:
25
+
21
26
  [//]: # (ob:a2f15f7d)
22
27
  1. Work only on Markdown or static HTML knowledge artifacts, never source code.
23
28
  2. Read the artifact policy. Enable `portable` once when requested; it stays on
@@ -1,5 +1,5 @@
1
1
  {
2
- "//": "Merge into .claude/settings.json. This is a best-effort fallback over changed, staged, and untracked Markdown and static HTML. It records only the hook identity as recorded_by; it does not infer who authored the content or why.",
2
+ "//": "Merge into .claude/settings.json. This best-effort fallback checks Git candidates plus current paths already admitted to the ledger. It records only the hook identity as recorded_by; it does not infer who authored the content or why.",
3
3
  "hooks": {
4
4
  "Stop": [
5
5
  {
@@ -13,6 +13,11 @@ testimony and verification. Do not snapshot every conversational turn.
13
13
  [//]: # (ob:d8284708)
14
14
  ## Workflow
15
15
 
16
+ [//]: # (ob:78295d28)
17
+ Before editing an existing target, run
18
+ `python3 proofpress.py capture --recorder claude-preflight <file>`. This
19
+ preserves any human drift without guessing its author or reason. Then:
20
+
16
21
  [//]: # (ob:04e1285c)
17
22
  1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
18
23
 
@@ -9,6 +9,12 @@ For accepted, meaningful revisions to Markdown or static HTML knowledge
9
9
  artifacts—never source code—close this loop. Do not snapshot every
10
10
  conversational turn.
11
11
 
12
+ [//]: # (ob:af9efd22)
13
+ Before editing an existing target, run
14
+ `python3 proofpress.py capture --recorder codex-preflight <file>`. This
15
+ preserves any human drift as a separate version without guessing its author or
16
+ reason. Then:
17
+
12
18
  [//]: # (ob:d181cc6e)
13
19
  1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
14
20
 
@@ -1,5 +1,5 @@
1
1
  # Merge into ~/.codex/config.toml (Codex hooks GA since v0.124).
2
- # Best-effort fallback: capture changed, staged, and untracked Markdown and static HTML files.
2
+ # Best-effort fallback: capture Git candidates and current admitted ledger paths.
3
3
  # The hook identifies itself only as recorded_by; it does not pretend to know
4
4
  # who wrote the content or why.
5
5
 
@@ -13,6 +13,11 @@ testimony and verification. Do not snapshot every conversational turn.
13
13
  [//]: # (ob:e14e12ad)
14
14
  ## Workflow
15
15
 
16
+ [//]: # (ob:0d9e887a)
17
+ Before editing an existing target, run
18
+ `python3 proofpress.py capture --recorder cursor-preflight <file>`. This
19
+ preserves any human drift without guessing its author or reason. Then:
20
+
16
21
  [//]: # (ob:42fd42e7)
17
22
  1. Read `python3 proofpress.py policy <file>`. If the user asks for a portable
18
23
 
@@ -5,9 +5,10 @@
5
5
  * lifecycle events (25+ hooks; see pi's extension docs — the API surface
6
6
  * moves fast, verify event names against your installed version).
7
7
  *
8
- * Intent: on session end, run best-effort capture across changed, staged, and
9
- * untracked Markdown and static HTML. The hook records only recorded_by, never an inferred
10
- * author or reason. Content-addressed duplicate runs are no-ops.
8
+ * Intent: on session end, run best-effort capture across Git candidates and
9
+ * current paths already admitted to the ledger. The hook records only
10
+ * recorded_by, never an inferred author or reason. Content-addressed duplicate
11
+ * runs are no-ops.
11
12
  */
12
13
  import { execFileSync } from "node:child_process";
13
14
 
@@ -6,6 +6,11 @@ For accepted, meaningful revisions to Markdown or static HTML knowledge
6
6
  artifacts—never source code—close this loop. Do not snapshot every
7
7
  conversational turn.
8
8
 
9
+ [//]: # (ob:211b5d98)
10
+ Before editing an existing target, run
11
+ `python3 proofpress.py capture --recorder pi-preflight <file>`. This preserves
12
+ any human drift without guessing its author or reason. Then:
13
+
9
14
  [//]: # (ob:87ce54bb)
10
15
  1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
11
16