loki-mode 7.129.4 → 8.0.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.
Files changed (74) hide show
  1. package/README.md +81 -5
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +282 -95
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +151 -102
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +124 -0
  29. package/autonomy/loki +189 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4424 -678
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. package/skills/quality-gates.md +22 -0
@@ -42,6 +42,40 @@
42
42
  # precedent at autonomy/lib/voter-agents.sh:259.
43
43
  _loki_prd_enrich_invoke() {
44
44
  local prompt="$1"
45
+ # v8 RAW-SDK TEXT PATH (opt-in LOKI_SDK_PRD_ENRICH=1). Free-form enrichment
46
+ # via the pure-HTTPS @anthropic-ai/sdk (no claude binary) through the loki-ts
47
+ # `internal sdk-text` bridge. Runs BEFORE the claude-binary guard so the
48
+ # no-binary deploy win holds. Fail-closed: on ANY miss (flag off, no key,
49
+ # bun/entrypoint absent, non-zero, empty) fall straight through to the claude
50
+ # path below -- zero behavior change when off or unavailable. The output is
51
+ # free-form text the caller consumes verbatim, same as the claude path.
52
+ if [ "${LOKI_SDK_PRD_ENRICH:-0}" = "1" ]; then
53
+ local _pe_root _pe_loki _pe_pf _pe_out _pe_rc
54
+ _pe_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
55
+ _pe_loki="${_pe_root}/bin/loki"
56
+ if [ -x "$_pe_loki" ] && command -v bun >/dev/null 2>&1; then
57
+ _pe_pf="$(mktemp 2>/dev/null)" || _pe_pf=""
58
+ if [ -n "$_pe_pf" ]; then
59
+ printf '%s' "$prompt" > "$_pe_pf"
60
+ _pe_rc=0
61
+ local _pe_to_s="${LOKI_PRD_ENRICH_TIMEOUT:-180}"
62
+ local _pe_wrap
63
+ if command -v timeout >/dev/null 2>&1; then _pe_wrap="timeout $(( _pe_to_s + 15 ))"
64
+ elif command -v gtimeout >/dev/null 2>&1; then _pe_wrap="gtimeout $(( _pe_to_s + 15 ))"
65
+ else _pe_wrap=""; fi
66
+ _pe_out="$($_pe_wrap "$_pe_loki" internal sdk-text \
67
+ --prompt-file "$_pe_pf" \
68
+ --model "${LOKI_SDK_PRD_ENRICH_MODEL:-claude-sonnet-5}" --effort medium \
69
+ --timeout-ms "$(( _pe_to_s * 1000 ))" 2>/dev/null)" || _pe_rc=$?
70
+ rm -f "$_pe_pf" 2>/dev/null || true
71
+ if [ "$_pe_rc" -eq 0 ] && [ -n "$_pe_out" ]; then
72
+ printf '%s' "$_pe_out"
73
+ return 0
74
+ fi
75
+ fi
76
+ fi
77
+ # fall through to the claude path (fail-closed)
78
+ fi
45
79
  command -v claude >/dev/null 2>&1 || return 1
46
80
  local rc=0
47
81
  local out=""
@@ -65,6 +99,14 @@ _loki_prd_enrich_invoke() {
65
99
  _loki_prd_enrich_provider_ok() {
66
100
  [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
67
101
  [ "${PROVIDER_DEGRADED:-false}" != "true" ] || return 1
102
+ # v8: the raw-SDK enrich path (LOKI_SDK_PRD_ENRICH=1) needs no claude binary,
103
+ # so attempt is viable when that path is usable (bridge + bun). The invoke fn
104
+ # still fails closed to claude on an SDK miss.
105
+ if [ "${LOKI_SDK_PRD_ENRICH:-0}" = "1" ] \
106
+ && [ -x "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/bin/loki" ] \
107
+ && command -v bun >/dev/null 2>&1; then
108
+ return 0
109
+ fi
68
110
  command -v claude >/dev/null 2>&1 || return 1
69
111
  return 0
70
112
  }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """Emit the FIXED analytics allowlist from a finished proof.json as key=value
3
+ lines, one per line, for loki_emit_build_verified (autonomy/telemetry.sh).
4
+
5
+ This is the SINGLE authoritative allowlist for opt-in build-outcome analytics.
6
+ Only already-computed scalars are ever emitted -- never code, spec/PRD text,
7
+ paths, or file names. Anything not named here is never read, so a proof.json
8
+ schema change cannot silently start leaking a new field.
9
+
10
+ Free-text-shaped fields are excluded on purpose:
11
+ - headline is a bounded enum (VERIFIED / VERIFIED WITH GAPS / NOT VERIFIED),
12
+ computed deterministically from facts -- never spec/project text.
13
+ - files_changed is emitted as a COUNT only, never the paths list.
14
+
15
+ Usage: proof-analytics-props.py <proof.json>
16
+ Fail-open: any error prints nothing and exits non-zero; the caller drops it.
17
+ """
18
+ import json
19
+ import sys
20
+
21
+
22
+ def main():
23
+ if len(sys.argv) < 2:
24
+ return 1
25
+ try:
26
+ p = json.load(open(sys.argv[1]))
27
+ except Exception:
28
+ return 1
29
+
30
+ honesty = p.get("honesty") or {}
31
+ council = p.get("council") or {}
32
+ facts = p.get("facts") or {}
33
+ qg = p.get("quality_gates") or {}
34
+ fc = p.get("files_changed") or {}
35
+
36
+ gates_passed = qg.get("passed")
37
+ gates_total = qg.get("total")
38
+ try:
39
+ rate = round(int(gates_passed) / int(gates_total), 3) if gates_total else None
40
+ except Exception:
41
+ rate = None
42
+
43
+ out = {
44
+ "headline": str(honesty.get("headline") or ""), # bounded enum only
45
+ "evidence_gate_verdict": str(
46
+ honesty.get("evidence_gate_verdict")
47
+ or (facts.get("evidence_gate") or {}).get("verdict")
48
+ or ""
49
+ ),
50
+ "final_verdict": str(council.get("final_verdict") or ""),
51
+ "iterations": p.get("iterations"),
52
+ "files_changed_count": fc.get("count") if isinstance(fc, dict) else None,
53
+ "wall_clock_sec": p.get("wall_clock_sec"),
54
+ "gates_passed": gates_passed,
55
+ "gates_total": gates_total,
56
+ "gate_pass_rate": rate,
57
+ }
58
+
59
+ for k, v in out.items():
60
+ if v is None or v == "":
61
+ continue
62
+ # key=value; sanitize so a value can never inject a second pair or a newline.
63
+ v = str(v).replace("\n", " ").replace("=", "-")
64
+ sys.stdout.write("%s=%s\n" % (k, v))
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())
@@ -22,6 +22,7 @@ import hashlib
22
22
  import json
23
23
  import os
24
24
  import random
25
+ import re
25
26
  import string
26
27
  import subprocess
27
28
  import sys
@@ -37,6 +38,8 @@ if _HERE not in sys.path:
37
38
  import proof_redact # noqa: E402
38
39
  from efficiency_cost import collect_efficiency as _collect_efficiency # noqa: E402
39
40
  import effort_estimator # noqa: E402
41
+ from tree_digest import MANIFEST_VERSION, compute_tree_digest # noqa: E402
42
+ from workspace_diff import collect_workspace_diff # noqa: E402
40
43
 
41
44
 
42
45
  # ---------------------------------------------------------------------------
@@ -326,6 +329,38 @@ def _collect_quality_gates(loki_dir):
326
329
  {"name": "unit_tests", "status": _norm_gate_status(st)}
327
330
  )
328
331
  total += 1
332
+ # The iteration loop writes the authoritative unresolved-gate set here.
333
+ # These failures must be merged even when individual pass markers exist.
334
+ # Otherwise a run can stop on code_review while the receipt reports only
335
+ # static_analysis and unit_tests, producing a cryptographically valid but
336
+ # semantically false green receipt.
337
+ failure_path = os.path.join(loki_dir, "quality", "gate-failures.txt")
338
+ try:
339
+ with open(failure_path, "r", encoding="utf-8") as handle:
340
+ raw_failures = handle.read(8192)
341
+ except OSError:
342
+ raw_failures = ""
343
+ failed_names = []
344
+ for token in re.split(r"[,\s]+", raw_failures):
345
+ name = token.strip()
346
+ if not name or not re.fullmatch(r"[A-Za-z0-9_.:-]+", name):
347
+ continue
348
+ name = re.sub(r"_(PAUSED|ESCALATED)$", "", name,
349
+ flags=re.IGNORECASE)
350
+ if name not in failed_names:
351
+ failed_names.append(name)
352
+ if failed_names:
353
+ by_name = {str(g.get("name") or ""): g for g in gates}
354
+ for name in failed_names:
355
+ if name in by_name:
356
+ by_name[name]["status"] = "failed"
357
+ else:
358
+ gate = {"name": name, "status": "failed"}
359
+ gates.append(gate)
360
+ by_name[name] = gate
361
+
362
+ total = len(gates)
363
+ passed = sum(1 for gate in gates if gate.get("status") == "passed")
329
364
  return {"passed": passed, "total": total, "gates": gates}
330
365
 
331
366
 
@@ -378,6 +413,88 @@ def _collect_build(loki_dir):
378
413
  return out
379
414
 
380
415
 
416
+ def _collect_termination(loki_dir, session_exit_code=None):
417
+ """Read the supervised-process termination marker.
418
+
419
+ The marker is written before proof generation when INT or TERM ends a
420
+ supervised build. File presence is fail-closed: a truncated marker still
421
+ proves that the process did not complete normally.
422
+ """
423
+ path = os.path.join(loki_dir, "state", "termination.json")
424
+ out = {
425
+ "terminated": False,
426
+ "status": "completed",
427
+ "reason": "",
428
+ "signal": "",
429
+ "exit_code": None,
430
+ "outcome": "",
431
+ "run_status": "",
432
+ }
433
+ state_paths = [os.path.join(loki_dir, "autonomy-state.json")]
434
+ sessions_dir = os.path.join(loki_dir, "sessions")
435
+ try:
436
+ for entry in os.listdir(sessions_dir):
437
+ state_paths.append(
438
+ os.path.join(sessions_dir, entry, "autonomy-state.json")
439
+ )
440
+ except OSError:
441
+ pass
442
+ state_paths = [path for path in state_paths if os.path.isfile(path)]
443
+ if state_paths:
444
+ latest_state = max(state_paths, key=os.path.getmtime)
445
+ state = _read_json(latest_state, default=None)
446
+ if isinstance(state, dict):
447
+ out["run_status"] = str(state.get("status") or "").strip().lower()
448
+ completion = _read_json(
449
+ os.path.join(loki_dir, "state", "completion.json"), default=None
450
+ )
451
+ if isinstance(completion, dict):
452
+ outcome = str(completion.get("outcome") or "").strip().lower()
453
+ out["outcome"] = outcome
454
+ if outcome:
455
+ out["status"] = outcome
456
+ if outcome not in ("complete", "completed", "success"):
457
+ out["reason"] = outcome
458
+ if session_exit_code is not None:
459
+ out["exit_code"] = session_exit_code
460
+ if session_exit_code != 0 and not out["reason"]:
461
+ out["reason"] = "nonzero_session_exit"
462
+ if not os.path.exists(path):
463
+ return out
464
+ raw = _read_json(path, default=None)
465
+ out["terminated"] = True
466
+ out["status"] = "interrupted"
467
+ if not isinstance(raw, dict):
468
+ out["reason"] = "invalid_termination_record"
469
+ return out
470
+ out["reason"] = str(raw.get("reason") or "supervisor_signal")
471
+ out["signal"] = str(raw.get("signal") or "")
472
+ ec = raw.get("exit_code")
473
+ out["exit_code"] = _to_int(ec, None) if ec is not None else None
474
+ return out
475
+
476
+
477
+ def _collect_model(loki_dir, observed):
478
+ """Return the dispatched model when recorded, otherwise ``unavailable``."""
479
+ for value in (
480
+ observed,
481
+ os.environ.get("LOKI_CURRENT_MODEL"),
482
+ os.environ.get("LOKI_SESSION_MODEL"),
483
+ os.environ.get("SESSION_MODEL"),
484
+ ):
485
+ if str(value or "").strip():
486
+ return str(value).strip()
487
+ policy = _read_json(
488
+ os.path.join(loki_dir, "state", "execution-policy.json"), default={}
489
+ )
490
+ model = policy.get("model") if isinstance(policy, dict) else None
491
+ if isinstance(model, dict):
492
+ value = model.get("sdk_id") or model.get("alias")
493
+ if str(value or "").strip():
494
+ return str(value).strip()
495
+ return "unavailable"
496
+
497
+
381
498
  def _collect_security(loki_dir):
382
499
  """Read .loki/quality/security-findings.json (the secure-by-default gate).
383
500
 
@@ -584,6 +701,53 @@ def _collect_evidence_gate(loki_dir):
584
701
  return out
585
702
 
586
703
 
704
+ def _classify_func_axis(raw):
705
+ """Map one recorded functional axis {ok, inconclusive, reason} to an HONEST
706
+ tri-state. This is the trust-critical rule -- get it wrong and the receipt
707
+ fabricates a green:
708
+
709
+ - PROVEN iff ok is True AND NOT inconclusive (a fresh positive proof:
710
+ the record survived / the 401 was observed / the scan was clean).
711
+ - GAP iff ok is False AND NOT inconclusive (freshly disproven).
712
+ - NOT_CHECKED otherwise (inconclusive, or absent). Never a green, never a
713
+ gap -- it was not proven and it was not disproven.
714
+
715
+ inconclusive DOMINATES ok: an axis flagged inconclusive is never proven even
716
+ if ok happens to be true (the gate writes ok:true as its non-blocking default
717
+ when it could not run, so ok alone is not evidence)."""
718
+ if not isinstance(raw, dict):
719
+ return "not_checked", ""
720
+ reason = str(raw.get("reason") or "")
721
+ if raw.get("inconclusive") is True:
722
+ return "not_checked", reason
723
+ if raw.get("ok") is True:
724
+ return "proven", reason
725
+ if raw.get("ok") is False:
726
+ return "gap", reason
727
+ return "not_checked", reason
728
+
729
+
730
+ def _collect_functionality(loki_dir):
731
+ """Read the nomock/persistence/auth axes from evidence-gate-details.json and
732
+ surface each as an HONEST proof fact. Deterministic + re-derivable: the values
733
+ come straight from the recorded axes, no LLM opinion.
734
+
735
+ Shape (per axis): {state: proven|gap|not_checked, reason}. Only `proven` is a
736
+ green receipt row; `gap` is an honest disproven row (lands in degraded[]);
737
+ `not_checked` is omitted from the receipt's green rows entirely. Absent file
738
+ or absent axis -> not_checked (the gate did not record it -> nothing proven)."""
739
+ raw = _read_json(
740
+ os.path.join(loki_dir, "council", "evidence-gate-details.json"),
741
+ default=None,
742
+ )
743
+ axes = raw if isinstance(raw, dict) else {}
744
+ out = {}
745
+ for axis in ("nomock", "persistence", "auth", "authorization"):
746
+ state, reason = _classify_func_axis(axes.get(axis))
747
+ out[axis] = {"state": state, "reason": reason}
748
+ return out
749
+
750
+
587
751
  def _diff_sha256(files_changed):
588
752
  """sha256 of the canonical diff stat (count/insertions/deletions/files).
589
753
 
@@ -602,93 +766,12 @@ def _diff_sha256(files_changed):
602
766
 
603
767
 
604
768
  def _git_diffstat(target_dir, include_diffs):
605
- """Return (files_changed dict, diffs list|None).
606
-
607
- base = $_LOKI_ITER_START_SHA, else HEAD~1. Non-git -> empty.
608
- """
609
- empty = {"count": 0, "insertions": 0, "deletions": 0, "files": []}
610
-
611
- def _git(args):
612
- try:
613
- out = subprocess.run(
614
- ["git", "-C", target_dir] + args,
615
- capture_output=True, text=True, timeout=30,
616
- )
617
- if out.returncode != 0:
618
- return None
619
- return out.stdout
620
- except Exception:
621
- return None
622
-
623
- # Confirm we are in a git repo.
624
- if _git(["rev-parse", "--is-inside-work-tree"]) is None:
625
- return empty, (None if not include_diffs else None)
626
-
627
- base = os.environ.get("_LOKI_ITER_START_SHA", "").strip()
628
- if not base:
629
- base = "HEAD~1"
630
-
631
- numstat = _git(["diff", "--numstat", base, "HEAD"])
632
- if numstat is None:
633
- # base may be invalid (shallow / first commit); fall back to HEAD only.
634
- numstat = _git(["diff", "--numstat", "HEAD"])
635
- if numstat is None:
636
- return empty, (None if not include_diffs else None)
637
-
638
- files = []
639
- ins_total = 0
640
- del_total = 0
641
- for line in numstat.splitlines():
642
- parts = line.split("\t")
643
- if len(parts) < 3:
644
- continue
645
- ins_s, del_s, path = parts[0], parts[1], parts[2]
646
- ins = _to_int(ins_s) if ins_s != "-" else 0
647
- dele = _to_int(del_s) if del_s != "-" else 0
648
- ins_total += ins
649
- del_total += dele
650
- files.append({
651
- "path": path,
652
- "insertions": ins,
653
- "deletions": dele,
654
- "status": "binary" if ins_s == "-" else "modified",
655
- })
656
-
657
- files_changed = {
658
- "count": len(files),
659
- "insertions": ins_total,
660
- "deletions": del_total,
661
- "files": files,
662
- }
663
-
664
- diffs = None
665
- if include_diffs:
666
- diffs = []
667
- patch = _git(["diff", base, "HEAD"])
668
- if patch is None:
669
- patch = _git(["diff", "HEAD"])
670
- if patch:
671
- # Split per file on the diff --git markers, preserving the header.
672
- chunks = []
673
- current = []
674
- for line in patch.splitlines(keepends=True):
675
- if line.startswith("diff --git ") and current:
676
- chunks.append("".join(current))
677
- current = [line]
678
- else:
679
- current.append(line)
680
- if current:
681
- chunks.append("".join(current))
682
- for chunk in chunks:
683
- # Best-effort path extraction from the "diff --git a/x b/x" line.
684
- p = ""
685
- first = chunk.splitlines()[0] if chunk else ""
686
- bits = first.split(" b/")
687
- if len(bits) == 2:
688
- p = bits[1].strip()
689
- diffs.append({"path": p, "patch": chunk})
690
-
691
- return files_changed, diffs
769
+ """Return the final worktree diff relative to this iteration's base."""
770
+ return collect_workspace_diff(
771
+ target_dir,
772
+ os.environ.get("_LOKI_ITER_START_SHA", "").strip(),
773
+ include_diffs,
774
+ )
692
775
 
693
776
 
694
777
  def _collect_iterations(loki_dir):
@@ -823,7 +906,7 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
823
906
 
824
907
  cost, model_from_eff = _collect_efficiency(loki_dir)
825
908
  provider_name = args.provider or os.environ.get("PROVIDER_NAME") or "claude"
826
- model = model_from_eff or os.environ.get("SESSION_MODEL") or ""
909
+ model = _collect_model(loki_dir, model_from_eff)
827
910
 
828
911
  files_changed, diffs = _git_diffstat(target_dir, args.include_diffs)
829
912
  iterations = _collect_iterations(loki_dir)
@@ -832,11 +915,13 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
832
915
  quality_gates = _collect_quality_gates(loki_dir)
833
916
 
834
917
  build = _collect_build(loki_dir)
918
+ termination = _collect_termination(loki_dir, args.session_exit_code)
835
919
  tests = _collect_tests(loki_dir)
836
920
  security = _collect_security(loki_dir)
837
921
  functional = _collect_functional(loki_dir) # FV-2 record-half: descriptive only
838
922
  healthcheck = _collect_healthcheck(loki_dir) # Evidence Receipt record-half
839
923
  evidence_gate = _collect_evidence_gate(loki_dir)
924
+ functionality = _collect_functionality(loki_dir) # func axes as HONEST facts
840
925
 
841
926
  deployed_url = os.environ.get("LOKI_DEPLOYED_URL") or None
842
927
 
@@ -863,9 +948,14 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
863
948
  "head_sha": _git_head_sha(target_dir),
864
949
  "diff": files_changed,
865
950
  "diff_sha256": _diff_sha256(files_changed),
951
+ # Exact source snapshot verified by this receipt. Dashboard supervisors
952
+ # recompute it after runner exit to reject proofs followed by code edits.
953
+ "tree_sha256": compute_tree_digest(target_dir),
954
+ "tree_manifest_version": MANIFEST_VERSION,
866
955
  }
867
956
  facts = {
868
957
  "git": git_facts,
958
+ "execution": termination,
869
959
  "build": build,
870
960
  "tests": tests,
871
961
  "quality_gates": [
@@ -878,6 +968,14 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
878
968
  # _compute_degraded, so it does not (yet) change the verdict. Wiring it into
879
969
  # the green headline is the founder-gated trust-semantics decision.
880
970
  "functional": functional,
971
+ # Functionality-proving axes (nomock / persistence / auth), surfaced as
972
+ # HONEST facts straight from the recorded evidence-gate axes. Each is
973
+ # {state: proven|gap|not_checked, reason}. ONLY `proven` (a fresh ok:true)
974
+ # is a green receipt row; `not_checked` (inconclusive/absent) is never a
975
+ # green and never a gap; `gap` (a fresh ok:false) is a disproven row and
976
+ # ALSO lands in degraded[] (below), so it forces VERIFIED WITH GAPS and can
977
+ # never hide behind a green headline. Deterministic + re-derivable.
978
+ "functionality": functionality,
881
979
  # Evidence Receipt (record half): did the built app come up + respond?
882
980
  # Descriptive; NOT read by _compute_headline (gating is founder-gated).
883
981
  "healthcheck": healthcheck,
@@ -966,6 +1064,9 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
966
1064
  "quality_gates": quality_gates,
967
1065
  "cost": cost,
968
1066
  "deployment": deployment,
1067
+ # Typed compatibility mirror for consumers that do not traverse facts.
1068
+ "tree_sha256": git_facts["tree_sha256"],
1069
+ "tree_manifest_version": git_facts["tree_manifest_version"],
969
1070
  # Rank 6: work-based engineering-hours estimate (top-level, NOT a fact).
970
1071
  "effort_estimate": effort_estimate,
971
1072
  # v1.1 evidence model (additive).
@@ -988,6 +1089,28 @@ def _compute_degraded(facts):
988
1089
  # must SHOW it (otherwise a failed test would render an amber banner whose
989
1090
  # "items below" list is empty -- the exact misleading state we forbid).
990
1091
  weak = ("not_run", "inconclusive", "skipped", "failed")
1092
+ execution = facts.get("execution") or {}
1093
+ if execution.get("terminated"):
1094
+ signal_name = execution.get("signal") or "unknown"
1095
+ reason = execution.get("reason") or "interrupted"
1096
+ out.append({"item": "execution", "status": "failed",
1097
+ "reason": "%s (%s)" % (reason, signal_name)})
1098
+ else:
1099
+ outcome = str(execution.get("outcome") or "").lower()
1100
+ run_status = str(execution.get("run_status") or "").lower()
1101
+ if execution.get("exit_code") not in (None, 0):
1102
+ out.append({"item": "execution", "status": "failed",
1103
+ "reason": "exit_code=%s" % execution.get("exit_code")})
1104
+ elif outcome and outcome not in ("complete", "completed", "success"):
1105
+ out.append({"item": "execution", "status": "failed",
1106
+ "reason": outcome})
1107
+ elif run_status in {
1108
+ "failed", "force_stopped", "inconclusive_spec_contradiction",
1109
+ "interrupted", "max_iterations_reached", "max_retries_exceeded",
1110
+ "paused", "policy_blocked", "provider_deadline_partial_mutation",
1111
+ }:
1112
+ out.append({"item": "execution", "status": "failed",
1113
+ "reason": run_status})
991
1114
  tests = facts.get("tests") or {}
992
1115
  if tests.get("status") in weak:
993
1116
  reason = "no test command recorded" if not tests.get("command") \
@@ -1018,6 +1141,19 @@ def _compute_degraded(facts):
1018
1141
  if not (git.get("diff") or {}).get("count"):
1019
1142
  out.append({"item": "git.diff", "status": "not_run",
1020
1143
  "reason": "no file changes detected"})
1144
+ # Functionality axes: ONLY a freshly-disproven axis (state == gap, i.e. the
1145
+ # gate ran and the axis FAILED -- a record did not survive, auth was NOT
1146
+ # enforced, the diff shipped mock data) is a gap in the proof of done. A
1147
+ # `not_checked` axis (inconclusive / not attempted) is deliberately NOT a gap:
1148
+ # the honesty rule is that not-proven is not the same as disproven, and the
1149
+ # gate already passes those through. Surfacing them here would spam the ledger
1150
+ # with "we didn't check X" for every axis the driver could not exercise.
1151
+ fnc = facts.get("functionality") or {}
1152
+ for axis in ("nomock", "persistence", "auth", "authorization"):
1153
+ rec = fnc.get(axis) or {}
1154
+ if rec.get("state") == "gap":
1155
+ out.append({"item": "functionality:%s" % axis, "status": "failed",
1156
+ "reason": rec.get("reason") or "axis disproven"})
1021
1157
  return out
1022
1158
 
1023
1159
 
@@ -1057,8 +1193,31 @@ def _compute_headline(facts, degraded):
1057
1193
  and fn.get("ran")
1058
1194
  and fn.get("functional_status") == "failed"
1059
1195
  )
1196
+ execution = facts.get("execution") or {}
1197
+ execution_outcome = str(execution.get("outcome") or "").lower()
1198
+ failed_run_statuses = {
1199
+ "failed",
1200
+ "force_stopped",
1201
+ "inconclusive_spec_contradiction",
1202
+ "interrupted",
1203
+ "max_iterations_reached",
1204
+ "max_retries_exceeded",
1205
+ "paused",
1206
+ "policy_blocked",
1207
+ "provider_deadline_partial_mutation",
1208
+ }
1209
+ execution_failed = bool(
1210
+ execution.get("terminated")
1211
+ or execution.get("exit_code") not in (None, 0)
1212
+ or str(execution.get("run_status") or "").lower() in failed_run_statuses
1213
+ or (
1214
+ execution_outcome
1215
+ and execution_outcome not in ("complete", "completed", "success")
1216
+ )
1217
+ )
1060
1218
  any_failed = (
1061
- tests.get("status") == "failed"
1219
+ execution_failed
1220
+ or tests.get("status") == "failed"
1062
1221
  or build.get("status") == "failed"
1063
1222
  or any(g.get("status") == "failed"
1064
1223
  for g in (facts.get("quality_gates") or []))
@@ -1165,6 +1324,15 @@ def _build_social_hook(proof):
1165
1324
  return " - ".join(parts)
1166
1325
 
1167
1326
 
1327
+ def _receipt_title(proof):
1328
+ """Conservative user-facing verdict label for the rendered receipt."""
1329
+ headline = str((proof.get("honesty") or {}).get("headline") or "").upper()
1330
+ return {
1331
+ "VERIFIED": "Recorded checks passed",
1332
+ "VERIFIED WITH GAPS": "Checks completed with gaps",
1333
+ }.get(headline, "Not verified")
1334
+
1335
+
1168
1336
  def _attr_esc(s):
1169
1337
  """HTML-attribute-escape a string destined for content="...".`"""
1170
1338
  return (str(s).replace("&", "&amp;").replace('"', "&quot;")
@@ -1213,10 +1381,14 @@ def _render_fallback_html(proof):
1213
1381
  rows.append("<ul>")
1214
1382
  rows.append("<li>Cost (USD): %s</li>" % (
1215
1383
  esc(usd_disp) if usd_disp is not None else "not recorded for this run"))
1216
- rows.append("<li>Input tokens: %s</li>" % esc(cost.get("input_tokens", 0)))
1217
- rows.append("<li>Output tokens: %s</li>" % esc(cost.get("output_tokens", 0)))
1218
- rows.append("<li>Cache read tokens: %s</li>" % esc(cost.get("cache_read_tokens", 0)))
1219
- rows.append("<li>Cache creation tokens: %s</li>" % esc(cost.get("cache_creation_tokens", 0)))
1384
+ def token_value(key):
1385
+ value = cost.get(key)
1386
+ return value if value is not None else "not recorded"
1387
+
1388
+ rows.append("<li>Input tokens: %s</li>" % esc(token_value("input_tokens")))
1389
+ rows.append("<li>Output tokens: %s</li>" % esc(token_value("output_tokens")))
1390
+ rows.append("<li>Cache read tokens: %s</li>" % esc(token_value("cache_read_tokens")))
1391
+ rows.append("<li>Cache creation tokens: %s</li>" % esc(token_value("cache_creation_tokens")))
1220
1392
  rows.append("<li>Wall clock (sec): %s</li>" % esc(proof.get("wall_clock_sec", 0)))
1221
1393
  rows.append("</ul>")
1222
1394
 
@@ -1269,6 +1441,9 @@ def _render_html(proof, repo_root):
1269
1441
  tpl = _read_text(template_path, default="")
1270
1442
  marker = "__PROOF_JSON__"
1271
1443
  if tpl and marker in tpl:
1444
+ receipt_title = _receipt_title(proof)
1445
+ tpl = tpl.replace("__PROOF_RECEIPT_TITLE__", _attr_esc(receipt_title))
1446
+ tpl = tpl.replace("__PROOF_RECEIPT_TITLE_JSON__", json.dumps(receipt_title))
1272
1447
  # Substitute the dynamic social hook BEFORE the JSON payload, so a proof
1273
1448
  # value that happens to contain the hook token cannot get clobbered.
1274
1449
  # The hook embeds the real measured cost + files-changed + council ratio
@@ -1406,7 +1581,14 @@ def generate(args):
1406
1581
  return out_dir
1407
1582
 
1408
1583
 
1409
- def main(argv=None):
1584
+ def build_parser():
1585
+ """The generator's CLI parser.
1586
+
1587
+ Split out of main() so callers (notably the tests, which drive generate()
1588
+ directly with a synthetic args object) can obtain every argument at its
1589
+ declared default instead of hand-listing fields that go stale whenever a new
1590
+ argument lands here.
1591
+ """
1410
1592
  parser = argparse.ArgumentParser(description="Loki Mode proof-of-run generator")
1411
1593
  parser.add_argument("--loki-dir", default=".loki")
1412
1594
  parser.add_argument("--out-dir", default="")
@@ -1414,8 +1596,13 @@ def main(argv=None):
1414
1596
  parser.add_argument("--run-id", default="")
1415
1597
  parser.add_argument("--loki-version", default="")
1416
1598
  parser.add_argument("--provider", default="")
1599
+ parser.add_argument("--session-exit-code", type=int, default=None)
1417
1600
  parser.add_argument("--quiet", action="store_true")
1418
- args = parser.parse_args(argv)
1601
+ return parser
1602
+
1603
+
1604
+ def main(argv=None):
1605
+ args = build_parser().parse_args(argv)
1419
1606
 
1420
1607
  try:
1421
1608
  generate(args)