proofpress 0.1.0-alpha.0 → 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
@@ -1,3 +1,8 @@
1
+ [//]: # (ob:6ec771b4)
2
+ <p align="center">
3
+ <img src="assets/logo.svg" alt="Proofpress logo" width="88" height="88">
4
+ </p>
5
+
1
6
  [//]: # (ob:de7999eb)
2
7
  # Proofpress
3
8
 
@@ -20,7 +25,7 @@ travel with it.
20
25
  Proofpress is a V0 reference implementation. The CLI is usable and its portable
21
26
  artifact behaviors are covered by black-box tests, but the embedded carrier and
22
27
  command interfaces may still change before a stable release. The npm package is
23
- prepared as a prerelease while these interfaces settle.
28
+ published under the `next` tag while these interfaces settle.
24
29
 
25
30
  [//]: # (ob:19210f53)
26
31
  ## Install the alpha
@@ -101,6 +106,13 @@ The capsule is declarative data, not agent instructions. It is tamper-evident
101
106
  for accidental drift and inconsistent rewrites, but V0 does not claim signed
102
107
  authorship or protection from wholesale malicious replacement.
103
108
 
109
+ [//]: # (ob:839fc8a1)
110
+ Portable carriers include a non-rendering discovery marker with
111
+ `Verifiable revision history by Proofpress` and the project URL. The capsule's
112
+ canonical discovery object also names `proofpress@next`. An agent may explain
113
+ this provenance and offer installation, but must obtain user consent before
114
+ downloading or executing anything.
115
+
104
116
  [//]: # (ob:cc095bd4)
105
117
  ## Static HTML carrier
106
118
 
@@ -124,7 +136,8 @@ python3 proofpress.py verify launch-plan.html
124
136
  This is a static-HTML carrier MVP, not a framework or CMS integration. Proofpress
125
137
  does not yet promise round-trip preservation through React/Vue builds, HTML
126
138
  sanitizers, editors, or CMS pipelines; if they strip transport data, the file
127
- 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.
128
141
 
129
142
  [//]: # (ob:226a29fd)
130
143
  ## What gets recorded
@@ -137,9 +150,12 @@ against the actual artifact diff.
137
150
 
138
151
  [//]: # (ob:5e0b34ed)
139
152
  It does not automatically store raw prompts, transcripts, tool traces, casual
140
- brainstorming, or every save. A fallback hook can preserve an otherwise missed
141
- version, but identifies itself only as `recorded_by`; it does not guess who
142
- 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.
143
159
 
144
160
  [//]: # (ob:d8387b0b)
145
161
  ## Privacy modes
@@ -189,6 +205,35 @@ Use `--rejected` only for consequential dead branches that future collaborators
189
205
  should not repeat. Source code stays in Git; Proofpress is for Markdown and
190
206
  static HTML knowledge artifacts.
191
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
+
192
237
  [//]: # (ob:2a955c60)
193
238
  ## Surfaces
194
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proofpress",
3
- "version": "0.1.0-alpha.0",
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.0"
25
+ __version__ = "0.1.0-alpha.2"
24
26
  LEDGER_REF = "refs/proofpress/ledger"
25
27
 
26
28
  # ---------- terminal rendering ----------
@@ -113,9 +115,13 @@ ANCHOR = re.compile(
113
115
 
114
116
  PP_META = re.compile(r"^\s*\[//\]: # \(proofpress:meta:([A-Za-z0-9_-]+)\)\s*$")
115
117
  PP_CAPSULE = re.compile(r"^\s*\[//\]: # \(proofpress:capsule:([A-Za-z0-9_-]+)\)\s*$")
118
+ PP_DISCOVERY = re.compile(r"^\s*\[//\]: # \(proofpress:discovery:(.*?)\)\s*$")
116
119
  HTML_META = re.compile(
117
120
  r'<meta\s+name=["\']proofpress:meta["\']\s+content=["\']([A-Za-z0-9_-]+)["\']\s*/?>',
118
121
  re.I)
122
+ HTML_DISCOVERY = re.compile(
123
+ r'<meta\s+name=["\']proofpress:discovery["\']\s+content=["\']([^"\']*)["\']\s*/?>',
124
+ re.I)
119
125
  HTML_CAPSULE = re.compile(
120
126
  r'<script\s+type=["\']application/vnd\.proofpress\+json["\']\s+'
121
127
  r'data-proofpress=["\']capsule["\']\s*>([A-Za-z0-9_-]+)</script>', re.I)
@@ -128,6 +134,16 @@ HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input",
128
134
  POLICIES = ("ignored", "local", "portable")
129
135
  ATTRIBUTION_BASES = ("signed", "environment_attested", "harness_attested",
130
136
  "self_asserted", "unknown")
137
+ DISCOVERY_LABEL = "Verifiable revision history by Proofpress"
138
+ DISCOVERY_URL = "https://github.com/chenmingtang830/proofpress"
139
+ DISCOVERY_TEXT = f"{DISCOVERY_LABEL} | {DISCOVERY_URL}"
140
+ CAPSULE_DISCOVERY = {
141
+ "label": DISCOVERY_LABEL,
142
+ "project_url": DISCOVERY_URL,
143
+ "package": "proofpress",
144
+ "dist_tag": "next",
145
+ "requires_user_consent": True,
146
+ }
131
147
 
132
148
 
133
149
  def _b64e(data):
@@ -168,19 +184,40 @@ def split_markdown_transport(text):
168
184
  Proofpress transport markers are excluded without touching block anchors.
169
185
  Duplicate markers are invalid because ambiguity is unsafe.
170
186
  """
171
- visible, metas, capsules = [], [], []
187
+ visible, metas, capsules, discoveries = [], [], [], []
188
+ fence = None
172
189
  for line in text.splitlines():
173
- mm, cm = PP_META.match(line), PP_CAPSULE.match(line)
190
+ fm = re.match(r"^\s*(`{3,}|~{3,})", line)
191
+ if fm:
192
+ marker = fm.group(1)
193
+ if fence is None:
194
+ fence = (marker[0], len(marker))
195
+ elif marker[0] == fence[0] and len(marker) >= fence[1]:
196
+ fence = None
197
+ visible.append(line)
198
+ continue
199
+ if fence is not None:
200
+ visible.append(line)
201
+ continue
202
+ mm, cm, dm = PP_META.match(line), PP_CAPSULE.match(line), PP_DISCOVERY.match(line)
174
203
  if mm:
175
204
  metas.append(mm.group(1)); continue
176
205
  if cm:
177
206
  capsules.append(cm.group(1)); continue
207
+ if dm:
208
+ discoveries.append(dm.group(1)); continue
178
209
  visible.append(line)
179
210
  errors, meta, capsule = [], None, None
180
211
  if len(metas) > 1:
181
212
  errors.append("duplicate_meta")
182
213
  if len(capsules) > 1:
183
214
  errors.append("duplicate_capsule")
215
+ if len(discoveries) > 1:
216
+ errors.append("duplicate_discovery")
217
+ if discoveries and discoveries[-1] != DISCOVERY_TEXT:
218
+ errors.append("invalid_discovery")
219
+ if discoveries and not capsules:
220
+ errors.append("discovery_without_capsule")
184
221
  if metas:
185
222
  try:
186
223
  meta = decode_meta(metas[-1])
@@ -201,12 +238,20 @@ def split_html_transport(text):
201
238
  This carrier deliberately recognises only the exact, declarative tags that
202
239
  Proofpress writes. It never interprets a script as instructions.
203
240
  """
204
- metas, capsules = HTML_META.findall(text), HTML_CAPSULE.findall(text)
241
+ metas = HTML_META.findall(text)
242
+ capsules = HTML_CAPSULE.findall(text)
243
+ discoveries = HTML_DISCOVERY.findall(text)
205
244
  errors, meta, capsule = [], None, None
206
245
  if len(metas) > 1:
207
246
  errors.append("duplicate_meta")
208
247
  if len(capsules) > 1:
209
248
  errors.append("duplicate_capsule")
249
+ if len(discoveries) > 1:
250
+ errors.append("duplicate_discovery")
251
+ if discoveries and discoveries[-1] != DISCOVERY_TEXT:
252
+ errors.append("invalid_discovery")
253
+ if discoveries and not capsules:
254
+ errors.append("discovery_without_capsule")
210
255
  if metas:
211
256
  try:
212
257
  meta = decode_meta(metas[-1])
@@ -219,7 +264,8 @@ def split_html_transport(text):
219
264
  errors.append("invalid_capsule")
220
265
  # Transport tags may occupy their own lines. Trim only carrier-edge
221
266
  # whitespace so repeated read/write cycles do not accumulate blank lines.
222
- body = HTML_META.sub("", HTML_CAPSULE.sub("", text)).strip() + "\n"
267
+ body = HTML_META.sub(
268
+ "", HTML_DISCOVERY.sub("", HTML_CAPSULE.sub("", text))).strip() + "\n"
223
269
  return body, meta, capsule, errors
224
270
 
225
271
 
@@ -270,6 +316,12 @@ def write_html_artifact(path, body, meta=None, capsule=None):
270
316
  else:
271
317
  out = marker + "\n" + out
272
318
  if capsule is not None:
319
+ discovery = f'<meta name="proofpress:discovery" content="{DISCOVERY_TEXT}">'
320
+ if re.search(r"</head\s*>", out, re.I):
321
+ out = re.sub(r"</head\s*>", discovery + "\n</head>", out,
322
+ count=1, flags=re.I)
323
+ else:
324
+ out = discovery + "\n" + out
273
325
  marker = ('<script type="application/vnd.proofpress+json" '
274
326
  f'data-proofpress="capsule">{encode_capsule(capsule)}</script>')
275
327
  if re.search(r"</body\s*>", out, re.I):
@@ -288,6 +340,7 @@ def write_artifact(path, body, meta=None, capsule=None):
288
340
  if meta is not None:
289
341
  markers.append(f"[//]: # (proofpress:meta:{encode_meta(meta)})")
290
342
  if capsule is not None:
343
+ markers.append(f"[//]: # (proofpress:discovery:{DISCOVERY_TEXT})")
291
344
  markers.append(f"[//]: # (proofpress:capsule:{encode_capsule(capsule)})")
292
345
  if markers:
293
346
  out = out.rstrip() + "\n\n" + "\n".join(markers) + "\n"
@@ -448,6 +501,30 @@ def version_id(version):
448
501
  ensure_ascii=False).encode()).hexdigest()[:8]
449
502
 
450
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
+
451
528
  # ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
452
529
 
453
530
  def assign_ids(new_blocks, old_version):
@@ -626,6 +703,40 @@ def versions_of(path):
626
703
  return out
627
704
 
628
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
+
629
740
  def latest_for(path):
630
741
  vs = versions_of(path)
631
742
  return vs[0] if vs else None
@@ -652,9 +763,19 @@ def write_event(event, version=None):
652
763
 
653
764
  # ---------- commands ----------
654
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
+
655
775
  def do_snapshot(path, author, kind, session, note, claims=None, context=None,
656
776
  text=None, ts=None, source_commit=None, artifact_id=None,
657
- policy="local", actors=None, attribution_basis="unknown"):
777
+ policy="local", actors=None, attribution_basis="unknown",
778
+ ingredients=None):
658
779
  """Core snapshot: file → block tree → ledger. Returns event or None (no change).
659
780
 
660
781
  `claims` is the author's structured self-description of the change
@@ -663,7 +784,12 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
663
784
  verified, never trusted.
664
785
 
665
786
  `text`/`ts`/`source_commit` let `ingest` replay historical git commits
666
- 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)
667
793
  if text is None:
668
794
  text = open(path).read()
669
795
  prev_ev = latest_for(path)
@@ -686,7 +812,10 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
686
812
  "stats": stats,
687
813
  "changes": changes,
688
814
  "policy": policy,
815
+ "soft_fingerprint": soft_fingerprint(blocks),
689
816
  }
817
+ if ingredients:
818
+ event["ingredients"] = ingredients
690
819
  if actors:
691
820
  event["actors"] = actors
692
821
  event["attribution_basis"] = attribution_basis
@@ -721,6 +850,7 @@ def _actors_from_args(a):
721
850
 
722
851
  def make_checkpoint_capsule(path, body, meta, recorded_by=None,
723
852
  attribution_basis="self_asserted"):
853
+ check_attribution_basis(attribution_basis)
724
854
  prev_ev = latest_for(path)
725
855
  prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
726
856
  blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
@@ -740,6 +870,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
740
870
  "note": "portable lineage checkpoint", "policy": "portable",
741
871
  "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
742
872
  "stats": stats, "changes": changes,
873
+ "soft_fingerprint": soft_fingerprint(blocks),
743
874
  "portable_checkpoint": True,
744
875
  "portable_lineage_id": lineage,
745
876
  }
@@ -749,6 +880,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
749
880
  "portable_lineage_id": lineage,
750
881
  "head": vid,
751
882
  "body_digest": body_digest(version),
883
+ "discovery": dict(CAPSULE_DISCOVERY),
752
884
  "records": [{"event": event, "version": version}],
753
885
  }
754
886
 
@@ -761,6 +893,9 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
761
893
  return ["missing_capsule"]
762
894
  if capsule.get("proofpress_capsule") != 1:
763
895
  errors.append("unsupported_capsule")
896
+ if ("discovery" in capsule and
897
+ capsule.get("discovery") != CAPSULE_DISCOVERY):
898
+ errors.append("invalid_capsule_discovery")
764
899
  if capsule.get("artifact_id") != meta.get("artifact_id"):
765
900
  errors.append("artifact_id_mismatch")
766
901
  if capsule.get("portable_lineage_id") != meta.get("portable_lineage_id"):
@@ -808,6 +943,7 @@ def append_capsule(path, body, meta, capsule, event, version):
808
943
  blocking = [e for e in errors if e != "body_mismatch"]
809
944
  if blocking:
810
945
  raise SystemExit("cannot extend invalid capsule: " + ", ".join(blocking))
946
+ capsule["discovery"] = dict(CAPSULE_DISCOVERY)
811
947
  prior_v = capsule["records"][-1]["version"]
812
948
  if version_id(version) == capsule["head"]:
813
949
  capsule["body_digest"] = body_digest(version)
@@ -826,12 +962,15 @@ def append_capsule(path, body, meta, capsule, event, version):
826
962
 
827
963
 
828
964
  def cmd_snapshot(a):
829
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
965
+ body, meta, capsule, errors, recovered = read_artifact_for_update(a.file)
830
966
  if errors:
831
967
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
832
968
  if meta["policy"] == "ignored":
833
969
  print(f"skipped: {a.file} policy is ignored")
834
970
  return
971
+ if recovered:
972
+ print(f"recovered identity: {a.file} -> {meta['artifact_id']} "
973
+ "(matched previous ledger path)")
835
974
  if getattr(a, "base_version", None):
836
975
  current = (capsule or {}).get("head")
837
976
  if current is None:
@@ -861,8 +1000,18 @@ def cmd_snapshot(a):
861
1000
  ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
862
1001
  claims=claims, context=context, text=body,
863
1002
  artifact_id=meta["artifact_id"], policy=meta["policy"],
864
- actors=actors, attribution_basis=a.attribution_basis)
1003
+ actors=actors, attribution_basis=a.attribution_basis,
1004
+ ingredients=getattr(a, "ingredients", None))
865
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
866
1015
  if meta["policy"] == "portable" and capsule is not None:
867
1016
  latest = latest_for(a.file)
868
1017
  if latest and capsule.get("head") != latest.get("version"):
@@ -940,6 +1089,23 @@ def change_label(c):
940
1089
  return (ctx or c.get("preview", "")[:48]), ""
941
1090
 
942
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
+
943
1109
  def cmd_diff(a):
944
1110
  evs = versions_of(a.file)
945
1111
  if len(evs) < 2 and not (a.va and a.vb):
@@ -1087,6 +1253,98 @@ def cmd_ingest(a):
1087
1253
  print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
1088
1254
 
1089
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
+
1090
1348
  def cmd_export(a):
1091
1349
  """Write an artifact's ledger chain as one self-contained JSON bundle.
1092
1350
 
@@ -1177,7 +1435,7 @@ def cmd_anchor(a):
1177
1435
 
1178
1436
 
1179
1437
  def cmd_policy(a):
1180
- body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
1438
+ body, meta, capsule, errors, _ = read_artifact_for_update(a.file)
1181
1439
  if errors:
1182
1440
  raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1183
1441
  if a.policy is None:
@@ -1207,6 +1465,58 @@ def cmd_policy(a):
1207
1465
  print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
1208
1466
 
1209
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
+
1210
1520
  def inspect_result(path):
1211
1521
  body, meta, capsule, errors = read_artifact(path)
1212
1522
  result = {"artifact": path, "managed": meta is not None,
@@ -1218,8 +1528,19 @@ def inspect_result(path):
1218
1528
  if capsule:
1219
1529
  result["head"] = capsule.get("head")
1220
1530
  result["versions"] = len(capsule.get("records", []))
1531
+ if capsule.get("discovery") == CAPSULE_DISCOVERY:
1532
+ result["discovery"] = capsule["discovery"]
1221
1533
  elif capsule is not None:
1222
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"])
1223
1544
  if result["errors"]:
1224
1545
  result["status"] = "mismatch"
1225
1546
  return result
@@ -1236,8 +1557,22 @@ def cmd_inspect(a):
1236
1557
  print(f" artifact: {result['artifact_id']}")
1237
1558
  if result.get("head"):
1238
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")
1565
+ if result.get("discovery"):
1566
+ print(f" provenance: {result['discovery']['label']}")
1567
+ print(f" learn more: {result['discovery']['project_url']}")
1568
+ print(f" package: {result['discovery']['package']}@"
1569
+ f"{result['discovery']['dist_tag']} (user consent required)")
1239
1570
  for error in result["errors"]:
1240
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")
1241
1576
  if result["status"] != "ok":
1242
1577
  sys.exit(1)
1243
1578
 
@@ -1273,7 +1608,12 @@ def cmd_clean(a):
1273
1608
 
1274
1609
 
1275
1610
  def changed_artifact_files():
1276
- """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
+ """
1277
1617
  paths = set()
1278
1618
  for pattern in ("*.md", "*.html", "*.htm"):
1279
1619
  commands = [
@@ -1286,20 +1626,44 @@ def changed_artifact_files():
1286
1626
  paths.update(p for p in git(*args).splitlines() if p)
1287
1627
  except RuntimeError:
1288
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)
1289
1640
  return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
1290
1641
 
1291
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
+
1292
1654
  def cmd_capture(a):
1293
- paths = changed_artifact_files()
1655
+ paths = sorted(set(a.files)) if a.files else changed_artifact_files()
1294
1656
  captured = skipped = 0
1295
1657
  for path in paths:
1296
- body, meta, _, errors = read_artifact(path, create_meta=True)
1658
+ body, meta, _, errors, recovered = read_artifact_for_update(path)
1297
1659
  if errors:
1298
1660
  print(f"capture warning: {path}: " + ", ".join(errors))
1299
1661
  skipped += 1; continue
1300
1662
  if meta["policy"] == "ignored":
1301
1663
  print(f"capture skipped: {path} is ignored")
1302
1664
  skipped += 1; continue
1665
+ if not recovered and matches_latest(path, body, meta):
1666
+ skipped += 1; continue
1303
1667
  anchor_file(path, quiet=True)
1304
1668
  ns = argparse.Namespace(
1305
1669
  file=path, author=a.recorder, kind="system", session=a.session,
@@ -1400,6 +1764,10 @@ def cmd_show(a):
1400
1764
  print(C("dim", "why: ") + md_term(ctx_ev["why"]))
1401
1765
  for r in ctx_ev.get("rejected", []):
1402
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", "")})'))
1403
1771
  print()
1404
1772
  tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
1405
1773
  for b in v["blocks"]:
@@ -1439,6 +1807,30 @@ def cmd_verify(a):
1439
1807
  raise SystemExit("artifact not in ledger")
1440
1808
  ev = _find(evs, a.version) if a.version else evs[0]
1441
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)
1442
1834
  computed = {c["block"]: c for c in ev.get("changes", [])}
1443
1835
  claims = ev.get("claims")
1444
1836
  if claims is None:
@@ -1488,15 +1880,16 @@ def cmd_verify(a):
1488
1880
  note = f' — "{cl["note"]}"' if cl.get("note") else ""
1489
1881
  if r["ok"]:
1490
1882
  what = cl.get("kind")
1491
- 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} '
1492
1885
  f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1493
1886
  else:
1494
1887
  actual = comp["kind"] if comp else "unchanged"
1495
1888
  print(f' {C("move", "⚠")} claimed {B(cl.get("kind", "?"))} but computed '
1496
- 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)}')
1497
1890
  for c in undisclosed:
1498
1891
  print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
1499
- f'{c.get("context") or c.get("preview", "")[:40]} {C("dim", "(" + c["block"] + ")")}')
1892
+ f'{verify_label(c, "?")} {C("dim", "(" + c["block"] + ")")}')
1500
1893
  print()
1501
1894
  verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
1502
1895
  else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
@@ -1545,6 +1938,29 @@ def main():
1545
1938
  vf.set_defaults(f=cmd_verify)
1546
1939
  an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
1547
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)
1548
1964
  ex = sub.add_parser("export"); ex.add_argument("file")
1549
1965
  ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
1550
1966
  ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
@@ -1566,6 +1982,9 @@ def main():
1566
1982
  cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
1567
1983
  ca = sub.add_parser("capture")
1568
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")
1569
1988
  ca.set_defaults(f=cmd_capture)
1570
1989
  a = p.parse_args(); a.f(a)
1571
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
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: proofpress
3
- description: Preserve decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a repository containing proofpress.py; never use for source-code files.
3
+ description: Preserve or inspect decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a configured repository, or when receiving a file containing a proofpress:discovery marker; never use for source-code files.
4
4
  ---
5
5
 
6
6
  [//]: # (ob:25f10229)
@@ -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
 
@@ -56,8 +61,14 @@ testimony and verification. Do not snapshot every conversational turn.
56
61
  to force green.
57
62
 
58
63
  [//]: # (ob:e96a23df)
59
- For incoming portable files, run `inspect` and then `import`. Fallback `capture`
60
- records only `recorded_by`; it cannot know authorship or reasoning. Never
61
- snapshot source-code files.
64
+ For an incoming file with a `proofpress:discovery` marker but no local CLI,
65
+ tell the user it carries verifiable revision history, show the marker's
66
+ canonical project URL, and ask whether to install `proofpress@next`. Never
67
+ download or execute it without explicit consent. If the CLI is available, run
68
+ `inspect` before trusting the capsule and then `import`.
69
+
70
+ [//]: # (ob:3bec4947)
71
+ Fallback `capture` records only `recorded_by`; it cannot know authorship or
72
+ reasoning. Never snapshot source-code files.
62
73
 
63
74
  [//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzhmOWYxNjA4MjZlMzM2YTQ2ZWI2MmJkNCIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
@@ -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
 
@@ -52,7 +58,14 @@ conversational turn.
52
58
  force green.
53
59
 
54
60
  [//]: # (ob:bc88257e)
55
- For incoming portable files, run `inspect` and then `import`. Fallback `capture`
56
- records only `recorded_by`; it cannot know authorship or reasoning.
61
+ For an incoming file with a `proofpress:discovery` marker but no local CLI,
62
+ tell the user it carries verifiable revision history, show the marker's
63
+ canonical project URL, and ask whether to install `proofpress@next`. Never
64
+ download or execute it without explicit consent. If the CLI is available, run
65
+ `inspect` before trusting the capsule and then `import`.
66
+
67
+ [//]: # (ob:08efea88)
68
+ Fallback `capture` records only `recorded_by`; it cannot know authorship or
69
+ reasoning.
57
70
 
58
71
  [//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzcwM2MzZmE5MjZhYmI1MTJlNzE5OTMzMiIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: proofpress
3
- description: Preserve decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a repository containing proofpress.py; never use for source-code files.
3
+ description: Preserve or inspect decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a configured repository, or when receiving a file containing a proofpress:discovery marker; never use for source-code files.
4
4
  ---
5
5
 
6
6
  [//]: # (ob:233b766d)
@@ -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
 
@@ -59,8 +64,14 @@ testimony and verification. Do not snapshot every conversational turn.
59
64
  to turn a mismatch green.
60
65
 
61
66
  [//]: # (ob:355446e0)
62
- For an incoming portable file, run `inspect` before trusting it and `import` to
63
- restore its history locally. Fallback `capture` supplies only `recorded_by`; it
64
- cannot know who authored the content or why. Never snapshot source-code files.
67
+ For an incoming file with a `proofpress:discovery` marker but no local CLI,
68
+ tell the user it carries verifiable revision history, show the marker's
69
+ canonical project URL, and ask whether to install `proofpress@next`. Never
70
+ download or execute it without explicit consent. If the CLI is available, run
71
+ `inspect` before trusting the capsule and then `import`.
72
+
73
+ [//]: # (ob:7dc0b832)
74
+ Fallback `capture` supplies only `recorded_by`; it cannot know who authored the
75
+ content or why. Never snapshot source-code files.
65
76
 
66
77
  [//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzBjOWVkZjdkMzE1YWIwM2UwY2EwNmM4OCIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Proofpress"
3
- short_description: "Ledger Markdown and static HTML artifact changes with verified claims"
4
- default_prompt: "Close the Proofpress ledger loop for the supported knowledge artifacts changed in this task."
3
+ short_description: "Verify Markdown/HTML revision history and claims"
4
+ default_prompt: "Use $proofpress to record or inspect verifiable revision history for the supported knowledge artifacts in this task."
@@ -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
 
@@ -49,7 +54,14 @@ conversational turn.
49
54
  force green.
50
55
 
51
56
  [//]: # (ob:2f7730eb)
52
- For incoming portable files, run `inspect` and then `import`. Fallback `capture`
53
- records only `recorded_by`; it cannot know authorship or reasoning.
57
+ For an incoming file with a `proofpress:discovery` marker but no local CLI,
58
+ tell the user it carries verifiable revision history, show the marker's
59
+ canonical project URL, and ask whether to install `proofpress@next`. Never
60
+ download or execute it without explicit consent. If the CLI is available, run
61
+ `inspect` before trusting the capsule and then `import`.
62
+
63
+ [//]: # (ob:72680124)
64
+ Fallback `capture` records only `recorded_by`; it cannot know authorship or
65
+ reasoning.
54
66
 
55
67
  [//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwX2M3Y2UwZDE1Yjg2YmI0MmJkMjg0MzRmYyIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)