loki-mode 7.8.2 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/proof-generator.py +690 -0
- package/autonomy/lib/proof-template.html +803 -0
- package/autonomy/lib/proof_redact.py +297 -0
- package/autonomy/loki +313 -2
- package/autonomy/run.sh +36 -0
- package/bin/loki +11 -3
- package/completions/_loki +9 -0
- package/completions/loki.bash +12 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +90 -0
- package/dashboard/static/proofs.html +119 -0
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +233 -170
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Standalone proof-of-run generator for Loki Mode (R1).
|
|
3
|
+
|
|
4
|
+
Single implementation called by both routes:
|
|
5
|
+
- bash: autonomy/run.sh generate_proof_of_run() via python3
|
|
6
|
+
- Bun: loki-ts/src/runner/proof.ts via spawn
|
|
7
|
+
|
|
8
|
+
Assembles the frozen proof.json schema v1.0 from .loki/ state, runs the
|
|
9
|
+
redaction chokepoint exactly once, computes an integrity hash, and writes
|
|
10
|
+
.loki/proofs/<run_id>/proof.json plus a self-contained index.html.
|
|
11
|
+
|
|
12
|
+
Design rules (R1-proof-of-run-PLAN.md):
|
|
13
|
+
- Redaction runs once on the assembled dict BEFORE serialization.
|
|
14
|
+
- The generator REFUSES to emit if redaction did not run.
|
|
15
|
+
- HTML is built only from the redacted dict.
|
|
16
|
+
- Catch all exceptions; never raise to the caller. Print one warning line.
|
|
17
|
+
- Idempotent: re-running for the same run_id overwrites cleanly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
import string
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
SCHEMA_VERSION = "1.0"
|
|
31
|
+
|
|
32
|
+
# Make proof_redact importable regardless of cwd.
|
|
33
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
34
|
+
if _HERE not in sys.path:
|
|
35
|
+
sys.path.insert(0, _HERE)
|
|
36
|
+
|
|
37
|
+
import proof_redact # noqa: E402
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# small helpers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _utc_now_iso():
|
|
45
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_json(path, default=None):
|
|
49
|
+
try:
|
|
50
|
+
with open(path, "r") as f:
|
|
51
|
+
return json.load(f)
|
|
52
|
+
except Exception:
|
|
53
|
+
return default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_text(path, default=""):
|
|
57
|
+
try:
|
|
58
|
+
with open(path, "r", errors="replace") as f:
|
|
59
|
+
return f.read()
|
|
60
|
+
except Exception:
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _gen_run_id():
|
|
65
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
66
|
+
rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
67
|
+
return ts + "-" + rand
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_int(v, default=0):
|
|
71
|
+
try:
|
|
72
|
+
return int(v)
|
|
73
|
+
except Exception:
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _to_float(v, default=0.0):
|
|
78
|
+
try:
|
|
79
|
+
return float(v)
|
|
80
|
+
except Exception:
|
|
81
|
+
return default
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# data collection
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _collect_efficiency(loki_dir):
|
|
89
|
+
"""Sum cost + tokens across .loki/metrics/efficiency/iteration-*.json.
|
|
90
|
+
|
|
91
|
+
Returns cost dict and a best-effort model name (last non-empty seen).
|
|
92
|
+
|
|
93
|
+
Credibility: cost.usd is set to None when NO valid efficiency record was
|
|
94
|
+
read (cost was never collected for this run). A skeptic seeing "$0.00" on
|
|
95
|
+
HN assumes the artifact is fake; "cost not recorded" is the honest signal.
|
|
96
|
+
A genuine 0.0 (records existed but summed to zero) is preserved as 0.0.
|
|
97
|
+
"""
|
|
98
|
+
cost = {
|
|
99
|
+
"usd": 0.0,
|
|
100
|
+
"input_tokens": 0,
|
|
101
|
+
"output_tokens": 0,
|
|
102
|
+
"cache_read_tokens": 0,
|
|
103
|
+
"cache_creation_tokens": 0,
|
|
104
|
+
}
|
|
105
|
+
model = ""
|
|
106
|
+
collected = False
|
|
107
|
+
eff_dir = os.path.join(loki_dir, "metrics", "efficiency")
|
|
108
|
+
try:
|
|
109
|
+
names = sorted(os.listdir(eff_dir))
|
|
110
|
+
except Exception:
|
|
111
|
+
names = []
|
|
112
|
+
for name in names:
|
|
113
|
+
if not (name.startswith("iteration-") and name.endswith(".json")):
|
|
114
|
+
continue
|
|
115
|
+
rec = _read_json(os.path.join(eff_dir, name), default=None)
|
|
116
|
+
if not isinstance(rec, dict):
|
|
117
|
+
continue
|
|
118
|
+
collected = True
|
|
119
|
+
cost["usd"] += _to_float(rec.get("cost_usd"))
|
|
120
|
+
cost["input_tokens"] += _to_int(rec.get("input_tokens"))
|
|
121
|
+
cost["output_tokens"] += _to_int(rec.get("output_tokens"))
|
|
122
|
+
cost["cache_read_tokens"] += _to_int(rec.get("cache_read_tokens"))
|
|
123
|
+
cost["cache_creation_tokens"] += _to_int(rec.get("cache_creation_tokens"))
|
|
124
|
+
if rec.get("model"):
|
|
125
|
+
model = str(rec.get("model"))
|
|
126
|
+
if collected:
|
|
127
|
+
# Round usd to a sane precision but keep it precise (anti-pattern:
|
|
128
|
+
# round suspiciously-clean numbers). 4 decimals preserves odd values.
|
|
129
|
+
cost["usd"] = round(cost["usd"], 4)
|
|
130
|
+
else:
|
|
131
|
+
# No efficiency files were read: cost was not collected for this run.
|
|
132
|
+
cost["usd"] = None
|
|
133
|
+
return cost, model
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _collect_council(loki_dir):
|
|
137
|
+
state = _read_json(os.path.join(loki_dir, "council", "state.json"), default={})
|
|
138
|
+
if not isinstance(state, dict):
|
|
139
|
+
state = {}
|
|
140
|
+
enabled = bool(state.get("enabled", False))
|
|
141
|
+
verdicts = state.get("verdicts") or []
|
|
142
|
+
final_verdict = ""
|
|
143
|
+
if isinstance(verdicts, list) and verdicts:
|
|
144
|
+
last = verdicts[-1]
|
|
145
|
+
if isinstance(last, dict):
|
|
146
|
+
final_verdict = str(last.get("verdict") or last.get("decision") or "")
|
|
147
|
+
else:
|
|
148
|
+
final_verdict = str(last)
|
|
149
|
+
threshold = state.get("threshold")
|
|
150
|
+
if threshold is None:
|
|
151
|
+
threshold = state.get("approval_threshold")
|
|
152
|
+
|
|
153
|
+
reviewers = []
|
|
154
|
+
votes_dir = os.path.join(loki_dir, "council", "votes")
|
|
155
|
+
try:
|
|
156
|
+
vote_files = sorted(os.listdir(votes_dir))
|
|
157
|
+
except Exception:
|
|
158
|
+
vote_files = []
|
|
159
|
+
for vf in vote_files:
|
|
160
|
+
if not vf.endswith(".json"):
|
|
161
|
+
continue
|
|
162
|
+
rec = _read_json(os.path.join(votes_dir, vf), default=None)
|
|
163
|
+
if not isinstance(rec, dict):
|
|
164
|
+
continue
|
|
165
|
+
reviewers.append({
|
|
166
|
+
"role": str(rec.get("role") or rec.get("reviewer") or ""),
|
|
167
|
+
"vote": str(rec.get("vote") or rec.get("decision") or ""),
|
|
168
|
+
# Full text here; truncation to <=300 happens AFTER redaction so a
|
|
169
|
+
# secret straddling the cap cannot be sliced into a sub-pattern
|
|
170
|
+
# fragment that escapes the redactor.
|
|
171
|
+
"summary": str(rec.get("summary") or rec.get("rationale") or ""),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"enabled": enabled,
|
|
176
|
+
"final_verdict": final_verdict,
|
|
177
|
+
"threshold": threshold,
|
|
178
|
+
"reviewers": reviewers,
|
|
179
|
+
"findings_link": None,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _collect_quality_gates(loki_dir):
|
|
184
|
+
gates_raw = _read_json(
|
|
185
|
+
os.path.join(loki_dir, "state", "quality-gates.json"), default=None
|
|
186
|
+
)
|
|
187
|
+
gates = []
|
|
188
|
+
passed = 0
|
|
189
|
+
total = 0
|
|
190
|
+
if isinstance(gates_raw, dict):
|
|
191
|
+
for name, val in gates_raw.items():
|
|
192
|
+
status = "unknown"
|
|
193
|
+
if isinstance(val, bool):
|
|
194
|
+
status = "passed" if val else "failed"
|
|
195
|
+
elif isinstance(val, dict):
|
|
196
|
+
if "passed" in val:
|
|
197
|
+
status = "passed" if val.get("passed") else "failed"
|
|
198
|
+
elif "status" in val:
|
|
199
|
+
status = str(val.get("status"))
|
|
200
|
+
else:
|
|
201
|
+
status = str(val)
|
|
202
|
+
gates.append({"name": str(name), "status": status})
|
|
203
|
+
total += 1
|
|
204
|
+
if status == "passed":
|
|
205
|
+
passed += 1
|
|
206
|
+
return {"passed": passed, "total": total, "gates": gates}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _git_diffstat(target_dir, include_diffs):
|
|
210
|
+
"""Return (files_changed dict, diffs list|None).
|
|
211
|
+
|
|
212
|
+
base = $_LOKI_ITER_START_SHA, else HEAD~1. Non-git -> empty.
|
|
213
|
+
"""
|
|
214
|
+
empty = {"count": 0, "insertions": 0, "deletions": 0, "files": []}
|
|
215
|
+
|
|
216
|
+
def _git(args):
|
|
217
|
+
try:
|
|
218
|
+
out = subprocess.run(
|
|
219
|
+
["git", "-C", target_dir] + args,
|
|
220
|
+
capture_output=True, text=True, timeout=30,
|
|
221
|
+
)
|
|
222
|
+
if out.returncode != 0:
|
|
223
|
+
return None
|
|
224
|
+
return out.stdout
|
|
225
|
+
except Exception:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Confirm we are in a git repo.
|
|
229
|
+
if _git(["rev-parse", "--is-inside-work-tree"]) is None:
|
|
230
|
+
return empty, (None if not include_diffs else None)
|
|
231
|
+
|
|
232
|
+
base = os.environ.get("_LOKI_ITER_START_SHA", "").strip()
|
|
233
|
+
if not base:
|
|
234
|
+
base = "HEAD~1"
|
|
235
|
+
|
|
236
|
+
numstat = _git(["diff", "--numstat", base, "HEAD"])
|
|
237
|
+
if numstat is None:
|
|
238
|
+
# base may be invalid (shallow / first commit); fall back to HEAD only.
|
|
239
|
+
numstat = _git(["diff", "--numstat", "HEAD"])
|
|
240
|
+
if numstat is None:
|
|
241
|
+
return empty, (None if not include_diffs else None)
|
|
242
|
+
|
|
243
|
+
files = []
|
|
244
|
+
ins_total = 0
|
|
245
|
+
del_total = 0
|
|
246
|
+
for line in numstat.splitlines():
|
|
247
|
+
parts = line.split("\t")
|
|
248
|
+
if len(parts) < 3:
|
|
249
|
+
continue
|
|
250
|
+
ins_s, del_s, path = parts[0], parts[1], parts[2]
|
|
251
|
+
ins = _to_int(ins_s) if ins_s != "-" else 0
|
|
252
|
+
dele = _to_int(del_s) if del_s != "-" else 0
|
|
253
|
+
ins_total += ins
|
|
254
|
+
del_total += dele
|
|
255
|
+
files.append({
|
|
256
|
+
"path": path,
|
|
257
|
+
"insertions": ins,
|
|
258
|
+
"deletions": dele,
|
|
259
|
+
"status": "binary" if ins_s == "-" else "modified",
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
files_changed = {
|
|
263
|
+
"count": len(files),
|
|
264
|
+
"insertions": ins_total,
|
|
265
|
+
"deletions": del_total,
|
|
266
|
+
"files": files,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
diffs = None
|
|
270
|
+
if include_diffs:
|
|
271
|
+
diffs = []
|
|
272
|
+
patch = _git(["diff", base, "HEAD"])
|
|
273
|
+
if patch is None:
|
|
274
|
+
patch = _git(["diff", "HEAD"])
|
|
275
|
+
if patch:
|
|
276
|
+
# Split per file on the diff --git markers, preserving the header.
|
|
277
|
+
chunks = []
|
|
278
|
+
current = []
|
|
279
|
+
for line in patch.splitlines(keepends=True):
|
|
280
|
+
if line.startswith("diff --git ") and current:
|
|
281
|
+
chunks.append("".join(current))
|
|
282
|
+
current = [line]
|
|
283
|
+
else:
|
|
284
|
+
current.append(line)
|
|
285
|
+
if current:
|
|
286
|
+
chunks.append("".join(current))
|
|
287
|
+
for chunk in chunks:
|
|
288
|
+
# Best-effort path extraction from the "diff --git a/x b/x" line.
|
|
289
|
+
p = ""
|
|
290
|
+
first = chunk.splitlines()[0] if chunk else ""
|
|
291
|
+
bits = first.split(" b/")
|
|
292
|
+
if len(bits) == 2:
|
|
293
|
+
p = bits[1].strip()
|
|
294
|
+
diffs.append({"path": p, "patch": chunk})
|
|
295
|
+
|
|
296
|
+
return files_changed, diffs
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _collect_iterations(loki_dir):
|
|
300
|
+
completed = _read_json(os.path.join(loki_dir, "queue", "completed.json"), default=[])
|
|
301
|
+
failed = _read_json(os.path.join(loki_dir, "queue", "failed.json"), default=[])
|
|
302
|
+
n_completed = len(completed) if isinstance(completed, list) else 0
|
|
303
|
+
n_failed = len(failed) if isinstance(failed, list) else 0
|
|
304
|
+
count = _to_int(os.environ.get("ITERATION_COUNT"), n_completed + n_failed)
|
|
305
|
+
if count < n_completed + n_failed:
|
|
306
|
+
count = n_completed + n_failed
|
|
307
|
+
return {"count": count, "succeeded": n_completed, "failed": n_failed}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _collect_spec(loki_dir, target_dir):
|
|
311
|
+
"""Return spec dict {source, brief}. brief truncated to 600 chars."""
|
|
312
|
+
prd_path = os.environ.get("PRD_PATH", "").strip()
|
|
313
|
+
source = ""
|
|
314
|
+
brief = ""
|
|
315
|
+
if prd_path and os.path.isfile(prd_path):
|
|
316
|
+
source = prd_path
|
|
317
|
+
brief = _read_text(prd_path)
|
|
318
|
+
else:
|
|
319
|
+
gen = os.path.join(loki_dir, "generated-prd.md")
|
|
320
|
+
if os.path.isfile(gen):
|
|
321
|
+
source = gen
|
|
322
|
+
brief = _read_text(gen)
|
|
323
|
+
else:
|
|
324
|
+
source = "codebase-analysis"
|
|
325
|
+
brief = ""
|
|
326
|
+
# Full brief here; the <=600 cap is applied AFTER redaction in generate()
|
|
327
|
+
# so a secret straddling the cap cannot be sliced into an under-length
|
|
328
|
+
# fragment that bypasses the redactor.
|
|
329
|
+
return {"source": source, "brief": brief}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _collect_meta(loki_dir, repo_root):
|
|
333
|
+
orch = _read_json(
|
|
334
|
+
os.path.join(loki_dir, "state", "orchestrator.json"), default={}
|
|
335
|
+
)
|
|
336
|
+
if not isinstance(orch, dict):
|
|
337
|
+
orch = {}
|
|
338
|
+
started_at = str(orch.get("startedAt") or "")
|
|
339
|
+
version = str(orch.get("version") or "")
|
|
340
|
+
if not version and repo_root:
|
|
341
|
+
version = _read_text(os.path.join(repo_root, "VERSION")).strip()
|
|
342
|
+
return started_at, version
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _wall_clock_sec(started_at, generated_at):
|
|
346
|
+
def _parse(s):
|
|
347
|
+
try:
|
|
348
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
349
|
+
except Exception:
|
|
350
|
+
return None
|
|
351
|
+
a = _parse(started_at)
|
|
352
|
+
b = _parse(generated_at)
|
|
353
|
+
if a and b:
|
|
354
|
+
delta = (b - a).total_seconds()
|
|
355
|
+
return int(delta) if delta >= 0 else 0
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# assembly + emit
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _canonical(obj):
|
|
364
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
368
|
+
generated_at = _utc_now_iso()
|
|
369
|
+
run_id = args.run_id or os.environ.get("LOKI_SESSION_ID") or _gen_run_id()
|
|
370
|
+
|
|
371
|
+
started_at, version_from_state = _collect_meta(loki_dir, repo_root)
|
|
372
|
+
loki_version = args.loki_version or version_from_state or "unknown"
|
|
373
|
+
|
|
374
|
+
cost, model_from_eff = _collect_efficiency(loki_dir)
|
|
375
|
+
provider_name = args.provider or os.environ.get("PROVIDER_NAME") or "claude"
|
|
376
|
+
model = model_from_eff or os.environ.get("SESSION_MODEL") or ""
|
|
377
|
+
|
|
378
|
+
files_changed, diffs = _git_diffstat(target_dir, args.include_diffs)
|
|
379
|
+
iterations = _collect_iterations(loki_dir)
|
|
380
|
+
spec = _collect_spec(loki_dir, target_dir)
|
|
381
|
+
council = _collect_council(loki_dir)
|
|
382
|
+
quality_gates = _collect_quality_gates(loki_dir)
|
|
383
|
+
|
|
384
|
+
deployed_url = os.environ.get("LOKI_DEPLOYED_URL") or None
|
|
385
|
+
|
|
386
|
+
# Assemble WITHOUT redaction / verification fields (advisor ordering).
|
|
387
|
+
proof = {
|
|
388
|
+
"schema_version": SCHEMA_VERSION,
|
|
389
|
+
"run_id": run_id,
|
|
390
|
+
"generated_at": generated_at,
|
|
391
|
+
"loki_version": loki_version,
|
|
392
|
+
"started_at": started_at,
|
|
393
|
+
"wall_clock_sec": _wall_clock_sec(started_at, generated_at),
|
|
394
|
+
"spec": spec,
|
|
395
|
+
"provider": {"name": provider_name, "model": model},
|
|
396
|
+
"iterations": iterations,
|
|
397
|
+
"files_changed": files_changed,
|
|
398
|
+
"diffs": diffs,
|
|
399
|
+
"council": council,
|
|
400
|
+
"quality_gates": quality_gates,
|
|
401
|
+
"cost": cost,
|
|
402
|
+
"deployment": {"deployed_url": deployed_url, "public_url": None},
|
|
403
|
+
}
|
|
404
|
+
return proof, run_id
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _council_ratio(proof):
|
|
408
|
+
"""Return (approve_count, total) mirroring the template's councilSummary:
|
|
409
|
+
council enabled + non-empty reviewers[], counting APPROVE/APPROVED votes.
|
|
410
|
+
Returns None when there is no usable council data.
|
|
411
|
+
"""
|
|
412
|
+
council = proof.get("council") or {}
|
|
413
|
+
if not council.get("enabled"):
|
|
414
|
+
return None
|
|
415
|
+
reviewers = council.get("reviewers") or []
|
|
416
|
+
if not isinstance(reviewers, list) or not reviewers:
|
|
417
|
+
return None
|
|
418
|
+
ok = 0
|
|
419
|
+
for r in reviewers:
|
|
420
|
+
if not isinstance(r, dict):
|
|
421
|
+
continue
|
|
422
|
+
v = str(r.get("vote") or "").upper()
|
|
423
|
+
if v in ("APPROVE", "APPROVED"):
|
|
424
|
+
ok += 1
|
|
425
|
+
return ok, len(reviewers)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _fmt_usd_hook(usd):
|
|
429
|
+
"""Format a USD cost for the social hook, mirroring the template's fmtUsd:
|
|
430
|
+
up to 4 decimals, trimmed, padded to >=2. Returns None when uncollected."""
|
|
431
|
+
if usd is None:
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
n = float(usd)
|
|
435
|
+
except Exception:
|
|
436
|
+
return None
|
|
437
|
+
s = ("%.4f" % n).rstrip("0").rstrip(".")
|
|
438
|
+
if "." not in s:
|
|
439
|
+
s += ".00"
|
|
440
|
+
elif len(s.split(".")[1]) == 1:
|
|
441
|
+
s += "0"
|
|
442
|
+
return "$" + s
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _build_social_hook(proof):
|
|
446
|
+
"""One-line viral hook embedding the real measured cost + files changed +
|
|
447
|
+
council ratio. When cost was not collected, omit the cost (never fabricate
|
|
448
|
+
a number, never print "$0.00")."""
|
|
449
|
+
usd = _fmt_usd_hook((proof.get("cost") or {}).get("usd"))
|
|
450
|
+
lead = ("Built autonomously for " + usd) if usd is not None \
|
|
451
|
+
else "Built autonomously by Loki Mode"
|
|
452
|
+
parts = [lead]
|
|
453
|
+
fc = (proof.get("files_changed") or {}).get("count", 0)
|
|
454
|
+
try:
|
|
455
|
+
fc = int(fc)
|
|
456
|
+
except Exception:
|
|
457
|
+
fc = 0
|
|
458
|
+
parts.append("%d file%s changed" % (fc, "" if fc == 1 else "s"))
|
|
459
|
+
cr = _council_ratio(proof)
|
|
460
|
+
if cr:
|
|
461
|
+
parts.append("%d-of-%d reviewers approved" % (cr[0], cr[1]))
|
|
462
|
+
return " - ".join(parts)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _attr_esc(s):
|
|
466
|
+
"""HTML-attribute-escape a string destined for content="...".`"""
|
|
467
|
+
return (str(s).replace("&", "&").replace('"', """)
|
|
468
|
+
.replace("<", "<").replace(">", ">"))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _render_fallback_html(proof):
|
|
472
|
+
"""Self-contained index.html built ONLY from the redacted proof dict.
|
|
473
|
+
|
|
474
|
+
No external resources (no src=, @import, or http(s) links into assets).
|
|
475
|
+
Renders Tier1-4 fields in the ranked order from the spec.
|
|
476
|
+
"""
|
|
477
|
+
data_json = json.dumps(proof, indent=2)
|
|
478
|
+
cost = proof.get("cost", {})
|
|
479
|
+
fc = proof.get("files_changed", {})
|
|
480
|
+
council = proof.get("council", {})
|
|
481
|
+
prov = proof.get("provider", {})
|
|
482
|
+
dep = proof.get("deployment", {})
|
|
483
|
+
spec = proof.get("spec", {})
|
|
484
|
+
deployed = dep.get("deployed_url") or "(local only / none)"
|
|
485
|
+
|
|
486
|
+
def esc(s):
|
|
487
|
+
return (str(s).replace("&", "&").replace("<", "<")
|
|
488
|
+
.replace(">", ">").replace('"', """))
|
|
489
|
+
|
|
490
|
+
usd_val = cost.get("usd", None)
|
|
491
|
+
usd_disp = _fmt_usd_hook(usd_val)
|
|
492
|
+
|
|
493
|
+
rows = []
|
|
494
|
+
rows.append("<h1>Loki Mode -- Proof of Run</h1>")
|
|
495
|
+
if usd_disp is not None:
|
|
496
|
+
rows.append('<p class="hook">%s to run. Here is the bill, the diff, and the run id.</p>'
|
|
497
|
+
% esc(usd_disp))
|
|
498
|
+
else:
|
|
499
|
+
rows.append('<p class="hook">Cost not recorded for this run. Here is the diff and the run id.</p>')
|
|
500
|
+
|
|
501
|
+
# Tier 1: one-click verifiable.
|
|
502
|
+
rows.append("<h2>Live / Deployed</h2>")
|
|
503
|
+
rows.append("<p>Deployed URL: %s</p>" % esc(deployed))
|
|
504
|
+
rows.append("<p>Files changed: %s (+%s / -%s)</p>" % (
|
|
505
|
+
esc(fc.get("count", 0)), esc(fc.get("insertions", 0)),
|
|
506
|
+
esc(fc.get("deletions", 0))))
|
|
507
|
+
|
|
508
|
+
# Tier 2: itemized cost (the hero) + wall clock + diffstat.
|
|
509
|
+
rows.append("<h2>Itemized Bill</h2>")
|
|
510
|
+
rows.append("<ul>")
|
|
511
|
+
rows.append("<li>Cost (USD): %s</li>" % (
|
|
512
|
+
esc(usd_disp) if usd_disp is not None else "not recorded for this run"))
|
|
513
|
+
rows.append("<li>Input tokens: %s</li>" % esc(cost.get("input_tokens", 0)))
|
|
514
|
+
rows.append("<li>Output tokens: %s</li>" % esc(cost.get("output_tokens", 0)))
|
|
515
|
+
rows.append("<li>Cache read tokens: %s</li>" % esc(cost.get("cache_read_tokens", 0)))
|
|
516
|
+
rows.append("<li>Cache creation tokens: %s</li>" % esc(cost.get("cache_creation_tokens", 0)))
|
|
517
|
+
rows.append("<li>Wall clock (sec): %s</li>" % esc(proof.get("wall_clock_sec", 0)))
|
|
518
|
+
rows.append("</ul>")
|
|
519
|
+
|
|
520
|
+
# Tier 3: council + flagged-and-resolved.
|
|
521
|
+
rows.append("<h2>Council Review</h2>")
|
|
522
|
+
rows.append("<p>Enabled: %s | Final verdict: %s</p>" % (
|
|
523
|
+
esc(council.get("enabled")), esc(council.get("final_verdict") or "n/a")))
|
|
524
|
+
|
|
525
|
+
# Tier 4: provenance / anti-spam.
|
|
526
|
+
rows.append("<h2>Provenance</h2>")
|
|
527
|
+
rows.append("<p>Spec source: %s</p>" % esc(spec.get("source")))
|
|
528
|
+
rows.append("<p>Loki version: %s | Provider: %s | Model: %s</p>" % (
|
|
529
|
+
esc(proof.get("loki_version")), esc(prov.get("name")), esc(prov.get("model"))))
|
|
530
|
+
rows.append("<p>Run id: %s | Generated: %s</p>" % (
|
|
531
|
+
esc(proof.get("run_id")), esc(proof.get("generated_at"))))
|
|
532
|
+
ver = proof.get("verification", {})
|
|
533
|
+
rows.append('<p class="hash">Integrity hash (%s): %s</p>' % (
|
|
534
|
+
esc(ver.get("algo", "sha256")), esc(ver.get("hash", ""))))
|
|
535
|
+
red = proof.get("redaction", {})
|
|
536
|
+
rows.append("<p>Redaction applied: %s (%s redactions, rules v%s)</p>" % (
|
|
537
|
+
esc(red.get("applied")), esc(red.get("redactions_count")),
|
|
538
|
+
esc(red.get("rules_version"))))
|
|
539
|
+
|
|
540
|
+
body = "\n".join(rows)
|
|
541
|
+
html = (
|
|
542
|
+
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
|
543
|
+
"<meta charset=\"utf-8\">\n"
|
|
544
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|
545
|
+
"<title>Loki Mode Proof of Run -- " + esc(proof.get("run_id", "")) + "</title>\n"
|
|
546
|
+
"<style>\n"
|
|
547
|
+
"body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:780px;"
|
|
548
|
+
"margin:2rem auto;padding:0 1rem;color:#1a1a1a;line-height:1.5}\n"
|
|
549
|
+
"h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:1.6rem;border-bottom:1px solid #ddd}\n"
|
|
550
|
+
".hook{font-size:1.1rem;font-weight:600}\n"
|
|
551
|
+
".hash{font-family:monospace;font-size:.8rem;word-break:break-all;color:#555}\n"
|
|
552
|
+
"ul{padding-left:1.2rem}\n"
|
|
553
|
+
"pre{background:#f6f6f6;padding:1rem;overflow:auto;font-size:.75rem;border-radius:6px}\n"
|
|
554
|
+
"</style>\n</head>\n<body>\n"
|
|
555
|
+
+ body +
|
|
556
|
+
"\n<h2>Raw proof.json (redacted)</h2>\n<pre>"
|
|
557
|
+
+ esc(data_json) +
|
|
558
|
+
"</pre>\n</body>\n</html>\n"
|
|
559
|
+
)
|
|
560
|
+
return html
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _render_html(proof, repo_root):
|
|
564
|
+
"""Prefer the shared template; fall back to the self-contained renderer."""
|
|
565
|
+
template_path = os.path.join(_HERE, "proof-template.html")
|
|
566
|
+
tpl = _read_text(template_path, default="")
|
|
567
|
+
marker = "__PROOF_JSON__"
|
|
568
|
+
if tpl and marker in tpl:
|
|
569
|
+
# Substitute the dynamic social hook BEFORE the JSON payload, so a proof
|
|
570
|
+
# value that happens to contain the hook token cannot get clobbered.
|
|
571
|
+
# The hook embeds the real measured cost + files-changed + council ratio
|
|
572
|
+
# (cost-free variant when uncollected) for the viral punch.
|
|
573
|
+
hook = _build_social_hook(proof)
|
|
574
|
+
tpl = tpl.replace("__PROOF_OG_DESCRIPTION__", _attr_esc(hook))
|
|
575
|
+
# Template renders client-side from an inlined JSON blob. Per the
|
|
576
|
+
# template GENERATOR CONTRACT, escape "<" so a value containing
|
|
577
|
+
# "</script>" or "<!--" cannot break out of the script block.
|
|
578
|
+
payload = json.dumps(proof, ensure_ascii=False).replace("<", "\\u003c")
|
|
579
|
+
return tpl.replace(marker, payload)
|
|
580
|
+
return _render_fallback_html(proof)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def generate(args):
|
|
584
|
+
loki_dir = os.path.abspath(args.loki_dir)
|
|
585
|
+
target_dir = os.path.dirname(loki_dir) or "."
|
|
586
|
+
|
|
587
|
+
# Resolve repo root: walk up for VERSION + autonomy/run.sh.
|
|
588
|
+
repo_root = ""
|
|
589
|
+
probe = _HERE
|
|
590
|
+
for _ in range(6):
|
|
591
|
+
if (os.path.isfile(os.path.join(probe, "VERSION"))
|
|
592
|
+
and os.path.isfile(os.path.join(probe, "autonomy", "run.sh"))):
|
|
593
|
+
repo_root = probe
|
|
594
|
+
break
|
|
595
|
+
parent = os.path.dirname(probe)
|
|
596
|
+
if parent == probe:
|
|
597
|
+
break
|
|
598
|
+
probe = parent
|
|
599
|
+
|
|
600
|
+
# Configure redaction context (best effort; generic rules still apply).
|
|
601
|
+
proof_redact.reset_context()
|
|
602
|
+
proof_redact.set_context(
|
|
603
|
+
home=os.environ.get("HOME") or os.path.expanduser("~"),
|
|
604
|
+
repo_root=target_dir,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
proof, run_id = _build_proof(args, loki_dir, target_dir, repo_root)
|
|
608
|
+
|
|
609
|
+
# THE CHOKEPOINT: redact the assembled dict exactly once.
|
|
610
|
+
redacted, count = proof_redact.redact_tree(proof)
|
|
611
|
+
|
|
612
|
+
# Refuse to emit if redaction did not run. redact_tree always returns a
|
|
613
|
+
# dict + int count; a missing/None result means the chokepoint failed.
|
|
614
|
+
if not isinstance(redacted, dict) or count is None:
|
|
615
|
+
raise RuntimeError("redaction did not run; refusing to emit proof")
|
|
616
|
+
|
|
617
|
+
# Apply schema length caps AFTER redaction (security ordering: never
|
|
618
|
+
# truncate a raw string and risk slicing a secret into an under-length
|
|
619
|
+
# fragment that escapes the redactor). Caps: brief <=600, summary <=300.
|
|
620
|
+
try:
|
|
621
|
+
spec_obj = redacted.get("spec")
|
|
622
|
+
if isinstance(spec_obj, dict) and isinstance(spec_obj.get("brief"), str):
|
|
623
|
+
spec_obj["brief"] = spec_obj["brief"][:600]
|
|
624
|
+
council_obj = redacted.get("council")
|
|
625
|
+
if isinstance(council_obj, dict):
|
|
626
|
+
for rv in council_obj.get("reviewers") or []:
|
|
627
|
+
if isinstance(rv, dict) and isinstance(rv.get("summary"), str):
|
|
628
|
+
rv["summary"] = rv["summary"][:300]
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
redacted["redaction"] = {
|
|
633
|
+
"applied": True,
|
|
634
|
+
"rules_version": proof_redact.RULES_VERSION,
|
|
635
|
+
"redactions_count": int(count),
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Integrity hash over the canonical form INCLUDING redaction but EXCLUDING
|
|
639
|
+
# verification (advisor ordering). Verifier re-canonicalizes the compact
|
|
640
|
+
# sort_keys form, never the pretty bytes on disk.
|
|
641
|
+
digest = hashlib.sha256(_canonical(redacted).encode("utf-8")).hexdigest()
|
|
642
|
+
redacted["verification"] = {
|
|
643
|
+
"hash": digest,
|
|
644
|
+
"algo": "sha256",
|
|
645
|
+
"scope": "integrity",
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# Determine output dir.
|
|
649
|
+
if args.out_dir:
|
|
650
|
+
out_dir = os.path.abspath(args.out_dir)
|
|
651
|
+
else:
|
|
652
|
+
out_dir = os.path.join(loki_dir, "proofs", run_id)
|
|
653
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
654
|
+
|
|
655
|
+
proof_path = os.path.join(out_dir, "proof.json")
|
|
656
|
+
with open(proof_path, "w") as f:
|
|
657
|
+
json.dump(redacted, f, indent=2)
|
|
658
|
+
|
|
659
|
+
html = _render_html(redacted, repo_root)
|
|
660
|
+
html_path = os.path.join(out_dir, "index.html")
|
|
661
|
+
with open(html_path, "w") as f:
|
|
662
|
+
f.write(html)
|
|
663
|
+
|
|
664
|
+
if not args.quiet:
|
|
665
|
+
print("proof-of-run written: " + proof_path)
|
|
666
|
+
return out_dir
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def main(argv=None):
|
|
670
|
+
parser = argparse.ArgumentParser(description="Loki Mode proof-of-run generator")
|
|
671
|
+
parser.add_argument("--loki-dir", default=".loki")
|
|
672
|
+
parser.add_argument("--out-dir", default="")
|
|
673
|
+
parser.add_argument("--include-diffs", action="store_true")
|
|
674
|
+
parser.add_argument("--run-id", default="")
|
|
675
|
+
parser.add_argument("--loki-version", default="")
|
|
676
|
+
parser.add_argument("--provider", default="")
|
|
677
|
+
parser.add_argument("--quiet", action="store_true")
|
|
678
|
+
args = parser.parse_args(argv)
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
generate(args)
|
|
682
|
+
return 0
|
|
683
|
+
except Exception as exc: # never raise to caller (fire-and-forget)
|
|
684
|
+
# One-line warning only; do not leak a stack trace into run output.
|
|
685
|
+
sys.stderr.write("warn: proof-of-run generation failed: %s\n" % exc)
|
|
686
|
+
return 0
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
if __name__ == "__main__":
|
|
690
|
+
sys.exit(main())
|