loki-mode 7.8.3 → 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.
@@ -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("&", "&amp;").replace('"', "&quot;")
468
+ .replace("<", "&lt;").replace(">", "&gt;"))
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("&", "&amp;").replace("<", "&lt;")
488
+ .replace(">", "&gt;").replace('"', "&quot;"))
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())