loki-mode 7.84.0 → 7.85.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,483 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic re-verifier for Loki Mode proof-of-run receipts.
3
+
4
+ Companion to proof-generator.py. The generator writes proof.json with an
5
+ integrity hash so a skeptic can prove the JSON was not edited. This module
6
+ goes one step further: it re-checks the receipt's recorded FACTS against the
7
+ live repo, so a skeptic can prove the facts are STILL TRUE (the diff the
8
+ receipt claims still matches what is in git), not merely that the JSON bytes
9
+ are unaltered. That re-check is what makes the receipt non-forgeable: you
10
+ cannot hand-write a proof.json whose recorded diff survives a re-run against
11
+ the repo it claims to describe.
12
+
13
+ Three checks (mirrors dashboard/audit.py verify-CLI style):
14
+
15
+ 1. TAMPER CHECK (hash_ok): strip verification.hash, re-canonicalize exactly
16
+ as the generator does (sort_keys=True, compact separators, the same
17
+ ensure_ascii setting), sha256, compare to the recorded verification.hash.
18
+ Any mismatch means the JSON was edited after signing.
19
+
20
+ 2. DRIFT CHECK (diff_drift): from the recorded git base sha, re-run
21
+ `git diff --shortstat <base> <head>` in the repo and compare the file /
22
+ insertion / deletion counts (and diff_sha256 when present) to what the
23
+ receipt recorded. A mismatch means the repo no longer matches the receipt.
24
+
25
+ 3. GPG (gpg_ok): if a detached signature is present and gpg is available,
26
+ verify it over the canonical bytes. Otherwise "n/a".
27
+
28
+ Honesty rules (CLAUDE.md binding):
29
+ - Never claim "verified" when a check could not run. If the base ref is
30
+ missing or unresolvable, diff_drift is reported as None and `ok` is False
31
+ with a reason; we never silently pass an undrifted-but-unchecked receipt.
32
+ - Surface honesty.degraded from the proof so the verifier output also shows
33
+ the gaps the generator already disclosed.
34
+ - set -u / robust: missing file, malformed JSON, missing fields produce a
35
+ clear error and exit 2, never a traceback-as-UX.
36
+
37
+ Schema compatibility:
38
+ - Generator schema v1.0 records the diff under top-level files_changed{} and
39
+ diffs[], with NO recorded base sha, so drift cannot be re-derived (the
40
+ verifier says so honestly rather than pretending).
41
+ - Schema v1.1 (if/when the generator slice lands it) records
42
+ facts.git.{base_sha, head_sha, diff, diff_sha256}. This verifier prefers
43
+ facts.git when present and falls back to the v1.0 layout otherwise.
44
+
45
+ CLI:
46
+ python3 autonomy/lib/proof-verify.py <proof.json> [repo_dir]
47
+ Prints the JSON result. Exit 0 if ok, 1 on tamper / drift / bad signature,
48
+ 2 on a usage / load error (missing file, malformed JSON).
49
+ """
50
+
51
+ import hashlib
52
+ import json
53
+ import os
54
+ import subprocess
55
+ import sys
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # canonicalization (MUST match proof-generator._canonical exactly)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def _canonical(obj):
63
+ """Canonical JSON form used for the integrity hash.
64
+
65
+ Mirrors proof-generator.py _canonical(): json.dumps with sort_keys=True
66
+ and compact separators. The generator does not pass ensure_ascii, so it
67
+ defaults to True; we match that here so the recomputed hash is identical.
68
+ """
69
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # git helpers
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _git(repo_dir, args, timeout=30):
77
+ """Run git in repo_dir. Returns stdout string, or None on any failure."""
78
+ try:
79
+ out = subprocess.run(
80
+ ["git", "-C", repo_dir] + args,
81
+ capture_output=True, text=True, timeout=timeout,
82
+ )
83
+ if out.returncode != 0:
84
+ return None
85
+ return out.stdout
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ def _is_git_repo(repo_dir):
91
+ return _git(repo_dir, ["rev-parse", "--is-inside-work-tree"]) is not None
92
+
93
+
94
+ def _rev_resolvable(repo_dir, ref):
95
+ """True iff `ref` resolves to a commit in repo_dir."""
96
+ if not ref:
97
+ return False
98
+ out = _git(repo_dir, ["rev-parse", "--verify", "--quiet", str(ref) + "^{commit}"])
99
+ return bool(out and out.strip())
100
+
101
+
102
+ def _numstat(repo_dir, base, head):
103
+ """Return {count, insertions, deletions, files} for
104
+ `git diff --numstat base head`, or None if the diff could not be computed.
105
+
106
+ The `files` list mirrors proof-generator._git_diffstat EXACTLY (path /
107
+ insertions / deletions / status) so that hashing the canonical stat here
108
+ reproduces the generator's diff_sha256. (Earlier this hashed the full patch
109
+ text while the generator hashed the stat object -- so every untampered v1.1
110
+ proof falsely reported drift. BUG-DIFFSHA.)"""
111
+ raw = _git(repo_dir, ["diff", "--numstat", str(base), str(head)])
112
+ if raw is None:
113
+ return None
114
+ files = []
115
+ ins_total = 0
116
+ del_total = 0
117
+ for line in raw.splitlines():
118
+ parts = line.split("\t")
119
+ if len(parts) < 3:
120
+ continue
121
+ ins_s, del_s, path = parts[0], parts[1], parts[2]
122
+ # binary files show "-" for both columns; count the file, add 0.
123
+ ins = 0 if ins_s == "-" else _to_int(ins_s)
124
+ dele = 0 if del_s == "-" else _to_int(del_s)
125
+ ins_total += ins
126
+ del_total += dele
127
+ files.append({
128
+ "path": path,
129
+ "insertions": ins,
130
+ "deletions": dele,
131
+ "status": "binary" if ins_s == "-" else "modified",
132
+ })
133
+ return {
134
+ "count": len(files),
135
+ "insertions": ins_total,
136
+ "deletions": del_total,
137
+ "files": files,
138
+ }
139
+
140
+
141
+ def _diff_sha256_from_stat(files_changed):
142
+ """Recompute the generator's diff_sha256 from a stat object.
143
+
144
+ MUST match proof-generator._diff_sha256: sha256 of the canonical
145
+ {count, insertions, deletions, files} object (NOT the full patch text)."""
146
+ fc = files_changed or {}
147
+ canon = {
148
+ "count": fc.get("count", 0),
149
+ "insertions": fc.get("insertions", 0),
150
+ "deletions": fc.get("deletions", 0),
151
+ "files": fc.get("files", []),
152
+ }
153
+ return hashlib.sha256(_canonical(canon).encode("utf-8")).hexdigest()
154
+
155
+
156
+ def _full_diff(repo_dir, base, head):
157
+ """Return the full `git diff base head` patch text, or None."""
158
+ return _git(repo_dir, ["diff", str(base), str(head)])
159
+
160
+
161
+ def _to_int(v, default=0):
162
+ try:
163
+ return int(v)
164
+ except Exception:
165
+ return default
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # proof field extraction (schema v1.0 + v1.1 tolerant)
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def _recorded_git_refs(proof):
173
+ """Return (base_sha, head_sha) the receipt recorded, or (None, None).
174
+
175
+ Prefers schema v1.1 facts.git.{base_sha,head_sha}. Schema v1.0 records no
176
+ base sha, so this returns (None, None) there and the caller reports drift
177
+ as unverifiable rather than passing it silently.
178
+ """
179
+ facts = proof.get("facts")
180
+ if isinstance(facts, dict):
181
+ git = facts.get("git")
182
+ if isinstance(git, dict):
183
+ base = git.get("base_sha")
184
+ head = git.get("head_sha")
185
+ return (str(base) if base else None,
186
+ str(head) if head else None)
187
+ return None, None
188
+
189
+
190
+ def _recorded_diff_stat(proof):
191
+ """Return the recorded {count, insertions, deletions}, schema-tolerant.
192
+
193
+ v1.1 records facts.git.diff = {count, insertions, deletions, ...}.
194
+ v1.0 records top-level files_changed = {count, insertions, deletions, ...}.
195
+ Returns None if neither is present / usable.
196
+ """
197
+ facts = proof.get("facts")
198
+ if isinstance(facts, dict):
199
+ git = facts.get("git")
200
+ if isinstance(git, dict) and isinstance(git.get("diff"), dict):
201
+ d = git["diff"]
202
+ return {
203
+ "count": _to_int(d.get("count")),
204
+ "insertions": _to_int(d.get("insertions")),
205
+ "deletions": _to_int(d.get("deletions")),
206
+ }
207
+ fc = proof.get("files_changed")
208
+ if isinstance(fc, dict):
209
+ return {
210
+ "count": _to_int(fc.get("count")),
211
+ "insertions": _to_int(fc.get("insertions")),
212
+ "deletions": _to_int(fc.get("deletions")),
213
+ }
214
+ return None
215
+
216
+
217
+ def _recorded_diff_sha256(proof):
218
+ """Return facts.git.diff_sha256 if recorded (v1.1 only), else None."""
219
+ facts = proof.get("facts")
220
+ if isinstance(facts, dict):
221
+ git = facts.get("git")
222
+ if isinstance(git, dict):
223
+ v = git.get("diff_sha256")
224
+ if v:
225
+ return str(v)
226
+ return None
227
+
228
+
229
+ def _recorded_degraded(proof):
230
+ """Return the honesty.degraded list the generator disclosed, or []."""
231
+ honesty = proof.get("honesty")
232
+ if isinstance(honesty, dict):
233
+ deg = honesty.get("degraded")
234
+ if isinstance(deg, list):
235
+ return [str(x) for x in deg]
236
+ return []
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # gpg
241
+ # ---------------------------------------------------------------------------
242
+
243
+ def _gpg_available():
244
+ try:
245
+ out = subprocess.run(["gpg", "--version"], capture_output=True,
246
+ text=True, timeout=10)
247
+ return out.returncode == 0
248
+ except Exception:
249
+ return False
250
+
251
+
252
+ def _verify_gpg(canonical_bytes, signature):
253
+ """Verify a detached signature over canonical_bytes.
254
+
255
+ Returns True (good sig), False (bad sig / gpg failure), or "n/a" when no
256
+ signature is present or gpg is unavailable.
257
+ """
258
+ if not signature:
259
+ return "n/a"
260
+ if not _gpg_available():
261
+ return "n/a"
262
+ import tempfile
263
+ data_path = None
264
+ sig_path = None
265
+ try:
266
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as df:
267
+ df.write(canonical_bytes)
268
+ data_path = df.name
269
+ sig_bytes = signature
270
+ if isinstance(sig_bytes, str):
271
+ sig_bytes = sig_bytes.encode("utf-8")
272
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".sig") as sf:
273
+ sf.write(sig_bytes)
274
+ sig_path = sf.name
275
+ out = subprocess.run(
276
+ ["gpg", "--verify", sig_path, data_path],
277
+ capture_output=True, text=True, timeout=30,
278
+ )
279
+ return out.returncode == 0
280
+ except Exception:
281
+ return False
282
+ finally:
283
+ for p in (data_path, sig_path):
284
+ if p:
285
+ try:
286
+ os.unlink(p)
287
+ except OSError:
288
+ pass
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # the verifier
293
+ # ---------------------------------------------------------------------------
294
+
295
+ class ProofLoadError(Exception):
296
+ """Raised for a missing file / malformed JSON / unusable proof shape."""
297
+
298
+
299
+ def _load_proof(proof_path):
300
+ if not os.path.isfile(proof_path):
301
+ raise ProofLoadError("proof file not found: %s" % proof_path)
302
+ try:
303
+ with open(proof_path, "r") as f:
304
+ data = json.load(f)
305
+ except json.JSONDecodeError as exc:
306
+ raise ProofLoadError("malformed JSON in %s: %s" % (proof_path, exc))
307
+ except OSError as exc:
308
+ raise ProofLoadError("could not read %s: %s" % (proof_path, exc))
309
+ if not isinstance(data, dict):
310
+ raise ProofLoadError("proof root is not a JSON object: %s" % proof_path)
311
+ return data
312
+
313
+
314
+ def verify(proof_path, repo_dir="."):
315
+ """Re-verify a proof.json against the repo.
316
+
317
+ Returns a dict:
318
+ {
319
+ hash_ok: bool tamper check passed
320
+ diff_drift: bool | None True=drift, False=match,
321
+ None=could not check
322
+ diff_recheck: {recorded, current} the two diff stats compared
323
+ gpg_ok: True | False | "n/a" signature verdict
324
+ degraded: [str] honesty.degraded from the proof
325
+ reason: str why ok is False (when it is)
326
+ ok: bool overall verdict
327
+ }
328
+
329
+ `ok` = hash_ok AND diff_drift is False AND gpg_ok in (True, "n/a").
330
+ Note: diff_drift None (unverifiable) makes ok False, by design -- we never
331
+ report "verified" when the central fact could not be re-checked.
332
+ """
333
+ proof = _load_proof(proof_path)
334
+
335
+ result = {
336
+ "hash_ok": False,
337
+ "diff_drift": None,
338
+ "diff_recheck": {"recorded": None, "current": None},
339
+ "gpg_ok": "n/a",
340
+ "degraded": _recorded_degraded(proof),
341
+ "reason": "",
342
+ "ok": False,
343
+ }
344
+
345
+ # ----- 1. TAMPER CHECK -------------------------------------------------
346
+ verification = proof.get("verification")
347
+ if not isinstance(verification, dict) or not verification.get("hash"):
348
+ result["hash_ok"] = False
349
+ result["reason"] = "no verification.hash recorded; cannot prove integrity"
350
+ return result
351
+ recorded_hash = str(verification.get("hash"))
352
+
353
+ # Recompute over the canonical form with verification REMOVED, exactly as
354
+ # the generator hashed it (hash computed before verification was attached).
355
+ unsigned = dict(proof)
356
+ unsigned.pop("verification", None)
357
+ canonical_str = _canonical(unsigned)
358
+ canonical_bytes = canonical_str.encode("utf-8")
359
+ recomputed = hashlib.sha256(canonical_bytes).hexdigest()
360
+ result["hash_ok"] = (recomputed == recorded_hash)
361
+ if not result["hash_ok"]:
362
+ result["reason"] = "integrity hash mismatch (proof.json was edited after signing)"
363
+ # Continue to gather drift/gpg signals for the report, but ok stays False.
364
+
365
+ # ----- 3. GPG (compute before returning so the report is complete) -----
366
+ gpg_sig = verification.get("gpg_signature")
367
+ result["gpg_ok"] = _verify_gpg(canonical_bytes, gpg_sig)
368
+
369
+ # ----- 2. DRIFT CHECK --------------------------------------------------
370
+ recorded_stat = _recorded_diff_stat(proof)
371
+ result["diff_recheck"]["recorded"] = recorded_stat
372
+
373
+ base_sha, head_sha = _recorded_git_refs(proof)
374
+
375
+ if not _is_git_repo(repo_dir):
376
+ result["diff_drift"] = None
377
+ if not result["reason"]:
378
+ result["reason"] = "repo_dir is not a git work tree; drift unverifiable"
379
+ elif not base_sha:
380
+ # Schema v1.0 (or a v1.1 proof missing base_sha): no recorded base ref,
381
+ # so the diff cannot be re-derived. Report honestly, do NOT pass.
382
+ result["diff_drift"] = None
383
+ if not result["reason"]:
384
+ result["reason"] = "base ref unresolvable (no recorded base_sha; drift unverifiable)"
385
+ elif not _rev_resolvable(repo_dir, base_sha):
386
+ result["diff_drift"] = None
387
+ if not result["reason"]:
388
+ result["reason"] = ("base ref unresolvable (%s not found in repo; "
389
+ "drift unverifiable)" % base_sha)
390
+ else:
391
+ # Drift answers "does this receipt still describe the CURRENT branch
392
+ # state". A receipt is for verifying the work as it stands now, so we
393
+ # diff base..live-HEAD: a new commit since the receipt was generated is
394
+ # genuine drift (the receipt no longer matches the branch). The recorded
395
+ # head_sha is used for the tamper/hash check, not here. (The integrity
396
+ # hash already proves the receipt's own bytes are unedited; drift proves
397
+ # the recorded FACTS still match the repo.)
398
+ head_ref = "HEAD"
399
+ current_stat = _numstat(repo_dir, base_sha, head_ref)
400
+ result["diff_recheck"]["current"] = current_stat
401
+
402
+ if current_stat is None:
403
+ result["diff_drift"] = None
404
+ if not result["reason"]:
405
+ result["reason"] = "git diff could not be computed; drift unverifiable"
406
+ else:
407
+ drift = False
408
+ if recorded_stat is not None:
409
+ drift = (
410
+ recorded_stat.get("count") != current_stat.get("count")
411
+ or recorded_stat.get("insertions") != current_stat.get("insertions")
412
+ or recorded_stat.get("deletions") != current_stat.get("deletions")
413
+ )
414
+ else:
415
+ # We can re-derive the diff but the receipt recorded no stat to
416
+ # compare against -- cannot confirm the facts match.
417
+ result["diff_drift"] = None
418
+ if not result["reason"]:
419
+ result["reason"] = ("no recorded diff stat to compare; "
420
+ "drift unverifiable")
421
+
422
+ # diff_sha256: a stronger content check than the counts. Only when
423
+ # the receipt recorded one (v1.1).
424
+ recorded_dsha = _recorded_diff_sha256(proof)
425
+ if result["diff_drift"] is not False and recorded_stat is not None:
426
+ # only evaluate sha when we are still in the comparable branch
427
+ pass
428
+ if recorded_dsha is not None and current_stat is not None:
429
+ # Recompute the SAME canonical stat-hash the generator wrote
430
+ # (proof-generator._diff_sha256), NOT a hash of the patch text.
431
+ cur_dsha = _diff_sha256_from_stat(current_stat)
432
+ result["diff_recheck"]["current_diff_sha256"] = cur_dsha
433
+ result["diff_recheck"]["recorded_diff_sha256"] = recorded_dsha
434
+ if cur_dsha != recorded_dsha:
435
+ drift = True
436
+
437
+ if recorded_stat is not None:
438
+ result["diff_drift"] = drift
439
+ if drift and not result["reason"]:
440
+ result["reason"] = "recorded diff no longer matches the repo (drift detected)"
441
+
442
+ # ----- overall verdict -------------------------------------------------
443
+ result["ok"] = bool(
444
+ result["hash_ok"]
445
+ and result["diff_drift"] is False
446
+ and result["gpg_ok"] in (True, "n/a")
447
+ )
448
+ if result["ok"]:
449
+ result["reason"] = ""
450
+ elif not result["reason"]:
451
+ if result["gpg_ok"] is False:
452
+ result["reason"] = "gpg signature verification failed"
453
+ else:
454
+ result["reason"] = "verification failed"
455
+ return result
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # CLI shim (mirrors dashboard/audit.py _unified_cli style)
460
+ # ---------------------------------------------------------------------------
461
+
462
+ def _cli(argv=None):
463
+ argv = list(sys.argv[1:] if argv is None else argv)
464
+ if not argv or argv[0] in ("-h", "--help"):
465
+ print(json.dumps(
466
+ {"error": "usage: proof-verify.py <proof.json> [repo_dir]"}))
467
+ return 2
468
+ proof_path = argv[0]
469
+ repo_dir = argv[1] if len(argv) > 1 else "."
470
+ try:
471
+ result = verify(proof_path, repo_dir)
472
+ except ProofLoadError as exc:
473
+ print(json.dumps({"ok": False, "error": str(exc)}))
474
+ return 2
475
+ except Exception as exc: # defensive: never a traceback-as-UX
476
+ print(json.dumps({"ok": False, "error": "verify failed: %s" % exc}))
477
+ return 2
478
+ print(json.dumps(result, indent=2))
479
+ return 0 if result.get("ok") else 1
480
+
481
+
482
+ if __name__ == "__main__":
483
+ sys.exit(_cli())
package/autonomy/loki CHANGED
@@ -16495,7 +16495,9 @@ main() {
16495
16495
  _deprecated_alias share "report share" "$@"
16496
16496
  cmd_report share "$@"
16497
16497
  ;;
16498
- proof)
16498
+ proof|receipt)
16499
+ # `loki receipt` is a friendly alias for `loki proof` (the Evidence
16500
+ # Receipt surface): same subcommands (list/show/verify/open/share).
16499
16501
  cmd_proof "$@"
16500
16502
  ;;
16501
16503
  bench)
@@ -30451,6 +30453,8 @@ cmd_proof() {
30451
30453
  echo "Subcommands:"
30452
30454
  echo " list List proof-of-run artifacts in .loki/proofs/"
30453
30455
  echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
30456
+ echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
30457
+ echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
30454
30458
  echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
30455
30459
  echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
30456
30460
  echo ""
@@ -30526,6 +30530,33 @@ PYEOF
30526
30530
  fi
30527
30531
  exit 0
30528
30532
  ;;
30533
+ verify)
30534
+ # Deterministic re-check of a receipt against the repo: re-hashes the
30535
+ # canonical proof (tamper check) and re-derives the diff from the
30536
+ # recorded base_sha vs live HEAD (drift check). Exit 0 = clean, 1 =
30537
+ # tamper/drift, 2 = unusable input. This is the "verify it yourself"
30538
+ # path that makes the Evidence Receipt non-forgeable.
30539
+ local id="${1:-}"
30540
+ if [ -z "$id" ]; then
30541
+ echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
30542
+ exit 2
30543
+ fi
30544
+ local pj="${proofs_dir}/${id}/proof.json"
30545
+ if [ ! -f "$pj" ]; then
30546
+ echo -e "${RED}Proof not found: ${id}${NC}"
30547
+ echo "Use 'loki proof list' to see available proofs."
30548
+ exit 1
30549
+ fi
30550
+ # The verifier ships beside the generator under autonomy/lib/ (same
30551
+ # dir resolution run.sh uses for proof-generator.py).
30552
+ local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
30553
+ if [ ! -f "$verifier" ]; then
30554
+ echo -e "${RED}Verifier not found (autonomy/lib/proof-verify.py).${NC}"
30555
+ exit 2
30556
+ fi
30557
+ python3 "$verifier" "$pj" "${TARGET_DIR:-.}"
30558
+ exit $?
30559
+ ;;
30529
30560
  open)
30530
30561
  local id="${1:-}"
30531
30562
  if [ -z "$id" ]; then
package/autonomy/run.sh CHANGED
@@ -7758,7 +7758,7 @@ sys.stdout.write(t.strip())
7758
7758
  # non-blocking behavior for legitimate no-test projects.
7759
7759
  touch "$quality_dir/unit-tests.pass"
7760
7760
  cat > "$quality_dir/test-results.json" << TREOF
7761
- {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected"}
7761
+ {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected","command":null,"exit_code":null,"status":"not_run","passed_count":null,"failed_count":null}
7762
7762
  TREOF
7763
7763
  # Finding #598: stamp the per-iteration freshness marker so a later
7764
7764
  # completion-route capture (ensure_completion_test_evidence) reuses this
@@ -7771,8 +7771,32 @@ TREOF
7771
7771
  # Sanitize details for JSON
7772
7772
  details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | head -c 500)
7773
7773
 
7774
+ # Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
7775
+ # non-forgeable receipt needs -- the command that ran, its exit code, and a
7776
+ # status enum -- alongside the legacy pass/runner/min_coverage keys the
7777
+ # completion-council evidence gate reads (those are UNCHANGED for back-compat).
7778
+ # A receipt that says "tests passed" without the command+exit_code is exactly
7779
+ # the "trust me" transcript we are replacing. counts are best-effort parsed
7780
+ # from the runner summary; null (not 0) when unparseable, so "unknown" never
7781
+ # reads as "0 failures".
7782
+ local _tr_cmd _tr_exit _tr_status
7783
+ case "$test_runner" in
7784
+ pytest) _tr_cmd="pytest" ;;
7785
+ go-test) _tr_cmd="go test ./..." ;;
7786
+ cargo-test) _tr_cmd="cargo test" ;;
7787
+ npm-test|jest|vitest) _tr_cmd="$test_runner" ;;
7788
+ *) _tr_cmd="$test_runner" ;;
7789
+ esac
7790
+ if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
7791
+ # Best-effort pass/fail counts from the summary text (null when not found).
7792
+ local _tr_passed_n _tr_failed_n
7793
+ _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
7794
+ _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
7795
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=null
7796
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=null
7797
+
7774
7798
  cat > "$quality_dir/test-results.json" << TREOF
7775
- {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details"}
7799
+ {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$test_passed,"min_coverage":$min_coverage,"summary":"$details","command":"$_tr_cmd","exit_code":$_tr_exit,"status":"$_tr_status","passed_count":$_tr_passed_n,"failed_count":$_tr_failed_n}
7776
7800
  TREOF
7777
7801
  # Finding #598: stamp the per-iteration freshness marker (see above).
7778
7802
  printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.84.0"
10
+ __version__ = "7.85.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.84.0
5
+ **Version:** v7.85.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.84.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.85.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.84.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
2
+ var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.85.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -796,4 +796,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
796
796
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
797
797
  `),process.stderr.write($Q),2}}s1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
798
798
 
799
- //# debugId=079215D508F3218764756E2164756E21
799
+ //# debugId=05CD23D774CD678C64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.84.0'
60
+ __version__ = '7.85.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.84.0",
4
+ "version": "7.85.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.84.0",
5
+ "version": "7.85.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",