proofpress 0.0.1 → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proofpress.py ADDED
@@ -0,0 +1,1637 @@
1
+ #!/usr/bin/env python3
2
+ """proofpress — verifiable version ledger for Markdown and static HTML knowledge artifacts.
3
+
4
+ The Git ledger stores local history on refs/proofpress/ledger. A portable
5
+ artifact additionally carries a compact, path-independent capsule inside the
6
+ carrier file, so admitted versions and decision context can survive handoff.
7
+
8
+ Commands:
9
+ snapshot <file> [--author] [--kind agent|human|system] [--session] [--note]
10
+ [--claims claims.json] [--why TEXT] [--rejected TEXT]...
11
+ log <file> [--json]
12
+ diff <file> [<vA> <vB>] [--json]
13
+ show <file-or-version> [--json]
14
+ verify <file> [<version>] [--json] claim 校验:exit 0 ✓ / 1 ⚠ / 2 无 claims
15
+ ingest <file> 从 git commit 历史回填账本版本
16
+ policy / inspect / import / clean / capture
17
+ anchor / blocks / init / sync
18
+ """
19
+
20
+ import argparse, base64, difflib, hashlib, json, os, re, secrets, subprocess, sys, tempfile, zlib
21
+ from datetime import datetime, timezone
22
+
23
+ __version__ = "0.1.0-alpha.1"
24
+ LEDGER_REF = "refs/proofpress/ledger"
25
+
26
+ # ---------- terminal rendering ----------
27
+ # Dark-theme palette from docs/DESIGN.md, emitted as 24-bit truecolor so the
28
+ # exact same hex values drive both the review artifacts and the terminal.
29
+ # Non-tty / NO_COLOR output stays plain text, so piped (agent) consumption is
30
+ # identical to the historical format.
31
+
32
+ TERM_HEX = {"accent": "5FB3C4", "add": "6FBF8E", "del": "E08B80",
33
+ "move": "D3A94F", "dim": "787B87"}
34
+
35
+
36
+ def _color_on():
37
+ return sys.stdout.isatty() and "NO_COLOR" not in os.environ
38
+
39
+
40
+ def C(role, s, bold=False, strike=False):
41
+ """Wrap `s` in the palette color for `role` when writing to a tty."""
42
+ if not _color_on():
43
+ return str(s)
44
+ codes = []
45
+ h = TERM_HEX.get(role)
46
+ if h:
47
+ codes.append(f"38;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}")
48
+ if bold:
49
+ codes.append("1")
50
+ if strike:
51
+ codes.append("9")
52
+ if not codes:
53
+ return str(s)
54
+ return f"\x1b[{';'.join(codes)}m{s}\x1b[0m"
55
+
56
+
57
+ def B(s):
58
+ return f"\x1b[1m{s}\x1b[0m" if _color_on() else str(s)
59
+
60
+
61
+ KIND_GLYPH = {"added": ("add", "+"), "removed": ("del", "−"),
62
+ "modified": ("accent", "●"), "moved": ("move", "⇄")}
63
+
64
+
65
+ def stats_text(stats):
66
+ if not stats:
67
+ return "initial snapshot", "dim"
68
+ order = ["modified", "added", "removed", "moved"]
69
+ parts = [f"{KIND_GLYPH[k][1]} {k} {stats[k]}" for k in order if k in stats]
70
+ color = next(KIND_GLYPH[k][0] for k in order if k in stats)
71
+ return " ".join(parts), color
72
+
73
+
74
+ def stats_summary(stats):
75
+ order = ["modified", "added", "removed", "moved"]
76
+ kinds = [k for k in order if k in stats]
77
+ if len(kinds) == 1:
78
+ k, n = kinds[0], stats[kinds[0]]
79
+ return f"{n} block{'s' if n > 1 else ''} {k}"
80
+ return ", ".join(f"{stats[k]} {k}" for k in kinds)
81
+
82
+
83
+ _MD_BOLD = re.compile(r"\*\*(.+?)\*\*")
84
+ _MD_CODE = re.compile(r"`([^`]+)`")
85
+
86
+
87
+ def md_term(s):
88
+ """Minimal inline markdown → ANSI for terminal reading (bold, code).
89
+ Plain text passes through untouched when color is off, so piped output
90
+ keeps the raw markdown."""
91
+ if not _color_on():
92
+ return s
93
+ s = _MD_BOLD.sub(lambda m: B(m.group(1)), s)
94
+ s = _MD_CODE.sub(lambda m: C("accent", m.group(1)), s)
95
+ return s
96
+
97
+
98
+ def git(*args, input=None):
99
+ r = subprocess.run(["git", *args], capture_output=True, text=True, input=input)
100
+ if r.returncode != 0:
101
+ raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
102
+ return r.stdout
103
+
104
+
105
+ # ---------- carriers → block tree ----------
106
+
107
+ # Canonical anchor form is a markdown link-reference-definition comment,
108
+ # invisible in every CommonMark renderer (GitHub, Claude preview, etc.).
109
+ # The legacy HTML-comment form is still accepted on parse.
110
+ ANCHOR = re.compile(
111
+ r"^\s*(?:\[//\]: # \(ob:([0-9a-f]{8})\)|<!-- ob:([0-9a-f]{8}) -->)\s*$"
112
+ )
113
+
114
+ PP_META = re.compile(r"^\s*\[//\]: # \(proofpress:meta:([A-Za-z0-9_-]+)\)\s*$")
115
+ PP_CAPSULE = re.compile(r"^\s*\[//\]: # \(proofpress:capsule:([A-Za-z0-9_-]+)\)\s*$")
116
+ PP_DISCOVERY = re.compile(r"^\s*\[//\]: # \(proofpress:discovery:(.*?)\)\s*$")
117
+ HTML_META = re.compile(
118
+ r'<meta\s+name=["\']proofpress:meta["\']\s+content=["\']([A-Za-z0-9_-]+)["\']\s*/?>',
119
+ re.I)
120
+ HTML_DISCOVERY = re.compile(
121
+ r'<meta\s+name=["\']proofpress:discovery["\']\s+content=["\']([^"\']*)["\']\s*/?>',
122
+ re.I)
123
+ HTML_CAPSULE = re.compile(
124
+ r'<script\s+type=["\']application/vnd\.proofpress\+json["\']\s+'
125
+ r'data-proofpress=["\']capsule["\']\s*>([A-Za-z0-9_-]+)</script>', re.I)
126
+ HTML_BLOCK_START = re.compile(r"(?is)<(h[1-6]|p|li|pre|blockquote|td|th|figcaption)\b[^>]*>")
127
+ HTML_ANCHOR_ATTR = re.compile(
128
+ r"\s+data-proofpress-id\s*=\s*(?:\"([0-9a-f]{8})\"|'([0-9a-f]{8})'|([0-9a-f]{8}))",
129
+ re.I)
130
+ HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input",
131
+ "link", "meta", "param", "source", "track", "wbr"}
132
+ POLICIES = ("ignored", "local", "portable")
133
+ ATTRIBUTION_BASES = ("signed", "environment_attested", "harness_attested",
134
+ "self_asserted", "unknown")
135
+ DISCOVERY_LABEL = "Verifiable revision history by Proofpress"
136
+ DISCOVERY_URL = "https://github.com/chenmingtang830/proofpress"
137
+ DISCOVERY_TEXT = f"{DISCOVERY_LABEL} | {DISCOVERY_URL}"
138
+ CAPSULE_DISCOVERY = {
139
+ "label": DISCOVERY_LABEL,
140
+ "project_url": DISCOVERY_URL,
141
+ "package": "proofpress",
142
+ "dist_tag": "next",
143
+ "requires_user_consent": True,
144
+ }
145
+
146
+
147
+ def _b64e(data):
148
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
149
+
150
+
151
+ def _b64d(value):
152
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
153
+
154
+
155
+ def encode_meta(meta):
156
+ raw = json.dumps(meta, ensure_ascii=False, sort_keys=True,
157
+ separators=(",", ":")).encode()
158
+ return _b64e(raw)
159
+
160
+
161
+ def decode_meta(value):
162
+ return json.loads(_b64d(value))
163
+
164
+
165
+ def encode_capsule(capsule):
166
+ raw = json.dumps(capsule, ensure_ascii=False, sort_keys=True,
167
+ separators=(",", ":")).encode()
168
+ return _b64e(zlib.compress(raw, 9))
169
+
170
+
171
+ def decode_capsule(value):
172
+ return json.loads(zlib.decompress(_b64d(value)))
173
+
174
+
175
+ def carrier_for_path(path):
176
+ return "html" if os.path.splitext(path)[1].lower() in (".html", ".htm") else "markdown"
177
+
178
+
179
+ def split_markdown_transport(text):
180
+ """Return visible projection, metadata, capsule, and decode errors.
181
+
182
+ Proofpress transport markers are excluded without touching block anchors.
183
+ Duplicate markers are invalid because ambiguity is unsafe.
184
+ """
185
+ visible, metas, capsules, discoveries = [], [], [], []
186
+ fence = None
187
+ for line in text.splitlines():
188
+ fm = re.match(r"^\s*(`{3,}|~{3,})", line)
189
+ if fm:
190
+ marker = fm.group(1)
191
+ if fence is None:
192
+ fence = (marker[0], len(marker))
193
+ elif marker[0] == fence[0] and len(marker) >= fence[1]:
194
+ fence = None
195
+ visible.append(line)
196
+ continue
197
+ if fence is not None:
198
+ visible.append(line)
199
+ continue
200
+ mm, cm, dm = PP_META.match(line), PP_CAPSULE.match(line), PP_DISCOVERY.match(line)
201
+ if mm:
202
+ metas.append(mm.group(1)); continue
203
+ if cm:
204
+ capsules.append(cm.group(1)); continue
205
+ if dm:
206
+ discoveries.append(dm.group(1)); continue
207
+ visible.append(line)
208
+ errors, meta, capsule = [], None, None
209
+ if len(metas) > 1:
210
+ errors.append("duplicate_meta")
211
+ if len(capsules) > 1:
212
+ errors.append("duplicate_capsule")
213
+ if len(discoveries) > 1:
214
+ errors.append("duplicate_discovery")
215
+ if discoveries and discoveries[-1] != DISCOVERY_TEXT:
216
+ errors.append("invalid_discovery")
217
+ if discoveries and not capsules:
218
+ errors.append("discovery_without_capsule")
219
+ if metas:
220
+ try:
221
+ meta = decode_meta(metas[-1])
222
+ except Exception:
223
+ errors.append("invalid_meta")
224
+ if capsules:
225
+ try:
226
+ capsule = decode_capsule(capsules[-1])
227
+ except Exception:
228
+ errors.append("invalid_capsule")
229
+ body = "\n".join(visible).rstrip() + "\n"
230
+ return body, meta, capsule, errors
231
+
232
+
233
+ def split_html_transport(text):
234
+ """Return an HTML projection without Proofpress's non-rendering payloads.
235
+
236
+ This carrier deliberately recognises only the exact, declarative tags that
237
+ Proofpress writes. It never interprets a script as instructions.
238
+ """
239
+ metas = HTML_META.findall(text)
240
+ capsules = HTML_CAPSULE.findall(text)
241
+ discoveries = HTML_DISCOVERY.findall(text)
242
+ errors, meta, capsule = [], None, None
243
+ if len(metas) > 1:
244
+ errors.append("duplicate_meta")
245
+ if len(capsules) > 1:
246
+ errors.append("duplicate_capsule")
247
+ if len(discoveries) > 1:
248
+ errors.append("duplicate_discovery")
249
+ if discoveries and discoveries[-1] != DISCOVERY_TEXT:
250
+ errors.append("invalid_discovery")
251
+ if discoveries and not capsules:
252
+ errors.append("discovery_without_capsule")
253
+ if metas:
254
+ try:
255
+ meta = decode_meta(metas[-1])
256
+ except Exception:
257
+ errors.append("invalid_meta")
258
+ if capsules:
259
+ try:
260
+ capsule = decode_capsule(capsules[-1])
261
+ except Exception:
262
+ errors.append("invalid_capsule")
263
+ # Transport tags may occupy their own lines. Trim only carrier-edge
264
+ # whitespace so repeated read/write cycles do not accumulate blank lines.
265
+ body = HTML_META.sub(
266
+ "", HTML_DISCOVERY.sub("", HTML_CAPSULE.sub("", text))).strip() + "\n"
267
+ return body, meta, capsule, errors
268
+
269
+
270
+ def split_transport(text, carrier="markdown"):
271
+ return split_html_transport(text) if carrier == "html" else split_markdown_transport(text)
272
+
273
+
274
+ def new_meta(policy="local"):
275
+ return {"proofpress": 1, "artifact_id": "pp_" + secrets.token_hex(12),
276
+ "policy": policy}
277
+
278
+
279
+ def read_artifact(path, create_meta=False):
280
+ raw = open(path).read()
281
+ body, meta, capsule, errors = split_transport(raw, carrier_for_path(path))
282
+ if meta is None and create_meta and "invalid_meta" not in errors:
283
+ meta = new_meta()
284
+ if meta is not None:
285
+ if meta.get("policy") not in POLICIES:
286
+ errors.append("invalid_policy")
287
+ if not meta.get("artifact_id"):
288
+ errors.append("missing_artifact_id")
289
+ return body, meta, capsule, errors
290
+
291
+
292
+ def atomic_write(path, text):
293
+ directory = os.path.dirname(os.path.abspath(path)) or "."
294
+ fd, tmp = tempfile.mkstemp(prefix=".proofpress-", dir=directory, text=True)
295
+ try:
296
+ with os.fdopen(fd, "w") as f:
297
+ f.write(text)
298
+ os.replace(tmp, path)
299
+ except Exception:
300
+ try:
301
+ os.unlink(tmp)
302
+ except FileNotFoundError:
303
+ pass
304
+ raise
305
+
306
+
307
+ def write_html_artifact(path, body, meta=None, capsule=None):
308
+ """Embed transport data without rendering it or executing it in a browser."""
309
+ out = body.rstrip() + "\n"
310
+ if meta is not None:
311
+ marker = f'<meta name="proofpress:meta" content="{encode_meta(meta)}">'
312
+ if re.search(r"</head\s*>", out, re.I):
313
+ out = re.sub(r"</head\s*>", marker + "\n</head>", out, count=1, flags=re.I)
314
+ else:
315
+ out = marker + "\n" + out
316
+ if capsule is not None:
317
+ discovery = f'<meta name="proofpress:discovery" content="{DISCOVERY_TEXT}">'
318
+ if re.search(r"</head\s*>", out, re.I):
319
+ out = re.sub(r"</head\s*>", discovery + "\n</head>", out,
320
+ count=1, flags=re.I)
321
+ else:
322
+ out = discovery + "\n" + out
323
+ marker = ('<script type="application/vnd.proofpress+json" '
324
+ f'data-proofpress="capsule">{encode_capsule(capsule)}</script>')
325
+ if re.search(r"</body\s*>", out, re.I):
326
+ out = re.sub(r"</body\s*>", marker + "\n</body>", out, count=1, flags=re.I)
327
+ else:
328
+ out = out.rstrip() + "\n" + marker + "\n"
329
+ atomic_write(path, out)
330
+
331
+
332
+ def write_artifact(path, body, meta=None, capsule=None):
333
+ if carrier_for_path(path) == "html":
334
+ write_html_artifact(path, body, meta, capsule)
335
+ return
336
+ out = body.rstrip() + "\n"
337
+ markers = []
338
+ if meta is not None:
339
+ markers.append(f"[//]: # (proofpress:meta:{encode_meta(meta)})")
340
+ if capsule is not None:
341
+ markers.append(f"[//]: # (proofpress:discovery:{DISCOVERY_TEXT})")
342
+ markers.append(f"[//]: # (proofpress:capsule:{encode_capsule(capsule)})")
343
+ if markers:
344
+ out = out.rstrip() + "\n\n" + "\n".join(markers) + "\n"
345
+ atomic_write(path, out)
346
+
347
+
348
+ def format_anchor(block_id):
349
+ return f"[//]: # (ob:{block_id})"
350
+
351
+
352
+ def is_leading_yaml_frontmatter(block, index):
353
+ """True when block is YAML frontmatter that must remain at byte zero.
354
+
355
+ Agent-skill loaders require the opening `---` to be the first line. The
356
+ frontmatter still participates in the ledger and inherits identity by
357
+ exact hash/similarity; only its serialized anchor is omitted.
358
+ """
359
+ if index != 0 or block.get("type") != "para":
360
+ return False
361
+ lines = block.get("text", "").splitlines()
362
+ return len(lines) >= 2 and lines[0].strip() == "---" and lines[-1].strip() == "---"
363
+
364
+
365
+ def _html_block_type(tag):
366
+ if tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
367
+ return "heading"
368
+ if tag == "li":
369
+ return "list"
370
+ if tag == "pre":
371
+ return "code"
372
+ if tag in ("td", "th"):
373
+ return "table"
374
+ return "para"
375
+
376
+
377
+ def parse_html_blocks(text):
378
+ """Extract stable, leaf-level blocks from a static HTML document.
379
+
380
+ The persisted text is the outer HTML of each block, with Proofpress's own
381
+ identity attribute removed. Thus anchors never affect digests or diffs.
382
+ Containers (div/section/article) intentionally are not blocks: their
383
+ semantic children remain independently movable and reviewable.
384
+ """
385
+ text, _, _, _ = split_html_transport(text)
386
+ blocks, active, order, raw_text_tag = [], [], 0, None
387
+ token = re.compile(r"(?is)<!--.*?-->|<![^>]*>|</?[A-Za-z][^>]*>|[^<]+")
388
+ for match in token.finditer(text):
389
+ raw = match.group(0)
390
+ start = re.match(r"(?is)<([A-Za-z][\w:-]*)\b[^>]*>", raw)
391
+ end = re.match(r"(?is)</\s*([A-Za-z][\w:-]*)\s*>", raw)
392
+ if raw_text_tag:
393
+ if end and end.group(1).lower() == raw_text_tag:
394
+ raw_text_tag = None
395
+ continue
396
+ if start and start.group(1).lower() in ("script", "style"):
397
+ raw_text_tag = start.group(1).lower()
398
+ continue
399
+ for block in active:
400
+ block["parts"].append(raw)
401
+ if start and start.group(1).lower() not in HTML_VOID_TAGS:
402
+ block["depth"] += 1
403
+ elif end:
404
+ block["depth"] -= 1
405
+ if start:
406
+ tag = start.group(1).lower()
407
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "pre", "blockquote", "td", "th", "figcaption"):
408
+ am = HTML_ANCHOR_ATTR.search(raw)
409
+ clean = HTML_ANCHOR_ATTR.sub("", raw)
410
+ order += 1
411
+ active.append({"tag": tag, "depth": 1, "parts": [clean],
412
+ "anchor": (am.group(1) or am.group(2) or am.group(3)) if am else None,
413
+ "order": order})
414
+ if end:
415
+ completed = [b for b in active if b["depth"] == 0]
416
+ active = [b for b in active if b["depth"] != 0]
417
+ for block in completed:
418
+ value = "".join(block["parts"]).strip()
419
+ if value:
420
+ item = {"type": _html_block_type(block["tag"]), "text": value}
421
+ if block["anchor"]:
422
+ item["anchor"] = block["anchor"]
423
+ blocks.append((block["order"], item))
424
+ return [item for _, item in sorted(blocks)]
425
+
426
+
427
+ def parse_blocks(text, carrier="markdown"):
428
+ """Coarse block parser: heading / code / table / list / para.
429
+ Anchor lines ([//]: # (ob:xxxxxxxx), legacy <!-- ob:xxxxxxxx -->) attach identity to the NEXT block
430
+ and are excluded from block text (so they never affect hashing)."""
431
+ if carrier == "html":
432
+ return parse_html_blocks(text)
433
+ text, _, _, _ = split_markdown_transport(text)
434
+ lines = text.splitlines()
435
+ blocks, cur, kind = [], [], None
436
+ pending_anchor = None
437
+
438
+ def emit(block):
439
+ nonlocal pending_anchor
440
+ if pending_anchor:
441
+ block["anchor"] = pending_anchor
442
+ pending_anchor = None
443
+ blocks.append(block)
444
+
445
+ def flush():
446
+ nonlocal cur, kind
447
+ if cur:
448
+ body = "\n".join(cur).rstrip()
449
+ if body.strip():
450
+ emit({"type": kind or "para", "text": body})
451
+ cur, kind = [], None
452
+
453
+ i = 0
454
+ while i < len(lines):
455
+ ln = lines[i]
456
+ if kind == "code":
457
+ cur.append(ln)
458
+ if ln.strip().startswith("```"):
459
+ flush()
460
+ i += 1
461
+ continue
462
+ m = ANCHOR.match(ln)
463
+ if m:
464
+ flush(); pending_anchor = m.group(1) or m.group(2); i += 1; continue
465
+ if ln.strip().startswith("```"):
466
+ flush(); kind = "code"; cur = [ln]; i += 1; continue
467
+ if re.match(r"^#{1,6} ", ln):
468
+ flush(); emit({"type": "heading", "text": ln.rstrip()}); i += 1; continue
469
+ if not ln.strip():
470
+ flush(); i += 1; continue
471
+ this = "table" if ln.lstrip().startswith("|") else (
472
+ "list" if re.match(r"^\s*([-*+]|\d+\.)\s", ln) else "para")
473
+ if kind not in (None, this):
474
+ flush()
475
+ kind = kind or this
476
+ cur.append(ln)
477
+ i += 1
478
+ flush()
479
+ return blocks
480
+
481
+
482
+ def bhash(b):
483
+ return hashlib.sha1((b["type"] + "\0" + b["text"]).encode()).hexdigest()[:12]
484
+
485
+
486
+ def body_digest(version):
487
+ visible = [{"type": b["type"], "text": b["text"]}
488
+ for b in version.get("blocks", [])]
489
+ raw = json.dumps(visible, ensure_ascii=False, sort_keys=True,
490
+ separators=(",", ":")).encode()
491
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
492
+
493
+
494
+ def version_id(version):
495
+ """Content ID independent of the projection path."""
496
+ canonical = {"artifact_id": version.get("artifact_id"),
497
+ "blocks": version.get("blocks", [])}
498
+ return hashlib.sha1(json.dumps(canonical, sort_keys=True,
499
+ ensure_ascii=False).encode()).hexdigest()[:8]
500
+
501
+
502
+ # ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
503
+
504
+ def assign_ids(new_blocks, old_version):
505
+ """Give each new block an id: exact-hash match → inherit; else best
506
+ same-type similarity > 0.6 among unmatched → inherit (modified); else new."""
507
+ old = old_version["blocks"] if old_version else []
508
+ used = set()
509
+ # pass 0: explicit anchors — identity is declared, trust it
510
+ for nb in new_blocks:
511
+ a = nb.pop("anchor", None)
512
+ if a and a not in used:
513
+ nb["id"] = a; used.add(a)
514
+ # pass 1: exact content
515
+ by_hash = {}
516
+ for ob in old:
517
+ by_hash.setdefault(ob["hash"], []).append(ob)
518
+ for nb in new_blocks:
519
+ h = bhash(nb)
520
+ nb["hash"] = h
521
+ if "id" in nb:
522
+ continue
523
+ cands = [ob for ob in by_hash.get(h, []) if ob["id"] not in used]
524
+ if cands:
525
+ nb["id"] = cands[0]["id"]; used.add(nb["id"])
526
+ # pass 2: similarity
527
+ for nb in new_blocks:
528
+ if "id" in nb:
529
+ continue
530
+ best, score = None, 0.0
531
+ for ob in old:
532
+ if ob["id"] in used or ob["type"] != nb["type"]:
533
+ continue
534
+ s = difflib.SequenceMatcher(None, ob["text"], nb["text"]).ratio()
535
+ if s > score:
536
+ best, score = ob, s
537
+ if best and score > 0.6:
538
+ nb["id"] = best["id"]; used.add(nb["id"])
539
+ else:
540
+ nb["id"] = hashlib.sha1(f"{nb['text']}{len(new_blocks)}{datetime.now()}".encode()).hexdigest()[:8]
541
+ return new_blocks
542
+
543
+
544
+ # ---------- 语义 diff(结构层 + 数字抽取) ----------
545
+
546
+ # The lookbehind rejects date/range separators ("2026-07-18") and identifier
547
+ # digits ("v0", "sha1") — a data number never starts mid-word.
548
+ NUM = re.compile(r"(?<![\w-])[+-]?[$€¥]?\d[\d,.]*%?[MKB]?")
549
+
550
+ _ORDINAL = re.compile(r"(?m)^\s*\d+[.)]\s")
551
+
552
+
553
+ def extract_nums(text):
554
+ """Numbers that are data, not numbering: list ordinals stripped first."""
555
+ return NUM.findall(_ORDINAL.sub("", text))
556
+
557
+ def heading_context(blocks, idx):
558
+ for j in range(idx, -1, -1):
559
+ if blocks[j]["type"] == "heading":
560
+ return re.sub(r"^#+\s*", "", blocks[j]["text"])[:40]
561
+ return "(文首)"
562
+
563
+
564
+ def lis_ids(seq):
565
+ """ids forming a longest increasing subsequence of old positions."""
566
+ import bisect
567
+ pos = [p for _, p in seq]
568
+ tails, tidx = [], []
569
+ prev = [-1] * len(pos)
570
+ for i, p in enumerate(pos):
571
+ k = bisect.bisect_left(tails, p)
572
+ if k == len(tails):
573
+ tails.append(p); tidx.append(i)
574
+ else:
575
+ tails[k] = p; tidx[k] = i
576
+ prev[i] = tidx[k - 1] if k > 0 else -1
577
+ out, i = set(), tidx[-1] if tidx else -1
578
+ while i != -1:
579
+ out.add(seq[i][0]); i = prev[i]
580
+ return out
581
+
582
+
583
+ def semantic_diff(old_v, new_v):
584
+ old = old_v["blocks"] if old_v else []
585
+ new = new_v["blocks"]
586
+ old_by_id = {b["id"]: (i, b) for i, b in enumerate(old)}
587
+ new_ids = {b["id"] for b in new}
588
+ changes = []
589
+ common = [(b["id"], old_by_id[b["id"]][0]) for b in new if b["id"] in old_by_id]
590
+ stable = lis_ids(common) if common else set()
591
+ for i, nb in enumerate(new):
592
+ ctx = heading_context(new, i)
593
+ if nb["id"] not in old_by_id:
594
+ changes.append({"kind": "added", "block": nb["id"], "type": nb["type"],
595
+ "context": ctx, "preview": nb["text"][:60]})
596
+ continue
597
+ oi, ob = old_by_id[nb["id"]]
598
+ moved = nb["id"] not in stable
599
+ if ob["hash"] != nb["hash"]:
600
+ nums_o, nums_n = extract_nums(ob["text"]), extract_nums(nb["text"])
601
+ # positional pairing is only meaningful when the number counts
602
+ # match — otherwise it pairs unrelated values and reports noise
603
+ numchg = ([[a, b] for a, b in zip(nums_o, nums_n) if a != b]
604
+ if len(nums_o) == len(nums_n) else [])
605
+ ch = {"kind": "modified", "block": nb["id"], "type": nb["type"], "context": ctx}
606
+ if moved:
607
+ ch["also_moved"] = True
608
+ if numchg:
609
+ ch["numbers"] = numchg[:6]
610
+ changes.append(ch)
611
+ elif moved:
612
+ changes.append({"kind": "moved", "block": nb["id"], "type": nb["type"],
613
+ "context": ctx, "content_unchanged": True})
614
+ for ob in old:
615
+ if ob["id"] not in new_ids:
616
+ changes.append({"kind": "removed", "block": ob["id"], "type": ob["type"],
617
+ "context": "", "preview": ob["text"][:60]})
618
+ stats = {}
619
+ for c in changes:
620
+ stats[c["kind"]] = stats.get(c["kind"], 0) + 1
621
+ return changes, stats
622
+
623
+
624
+ def word_diff(a, b, width=100, color=False):
625
+ aw, bw = a.split(), b.split()
626
+ out = []
627
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, aw, bw).get_opcodes():
628
+ if op in ("delete", "replace"):
629
+ seg = " ".join(aw[i1:i2])
630
+ out.append(C("del", seg, strike=True) if color else "[-" + seg + "-]")
631
+ if op in ("insert", "replace"):
632
+ seg = " ".join(bw[j1:j2])
633
+ out.append(C("add", seg) if color else "{+" + seg + "+}")
634
+ s = " ".join(out)
635
+ return s if color else s[:width * 3]
636
+
637
+
638
+ # ---------- 账本读写(refs/proofpress/ledger 上的 commit 链) ----------
639
+
640
+ def ledger_events():
641
+ try:
642
+ shas = git("rev-list", LEDGER_REF).split()
643
+ except RuntimeError:
644
+ return []
645
+ evs = []
646
+ for sha in shas: # newest first
647
+ ev = json.loads(git("show", f"{sha}:event.json"))
648
+ ev["_commit"] = sha
649
+ evs.append(ev)
650
+ return evs
651
+
652
+
653
+ def read_version(commit_sha):
654
+ return json.loads(git("show", f"{commit_sha}:version.json"))
655
+
656
+
657
+ def artifact_id_at(path):
658
+ if not os.path.isfile(path):
659
+ return None
660
+ try:
661
+ _, meta, _, _ = read_artifact(path)
662
+ return (meta or {}).get("artifact_id")
663
+ except OSError:
664
+ return None
665
+
666
+
667
+ def versions_of(path):
668
+ aid = artifact_id_at(path)
669
+ out = []
670
+ for e in ledger_events():
671
+ if e.get("event") != "version_created":
672
+ continue
673
+ if aid and e.get("artifact_id") == aid:
674
+ out.append(e)
675
+ elif e.get("artifact") == path and (not aid or not e.get("artifact_id")):
676
+ out.append(e)
677
+ return out
678
+
679
+
680
+ def latest_for(path):
681
+ vs = versions_of(path)
682
+ return vs[0] if vs else None
683
+
684
+
685
+ def write_event(event, version=None):
686
+ entries = []
687
+ files = [("event.json", event)] + ([("version.json", version)] if version else [])
688
+ for name, obj in files:
689
+ blob = git("hash-object", "-w", "--stdin",
690
+ input=json.dumps(obj, ensure_ascii=False, indent=1)).strip()
691
+ entries.append(f"100644 blob {blob}\t{name}")
692
+ tree = git("mktree", input="\n".join(entries) + "\n").strip()
693
+ parent = []
694
+ try:
695
+ parent = ["-p", git("rev-parse", LEDGER_REF).strip()]
696
+ except RuntimeError:
697
+ pass
698
+ msg = f"{event['event']}: {event['artifact']} {event.get('version', '')}".rstrip()
699
+ commit = git("commit-tree", tree, *parent, "-m", msg).strip()
700
+ git("update-ref", LEDGER_REF, commit)
701
+ return commit
702
+
703
+
704
+ # ---------- commands ----------
705
+
706
+ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
707
+ text=None, ts=None, source_commit=None, artifact_id=None,
708
+ policy="local", actors=None, attribution_basis="unknown"):
709
+ """Core snapshot: file → block tree → ledger. Returns event or None (no change).
710
+
711
+ `claims` is the author's structured self-description of the change
712
+ (list of {"block", "kind", "note"?}); it is stored verbatim so `verify`
713
+ can check it against the computed diff — claims are input to be
714
+ verified, never trusted.
715
+
716
+ `text`/`ts`/`source_commit` let `ingest` replay historical git commits
717
+ into the ledger with their original content, author date and commit sha."""
718
+ if text is None:
719
+ text = open(path).read()
720
+ prev_ev = latest_for(path)
721
+ prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
722
+ blocks = assign_ids(parse_blocks(text, carrier_for_path(path)), prev_v)
723
+ version = {"artifact": path, "artifact_id": artifact_id, "blocks": blocks}
724
+ vid = version_id(version)
725
+ if prev_ev and prev_ev["version"] == vid:
726
+ return None
727
+ changes, stats = semantic_diff(prev_v, version)
728
+ event = {
729
+ "event": "version_created",
730
+ "artifact": path,
731
+ "artifact_id": artifact_id,
732
+ "version": vid,
733
+ "parent": prev_ev["version"] if prev_ev else None,
734
+ "author": {"name": author, "kind": kind, "session": session},
735
+ "note": note,
736
+ "ts": ts or datetime.now(timezone.utc).isoformat(timespec="seconds"),
737
+ "stats": stats,
738
+ "changes": changes,
739
+ "policy": policy,
740
+ }
741
+ if actors:
742
+ event["actors"] = actors
743
+ event["attribution_basis"] = attribution_basis
744
+ if source_commit:
745
+ event["source_commit"] = source_commit
746
+ if claims is not None:
747
+ event["claims"] = claims
748
+ if context is not None:
749
+ # Decision context is attributed testimony, not verified data: the
750
+ # engine can check WHAT changed against claims, but WHY is the
751
+ # author's account, recorded at submit time while it is still hot.
752
+ event["context"] = context
753
+ event["_commit_new"] = write_event(event, version)
754
+ event["_version_new"] = version
755
+ return event
756
+
757
+
758
+ def _public_event(event):
759
+ return {k: v for k, v in event.items() if not k.startswith("_")}
760
+
761
+
762
+ def _actors_from_args(a):
763
+ actors = {}
764
+ for field in ("requested_by", "produced_by", "edited_by", "recorded_by"):
765
+ values = getattr(a, field, None)
766
+ if values:
767
+ actors[field] = values
768
+ if "recorded_by" not in actors and getattr(a, "author", None):
769
+ actors["recorded_by"] = [a.author]
770
+ return actors
771
+
772
+
773
+ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
774
+ attribution_basis="self_asserted"):
775
+ prev_ev = latest_for(path)
776
+ prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
777
+ blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
778
+ version = {"artifact": path, "artifact_id": meta["artifact_id"],
779
+ "blocks": blocks}
780
+ vid = version_id(version)
781
+ changes, stats = semantic_diff(None, version)
782
+ lineage = meta.get("portable_lineage_id") or "ppl_" + secrets.token_hex(12)
783
+ meta["portable_lineage_id"] = lineage
784
+ event = {
785
+ "event": "version_created", "artifact": path,
786
+ "artifact_id": meta["artifact_id"], "version": vid, "parent": None,
787
+ "author": {"name": recorded_by or "unknown", "kind": "system",
788
+ "session": None},
789
+ "actors": ({"recorded_by": [recorded_by]} if recorded_by else {}),
790
+ "attribution_basis": attribution_basis,
791
+ "note": "portable lineage checkpoint", "policy": "portable",
792
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
793
+ "stats": stats, "changes": changes,
794
+ "portable_checkpoint": True,
795
+ "portable_lineage_id": lineage,
796
+ }
797
+ return {
798
+ "proofpress_capsule": 1,
799
+ "artifact_id": meta["artifact_id"],
800
+ "portable_lineage_id": lineage,
801
+ "head": vid,
802
+ "body_digest": body_digest(version),
803
+ "discovery": dict(CAPSULE_DISCOVERY),
804
+ "records": [{"event": event, "version": version}],
805
+ }
806
+
807
+
808
+ def validate_capsule(body, meta, capsule, carrier="markdown"):
809
+ errors = []
810
+ if not meta:
811
+ return ["missing_meta"]
812
+ if not capsule:
813
+ return ["missing_capsule"]
814
+ if capsule.get("proofpress_capsule") != 1:
815
+ errors.append("unsupported_capsule")
816
+ if ("discovery" in capsule and
817
+ capsule.get("discovery") != CAPSULE_DISCOVERY):
818
+ errors.append("invalid_capsule_discovery")
819
+ if capsule.get("artifact_id") != meta.get("artifact_id"):
820
+ errors.append("artifact_id_mismatch")
821
+ if capsule.get("portable_lineage_id") != meta.get("portable_lineage_id"):
822
+ errors.append("lineage_id_mismatch")
823
+ records = capsule.get("records")
824
+ if not isinstance(records, list) or not records:
825
+ return errors + ["missing_records"]
826
+ prev_v, prev_id = None, None
827
+ for i, record in enumerate(records):
828
+ event, version = record.get("event"), record.get("version")
829
+ if not isinstance(event, dict) or not isinstance(version, dict):
830
+ errors.append(f"invalid_record:{i}"); continue
831
+ vid = version_id(version)
832
+ if event.get("version") != vid:
833
+ errors.append(f"version_id_mismatch:{i}")
834
+ if event.get("artifact_id") != capsule.get("artifact_id"):
835
+ errors.append(f"record_artifact_mismatch:{i}")
836
+ if event.get("parent") != prev_id:
837
+ errors.append(f"chain_mismatch:{i}")
838
+ computed, stats = semantic_diff(prev_v, version)
839
+ if event.get("changes") != computed or event.get("stats") != stats:
840
+ errors.append(f"change_mismatch:{i}")
841
+ prev_v, prev_id = version, vid
842
+ if capsule.get("head") != prev_id:
843
+ errors.append("head_mismatch")
844
+ if prev_v and capsule.get("body_digest") != body_digest(prev_v):
845
+ errors.append("capsule_body_digest_mismatch")
846
+ current_blocks = assign_ids(parse_blocks(body, carrier), prev_v)
847
+ current_v = {"artifact": prev_v.get("artifact") if prev_v else "",
848
+ "artifact_id": meta.get("artifact_id"), "blocks": current_blocks}
849
+ if prev_v and body_digest(current_v) != capsule.get("body_digest"):
850
+ errors.append("body_mismatch")
851
+ return errors
852
+
853
+
854
+ def append_capsule(path, body, meta, capsule, event, version):
855
+ if capsule is None:
856
+ return make_checkpoint_capsule(
857
+ path, body, meta,
858
+ recorded_by=(event.get("actors", {}).get("recorded_by") or [None])[0],
859
+ attribution_basis=event.get("attribution_basis", "unknown"))
860
+ errors = validate_capsule(body, meta, capsule, carrier_for_path(path))
861
+ # body mismatch is expected immediately before appending a new snapshot;
862
+ # every other inconsistency means the prior history is unsafe to extend.
863
+ blocking = [e for e in errors if e != "body_mismatch"]
864
+ if blocking:
865
+ raise SystemExit("cannot extend invalid capsule: " + ", ".join(blocking))
866
+ capsule["discovery"] = dict(CAPSULE_DISCOVERY)
867
+ prior_v = capsule["records"][-1]["version"]
868
+ if version_id(version) == capsule["head"]:
869
+ capsule["body_digest"] = body_digest(version)
870
+ return capsule
871
+ changes, stats = semantic_diff(prior_v, version)
872
+ portable_event = _public_event(event)
873
+ portable_event["parent"] = capsule["head"]
874
+ portable_event["changes"] = changes
875
+ portable_event["stats"] = stats
876
+ portable_event["policy"] = "portable"
877
+ portable_event["portable_lineage_id"] = capsule["portable_lineage_id"]
878
+ capsule["records"].append({"event": portable_event, "version": version})
879
+ capsule["head"] = portable_event["version"]
880
+ capsule["body_digest"] = body_digest(version)
881
+ return capsule
882
+
883
+
884
+ def cmd_snapshot(a):
885
+ body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
886
+ if errors:
887
+ raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
888
+ if meta["policy"] == "ignored":
889
+ print(f"skipped: {a.file} policy is ignored")
890
+ return
891
+ if getattr(a, "base_version", None):
892
+ current = (capsule or {}).get("head")
893
+ if current is None:
894
+ latest = latest_for(a.file)
895
+ current = latest.get("version") if latest else None
896
+ if current != a.base_version:
897
+ raise SystemExit(
898
+ f"stale base: expected {a.base_version}, current "
899
+ f"{current or '∅'}; inspect and reconcile before snapshot")
900
+ # Persist identity even for local artifacts; this is not a portable capsule.
901
+ if artifact_id_at(a.file) is None:
902
+ write_artifact(a.file, body, meta, capsule)
903
+ claims = None
904
+ if a.claims:
905
+ claims = json.load(open(a.claims))
906
+ if not isinstance(claims, list):
907
+ raise SystemExit("--claims file must be a JSON array of "
908
+ '{"block", "kind", "note"?} objects')
909
+ context = None
910
+ if a.why or a.rejected:
911
+ context = {}
912
+ if a.why:
913
+ context["why"] = a.why
914
+ if a.rejected:
915
+ context["rejected"] = a.rejected
916
+ actors = _actors_from_args(a)
917
+ ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
918
+ claims=claims, context=context, text=body,
919
+ artifact_id=meta["artifact_id"], policy=meta["policy"],
920
+ actors=actors, attribution_basis=a.attribution_basis)
921
+ if not ev:
922
+ if meta["policy"] == "portable" and capsule is not None:
923
+ latest = latest_for(a.file)
924
+ if latest and capsule.get("head") != latest.get("version"):
925
+ version = read_version(latest["_commit"])
926
+ current_v = {"artifact": a.file,
927
+ "artifact_id": meta["artifact_id"],
928
+ "blocks": assign_ids(parse_blocks(body, carrier_for_path(a.file)), version)}
929
+ if body_digest(current_v) == body_digest(version):
930
+ capsule = append_capsule(a.file, body, meta, capsule,
931
+ latest, version)
932
+ meta["portable_head"] = capsule["head"]
933
+ write_artifact(a.file, body, meta, capsule)
934
+ print(f"repaired capsule: {a.file} -> {capsule['head']}")
935
+ return
936
+ print(f"no change: {a.file} matches the latest ledger version"); return
937
+ if meta["policy"] == "portable":
938
+ capsule = append_capsule(a.file, body, meta, capsule, ev,
939
+ ev["_version_new"])
940
+ meta["portable_head"] = capsule["head"]
941
+ write_artifact(a.file, body, meta, capsule)
942
+ n = f"v{len(versions_of(a.file))}"
943
+ print(f"✓ {n} ({ev['version']}) ← {ev['parent'] or '∅'} [{ev['_commit_new'][:8]} @ {LEDGER_REF}]")
944
+ if ev["stats"]:
945
+ print(" " + " ".join(f"{k} {v}" for k, v in sorted(ev["stats"].items())))
946
+ if a.note:
947
+ print(f" note: {a.note}")
948
+ if claims is not None:
949
+ print(f" claims: {len(claims)} recorded — run `proofpress verify {a.file}` to check them")
950
+
951
+
952
+ def cmd_log(a):
953
+ evs = versions_of(a.file)
954
+ if not evs:
955
+ print("artifact not in ledger"); return
956
+ if a.json:
957
+ out = []
958
+ for i, ev in enumerate(evs):
959
+ e = {k: v for k, v in ev.items() if k != "_commit"}
960
+ e["v"] = len(evs) - i
961
+ out.append(e)
962
+ print(json.dumps(out, ensure_ascii=False, indent=2)); return
963
+ namew = max(len(ev["author"]["name"]) for ev in evs)
964
+ stat_plain = [stats_text(ev.get("stats", {})) for ev in evs]
965
+ statw = max(len(t) for t, _ in stat_plain)
966
+ for i, ev in enumerate(evs):
967
+ n = len(evs) - i
968
+ who = ev["author"]
969
+ ts = ev["ts"][5:16].replace("T", " ")
970
+ txt, color = stat_plain[i]
971
+ line = (f'{C("accent", f"v{n}")} {ev["version"]} {C("dim", ts)} '
972
+ f'{who["name"]:<{namew}} {C("dim", who["kind"])} '
973
+ f'{C(color, txt.ljust(statw))}')
974
+ if ev.get("note"):
975
+ line += f" {ev['note']}"
976
+ if who.get("session"):
977
+ line += C("dim", f" session={who['session']}")
978
+ print(line)
979
+
980
+
981
+ def _find(evs, prefix):
982
+ for ev in evs:
983
+ if ev["version"].startswith(prefix):
984
+ return ev
985
+ raise SystemExit(f"version not found: {prefix}")
986
+
987
+
988
+ def change_label(c):
989
+ """Row title + secondary context for a change — one formatter shared by
990
+ the tty and markdown renderers (the Action must never re-implement this)."""
991
+ ctx = c.get("context", "")
992
+ if c["kind"] in ("added", "removed"):
993
+ title = (c.get("preview") or "").strip().replace("\n", " ")[:56] or ctx
994
+ same = ctx and title.lstrip("# ").startswith(ctx[:24])
995
+ return title, ("" if same or not ctx else ctx)
996
+ return (ctx or c.get("preview", "")[:48]), ""
997
+
998
+
999
+ def cmd_diff(a):
1000
+ evs = versions_of(a.file)
1001
+ if len(evs) < 2 and not (a.va and a.vb):
1002
+ print("_fewer than two ledger versions — nothing to diff yet_" if a.md
1003
+ else "fewer than two versions")
1004
+ return
1005
+ ev_b = _find(evs, a.vb) if a.vb else evs[0]
1006
+ base_note = ""
1007
+ if a.base_commit and not a.va:
1008
+ hit = next((e for e in evs if (e.get("source_commit") or "").startswith(a.base_commit)), None)
1009
+ ev_a = hit or evs[1]
1010
+ if not hit:
1011
+ base_note = "base commit not in ledger — showing last recorded change"
1012
+ else:
1013
+ ev_a = _find(evs, a.va) if a.va else evs[1]
1014
+ va, vb = read_version(ev_a["_commit"]), read_version(ev_b["_commit"])
1015
+ changes, stats = semantic_diff(va, vb)
1016
+ if a.json:
1017
+ print(json.dumps({"artifact": a.file, "from": ev_a["version"],
1018
+ "to": ev_b["version"], "stats": stats,
1019
+ "changes": changes}, ensure_ascii=False, indent=2))
1020
+ return
1021
+ if a.md:
1022
+ # GitHub markdown can't render our palette (no custom text color),
1023
+ # so we approximate with circle emoji — but yellow is reserved
1024
+ # exclusively for a real alarm (⚠️ claim mismatch below), never for
1025
+ # "this block changed". add/del keep the obvious green/red; modified
1026
+ # gets blue (closest emoji match to our terminal accent teal); moved
1027
+ # gets purple so it never reads as a warning either.
1028
+ emoji = {"added": "🟢 new", "removed": "🔴 del",
1029
+ "modified": "🔵 mod", "moved": "🟣 mov"}
1030
+ stat_str = " ".join(f"{v} {k}" for k, v in sorted(stats.items())) or "no block changes"
1031
+ out = [f"`{ev_a['version']} → {ev_b['version']}` ▍ {stat_str}"]
1032
+ if base_note:
1033
+ out.append(f"_{base_note}_")
1034
+ for c in changes[:20]:
1035
+ title, ctx = change_label(c)
1036
+ row = f"- {emoji[c['kind']]} · {title}" + (f" _· {ctx}_" if ctx else "")
1037
+ if c.get("numbers"):
1038
+ row += " **Δ " + " ".join(f"{x}→{y}" for x, y in c["numbers"]) + "**"
1039
+ out.append(row)
1040
+ if len(changes) > 20:
1041
+ out.append(f"- _…and {len(changes) - 20} more_")
1042
+ if ev_b.get("note"):
1043
+ out.append(f"> changelog: {ev_b['note']}")
1044
+ ctx_b = ev_b.get("context") or {}
1045
+ if ctx_b.get("why"):
1046
+ out.append(f"> why: {ctx_b['why']}")
1047
+ print("\n".join(out))
1048
+ return
1049
+ # numeric deltas are the highest-risk signal — surface them in the header
1050
+ allnums = [d for c in changes for d in c.get("numbers", [])]
1051
+ head = (f'{C("dim", ev_a["version"] + " → " + ev_b["version"])} '
1052
+ + C("accent", "▍ " + stats_summary(stats)))
1053
+ if allnums:
1054
+ head += " " + C("move", "Δ " + " ".join(f"{x}→{y}" for x, y in allnums[:4]))
1055
+ print(head)
1056
+ print()
1057
+ tagmap = {"added": ("add", "[new]"), "removed": ("del", "[del]"),
1058
+ "modified": ("accent", "[mod]"), "moved": ("move", "[mov]")}
1059
+ old_by_id = {b["id"]: b for b in va["blocks"]}
1060
+ new_by_id = {b["id"]: b for b in vb["blocks"]}
1061
+ if base_note:
1062
+ print(" " + C("dim", base_note))
1063
+ for c in changes:
1064
+ color, tag = tagmap[c["kind"]]
1065
+ title, ctx = change_label(c)
1066
+ where = C("dim", "(" + c["block"] + (f" · {ctx}" if ctx else "") + ")")
1067
+ line = f' {C(color, "▍ " + tag)} {B(md_term(title))} {where}'
1068
+ if c.get("numbers"):
1069
+ line += " " + C("move", "Δ " + " ".join(f"{x} → {y}" for x, y in c["numbers"]))
1070
+ if c.get("also_moved"):
1071
+ line += C("dim", " (also moved)")
1072
+ if c.get("content_unchanged"):
1073
+ line += C("dim", " — content unchanged")
1074
+ print(line)
1075
+ if c["kind"] == "modified":
1076
+ body = word_diff(old_by_id[c["block"]]["text"],
1077
+ new_by_id[c["block"]]["text"], color=_color_on())
1078
+ for ln in body.splitlines() or [body]:
1079
+ print(" " + ln)
1080
+ # this version's self-description travels with the diff (provenance layer v0)
1081
+ if ev_b.get("note") or ev_b.get("context"):
1082
+ n_b = len(evs) - next(i for i, e in enumerate(evs) if e["version"] == ev_b["version"])
1083
+ who = ev_b["author"]
1084
+ print()
1085
+ if ev_b.get("note"):
1086
+ print(" " + C("dim", f'changelog (v{n_b}, {who["kind"]}:{who["name"]}): ')
1087
+ + ev_b["note"])
1088
+ ctx_b = ev_b.get("context") or {}
1089
+ if ctx_b.get("why"):
1090
+ print(" " + C("dim", "why: ") + ctx_b["why"])
1091
+ for r in ctx_b.get("rejected", []):
1092
+ print(" " + C("dim", "rejected: ") + r)
1093
+ if ev_b.get("claims") is not None:
1094
+ print(" " + C("dim", f'claims: {len(ev_b["claims"])} recorded — `proofpress verify {a.file} {ev_b["version"]}`'))
1095
+
1096
+
1097
+ def cmd_ingest(a):
1098
+ """Backfill ledger versions from the file's git commit history.
1099
+
1100
+ This is the no-install rail: teammates who never heard of proofpress
1101
+ just commit markdown as usual; the next `ingest` run turns each of
1102
+ their commits into a ledger version signed with the commit's author
1103
+ and date. Idempotent — commits already ingested (by sha) or identical
1104
+ in content to the ledger tip are skipped. When the ledger already has
1105
+ versions, only commits newer than the latest ledger entry are
1106
+ considered, so rewritten/older history is never replayed on top.
1107
+ Renames are not followed (v0)."""
1108
+ body, meta, _, errors = read_artifact(a.file, create_meta=True)
1109
+ if errors:
1110
+ raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1111
+ if artifact_id_at(a.file) is None:
1112
+ write_artifact(a.file, body, meta, None)
1113
+ out = git("log", "--format=%H%x00%an%x00%aI%x00%s", "--", a.file)
1114
+ entries = [ln.split("\x00", 3) for ln in out.strip().splitlines() if ln]
1115
+ entries.reverse() # oldest → newest
1116
+ if not entries:
1117
+ print(f"no git history for {a.file}"); return
1118
+ evs = versions_of(a.file)
1119
+ known = {e.get("source_commit") for e in evs if e.get("source_commit")}
1120
+ floor_ts = evs[0]["ts"] if evs else None
1121
+ made = skipped = 0
1122
+ for sha, author, ts, subject in entries:
1123
+ if sha in known or (floor_ts and ts <= floor_ts):
1124
+ skipped += 1
1125
+ continue
1126
+ try:
1127
+ content = git("show", f"{sha}:{a.file}")
1128
+ except RuntimeError:
1129
+ skipped += 1
1130
+ continue # path did not exist at that commit (rename/add later)
1131
+ ev = do_snapshot(a.file, author, "human", None, subject,
1132
+ text=content, ts=ts, source_commit=sha,
1133
+ artifact_id=meta["artifact_id"], policy="local",
1134
+ actors={"recorded_by": ["git-ingest"],
1135
+ "edited_by": [author]},
1136
+ attribution_basis="environment_attested")
1137
+ if ev:
1138
+ made += 1
1139
+ n = f"v{len(versions_of(a.file))}"
1140
+ print(f"✓ {n} ({ev['version']}) {ts[:16]} {author} {subject[:50]}")
1141
+ else:
1142
+ skipped += 1 # identical content to ledger tip
1143
+ print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
1144
+
1145
+
1146
+ def cmd_export(a):
1147
+ """Write an artifact's ledger chain as one self-contained JSON bundle.
1148
+
1149
+ The sidecar answers who/what/when/why (+ claims, verification inputs,
1150
+ rejected alternatives) without access to this repo or its git refs —
1151
+ the transport representation of the ledger. v0 includes everything;
1152
+ external-clean redaction is a later, mandatory share-flow feature."""
1153
+ evs = versions_of(a.file)
1154
+ if not evs:
1155
+ raise SystemExit("artifact not in ledger")
1156
+ bundle = {
1157
+ "proofpress_export": 1,
1158
+ "artifact": a.file,
1159
+ "head": evs[0]["version"],
1160
+ "exported_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
1161
+ "versions": [],
1162
+ }
1163
+ for i, ev in enumerate(reversed(evs)): # oldest → newest
1164
+ e = {k: v for k, v in ev.items() if k != "_commit"}
1165
+ e["v"] = i + 1
1166
+ bundle["versions"].append(e)
1167
+ dest = a.output or (os.path.basename(a.file) + ".proofpress.json")
1168
+ with open(dest, "w") as f:
1169
+ json.dump(bundle, f, ensure_ascii=False, indent=1)
1170
+ print(f"exported {len(evs)} versions ({bundle['head']} head) -> {dest}")
1171
+
1172
+
1173
+ def anchor_file(path, quiet=False):
1174
+ """Write carrier-native identity anchors (idempotently).
1175
+
1176
+ Markdown writes invisible link-reference comments. Static HTML writes a
1177
+ `data-proofpress-id` attribute directly onto each supported leaf block.
1178
+ Ids come from the ledger's latest version when one exists.
1179
+ """
1180
+ body, meta, capsule, errors = read_artifact(path)
1181
+ if errors:
1182
+ raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1183
+ prev_ev = latest_for(path)
1184
+ prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
1185
+ carrier = carrier_for_path(path)
1186
+ blocks = assign_ids(parse_blocks(body, carrier), prev_v)
1187
+ if carrier == "html":
1188
+ cursor = 0
1189
+
1190
+ def put_anchor(match):
1191
+ nonlocal cursor
1192
+ if cursor >= len(blocks):
1193
+ return match.group(0)
1194
+ block = blocks[cursor]
1195
+ cursor += 1
1196
+ start = HTML_ANCHOR_ATTR.sub("", match.group(0)).rstrip()
1197
+ return start[:-1] + f' data-proofpress-id="{block["id"]}">'
1198
+
1199
+ anchored_body = HTML_BLOCK_START.sub(put_anchor, body)
1200
+ if cursor != len(blocks):
1201
+ raise SystemExit("could not anchor every HTML block; file changed while parsing")
1202
+ else:
1203
+ out = []
1204
+ for i, b in enumerate(blocks):
1205
+ if not is_leading_yaml_frontmatter(b, i):
1206
+ out.append(format_anchor(b["id"]))
1207
+ out.append(b["text"])
1208
+ out.append("")
1209
+ anchored_body = "\n".join(out).rstrip() + "\n"
1210
+ write_artifact(path, anchored_body, meta, capsule)
1211
+ if not quiet:
1212
+ print(f"anchored {len(blocks)} blocks -> {path} (content hashes unchanged; ledger unaffected)")
1213
+ # id inventory for claim-writing: which identities carried over vs are new.
1214
+ # Deliberately silent about whether an inherited block's content changed —
1215
+ # that is what the author must declare and `verify` will check.
1216
+ if prev_v:
1217
+ prev_ids = {b["id"] for b in prev_v["blocks"]}
1218
+ cur_ids = [b["id"] for b in blocks]
1219
+ inherited = [i for i in cur_ids if i in prev_ids]
1220
+ new = [i for i in cur_ids if i not in prev_ids]
1221
+ gone = [i for i in prev_ids if i not in set(cur_ids)]
1222
+ if inherited:
1223
+ print(f" inherited {len(inherited)}: " + " ".join(inherited))
1224
+ if new:
1225
+ print(f" new {len(new)}: " + " ".join(new))
1226
+ if gone:
1227
+ print(f" gone {len(gone)} (claim as removed): " + " ".join(gone))
1228
+ return blocks
1229
+
1230
+
1231
+ def cmd_anchor(a):
1232
+ anchor_file(a.file)
1233
+
1234
+
1235
+ def cmd_policy(a):
1236
+ body, meta, capsule, errors = read_artifact(a.file, create_meta=True)
1237
+ if errors:
1238
+ raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1239
+ if a.policy is None:
1240
+ print(f"{a.file}: {meta['policy']} ({meta['artifact_id']})")
1241
+ return
1242
+ old = meta.get("policy", "local")
1243
+ if a.policy == "portable":
1244
+ if old == "portable" and capsule is not None:
1245
+ cap_errors = validate_capsule(body, meta, capsule, carrier_for_path(a.file))
1246
+ if cap_errors:
1247
+ raise SystemExit("invalid existing capsule: " + ", ".join(cap_errors))
1248
+ print(f"unchanged: {a.file} is already portable")
1249
+ return
1250
+ meta["policy"] = "portable"
1251
+ meta["portable_lineage_id"] = "ppl_" + secrets.token_hex(12)
1252
+ capsule = make_checkpoint_capsule(
1253
+ a.file, body, meta, recorded_by=a.author,
1254
+ attribution_basis=a.attribution_basis)
1255
+ meta["portable_head"] = capsule["head"]
1256
+ write_artifact(a.file, body, meta, capsule)
1257
+ print(f"portable: {a.file} ({meta['artifact_id']}, head {capsule['head']})")
1258
+ return
1259
+ meta["policy"] = a.policy
1260
+ meta.pop("portable_lineage_id", None)
1261
+ meta.pop("portable_head", None)
1262
+ write_artifact(a.file, body, meta, None)
1263
+ print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
1264
+
1265
+
1266
+ def inspect_result(path):
1267
+ body, meta, capsule, errors = read_artifact(path)
1268
+ result = {"artifact": path, "managed": meta is not None,
1269
+ "policy": (meta or {}).get("policy", "unmanaged"),
1270
+ "artifact_id": (meta or {}).get("artifact_id"),
1271
+ "capsule": capsule is not None, "status": "ok", "errors": list(errors)}
1272
+ if meta and meta.get("policy") == "portable":
1273
+ result["errors"].extend(validate_capsule(body, meta, capsule, carrier_for_path(path)))
1274
+ if capsule:
1275
+ result["head"] = capsule.get("head")
1276
+ result["versions"] = len(capsule.get("records", []))
1277
+ if capsule.get("discovery") == CAPSULE_DISCOVERY:
1278
+ result["discovery"] = capsule["discovery"]
1279
+ elif capsule is not None:
1280
+ result["errors"].append("capsule_on_nonportable_artifact")
1281
+ if result["errors"]:
1282
+ result["status"] = "mismatch"
1283
+ return result
1284
+
1285
+
1286
+ def cmd_inspect(a):
1287
+ result = inspect_result(a.file)
1288
+ if a.json:
1289
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1290
+ else:
1291
+ print(f"inspect {a.file}: {result['status']}")
1292
+ print(f" policy: {result['policy']}")
1293
+ if result.get("artifact_id"):
1294
+ print(f" artifact: {result['artifact_id']}")
1295
+ if result.get("head"):
1296
+ print(f" capsule: {result['versions']} version(s), head {result['head']}")
1297
+ if result.get("discovery"):
1298
+ print(f" provenance: {result['discovery']['label']}")
1299
+ print(f" learn more: {result['discovery']['project_url']}")
1300
+ print(f" package: {result['discovery']['package']}@"
1301
+ f"{result['discovery']['dist_tag']} (user consent required)")
1302
+ for error in result["errors"]:
1303
+ print(f" error: {error}")
1304
+ if result["status"] != "ok":
1305
+ sys.exit(1)
1306
+
1307
+
1308
+ def cmd_import(a):
1309
+ body, meta, capsule, errors = read_artifact(a.file)
1310
+ if errors:
1311
+ raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
1312
+ cap_errors = validate_capsule(body, meta, capsule, carrier_for_path(a.file))
1313
+ if cap_errors:
1314
+ raise SystemExit("invalid capsule: " + ", ".join(cap_errors))
1315
+ known = {e["version"] for e in versions_of(a.file)}
1316
+ made = skipped = 0
1317
+ for record in capsule["records"]:
1318
+ event = dict(record["event"])
1319
+ version = dict(record["version"])
1320
+ if event["version"] in known:
1321
+ skipped += 1; continue
1322
+ event["artifact"] = a.file
1323
+ event["imported_from_capsule"] = True
1324
+ version["artifact"] = a.file
1325
+ write_event(event, version)
1326
+ known.add(event["version"]); made += 1
1327
+ print(f"imported {made}, skipped {skipped} -> {a.file} ({meta['artifact_id']})")
1328
+
1329
+
1330
+ def cmd_clean(a):
1331
+ body, _, _, _ = read_artifact(a.file)
1332
+ if os.path.abspath(a.output) == os.path.abspath(a.file):
1333
+ raise SystemExit("clean output must differ from the source artifact")
1334
+ atomic_write(a.output, body)
1335
+ print(f"clean copy -> {a.output} (capsule and artifact metadata removed)")
1336
+
1337
+
1338
+ def changed_artifact_files():
1339
+ """Find changed carriers that the fallback hook can safely snapshot."""
1340
+ paths = set()
1341
+ for pattern in ("*.md", "*.html", "*.htm"):
1342
+ commands = [
1343
+ ("diff", "--name-only", "HEAD", "--", pattern),
1344
+ ("diff", "--cached", "--name-only", "HEAD", "--", pattern),
1345
+ ("ls-files", "--others", "--exclude-standard", "--", pattern),
1346
+ ]
1347
+ for args in commands:
1348
+ try:
1349
+ paths.update(p for p in git(*args).splitlines() if p)
1350
+ except RuntimeError:
1351
+ continue
1352
+ return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
1353
+
1354
+
1355
+ def cmd_capture(a):
1356
+ paths = changed_artifact_files()
1357
+ captured = skipped = 0
1358
+ for path in paths:
1359
+ body, meta, _, errors = read_artifact(path, create_meta=True)
1360
+ if errors:
1361
+ print(f"capture warning: {path}: " + ", ".join(errors))
1362
+ skipped += 1; continue
1363
+ if meta["policy"] == "ignored":
1364
+ print(f"capture skipped: {path} is ignored")
1365
+ skipped += 1; continue
1366
+ anchor_file(path, quiet=True)
1367
+ ns = argparse.Namespace(
1368
+ file=path, author=a.recorder, kind="system", session=a.session,
1369
+ note="best-effort fallback capture; edit attribution unknown",
1370
+ claims=None, why=None, rejected=None,
1371
+ requested_by=None, produced_by=None, edited_by=None,
1372
+ recorded_by=[a.recorder], attribution_basis="harness_attested")
1373
+ before = latest_for(path)
1374
+ cmd_snapshot(ns)
1375
+ after = latest_for(path)
1376
+ if after and (not before or after["version"] != before["version"]):
1377
+ captured += 1
1378
+ else:
1379
+ skipped += 1
1380
+ print(f"capture complete: {captured} snapshot(s), {skipped} skipped/no-op")
1381
+
1382
+
1383
+ def cmd_init(a):
1384
+ try:
1385
+ git("remote", "get-url", "origin")
1386
+ except RuntimeError:
1387
+ print("no remote origin yet; re-run proofpress init after adding one and the ledger will sync with push/fetch")
1388
+ return
1389
+ # Deliberately NOT forced (no leading +): a forced refspec lets any
1390
+ # `git fetch` silently rewind the local ledger to the remote's older
1391
+ # state, orphaning unsynced events (this happened — 2026-07-22, live).
1392
+ # Unforced, a divergent fetch fails loudly instead; run `proofpress
1393
+ # sync` first and the fetch fast-forwards.
1394
+ spec = "refs/proofpress/*:refs/proofpress/*"
1395
+ forced = "+" + spec
1396
+ cur = subprocess.run(["git", "config", "--get-all", "remote.origin.fetch"],
1397
+ capture_output=True, text=True).stdout
1398
+ if forced in cur:
1399
+ git("config", "--unset-all", "remote.origin.fetch", re.escape(forced))
1400
+ git("config", "--add", "remote.origin.fetch", spec)
1401
+ print("fetch refspec de-forced: a stale remote can no longer clobber local ledger events")
1402
+ elif spec not in cur:
1403
+ git("config", "--add", "remote.origin.fetch", spec)
1404
+ print("fetch refspec written: clone/pull will carry the ledger automatically")
1405
+ else:
1406
+ print("fetch refspec already present")
1407
+ print("push side: run `proofpress sync` (or git push origin 'refs/proofpress/*')")
1408
+
1409
+
1410
+ def cmd_sync(a):
1411
+ git("push", "origin", "refs/proofpress/*:refs/proofpress/*")
1412
+ print("ledger pushed to origin")
1413
+
1414
+
1415
+ def cmd_blocks(a):
1416
+ evs = versions_of(a.file)
1417
+ if not evs:
1418
+ print("artifact not in ledger"); return
1419
+ ev = _find(evs, a.version) if a.version else evs[0]
1420
+ v = read_version(ev["_commit"])
1421
+ print(f"{a.file} @ {ev['version']} ({len(v['blocks'])} blocks)")
1422
+ for b in v["blocks"]:
1423
+ first = b["text"].splitlines()[0]
1424
+ indent = " " if b["type"] != "heading" else ""
1425
+ print(f" {b['id']} {b['type']:<7} {indent}{first[:56]}")
1426
+
1427
+
1428
+ def _resolve_show_ref(ref):
1429
+ """`show` accepts an artifact path (→ latest version) or a version prefix."""
1430
+ lat = latest_for(ref)
1431
+ if lat:
1432
+ return lat, versions_of(ref)
1433
+ for ev in ledger_events():
1434
+ if ev["event"] == "version_created" and ev["version"].startswith(ref):
1435
+ return ev, versions_of(ev["artifact"])
1436
+ raise SystemExit("not found")
1437
+
1438
+
1439
+ def cmd_show(a):
1440
+ ev, evs = _resolve_show_ref(a.ref)
1441
+ if a.json:
1442
+ e = {k: v for k, v in ev.items() if k != "_commit"}
1443
+ print(json.dumps(e, ensure_ascii=False, indent=2)); return
1444
+ idx = next(i for i, e in enumerate(evs) if e["version"] == ev["version"])
1445
+ n = len(evs) - idx
1446
+ v = read_version(ev["_commit"])
1447
+ parent_v = read_version(evs[idx + 1]["_commit"]) if idx + 1 < len(evs) else None
1448
+ changes, stats = semantic_diff(parent_v, v) if parent_v else ([], {})
1449
+ chg_by_id = {c["block"]: c for c in changes if c["kind"] != "removed"}
1450
+ who = ev["author"]
1451
+ ts = ev["ts"][:16].replace("T", " ")
1452
+ print(f'{B(ev["artifact"])} '
1453
+ + C("dim", f'@ v{n} · {ev["version"]} · {who["name"]} ({who["kind"]}) · {ts}'))
1454
+ if parent_v:
1455
+ line = C("accent", f"▍ {stats_summary(stats)} vs v{n - 1}")
1456
+ if ev.get("note"):
1457
+ line += C("dim", f' — {ev["note"]}')
1458
+ print(line)
1459
+ elif ev.get("note"):
1460
+ print(C("dim", ev["note"]))
1461
+ ctx_ev = ev.get("context") or {}
1462
+ if ctx_ev.get("why"):
1463
+ print(C("dim", "why: ") + md_term(ctx_ev["why"]))
1464
+ for r in ctx_ev.get("rejected", []):
1465
+ print(C("dim", "rejected: ") + md_term(r))
1466
+ print()
1467
+ tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
1468
+ for b in v["blocks"]:
1469
+ c = chg_by_id.get(b["id"])
1470
+ text = B(b["text"]) if b["type"] == "heading" else md_term(b["text"])
1471
+ if c:
1472
+ color = tagcolor[c["kind"]]
1473
+ print(f'{C(color, "▍ " + c["kind"])} {C("dim", "(" + b["id"] + ")")}')
1474
+ for ln in text.splitlines():
1475
+ print(f'{C(color, "▍")} {ln}')
1476
+ else:
1477
+ print(text)
1478
+ print()
1479
+
1480
+
1481
+ def cmd_verify(a):
1482
+ """Check the author's structured claims against the computed diff.
1483
+
1484
+ Deterministic data comparison — same inputs, same verdict, no model in
1485
+ the loop. Exit codes: 0 all claims check out, 1 mismatch/undisclosed,
1486
+ 2 version carries no claims (unverifiable)."""
1487
+ if os.path.isfile(a.file):
1488
+ inspection = inspect_result(a.file)
1489
+ if inspection["policy"] == "portable" and inspection["status"] != "ok":
1490
+ if a.json:
1491
+ print(json.dumps({"artifact": a.file, "status": "capsule_mismatch",
1492
+ "errors": inspection["errors"]},
1493
+ ensure_ascii=False, indent=2))
1494
+ elif a.md:
1495
+ print("⚠️ **capsule mismatch** — " + ", ".join(inspection["errors"]))
1496
+ else:
1497
+ print(f"verify {a.file}")
1498
+ print(" capsule mismatch: " + ", ".join(inspection["errors"]))
1499
+ sys.exit(1)
1500
+ evs = versions_of(a.file)
1501
+ if not evs:
1502
+ raise SystemExit("artifact not in ledger")
1503
+ ev = _find(evs, a.version) if a.version else evs[0]
1504
+ n = len(evs) - next(i for i, e in enumerate(evs) if e["version"] == ev["version"])
1505
+ computed = {c["block"]: c for c in ev.get("changes", [])}
1506
+ claims = ev.get("claims")
1507
+ if claims is None:
1508
+ if a.md:
1509
+ print("⚪ _no claims recorded for this version (snapshot ran without --claims)_")
1510
+ elif a.json:
1511
+ print(json.dumps({"artifact": a.file, "version": ev["version"],
1512
+ "status": "no_claims"}, ensure_ascii=False))
1513
+ else:
1514
+ print(f'verify {a.file} @ {ev["version"]} (v{n})')
1515
+ print(C("dim", " no claims recorded for this version "
1516
+ "(snapshot ran without --claims) — nothing to verify"))
1517
+ sys.exit(2)
1518
+ results, claimed_ids = [], set()
1519
+ for cl in claims:
1520
+ bid = cl.get("block", "")
1521
+ claimed_ids.add(bid)
1522
+ comp = computed.get(bid)
1523
+ if cl.get("kind") == "unchanged":
1524
+ ok = comp is None
1525
+ else:
1526
+ ok = comp is not None and comp["kind"] == cl.get("kind")
1527
+ results.append({"claim": cl, "computed": comp, "ok": ok})
1528
+ undisclosed = [c for bid, c in computed.items() if bid not in claimed_ids]
1529
+ n_ok = sum(1 for r in results if r["ok"])
1530
+ n_bad = len(results) - n_ok
1531
+ status = "verified" if not n_bad and not undisclosed else "mismatch"
1532
+ if a.md:
1533
+ # yellow/red only shows up here — a real alarm, never decoration
1534
+ if status == "verified":
1535
+ print(f"🟢 **claims verified** ({len(results)} claims, deterministic check)")
1536
+ else:
1537
+ print(f"⚠️ **claim mismatch** — {n_bad} claim(s) do not match the computed diff; "
1538
+ f"{len(undisclosed)} undisclosed change(s)")
1539
+ sys.exit(0 if status == "verified" else 1)
1540
+ if a.json:
1541
+ print(json.dumps({"artifact": a.file, "version": ev["version"], "v": n,
1542
+ "status": status, "results": results,
1543
+ "undisclosed": undisclosed}, ensure_ascii=False, indent=2))
1544
+ else:
1545
+ ver = ev["version"]
1546
+ print(f'verify {B(a.file)} ' + C("dim", f"@ {ver} (v{n})"))
1547
+ print()
1548
+ for r in results:
1549
+ cl, comp = r["claim"], r["computed"]
1550
+ ctx = (comp or {}).get("context", "")
1551
+ note = f' — "{cl["note"]}"' if cl.get("note") else ""
1552
+ if r["ok"]:
1553
+ what = cl.get("kind")
1554
+ print(f' {C("add", "✓")} [{what}] {ctx or cl.get("block", "?")} '
1555
+ f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
1556
+ else:
1557
+ actual = comp["kind"] if comp else "unchanged"
1558
+ 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)}')
1560
+ for c in undisclosed:
1561
+ print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
1562
+ f'{c.get("context") or c.get("preview", "")[:40]} {C("dim", "(" + c["block"] + ")")}')
1563
+ print()
1564
+ verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
1565
+ else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
1566
+ print(f" {verdict}")
1567
+ sys.exit(0 if status == "verified" else 1)
1568
+
1569
+
1570
+ def main():
1571
+ p = argparse.ArgumentParser(prog="proofpress")
1572
+ p.add_argument("--version", action="version",
1573
+ version=f"%(prog)s {__version__}")
1574
+ sub = p.add_subparsers(dest="cmd", required=True)
1575
+ s = sub.add_parser("snapshot"); s.add_argument("file")
1576
+ s.add_argument("--author", default="unknown"); s.add_argument("--kind", default="human", choices=["human", "agent", "system"])
1577
+ s.add_argument("--session", default=None); s.add_argument("--note", default=None)
1578
+ s.add_argument("--base-version", default=None,
1579
+ help="refuse the snapshot unless this is the current head")
1580
+ s.add_argument("--claims", default=None,
1581
+ help='JSON file: [{"block", "kind": added|removed|modified|moved|unchanged, "note"?}]')
1582
+ s.add_argument("--why", default=None,
1583
+ help="decision context: why this change was made (recorded as testimony)")
1584
+ s.add_argument("--rejected", action="append", default=None, metavar="OPTION — REASON",
1585
+ help="an alternative considered and rejected (repeatable)")
1586
+ s.add_argument("--requested-by", action="append", default=None)
1587
+ s.add_argument("--produced-by", action="append", default=None)
1588
+ s.add_argument("--edited-by", action="append", default=None)
1589
+ s.add_argument("--recorded-by", action="append", default=None)
1590
+ s.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
1591
+ default="self_asserted")
1592
+ s.set_defaults(f=cmd_snapshot)
1593
+ l = sub.add_parser("log"); l.add_argument("file")
1594
+ l.add_argument("--json", action="store_true"); l.set_defaults(f=cmd_log)
1595
+ d = sub.add_parser("diff"); d.add_argument("file")
1596
+ d.add_argument("va", nargs="?"); d.add_argument("vb", nargs="?")
1597
+ d.add_argument("--json", action="store_true")
1598
+ d.add_argument("--md", action="store_true",
1599
+ help="markdown output (PR-comment section; used by the Action)")
1600
+ d.add_argument("--base-commit", default=None, metavar="SHA",
1601
+ help="pick the from-version by its source git commit (fallback: last change)")
1602
+ d.set_defaults(f=cmd_diff)
1603
+ vf = sub.add_parser("verify"); vf.add_argument("file")
1604
+ vf.add_argument("version", nargs="?")
1605
+ vf.add_argument("--json", action="store_true")
1606
+ vf.add_argument("--md", action="store_true",
1607
+ help="one-line markdown verdict (used by the Action)")
1608
+ vf.set_defaults(f=cmd_verify)
1609
+ an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
1610
+ ig = sub.add_parser("ingest"); ig.add_argument("file"); ig.set_defaults(f=cmd_ingest)
1611
+ ex = sub.add_parser("export"); ex.add_argument("file")
1612
+ ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
1613
+ ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
1614
+ sy = sub.add_parser("sync"); sy.set_defaults(f=cmd_sync)
1615
+ b = sub.add_parser("blocks"); b.add_argument("file")
1616
+ b.add_argument("version", nargs="?"); b.set_defaults(f=cmd_blocks)
1617
+ sh = sub.add_parser("show"); sh.add_argument("ref", metavar="file-or-version")
1618
+ sh.add_argument("--json", action="store_true"); sh.set_defaults(f=cmd_show)
1619
+ po = sub.add_parser("policy"); po.add_argument("file")
1620
+ po.add_argument("policy", nargs="?", choices=POLICIES)
1621
+ po.add_argument("--author", default="unknown")
1622
+ po.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
1623
+ default="self_asserted")
1624
+ po.set_defaults(f=cmd_policy)
1625
+ ins = sub.add_parser("inspect"); ins.add_argument("file")
1626
+ ins.add_argument("--json", action="store_true"); ins.set_defaults(f=cmd_inspect)
1627
+ imp = sub.add_parser("import"); imp.add_argument("file"); imp.set_defaults(f=cmd_import)
1628
+ cl = sub.add_parser("clean"); cl.add_argument("file")
1629
+ cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
1630
+ ca = sub.add_parser("capture")
1631
+ ca.add_argument("--recorder", required=True); ca.add_argument("--session", default=None)
1632
+ ca.set_defaults(f=cmd_capture)
1633
+ a = p.parse_args(); a.f(a)
1634
+
1635
+
1636
+ if __name__ == "__main__":
1637
+ main()