proofpress 0.0.1 → 0.1.0-alpha.0

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