proofpress 0.1.0-alpha.0 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -5
- package/package.json +1 -1
- package/proofpress.py +436 -17
- package/skills/README.md +6 -1
- package/skills/claude-code/hooks-example.json +1 -1
- package/skills/claude-code/proofpress/SKILL.md +15 -4
- package/skills/codex/AGENTS-snippet.md +15 -2
- package/skills/codex/config-hooks.toml +1 -1
- package/skills/cursor/proofpress/SKILL.md +15 -4
- package/skills/cursor/proofpress/agents/openai.yaml +2 -2
- package/skills/pi/extension-skeleton.ts +4 -3
- package/skills/pi/proofpress-skill.md +14 -2
package/README.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
[//]: # (ob:6ec771b4)
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="assets/logo.svg" alt="Proofpress logo" width="88" height="88">
|
|
4
|
+
</p>
|
|
5
|
+
|
|
1
6
|
[//]: # (ob:de7999eb)
|
|
2
7
|
# Proofpress
|
|
3
8
|
|
|
@@ -20,7 +25,7 @@ travel with it.
|
|
|
20
25
|
Proofpress is a V0 reference implementation. The CLI is usable and its portable
|
|
21
26
|
artifact behaviors are covered by black-box tests, but the embedded carrier and
|
|
22
27
|
command interfaces may still change before a stable release. The npm package is
|
|
23
|
-
|
|
28
|
+
published under the `next` tag while these interfaces settle.
|
|
24
29
|
|
|
25
30
|
[//]: # (ob:19210f53)
|
|
26
31
|
## Install the alpha
|
|
@@ -101,6 +106,13 @@ The capsule is declarative data, not agent instructions. It is tamper-evident
|
|
|
101
106
|
for accidental drift and inconsistent rewrites, but V0 does not claim signed
|
|
102
107
|
authorship or protection from wholesale malicious replacement.
|
|
103
108
|
|
|
109
|
+
[//]: # (ob:839fc8a1)
|
|
110
|
+
Portable carriers include a non-rendering discovery marker with
|
|
111
|
+
`Verifiable revision history by Proofpress` and the project URL. The capsule's
|
|
112
|
+
canonical discovery object also names `proofpress@next`. An agent may explain
|
|
113
|
+
this provenance and offer installation, but must obtain user consent before
|
|
114
|
+
downloading or executing anything.
|
|
115
|
+
|
|
104
116
|
[//]: # (ob:cc095bd4)
|
|
105
117
|
## Static HTML carrier
|
|
106
118
|
|
|
@@ -124,7 +136,8 @@ python3 proofpress.py verify launch-plan.html
|
|
|
124
136
|
This is a static-HTML carrier MVP, not a framework or CMS integration. Proofpress
|
|
125
137
|
does not yet promise round-trip preservation through React/Vue builds, HTML
|
|
126
138
|
sanitizers, editors, or CMS pipelines; if they strip transport data, the file
|
|
127
|
-
degrades to an ordinary HTML artifact
|
|
139
|
+
degrades to an ordinary HTML artifact — `identify` can still recognize it
|
|
140
|
+
locally, but its provenance does not come back.
|
|
128
141
|
|
|
129
142
|
[//]: # (ob:226a29fd)
|
|
130
143
|
## What gets recorded
|
|
@@ -137,9 +150,12 @@ against the actual artifact diff.
|
|
|
137
150
|
|
|
138
151
|
[//]: # (ob:5e0b34ed)
|
|
139
152
|
It does not automatically store raw prompts, transcripts, tool traces, casual
|
|
140
|
-
brainstorming, or every save. A fallback hook
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
brainstorming, or every save. A fallback hook checks Git candidates and current
|
|
154
|
+
paths already admitted to the ledger, including Git-ignored artifacts. It can
|
|
155
|
+
preserve an otherwise missed version, but identifies itself only as
|
|
156
|
+
`recorded_by`; it does not guess who wrote the content or why. Harness skills
|
|
157
|
+
can also capture a specific existing file before an agent edits it, keeping
|
|
158
|
+
unattributed human drift separate from the agent's revision.
|
|
143
159
|
|
|
144
160
|
[//]: # (ob:d8387b0b)
|
|
145
161
|
## Privacy modes
|
|
@@ -189,6 +205,35 @@ Use `--rejected` only for consequential dead branches that future collaborators
|
|
|
189
205
|
should not repeat. Source code stays in Git; Proofpress is for Markdown and
|
|
190
206
|
static HTML knowledge artifacts.
|
|
191
207
|
|
|
208
|
+
[//]: # (ob:59769c11)
|
|
209
|
+
## Merged lineage and stripped copies
|
|
210
|
+
|
|
211
|
+
[//]: # (ob:91a8deb1)
|
|
212
|
+
When one document merges several Proofpress-managed sources, record the
|
|
213
|
+
upstream references — identity, head version, and digest, never copied
|
|
214
|
+
history:
|
|
215
|
+
|
|
216
|
+
[//]: # (ob:100c5aa7)
|
|
217
|
+
```sh
|
|
218
|
+
python3 proofpress.py merge-lineage proposal.md \
|
|
219
|
+
--from research-a.md --from research-b.md
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
[//]: # (ob:144c3d60)
|
|
223
|
+
When a copy lost its metadata and capsule (pasted as plain text, reformatted,
|
|
224
|
+
sanitized), the ledger can still recognize it by a deterministic content
|
|
225
|
+
fingerprint:
|
|
226
|
+
|
|
227
|
+
[//]: # (ob:28dec45f)
|
|
228
|
+
```sh
|
|
229
|
+
python3 proofpress.py identify pasted-copy.md
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
[//]: # (ob:b705ac38)
|
|
233
|
+
`identify` answers identity — "this is that artifact" — on a machine holding
|
|
234
|
+
the ledger. It does not restore history or prove the copy was never altered,
|
|
235
|
+
and a copy with wording changes intentionally does not match.
|
|
236
|
+
|
|
192
237
|
[//]: # (ob:2a955c60)
|
|
193
238
|
## Surfaces
|
|
194
239
|
|
package/package.json
CHANGED
package/proofpress.py
CHANGED
|
@@ -13,14 +13,16 @@ Commands:
|
|
|
13
13
|
show <file-or-version> [--json]
|
|
14
14
|
verify <file> [<version>] [--json] claim 校验:exit 0 ✓ / 1 ⚠ / 2 无 claims
|
|
15
15
|
ingest <file> 从 git commit 历史回填账本版本
|
|
16
|
+
merge-lineage <file> --from a.md --from b.md 多父溯源:记录 ingredient 引用
|
|
17
|
+
identify <file> 软绑定反查:capsule 被剥离后认回身份
|
|
16
18
|
policy / inspect / import / clean / capture
|
|
17
19
|
anchor / blocks / init / sync
|
|
18
20
|
"""
|
|
19
21
|
|
|
20
|
-
import argparse, base64, difflib, hashlib, json, os, re, secrets, subprocess, sys, tempfile, zlib
|
|
22
|
+
import argparse, base64, difflib, hashlib, html, json, os, re, secrets, subprocess, sys, tempfile, zlib
|
|
21
23
|
from datetime import datetime, timezone
|
|
22
24
|
|
|
23
|
-
__version__ = "0.1.0-alpha.
|
|
25
|
+
__version__ = "0.1.0-alpha.2"
|
|
24
26
|
LEDGER_REF = "refs/proofpress/ledger"
|
|
25
27
|
|
|
26
28
|
# ---------- terminal rendering ----------
|
|
@@ -113,9 +115,13 @@ ANCHOR = re.compile(
|
|
|
113
115
|
|
|
114
116
|
PP_META = re.compile(r"^\s*\[//\]: # \(proofpress:meta:([A-Za-z0-9_-]+)\)\s*$")
|
|
115
117
|
PP_CAPSULE = re.compile(r"^\s*\[//\]: # \(proofpress:capsule:([A-Za-z0-9_-]+)\)\s*$")
|
|
118
|
+
PP_DISCOVERY = re.compile(r"^\s*\[//\]: # \(proofpress:discovery:(.*?)\)\s*$")
|
|
116
119
|
HTML_META = re.compile(
|
|
117
120
|
r'<meta\s+name=["\']proofpress:meta["\']\s+content=["\']([A-Za-z0-9_-]+)["\']\s*/?>',
|
|
118
121
|
re.I)
|
|
122
|
+
HTML_DISCOVERY = re.compile(
|
|
123
|
+
r'<meta\s+name=["\']proofpress:discovery["\']\s+content=["\']([^"\']*)["\']\s*/?>',
|
|
124
|
+
re.I)
|
|
119
125
|
HTML_CAPSULE = re.compile(
|
|
120
126
|
r'<script\s+type=["\']application/vnd\.proofpress\+json["\']\s+'
|
|
121
127
|
r'data-proofpress=["\']capsule["\']\s*>([A-Za-z0-9_-]+)</script>', re.I)
|
|
@@ -128,6 +134,16 @@ HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
|
128
134
|
POLICIES = ("ignored", "local", "portable")
|
|
129
135
|
ATTRIBUTION_BASES = ("signed", "environment_attested", "harness_attested",
|
|
130
136
|
"self_asserted", "unknown")
|
|
137
|
+
DISCOVERY_LABEL = "Verifiable revision history by Proofpress"
|
|
138
|
+
DISCOVERY_URL = "https://github.com/chenmingtang830/proofpress"
|
|
139
|
+
DISCOVERY_TEXT = f"{DISCOVERY_LABEL} | {DISCOVERY_URL}"
|
|
140
|
+
CAPSULE_DISCOVERY = {
|
|
141
|
+
"label": DISCOVERY_LABEL,
|
|
142
|
+
"project_url": DISCOVERY_URL,
|
|
143
|
+
"package": "proofpress",
|
|
144
|
+
"dist_tag": "next",
|
|
145
|
+
"requires_user_consent": True,
|
|
146
|
+
}
|
|
131
147
|
|
|
132
148
|
|
|
133
149
|
def _b64e(data):
|
|
@@ -168,19 +184,40 @@ def split_markdown_transport(text):
|
|
|
168
184
|
Proofpress transport markers are excluded without touching block anchors.
|
|
169
185
|
Duplicate markers are invalid because ambiguity is unsafe.
|
|
170
186
|
"""
|
|
171
|
-
visible, metas, capsules = [], [], []
|
|
187
|
+
visible, metas, capsules, discoveries = [], [], [], []
|
|
188
|
+
fence = None
|
|
172
189
|
for line in text.splitlines():
|
|
173
|
-
|
|
190
|
+
fm = re.match(r"^\s*(`{3,}|~{3,})", line)
|
|
191
|
+
if fm:
|
|
192
|
+
marker = fm.group(1)
|
|
193
|
+
if fence is None:
|
|
194
|
+
fence = (marker[0], len(marker))
|
|
195
|
+
elif marker[0] == fence[0] and len(marker) >= fence[1]:
|
|
196
|
+
fence = None
|
|
197
|
+
visible.append(line)
|
|
198
|
+
continue
|
|
199
|
+
if fence is not None:
|
|
200
|
+
visible.append(line)
|
|
201
|
+
continue
|
|
202
|
+
mm, cm, dm = PP_META.match(line), PP_CAPSULE.match(line), PP_DISCOVERY.match(line)
|
|
174
203
|
if mm:
|
|
175
204
|
metas.append(mm.group(1)); continue
|
|
176
205
|
if cm:
|
|
177
206
|
capsules.append(cm.group(1)); continue
|
|
207
|
+
if dm:
|
|
208
|
+
discoveries.append(dm.group(1)); continue
|
|
178
209
|
visible.append(line)
|
|
179
210
|
errors, meta, capsule = [], None, None
|
|
180
211
|
if len(metas) > 1:
|
|
181
212
|
errors.append("duplicate_meta")
|
|
182
213
|
if len(capsules) > 1:
|
|
183
214
|
errors.append("duplicate_capsule")
|
|
215
|
+
if len(discoveries) > 1:
|
|
216
|
+
errors.append("duplicate_discovery")
|
|
217
|
+
if discoveries and discoveries[-1] != DISCOVERY_TEXT:
|
|
218
|
+
errors.append("invalid_discovery")
|
|
219
|
+
if discoveries and not capsules:
|
|
220
|
+
errors.append("discovery_without_capsule")
|
|
184
221
|
if metas:
|
|
185
222
|
try:
|
|
186
223
|
meta = decode_meta(metas[-1])
|
|
@@ -201,12 +238,20 @@ def split_html_transport(text):
|
|
|
201
238
|
This carrier deliberately recognises only the exact, declarative tags that
|
|
202
239
|
Proofpress writes. It never interprets a script as instructions.
|
|
203
240
|
"""
|
|
204
|
-
metas
|
|
241
|
+
metas = HTML_META.findall(text)
|
|
242
|
+
capsules = HTML_CAPSULE.findall(text)
|
|
243
|
+
discoveries = HTML_DISCOVERY.findall(text)
|
|
205
244
|
errors, meta, capsule = [], None, None
|
|
206
245
|
if len(metas) > 1:
|
|
207
246
|
errors.append("duplicate_meta")
|
|
208
247
|
if len(capsules) > 1:
|
|
209
248
|
errors.append("duplicate_capsule")
|
|
249
|
+
if len(discoveries) > 1:
|
|
250
|
+
errors.append("duplicate_discovery")
|
|
251
|
+
if discoveries and discoveries[-1] != DISCOVERY_TEXT:
|
|
252
|
+
errors.append("invalid_discovery")
|
|
253
|
+
if discoveries and not capsules:
|
|
254
|
+
errors.append("discovery_without_capsule")
|
|
210
255
|
if metas:
|
|
211
256
|
try:
|
|
212
257
|
meta = decode_meta(metas[-1])
|
|
@@ -219,7 +264,8 @@ def split_html_transport(text):
|
|
|
219
264
|
errors.append("invalid_capsule")
|
|
220
265
|
# Transport tags may occupy their own lines. Trim only carrier-edge
|
|
221
266
|
# whitespace so repeated read/write cycles do not accumulate blank lines.
|
|
222
|
-
body = HTML_META.sub(
|
|
267
|
+
body = HTML_META.sub(
|
|
268
|
+
"", HTML_DISCOVERY.sub("", HTML_CAPSULE.sub("", text))).strip() + "\n"
|
|
223
269
|
return body, meta, capsule, errors
|
|
224
270
|
|
|
225
271
|
|
|
@@ -270,6 +316,12 @@ def write_html_artifact(path, body, meta=None, capsule=None):
|
|
|
270
316
|
else:
|
|
271
317
|
out = marker + "\n" + out
|
|
272
318
|
if capsule is not None:
|
|
319
|
+
discovery = f'<meta name="proofpress:discovery" content="{DISCOVERY_TEXT}">'
|
|
320
|
+
if re.search(r"</head\s*>", out, re.I):
|
|
321
|
+
out = re.sub(r"</head\s*>", discovery + "\n</head>", out,
|
|
322
|
+
count=1, flags=re.I)
|
|
323
|
+
else:
|
|
324
|
+
out = discovery + "\n" + out
|
|
273
325
|
marker = ('<script type="application/vnd.proofpress+json" '
|
|
274
326
|
f'data-proofpress="capsule">{encode_capsule(capsule)}</script>')
|
|
275
327
|
if re.search(r"</body\s*>", out, re.I):
|
|
@@ -288,6 +340,7 @@ def write_artifact(path, body, meta=None, capsule=None):
|
|
|
288
340
|
if meta is not None:
|
|
289
341
|
markers.append(f"[//]: # (proofpress:meta:{encode_meta(meta)})")
|
|
290
342
|
if capsule is not None:
|
|
343
|
+
markers.append(f"[//]: # (proofpress:discovery:{DISCOVERY_TEXT})")
|
|
291
344
|
markers.append(f"[//]: # (proofpress:capsule:{encode_capsule(capsule)})")
|
|
292
345
|
if markers:
|
|
293
346
|
out = out.rstrip() + "\n\n" + "\n".join(markers) + "\n"
|
|
@@ -448,6 +501,30 @@ def version_id(version):
|
|
|
448
501
|
ensure_ascii=False).encode()).hexdigest()[:8]
|
|
449
502
|
|
|
450
503
|
|
|
504
|
+
# ---------- soft binding(剥离元数据后仍可识别的确定性指纹) ----------
|
|
505
|
+
|
|
506
|
+
_FP_RAWTEXT = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
|
|
507
|
+
_FP_TAG = re.compile(r"<[^>]+>")
|
|
508
|
+
_FP_MD_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def soft_fingerprint(blocks):
|
|
512
|
+
"""Tier-1 exact soft binding: hash of the visible text skeleton.
|
|
513
|
+
|
|
514
|
+
Deterministic, model-free. Normalization is deliberately harsh — strip
|
|
515
|
+
HTML tags, markdown link targets and all syntax/punctuation, collapse
|
|
516
|
+
whitespace — so the fingerprint survives formatting drift (plain-text
|
|
517
|
+
copy, re-rendering, whitespace mangling) but flips on any wording
|
|
518
|
+
change. It proves identity ("this is pp_xxx"), never tamper-freedom."""
|
|
519
|
+
text = "\n".join(b["text"] for b in blocks)
|
|
520
|
+
text = _FP_RAWTEXT.sub(" ", text)
|
|
521
|
+
text = _FP_TAG.sub(" ", text)
|
|
522
|
+
text = _FP_MD_LINK.sub(r"\1", text)
|
|
523
|
+
text = html.unescape(text)
|
|
524
|
+
skeleton = " ".join(re.findall(r"[^\W_]+", text))
|
|
525
|
+
return "ppsb1:" + hashlib.sha256(skeleton.encode()).hexdigest()
|
|
526
|
+
|
|
527
|
+
|
|
451
528
|
# ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
|
|
452
529
|
|
|
453
530
|
def assign_ids(new_blocks, old_version):
|
|
@@ -626,6 +703,40 @@ def versions_of(path):
|
|
|
626
703
|
return out
|
|
627
704
|
|
|
628
705
|
|
|
706
|
+
def latest_at_recorded_path(path):
|
|
707
|
+
"""Return the newest event projected at path, even if file metadata is gone.
|
|
708
|
+
|
|
709
|
+
Normal edits carry artifact identity in the file. A whole-file replace can
|
|
710
|
+
strip that transport, so path continuity is the deterministic local
|
|
711
|
+
fallback: the replacement is another version of the artifact last recorded
|
|
712
|
+
at that path.
|
|
713
|
+
"""
|
|
714
|
+
target = os.path.normcase(os.path.abspath(path))
|
|
715
|
+
for event in ledger_events():
|
|
716
|
+
if event.get("event") != "version_created":
|
|
717
|
+
continue
|
|
718
|
+
recorded = event.get("artifact")
|
|
719
|
+
if recorded and os.path.normcase(os.path.abspath(recorded)) == target:
|
|
720
|
+
return event
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def read_artifact_for_update(path):
|
|
725
|
+
"""Read a carrier and recover stripped identity from its recorded path."""
|
|
726
|
+
body, meta, capsule, errors = read_artifact(path)
|
|
727
|
+
recovered = False
|
|
728
|
+
if meta is None and "invalid_meta" not in errors:
|
|
729
|
+
prior = latest_at_recorded_path(path)
|
|
730
|
+
if prior and prior.get("artifact_id"):
|
|
731
|
+
policy = prior.get("policy", "local")
|
|
732
|
+
meta = new_meta(policy if policy in POLICIES else "local")
|
|
733
|
+
meta["artifact_id"] = prior["artifact_id"]
|
|
734
|
+
recovered = True
|
|
735
|
+
else:
|
|
736
|
+
meta = new_meta()
|
|
737
|
+
return body, meta, capsule, errors, recovered
|
|
738
|
+
|
|
739
|
+
|
|
629
740
|
def latest_for(path):
|
|
630
741
|
vs = versions_of(path)
|
|
631
742
|
return vs[0] if vs else None
|
|
@@ -652,9 +763,19 @@ def write_event(event, version=None):
|
|
|
652
763
|
|
|
653
764
|
# ---------- commands ----------
|
|
654
765
|
|
|
766
|
+
def check_attribution_basis(basis):
|
|
767
|
+
# `signed` is the reserved top rung of the attribution ladder. Until
|
|
768
|
+
# cryptographic signing lands, accepting it would grade attribution
|
|
769
|
+
# above what is actually attested — a false claim by construction.
|
|
770
|
+
if basis == "signed":
|
|
771
|
+
raise SystemExit("attribution_basis 'signed' is reserved until "
|
|
772
|
+
"signing is implemented; use harness_attested or below")
|
|
773
|
+
|
|
774
|
+
|
|
655
775
|
def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
656
776
|
text=None, ts=None, source_commit=None, artifact_id=None,
|
|
657
|
-
policy="local", actors=None, attribution_basis="unknown"
|
|
777
|
+
policy="local", actors=None, attribution_basis="unknown",
|
|
778
|
+
ingredients=None):
|
|
658
779
|
"""Core snapshot: file → block tree → ledger. Returns event or None (no change).
|
|
659
780
|
|
|
660
781
|
`claims` is the author's structured self-description of the change
|
|
@@ -663,7 +784,12 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
663
784
|
verified, never trusted.
|
|
664
785
|
|
|
665
786
|
`text`/`ts`/`source_commit` let `ingest` replay historical git commits
|
|
666
|
-
into the ledger with their original content, author date and commit sha.
|
|
787
|
+
into the ledger with their original content, author date and commit sha.
|
|
788
|
+
|
|
789
|
+
`ingredients` is a list of upstream-artifact references (id + head +
|
|
790
|
+
digest, never copied history) recorded when this version merges other
|
|
791
|
+
Proofpress artifacts; the linear `parent` chain is unaffected."""
|
|
792
|
+
check_attribution_basis(attribution_basis)
|
|
667
793
|
if text is None:
|
|
668
794
|
text = open(path).read()
|
|
669
795
|
prev_ev = latest_for(path)
|
|
@@ -686,7 +812,10 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
686
812
|
"stats": stats,
|
|
687
813
|
"changes": changes,
|
|
688
814
|
"policy": policy,
|
|
815
|
+
"soft_fingerprint": soft_fingerprint(blocks),
|
|
689
816
|
}
|
|
817
|
+
if ingredients:
|
|
818
|
+
event["ingredients"] = ingredients
|
|
690
819
|
if actors:
|
|
691
820
|
event["actors"] = actors
|
|
692
821
|
event["attribution_basis"] = attribution_basis
|
|
@@ -721,6 +850,7 @@ def _actors_from_args(a):
|
|
|
721
850
|
|
|
722
851
|
def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
723
852
|
attribution_basis="self_asserted"):
|
|
853
|
+
check_attribution_basis(attribution_basis)
|
|
724
854
|
prev_ev = latest_for(path)
|
|
725
855
|
prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
|
|
726
856
|
blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
|
|
@@ -740,6 +870,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
|
740
870
|
"note": "portable lineage checkpoint", "policy": "portable",
|
|
741
871
|
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
742
872
|
"stats": stats, "changes": changes,
|
|
873
|
+
"soft_fingerprint": soft_fingerprint(blocks),
|
|
743
874
|
"portable_checkpoint": True,
|
|
744
875
|
"portable_lineage_id": lineage,
|
|
745
876
|
}
|
|
@@ -749,6 +880,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
|
749
880
|
"portable_lineage_id": lineage,
|
|
750
881
|
"head": vid,
|
|
751
882
|
"body_digest": body_digest(version),
|
|
883
|
+
"discovery": dict(CAPSULE_DISCOVERY),
|
|
752
884
|
"records": [{"event": event, "version": version}],
|
|
753
885
|
}
|
|
754
886
|
|
|
@@ -761,6 +893,9 @@ def validate_capsule(body, meta, capsule, carrier="markdown"):
|
|
|
761
893
|
return ["missing_capsule"]
|
|
762
894
|
if capsule.get("proofpress_capsule") != 1:
|
|
763
895
|
errors.append("unsupported_capsule")
|
|
896
|
+
if ("discovery" in capsule and
|
|
897
|
+
capsule.get("discovery") != CAPSULE_DISCOVERY):
|
|
898
|
+
errors.append("invalid_capsule_discovery")
|
|
764
899
|
if capsule.get("artifact_id") != meta.get("artifact_id"):
|
|
765
900
|
errors.append("artifact_id_mismatch")
|
|
766
901
|
if capsule.get("portable_lineage_id") != meta.get("portable_lineage_id"):
|
|
@@ -808,6 +943,7 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
808
943
|
blocking = [e for e in errors if e != "body_mismatch"]
|
|
809
944
|
if blocking:
|
|
810
945
|
raise SystemExit("cannot extend invalid capsule: " + ", ".join(blocking))
|
|
946
|
+
capsule["discovery"] = dict(CAPSULE_DISCOVERY)
|
|
811
947
|
prior_v = capsule["records"][-1]["version"]
|
|
812
948
|
if version_id(version) == capsule["head"]:
|
|
813
949
|
capsule["body_digest"] = body_digest(version)
|
|
@@ -826,12 +962,15 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
826
962
|
|
|
827
963
|
|
|
828
964
|
def cmd_snapshot(a):
|
|
829
|
-
body, meta, capsule, errors =
|
|
965
|
+
body, meta, capsule, errors, recovered = read_artifact_for_update(a.file)
|
|
830
966
|
if errors:
|
|
831
967
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
832
968
|
if meta["policy"] == "ignored":
|
|
833
969
|
print(f"skipped: {a.file} policy is ignored")
|
|
834
970
|
return
|
|
971
|
+
if recovered:
|
|
972
|
+
print(f"recovered identity: {a.file} -> {meta['artifact_id']} "
|
|
973
|
+
"(matched previous ledger path)")
|
|
835
974
|
if getattr(a, "base_version", None):
|
|
836
975
|
current = (capsule or {}).get("head")
|
|
837
976
|
if current is None:
|
|
@@ -861,8 +1000,18 @@ def cmd_snapshot(a):
|
|
|
861
1000
|
ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
|
|
862
1001
|
claims=claims, context=context, text=body,
|
|
863
1002
|
artifact_id=meta["artifact_id"], policy=meta["policy"],
|
|
864
|
-
actors=actors, attribution_basis=a.attribution_basis
|
|
1003
|
+
actors=actors, attribution_basis=a.attribution_basis,
|
|
1004
|
+
ingredients=getattr(a, "ingredients", None))
|
|
865
1005
|
if not ev:
|
|
1006
|
+
if meta["policy"] == "portable" and capsule is None and recovered:
|
|
1007
|
+
capsule = make_checkpoint_capsule(
|
|
1008
|
+
a.file, body, meta,
|
|
1009
|
+
recorded_by=(actors.get("recorded_by") or [a.author])[0],
|
|
1010
|
+
attribution_basis=a.attribution_basis)
|
|
1011
|
+
meta["portable_head"] = capsule["head"]
|
|
1012
|
+
write_artifact(a.file, body, meta, capsule)
|
|
1013
|
+
print(f"repaired capsule: {a.file} -> {capsule['head']}")
|
|
1014
|
+
return
|
|
866
1015
|
if meta["policy"] == "portable" and capsule is not None:
|
|
867
1016
|
latest = latest_for(a.file)
|
|
868
1017
|
if latest and capsule.get("head") != latest.get("version"):
|
|
@@ -940,6 +1089,23 @@ def change_label(c):
|
|
|
940
1089
|
return (ctx or c.get("preview", "")[:48]), ""
|
|
941
1090
|
|
|
942
1091
|
|
|
1092
|
+
def verify_label(change, fallback):
|
|
1093
|
+
"""Make a claim target distinguishable from its heading context.
|
|
1094
|
+
|
|
1095
|
+
A Markdown heading and every block beneath it share ``context``. Verify
|
|
1096
|
+
output therefore needs the block type and, for non-headings, a short
|
|
1097
|
+
preview so adjacent rows are not mistaken for duplicate blocks.
|
|
1098
|
+
"""
|
|
1099
|
+
if not change:
|
|
1100
|
+
return fallback
|
|
1101
|
+
ctx = change.get("context", "")
|
|
1102
|
+
block_type = change.get("type", "block")
|
|
1103
|
+
preview = (change.get("preview") or "").strip().replace("\n", " ")[:56]
|
|
1104
|
+
if block_type == "heading" or not preview:
|
|
1105
|
+
return f"{ctx or fallback} / {block_type}"
|
|
1106
|
+
return f"{ctx + ' / ' if ctx else ''}{block_type}: {preview}"
|
|
1107
|
+
|
|
1108
|
+
|
|
943
1109
|
def cmd_diff(a):
|
|
944
1110
|
evs = versions_of(a.file)
|
|
945
1111
|
if len(evs) < 2 and not (a.va and a.vb):
|
|
@@ -1087,6 +1253,98 @@ def cmd_ingest(a):
|
|
|
1087
1253
|
print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
|
|
1088
1254
|
|
|
1089
1255
|
|
|
1256
|
+
def _ingredient_ref(path):
|
|
1257
|
+
"""Reference + digest for an upstream artifact — never its history."""
|
|
1258
|
+
if not os.path.isfile(path):
|
|
1259
|
+
raise SystemExit(f"ingredient not found: {path}")
|
|
1260
|
+
_, meta, capsule, errors = read_artifact(path)
|
|
1261
|
+
if errors:
|
|
1262
|
+
raise SystemExit(f"ingredient {path}: invalid metadata: " + ", ".join(errors))
|
|
1263
|
+
if not meta:
|
|
1264
|
+
raise SystemExit(f"ingredient {path} is not proofpress-managed "
|
|
1265
|
+
"(snapshot or import it first)")
|
|
1266
|
+
ref = {"artifact": path, "artifact_id": meta["artifact_id"]}
|
|
1267
|
+
latest = latest_for(path)
|
|
1268
|
+
if latest:
|
|
1269
|
+
ref["version"] = latest["version"]
|
|
1270
|
+
ref["body_digest"] = body_digest(read_version(latest["_commit"]))
|
|
1271
|
+
elif capsule:
|
|
1272
|
+
ref["version"] = capsule.get("head")
|
|
1273
|
+
ref["body_digest"] = capsule.get("body_digest")
|
|
1274
|
+
else:
|
|
1275
|
+
raise SystemExit(f"ingredient {path} has no ledger history or capsule "
|
|
1276
|
+
"to reference")
|
|
1277
|
+
if meta.get("portable_lineage_id"):
|
|
1278
|
+
ref["portable_lineage_id"] = meta["portable_lineage_id"]
|
|
1279
|
+
return ref
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def cmd_merge_lineage(a):
|
|
1283
|
+
"""Record that this version merges multiple upstream Proofpress artifacts.
|
|
1284
|
+
|
|
1285
|
+
C2PA-ingredient style: each --from source is stored as a reference
|
|
1286
|
+
(artifact_id + head version + body digest) on the merge event only.
|
|
1287
|
+
Upstream history is never copied, and the linear parent chain of the
|
|
1288
|
+
target artifact is untouched — ingredients are additive provenance."""
|
|
1289
|
+
a.ingredients = [_ingredient_ref(p) for p in a.source]
|
|
1290
|
+
if a.note is None:
|
|
1291
|
+
a.note = "merged lineage from " + ", ".join(
|
|
1292
|
+
r["artifact_id"] for r in a.ingredients)
|
|
1293
|
+
cmd_snapshot(a)
|
|
1294
|
+
latest = latest_for(a.file)
|
|
1295
|
+
if not latest or latest.get("ingredients") != a.ingredients:
|
|
1296
|
+
raise SystemExit(
|
|
1297
|
+
"merge-lineage records ingredients on a new version, but the file "
|
|
1298
|
+
"content is unchanged from the ledger tip — write the merged "
|
|
1299
|
+
"content into the file first, then re-run")
|
|
1300
|
+
for r in a.ingredients:
|
|
1301
|
+
print(f" ingredient: {r['artifact_id']} @ {r.get('version', '?')}"
|
|
1302
|
+
f" ({r['artifact']})")
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def cmd_identify(a):
|
|
1306
|
+
"""Soft-binding lookup: recognize a file whose Proofpress metadata and
|
|
1307
|
+
capsule were stripped, using the local ledger as the fingerprint index.
|
|
1308
|
+
|
|
1309
|
+
Answers identity ("this is pp_xxx"), not integrity — a match does not
|
|
1310
|
+
prove the content was never altered, only that this exact visible text
|
|
1311
|
+
skeleton was admitted before. Exit 0 found, 1 not found."""
|
|
1312
|
+
carrier = carrier_for_path(a.file)
|
|
1313
|
+
body, meta, _, _ = split_transport(open(a.file).read(), carrier)
|
|
1314
|
+
fp = soft_fingerprint(parse_blocks(body, carrier))
|
|
1315
|
+
matches = {}
|
|
1316
|
+
for ev in ledger_events(): # newest first — keep first hit per artifact
|
|
1317
|
+
if ev.get("event") != "version_created":
|
|
1318
|
+
continue
|
|
1319
|
+
evfp = ev.get("soft_fingerprint") or soft_fingerprint(
|
|
1320
|
+
read_version(ev["_commit"])["blocks"]) # pre-fingerprint events
|
|
1321
|
+
if evfp == fp:
|
|
1322
|
+
matches.setdefault(ev.get("artifact_id") or ev.get("artifact"), ev)
|
|
1323
|
+
found = list(matches.values())
|
|
1324
|
+
if a.json:
|
|
1325
|
+
print(json.dumps({
|
|
1326
|
+
"file": a.file, "soft_fingerprint": fp,
|
|
1327
|
+
"status": "identified" if found else "not_found",
|
|
1328
|
+
"matches": [{"artifact_id": e.get("artifact_id"),
|
|
1329
|
+
"artifact": e.get("artifact"),
|
|
1330
|
+
"version": e["version"], "ts": e["ts"]}
|
|
1331
|
+
for e in found]}, ensure_ascii=False, indent=2))
|
|
1332
|
+
sys.exit(0 if found else 1)
|
|
1333
|
+
if not found:
|
|
1334
|
+
print(f"identify {a.file}: no ledger version matches this content "
|
|
1335
|
+
"(rewritten, or never admitted here)")
|
|
1336
|
+
sys.exit(1)
|
|
1337
|
+
print(f"identify {B(a.file)} " + C("dim", f"({fp[:22]}…)"))
|
|
1338
|
+
for e in found:
|
|
1339
|
+
marker = ""
|
|
1340
|
+
if meta and meta.get("artifact_id") == e.get("artifact_id"):
|
|
1341
|
+
marker = C("dim", " (identity intact in file)")
|
|
1342
|
+
print(f' {C("add", "✓")} {e.get("artifact_id") or "?"} @ {e["version"]}'
|
|
1343
|
+
f' {C("dim", e["ts"][:16].replace("T", " "))}'
|
|
1344
|
+
f' {e.get("artifact", "")}{marker}')
|
|
1345
|
+
sys.exit(0)
|
|
1346
|
+
|
|
1347
|
+
|
|
1090
1348
|
def cmd_export(a):
|
|
1091
1349
|
"""Write an artifact's ledger chain as one self-contained JSON bundle.
|
|
1092
1350
|
|
|
@@ -1177,7 +1435,7 @@ def cmd_anchor(a):
|
|
|
1177
1435
|
|
|
1178
1436
|
|
|
1179
1437
|
def cmd_policy(a):
|
|
1180
|
-
body, meta, capsule, errors =
|
|
1438
|
+
body, meta, capsule, errors, _ = read_artifact_for_update(a.file)
|
|
1181
1439
|
if errors:
|
|
1182
1440
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
1183
1441
|
if a.policy is None:
|
|
@@ -1207,6 +1465,58 @@ def cmd_policy(a):
|
|
|
1207
1465
|
print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
|
|
1208
1466
|
|
|
1209
1467
|
|
|
1468
|
+
def _matching_ledger_version(events, digest):
|
|
1469
|
+
"""Return the newest recorded version with this visible-body digest."""
|
|
1470
|
+
for event in events:
|
|
1471
|
+
if body_digest(read_version(event["_commit"])) == digest:
|
|
1472
|
+
return event
|
|
1473
|
+
return None
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _worktree_ledger_state(path, body, meta):
|
|
1477
|
+
"""Compare a local carrier with the ledger head, even without transport.
|
|
1478
|
+
|
|
1479
|
+
Portable artifacts already have a capsule body digest. Local artifacts do
|
|
1480
|
+
not, which used to let ``verify`` validate an old event while silently
|
|
1481
|
+
ignoring an overwritten worktree. A stripped carrier falls back to the
|
|
1482
|
+
last recorded path so the warning remains useful after whole-file replace.
|
|
1483
|
+
"""
|
|
1484
|
+
latest = latest_for(path) if meta else latest_at_recorded_path(path)
|
|
1485
|
+
if not latest:
|
|
1486
|
+
return None
|
|
1487
|
+
expected = read_version(latest["_commit"])
|
|
1488
|
+
current_blocks = parse_blocks(body, carrier_for_path(path))
|
|
1489
|
+
actual_digest = body_digest({"blocks": current_blocks})
|
|
1490
|
+
expected_digest = body_digest(expected)
|
|
1491
|
+
expected_fp = latest.get("soft_fingerprint") or soft_fingerprint(expected["blocks"])
|
|
1492
|
+
actual_fp = soft_fingerprint(current_blocks)
|
|
1493
|
+
events = (versions_of(path) if meta else
|
|
1494
|
+
[e for e in ledger_events()
|
|
1495
|
+
if e.get("event") == "version_created" and
|
|
1496
|
+
e.get("artifact_id") == latest.get("artifact_id")])
|
|
1497
|
+
matched = _matching_ledger_version(events, actual_digest)
|
|
1498
|
+
errors = []
|
|
1499
|
+
if meta is None:
|
|
1500
|
+
errors.append("unmanaged_worktree")
|
|
1501
|
+
elif meta.get("artifact_id") != latest.get("artifact_id"):
|
|
1502
|
+
errors.append("ledger_identity_mismatch")
|
|
1503
|
+
# Exact digest intentionally sees carrier formatting. The soft fingerprint
|
|
1504
|
+
# is the documented formatting-tolerant boundary: different Markdown table
|
|
1505
|
+
# spacing or emphasis placement is not a content drift.
|
|
1506
|
+
formatting_drift = actual_digest != expected_digest and actual_fp == expected_fp
|
|
1507
|
+
if actual_digest != expected_digest and not formatting_drift:
|
|
1508
|
+
errors.append("worktree_not_at_ledger_head")
|
|
1509
|
+
if not errors and not formatting_drift:
|
|
1510
|
+
return None
|
|
1511
|
+
return {
|
|
1512
|
+
"ledger_head": latest["version"],
|
|
1513
|
+
"observed_version": (matched.get("version") if matched else
|
|
1514
|
+
(latest["version"] if formatting_drift else None)),
|
|
1515
|
+
"errors": errors,
|
|
1516
|
+
"formatting_drift": formatting_drift,
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
|
|
1210
1520
|
def inspect_result(path):
|
|
1211
1521
|
body, meta, capsule, errors = read_artifact(path)
|
|
1212
1522
|
result = {"artifact": path, "managed": meta is not None,
|
|
@@ -1218,8 +1528,19 @@ def inspect_result(path):
|
|
|
1218
1528
|
if capsule:
|
|
1219
1529
|
result["head"] = capsule.get("head")
|
|
1220
1530
|
result["versions"] = len(capsule.get("records", []))
|
|
1531
|
+
if capsule.get("discovery") == CAPSULE_DISCOVERY:
|
|
1532
|
+
result["discovery"] = capsule["discovery"]
|
|
1221
1533
|
elif capsule is not None:
|
|
1222
1534
|
result["errors"].append("capsule_on_nonportable_artifact")
|
|
1535
|
+
# A portable mismatch is already reported by capsule validation. For local
|
|
1536
|
+
# and stripped carriers, compare the visible worktree to the ledger head.
|
|
1537
|
+
if not (meta and meta.get("policy") == "portable"):
|
|
1538
|
+
worktree = _worktree_ledger_state(path, body, meta)
|
|
1539
|
+
if worktree:
|
|
1540
|
+
result["ledger_head"] = worktree["ledger_head"]
|
|
1541
|
+
result["observed_version"] = worktree["observed_version"]
|
|
1542
|
+
result["formatting_drift"] = worktree["formatting_drift"]
|
|
1543
|
+
result["errors"].extend(worktree["errors"])
|
|
1223
1544
|
if result["errors"]:
|
|
1224
1545
|
result["status"] = "mismatch"
|
|
1225
1546
|
return result
|
|
@@ -1236,8 +1557,22 @@ def cmd_inspect(a):
|
|
|
1236
1557
|
print(f" artifact: {result['artifact_id']}")
|
|
1237
1558
|
if result.get("head"):
|
|
1238
1559
|
print(f" capsule: {result['versions']} version(s), head {result['head']}")
|
|
1560
|
+
if result.get("ledger_head"):
|
|
1561
|
+
observed = result.get("observed_version") or "unrecorded/unknown"
|
|
1562
|
+
print(f" ledger head: {result['ledger_head']} (worktree: {observed})")
|
|
1563
|
+
if result.get("formatting_drift"):
|
|
1564
|
+
print(" note: formatting differs from the ledger head; visible content matches")
|
|
1565
|
+
if result.get("discovery"):
|
|
1566
|
+
print(f" provenance: {result['discovery']['label']}")
|
|
1567
|
+
print(f" learn more: {result['discovery']['project_url']}")
|
|
1568
|
+
print(f" package: {result['discovery']['package']}@"
|
|
1569
|
+
f"{result['discovery']['dist_tag']} (user consent required)")
|
|
1239
1570
|
for error in result["errors"]:
|
|
1240
1571
|
print(f" error: {error}")
|
|
1572
|
+
if "unmanaged_worktree" in result["errors"]:
|
|
1573
|
+
print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
|
|
1574
|
+
elif "worktree_not_at_ledger_head" in result["errors"]:
|
|
1575
|
+
print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
|
|
1241
1576
|
if result["status"] != "ok":
|
|
1242
1577
|
sys.exit(1)
|
|
1243
1578
|
|
|
@@ -1273,7 +1608,12 @@ def cmd_clean(a):
|
|
|
1273
1608
|
|
|
1274
1609
|
|
|
1275
1610
|
def changed_artifact_files():
|
|
1276
|
-
"""Find
|
|
1611
|
+
"""Find carriers that the fallback hook can safely reconcile.
|
|
1612
|
+
|
|
1613
|
+
Git remains useful for discovering never-admitted files. Already-admitted
|
|
1614
|
+
artifacts also come from the ledger, so ignored files and manual edits are
|
|
1615
|
+
still checked when a hook runs.
|
|
1616
|
+
"""
|
|
1277
1617
|
paths = set()
|
|
1278
1618
|
for pattern in ("*.md", "*.html", "*.htm"):
|
|
1279
1619
|
commands = [
|
|
@@ -1286,20 +1626,44 @@ def changed_artifact_files():
|
|
|
1286
1626
|
paths.update(p for p in git(*args).splitlines() if p)
|
|
1287
1627
|
except RuntimeError:
|
|
1288
1628
|
continue
|
|
1629
|
+
seen = set()
|
|
1630
|
+
for event in ledger_events():
|
|
1631
|
+
if event.get("event") != "version_created":
|
|
1632
|
+
continue
|
|
1633
|
+
identity = event.get("artifact_id") or event.get("artifact")
|
|
1634
|
+
if identity in seen:
|
|
1635
|
+
continue
|
|
1636
|
+
seen.add(identity)
|
|
1637
|
+
path = event.get("artifact")
|
|
1638
|
+
if path and os.path.isfile(path):
|
|
1639
|
+
paths.add(path)
|
|
1289
1640
|
return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
|
|
1290
1641
|
|
|
1291
1642
|
|
|
1643
|
+
def matches_latest(path, body, meta):
|
|
1644
|
+
latest = latest_for(path)
|
|
1645
|
+
if not latest:
|
|
1646
|
+
return False
|
|
1647
|
+
previous = read_version(latest["_commit"])
|
|
1648
|
+
blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), previous)
|
|
1649
|
+
current = {"artifact": path, "artifact_id": meta["artifact_id"],
|
|
1650
|
+
"blocks": blocks}
|
|
1651
|
+
return version_id(current) == latest["version"]
|
|
1652
|
+
|
|
1653
|
+
|
|
1292
1654
|
def cmd_capture(a):
|
|
1293
|
-
paths = changed_artifact_files()
|
|
1655
|
+
paths = sorted(set(a.files)) if a.files else changed_artifact_files()
|
|
1294
1656
|
captured = skipped = 0
|
|
1295
1657
|
for path in paths:
|
|
1296
|
-
body, meta, _, errors =
|
|
1658
|
+
body, meta, _, errors, recovered = read_artifact_for_update(path)
|
|
1297
1659
|
if errors:
|
|
1298
1660
|
print(f"capture warning: {path}: " + ", ".join(errors))
|
|
1299
1661
|
skipped += 1; continue
|
|
1300
1662
|
if meta["policy"] == "ignored":
|
|
1301
1663
|
print(f"capture skipped: {path} is ignored")
|
|
1302
1664
|
skipped += 1; continue
|
|
1665
|
+
if not recovered and matches_latest(path, body, meta):
|
|
1666
|
+
skipped += 1; continue
|
|
1303
1667
|
anchor_file(path, quiet=True)
|
|
1304
1668
|
ns = argparse.Namespace(
|
|
1305
1669
|
file=path, author=a.recorder, kind="system", session=a.session,
|
|
@@ -1400,6 +1764,10 @@ def cmd_show(a):
|
|
|
1400
1764
|
print(C("dim", "why: ") + md_term(ctx_ev["why"]))
|
|
1401
1765
|
for r in ctx_ev.get("rejected", []):
|
|
1402
1766
|
print(C("dim", "rejected: ") + md_term(r))
|
|
1767
|
+
for ing in ev.get("ingredients", []):
|
|
1768
|
+
print(C("dim", "ingredient: ")
|
|
1769
|
+
+ f'{ing.get("artifact_id", "?")} @ {ing.get("version", "?")}'
|
|
1770
|
+
+ C("dim", f' ({ing.get("artifact", "")})'))
|
|
1403
1771
|
print()
|
|
1404
1772
|
tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
|
|
1405
1773
|
for b in v["blocks"]:
|
|
@@ -1439,6 +1807,30 @@ def cmd_verify(a):
|
|
|
1439
1807
|
raise SystemExit("artifact not in ledger")
|
|
1440
1808
|
ev = _find(evs, a.version) if a.version else evs[0]
|
|
1441
1809
|
n = len(evs) - next(i for i, e in enumerate(evs) if e["version"] == ev["version"])
|
|
1810
|
+
# ``verify file`` means the current worktree as well as the recorded
|
|
1811
|
+
# claims. ``verify file <version>`` remains an historical claim check.
|
|
1812
|
+
if (os.path.isfile(a.file) and not a.version and
|
|
1813
|
+
inspection["status"] != "ok"):
|
|
1814
|
+
if a.json:
|
|
1815
|
+
print(json.dumps({"artifact": a.file, "version": ev["version"],
|
|
1816
|
+
"status": "worktree_mismatch",
|
|
1817
|
+
"errors": inspection["errors"],
|
|
1818
|
+
"ledger_head": inspection.get("ledger_head"),
|
|
1819
|
+
"observed_version": inspection.get("observed_version")},
|
|
1820
|
+
ensure_ascii=False, indent=2))
|
|
1821
|
+
elif a.md:
|
|
1822
|
+
print("⚠️ **worktree mismatch** — " + ", ".join(inspection["errors"]))
|
|
1823
|
+
else:
|
|
1824
|
+
print(f"verify {a.file} @ {ev['version']} (v{n})")
|
|
1825
|
+
print(" worktree mismatch: " + ", ".join(inspection["errors"]))
|
|
1826
|
+
if inspection.get("ledger_head"):
|
|
1827
|
+
observed = inspection.get("observed_version") or "unrecorded/unknown"
|
|
1828
|
+
print(f" ledger head: {inspection['ledger_head']} (worktree: {observed})")
|
|
1829
|
+
if "unmanaged_worktree" in inspection["errors"]:
|
|
1830
|
+
print(f" action: run `proofpress identify {a.file}`; reconcile the file before snapshotting")
|
|
1831
|
+
else:
|
|
1832
|
+
print(" action: restore or reconcile the ledger head; snapshot only if this edit is intentional")
|
|
1833
|
+
sys.exit(1)
|
|
1442
1834
|
computed = {c["block"]: c for c in ev.get("changes", [])}
|
|
1443
1835
|
claims = ev.get("claims")
|
|
1444
1836
|
if claims is None:
|
|
@@ -1488,15 +1880,16 @@ def cmd_verify(a):
|
|
|
1488
1880
|
note = f' — "{cl["note"]}"' if cl.get("note") else ""
|
|
1489
1881
|
if r["ok"]:
|
|
1490
1882
|
what = cl.get("kind")
|
|
1491
|
-
|
|
1883
|
+
label = verify_label(comp, cl.get("block", "?"))
|
|
1884
|
+
print(f' {C("add", "✓")} [{what}] {label} '
|
|
1492
1885
|
f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
|
|
1493
1886
|
else:
|
|
1494
1887
|
actual = comp["kind"] if comp else "unchanged"
|
|
1495
1888
|
print(f' {C("move", "⚠")} claimed {B(cl.get("kind", "?"))} but computed '
|
|
1496
|
-
f'{B(actual)}: {
|
|
1889
|
+
f'{B(actual)}: {verify_label(comp, "?")} {C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
|
|
1497
1890
|
for c in undisclosed:
|
|
1498
1891
|
print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
|
|
1499
|
-
f'{
|
|
1892
|
+
f'{verify_label(c, "?")} {C("dim", "(" + c["block"] + ")")}')
|
|
1500
1893
|
print()
|
|
1501
1894
|
verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
|
|
1502
1895
|
else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
|
|
@@ -1545,6 +1938,29 @@ def main():
|
|
|
1545
1938
|
vf.set_defaults(f=cmd_verify)
|
|
1546
1939
|
an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
|
|
1547
1940
|
ig = sub.add_parser("ingest"); ig.add_argument("file"); ig.set_defaults(f=cmd_ingest)
|
|
1941
|
+
ml = sub.add_parser("merge-lineage",
|
|
1942
|
+
help="snapshot a file that merges other Proofpress artifacts, recording ingredient references")
|
|
1943
|
+
ml.add_argument("file")
|
|
1944
|
+
ml.add_argument("--from", dest="source", action="append", required=True,
|
|
1945
|
+
metavar="ARTIFACT", help="upstream artifact merged into this file (repeatable)")
|
|
1946
|
+
ml.add_argument("--author", default="unknown")
|
|
1947
|
+
ml.add_argument("--kind", default="human", choices=["human", "agent", "system"])
|
|
1948
|
+
ml.add_argument("--session", default=None); ml.add_argument("--note", default=None)
|
|
1949
|
+
ml.add_argument("--base-version", default=None)
|
|
1950
|
+
ml.add_argument("--claims", default=None)
|
|
1951
|
+
ml.add_argument("--why", default=None)
|
|
1952
|
+
ml.add_argument("--rejected", action="append", default=None)
|
|
1953
|
+
ml.add_argument("--requested-by", action="append", default=None)
|
|
1954
|
+
ml.add_argument("--produced-by", action="append", default=None)
|
|
1955
|
+
ml.add_argument("--edited-by", action="append", default=None)
|
|
1956
|
+
ml.add_argument("--recorded-by", action="append", default=None)
|
|
1957
|
+
ml.add_argument("--attribution-basis", choices=ATTRIBUTION_BASES,
|
|
1958
|
+
default="self_asserted")
|
|
1959
|
+
ml.set_defaults(f=cmd_merge_lineage)
|
|
1960
|
+
idn = sub.add_parser("identify",
|
|
1961
|
+
help="recognize a stripped file by its soft-binding fingerprint")
|
|
1962
|
+
idn.add_argument("file")
|
|
1963
|
+
idn.add_argument("--json", action="store_true"); idn.set_defaults(f=cmd_identify)
|
|
1548
1964
|
ex = sub.add_parser("export"); ex.add_argument("file")
|
|
1549
1965
|
ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
|
|
1550
1966
|
ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
|
|
@@ -1566,6 +1982,9 @@ def main():
|
|
|
1566
1982
|
cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
|
|
1567
1983
|
ca = sub.add_parser("capture")
|
|
1568
1984
|
ca.add_argument("--recorder", required=True); ca.add_argument("--session", default=None)
|
|
1985
|
+
ca.add_argument("files", nargs="*",
|
|
1986
|
+
help="specific Markdown/HTML files to reconcile; "
|
|
1987
|
+
"default: Git candidates plus admitted ledger paths")
|
|
1569
1988
|
ca.set_defaults(f=cmd_capture)
|
|
1570
1989
|
a = p.parse_args(); a.f(a)
|
|
1571
1990
|
|
package/skills/README.md
CHANGED
|
@@ -11,13 +11,18 @@ Git.
|
|
|
11
11
|
| Layer | What it knows | Attribution rule |
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| Skill | accepted version, claims, why, consequential rejection | supplies known actor roles and basis |
|
|
14
|
-
| Hook / `capture` |
|
|
14
|
+
| Hook / `capture` | Git candidates, admitted ledger paths, or explicit files | supplies only `recorded_by` |
|
|
15
15
|
| `ingest` | committed content and Git author | attributes from Git metadata |
|
|
16
16
|
| Portable capsule | admitted records attached to the artifact | preserves recorded fields; invents none |
|
|
17
17
|
|
|
18
18
|
[//]: # (ob:50c32349)
|
|
19
19
|
## Shared contract
|
|
20
20
|
|
|
21
|
+
[//]: # (ob:666c5555)
|
|
22
|
+
Before editing an existing target, run `capture --recorder
|
|
23
|
+
<harness>-preflight <file>` so any human drift becomes a separate unattributed
|
|
24
|
+
version. Then:
|
|
25
|
+
|
|
21
26
|
[//]: # (ob:a2f15f7d)
|
|
22
27
|
1. Work only on Markdown or static HTML knowledge artifacts, never source code.
|
|
23
28
|
2. Read the artifact policy. Enable `portable` once when requested; it stays on
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"//": "Merge into .claude/settings.json. This
|
|
2
|
+
"//": "Merge into .claude/settings.json. This best-effort fallback checks Git candidates plus current paths already admitted to the ledger. It records only the hook identity as recorded_by; it does not infer who authored the content or why.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"Stop": [
|
|
5
5
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: proofpress
|
|
3
|
-
description: Preserve decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a repository containing proofpress
|
|
3
|
+
description: Preserve or inspect decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a configured repository, or when receiving a file containing a proofpress:discovery marker; never use for source-code files.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
[//]: # (ob:25f10229)
|
|
@@ -13,6 +13,11 @@ testimony and verification. Do not snapshot every conversational turn.
|
|
|
13
13
|
[//]: # (ob:d8284708)
|
|
14
14
|
## Workflow
|
|
15
15
|
|
|
16
|
+
[//]: # (ob:78295d28)
|
|
17
|
+
Before editing an existing target, run
|
|
18
|
+
`python3 proofpress.py capture --recorder claude-preflight <file>`. This
|
|
19
|
+
preserves any human drift without guessing its author or reason. Then:
|
|
20
|
+
|
|
16
21
|
[//]: # (ob:04e1285c)
|
|
17
22
|
1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
|
|
18
23
|
|
|
@@ -56,8 +61,14 @@ testimony and verification. Do not snapshot every conversational turn.
|
|
|
56
61
|
to force green.
|
|
57
62
|
|
|
58
63
|
[//]: # (ob:e96a23df)
|
|
59
|
-
For incoming
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
For an incoming file with a `proofpress:discovery` marker but no local CLI,
|
|
65
|
+
tell the user it carries verifiable revision history, show the marker's
|
|
66
|
+
canonical project URL, and ask whether to install `proofpress@next`. Never
|
|
67
|
+
download or execute it without explicit consent. If the CLI is available, run
|
|
68
|
+
`inspect` before trusting the capsule and then `import`.
|
|
69
|
+
|
|
70
|
+
[//]: # (ob:3bec4947)
|
|
71
|
+
Fallback `capture` records only `recorded_by`; it cannot know authorship or
|
|
72
|
+
reasoning. Never snapshot source-code files.
|
|
62
73
|
|
|
63
74
|
[//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzhmOWYxNjA4MjZlMzM2YTQ2ZWI2MmJkNCIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
|
|
@@ -9,6 +9,12 @@ For accepted, meaningful revisions to Markdown or static HTML knowledge
|
|
|
9
9
|
artifacts—never source code—close this loop. Do not snapshot every
|
|
10
10
|
conversational turn.
|
|
11
11
|
|
|
12
|
+
[//]: # (ob:af9efd22)
|
|
13
|
+
Before editing an existing target, run
|
|
14
|
+
`python3 proofpress.py capture --recorder codex-preflight <file>`. This
|
|
15
|
+
preserves any human drift as a separate version without guessing its author or
|
|
16
|
+
reason. Then:
|
|
17
|
+
|
|
12
18
|
[//]: # (ob:d181cc6e)
|
|
13
19
|
1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
|
|
14
20
|
|
|
@@ -52,7 +58,14 @@ conversational turn.
|
|
|
52
58
|
force green.
|
|
53
59
|
|
|
54
60
|
[//]: # (ob:bc88257e)
|
|
55
|
-
For incoming
|
|
56
|
-
|
|
61
|
+
For an incoming file with a `proofpress:discovery` marker but no local CLI,
|
|
62
|
+
tell the user it carries verifiable revision history, show the marker's
|
|
63
|
+
canonical project URL, and ask whether to install `proofpress@next`. Never
|
|
64
|
+
download or execute it without explicit consent. If the CLI is available, run
|
|
65
|
+
`inspect` before trusting the capsule and then `import`.
|
|
66
|
+
|
|
67
|
+
[//]: # (ob:08efea88)
|
|
68
|
+
Fallback `capture` records only `recorded_by`; it cannot know authorship or
|
|
69
|
+
reasoning.
|
|
57
70
|
|
|
58
71
|
[//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzcwM2MzZmE5MjZhYmI1MTJlNzE5OTMzMiIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Merge into ~/.codex/config.toml (Codex hooks GA since v0.124).
|
|
2
|
-
# Best-effort fallback: capture
|
|
2
|
+
# Best-effort fallback: capture Git candidates and current admitted ledger paths.
|
|
3
3
|
# The hook identifies itself only as recorded_by; it does not pretend to know
|
|
4
4
|
# who wrote the content or why.
|
|
5
5
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: proofpress
|
|
3
|
-
description: Preserve decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a repository containing proofpress
|
|
3
|
+
description: Preserve or inspect decision history for Markdown and static HTML knowledge artifacts with Proofpress. Use when creating or accepting a meaningful revision to an ADR, design doc, PRD, README, strategy note, portable .md artifact, or static .html/.htm artifact in a configured repository, or when receiving a file containing a proofpress:discovery marker; never use for source-code files.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
[//]: # (ob:233b766d)
|
|
@@ -13,6 +13,11 @@ testimony and verification. Do not snapshot every conversational turn.
|
|
|
13
13
|
[//]: # (ob:e14e12ad)
|
|
14
14
|
## Workflow
|
|
15
15
|
|
|
16
|
+
[//]: # (ob:0d9e887a)
|
|
17
|
+
Before editing an existing target, run
|
|
18
|
+
`python3 proofpress.py capture --recorder cursor-preflight <file>`. This
|
|
19
|
+
preserves any human drift without guessing its author or reason. Then:
|
|
20
|
+
|
|
16
21
|
[//]: # (ob:42fd42e7)
|
|
17
22
|
1. Read `python3 proofpress.py policy <file>`. If the user asks for a portable
|
|
18
23
|
|
|
@@ -59,8 +64,14 @@ testimony and verification. Do not snapshot every conversational turn.
|
|
|
59
64
|
to turn a mismatch green.
|
|
60
65
|
|
|
61
66
|
[//]: # (ob:355446e0)
|
|
62
|
-
For an incoming
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
For an incoming file with a `proofpress:discovery` marker but no local CLI,
|
|
68
|
+
tell the user it carries verifiable revision history, show the marker's
|
|
69
|
+
canonical project URL, and ask whether to install `proofpress@next`. Never
|
|
70
|
+
download or execute it without explicit consent. If the CLI is available, run
|
|
71
|
+
`inspect` before trusting the capsule and then `import`.
|
|
72
|
+
|
|
73
|
+
[//]: # (ob:7dc0b832)
|
|
74
|
+
Fallback `capture` supplies only `recorded_by`; it cannot know who authored the
|
|
75
|
+
content or why. Never snapshot source-code files.
|
|
65
76
|
|
|
66
77
|
[//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwXzBjOWVkZjdkMzE1YWIwM2UwY2EwNmM4OCIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Proofpress"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "
|
|
3
|
+
short_description: "Verify Markdown/HTML revision history and claims"
|
|
4
|
+
default_prompt: "Use $proofpress to record or inspect verifiable revision history for the supported knowledge artifacts in this task."
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* lifecycle events (25+ hooks; see pi's extension docs — the API surface
|
|
6
6
|
* moves fast, verify event names against your installed version).
|
|
7
7
|
*
|
|
8
|
-
* Intent: on session end, run best-effort capture across
|
|
9
|
-
*
|
|
10
|
-
* author or reason. Content-addressed duplicate
|
|
8
|
+
* Intent: on session end, run best-effort capture across Git candidates and
|
|
9
|
+
* current paths already admitted to the ledger. The hook records only
|
|
10
|
+
* recorded_by, never an inferred author or reason. Content-addressed duplicate
|
|
11
|
+
* runs are no-ops.
|
|
11
12
|
*/
|
|
12
13
|
import { execFileSync } from "node:child_process";
|
|
13
14
|
|
|
@@ -6,6 +6,11 @@ For accepted, meaningful revisions to Markdown or static HTML knowledge
|
|
|
6
6
|
artifacts—never source code—close this loop. Do not snapshot every
|
|
7
7
|
conversational turn.
|
|
8
8
|
|
|
9
|
+
[//]: # (ob:211b5d98)
|
|
10
|
+
Before editing an existing target, run
|
|
11
|
+
`python3 proofpress.py capture --recorder pi-preflight <file>`. This preserves
|
|
12
|
+
any human drift without guessing its author or reason. Then:
|
|
13
|
+
|
|
9
14
|
[//]: # (ob:87ce54bb)
|
|
10
15
|
1. Read `python3 proofpress.py policy <file>`. If the user asks for portability,
|
|
11
16
|
|
|
@@ -49,7 +54,14 @@ conversational turn.
|
|
|
49
54
|
force green.
|
|
50
55
|
|
|
51
56
|
[//]: # (ob:2f7730eb)
|
|
52
|
-
For incoming
|
|
53
|
-
|
|
57
|
+
For an incoming file with a `proofpress:discovery` marker but no local CLI,
|
|
58
|
+
tell the user it carries verifiable revision history, show the marker's
|
|
59
|
+
canonical project URL, and ask whether to install `proofpress@next`. Never
|
|
60
|
+
download or execute it without explicit consent. If the CLI is available, run
|
|
61
|
+
`inspect` before trusting the capsule and then `import`.
|
|
62
|
+
|
|
63
|
+
[//]: # (ob:72680124)
|
|
64
|
+
Fallback `capture` records only `recorded_by`; it cannot know authorship or
|
|
65
|
+
reasoning.
|
|
54
66
|
|
|
55
67
|
[//]: # (proofpress:meta:eyJhcnRpZmFjdF9pZCI6InBwX2M3Y2UwZDE1Yjg2YmI0MmJkMjg0MzRmYyIsInBvbGljeSI6ImxvY2FsIiwicHJvb2ZwcmVzcyI6MX0)
|