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/LICENSE +201 -0
- package/README.md +221 -4
- package/assets/logo.svg +6 -0
- package/bin/proofpress.js +24 -0
- package/lib/python.js +44 -0
- package/lib/setup.js +194 -0
- package/package.json +36 -4
- package/proofpress.py +1637 -0
- package/skills/README.md +105 -0
- package/skills/claude-code/hooks-example.json +16 -0
- package/skills/claude-code/proofpress/SKILL.md +69 -0
- package/skills/codex/AGENTS-snippet.md +65 -0
- package/skills/codex/config-hooks.toml +10 -0
- package/skills/cursor/proofpress/SKILL.md +72 -0
- package/skills/cursor/proofpress/agents/openai.yaml +4 -0
- package/skills/pi/extension-skeleton.ts +23 -0
- package/skills/pi/proofpress-skill.md +62 -0
- package/.npmrc-tmp +0 -1
- package/index.js +0 -1
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()
|