loki-mode 7.129.5 → 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.
- package/README.md +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4417 -676
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- 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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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
|
-
|
|
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("&", "&").replace('"', """)
|
|
@@ -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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
|
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
|
-
|
|
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)
|