proofpress 0.1.0-alpha.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,16 +7,24 @@
7
7
  # Proofpress
8
8
 
9
9
  [//]: # (ob:e667d986)
10
- Proofpress is a verifiable `log.md` for knowledge work: a shared history of who
11
- changed what, when, and why, checked against the artifact itself.
10
+ Proofpress is the trust layer for multiplayer AI: an open, agent-native ledger
11
+ that travels with the artifact.
12
12
 
13
13
  [//]: # (ob:0e0e9d9a)
14
- Agents can produce a document quickly, but the reasoning that shaped its final
15
- form is usually scattered across chat sessions, Slack, meetings, and somebody's
16
- memory. Git is excellent when the work already lives in Git; most knowledge
17
- artifacts do not. Proofpress keeps the accepted version history and consequential
18
- decisions attached to a Markdown or static HTML artifact so that history can
19
- travel with it.
14
+ Humans and agents can produce a document quickly, but the decisions that shaped
15
+ its final form are usually scattered across chat sessions, Slack, meetings, and
16
+ somebody's memory. Git is excellent when the work already lives in Git; most
17
+ knowledge artifacts do not. Proofpress keeps a shared, checkable record of
18
+ accepted changes, stated reasons, consequential rejections, and their relation
19
+ to the artifact itself—so that history can travel with the work.
20
+
21
+ [//]: # (ob:cc376e2b)
22
+ Think C2PA, but for knowledge work: a portable, inspectable record of the
23
+ history admitted into an artifact. This is an analogy, not a claim of C2PA
24
+ compatibility, signed authorship, or complete capture.
25
+
26
+ [//]: # (ob:6ef36a68)
27
+ > Git made code collaborative. Proofpress makes intelligence cumulative.
20
28
 
21
29
  [//]: # (ob:169d8523)
22
30
  ## Status
@@ -36,7 +44,7 @@ plus repository setup command. Python 3 and Git are required:
36
44
 
37
45
  [//]: # (ob:7b197ac1)
38
46
  ```sh
39
- npm install --save-dev proofpress@next
47
+ npm install --save-dev proofpress
40
48
  npx --no-install proofpress --version
41
49
  npx --no-install proofpress setup --agent codex
42
50
  ```
@@ -46,6 +54,20 @@ npx --no-install proofpress setup --agent codex
46
54
  `.proofpress/manifest.json`. It is idempotent and supports `codex`, `claude`,
47
55
  `cursor`, or `all`. Portable history remains opt-in per artifact.
48
56
 
57
+ [//]: # (ob:da019dfc)
58
+ `proofpress@0.1.0` is the current stable release channel. The deprecated
59
+ `0.0.1` placeholder is no longer the default install.
60
+
61
+ [//]: # (ob:4273537d)
62
+ ## Verify a portable handoff in five minutes
63
+
64
+ [//]: # (ob:1160e434)
65
+ The [portable handoff demo](examples/portable-handoff/) contains one neutral
66
+ community-planning document with a real, embedded v1 → v2 ledger. In a clean
67
+ Git repository, install `proofpress`, copy only `strategy.md`, then run
68
+ `inspect`, `import`, and `verify`. The recipient needs neither the source
69
+ repository nor the original session.
70
+
49
71
  [//]: # (ob:2b655d31)
50
72
  To add a visible, transparent distribution mark to a repository README:
51
73
 
@@ -109,7 +131,7 @@ authorship or protection from wholesale malicious replacement.
109
131
  [//]: # (ob:839fc8a1)
110
132
  Portable carriers include a non-rendering discovery marker with
111
133
  `Verifiable revision history by Proofpress` and the project URL. The capsule's
112
- canonical discovery object also names `proofpress@next`. An agent may explain
134
+ canonical discovery object names `proofpress@latest`. An agent may explain
113
135
  this provenance and offer installation, but must obtain user consent before
114
136
  downloading or executing anything.
115
137
 
@@ -136,7 +158,8 @@ python3 proofpress.py verify launch-plan.html
136
158
  This is a static-HTML carrier MVP, not a framework or CMS integration. Proofpress
137
159
  does not yet promise round-trip preservation through React/Vue builds, HTML
138
160
  sanitizers, editors, or CMS pipelines; if they strip transport data, the file
139
- degrades to an ordinary HTML artifact.
161
+ degrades to an ordinary HTML artifact — `identify` can still recognize it
162
+ locally, but its provenance does not come back.
140
163
 
141
164
  [//]: # (ob:226a29fd)
142
165
  ## What gets recorded
@@ -149,9 +172,12 @@ against the actual artifact diff.
149
172
 
150
173
  [//]: # (ob:5e0b34ed)
151
174
  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.
175
+ brainstorming, or every save. A fallback hook checks Git candidates and current
176
+ paths already admitted to the ledger, including Git-ignored artifacts. It can
177
+ preserve an otherwise missed version, but identifies itself only as
178
+ `recorded_by`; it does not guess who wrote the content or why. Harness skills
179
+ can also capture a specific existing file before an agent edits it, keeping
180
+ unattributed human drift separate from the agent's revision.
155
181
 
156
182
  [//]: # (ob:d8387b0b)
157
183
  ## Privacy modes
@@ -201,6 +227,35 @@ Use `--rejected` only for consequential dead branches that future collaborators
201
227
  should not repeat. Source code stays in Git; Proofpress is for Markdown and
202
228
  static HTML knowledge artifacts.
203
229
 
230
+ [//]: # (ob:59769c11)
231
+ ## Merged lineage and stripped copies
232
+
233
+ [//]: # (ob:91a8deb1)
234
+ When one document merges several Proofpress-managed sources, record the
235
+ upstream references — identity, head version, and digest, never copied
236
+ history:
237
+
238
+ [//]: # (ob:100c5aa7)
239
+ ```sh
240
+ python3 proofpress.py merge-lineage proposal.md \
241
+ --from research-a.md --from research-b.md
242
+ ```
243
+
244
+ [//]: # (ob:144c3d60)
245
+ When a copy lost its metadata and capsule (pasted as plain text, reformatted,
246
+ sanitized), the ledger can still recognize it by a deterministic content
247
+ fingerprint:
248
+
249
+ [//]: # (ob:28dec45f)
250
+ ```sh
251
+ python3 proofpress.py identify pasted-copy.md
252
+ ```
253
+
254
+ [//]: # (ob:b705ac38)
255
+ `identify` answers identity — "this is that artifact" — on a machine holding
256
+ the ledger. It does not restore history or prove the copy was never altered,
257
+ and a copy with wording changes intentionally does not match.
258
+
204
259
  [//]: # (ob:2a955c60)
205
260
  ## Surfaces
206
261
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proofpress",
3
- "version": "0.1.0-alpha.1",
4
- "description": "Verifiable revision provenance for Markdown and static HTML knowledge artifacts",
3
+ "version": "0.1.0",
4
+ "description": "The trust layer for multiplayer AI: an open, agent-native ledger for knowledge artifacts",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
7
7
  "proofpress": "bin/proofpress.js"
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"
24
26
  LEDGER_REF = "refs/proofpress/ledger"
25
27
 
26
28
  # ---------- terminal rendering ----------
@@ -139,9 +141,22 @@ CAPSULE_DISCOVERY = {
139
141
  "label": DISCOVERY_LABEL,
140
142
  "project_url": DISCOVERY_URL,
141
143
  "package": "proofpress",
142
- "dist_tag": "next",
144
+ "dist_tag": "latest",
143
145
  "requires_user_consent": True,
144
146
  }
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
+ )
156
+
157
+
158
+ def known_capsule_discovery(value):
159
+ return value == CAPSULE_DISCOVERY or value in LEGACY_CAPSULE_DISCOVERIES
145
160
 
146
161
 
147
162
  def _b64e(data):
@@ -499,6 +514,30 @@ def version_id(version):
499
514
  ensure_ascii=False).encode()).hexdigest()[:8]
500
515
 
501
516
 
517
+ # ---------- soft binding(剥离元数据后仍可识别的确定性指纹) ----------
518
+
519
+ _FP_RAWTEXT = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
520
+ _FP_TAG = re.compile(r"<[^>]+>")
521
+ _FP_MD_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
522
+
523
+
524
+ def soft_fingerprint(blocks):
525
+ """Tier-1 exact soft binding: hash of the visible text skeleton.
526
+
527
+ Deterministic, model-free. Normalization is deliberately harsh — strip
528
+ HTML tags, markdown link targets and all syntax/punctuation, collapse
529
+ whitespace — so the fingerprint survives formatting drift (plain-text
530
+ copy, re-rendering, whitespace mangling) but flips on any wording
531
+ change. It proves identity ("this is pp_xxx"), never tamper-freedom."""
532
+ text = "\n".join(b["text"] for b in blocks)
533
+ text = _FP_RAWTEXT.sub(" ", text)
534
+ text = _FP_TAG.sub(" ", text)
535
+ text = _FP_MD_LINK.sub(r"\1", text)
536
+ text = html.unescape(text)
537
+ skeleton = " ".join(re.findall(r"[^\W_]+", text))
538
+ return "ppsb1:" + hashlib.sha256(skeleton.encode()).hexdigest()
539
+
540
+
502
541
  # ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
503
542
 
504
543
  def assign_ids(new_blocks, old_version):
@@ -677,6 +716,40 @@ def versions_of(path):
677
716
  return out
678
717
 
679
718
 
719
+ def latest_at_recorded_path(path):
720
+ """Return the newest event projected at path, even if file metadata is gone.
721
+
722
+ Normal edits carry artifact identity in the file. A whole-file replace can
723
+ strip that transport, so path continuity is the deterministic local
724
+ fallback: the replacement is another version of the artifact last recorded
725
+ at that path.
726
+ """
727
+ target = os.path.normcase(os.path.abspath(path))
728
+ for event in ledger_events():
729
+ if event.get("event") != "version_created":
730
+ continue
731
+ recorded = event.get("artifact")
732
+ if recorded and os.path.normcase(os.path.abspath(recorded)) == target:
733
+ return event
734
+ return None
735
+
736
+
737
+ def read_artifact_for_update(path):
738
+ """Read a carrier and recover stripped identity from its recorded path."""
739
+ body, meta, capsule, errors = read_artifact(path)
740
+ recovered = False
741
+ if meta is None and "invalid_meta" not in errors:
742
+ prior = latest_at_recorded_path(path)
743
+ if prior and prior.get("artifact_id"):
744
+ policy = prior.get("policy", "local")
745
+ meta = new_meta(policy if policy in POLICIES else "local")
746
+ meta["artifact_id"] = prior["artifact_id"]
747
+ recovered = True
748
+ else:
749
+ meta = new_meta()
750
+ return body, meta, capsule, errors, recovered
751
+
752
+
680
753
  def latest_for(path):
681
754
  vs = versions_of(path)
682
755
  return vs[0] if vs else None
@@ -703,9 +776,19 @@ def write_event(event, version=None):
703
776
 
704
777
  # ---------- commands ----------
705
778
 
779
+ def check_attribution_basis(basis):
780
+ # `signed` is the reserved top rung of the attribution ladder. Until
781
+ # cryptographic signing lands, accepting it would grade attribution
782
+ # above what is actually attested — a false claim by construction.
783
+ if basis == "signed":
784
+ raise SystemExit("attribution_basis 'signed' is reserved until "
785
+ "signing is implemented; use harness_attested or below")
786
+
787
+
706
788
  def do_snapshot(path, author, kind, session, note, claims=None, context=None,
707
789
  text=None, ts=None, source_commit=None, artifact_id=None,
708
- policy="local", actors=None, attribution_basis="unknown"):
790
+ policy="local", actors=None, attribution_basis="unknown",
791
+ ingredients=None):
709
792
  """Core snapshot: file → block tree → ledger. Returns event or None (no change).
710
793
 
711
794
  `claims` is the author's structured self-description of the change
@@ -714,7 +797,12 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
714
797
  verified, never trusted.
715
798
 
716
799
  `text`/`ts`/`source_commit` let `ingest` replay historical git commits
717
- into the ledger with their original content, author date and commit sha."""
800
+ into the ledger with their original content, author date and commit sha.
801
+
802
+ `ingredients` is a list of upstream-artifact references (id + head +
803
+ digest, never copied history) recorded when this version merges other
804
+ Proofpress artifacts; the linear `parent` chain is unaffected."""
805
+ check_attribution_basis(attribution_basis)
718
806
  if text is None:
719
807
  text = open(path).read()
720
808
  prev_ev = latest_for(path)
@@ -737,7 +825,10 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
737
825
  "stats": stats,
738
826
  "changes": changes,
739
827
  "policy": policy,
828
+ "soft_fingerprint": soft_fingerprint(blocks),
740
829
  }
830
+ if ingredients:
831
+ event["ingredients"] = ingredients
741
832
  if actors:
742
833
  event["actors"] = actors
743
834
  event["attribution_basis"] = attribution_basis
@@ -772,6 +863,7 @@ def _actors_from_args(a):
772
863
 
773
864
  def make_checkpoint_capsule(path, body, meta, recorded_by=None,
774
865
  attribution_basis="self_asserted"):
866
+ check_attribution_basis(attribution_basis)
775
867
  prev_ev = latest_for(path)
776
868
  prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
777
869
  blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
@@ -791,6 +883,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
791
883
  "note": "portable lineage checkpoint", "policy": "portable",
792
884
  "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
793
885
  "stats": stats, "changes": changes,
886
+ "soft_fingerprint": soft_fingerprint(blocks),
794
887
  "portable_checkpoint": True,
795
888
  "portable_lineage_id": lineage,
796
889
  }
@@ -814,7 +907,7 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
814
907
  if capsule.get("proofpress_capsule") != 1:
815
908
  errors.append("unsupported_capsule")
816
909
  if ("discovery" in capsule and
817
- capsule.get("discovery") != CAPSULE_DISCOVERY):
910
+ not known_capsule_discovery(capsule.get("discovery"))):
818
911
  errors.append("invalid_capsule_discovery")
819
912
  if capsule.get("artifact_id") != meta.get("artifact_id"):
820
913
  errors.append("artifact_id_mismatch")
@@ -882,12 +975,15 @@ def append_capsule(path, body, meta, capsule, event, version):
882
975
 
883
976
 
884
977
  def cmd_snapshot(a):
885
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
978
+ body, meta, capsule, errors, recovered = read_artifact_for_update(a.file)
886
979
  if errors:
887
980
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
888
981
  if meta["policy"] == "ignored":
889
982
  print(f"skipped: {a.file} policy is ignored")
890
983
  return
984
+ if recovered:
985
+ print(f"recovered identity: {a.file} -> {meta['artifact_id']} "
986
+ "(matched previous ledger path)")
891
987
  if getattr(a, "base_version", None):
892
988
  current = (capsule or {}).get("head")
893
989
  if current is None:
@@ -917,8 +1013,18 @@ def cmd_snapshot(a):
917
1013
  ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
918
1014
  claims=claims, context=context, text=body,
919
1015
  artifact_id=meta["artifact_id"], policy=meta["policy"],
920
- actors=actors, attribution_basis=a.attribution_basis)
1016
+ actors=actors, attribution_basis=a.attribution_basis,
1017
+ ingredients=getattr(a, "ingredients", None))
921
1018
  if not ev:
1019
+ if meta["policy"] == "portable" and capsule is None and recovered:
1020
+ capsule = make_checkpoint_capsule(
1021
+ a.file, body, meta,
1022
+ recorded_by=(actors.get("recorded_by") or [a.author])[0],
1023
+ attribution_basis=a.attribution_basis)
1024
+ meta["portable_head"] = capsule["head"]
1025
+ write_artifact(a.file, body, meta, capsule)
1026
+ print(f"repaired capsule: {a.file} -> {capsule['head']}")
1027
+ return
922
1028
  if meta["policy"] == "portable" and capsule is not None:
923
1029
  latest = latest_for(a.file)
924
1030
  if latest and capsule.get("head") != latest.get("version"):
@@ -996,6 +1102,23 @@ def change_label(c):
996
1102
  return (ctx or c.get("preview", "")[:48]), ""
997
1103
 
998
1104
 
1105
+ def verify_label(change, fallback):
1106
+ """Make a claim target distinguishable from its heading context.
1107
+
1108
+ A Markdown heading and every block beneath it share ``context``. Verify
1109
+ output therefore needs the block type and, for non-headings, a short
1110
+ preview so adjacent rows are not mistaken for duplicate blocks.
1111
+ """
1112
+ if not change:
1113
+ return fallback
1114
+ ctx = change.get("context", "")
1115
+ block_type = change.get("type", "block")
1116
+ preview = (change.get("preview") or "").strip().replace("\n", " ")[:56]
1117
+ if block_type == "heading" or not preview:
1118
+ return f"{ctx or fallback} / {block_type}"
1119
+ return f"{ctx + ' / ' if ctx else ''}{block_type}: {preview}"
1120
+
1121
+
999
1122
  def cmd_diff(a):
1000
1123
  evs = versions_of(a.file)
1001
1124
  if len(evs) < 2 and not (a.va and a.vb):
@@ -1143,6 +1266,98 @@ def cmd_ingest(a):
1143
1266
  print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
1144
1267
 
1145
1268
 
1269
+ def _ingredient_ref(path):
1270
+ """Reference + digest for an upstream artifact — never its history."""
1271
+ if not os.path.isfile(path):
1272
+ raise SystemExit(f"ingredient not found: {path}")
1273
+ _, meta, capsule, errors = read_artifact(path)
1274
+ if errors:
1275
+ raise SystemExit(f"ingredient {path}: invalid metadata: " + ", ".join(errors))
1276
+ if not meta:
1277
+ raise SystemExit(f"ingredient {path} is not proofpress-managed "
1278
+ "(snapshot or import it first)")
1279
+ ref = {"artifact": path, "artifact_id": meta["artifact_id"]}
1280
+ latest = latest_for(path)
1281
+ if latest:
1282
+ ref["version"] = latest["version"]
1283
+ ref["body_digest"] = body_digest(read_version(latest["_commit"]))
1284
+ elif capsule:
1285
+ ref["version"] = capsule.get("head")
1286
+ ref["body_digest"] = capsule.get("body_digest")
1287
+ else:
1288
+ raise SystemExit(f"ingredient {path} has no ledger history or capsule "
1289
+ "to reference")
1290
+ if meta.get("portable_lineage_id"):
1291
+ ref["portable_lineage_id"] = meta["portable_lineage_id"]
1292
+ return ref
1293
+
1294
+
1295
+ def cmd_merge_lineage(a):
1296
+ """Record that this version merges multiple upstream Proofpress artifacts.
1297
+
1298
+ C2PA-ingredient style: each --from source is stored as a reference
1299
+ (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."""
1302
+ a.ingredients = [_ingredient_ref(p) for p in a.source]
1303
+ if a.note is None:
1304
+ a.note = "merged lineage from " + ", ".join(
1305
+ r["artifact_id"] for r in a.ingredients)
1306
+ cmd_snapshot(a)
1307
+ latest = latest_for(a.file)
1308
+ if not latest or latest.get("ingredients") != a.ingredients:
1309
+ raise SystemExit(
1310
+ "merge-lineage records ingredients on a new version, but the file "
1311
+ "content is unchanged from the ledger tip — write the merged "
1312
+ "content into the file first, then re-run")
1313
+ for r in a.ingredients:
1314
+ print(f" ingredient: {r['artifact_id']} @ {r.get('version', '?')}"
1315
+ f" ({r['artifact']})")
1316
+
1317
+
1318
+ def cmd_identify(a):
1319
+ """Soft-binding lookup: recognize a file whose Proofpress metadata and
1320
+ capsule were stripped, using the local ledger as the fingerprint index.
1321
+
1322
+ Answers identity ("this is pp_xxx"), not integrity — a match does not
1323
+ prove the content was never altered, only that this exact visible text
1324
+ skeleton was admitted before. Exit 0 found, 1 not found."""
1325
+ carrier = carrier_for_path(a.file)
1326
+ body, meta, _, _ = split_transport(open(a.file).read(), carrier)
1327
+ fp = soft_fingerprint(parse_blocks(body, carrier))
1328
+ matches = {}
1329
+ for ev in ledger_events(): # newest first — keep first hit per artifact
1330
+ if ev.get("event") != "version_created":
1331
+ continue
1332
+ evfp = ev.get("soft_fingerprint") or soft_fingerprint(
1333
+ read_version(ev["_commit"])["blocks"]) # pre-fingerprint events
1334
+ if evfp == fp:
1335
+ matches.setdefault(ev.get("artifact_id") or ev.get("artifact"), ev)
1336
+ found = list(matches.values())
1337
+ if a.json:
1338
+ print(json.dumps({
1339
+ "file": a.file, "soft_fingerprint": fp,
1340
+ "status": "identified" if found else "not_found",
1341
+ "matches": [{"artifact_id": e.get("artifact_id"),
1342
+ "artifact": e.get("artifact"),
1343
+ "version": e["version"], "ts": e["ts"]}
1344
+ for e in found]}, ensure_ascii=False, indent=2))
1345
+ sys.exit(0 if found else 1)
1346
+ if not found:
1347
+ print(f"identify {a.file}: no ledger version matches this content "
1348
+ "(rewritten, or never admitted here)")
1349
+ sys.exit(1)
1350
+ print(f"identify {B(a.file)} " + C("dim", f"({fp[:22]}…)"))
1351
+ for e in found:
1352
+ marker = ""
1353
+ if meta and meta.get("artifact_id") == e.get("artifact_id"):
1354
+ marker = C("dim", " (identity intact in file)")
1355
+ print(f' {C("add", "✓")} {e.get("artifact_id") or "?"} @ {e["version"]}'
1356
+ f' {C("dim", e["ts"][:16].replace("T", " "))}'
1357
+ f' {e.get("artifact", "")}{marker}')
1358
+ sys.exit(0)
1359
+
1360
+
1146
1361
  def cmd_export(a):
1147
1362
  """Write an artifact's ledger chain as one self-contained JSON bundle.
1148
1363
 
@@ -1233,7 +1448,7 @@ def cmd_anchor(a):
1233
1448
 
1234
1449
 
1235
1450
  def cmd_policy(a):
1236
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
1451
+ body, meta, capsule, errors, _ = read_artifact_for_update(a.file)
1237
1452
  if errors:
1238
1453
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1239
1454
  if a.policy is None:
@@ -1263,6 +1478,58 @@ def cmd_policy(a):
1263
1478
  print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
1264
1479
 
1265
1480
 
1481
+ def _matching_ledger_version(events, digest):
1482
+ """Return the newest recorded version with this visible-body digest."""
1483
+ for event in events:
1484
+ if body_digest(read_version(event["_commit"])) == digest:
1485
+ return event
1486
+ return None
1487
+
1488
+
1489
+ def _worktree_ledger_state(path, body, meta):
1490
+ """Compare a local carrier with the ledger head, even without transport.
1491
+
1492
+ Portable artifacts already have a capsule body digest. Local artifacts do
1493
+ not, which used to let ``verify`` validate an old event while silently
1494
+ ignoring an overwritten worktree. A stripped carrier falls back to the
1495
+ last recorded path so the warning remains useful after whole-file replace.
1496
+ """
1497
+ latest = latest_for(path) if meta else latest_at_recorded_path(path)
1498
+ if not latest:
1499
+ return None
1500
+ expected = read_version(latest["_commit"])
1501
+ current_blocks = parse_blocks(body, carrier_for_path(path))
1502
+ actual_digest = body_digest({"blocks": current_blocks})
1503
+ expected_digest = body_digest(expected)
1504
+ expected_fp = latest.get("soft_fingerprint") or soft_fingerprint(expected["blocks"])
1505
+ actual_fp = soft_fingerprint(current_blocks)
1506
+ events = (versions_of(path) if meta else
1507
+ [e for e in ledger_events()
1508
+ if e.get("event") == "version_created" and
1509
+ e.get("artifact_id") == latest.get("artifact_id")])
1510
+ matched = _matching_ledger_version(events, actual_digest)
1511
+ errors = []
1512
+ if meta is None:
1513
+ errors.append("unmanaged_worktree")
1514
+ elif meta.get("artifact_id") != latest.get("artifact_id"):
1515
+ errors.append("ledger_identity_mismatch")
1516
+ # Exact digest intentionally sees carrier formatting. The soft fingerprint
1517
+ # is the documented formatting-tolerant boundary: different Markdown table
1518
+ # spacing or emphasis placement is not a content drift.
1519
+ formatting_drift = actual_digest != expected_digest and actual_fp == expected_fp
1520
+ if actual_digest != expected_digest and not formatting_drift:
1521
+ errors.append("worktree_not_at_ledger_head")
1522
+ if not errors and not formatting_drift:
1523
+ return None
1524
+ return {
1525
+ "ledger_head": latest["version"],
1526
+ "observed_version": (matched.get("version") if matched else
1527
+ (latest["version"] if formatting_drift else None)),
1528
+ "errors": errors,
1529
+ "formatting_drift": formatting_drift,
1530
+ }
1531
+
1532
+
1266
1533
  def inspect_result(path):
1267
1534
  body, meta, capsule, errors = read_artifact(path)
1268
1535
  result = {"artifact": path, "managed": meta is not None,
@@ -1274,10 +1541,19 @@ def inspect_result(path):
1274
1541
  if capsule:
1275
1542
  result["head"] = capsule.get("head")
1276
1543
  result["versions"] = len(capsule.get("records", []))
1277
- if capsule.get("discovery") == CAPSULE_DISCOVERY:
1544
+ if known_capsule_discovery(capsule.get("discovery")):
1278
1545
  result["discovery"] = capsule["discovery"]
1279
1546
  elif capsule is not None:
1280
1547
  result["errors"].append("capsule_on_nonportable_artifact")
1548
+ # A portable mismatch is already reported by capsule validation. For local
1549
+ # and stripped carriers, compare the visible worktree to the ledger head.
1550
+ if not (meta and meta.get("policy") == "portable"):
1551
+ worktree = _worktree_ledger_state(path, body, meta)
1552
+ if worktree:
1553
+ result["ledger_head"] = worktree["ledger_head"]
1554
+ result["observed_version"] = worktree["observed_version"]
1555
+ result["formatting_drift"] = worktree["formatting_drift"]
1556
+ result["errors"].extend(worktree["errors"])
1281
1557
  if result["errors"]:
1282
1558
  result["status"] = "mismatch"
1283
1559
  return result
@@ -1294,6 +1570,11 @@ def cmd_inspect(a):
1294
1570
  print(f" artifact: {result['artifact_id']}")
1295
1571
  if result.get("head"):
1296
1572
  print(f" capsule: {result['versions']} version(s), head {result['head']}")
1573
+ if result.get("ledger_head"):
1574
+ observed = result.get("observed_version") or "unrecorded/unknown"
1575
+ print(f" ledger head: {result['ledger_head']} (worktree: {observed})")
1576
+ if result.get("formatting_drift"):
1577
+ print(" note: formatting differs from the ledger head; visible content matches")
1297
1578
  if result.get("discovery"):
1298
1579
  print(f" provenance: {result['discovery']['label']}")
1299
1580
  print(f" learn more: {result['discovery']['project_url']}")
@@ -1301,6 +1582,10 @@ def cmd_inspect(a):
1301
1582
  f"{result['discovery']['dist_tag']} (user consent required)")
1302
1583
  for error in result["errors"]:
1303
1584
  print(f" error: {error}")
1585
+ if "unmanaged_worktree" in result["errors"]:
1586
+ print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
1587
+ elif "worktree_not_at_ledger_head" in result["errors"]:
1588
+ print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
1304
1589
  if result["status"] != "ok":
1305
1590
  sys.exit(1)
1306
1591
 
@@ -1336,7 +1621,12 @@ def cmd_clean(a):
1336
1621
 
1337
1622
 
1338
1623
  def changed_artifact_files():
1339
- """Find changed carriers that the fallback hook can safely snapshot."""
1624
+ """Find carriers that the fallback hook can safely reconcile.
1625
+
1626
+ Git remains useful for discovering never-admitted files. Already-admitted
1627
+ artifacts also come from the ledger, so ignored files and manual edits are
1628
+ still checked when a hook runs.
1629
+ """
1340
1630
  paths = set()
1341
1631
  for pattern in ("*.md", "*.html", "*.htm"):
1342
1632
  commands = [
@@ -1349,20 +1639,44 @@ def changed_artifact_files():
1349
1639
  paths.update(p for p in git(*args).splitlines() if p)
1350
1640
  except RuntimeError:
1351
1641
  continue
1642
+ seen = set()
1643
+ for event in ledger_events():
1644
+ if event.get("event") != "version_created":
1645
+ continue
1646
+ identity = event.get("artifact_id") or event.get("artifact")
1647
+ if identity in seen:
1648
+ continue
1649
+ seen.add(identity)
1650
+ path = event.get("artifact")
1651
+ if path and os.path.isfile(path):
1652
+ paths.add(path)
1352
1653
  return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
1353
1654
 
1354
1655
 
1656
+ def matches_latest(path, body, meta):
1657
+ latest = latest_for(path)
1658
+ if not latest:
1659
+ return False
1660
+ previous = read_version(latest["_commit"])
1661
+ blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), previous)
1662
+ current = {"artifact": path, "artifact_id": meta["artifact_id"],
1663
+ "blocks": blocks}
1664
+ return version_id(current) == latest["version"]
1665
+
1666
+
1355
1667
  def cmd_capture(a):
1356
- paths = changed_artifact_files()
1668
+ paths = sorted(set(a.files)) if a.files else changed_artifact_files()
1357
1669
  captured = skipped = 0
1358
1670
  for path in paths:
1359
- body, meta, _, errors = read_artifact(path, create_meta=True)
1671
+ body, meta, _, errors, recovered = read_artifact_for_update(path)
1360
1672
  if errors:
1361
1673
  print(f"capture warning: {path}: " + ", ".join(errors))
1362
1674
  skipped += 1; continue
1363
1675
  if meta["policy"] == "ignored":
1364
1676
  print(f"capture skipped: {path} is ignored")
1365
1677
  skipped += 1; continue
1678
+ if not recovered and matches_latest(path, body, meta):
1679
+ skipped += 1; continue
1366
1680
  anchor_file(path, quiet=True)
1367
1681
  ns = argparse.Namespace(
1368
1682
  file=path, author=a.recorder, kind="system", session=a.session,
@@ -1463,6 +1777,10 @@ def cmd_show(a):
1463
1777
  print(C("dim", "why: ") + md_term(ctx_ev["why"]))
1464
1778
  for r in ctx_ev.get("rejected", []):
1465
1779
  print(C("dim", "rejected: ") + md_term(r))
1780
+ for ing in ev.get("ingredients", []):
1781
+ print(C("dim", "ingredient: ")
1782
+ + f'{ing.get("artifact_id", "?")} @ {ing.get("version", "?")}'
1783
+ + C("dim", f' ({ing.get("artifact", "")})'))
1466
1784
  print()
1467
1785
  tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
1468
1786
  for b in v["blocks"]:
@@ -1502,6 +1820,30 @@ def cmd_verify(a):
1502
1820
  raise SystemExit("artifact not in ledger")
1503
1821
  ev = _find(evs, a.version) if a.version else evs[0]
1504
1822
  n = len(evs) - next(i for i, e in enumerate(evs) if e["version"] == ev["version"])
1823
+ # ``verify file`` means the current worktree as well as the recorded
1824
+ # claims. ``verify file <version>`` remains an historical claim check.
1825
+ if (os.path.isfile(a.file) and not a.version and
1826
+ inspection["status"] != "ok"):
1827
+ if a.json:
1828
+ print(json.dumps({"artifact": a.file, "version": ev["version"],
1829
+ "status": "worktree_mismatch",
1830
+ "errors": inspection["errors"],
1831
+ "ledger_head": inspection.get("ledger_head"),
1832
+ "observed_version": inspection.get("observed_version")},
1833
+ ensure_ascii=False, indent=2))
1834
+ elif a.md:
1835
+ print("⚠️ **worktree mismatch** — " + ", ".join(inspection["errors"]))
1836
+ else:
1837
+ print(f"verify {a.file} @ {ev['version']} (v{n})")
1838
+ print(" worktree mismatch: " + ", ".join(inspection["errors"]))
1839
+ if inspection.get("ledger_head"):
1840
+ observed = inspection.get("observed_version") or "unrecorded/unknown"
1841
+ print(f" ledger head: {inspection['ledger_head']} (worktree: {observed})")
1842
+ if "unmanaged_worktree" in inspection["errors"]:
1843
+ print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
1844
+ else:
1845
+ print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
1846
+ sys.exit(1)
1505
1847
  computed = {c["block"]: c for c in ev.get("changes", [])}
1506
1848
  claims = ev.get("claims")
1507
1849
  if claims is None:
@@ -1551,15 +1893,16 @@ def cmd_verify(a):
1551
1893
  note = f' — "{cl["note"]}"' if cl.get("note") else ""
1552
1894
  if r["ok"]:
1553
1895
  what = cl.get("kind")
1554
- print(f' {C("add", "✓")} [{what}] {ctx or cl.get("block", "?")} '
1896
+ label = verify_label(comp, cl.get("block", "?"))
1897
+ print(f' {C("add", "✓")} [{what}] {label} '
1555
1898
  f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1556
1899
  else:
1557
1900
  actual = comp["kind"] if comp else "unchanged"
1558
1901
  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)}')
1902
+ f'{B(actual)}: {verify_label(comp, "?")} {C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1560
1903
  for c in undisclosed:
1561
1904
  print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
1562
- f'{c.get("context") or c.get("preview", "")[:40]} {C("dim", "(" + c["block"] + ")")}')
1905
+ f'{verify_label(c, "?")} {C("dim", "(" + c["block"] + ")")}')
1563
1906
  print()
1564
1907
  verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
1565
1908
  else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
@@ -1608,6 +1951,29 @@ def main():
1608
1951
  vf.set_defaults(f=cmd_verify)
1609
1952
  an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
1610
1953
  ig = sub.add_parser("ingest"); ig.add_argument("file"); ig.set_defaults(f=cmd_ingest)
1954
+ ml = sub.add_parser("merge-lineage",
1955
+ help="snapshot a file that merges other Proofpress artifacts, recording ingredient references")
1956
+ ml.add_argument("file")
1957
+ ml.add_argument("--from", dest="source", action="append", required=True,
1958
+ metavar="ARTIFACT", help="upstream artifact merged into this file (repeatable)")
1959
+ ml.add_argument("--author", default="unknown")
1960
+ ml.add_argument("--kind", default="human", choices=["human", "agent", "system"])
1961
+ ml.add_argument("--session", default=None); ml.add_argument("--note", default=None)
1962
+ ml.add_argument("--base-version", default=None)
1963
+ ml.add_argument("--claims", default=None)
1964
+ ml.add_argument("--why", default=None)
1965
+ ml.add_argument("--rejected", action="append", default=None)
1966
+ ml.add_argument("--requested-by", action="append", default=None)
1967
+ ml.add_argument("--produced-by", action="append", default=None)
1968
+ ml.add_argument("--edited-by", action="append", default=None)
1969
+ ml.add_argument("--recorded-by", action="append", default=None)
1970
+ ml.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
1971
+ default="self_asserted")
1972
+ ml.set_defaults(f=cmd_merge_lineage)
1973
+ idn = sub.add_parser("identify",
1974
+ help="recognize a stripped file by its soft-binding fingerprint")
1975
+ idn.add_argument("file")
1976
+ idn.add_argument("--json", action="store_true"); idn.set_defaults(f=cmd_identify)
1611
1977
  ex = sub.add_parser("export"); ex.add_argument("file")
1612
1978
  ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
1613
1979
  ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
@@ -1629,6 +1995,9 @@ def main():
1629
1995
  cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
1630
1996
  ca = sub.add_parser("capture")
1631
1997
  ca.add_argument("--recorder", required=True); ca.add_argument("--session", default=None)
1998
+ ca.add_argument("files", nargs="*",
1999
+ help="specific Markdown/HTML files to reconcile; "
2000
+ "default: Git candidates plus admitted ledger paths")
1632
2001
  ca.set_defaults(f=cmd_capture)
1633
2002
  a = p.parse_args(); a.f(a)
1634
2003
 
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
@@ -62,7 +67,7 @@ that use `npx --no-install`, so agents never download the package implicitly:
62
67
 
63
68
  [//]: # (ob:5ee2d540)
64
69
  ```sh
65
- npm install --save-dev proofpress@next
70
+ npm install --save-dev proofpress
66
71
  npx --no-install proofpress setup --agent codex
67
72
  ```
68
73
 
@@ -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
 
@@ -58,7 +63,7 @@ testimony and verification. Do not snapshot every conversational turn.
58
63
  [//]: # (ob:e96a23df)
59
64
  For an incoming file with a `proofpress:discovery` marker but no local CLI,
60
65
  tell the user it carries verifiable revision history, show the marker's
61
- canonical project URL, and ask whether to install `proofpress@next`. Never
66
+ canonical project URL, and ask whether to install `proofpress`. Never
62
67
  download or execute it without explicit consent. If the CLI is available, run
63
68
  `inspect` before trusting the capsule and then `import`.
64
69
 
@@ -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
 
@@ -54,7 +60,7 @@ conversational turn.
54
60
  [//]: # (ob:bc88257e)
55
61
  For an incoming file with a `proofpress:discovery` marker but no local CLI,
56
62
  tell the user it carries verifiable revision history, show the marker's
57
- canonical project URL, and ask whether to install `proofpress@next`. Never
63
+ canonical project URL, and ask whether to install `proofpress`. Never
58
64
  download or execute it without explicit consent. If the CLI is available, run
59
65
  `inspect` before trusting the capsule and then `import`.
60
66
 
@@ -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
 
@@ -61,7 +66,7 @@ testimony and verification. Do not snapshot every conversational turn.
61
66
  [//]: # (ob:355446e0)
62
67
  For an incoming file with a `proofpress:discovery` marker but no local CLI,
63
68
  tell the user it carries verifiable revision history, show the marker's
64
- canonical project URL, and ask whether to install `proofpress@next`. Never
69
+ canonical project URL, and ask whether to install `proofpress`. Never
65
70
  download or execute it without explicit consent. If the CLI is available, run
66
71
  `inspect` before trusting the capsule and then `import`.
67
72
 
@@ -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
 
@@ -51,7 +56,7 @@ conversational turn.
51
56
  [//]: # (ob:2f7730eb)
52
57
  For an incoming file with a `proofpress:discovery` marker but no local CLI,
53
58
  tell the user it carries verifiable revision history, show the marker's
54
- canonical project URL, and ask whether to install `proofpress@next`. Never
59
+ canonical project URL, and ask whether to install `proofpress`. Never
55
60
  download or execute it without explicit consent. If the CLI is available, run
56
61
  `inspect` before trusting the capsule and then `import`.
57
62