strata-agent-memory 0.2.0__py3-none-any.whl
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.
- agent_memory/__init__.py +12 -0
- agent_memory/__main__.py +4 -0
- agent_memory/audit.py +345 -0
- agent_memory/cli.py +1405 -0
- agent_memory/compact.py +83 -0
- agent_memory/compiler.py +626 -0
- agent_memory/contracts.py +417 -0
- agent_memory/evidence.py +831 -0
- agent_memory/export.py +282 -0
- agent_memory/hooks.py +458 -0
- agent_memory/identity.py +168 -0
- agent_memory/ledger.py +981 -0
- agent_memory/mcp_server.py +789 -0
- agent_memory/memorizer.py +162 -0
- agent_memory/pathmap.py +129 -0
- agent_memory/recheck.py +197 -0
- agent_memory/records.py +753 -0
- agent_memory/reliability.py +595 -0
- agent_memory/remote.py +316 -0
- agent_memory/render.py +197 -0
- agent_memory/serve.py +2277 -0
- agent_memory/signing.py +286 -0
- agent_memory/stats.py +177 -0
- agent_memory/trust.py +310 -0
- strata_agent_memory-0.2.0.dist-info/METADATA +202 -0
- strata_agent_memory-0.2.0.dist-info/RECORD +30 -0
- strata_agent_memory-0.2.0.dist-info/WHEEL +5 -0
- strata_agent_memory-0.2.0.dist-info/entry_points.txt +2 -0
- strata_agent_memory-0.2.0.dist-info/licenses/LICENSE +201 -0
- strata_agent_memory-0.2.0.dist-info/top_level.txt +1 -0
agent_memory/cli.py
ADDED
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
"""Command-line interface. Every operation available to the MCP server is also here,
|
|
2
|
+
so a human, a shell script, a CI job, or a local model with tool-calling can use it.
|
|
3
|
+
|
|
4
|
+
With ``--remote URL`` the commands a replica can answer go over HTTP to an
|
|
5
|
+
`agent-memory serve` instead of to a ledger under this directory, so a second machine
|
|
6
|
+
needs a token rather than a clone (docs/BETA-SPEC.md section 4.4).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from . import audit as audit_mod
|
|
20
|
+
from . import compact as compact_mod
|
|
21
|
+
from . import contracts
|
|
22
|
+
from . import evidence as ev
|
|
23
|
+
from . import export as export_mod
|
|
24
|
+
from . import memorizer
|
|
25
|
+
from . import pathmap
|
|
26
|
+
from . import recheck as recheck_mod
|
|
27
|
+
from . import reliability as rel_mod
|
|
28
|
+
from . import remote as remote_mod
|
|
29
|
+
from . import signing
|
|
30
|
+
from .compiler import MODES, ROLES, compile_context, record_compilation
|
|
31
|
+
from .identity import detect_author, detect_session
|
|
32
|
+
from .ledger import (
|
|
33
|
+
DIR_NAME, TRUSTED_ORG_PATHS_NAME, TRUSTED_REPOS_NAME, UNTRUSTED_POLICY, ConfigError, Ledger,
|
|
34
|
+
find_root, trust_org_path, trust_repo, write_starter_config,
|
|
35
|
+
)
|
|
36
|
+
from .records import CLAIM_TYPES, ValidationError, make_scope, new_record
|
|
37
|
+
from .render import RENDERERS, render
|
|
38
|
+
from .trust import TIERS, assess
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _author(args, ledger: Ledger) -> dict:
|
|
42
|
+
return detect_author(
|
|
43
|
+
ledger.config, cwd=str(ledger.root),
|
|
44
|
+
kind="human" if getattr(args, "as_human", False) else None,
|
|
45
|
+
harness=getattr(args, "harness", None), model=getattr(args, "model", None),
|
|
46
|
+
session=getattr(args, "session", None), user=getattr(args, "user", None),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scope(args, ledger: Ledger, author: dict) -> dict:
|
|
51
|
+
level = getattr(args, "scope", None) or "project"
|
|
52
|
+
return make_scope(level=level, project=ledger.config.get("project", "default"), user=author["user"] if level == "user" else None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _print(obj) -> None:
|
|
56
|
+
if isinstance(obj, str):
|
|
57
|
+
sys.stdout.write(obj if obj.endswith("\n") else obj + "\n")
|
|
58
|
+
else:
|
|
59
|
+
print(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _path_map(args, ledger: Ledger):
|
|
63
|
+
"""config.path_map, then AGENT_MEMORY_PATH_MAP, then --map: the last wins (§2)."""
|
|
64
|
+
return pathmap.load(ledger.config, flags=getattr(args, "map", None))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _sign_after(args, ledger: Ledger, author: dict, scope: dict, rec: dict, out: dict) -> bool:
|
|
68
|
+
"""File an attestation over ``rec`` when --sign was given; False when it did not.
|
|
69
|
+
|
|
70
|
+
The record itself is already in the ledger and cannot be unfiled, so a failed
|
|
71
|
+
signature is reported on stderr and in the exit code rather than pretended away.
|
|
72
|
+
"""
|
|
73
|
+
if not getattr(args, "sign", False):
|
|
74
|
+
return True
|
|
75
|
+
try:
|
|
76
|
+
att = signing.sign_record(ledger, author, scope, rec["id"], key_id=getattr(args, "key", None))
|
|
77
|
+
except (ValidationError, signing.SigningUnavailable) as exc:
|
|
78
|
+
out["attestation"] = None
|
|
79
|
+
print(f"filed {rec['id']} but did not sign it: {exc}", file=sys.stderr)
|
|
80
|
+
return False
|
|
81
|
+
out["attestation"] = att["id"]
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- commands
|
|
86
|
+
|
|
87
|
+
def cmd_init(args) -> int:
|
|
88
|
+
root = Path(args.path or ".")
|
|
89
|
+
if args.org:
|
|
90
|
+
# checked before anything is written: a path that does not exist is a typo, and
|
|
91
|
+
# writing it to a file outside the project and exiting 0 is not an answer (F-11)
|
|
92
|
+
org = Path(args.org).expanduser()
|
|
93
|
+
if not org.is_dir():
|
|
94
|
+
print(f"no such directory: {org}; --org takes an existing shared org-ledger "
|
|
95
|
+
f"directory. Nothing was written.", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
fresh = not (root / DIR_NAME / "config.json").exists()
|
|
98
|
+
ledger = Ledger.init(root, project=args.project, org_path=args.org, user=args.user)
|
|
99
|
+
# every run, not just the first: a ledger written before the allowlist existed is
|
|
100
|
+
# exactly the one that needs it, and `init` is what three documents tell you to run (F-4)
|
|
101
|
+
added = write_starter_config(ledger)
|
|
102
|
+
out = {"root": str(ledger.root), "config": ledger.config, "added_keys": added}
|
|
103
|
+
if fresh:
|
|
104
|
+
# a ledger this command just created is one nobody else wrote
|
|
105
|
+
out["trusted_repos"] = str(trust_repo(ledger.root))
|
|
106
|
+
if args.org:
|
|
107
|
+
# the repository may name an org path; only this machine may accept one (S7)
|
|
108
|
+
out["trusted_org_paths"] = str(trust_org_path(args.org))
|
|
109
|
+
_print(out)
|
|
110
|
+
print(f"project name: {ledger.config.get('project')}"
|
|
111
|
+
+ ("" if args.project else " (the directory name)")
|
|
112
|
+
+ f"; every record filed here is bound to it. Change it with "
|
|
113
|
+
f"`agent-memory init {args.path or '.'} --project NAME`.", file=sys.stderr)
|
|
114
|
+
if added:
|
|
115
|
+
print(f"wrote {', '.join(added)} to {ledger.dir / 'config.json'}"
|
|
116
|
+
+ (" (this ledger had none)" if not fresh else ""), file=sys.stderr)
|
|
117
|
+
if "recheck_allow" in added:
|
|
118
|
+
allow = ledger.config.get("recheck_allow") or []
|
|
119
|
+
print(f"re-check allowlist: {len(allow)} entries in "
|
|
120
|
+
f"{ledger.dir / 'config.json'} (\"recheck_allow\"). An entry is an argv prefix, "
|
|
121
|
+
"so add e.g. \"npm run lint\" to widen it by one command. A bare `python` or "
|
|
122
|
+
"`sh` entry admits arbitrary code: a receipt is a command every machine that "
|
|
123
|
+
"re-checks it runs unattended.", file=sys.stderr)
|
|
124
|
+
if fresh:
|
|
125
|
+
print(f"trusted this repository ({out['trusted_repos']}), so its config.json decides "
|
|
126
|
+
f"policy on this machine.", file=sys.stderr)
|
|
127
|
+
elif not ledger.trusted:
|
|
128
|
+
print(f"this ledger already existed, so it was not trusted: a config.json a clone "
|
|
129
|
+
f"brings with it only proposes policy (allowlist, redaction, path map, signers, "
|
|
130
|
+
f"org path, cross-vendor rule). Run `agent-memory trust {args.path or '.'}` if "
|
|
131
|
+
f"this checkout is yours.", file=sys.stderr)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_trust(args) -> int:
|
|
136
|
+
root = find_root(Path(args.path or "."))
|
|
137
|
+
if root is None:
|
|
138
|
+
print(f"no {DIR_NAME} directory found above {args.path or Path.cwd()}; run "
|
|
139
|
+
f"`agent-memory init`", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
path = trust_repo(root)
|
|
142
|
+
cfg_path = root / DIR_NAME / "config.json"
|
|
143
|
+
try:
|
|
144
|
+
config = json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
print(f"config.json is not valid JSON (a merge conflict?): {exc} ({cfg_path})", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
honoured = sorted(k for k in UNTRUSTED_POLICY if k in config)
|
|
149
|
+
_print({"root": str(root), "trusted_repos": str(path), "now_honoured": honoured})
|
|
150
|
+
print(f"trusted {root}: its config.json now decides policy on this machine"
|
|
151
|
+
+ (f" ({', '.join(honoured)})" if honoured else "")
|
|
152
|
+
+ f". Remove the line from {path} to undo.", file=sys.stderr)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_whoami(args) -> int:
|
|
157
|
+
ledger = Ledger.open()
|
|
158
|
+
_print(_author(args, ledger))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_run(args) -> int:
|
|
163
|
+
ledger = Ledger.open()
|
|
164
|
+
author = _author(args, ledger)
|
|
165
|
+
cmd = list(args.cmd or [])
|
|
166
|
+
if cmd and cmd[0] == "--":
|
|
167
|
+
cmd = cmd[1:]
|
|
168
|
+
if not cmd:
|
|
169
|
+
print("usage: agent-memory run [--cwd DIR] -- <command...>", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
if cmd[0].startswith("-"):
|
|
172
|
+
# an option that landed in the command means the caller mis-typed; never receipt it
|
|
173
|
+
print(f"usage: agent-memory run [--cwd DIR] -- <command...>; {cmd[0]!r} looks like an option, "
|
|
174
|
+
"put agent-memory's own options before the --", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
body = ev.run_and_capture(cmd if len(cmd) > 1 or not args.shell else cmd[0], cwd=args.cwd or str(ledger.root),
|
|
177
|
+
shell=args.shell, timeout=args.timeout, policy=ledger.config)
|
|
178
|
+
if args.assurance: # otherwise the ledger stamps the default on append
|
|
179
|
+
body["assurance"] = args.assurance
|
|
180
|
+
if args.no_rerun: # a deploy, a migration: nothing in the fleet may run this again
|
|
181
|
+
body["rerun"] = False
|
|
182
|
+
rec = ledger.append(new_record("evidence", author, _scope(args, ledger, author), body))
|
|
183
|
+
body = rec["body"]
|
|
184
|
+
out = {"id": rec["id"], "kind": body["kind"], "assurance": body.get("assurance"),
|
|
185
|
+
"exit": body["exit"], "duration_ms": body["duration_ms"]}
|
|
186
|
+
if body.get("redacted"): # say that something was scrubbed, never what it was
|
|
187
|
+
out["redacted"] = body["redacted"]
|
|
188
|
+
if body.get("start_error"): # the process never started; say so instead of a traceback
|
|
189
|
+
out["start_error"] = body["start_error"]
|
|
190
|
+
if body.get("rerun") is False:
|
|
191
|
+
out["rerun"] = False
|
|
192
|
+
if body["kind"] == "test":
|
|
193
|
+
out.update({"passed": body["passed"], "failed": body["failed"], "summary": body.get("summary")})
|
|
194
|
+
if not args.quiet:
|
|
195
|
+
sys.stderr.write(body["stdout_tail"][-1500:])
|
|
196
|
+
sys.stderr.write(body["stderr_tail"][-800:])
|
|
197
|
+
if body["stdout_tail"] or body["stderr_tail"]:
|
|
198
|
+
sys.stderr.write("\n")
|
|
199
|
+
signed = _sign_after(args, ledger, author, _scope(args, ledger, author), rec, out)
|
|
200
|
+
_print(out)
|
|
201
|
+
if not signed:
|
|
202
|
+
return 3
|
|
203
|
+
return 0 if body["exit"] == 0 else 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_claim(args) -> int:
|
|
207
|
+
ledger = Ledger.open()
|
|
208
|
+
author = _author(args, ledger)
|
|
209
|
+
body = {"type": args.type, "statement": args.statement}
|
|
210
|
+
evidence: List = list(args.evidence or [])
|
|
211
|
+
try:
|
|
212
|
+
if args.commit is not None:
|
|
213
|
+
evidence.append(ev.commit_binding(str(ledger.root), args.commit or None, branch=args.branch))
|
|
214
|
+
for f in args.file or []:
|
|
215
|
+
evidence.append(ev.file_binding(f))
|
|
216
|
+
for f in args.env or []:
|
|
217
|
+
# what the claim is true *under*: a change here makes it stale, not wrong
|
|
218
|
+
evidence.append(ev.file_binding(f, role="environment"))
|
|
219
|
+
if args.diff:
|
|
220
|
+
base, _, head = args.diff.partition("..")
|
|
221
|
+
evidence.append(ev.diff_binding(str(ledger.root), base, head or "HEAD"))
|
|
222
|
+
except ValueError as exc: # a binding that does not resolve is a rejection, not a crash
|
|
223
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
224
|
+
return 3
|
|
225
|
+
if args.attest:
|
|
226
|
+
evidence.append({"kind": "human", "who": author["user"], "statement": args.attest})
|
|
227
|
+
if evidence:
|
|
228
|
+
body["evidence"] = evidence
|
|
229
|
+
for key in ("rationale", "example", "outcome", "source", "contract"):
|
|
230
|
+
val = getattr(args, key, None)
|
|
231
|
+
if val is not None:
|
|
232
|
+
body[key] = val
|
|
233
|
+
if args.expect_exit is not None or args.expect_failed is not None:
|
|
234
|
+
# the claim is about a deliberate failure state, so say which one it expects
|
|
235
|
+
body["expect"] = {}
|
|
236
|
+
if args.expect_exit is not None:
|
|
237
|
+
body["expect"]["exit"] = args.expect_exit
|
|
238
|
+
if args.expect_failed is not None:
|
|
239
|
+
body["expect"]["failed"] = args.expect_failed
|
|
240
|
+
if args.confidence is not None:
|
|
241
|
+
body["confidence"] = args.confidence
|
|
242
|
+
if args.ttl_days is not None:
|
|
243
|
+
body["ttl_days"] = args.ttl_days
|
|
244
|
+
rec = new_record("claim", author, _scope(args, ledger, author), body, derived_from=args.derived_from or (), tags=args.tag or ())
|
|
245
|
+
try:
|
|
246
|
+
rec = ledger.append(rec)
|
|
247
|
+
except ValidationError as exc:
|
|
248
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
249
|
+
return 3
|
|
250
|
+
tier = assess(ledger).get(rec["id"], {}).get("tier")
|
|
251
|
+
out = {"id": rec["id"], "type": rec["body"]["type"], "tier": tier}
|
|
252
|
+
signed = _sign_after(args, ledger, author, _scope(args, ledger, author), rec, out)
|
|
253
|
+
_print(out)
|
|
254
|
+
return 0 if signed else 3
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_verify(args) -> int:
|
|
258
|
+
ledger = Ledger.open()
|
|
259
|
+
author = _author(args, ledger)
|
|
260
|
+
claim = ledger.get(args.claim)
|
|
261
|
+
if claim is None or claim["kind"] != "claim":
|
|
262
|
+
print(f"no claim {args.claim}", file=sys.stderr)
|
|
263
|
+
return 2
|
|
264
|
+
results = ev.recheck_claim(ledger, claim, cwd=str(ledger.root), strict=args.strict, timeout=args.timeout,
|
|
265
|
+
path_map=_path_map(args, ledger))
|
|
266
|
+
verdict = args.verdict
|
|
267
|
+
method = "human" if author["kind"] == "human" and verdict else ("independent" if verdict else "rerun")
|
|
268
|
+
env_changed: list = []
|
|
269
|
+
auto_note = None
|
|
270
|
+
if not verdict:
|
|
271
|
+
verdict, env_changed, auto_note = ev.verdict_from_results(results, ledger, claim)
|
|
272
|
+
vbody = {"claim": claim["id"], "verdict": verdict, "method": method, "results": results}
|
|
273
|
+
note = args.note
|
|
274
|
+
if auto_note: # e.g. every binding matched, but the receipt it matched had failed
|
|
275
|
+
note = f"{note}; {auto_note}" if note else auto_note
|
|
276
|
+
if env_changed: # the proof still holds; the world it held in does not
|
|
277
|
+
vbody["environment_changed"] = True
|
|
278
|
+
changed = "environment changed: " + ", ".join(env_changed)
|
|
279
|
+
note = f"{note}; {changed}" if note else changed
|
|
280
|
+
if note:
|
|
281
|
+
vbody["note"] = note
|
|
282
|
+
tags = [f"lens:{args.lens}"] if args.lens else []
|
|
283
|
+
try:
|
|
284
|
+
rec = ledger.append(new_record("verification", author, _scope(args, ledger, author), vbody,
|
|
285
|
+
derived_from=[claim["id"]], tags=tags))
|
|
286
|
+
except ValidationError as exc:
|
|
287
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
288
|
+
return 3
|
|
289
|
+
out = {"id": rec["id"], "verdict": verdict, "method": method, "environment_changed": bool(env_changed),
|
|
290
|
+
"claim_tier_now": assess(ledger)[claim["id"]]["tier"], "results": results}
|
|
291
|
+
signed = _sign_after(args, ledger, author, _scope(args, ledger, author), rec, out)
|
|
292
|
+
_print(out)
|
|
293
|
+
if not signed:
|
|
294
|
+
return 3
|
|
295
|
+
return 0 if verdict != "refuted" else 1
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_retract(args) -> int:
|
|
299
|
+
ledger = Ledger.open()
|
|
300
|
+
author = _author(args, ledger)
|
|
301
|
+
rec = ledger.append(new_record("retraction", author, _scope(args, ledger, author), {"claim": args.claim, "reason": args.reason},
|
|
302
|
+
derived_from=[args.claim]))
|
|
303
|
+
tainted = sorted(ledger.dependents_of(args.claim, through_kinds=("claim",)))
|
|
304
|
+
out = {"id": rec["id"], "tainted_dependents": tainted}
|
|
305
|
+
signed = _sign_after(args, ledger, author, _scope(args, ledger, author), rec, out)
|
|
306
|
+
_print(out)
|
|
307
|
+
return 0 if signed else 3
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def cmd_compile(args) -> int:
|
|
311
|
+
ledger = Ledger.open()
|
|
312
|
+
author = _author(args, ledger)
|
|
313
|
+
compiled = compile_context(ledger, role=args.role, mode=args.mode, task=args.task, contract_id=args.contract,
|
|
314
|
+
tags=args.tag or (), budget_tokens=args.budget, reader=author,
|
|
315
|
+
include_tainted=args.include_tainted, round_no=args.round, lens=args.lens,
|
|
316
|
+
reliability=not args.no_reliability)
|
|
317
|
+
text = render(compiled, args.render)
|
|
318
|
+
if not args.no_receipt:
|
|
319
|
+
rec = record_compilation(ledger, compiled, text, args.render, author, _scope(args, ledger, author),
|
|
320
|
+
save_dir=ledger.dir / "compiled")
|
|
321
|
+
if args.render != "json":
|
|
322
|
+
text += f"\n<!-- compilation receipt {rec['id']} -->\n"
|
|
323
|
+
if args.output:
|
|
324
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
325
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
326
|
+
else:
|
|
327
|
+
_print(text)
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_search(args) -> int:
|
|
332
|
+
ledger = Ledger.open()
|
|
333
|
+
assessment = assess(ledger)
|
|
334
|
+
for rec in ledger.search(" ".join(args.query), kinds=args.kind or ("claim",), limit=args.limit):
|
|
335
|
+
tier = assessment.get(rec["id"], {}).get("tier", "-")
|
|
336
|
+
body = rec["body"]
|
|
337
|
+
text = body.get("statement") or body.get("title") or body.get("state") or json.dumps(body)[:160]
|
|
338
|
+
print(f"{rec['id']} {rec['ts'][:10]} [{tier}] {rec['author'].get('harness')}: {text[:160]}")
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def cmd_show(args) -> int:
|
|
343
|
+
ledger = Ledger.open()
|
|
344
|
+
rec = ledger.get(args.id)
|
|
345
|
+
if rec is None:
|
|
346
|
+
print(f"no record {args.id}", file=sys.stderr)
|
|
347
|
+
return 2
|
|
348
|
+
out = dict(rec)
|
|
349
|
+
if rec["kind"] == "claim":
|
|
350
|
+
assessment = assess(ledger)
|
|
351
|
+
out["trust"] = assessment.get(rec["id"])
|
|
352
|
+
out["verifications"] = [v["id"] for v in ledger.verifications_for(rec["id"])]
|
|
353
|
+
out["retractions"] = [r["id"] for r in ledger.retractions_for(rec["id"])]
|
|
354
|
+
out["dependents"] = sorted(ledger.dependents_of(rec["id"]))
|
|
355
|
+
# what history says about claims like this one, or nothing when there is too little
|
|
356
|
+
table = rel_mod.cell_table(ledger, assessment, config=ledger.config)
|
|
357
|
+
rows = rel_mod.judgment_calibration(ledger, assessment, config=ledger.config)
|
|
358
|
+
annotated = rel_mod.annotate(rec, assessment, table, rows, config=ledger.config)
|
|
359
|
+
out["reliability"] = None if annotated is None else dict(annotated[1], line=annotated[0])
|
|
360
|
+
_print(out)
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_list(args) -> int:
|
|
365
|
+
ledger = Ledger.open()
|
|
366
|
+
assessment = assess(ledger)
|
|
367
|
+
rows = ledger.all() if args.kind == "all" else ledger.by_kind(args.kind)
|
|
368
|
+
for rec in rows[-args.limit:]:
|
|
369
|
+
body = rec["body"]
|
|
370
|
+
tier = assessment.get(rec["id"], {}).get("tier", "")
|
|
371
|
+
text = body.get("statement") or body.get("title") or body.get("state") or body.get("cmd") or body.get("claim") or ""
|
|
372
|
+
print(f"{rec['id']} {rec['ts'][:16]} {rec['kind']:<15} {('[' + tier + ']') if tier else '':<15} {rec['author'].get('harness')}: {str(text)[:120]}")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cmd_contract(args) -> int:
|
|
377
|
+
ledger = Ledger.open()
|
|
378
|
+
author = _author(args, ledger)
|
|
379
|
+
scope = _scope(args, ledger, author)
|
|
380
|
+
if args.action == "create":
|
|
381
|
+
acceptance = []
|
|
382
|
+
for c in args.check or []:
|
|
383
|
+
acceptance.append({"kind": "command", "cmd": c, "expect_exit": 0})
|
|
384
|
+
for s in args.human_check or []:
|
|
385
|
+
acceptance.append({"kind": "human", "statement": s})
|
|
386
|
+
for f in args.file_check or []:
|
|
387
|
+
acceptance.append({"kind": "file", "path": f})
|
|
388
|
+
if args.acceptance_json:
|
|
389
|
+
acceptance += json.loads(Path(args.acceptance_json).read_text())
|
|
390
|
+
try:
|
|
391
|
+
rec = contracts.create(ledger, author, scope, args.title, args.task, acceptance, assigned_to=args.assign,
|
|
392
|
+
tags=args.tag or (), issue=args.issue)
|
|
393
|
+
except ValidationError as exc:
|
|
394
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
395
|
+
return 3
|
|
396
|
+
out = {"id": rec["id"], "status": "proposed"}
|
|
397
|
+
signed = _sign_after(args, ledger, author, scope, rec, out)
|
|
398
|
+
_print(out)
|
|
399
|
+
return 0 if signed else 3
|
|
400
|
+
if args.action == "update":
|
|
401
|
+
rec = contracts.update(ledger, author, scope, args.id, args.status, note=args.note, claims=args.claims or ())
|
|
402
|
+
out = {"id": rec["id"], "status": args.status}
|
|
403
|
+
signed = _sign_after(args, ledger, author, scope, rec, out)
|
|
404
|
+
_print(out)
|
|
405
|
+
return 0 if signed else 3
|
|
406
|
+
if args.action == "from-issue":
|
|
407
|
+
try:
|
|
408
|
+
rec = contracts.from_issue(ledger, author, scope, args.ref, checks=args.check or (),
|
|
409
|
+
human_checks=args.human_check or (), file_checks=args.file_check or (),
|
|
410
|
+
assign=args.assign, tags=args.tag or ())
|
|
411
|
+
except ValidationError as exc: # gh missing/unauthenticated, or a contract with no check
|
|
412
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
413
|
+
return 3
|
|
414
|
+
_print({"id": rec["id"], "status": "proposed", "issue": rec["body"].get("issue"),
|
|
415
|
+
"title": rec["body"]["title"]})
|
|
416
|
+
return 0
|
|
417
|
+
if args.action == "sync":
|
|
418
|
+
try:
|
|
419
|
+
out = contracts.sync(ledger, args.id, close=args.close)
|
|
420
|
+
except ValidationError as exc:
|
|
421
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
422
|
+
return 3
|
|
423
|
+
_print(out)
|
|
424
|
+
if out.get("refused"): # the issue tracker does not get to say done before the ledger does
|
|
425
|
+
print(f"refused: {out['refused']}", file=sys.stderr)
|
|
426
|
+
return 1
|
|
427
|
+
return 0
|
|
428
|
+
if args.action == "status":
|
|
429
|
+
st = contracts.status_of(ledger, args.id, assess(ledger))
|
|
430
|
+
_print(st)
|
|
431
|
+
return 0
|
|
432
|
+
if args.action == "accept":
|
|
433
|
+
results = contracts.run_acceptance(ledger, args.id, cwd=str(ledger.root), author=author, scope=scope)
|
|
434
|
+
filed = [r["receipt"] for r in results if r.get("receipt")]
|
|
435
|
+
ok = all(r["outcome"] in ("match", "skipped") for r in results)
|
|
436
|
+
_print({"contract": args.id, "all_pass": ok, "receipts": filed,
|
|
437
|
+
"results": [{"check": r["check"], "outcome": r["outcome"], "detail": r["detail"]} for r in results]})
|
|
438
|
+
return 0 if ok else 1
|
|
439
|
+
if args.action == "list":
|
|
440
|
+
for st in contracts.open_contracts(ledger):
|
|
441
|
+
print(f"{st['id']} {st['status']:<12} {st['title']} ({len(st['claims'])} claims)")
|
|
442
|
+
return 0
|
|
443
|
+
return 2
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def cmd_recheck(args) -> int:
|
|
447
|
+
ledger = Ledger.open()
|
|
448
|
+
author = _author(args, ledger)
|
|
449
|
+
scope = _scope(args, ledger, author)
|
|
450
|
+
types = [t for group in (args.type or []) for t in str(group).split(",") if t.strip()]
|
|
451
|
+
try:
|
|
452
|
+
rows = recheck_mod.run(ledger, author, scope, only=args.only, types=types, older_than=args.older_than,
|
|
453
|
+
max=args.max, cwd=str(ledger.root), timeout=args.timeout,
|
|
454
|
+
path_map=_path_map(args, ledger), dry_run=args.dry_run,
|
|
455
|
+
min_age_days=args.min_age_days, force=args.force)
|
|
456
|
+
except ValueError as exc: # a malformed --map or --only is a usage error, not a crash
|
|
457
|
+
print(f"recheck: {exc}", file=sys.stderr)
|
|
458
|
+
return 2
|
|
459
|
+
if args.json:
|
|
460
|
+
_print(rows)
|
|
461
|
+
else:
|
|
462
|
+
for row in rows:
|
|
463
|
+
if row.get("error"):
|
|
464
|
+
print(f"{row['id']} error: {row['error']}")
|
|
465
|
+
elif row.get("skipped") == recheck_mod.OWN_CLAIM:
|
|
466
|
+
print(f"{row['id']} skipped: {row['skipped']} (a self-report is not a verification)")
|
|
467
|
+
elif row.get("plan") is not None: # --dry-run: what would run, not what it did
|
|
468
|
+
print(f"{row['id']} dry run {row['statement'][:80]}")
|
|
469
|
+
for b in row["plan"]:
|
|
470
|
+
what = f" {b['cmd']}" if b.get("cmd") else ""
|
|
471
|
+
where = f" (in {b['cwd']})" if b.get("cwd") else ""
|
|
472
|
+
print(f" {b['decision']:<16} {b['evidence']} [{b['kind']}]{what}{where}")
|
|
473
|
+
elif row.get("skipped"):
|
|
474
|
+
print(f"{row['id']} skipped: {row['skipped']} (--force re-checks it anyway)")
|
|
475
|
+
else:
|
|
476
|
+
print(f"{row['id']} {row['verdict']} {row['filed'] or '-'} {row['statement'][:80]}")
|
|
477
|
+
summary = recheck_mod.summarize(rows)
|
|
478
|
+
print("recheck: " + ", ".join(f"{k}={v}" for k, v in summary.items()), file=sys.stderr)
|
|
479
|
+
return recheck_mod.exit_code(rows)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_keygen(args) -> int:
|
|
483
|
+
ledger = Ledger.open()
|
|
484
|
+
author = _author(args, ledger)
|
|
485
|
+
try:
|
|
486
|
+
key = signing.keygen(user=author["user"], out_dir=args.out)
|
|
487
|
+
except signing.SigningUnavailable as exc:
|
|
488
|
+
# 3, like `sign`: the command was well formed and this machine cannot do it (F-11)
|
|
489
|
+
print(str(exc), file=sys.stderr)
|
|
490
|
+
return 3
|
|
491
|
+
signing.register(ledger, key)
|
|
492
|
+
# the private half is named, never printed: the public half is what others need
|
|
493
|
+
_print({"key_id": key["key_id"], "alg": key["alg"], "public": key["public"], "user": key["user"],
|
|
494
|
+
"private_key": key["path"], "registered_in": str(ledger.dir / "config.json")})
|
|
495
|
+
return 0
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def cmd_sign(args) -> int:
|
|
499
|
+
ledger = Ledger.open()
|
|
500
|
+
author = _author(args, ledger)
|
|
501
|
+
scope = _scope(args, ledger, author)
|
|
502
|
+
out = []
|
|
503
|
+
for rid in args.ids:
|
|
504
|
+
try:
|
|
505
|
+
att = signing.sign_record(ledger, author, scope, rid, key_id=args.key)
|
|
506
|
+
except signing.SigningUnavailable as exc:
|
|
507
|
+
# the same condition keygen answers for, and the same answer: a missing optional
|
|
508
|
+
# dependency is not a usage error (F-11)
|
|
509
|
+
print(str(exc), file=sys.stderr)
|
|
510
|
+
return 3
|
|
511
|
+
except ValidationError as exc:
|
|
512
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
513
|
+
return 3
|
|
514
|
+
out.append({"id": att["id"], "record": rid, "key_id": att["body"]["key_id"]})
|
|
515
|
+
_print(out)
|
|
516
|
+
return 0
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def cmd_verify_signatures(args) -> int:
|
|
520
|
+
ledger = Ledger.open()
|
|
521
|
+
rep = signing.report(ledger)
|
|
522
|
+
if args.json:
|
|
523
|
+
_print(rep)
|
|
524
|
+
else:
|
|
525
|
+
print(f"attestations: {rep['attested']} valid={rep['valid']} invalid={rep['invalid']} "
|
|
526
|
+
f"unknown_key={rep['unknown']} unverifiable={rep['unavailable']}")
|
|
527
|
+
for item in rep["items"]:
|
|
528
|
+
print(f"{item['id']} {item['status']:<12} {item['record']} [{item['key_id']}] {item['detail']}")
|
|
529
|
+
if rep["unavailable"]:
|
|
530
|
+
print(signing.NO_CRYPTO + f"; {signing.INSTALL_HINT}", file=sys.stderr)
|
|
531
|
+
return 3 if rep["invalid"] else 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_audit(args) -> int:
|
|
535
|
+
ledger = Ledger.open()
|
|
536
|
+
rep = audit_mod.report(ledger, recheck=args.recheck, cwd=str(ledger.root),
|
|
537
|
+
path_map=_path_map(args, ledger))
|
|
538
|
+
if args.json:
|
|
539
|
+
_print({k: (dict(v) if hasattr(v, "most_common") else v) for k, v in rep.items()})
|
|
540
|
+
else:
|
|
541
|
+
_print(audit_mod.format_report(rep))
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_reliability(args) -> int:
|
|
546
|
+
ledger = Ledger.open()
|
|
547
|
+
config = dict(ledger.config)
|
|
548
|
+
if args.n_min is not None:
|
|
549
|
+
config["reliability"] = dict(rel_mod.settings(ledger.config), n_min=args.n_min)
|
|
550
|
+
try:
|
|
551
|
+
rep = rel_mod.report(ledger, assess(ledger, config=config), config=config, since=args.since)
|
|
552
|
+
except ValueError as exc: # an unparseable --since or an invalid --n-min is a usage error, not a crash
|
|
553
|
+
print(f"reliability: {exc}", file=sys.stderr)
|
|
554
|
+
return 2
|
|
555
|
+
rep = rel_mod.view(rep, args.by)
|
|
556
|
+
_print(rep if args.json else rel_mod.format_report(rep))
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_compact(args) -> int:
|
|
561
|
+
ledger = Ledger.open()
|
|
562
|
+
author = _author(args, ledger)
|
|
563
|
+
scope = _scope(args, ledger, author)
|
|
564
|
+
claims = compact_mod.select_candidates(ledger, older_than_days=args.older_than, contract_id=args.contract, tags=args.tag or ())
|
|
565
|
+
if args.ids:
|
|
566
|
+
wanted = set(args.ids)
|
|
567
|
+
claims = [c for c in ledger.claims() if c["id"] in wanted]
|
|
568
|
+
if not claims:
|
|
569
|
+
print("nothing to compact", file=sys.stderr)
|
|
570
|
+
return 1
|
|
571
|
+
summarizer = None
|
|
572
|
+
if args.llm_endpoint:
|
|
573
|
+
try:
|
|
574
|
+
summarizer = memorizer.summarize_state(args.llm_endpoint, args.llm_model or "qwen2.5:14b",
|
|
575
|
+
api_key=args.llm_api_key, insecure=args.llm_insecure)
|
|
576
|
+
except ValueError as exc: # a key bound for a plaintext endpoint
|
|
577
|
+
print(f"refused: {exc}", file=sys.stderr)
|
|
578
|
+
return 2
|
|
579
|
+
rec = compact_mod.build_compaction(ledger, claims, author, scope, tags=args.tag or (), summarizer=summarizer)
|
|
580
|
+
if args.dry_run:
|
|
581
|
+
_print(rec)
|
|
582
|
+
return 0
|
|
583
|
+
rec = ledger.append(rec)
|
|
584
|
+
_print({"id": rec["id"], "replaces": len(rec["body"]["replaces"]), "counts": rec["body"]["counts"]})
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_memorize(args) -> int:
|
|
589
|
+
ledger = Ledger.open()
|
|
590
|
+
author = _author(args, ledger)
|
|
591
|
+
scope = _scope(args, ledger, author)
|
|
592
|
+
transcript = Path(args.transcript).read_text(encoding="utf-8") if args.transcript != "-" else sys.stdin.read()
|
|
593
|
+
try: # checked before the transcript goes anywhere, not while it is in flight
|
|
594
|
+
memorizer.check_endpoint(args.llm_endpoint, memorizer.resolve_api_key(args.llm_api_key),
|
|
595
|
+
args.llm_insecure)
|
|
596
|
+
except ValueError as exc:
|
|
597
|
+
print(f"refused: {exc}", file=sys.stderr)
|
|
598
|
+
return 2
|
|
599
|
+
proposals = memorizer.extract_claims(transcript, args.llm_endpoint, args.llm_model, max_claims=args.max,
|
|
600
|
+
api_key=args.llm_api_key, insecure=args.llm_insecure)
|
|
601
|
+
if args.dry_run:
|
|
602
|
+
_print(proposals)
|
|
603
|
+
return 0
|
|
604
|
+
filed, dropped = memorizer.file_proposals(ledger, proposals, author, scope)
|
|
605
|
+
_print({"filed": [{"id": r["id"], "type": r["body"]["type"], "statement": r["body"]["statement"][:100]} for r in filed],
|
|
606
|
+
"dropped": [{"statement": p.get("statement", "")[:100], "reason": why} for p, why in dropped]})
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def cmd_export(args) -> int:
|
|
611
|
+
ledger = Ledger.open()
|
|
612
|
+
reader = _author(args, ledger) # another user's private receipts are not ours to export
|
|
613
|
+
try:
|
|
614
|
+
text = export_mod.export(ledger, args.format, ids=args.ids or None, reader=reader)
|
|
615
|
+
except ValueError as exc:
|
|
616
|
+
print(str(exc), file=sys.stderr)
|
|
617
|
+
return 2
|
|
618
|
+
if args.output:
|
|
619
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
620
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
621
|
+
else:
|
|
622
|
+
_print(text)
|
|
623
|
+
return 0
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def cmd_fsck(args) -> int:
|
|
627
|
+
ledger = Ledger.open()
|
|
628
|
+
rep = ledger.fsck(rewrite=args.rewrite, force=args.force)
|
|
629
|
+
_print(rep)
|
|
630
|
+
return 0 if not rep["invalid"] and not rep["corrupt"] else 1
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def cmd_redact(args) -> int:
|
|
634
|
+
ledger = Ledger.open()
|
|
635
|
+
author = _author(args, ledger)
|
|
636
|
+
if author.get("kind") != "human":
|
|
637
|
+
print("redaction is a human act: re-run with --as-human (the tombstone records who did it)",
|
|
638
|
+
file=sys.stderr)
|
|
639
|
+
return 3
|
|
640
|
+
tomb = ledger.redact(args.id, args.reason, author)
|
|
641
|
+
body = tomb["body"]
|
|
642
|
+
_print({"id": tomb["id"], "kind": tomb["kind"], "redacted": True, "reason": body["reason"],
|
|
643
|
+
"redacted_by": body["redacted_by"]["user"], "redacted_at": body["redacted_at"],
|
|
644
|
+
"original_sha256": body["original_sha256"]})
|
|
645
|
+
print("the content is gone from this copy only: anyone who already pulled this repository still "
|
|
646
|
+
"has the line, and the ledger's git history does too", file=sys.stderr)
|
|
647
|
+
return 0
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def cmd_hook(args) -> int:
|
|
651
|
+
from . import hooks
|
|
652
|
+
return hooks.main([args.event], harness=args.harness or hooks.default_harness(), nudge=args.nudge)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def cmd_mcp(args) -> int:
|
|
656
|
+
from . import mcp_server
|
|
657
|
+
mcp_server.main(args.root)
|
|
658
|
+
return 0
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def serve_defaults() -> dict:
|
|
662
|
+
"""The scheduled lane's bounds, from the module that runs it: the flags and the
|
|
663
|
+
server agree on one set of numbers (imported here so `--help` costs no sqlite)."""
|
|
664
|
+
from . import serve as serve_mod
|
|
665
|
+
return {"lane_max": serve_mod.LANE_MAX, "lane_timeout": serve_mod.LANE_TIMEOUT,
|
|
666
|
+
"lane_older_than": serve_mod.LANE_OLDER_THAN}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def cmd_serve(args) -> int:
|
|
670
|
+
from . import serve as serve_mod
|
|
671
|
+
return serve_mod.main(host=args.host, port=args.port, tokens=args.tokens, allow_run=args.allow_run,
|
|
672
|
+
allow_origin=args.allow_origin or (), recheck_every=args.recheck_every,
|
|
673
|
+
git_sync_every=args.git_sync_every, rebuild=args.rebuild, json_logs=args.json_logs,
|
|
674
|
+
root=args.root, lane_max=args.lane_max, lane_timeout=args.lane_timeout,
|
|
675
|
+
lane_older_than=args.lane_older_than)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# --------------------------------------------------------------------------- remote mode
|
|
679
|
+
#
|
|
680
|
+
# With --remote the ledger is somebody else's process. Three things follow, and every
|
|
681
|
+
# function below is an application of them:
|
|
682
|
+
#
|
|
683
|
+
# * identity comes from the token, so --harness/--model/--user/--as-human are refused
|
|
684
|
+
# rather than sent and ignored (docs/PROTOCOL.md section 10);
|
|
685
|
+
# * a flag the HTTP API cannot carry is refused for the same reason: a claim that
|
|
686
|
+
# silently lost its --ttl-days is a claim nobody can audit;
|
|
687
|
+
# * `run` still runs here. A receipt is worth what the machine it ran on is worth, so
|
|
688
|
+
# the command executes locally and only the record travels.
|
|
689
|
+
|
|
690
|
+
# the local ledger is the whole of what these do; there is nothing to send
|
|
691
|
+
LOCAL_ONLY = ("init", "trust", "fsck", "redact", "keygen", "sign", "recheck", "serve", "hook",
|
|
692
|
+
"mcp", "compact", "memorize")
|
|
693
|
+
IDENTITY_OVERRIDES = ("as_human", "harness", "model", "user")
|
|
694
|
+
# `verify --timeout` bounds the re-check subprocess, which only the process running it
|
|
695
|
+
# can bound. The parser's default lives here so the remote path can tell a value the
|
|
696
|
+
# caller typed from the one argparse filled in, and refuse only the former.
|
|
697
|
+
VERIFY_TIMEOUT = 600.0
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _remote_session(args) -> Optional[str]:
|
|
701
|
+
"""The run a remote record came from: --session, else the harness's own session id
|
|
702
|
+
when the environment actually names one. It is the only thing a client may declare
|
|
703
|
+
about itself, and "unknown" declares nothing."""
|
|
704
|
+
if getattr(args, "session", None):
|
|
705
|
+
return args.session
|
|
706
|
+
session = detect_session()
|
|
707
|
+
return session if session != "unknown" else None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _refuse(command: str, flags: List[str], why: str) -> int:
|
|
711
|
+
print(f"{command}: {', '.join(flags)} not available over --remote: {why}", file=sys.stderr)
|
|
712
|
+
return 2
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _given(*pairs) -> List[str]:
|
|
716
|
+
"""The flags in ``pairs`` ((flag, value), ...) the caller actually passed."""
|
|
717
|
+
return [flag for flag, value in pairs if value not in (None, False, (), [], "")]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _remote_scope(args, client, identity: Optional[dict] = None) -> Optional[dict]:
|
|
721
|
+
"""The scope a record POSTed to /records carries, or None to let the server stamp it.
|
|
722
|
+
|
|
723
|
+
A record is bound to its project, so the project in the scope has to be the served
|
|
724
|
+
ledger's own name and not this directory's: only a non-default --scope has to ask
|
|
725
|
+
/health for it, and a user scope also needs the user the token files under.
|
|
726
|
+
"""
|
|
727
|
+
level = getattr(args, "scope", None) or "project"
|
|
728
|
+
if level == "project":
|
|
729
|
+
return None # the server builds it from its own project, which is the only right answer
|
|
730
|
+
user = (identity or client.whoami())["user"] if level == "user" else None
|
|
731
|
+
return make_scope(level=level, project=client.health()["project"], user=user)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _remote_tier(client, rid: str) -> Optional[str]:
|
|
735
|
+
"""The trust tier the served ledger computes for a claim it just accepted. POST
|
|
736
|
+
/records answers with the record; the tier is one read away and is what the local
|
|
737
|
+
`claim` prints."""
|
|
738
|
+
return ((client.get_record(rid) or {}).get("trust") or {}).get("tier")
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _remote_claim(args, client) -> int:
|
|
742
|
+
"""POST /claims for a plain claim; POST /records for one with a binding.
|
|
743
|
+
|
|
744
|
+
A commit, file, diff or human binding is computed here, against the checkout the
|
|
745
|
+
person typing has, which is the only machine that can compute it -- so the claim is
|
|
746
|
+
built as a record with no author, exactly as `run` builds a receipt, and the server
|
|
747
|
+
stamps the identity and the id. The binding still has to verify when it is filed:
|
|
748
|
+
that check happens on the server host, so a path only this machine can see comes
|
|
749
|
+
back as `rejected: evidence binding does not verify`.
|
|
750
|
+
"""
|
|
751
|
+
if args.sign:
|
|
752
|
+
return _refuse("claim", ["--sign"], "signing reads the keys registered in the local ledger")
|
|
753
|
+
bound = _given(("--commit", args.commit is not None), ("--file", args.file), ("--env", args.env),
|
|
754
|
+
("--diff", args.diff), ("--attest", args.attest))
|
|
755
|
+
# the token's user is what a human attestation is signed with, and what a user scope
|
|
756
|
+
# is filed under: one round trip, and only when one of them was asked for
|
|
757
|
+
identity = client.whoami() if (args.attest or (bound and args.scope == "user")) else None
|
|
758
|
+
body = {"type": args.type, "statement": args.statement}
|
|
759
|
+
evidence: List = list(args.evidence or [])
|
|
760
|
+
try:
|
|
761
|
+
if args.commit is not None: # the repo is this directory: there is no other checkout here
|
|
762
|
+
evidence.append(ev.commit_binding(None, args.commit or None, branch=args.branch))
|
|
763
|
+
for f in args.file or []:
|
|
764
|
+
evidence.append(ev.file_binding(f))
|
|
765
|
+
for f in args.env or []:
|
|
766
|
+
evidence.append(ev.file_binding(f, role="environment"))
|
|
767
|
+
if args.diff:
|
|
768
|
+
base, _, head = args.diff.partition("..")
|
|
769
|
+
evidence.append(ev.diff_binding(None, base, head or "HEAD"))
|
|
770
|
+
except ValueError as exc: # a binding that does not resolve is a rejection, not a crash
|
|
771
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
772
|
+
return 3
|
|
773
|
+
if args.attest:
|
|
774
|
+
evidence.append({"kind": "human", "who": identity["user"], "statement": args.attest})
|
|
775
|
+
if evidence:
|
|
776
|
+
body["evidence"] = evidence
|
|
777
|
+
for key in ("rationale", "example", "outcome", "source", "contract", "confidence", "ttl_days"):
|
|
778
|
+
val = getattr(args, key, None)
|
|
779
|
+
if val is not None:
|
|
780
|
+
body[key] = val
|
|
781
|
+
if args.expect_exit is not None or args.expect_failed is not None:
|
|
782
|
+
body["expect"] = {}
|
|
783
|
+
if args.expect_exit is not None:
|
|
784
|
+
body["expect"]["exit"] = args.expect_exit
|
|
785
|
+
if args.expect_failed is not None:
|
|
786
|
+
body["expect"]["failed"] = args.expect_failed
|
|
787
|
+
tags, derived = list(args.tag or []), list(args.derived_from or [])
|
|
788
|
+
if not bound:
|
|
789
|
+
_print(client.post_claim(dict(body, scope=args.scope, tags=tags, derived_from=derived)))
|
|
790
|
+
return 0
|
|
791
|
+
payload = {"kind": "claim", "body": body, "tags": tags, "derived_from": derived}
|
|
792
|
+
scope = _remote_scope(args, client, identity)
|
|
793
|
+
if scope:
|
|
794
|
+
payload["scope"] = scope
|
|
795
|
+
rec, _ = client.post_record(payload)
|
|
796
|
+
_print({"id": rec["id"], "type": rec["body"]["type"], "tier": _remote_tier(client, rec["id"])})
|
|
797
|
+
return 0
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _remote_retract(args, client) -> int:
|
|
801
|
+
"""A retraction is a record like any other: built here, filed there under the token.
|
|
802
|
+
|
|
803
|
+
The dependents come back from the served ledger, which walks the closure through
|
|
804
|
+
claims and compactions (what `show` reports), so the list is the one the server will
|
|
805
|
+
act on rather than one computed from a ledger this machine does not have.
|
|
806
|
+
"""
|
|
807
|
+
if args.sign:
|
|
808
|
+
return _refuse("retract", ["--sign"], "signing reads the keys registered in the local ledger")
|
|
809
|
+
payload = {"kind": "retraction", "body": {"claim": args.claim, "reason": args.reason},
|
|
810
|
+
"derived_from": [args.claim]}
|
|
811
|
+
scope = _remote_scope(args, client)
|
|
812
|
+
if scope:
|
|
813
|
+
payload["scope"] = scope
|
|
814
|
+
rec, _ = client.post_record(payload)
|
|
815
|
+
_print({"id": rec["id"], "tainted_dependents": client.get_record(args.claim).get("dependents") or []})
|
|
816
|
+
return 0
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _remote_list(args, client) -> int:
|
|
820
|
+
# trust=true is what makes this the same listing as the local one: a page is the
|
|
821
|
+
# index's answer and carries no tier until the server is asked to add it
|
|
822
|
+
page = client.list_records(kind=None if args.kind == "all" else args.kind, limit=args.limit,
|
|
823
|
+
trust=True)
|
|
824
|
+
records = page.get("records") or []
|
|
825
|
+
# the page is newest first; the local `list` prints the newest N oldest-first
|
|
826
|
+
for rec in reversed(records):
|
|
827
|
+
body = rec["body"]
|
|
828
|
+
tier = rec.get("tier") or ""
|
|
829
|
+
text = (body.get("statement") or body.get("title") or body.get("state") or body.get("cmd")
|
|
830
|
+
or body.get("claim") or "")
|
|
831
|
+
print(f"{rec['id']} {rec['ts'][:16]} {rec['kind']:<15} "
|
|
832
|
+
f"{('[' + tier + ']') if tier else '':<15} "
|
|
833
|
+
f"{rec['author'].get('harness')}: {str(text)[:120]}")
|
|
834
|
+
if len(records) < args.limit and page.get("next_cursor"):
|
|
835
|
+
print(f"the served ledger pages at {len(records)} records; there are more", file=sys.stderr)
|
|
836
|
+
return 0
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _remote_run(args, client) -> int:
|
|
840
|
+
"""Run the command here and file the receipt there.
|
|
841
|
+
|
|
842
|
+
The server never executes this: a receipt records what happened on the machine that
|
|
843
|
+
ran the command, and that machine is this one. Only the finished body crosses, with
|
|
844
|
+
no author on it, so the server stamps the token's identity and computes the id.
|
|
845
|
+
"""
|
|
846
|
+
if args.sign:
|
|
847
|
+
return _refuse("run", ["--sign"], "signing reads the keys registered in the local ledger")
|
|
848
|
+
cmd = list(args.cmd or [])
|
|
849
|
+
if cmd and cmd[0] == "--":
|
|
850
|
+
cmd = cmd[1:]
|
|
851
|
+
if not cmd:
|
|
852
|
+
print("usage: agent-memory run [--cwd DIR] -- <command...>", file=sys.stderr)
|
|
853
|
+
return 2
|
|
854
|
+
if cmd[0].startswith("-"):
|
|
855
|
+
print(f"usage: agent-memory run [--cwd DIR] -- <command...>; {cmd[0]!r} looks like an option, "
|
|
856
|
+
"put agent-memory's own options before the --", file=sys.stderr)
|
|
857
|
+
return 2
|
|
858
|
+
# no local config to read a redaction list from: the built-in patterns are what
|
|
859
|
+
# scrubs this body, and they run before the record exists either way
|
|
860
|
+
body = ev.run_and_capture(cmd if len(cmd) > 1 or not args.shell else cmd[0], cwd=args.cwd or os.getcwd(),
|
|
861
|
+
shell=args.shell, timeout=args.timeout)
|
|
862
|
+
if args.assurance:
|
|
863
|
+
body["assurance"] = args.assurance
|
|
864
|
+
if args.no_rerun:
|
|
865
|
+
body["rerun"] = False
|
|
866
|
+
rec, _ = client.post_record({"kind": "evidence", "body": body})
|
|
867
|
+
body = rec["body"]
|
|
868
|
+
out = {"id": rec["id"], "kind": body["kind"], "assurance": body.get("assurance"),
|
|
869
|
+
"exit": body["exit"], "duration_ms": body["duration_ms"]}
|
|
870
|
+
if body.get("redacted"):
|
|
871
|
+
out["redacted"] = body["redacted"]
|
|
872
|
+
if body.get("start_error"):
|
|
873
|
+
out["start_error"] = body["start_error"]
|
|
874
|
+
if body.get("rerun") is False:
|
|
875
|
+
out["rerun"] = False
|
|
876
|
+
if body["kind"] == "test":
|
|
877
|
+
out.update({"passed": body["passed"], "failed": body["failed"], "summary": body.get("summary")})
|
|
878
|
+
if not args.quiet:
|
|
879
|
+
sys.stderr.write(body["stdout_tail"][-1500:])
|
|
880
|
+
sys.stderr.write(body["stderr_tail"][-800:])
|
|
881
|
+
if body["stdout_tail"] or body["stderr_tail"]:
|
|
882
|
+
sys.stderr.write("\n")
|
|
883
|
+
_print(out)
|
|
884
|
+
return 0 if body["exit"] == 0 else 1
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _remote_verify(args, client) -> int:
|
|
888
|
+
"""Ask the server to re-check a claim on its own host.
|
|
889
|
+
|
|
890
|
+
Whether it may is the operator's decision, not ours: without --allow-run the answer
|
|
891
|
+
is a 403 whose message says to run the re-check where the receipts were made.
|
|
892
|
+
"""
|
|
893
|
+
if args.sign:
|
|
894
|
+
return _refuse("verify", ["--sign"], "signing reads the keys registered in the local ledger")
|
|
895
|
+
refused = _given(("--verdict", args.verdict), ("--map", args.map),
|
|
896
|
+
("--timeout", args.timeout != VERIFY_TIMEOUT))
|
|
897
|
+
if refused:
|
|
898
|
+
return _refuse("verify", refused, "POST /verify/{id} carries none of them: the server files what "
|
|
899
|
+
"its own re-check found, under the identity of the token, with "
|
|
900
|
+
"its own path map and its own per-binding timeout")
|
|
901
|
+
out = client.post_verify(args.claim, strict=args.strict, note=args.note, lens=args.lens)
|
|
902
|
+
_print(out)
|
|
903
|
+
return 0 if out.get("verdict") != "refuted" else 1
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _remote_compile(args, client) -> int:
|
|
907
|
+
text, receipt = client.compile(role=args.role, mode=args.mode, task=args.task, contract=args.contract,
|
|
908
|
+
tags=args.tag or (), budget=args.budget, round_no=args.round,
|
|
909
|
+
lens=args.lens, render=args.render, receipt=not args.no_receipt,
|
|
910
|
+
include_tainted=args.include_tainted,
|
|
911
|
+
reliability=not args.no_reliability)
|
|
912
|
+
if args.output:
|
|
913
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
914
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
915
|
+
else:
|
|
916
|
+
_print(text)
|
|
917
|
+
if receipt: # what the server recorded about this reader, whatever the renderer was
|
|
918
|
+
print(f"compilation receipt {receipt}", file=sys.stderr)
|
|
919
|
+
return 0
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _remote_audit(args, client) -> int:
|
|
923
|
+
refused = _given(("--recheck", args.recheck), ("--map", args.map))
|
|
924
|
+
if refused:
|
|
925
|
+
return _refuse("audit", refused, "GET /audit re-runs nothing; re-check with `verify`, or audit "
|
|
926
|
+
"where the ledger is")
|
|
927
|
+
rep = client.audit()
|
|
928
|
+
if args.json:
|
|
929
|
+
_print(rep)
|
|
930
|
+
else:
|
|
931
|
+
# the counters crossed the wire as plain objects, and the report ranks two of them
|
|
932
|
+
for key in ("authors", "vendors"):
|
|
933
|
+
rep[key] = Counter(rep.get(key) or {})
|
|
934
|
+
_print(audit_mod.format_report(rep))
|
|
935
|
+
return 0
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _remote_reliability(args, client) -> int:
|
|
939
|
+
rep = client.reliability(by=args.by, since=args.since, n_min=args.n_min)
|
|
940
|
+
_print(rep if args.json else rel_mod.format_report(rep))
|
|
941
|
+
return 0
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _remote_show(args, client) -> int:
|
|
945
|
+
_print(client.get_record(args.id))
|
|
946
|
+
return 0
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _remote_search(args, client) -> int:
|
|
950
|
+
for row in client.search(" ".join(args.query), kinds=args.kind or (), limit=args.limit):
|
|
951
|
+
text = str(row.get("body") or "")
|
|
952
|
+
print(f"{row['id']} {row['ts'][:10]} [{row.get('tier') or '-'}] {row.get('by')}: {text[:160]}")
|
|
953
|
+
return 0
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _remote_contract_status(args, client) -> int:
|
|
957
|
+
_print(client.contract_status(args.id))
|
|
958
|
+
return 0
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _remote_whoami(args, client) -> int:
|
|
962
|
+
"""Who the token says we are, and which ledger that is: `memory_whoami` over MCP
|
|
963
|
+
(there is no GET /whoami) plus what /health says about the replica answering."""
|
|
964
|
+
out = dict(client.whoami())
|
|
965
|
+
# the tool answered for the MCP session it was asked through, and that session was
|
|
966
|
+
# deleted a moment later: what a record filed from here would carry is our own
|
|
967
|
+
out["session"] = client.session or "unknown"
|
|
968
|
+
out["session_source"] = "client" if client.session else "unknown"
|
|
969
|
+
out["remote"] = client.base
|
|
970
|
+
out["server"] = client.health()
|
|
971
|
+
_print(out)
|
|
972
|
+
return 0
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
REMOTE_COMMANDS = {
|
|
976
|
+
"claim": _remote_claim, "run": _remote_run, "verify": _remote_verify, "compile": _remote_compile,
|
|
977
|
+
"audit": _remote_audit, "reliability": _remote_reliability, "show": _remote_show,
|
|
978
|
+
"search": _remote_search, "contract status": _remote_contract_status, "whoami": _remote_whoami,
|
|
979
|
+
"retract": _remote_retract, "list": _remote_list,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def run_remote(args, base: str) -> int:
|
|
984
|
+
"""Dispatch one command against a served ledger.
|
|
985
|
+
|
|
986
|
+
A command the replica cannot answer refuses here rather than falling through to a
|
|
987
|
+
local ledger: with --remote given, a ledger under this directory -- if there is one
|
|
988
|
+
at all -- is not the one the user meant.
|
|
989
|
+
"""
|
|
990
|
+
command = args.command if args.command != "contract" else f"contract {args.action}"
|
|
991
|
+
fn = REMOTE_COMMANDS.get(command)
|
|
992
|
+
if fn is None:
|
|
993
|
+
why = ("it needs the local ledger" if args.command in LOCAL_ONLY else
|
|
994
|
+
"the HTTP API has no route for it")
|
|
995
|
+
print(f"{command}: not available over --remote; {why}. Run it where the ledger is, "
|
|
996
|
+
f"or drop --remote.", file=sys.stderr)
|
|
997
|
+
return 2
|
|
998
|
+
overridden = [f"--{name.replace('_', '-')}" for name in IDENTITY_OVERRIDES if getattr(args, name, None)]
|
|
999
|
+
if overridden:
|
|
1000
|
+
return _refuse(command, overridden, "the token decides every identity field of every record it "
|
|
1001
|
+
"files; --session is the one thing a client may declare")
|
|
1002
|
+
token = remote_mod.resolve_token(args.token)
|
|
1003
|
+
if token and remote_mod.in_the_clear(base):
|
|
1004
|
+
# `serve` terminates no TLS: without a proxy in front of it the token is on the wire
|
|
1005
|
+
print(f"warning: the token goes to {base} in clear text; put TLS in front of the server",
|
|
1006
|
+
file=sys.stderr)
|
|
1007
|
+
client = remote_mod.Client(base, token=token, session=_remote_session(args))
|
|
1008
|
+
try:
|
|
1009
|
+
return fn(args, client)
|
|
1010
|
+
except remote_mod.RemoteError as exc:
|
|
1011
|
+
if exc.status == 422: # a filing rule, in the server's own words
|
|
1012
|
+
print(f"rejected: {exc.message}", file=sys.stderr)
|
|
1013
|
+
return 3
|
|
1014
|
+
print(f"{command}: {exc}", file=sys.stderr)
|
|
1015
|
+
return 2
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
# --------------------------------------------------------------------------- parser
|
|
1019
|
+
|
|
1020
|
+
def _add_identity(p: argparse.ArgumentParser) -> None:
|
|
1021
|
+
g = p.add_argument_group("identity")
|
|
1022
|
+
g.add_argument("--as-human", action="store_true", help="file as a human author (default when not inside a harness)")
|
|
1023
|
+
g.add_argument("--harness", help="override detected harness (claude-code, codex-cli, gemini-cli, ollama, ...)")
|
|
1024
|
+
g.add_argument("--model", help="model name for lineage (e.g. claude-opus-5, qwen3.6-35b)")
|
|
1025
|
+
g.add_argument("--session", help="session id override")
|
|
1026
|
+
g.add_argument("--user", help="user id override")
|
|
1027
|
+
g.add_argument("--scope", choices=["user", "project", "org"], default="project")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _add_signing(p: argparse.ArgumentParser) -> None:
|
|
1031
|
+
g = p.add_argument_group("signing")
|
|
1032
|
+
g.add_argument("--sign", action="store_true",
|
|
1033
|
+
help="file an ed25519 attestation over the new record (needs strata-agent-memory[sign])")
|
|
1034
|
+
g.add_argument("--key", help=f"key id to sign with (default: ${signing.KEY_ENV}, or the only registered key)")
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _add_map(p: argparse.ArgumentParser) -> None:
|
|
1038
|
+
p.add_argument("--map", action="append", metavar="FROM=TO",
|
|
1039
|
+
help="translate an absolute path recorded in a receipt before re-checking it, so a "
|
|
1040
|
+
"second machine can answer for it (repeatable; merged over config.path_map and "
|
|
1041
|
+
f"${pathmap.ENV_VAR})")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _add_llm_key(p: argparse.ArgumentParser) -> None:
|
|
1045
|
+
g = p.add_argument_group("llm credentials")
|
|
1046
|
+
g.add_argument("--llm-api-key", metavar="KEY",
|
|
1047
|
+
help=f"key for --llm-endpoint (or ${memorizer.API_KEY_ENV}); with neither, no "
|
|
1048
|
+
"Authorization header is sent. $OPENAI_API_KEY is never read: the endpoint "
|
|
1049
|
+
"is whatever you typed, and the transcript goes with the key")
|
|
1050
|
+
g.add_argument("--llm-insecure", action="store_true",
|
|
1051
|
+
help="allow sending the key to a plain http:// endpoint")
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1055
|
+
p = argparse.ArgumentParser(prog="agent-memory", description="Portable, auditable, cross-vendor agent memory.")
|
|
1056
|
+
p.add_argument("--version", action="version", version=f"agent-memory {__version__}")
|
|
1057
|
+
p.add_argument("--remote", metavar="URL",
|
|
1058
|
+
help=f"file through a ledger served by `agent-memory serve` instead of one under "
|
|
1059
|
+
f"this directory (or ${remote_mod.REMOTE_ENV}). Goes before the command: "
|
|
1060
|
+
f"`agent-memory --remote https://host claim ...`. Commands that need the local "
|
|
1061
|
+
f"ledger refuse; `run` still runs here and files the receipt there")
|
|
1062
|
+
p.add_argument("--token", metavar="T",
|
|
1063
|
+
help=f"bearer token for --remote (or ${remote_mod.TOKEN_ENV}): it decides the "
|
|
1064
|
+
f"identity every record it files carries, so --harness, --model, --user and "
|
|
1065
|
+
f"--as-human are refused over --remote")
|
|
1066
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1067
|
+
|
|
1068
|
+
s = sub.add_parser("init", help="create .agent-memory in a project (or add missing policy keys)",
|
|
1069
|
+
description="Create .agent-memory in a project, or add the policy keys an "
|
|
1070
|
+
"older ledger is missing (recheck_allow, recheck_allow_shell, "
|
|
1071
|
+
"redact). Writes two files outside the project when it creates a "
|
|
1072
|
+
f"ledger: ~/{DIR_NAME}/{TRUSTED_REPOS_NAME} (this machine trusts the "
|
|
1073
|
+
"repository's config.json) and, with --org, "
|
|
1074
|
+
f"~/{DIR_NAME}/{TRUSTED_ORG_PATHS_NAME}. An existing ledger is not trusted "
|
|
1075
|
+
"by re-running init: use `agent-memory trust`.")
|
|
1076
|
+
s.add_argument("path", nargs="?")
|
|
1077
|
+
s.add_argument("--project")
|
|
1078
|
+
s.add_argument("--org", help="path to an existing shared org-scope ledger directory")
|
|
1079
|
+
s.add_argument("--user")
|
|
1080
|
+
s.set_defaults(fn=cmd_init)
|
|
1081
|
+
|
|
1082
|
+
s = sub.add_parser("trust", help="trust this repository's config.json on this machine",
|
|
1083
|
+
description="A config.json is committed, so a clone brings it with it: until "
|
|
1084
|
+
"this machine trusts the repository, its recheck_allow, "
|
|
1085
|
+
"recheck_allow_shell, redact, path_map, org_path, signers, "
|
|
1086
|
+
"vendor_map and cross_vendor_required decide nothing. This "
|
|
1087
|
+
f"records the decision in ~/{DIR_NAME}/{TRUSTED_REPOS_NAME}, outside the "
|
|
1088
|
+
"repository.")
|
|
1089
|
+
s.add_argument("path", nargs="?", help="a path inside the checkout (default: the current one)")
|
|
1090
|
+
s.set_defaults(fn=cmd_trust)
|
|
1091
|
+
|
|
1092
|
+
s = sub.add_parser("whoami", help="show the identity records will be filed under")
|
|
1093
|
+
_add_identity(s)
|
|
1094
|
+
s.set_defaults(fn=cmd_whoami)
|
|
1095
|
+
|
|
1096
|
+
s = sub.add_parser("run", help="run a command and file an evidence receipt (prints ev_ id)")
|
|
1097
|
+
s.add_argument("--cwd")
|
|
1098
|
+
s.add_argument("--shell", action="store_true", help="run through the shell (pass one quoted string)")
|
|
1099
|
+
s.add_argument("--timeout", type=float, default=600)
|
|
1100
|
+
s.add_argument("--assurance", choices=["static", "dynamic", "adversarial"],
|
|
1101
|
+
help="override the assurance class of the receipt (default: static for lint/type/check "
|
|
1102
|
+
"commands and bindings, dynamic for anything executed, adversarial for exploit or fuzz harnesses)")
|
|
1103
|
+
s.add_argument("--no-rerun", action="store_true",
|
|
1104
|
+
help="mark the receipt do-not-re-run: no lane, audit or server will execute this "
|
|
1105
|
+
"command again (use it for deploys and migrations; the claim it backs stays "
|
|
1106
|
+
"evidenced and can never reach verified by re-run)")
|
|
1107
|
+
s.add_argument("-q", "--quiet", action="store_true")
|
|
1108
|
+
s.add_argument("cmd", nargs=argparse.REMAINDER)
|
|
1109
|
+
_add_identity(s)
|
|
1110
|
+
_add_signing(s)
|
|
1111
|
+
s.set_defaults(fn=cmd_run)
|
|
1112
|
+
|
|
1113
|
+
s = sub.add_parser("claim", help="file a claim")
|
|
1114
|
+
s.add_argument("type", choices=sorted(CLAIM_TYPES))
|
|
1115
|
+
s.add_argument("statement")
|
|
1116
|
+
s.add_argument("-e", "--evidence", action="append", help="receipt id (ev_...) from `run`")
|
|
1117
|
+
s.add_argument("--commit", nargs="?", const="", help="bind to a commit sha (default HEAD)")
|
|
1118
|
+
s.add_argument("--branch", help="with --commit: require sha to be an ancestor of this branch")
|
|
1119
|
+
s.add_argument("--file", action="append", help="bind to a file's current hash")
|
|
1120
|
+
s.add_argument("--env", action="append", help="bind to the current hash of an environment file "
|
|
1121
|
+
"(lockfile, version manifest): when it changes the claim goes stale, "
|
|
1122
|
+
"not refuted (repeatable)")
|
|
1123
|
+
s.add_argument("--diff", help="bind to `git diff --stat BASE..HEAD`")
|
|
1124
|
+
s.add_argument("--attest", help="human attestation text (humans only)")
|
|
1125
|
+
s.add_argument("--rationale")
|
|
1126
|
+
s.add_argument("--example")
|
|
1127
|
+
s.add_argument("--outcome", choices=["success", "failure", "unknown"])
|
|
1128
|
+
s.add_argument("--source")
|
|
1129
|
+
s.add_argument("--contract")
|
|
1130
|
+
s.add_argument("--expect-exit", type=int, help="the exit code the cited receipts must show (default 0): "
|
|
1131
|
+
"use it to claim a deliberate failure state")
|
|
1132
|
+
s.add_argument("--expect-failed", type=int, help="with --expect-exit: the number of failing tests the cited "
|
|
1133
|
+
"test receipts must show (default 0)")
|
|
1134
|
+
s.add_argument("--confidence", type=float)
|
|
1135
|
+
s.add_argument("--ttl-days", type=float)
|
|
1136
|
+
s.add_argument("--derived-from", action="append")
|
|
1137
|
+
s.add_argument("-t", "--tag", action="append")
|
|
1138
|
+
_add_identity(s)
|
|
1139
|
+
_add_signing(s)
|
|
1140
|
+
s.set_defaults(fn=cmd_claim)
|
|
1141
|
+
|
|
1142
|
+
s = sub.add_parser("verify", help="re-run a claim's evidence and file a verification")
|
|
1143
|
+
s.add_argument("claim")
|
|
1144
|
+
s.add_argument("--verdict", choices=["verified", "refuted", "inconclusive"], help="override the re-check result")
|
|
1145
|
+
s.add_argument("--note")
|
|
1146
|
+
s.add_argument("--lens", help="review lens (correctness, security, spec, performance, operability, or free text); "
|
|
1147
|
+
"stored as the tag lens:<value>")
|
|
1148
|
+
s.add_argument("--strict", action="store_true", help="also require identical stdout hash")
|
|
1149
|
+
s.add_argument("--timeout", type=float, default=VERIFY_TIMEOUT)
|
|
1150
|
+
_add_map(s)
|
|
1151
|
+
_add_identity(s)
|
|
1152
|
+
_add_signing(s)
|
|
1153
|
+
s.set_defaults(fn=cmd_verify)
|
|
1154
|
+
|
|
1155
|
+
s = sub.add_parser("retract", help="retract a claim (taints everything derived from it)")
|
|
1156
|
+
s.add_argument("claim")
|
|
1157
|
+
s.add_argument("reason")
|
|
1158
|
+
_add_identity(s)
|
|
1159
|
+
_add_signing(s)
|
|
1160
|
+
s.set_defaults(fn=cmd_retract)
|
|
1161
|
+
|
|
1162
|
+
s = sub.add_parser("compile", help="compile memory into a context block for a role")
|
|
1163
|
+
s.add_argument("--role", choices=list(ROLES), default="supervisor")
|
|
1164
|
+
s.add_argument("--mode", choices=list(MODES))
|
|
1165
|
+
s.add_argument("--task")
|
|
1166
|
+
s.add_argument("--contract")
|
|
1167
|
+
s.add_argument("-t", "--tag", action="append")
|
|
1168
|
+
s.add_argument("--budget", type=int)
|
|
1169
|
+
s.add_argument("--round", type=int, default=1)
|
|
1170
|
+
s.add_argument("--lens", help="verifier lens: correctness, security, spec, performance, operability, or free text")
|
|
1171
|
+
s.add_argument("--render", choices=sorted(RENDERERS), default="markdown")
|
|
1172
|
+
s.add_argument("--include-tainted", action="store_true")
|
|
1173
|
+
s.add_argument("--no-receipt", action="store_true", help="do not file a compilation receipt")
|
|
1174
|
+
s.add_argument("--no-reliability", action="store_true",
|
|
1175
|
+
help="do not annotate items with measured reliability (and leave the block out of the receipt)")
|
|
1176
|
+
s.add_argument("-o", "--output")
|
|
1177
|
+
_add_identity(s)
|
|
1178
|
+
s.set_defaults(fn=cmd_compile)
|
|
1179
|
+
|
|
1180
|
+
s = sub.add_parser("search", help="keyword search")
|
|
1181
|
+
s.add_argument("query", nargs="+")
|
|
1182
|
+
s.add_argument("--kind", action="append")
|
|
1183
|
+
s.add_argument("--limit", type=int, default=20)
|
|
1184
|
+
s.set_defaults(fn=cmd_search)
|
|
1185
|
+
|
|
1186
|
+
s = sub.add_parser("show", help="show one record with trust info")
|
|
1187
|
+
s.add_argument("id")
|
|
1188
|
+
s.set_defaults(fn=cmd_show)
|
|
1189
|
+
|
|
1190
|
+
s = sub.add_parser("list", help="list records")
|
|
1191
|
+
s.add_argument("--kind", default="claim", help="claim|evidence|verification|retraction|compaction|compilation|contract|contract_update|all")
|
|
1192
|
+
s.add_argument("--limit", type=int, default=50)
|
|
1193
|
+
s.set_defaults(fn=cmd_list)
|
|
1194
|
+
|
|
1195
|
+
s = sub.add_parser("contract", help="task contracts")
|
|
1196
|
+
cs = s.add_subparsers(dest="action", required=True)
|
|
1197
|
+
c = cs.add_parser("create")
|
|
1198
|
+
c.add_argument("title")
|
|
1199
|
+
c.add_argument("task")
|
|
1200
|
+
c.add_argument("--check", action="append", help="acceptance command expecting exit 0 (repeatable)")
|
|
1201
|
+
c.add_argument("--human-check", action="append")
|
|
1202
|
+
c.add_argument("--file-check", action="append")
|
|
1203
|
+
c.add_argument("--acceptance-json", help="path to a JSON list of acceptance checks")
|
|
1204
|
+
c.add_argument("--assign")
|
|
1205
|
+
c.add_argument("--issue", help="GitHub issue URL or number (source of truth for what work exists)")
|
|
1206
|
+
c.add_argument("-t", "--tag", action="append")
|
|
1207
|
+
_add_identity(c)
|
|
1208
|
+
_add_signing(c)
|
|
1209
|
+
c = cs.add_parser("update")
|
|
1210
|
+
c.add_argument("id")
|
|
1211
|
+
c.add_argument("status", choices=["proposed", "accepted", "in_progress", "submitted", "verified", "closed", "rejected"])
|
|
1212
|
+
c.add_argument("--note")
|
|
1213
|
+
c.add_argument("--claims", nargs="*")
|
|
1214
|
+
_add_identity(c)
|
|
1215
|
+
_add_signing(c)
|
|
1216
|
+
c = cs.add_parser("from-issue", help="create a contract from a GitHub issue (needs the gh CLI)")
|
|
1217
|
+
c.add_argument("ref", help="issue URL, owner/repo#N, or a number in this repository")
|
|
1218
|
+
c.add_argument("--check", action="append", help="acceptance command expecting exit 0 (repeatable)")
|
|
1219
|
+
c.add_argument("--human-check", action="append")
|
|
1220
|
+
c.add_argument("--file-check", action="append")
|
|
1221
|
+
c.add_argument("--assign")
|
|
1222
|
+
c.add_argument("-t", "--tag", action="append")
|
|
1223
|
+
_add_identity(c)
|
|
1224
|
+
c = cs.add_parser("sync", help="comment the contract status on its issue; --close closes the issue")
|
|
1225
|
+
c.add_argument("id")
|
|
1226
|
+
c.add_argument("--close", action="store_true",
|
|
1227
|
+
help="close the issue, but only when the contract itself is closed")
|
|
1228
|
+
_add_identity(c)
|
|
1229
|
+
c = cs.add_parser("status")
|
|
1230
|
+
c.add_argument("id")
|
|
1231
|
+
_add_identity(c)
|
|
1232
|
+
c = cs.add_parser("accept", help="run the acceptance checks and file receipts")
|
|
1233
|
+
c.add_argument("id")
|
|
1234
|
+
_add_identity(c)
|
|
1235
|
+
c = cs.add_parser("list")
|
|
1236
|
+
_add_identity(c)
|
|
1237
|
+
s.set_defaults(fn=cmd_contract)
|
|
1238
|
+
|
|
1239
|
+
s = sub.add_parser("audit", help="audit report")
|
|
1240
|
+
s.add_argument("--recheck", action="store_true", help="re-run every evidence binding (slow)")
|
|
1241
|
+
_add_map(s)
|
|
1242
|
+
s.add_argument("--json", action="store_true")
|
|
1243
|
+
s.set_defaults(fn=cmd_audit)
|
|
1244
|
+
|
|
1245
|
+
s = sub.add_parser("recheck", help="the verifier lane: re-run selected claims' receipts and file "
|
|
1246
|
+
"verifications under the running identity")
|
|
1247
|
+
s.add_argument("--only", choices=list(recheck_mod.ONLY_CHOICES), default="unverified",
|
|
1248
|
+
help="unverified (tier evidenced, default), stale, or all (everything but refuted/tainted)")
|
|
1249
|
+
s.add_argument("--type", action="append",
|
|
1250
|
+
help="restrict to these claim types, comma-separated (e.g. outcome,fact)")
|
|
1251
|
+
s.add_argument("--max", type=int, help="stop after this many claims")
|
|
1252
|
+
s.add_argument("--older-than", type=float, metavar="DAYS", help="only claims older than this")
|
|
1253
|
+
s.add_argument("--timeout", type=float, default=600, help="per-binding timeout in seconds")
|
|
1254
|
+
s.add_argument("--min-age-days", type=float, default=recheck_mod.MIN_AGE_DAYS, metavar="DAYS",
|
|
1255
|
+
help="skip a claim this identity already verified more recently than this "
|
|
1256
|
+
"(default 1; 0 disables the skip), so a nightly lane does not file the same "
|
|
1257
|
+
"verdict every night")
|
|
1258
|
+
s.add_argument("--force", action="store_true",
|
|
1259
|
+
help="re-check even what this identity verified within --min-age-days")
|
|
1260
|
+
_add_map(s)
|
|
1261
|
+
s.add_argument("--dry-run", action="store_true",
|
|
1262
|
+
help="execute nothing: print the bindings each selected claim would re-run, "
|
|
1263
|
+
"where, and what the config's allowlist decides about each")
|
|
1264
|
+
s.add_argument("--json", action="store_true")
|
|
1265
|
+
_add_identity(s)
|
|
1266
|
+
s.set_defaults(fn=cmd_recheck)
|
|
1267
|
+
|
|
1268
|
+
s = sub.add_parser("keygen", help="generate an ed25519 signing key and register its public half")
|
|
1269
|
+
s.add_argument("--user", help="the user the key belongs to (default: the detected identity)")
|
|
1270
|
+
s.add_argument("--out", help="directory for the private key (default ~/.agent-memory/keys)")
|
|
1271
|
+
s.set_defaults(fn=cmd_keygen)
|
|
1272
|
+
|
|
1273
|
+
s = sub.add_parser("sign", help="file attestations over existing records")
|
|
1274
|
+
s.add_argument("ids", nargs="+")
|
|
1275
|
+
s.add_argument("--key", help=f"key id to sign with (default: ${signing.KEY_ENV}, or the only registered key)")
|
|
1276
|
+
_add_identity(s)
|
|
1277
|
+
s.set_defaults(fn=cmd_sign)
|
|
1278
|
+
|
|
1279
|
+
s = sub.add_parser("verify-signatures", help="check every attestation in the ledger (exit 3 on any invalid)")
|
|
1280
|
+
s.add_argument("--json", action="store_true")
|
|
1281
|
+
s.set_defaults(fn=cmd_verify_signatures)
|
|
1282
|
+
|
|
1283
|
+
s = sub.add_parser("reliability", help="measured reliability: refutation priors, same-lineage agreement, "
|
|
1284
|
+
"fitted staleness and judgment calibration")
|
|
1285
|
+
s.add_argument("--by", choices=list(rel_mod.VIEWS), default="cell",
|
|
1286
|
+
help="cell (the full table and every section), vendor, type, or author")
|
|
1287
|
+
s.add_argument("--since", help="restrict the events (not the claims) to this date onwards, e.g. 2026-01-01")
|
|
1288
|
+
s.add_argument("--n-min", type=int, help="override reliability.n_min (below it the answer is insufficient history)")
|
|
1289
|
+
s.add_argument("--json", action="store_true")
|
|
1290
|
+
s.set_defaults(fn=cmd_reliability)
|
|
1291
|
+
|
|
1292
|
+
s = sub.add_parser("compact", help="compact older claims into an ancestry-preserving summary")
|
|
1293
|
+
s.add_argument("--older-than", type=float, help="days")
|
|
1294
|
+
s.add_argument("--contract")
|
|
1295
|
+
s.add_argument("--ids", nargs="*")
|
|
1296
|
+
s.add_argument("-t", "--tag", action="append")
|
|
1297
|
+
s.add_argument("--llm-endpoint", help="OpenAI-compatible base URL for an optional summarization pass")
|
|
1298
|
+
s.add_argument("--llm-model")
|
|
1299
|
+
_add_llm_key(s)
|
|
1300
|
+
s.add_argument("--dry-run", action="store_true")
|
|
1301
|
+
_add_identity(s)
|
|
1302
|
+
s.set_defaults(fn=cmd_compact)
|
|
1303
|
+
|
|
1304
|
+
s = sub.add_parser("memorize", help="extract claims from a transcript with any OpenAI-compatible model")
|
|
1305
|
+
s.add_argument("transcript", help="path or - for stdin")
|
|
1306
|
+
s.add_argument("--llm-endpoint", required=True)
|
|
1307
|
+
s.add_argument("--llm-model", required=True)
|
|
1308
|
+
_add_llm_key(s)
|
|
1309
|
+
s.add_argument("--max", type=int, default=20)
|
|
1310
|
+
s.add_argument("--dry-run", action="store_true")
|
|
1311
|
+
_add_identity(s)
|
|
1312
|
+
s.set_defaults(fn=cmd_memorize)
|
|
1313
|
+
|
|
1314
|
+
s = sub.add_parser("export", help="export the ledger in a standard format")
|
|
1315
|
+
s.add_argument("--format", choices=list(export_mod.FORMATS), default="prov-json",
|
|
1316
|
+
help="prov-json (W3C PROV for auditors), in-toto (one unsigned Statement per receipt), "
|
|
1317
|
+
"claim-evidence-map (markdown table of claims, proofs and verdicts)")
|
|
1318
|
+
s.add_argument("--ids", nargs="*", help="limit the export to these record ids")
|
|
1319
|
+
s.add_argument("-o", "--output")
|
|
1320
|
+
s.set_defaults(fn=cmd_export)
|
|
1321
|
+
|
|
1322
|
+
s = sub.add_parser("fsck", help="validate the ledger; --rewrite drops duplicate lines")
|
|
1323
|
+
s.add_argument("--rewrite", action="store_true")
|
|
1324
|
+
s.add_argument("--force", action="store_true",
|
|
1325
|
+
help="with --rewrite, also drop corrupt lines. Without it they are kept and the "
|
|
1326
|
+
"report says where each one is: a torn line can hold most of a record whose "
|
|
1327
|
+
"author was told it was filed")
|
|
1328
|
+
s.set_defaults(fn=cmd_fsck)
|
|
1329
|
+
|
|
1330
|
+
s = sub.add_parser("redact", help="erase a filed record's content, leaving a tombstone with its id, "
|
|
1331
|
+
"author and provenance (a captured secret, a name that must not be "
|
|
1332
|
+
"in a shared repository)")
|
|
1333
|
+
s.add_argument("id")
|
|
1334
|
+
s.add_argument("--reason", required=True, help="why it was removed; this stays in the ledger")
|
|
1335
|
+
_add_identity(s)
|
|
1336
|
+
s.set_defaults(fn=cmd_redact)
|
|
1337
|
+
|
|
1338
|
+
s = sub.add_parser("hook", help="harness hook adapter (reads hook JSON on stdin)")
|
|
1339
|
+
s.add_argument("event", choices=["session-start", "subagent-start", "stop"])
|
|
1340
|
+
s.add_argument("--harness", choices=["claude-code", "codex-cli", "gemini-cli", "cursor"], default=None,
|
|
1341
|
+
help="whose hook JSON is on stdin (default: the harness detected from the environment, "
|
|
1342
|
+
"else claude-code)")
|
|
1343
|
+
s.add_argument("--nudge", action="store_true",
|
|
1344
|
+
help="on stop, hand the reminder back to the agent once where the harness documents a "
|
|
1345
|
+
"continuation mechanism (Codex, Cursor); elsewhere it only reaches the user")
|
|
1346
|
+
s.set_defaults(fn=cmd_hook)
|
|
1347
|
+
|
|
1348
|
+
s = sub.add_parser("mcp", help="serve the MCP server over stdio")
|
|
1349
|
+
s.add_argument("--root")
|
|
1350
|
+
s.set_defaults(fn=cmd_mcp)
|
|
1351
|
+
|
|
1352
|
+
s = sub.add_parser("serve", help="serve this ledger over HTTP and remote MCP (a disposable replica)")
|
|
1353
|
+
s.add_argument("--host", default="127.0.0.1", help="a non-loopback host needs a tokens file")
|
|
1354
|
+
s.add_argument("--port", type=int, default=8765)
|
|
1355
|
+
s.add_argument("--tokens", help="JSON file mapping bearer tokens to authors "
|
|
1356
|
+
"(default .agent-memory/private/serve-tokens.json)")
|
|
1357
|
+
s.add_argument("--allow-origin", action="append", help="an extra allowed Origin (repeatable); loopback "
|
|
1358
|
+
"origins are always allowed")
|
|
1359
|
+
s.add_argument("--allow-run", action="store_true",
|
|
1360
|
+
help="let memory_run and POST /verify run commands on this host")
|
|
1361
|
+
s.add_argument("--recheck-every", type=float, metavar="SECONDS",
|
|
1362
|
+
help="run the recheck lane on this interval under the server identity")
|
|
1363
|
+
s.add_argument("--lane-max", type=int, default=serve_defaults()["lane_max"], metavar="N",
|
|
1364
|
+
help="claims one scheduled lane run may re-check (default %(default)s)")
|
|
1365
|
+
s.add_argument("--lane-timeout", type=float, default=serve_defaults()["lane_timeout"], metavar="SECONDS",
|
|
1366
|
+
help="per-binding timeout in the scheduled lane (default %(default)s)")
|
|
1367
|
+
s.add_argument("--lane-older-than", type=float, default=serve_defaults()["lane_older_than"], metavar="DAYS",
|
|
1368
|
+
help="only re-check claims last verified this long ago (default %(default)s)")
|
|
1369
|
+
s.add_argument("--git-sync-every", type=float, metavar="SECONDS",
|
|
1370
|
+
help="commit, pull --rebase and push the ledger on this interval")
|
|
1371
|
+
s.add_argument("--rebuild", action="store_true", help="rebuild the SQLite index from the shards")
|
|
1372
|
+
s.add_argument("--json-logs", action="store_true", help="write the request log to stdout instead of stderr")
|
|
1373
|
+
s.add_argument("--root")
|
|
1374
|
+
s.set_defaults(fn=cmd_serve)
|
|
1375
|
+
return p
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1379
|
+
parser = build_parser()
|
|
1380
|
+
args = parser.parse_args(argv)
|
|
1381
|
+
try:
|
|
1382
|
+
base = remote_mod.resolve_base(args.remote)
|
|
1383
|
+
except ValueError as exc: # a URL that is not one: a typo, not a server that is down
|
|
1384
|
+
print(f"--remote: {exc}", file=sys.stderr)
|
|
1385
|
+
return 2
|
|
1386
|
+
try:
|
|
1387
|
+
if base:
|
|
1388
|
+
return run_remote(args, base)
|
|
1389
|
+
return int(args.fn(args) or 0)
|
|
1390
|
+
except FileNotFoundError as exc:
|
|
1391
|
+
print(str(exc), file=sys.stderr)
|
|
1392
|
+
return 2
|
|
1393
|
+
except ConfigError as exc: # a config.json nobody can parse is a message, not a traceback
|
|
1394
|
+
print(str(exc), file=sys.stderr)
|
|
1395
|
+
return 2
|
|
1396
|
+
except ValidationError as exc:
|
|
1397
|
+
print(f"rejected: {exc}", file=sys.stderr)
|
|
1398
|
+
return 3
|
|
1399
|
+
except KeyError as exc:
|
|
1400
|
+
print(f"not found: {exc}", file=sys.stderr)
|
|
1401
|
+
return 2
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
if __name__ == "__main__":
|
|
1405
|
+
sys.exit(main())
|