proofpress 0.1.0-alpha.1 → 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 +37 -4
- package/package.json +1 -1
- package/proofpress.py +369 -13
- package/skills/README.md +6 -1
- package/skills/claude-code/hooks-example.json +1 -1
- package/skills/claude-code/proofpress/SKILL.md +5 -0
- package/skills/codex/AGENTS-snippet.md +6 -0
- package/skills/codex/config-hooks.toml +1 -1
- package/skills/cursor/proofpress/SKILL.md +5 -0
- package/skills/pi/extension-skeleton.ts +4 -3
- package/skills/pi/proofpress-skill.md +5 -0
package/README.md
CHANGED
|
@@ -136,7 +136,8 @@ python3 proofpress.py verify launch-plan.html
|
|
|
136
136
|
This is a static-HTML carrier MVP, not a framework or CMS integration. Proofpress
|
|
137
137
|
does not yet promise round-trip preservation through React/Vue builds, HTML
|
|
138
138
|
sanitizers, editors, or CMS pipelines; if they strip transport data, the file
|
|
139
|
-
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.
|
|
140
141
|
|
|
141
142
|
[//]: # (ob:226a29fd)
|
|
142
143
|
## What gets recorded
|
|
@@ -149,9 +150,12 @@ against the actual artifact diff.
|
|
|
149
150
|
|
|
150
151
|
[//]: # (ob:5e0b34ed)
|
|
151
152
|
It does not automatically store raw prompts, transcripts, tool traces, casual
|
|
152
|
-
brainstorming, or every save. A fallback hook
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
155
159
|
|
|
156
160
|
[//]: # (ob:d8387b0b)
|
|
157
161
|
## Privacy modes
|
|
@@ -201,6 +205,35 @@ Use `--rejected` only for consequential dead branches that future collaborators
|
|
|
201
205
|
should not repeat. Source code stays in Git; Proofpress is for Markdown and
|
|
202
206
|
static HTML knowledge artifacts.
|
|
203
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
|
+
|
|
204
237
|
[//]: # (ob:2a955c60)
|
|
205
238
|
## Surfaces
|
|
206
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 ----------
|
|
@@ -499,6 +501,30 @@ def version_id(version):
|
|
|
499
501
|
ensure_ascii=False).encode()).hexdigest()[:8]
|
|
500
502
|
|
|
501
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
|
+
|
|
502
528
|
# ---------- 块身份匹配(锚点缺席时的相似度兜底;v0 只有兜底) ----------
|
|
503
529
|
|
|
504
530
|
def assign_ids(new_blocks, old_version):
|
|
@@ -677,6 +703,40 @@ def versions_of(path):
|
|
|
677
703
|
return out
|
|
678
704
|
|
|
679
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
|
+
|
|
680
740
|
def latest_for(path):
|
|
681
741
|
vs = versions_of(path)
|
|
682
742
|
return vs[0] if vs else None
|
|
@@ -703,9 +763,19 @@ def write_event(event, version=None):
|
|
|
703
763
|
|
|
704
764
|
# ---------- commands ----------
|
|
705
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
|
+
|
|
706
775
|
def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
707
776
|
text=None, ts=None, source_commit=None, artifact_id=None,
|
|
708
|
-
policy="local", actors=None, attribution_basis="unknown"
|
|
777
|
+
policy="local", actors=None, attribution_basis="unknown",
|
|
778
|
+
ingredients=None):
|
|
709
779
|
"""Core snapshot: file → block tree → ledger. Returns event or None (no change).
|
|
710
780
|
|
|
711
781
|
`claims` is the author's structured self-description of the change
|
|
@@ -714,7 +784,12 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
714
784
|
verified, never trusted.
|
|
715
785
|
|
|
716
786
|
`text`/`ts`/`source_commit` let `ingest` replay historical git commits
|
|
717
|
-
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)
|
|
718
793
|
if text is None:
|
|
719
794
|
text = open(path).read()
|
|
720
795
|
prev_ev = latest_for(path)
|
|
@@ -737,7 +812,10 @@ def do_snapshot(path, author, kind, session, note, claims=None, context=None,
|
|
|
737
812
|
"stats": stats,
|
|
738
813
|
"changes": changes,
|
|
739
814
|
"policy": policy,
|
|
815
|
+
"soft_fingerprint": soft_fingerprint(blocks),
|
|
740
816
|
}
|
|
817
|
+
if ingredients:
|
|
818
|
+
event["ingredients"] = ingredients
|
|
741
819
|
if actors:
|
|
742
820
|
event["actors"] = actors
|
|
743
821
|
event["attribution_basis"] = attribution_basis
|
|
@@ -772,6 +850,7 @@ def _actors_from_args(a):
|
|
|
772
850
|
|
|
773
851
|
def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
774
852
|
attribution_basis="self_asserted"):
|
|
853
|
+
check_attribution_basis(attribution_basis)
|
|
775
854
|
prev_ev = latest_for(path)
|
|
776
855
|
prev_v = read_version(prev_ev["_commit"]) if prev_ev else None
|
|
777
856
|
blocks = assign_ids(parse_blocks(body, carrier_for_path(path)), prev_v)
|
|
@@ -791,6 +870,7 @@ def make_checkpoint_capsule(path, body, meta, recorded_by=None,
|
|
|
791
870
|
"note": "portable lineage checkpoint", "policy": "portable",
|
|
792
871
|
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
793
872
|
"stats": stats, "changes": changes,
|
|
873
|
+
"soft_fingerprint": soft_fingerprint(blocks),
|
|
794
874
|
"portable_checkpoint": True,
|
|
795
875
|
"portable_lineage_id": lineage,
|
|
796
876
|
}
|
|
@@ -882,12 +962,15 @@ def append_capsule(path, body, meta, capsule, event, version):
|
|
|
882
962
|
|
|
883
963
|
|
|
884
964
|
def cmd_snapshot(a):
|
|
885
|
-
body, meta, capsule, errors =
|
|
965
|
+
body, meta, capsule, errors, recovered = read_artifact_for_update(a.file)
|
|
886
966
|
if errors:
|
|
887
967
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
888
968
|
if meta["policy"] == "ignored":
|
|
889
969
|
print(f"skipped: {a.file} policy is ignored")
|
|
890
970
|
return
|
|
971
|
+
if recovered:
|
|
972
|
+
print(f"recovered identity: {a.file} -> {meta['artifact_id']} "
|
|
973
|
+
"(matched previous ledger path)")
|
|
891
974
|
if getattr(a, "base_version", None):
|
|
892
975
|
current = (capsule or {}).get("head")
|
|
893
976
|
if current is None:
|
|
@@ -917,8 +1000,18 @@ def cmd_snapshot(a):
|
|
|
917
1000
|
ev = do_snapshot(a.file, a.author, a.kind, a.session, a.note,
|
|
918
1001
|
claims=claims, context=context, text=body,
|
|
919
1002
|
artifact_id=meta["artifact_id"], policy=meta["policy"],
|
|
920
|
-
actors=actors, attribution_basis=a.attribution_basis
|
|
1003
|
+
actors=actors, attribution_basis=a.attribution_basis,
|
|
1004
|
+
ingredients=getattr(a, "ingredients", None))
|
|
921
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
|
|
922
1015
|
if meta["policy"] == "portable" and capsule is not None:
|
|
923
1016
|
latest = latest_for(a.file)
|
|
924
1017
|
if latest and capsule.get("head") != latest.get("version"):
|
|
@@ -996,6 +1089,23 @@ def change_label(c):
|
|
|
996
1089
|
return (ctx or c.get("preview", "")[:48]), ""
|
|
997
1090
|
|
|
998
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
|
+
|
|
999
1109
|
def cmd_diff(a):
|
|
1000
1110
|
evs = versions_of(a.file)
|
|
1001
1111
|
if len(evs) < 2 and not (a.va and a.vb):
|
|
@@ -1143,6 +1253,98 @@ def cmd_ingest(a):
|
|
|
1143
1253
|
print(f"ingested {made}, skipped {skipped} (already known / older than ledger / no content change)")
|
|
1144
1254
|
|
|
1145
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
|
+
|
|
1146
1348
|
def cmd_export(a):
|
|
1147
1349
|
"""Write an artifact's ledger chain as one self-contained JSON bundle.
|
|
1148
1350
|
|
|
@@ -1233,7 +1435,7 @@ def cmd_anchor(a):
|
|
|
1233
1435
|
|
|
1234
1436
|
|
|
1235
1437
|
def cmd_policy(a):
|
|
1236
|
-
body, meta, capsule, errors =
|
|
1438
|
+
body, meta, capsule, errors, _ = read_artifact_for_update(a.file)
|
|
1237
1439
|
if errors:
|
|
1238
1440
|
raise SystemExit("invalid Proofpress metadata: " + ", ".join(errors))
|
|
1239
1441
|
if a.policy is None:
|
|
@@ -1263,6 +1465,58 @@ def cmd_policy(a):
|
|
|
1263
1465
|
print(f"{a.policy}: {a.file} (capsule removed from this copy; old copies unchanged)")
|
|
1264
1466
|
|
|
1265
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
|
+
|
|
1266
1520
|
def inspect_result(path):
|
|
1267
1521
|
body, meta, capsule, errors = read_artifact(path)
|
|
1268
1522
|
result = {"artifact": path, "managed": meta is not None,
|
|
@@ -1278,6 +1532,15 @@ def inspect_result(path):
|
|
|
1278
1532
|
result["discovery"] = capsule["discovery"]
|
|
1279
1533
|
elif capsule is not None:
|
|
1280
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"])
|
|
1281
1544
|
if result["errors"]:
|
|
1282
1545
|
result["status"] = "mismatch"
|
|
1283
1546
|
return result
|
|
@@ -1294,6 +1557,11 @@ def cmd_inspect(a):
|
|
|
1294
1557
|
print(f" artifact: {result['artifact_id']}")
|
|
1295
1558
|
if result.get("head"):
|
|
1296
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")
|
|
1297
1565
|
if result.get("discovery"):
|
|
1298
1566
|
print(f" provenance: {result['discovery']['label']}")
|
|
1299
1567
|
print(f" learn more: {result['discovery']['project_url']}")
|
|
@@ -1301,6 +1569,10 @@ def cmd_inspect(a):
|
|
|
1301
1569
|
f"{result['discovery']['dist_tag']} (user consent required)")
|
|
1302
1570
|
for error in result["errors"]:
|
|
1303
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")
|
|
1304
1576
|
if result["status"] != "ok":
|
|
1305
1577
|
sys.exit(1)
|
|
1306
1578
|
|
|
@@ -1336,7 +1608,12 @@ def cmd_clean(a):
|
|
|
1336
1608
|
|
|
1337
1609
|
|
|
1338
1610
|
def changed_artifact_files():
|
|
1339
|
-
"""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
|
+
"""
|
|
1340
1617
|
paths = set()
|
|
1341
1618
|
for pattern in ("*.md", "*.html", "*.htm"):
|
|
1342
1619
|
commands = [
|
|
@@ -1349,20 +1626,44 @@ def changed_artifact_files():
|
|
|
1349
1626
|
paths.update(p for p in git(*args).splitlines() if p)
|
|
1350
1627
|
except RuntimeError:
|
|
1351
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)
|
|
1352
1640
|
return sorted(p for p in paths if os.path.isfile(p) and carrier_for_path(p) in ("markdown", "html"))
|
|
1353
1641
|
|
|
1354
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
|
+
|
|
1355
1654
|
def cmd_capture(a):
|
|
1356
|
-
paths = changed_artifact_files()
|
|
1655
|
+
paths = sorted(set(a.files)) if a.files else changed_artifact_files()
|
|
1357
1656
|
captured = skipped = 0
|
|
1358
1657
|
for path in paths:
|
|
1359
|
-
body, meta, _, errors =
|
|
1658
|
+
body, meta, _, errors, recovered = read_artifact_for_update(path)
|
|
1360
1659
|
if errors:
|
|
1361
1660
|
print(f"capture warning: {path}: " + ", ".join(errors))
|
|
1362
1661
|
skipped += 1; continue
|
|
1363
1662
|
if meta["policy"] == "ignored":
|
|
1364
1663
|
print(f"capture skipped: {path} is ignored")
|
|
1365
1664
|
skipped += 1; continue
|
|
1665
|
+
if not recovered and matches_latest(path, body, meta):
|
|
1666
|
+
skipped += 1; continue
|
|
1366
1667
|
anchor_file(path, quiet=True)
|
|
1367
1668
|
ns = argparse.Namespace(
|
|
1368
1669
|
file=path, author=a.recorder, kind="system", session=a.session,
|
|
@@ -1463,6 +1764,10 @@ def cmd_show(a):
|
|
|
1463
1764
|
print(C("dim", "why: ") + md_term(ctx_ev["why"]))
|
|
1464
1765
|
for r in ctx_ev.get("rejected", []):
|
|
1465
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", "")})'))
|
|
1466
1771
|
print()
|
|
1467
1772
|
tagcolor = {"added": "add", "modified": "accent", "moved": "move"}
|
|
1468
1773
|
for b in v["blocks"]:
|
|
@@ -1502,6 +1807,30 @@ def cmd_verify(a):
|
|
|
1502
1807
|
raise SystemExit("artifact not in ledger")
|
|
1503
1808
|
ev = _find(evs, a.version) if a.version else evs[0]
|
|
1504
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)
|
|
1505
1834
|
computed = {c["block"]: c for c in ev.get("changes", [])}
|
|
1506
1835
|
claims = ev.get("claims")
|
|
1507
1836
|
if claims is None:
|
|
@@ -1551,15 +1880,16 @@ def cmd_verify(a):
|
|
|
1551
1880
|
note = f' — "{cl["note"]}"' if cl.get("note") else ""
|
|
1552
1881
|
if r["ok"]:
|
|
1553
1882
|
what = cl.get("kind")
|
|
1554
|
-
|
|
1883
|
+
label = verify_label(comp, cl.get("block", "?"))
|
|
1884
|
+
print(f' {C("add", "✓")} [{what}] {label} '
|
|
1555
1885
|
f'{C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
|
|
1556
1886
|
else:
|
|
1557
1887
|
actual = comp["kind"] if comp else "unchanged"
|
|
1558
1888
|
print(f' {C("move", "⚠")} claimed {B(cl.get("kind", "?"))} but computed '
|
|
1559
|
-
f'{B(actual)}: {
|
|
1889
|
+
f'{B(actual)}: {verify_label(comp, "?")} {C("dim", "(" + cl.get("block", "?") + ")")}{C("dim", note)}')
|
|
1560
1890
|
for c in undisclosed:
|
|
1561
1891
|
print(f' {C("move", "⚠")} undisclosed change: [{c["kind"]}] '
|
|
1562
|
-
f'{
|
|
1892
|
+
f'{verify_label(c, "?")} {C("dim", "(" + c["block"] + ")")}')
|
|
1563
1893
|
print()
|
|
1564
1894
|
verdict = (C("add", f"{n_ok} verified") if not n_bad and not undisclosed
|
|
1565
1895
|
else C("move", f"{n_ok} verified, {n_bad} mismatch, {len(undisclosed)} undisclosed"))
|
|
@@ -1608,6 +1938,29 @@ def main():
|
|
|
1608
1938
|
vf.set_defaults(f=cmd_verify)
|
|
1609
1939
|
an = sub.add_parser("anchor"); an.add_argument("file"); an.set_defaults(f=cmd_anchor)
|
|
1610
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)
|
|
1611
1964
|
ex = sub.add_parser("export"); ex.add_argument("file")
|
|
1612
1965
|
ex.add_argument("-o", "--output", default=None); ex.set_defaults(f=cmd_export)
|
|
1613
1966
|
ini = sub.add_parser("init"); ini.set_defaults(f=cmd_init)
|
|
@@ -1629,6 +1982,9 @@ def main():
|
|
|
1629
1982
|
cl.add_argument("-o", "--output", required=True); cl.set_defaults(f=cmd_clean)
|
|
1630
1983
|
ca = sub.add_parser("capture")
|
|
1631
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")
|
|
1632
1988
|
ca.set_defaults(f=cmd_capture)
|
|
1633
1989
|
a = p.parse_args(); a.f(a)
|
|
1634
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
|
{
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|