loki-mode 7.84.0 → 7.86.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 +76 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/autonomy/lib/proof-generator.py +407 -13
- package/autonomy/lib/proof-template.html +309 -1
- package/autonomy/lib/proof-verify.py +483 -0
- package/autonomy/loki +32 -1
- package/autonomy/run.sh +26 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +56 -0
- package/dashboard/static/index.html +93 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,82 @@ _The free, source-available autonomous coding agent by [Autonomi](https://www.au
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
+
## Loki does not lie about "done"
|
|
49
|
+
|
|
50
|
+
Most coding agents declare a task done by telling you so in a transcript. The
|
|
51
|
+
transcript is the agent's own narration; there is nothing to check. Loki Mode
|
|
52
|
+
takes a different stance: it does not call work done until the work is verified,
|
|
53
|
+
and every build produces an **Evidence Receipt** you can re-verify yourself.
|
|
54
|
+
|
|
55
|
+
The receipt separates two things most tools blur together:
|
|
56
|
+
|
|
57
|
+
- **Facts** -- deterministic, non-LLM, and re-derivable by anyone: the git diff
|
|
58
|
+
(base/head SHAs, file/insertion/deletion counts, a `diff_sha256`), the test
|
|
59
|
+
command that ran with its exit code, the build command with its exit code, and
|
|
60
|
+
each quality-gate verdict. A skeptic can recompute every one of these from the
|
|
61
|
+
same repo state.
|
|
62
|
+
- **Assessments** -- AI judgments such as the review council's verdict. These are
|
|
63
|
+
labeled explicitly as judgment, not proof, and never make the headline green on
|
|
64
|
+
their own.
|
|
65
|
+
|
|
66
|
+
The receipt's headline is computed only from the facts:
|
|
67
|
+
|
|
68
|
+
- **VERIFIED** -- tests recorded a real command, ran, and exited 0; the diff is
|
|
69
|
+
non-empty; nothing was skipped.
|
|
70
|
+
- **VERIFIED WITH GAPS** -- some facts checked out, but something was not run or
|
|
71
|
+
was inconclusive. Every gap is listed by name, so silence never reads as a pass.
|
|
72
|
+
- **NOT VERIFIED** -- a test, build, or gate ran and failed (or there was nothing
|
|
73
|
+
to verify).
|
|
74
|
+
|
|
75
|
+
This is honesty-of-done, not a claim of perfection. The receipt proves the
|
|
76
|
+
completion claim is backed by deterministic evidence and is independently
|
|
77
|
+
re-checkable; it does not claim the generated code is bug-free.
|
|
78
|
+
|
|
79
|
+
### Verify it yourself
|
|
80
|
+
|
|
81
|
+
Receipts are written to `.loki/proofs/<run_id>/` automatically at run completion
|
|
82
|
+
(opt out with `LOKI_PROOF=0`). Inspect and re-check them with `loki proof`
|
|
83
|
+
(aliased as `loki receipt`):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
loki proof list # every receipt: run id, time, council verdict, cost, files
|
|
87
|
+
loki proof show <id> # the full proof.json (facts, assessments, honesty)
|
|
88
|
+
loki proof verify <id> # re-check the receipt against the repo (exit 0 clean, 1 tamper/drift)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`loki proof verify` does two independent checks and prints the result as JSON:
|
|
92
|
+
|
|
93
|
+
- **Tamper check** -- recomputes the receipt's integrity hash and compares it to
|
|
94
|
+
the recorded one. If anyone edited the receipt after it was written, `hash_ok`
|
|
95
|
+
is `false`.
|
|
96
|
+
- **Drift check** -- re-runs the diff from the recorded base SHA against the
|
|
97
|
+
current repo and compares the file/insertion/deletion counts and `diff_sha256`
|
|
98
|
+
to what the receipt recorded. If the repo no longer matches, `diff_drift` is
|
|
99
|
+
`true`.
|
|
100
|
+
|
|
101
|
+
A clean receipt prints `"ok": true` and exits 0. A tampered or drifted receipt
|
|
102
|
+
exits 1. When a check cannot run (for example a receipt with no recorded base
|
|
103
|
+
SHA), the verifier reports it as unverifiable rather than passing it silently.
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"hash_ok": true,
|
|
108
|
+
"diff_drift": false,
|
|
109
|
+
"gpg_ok": "n/a",
|
|
110
|
+
"degraded": [],
|
|
111
|
+
"reason": "",
|
|
112
|
+
"ok": true
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
You can share a receipt as a self-contained HTML page (`loki proof open <id>`),
|
|
117
|
+
or publish it as a GitHub Gist with `loki proof share <id>` (opt-in; the page is
|
|
118
|
+
redacted before it leaves your machine). An optional, off-by-default GPG detached
|
|
119
|
+
signature (`LOKI_PROOF_GPG_KEY`) lets a third party confirm the receipt came from
|
|
120
|
+
you.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
48
124
|
## Get Started in 30 Seconds
|
|
49
125
|
|
|
50
126
|
```bash
|
package/SKILL.md
CHANGED
|
@@ -3,12 +3,14 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
6
|
+
# Loki Mode v7.86.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
10
10
|
**Spec in, verified product out.** Spec-driven: a "spec" is whatever describes the work -- a Markdown PRD, a GitHub issue, an OpenAPI doc, a Jira ticket (a PRD is one form of spec). The differentiator is the trust layer: Loki does not call work done until it is verified. The RARV-C closure loop, 8 quality gates, the completion council, and the verified-completion evidence gate must all clear before completion is accepted.
|
|
11
11
|
|
|
12
|
+
**Evidence Receipt (verify it yourself).** Every run writes a receipt to `.loki/proofs/<run_id>/` (opt out with `LOKI_PROOF=0`) that separates deterministic FACTS (git diff with base/head SHAs and a `diff_sha256`, the test command + exit code, the build command + exit code, each gate verdict) from AI ASSESSMENTS (the council verdict, labeled judgment not proof). The headline is computed only from the facts: VERIFIED (tests ran a real command and exited 0, diff non-empty, nothing skipped), VERIFIED WITH GAPS (each gap listed by name), or NOT VERIFIED (a check ran and failed). Inspect and re-check with `loki proof list|show <id>|verify <id>` (aliased `loki receipt`); `loki proof verify` re-hashes the receipt (tamper) and re-derives the diff from the recorded base SHA against the live repo (drift), exiting 0 clean / 1 tamper-or-drift. This is honesty-of-done, not a claim that the code is bug-free.
|
|
13
|
+
|
|
12
14
|
**Provider-agnostic (stable since v5.0.0):** runs on Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers; no vendor lock-in. Gemini deprecated v7.5.18. See `skills/providers.md`. **Current track (v7.7.x):** LSP grounding as first-class agent tool (v7.7.0-v7.7.9; lsp_get_diagnostics actually-returns-diagnostics regression fix v7.7.14), provider_source cli (v7.7.11-v7.7.12 bash/bun parity), Docker/bash-3.2 robustness (v7.7.13), audit chain cross-file verification fix (v7.7.15), Phase 1 RARV-C closure (real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`).
|
|
13
15
|
|
|
14
16
|
**Runtime migration:** Bash-to-Bun migration. Read-only commands (`version`, `status`, `stats`, `doctor`, `provider show/list`, `memory list/index`) flow through Bun runtime via `bin/loki` since v7.3.0. Every other command remains on the Bash runtime (`autonomy/loki`). Rollback: `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md`.
|
|
@@ -406,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
406
408
|
|
|
407
409
|
---
|
|
408
410
|
|
|
409
|
-
**v7.
|
|
411
|
+
**v7.86.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.86.0
|
|
@@ -27,7 +27,7 @@ import subprocess
|
|
|
27
27
|
import sys
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
|
|
30
|
-
SCHEMA_VERSION = "1.
|
|
30
|
+
SCHEMA_VERSION = "1.1"
|
|
31
31
|
|
|
32
32
|
# Make proof_redact importable regardless of cwd.
|
|
33
33
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -170,6 +170,28 @@ def _collect_council(loki_dir):
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
def _norm_gate_status(raw):
|
|
174
|
+
"""Map a recorded gate value to one of {passed,failed,inconclusive,not_run}.
|
|
175
|
+
|
|
176
|
+
A bare bool means the gate ran with a clear outcome. A string is normalized
|
|
177
|
+
so e.g. "skip"/"skipped" -> not_run and "inconclusive" stays inconclusive.
|
|
178
|
+
Never conflate a missing/not-run gate with passed: an unrecognized truthy
|
|
179
|
+
string is reported verbatim (lowercased) rather than silently "passed".
|
|
180
|
+
"""
|
|
181
|
+
if isinstance(raw, bool):
|
|
182
|
+
return "passed" if raw else "failed"
|
|
183
|
+
s = str(raw).strip().lower()
|
|
184
|
+
if s in ("passed", "pass", "true", "ok", "verified"):
|
|
185
|
+
return "passed"
|
|
186
|
+
if s in ("failed", "fail", "false", "error"):
|
|
187
|
+
return "failed"
|
|
188
|
+
if s in ("inconclusive", "unknown", "partial"):
|
|
189
|
+
return "inconclusive"
|
|
190
|
+
if s in ("not_run", "notrun", "skip", "skipped", "n/a", "na", "", "none"):
|
|
191
|
+
return "not_run"
|
|
192
|
+
return s
|
|
193
|
+
|
|
194
|
+
|
|
173
195
|
def _collect_quality_gates(loki_dir):
|
|
174
196
|
gates_raw = _read_json(
|
|
175
197
|
os.path.join(loki_dir, "state", "quality-gates.json"), default=None
|
|
@@ -179,16 +201,15 @@ def _collect_quality_gates(loki_dir):
|
|
|
179
201
|
total = 0
|
|
180
202
|
if isinstance(gates_raw, dict):
|
|
181
203
|
for name, val in gates_raw.items():
|
|
182
|
-
|
|
183
|
-
if isinstance(val, bool):
|
|
184
|
-
status = "passed" if val else "failed"
|
|
185
|
-
elif isinstance(val, dict):
|
|
204
|
+
if isinstance(val, dict):
|
|
186
205
|
if "passed" in val:
|
|
187
|
-
status =
|
|
206
|
+
status = _norm_gate_status(val.get("passed"))
|
|
188
207
|
elif "status" in val:
|
|
189
|
-
status =
|
|
208
|
+
status = _norm_gate_status(val.get("status"))
|
|
209
|
+
else:
|
|
210
|
+
status = "not_run"
|
|
190
211
|
else:
|
|
191
|
-
status =
|
|
212
|
+
status = _norm_gate_status(val)
|
|
192
213
|
gates.append({"name": str(name), "status": status})
|
|
193
214
|
total += 1
|
|
194
215
|
if status == "passed":
|
|
@@ -196,6 +217,161 @@ def _collect_quality_gates(loki_dir):
|
|
|
196
217
|
return {"passed": passed, "total": total, "gates": gates}
|
|
197
218
|
|
|
198
219
|
|
|
220
|
+
def _collect_build(loki_dir):
|
|
221
|
+
"""Read .loki/quality/build-results.json (Slice A writes it).
|
|
222
|
+
|
|
223
|
+
Deterministic FACT, never an LLM opinion. Tolerates an absent file ->
|
|
224
|
+
status not_run. Shape: {command, exit_code, ran, duration_sec, status}.
|
|
225
|
+
"""
|
|
226
|
+
raw = _read_json(
|
|
227
|
+
os.path.join(loki_dir, "quality", "build-results.json"), default=None
|
|
228
|
+
)
|
|
229
|
+
out = {
|
|
230
|
+
"command": "",
|
|
231
|
+
"exit_code": None,
|
|
232
|
+
"ran": False,
|
|
233
|
+
"duration_sec": None,
|
|
234
|
+
"status": "not_run",
|
|
235
|
+
}
|
|
236
|
+
if not isinstance(raw, dict):
|
|
237
|
+
return out
|
|
238
|
+
out["command"] = str(raw.get("command") or "")
|
|
239
|
+
ran = bool(raw.get("ran", True))
|
|
240
|
+
out["ran"] = ran
|
|
241
|
+
ec = raw.get("exit_code")
|
|
242
|
+
out["exit_code"] = _to_int(ec, None) if ec is not None else None
|
|
243
|
+
dur = raw.get("duration_sec")
|
|
244
|
+
out["duration_sec"] = _to_float(dur, None) if dur is not None else None
|
|
245
|
+
if not ran:
|
|
246
|
+
out["status"] = "not_run"
|
|
247
|
+
elif out["exit_code"] == 0:
|
|
248
|
+
out["status"] = "verified"
|
|
249
|
+
elif out["exit_code"] is None:
|
|
250
|
+
out["status"] = "inconclusive"
|
|
251
|
+
else:
|
|
252
|
+
out["status"] = "failed"
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _norm_tests_status(raw):
|
|
257
|
+
"""Map a recorded test status to {verified,failed,inconclusive,not_run}.
|
|
258
|
+
|
|
259
|
+
Tests use "verified" (not "passed") as the green state so the headline can
|
|
260
|
+
require tests.status == verified. A truthy pass-like string -> verified.
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(raw, bool):
|
|
263
|
+
return "verified" if raw else "failed"
|
|
264
|
+
s = str(raw).strip().lower()
|
|
265
|
+
if s in ("verified", "passed", "pass", "true", "ok", "green"):
|
|
266
|
+
return "verified"
|
|
267
|
+
if s in ("failed", "fail", "false", "error", "red"):
|
|
268
|
+
return "failed"
|
|
269
|
+
if s in ("inconclusive", "unknown", "partial"):
|
|
270
|
+
return "inconclusive"
|
|
271
|
+
if s in ("not_run", "notrun", "skip", "skipped", "n/a", "na", "", "none"):
|
|
272
|
+
return "not_run"
|
|
273
|
+
return s
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _collect_tests(loki_dir):
|
|
277
|
+
"""Read .loki/quality/test-results.json.
|
|
278
|
+
|
|
279
|
+
NEW shape (Slice A): {runner, command, exit_code, passed_count,
|
|
280
|
+
failed_count, status, duration_sec}. OLD shape (back-compat):
|
|
281
|
+
{pass, runner} where pass is true / false / "inconclusive". Maps the old
|
|
282
|
+
pass flag to a status (true->verified, "inconclusive"->inconclusive,
|
|
283
|
+
false->failed, missing->not_run). Deterministic FACT.
|
|
284
|
+
"""
|
|
285
|
+
raw = _read_json(
|
|
286
|
+
os.path.join(loki_dir, "quality", "test-results.json"), default=None
|
|
287
|
+
)
|
|
288
|
+
out = {
|
|
289
|
+
"runner": "",
|
|
290
|
+
"command": "",
|
|
291
|
+
"exit_code": None,
|
|
292
|
+
"passed_count": None,
|
|
293
|
+
"failed_count": None,
|
|
294
|
+
"status": "not_run",
|
|
295
|
+
"duration_sec": None,
|
|
296
|
+
}
|
|
297
|
+
if not isinstance(raw, dict):
|
|
298
|
+
return out
|
|
299
|
+
out["runner"] = str(raw.get("runner") or "")
|
|
300
|
+
out["command"] = str(raw.get("command") or "")
|
|
301
|
+
ec = raw.get("exit_code")
|
|
302
|
+
out["exit_code"] = _to_int(ec, None) if ec is not None else None
|
|
303
|
+
pc = raw.get("passed_count")
|
|
304
|
+
out["passed_count"] = _to_int(pc, None) if pc is not None else None
|
|
305
|
+
fc = raw.get("failed_count")
|
|
306
|
+
out["failed_count"] = _to_int(fc, None) if fc is not None else None
|
|
307
|
+
dur = raw.get("duration_sec")
|
|
308
|
+
out["duration_sec"] = _to_float(dur, None) if dur is not None else None
|
|
309
|
+
|
|
310
|
+
if "status" in raw and raw.get("status"):
|
|
311
|
+
out["status"] = _norm_tests_status(raw.get("status"))
|
|
312
|
+
elif out["exit_code"] is not None:
|
|
313
|
+
out["status"] = "verified" if out["exit_code"] == 0 else "failed"
|
|
314
|
+
elif "pass" in raw:
|
|
315
|
+
# OLD shape: {pass, runner}. A bare pass:true must NOT become a green
|
|
316
|
+
# headline on its own without a real exit_code + command; it maps to a
|
|
317
|
+
# weaker "verified" here, but the headline logic additionally requires a
|
|
318
|
+
# non-empty test command before declaring the run VERIFIED.
|
|
319
|
+
p = raw.get("pass")
|
|
320
|
+
if p is True:
|
|
321
|
+
out["status"] = "verified"
|
|
322
|
+
elif isinstance(p, str) and p.strip().lower() == "inconclusive":
|
|
323
|
+
out["status"] = "inconclusive"
|
|
324
|
+
elif p is False:
|
|
325
|
+
out["status"] = "failed"
|
|
326
|
+
else:
|
|
327
|
+
out["status"] = "inconclusive"
|
|
328
|
+
else:
|
|
329
|
+
out["status"] = "not_run"
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _collect_evidence_gate(loki_dir):
|
|
334
|
+
"""Read .loki/council/evidence-gate-details.json (written on every gate run).
|
|
335
|
+
|
|
336
|
+
Deterministic FACT about whether the verified-completion evidence gate ran
|
|
337
|
+
and its verdict. Absent -> ran False. baseline_established reflects whether
|
|
338
|
+
a diff baseline was usable (diff axis not inconclusive).
|
|
339
|
+
"""
|
|
340
|
+
raw = _read_json(
|
|
341
|
+
os.path.join(loki_dir, "council", "evidence-gate-details.json"),
|
|
342
|
+
default=None,
|
|
343
|
+
)
|
|
344
|
+
out = {"ran": False, "verdict": "", "baseline_established": False}
|
|
345
|
+
if not isinstance(raw, dict):
|
|
346
|
+
return out
|
|
347
|
+
out["ran"] = True
|
|
348
|
+
out["verdict"] = str(raw.get("verdict") or "")
|
|
349
|
+
diff = raw.get("diff") if isinstance(raw.get("diff"), dict) else {}
|
|
350
|
+
# A baseline is "established" when the diff axis produced a usable result
|
|
351
|
+
# (not flagged inconclusive). This is the diff-baseline the gate compared to.
|
|
352
|
+
out["baseline_established"] = bool(
|
|
353
|
+
diff and not diff.get("inconclusive") and diff.get("ok") is not None
|
|
354
|
+
)
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _diff_sha256(files_changed):
|
|
359
|
+
"""sha256 of the canonical diff stat (count/insertions/deletions/files).
|
|
360
|
+
|
|
361
|
+
Deterministic + re-derivable: a verifier recomputes this from the same
|
|
362
|
+
files_changed object. Hashing the stat (not the full patch) keeps it stable
|
|
363
|
+
whether or not --include-diffs was passed.
|
|
364
|
+
"""
|
|
365
|
+
fc = files_changed or {}
|
|
366
|
+
canon = {
|
|
367
|
+
"count": fc.get("count", 0),
|
|
368
|
+
"insertions": fc.get("insertions", 0),
|
|
369
|
+
"deletions": fc.get("deletions", 0),
|
|
370
|
+
"files": fc.get("files", []),
|
|
371
|
+
}
|
|
372
|
+
return hashlib.sha256(_canonical(canon).encode("utf-8")).hexdigest()
|
|
373
|
+
|
|
374
|
+
|
|
199
375
|
def _git_diffstat(target_dir, include_diffs):
|
|
200
376
|
"""Return (files_changed dict, diffs list|None).
|
|
201
377
|
|
|
@@ -363,6 +539,26 @@ def _canonical(obj):
|
|
|
363
539
|
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
364
540
|
|
|
365
541
|
|
|
542
|
+
def _gpg_detached_sign(data, key_id):
|
|
543
|
+
"""Produce an ASCII-armored gpg detached signature over `data`.
|
|
544
|
+
|
|
545
|
+
Returns the armored signature string, or None on any failure (gpg missing,
|
|
546
|
+
key not found, timeout). Best-effort: signing is an optional add-on and
|
|
547
|
+
never blocks proof emission. Local-only: invokes the on-PATH gpg, no network.
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
proc = subprocess.run(
|
|
551
|
+
["gpg", "--batch", "--yes", "--armor", "--detach-sign",
|
|
552
|
+
"--local-user", key_id, "--output", "-"],
|
|
553
|
+
input=data, capture_output=True, timeout=30,
|
|
554
|
+
)
|
|
555
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
556
|
+
return None
|
|
557
|
+
return proc.stdout.decode("utf-8", errors="replace")
|
|
558
|
+
except Exception:
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
366
562
|
def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
367
563
|
generated_at = _utc_now_iso()
|
|
368
564
|
run_id = args.run_id or os.environ.get("LOKI_SESSION_ID") or _gen_run_id()
|
|
@@ -380,6 +576,10 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
|
380
576
|
council = _collect_council(loki_dir)
|
|
381
577
|
quality_gates = _collect_quality_gates(loki_dir)
|
|
382
578
|
|
|
579
|
+
build = _collect_build(loki_dir)
|
|
580
|
+
tests = _collect_tests(loki_dir)
|
|
581
|
+
evidence_gate = _collect_evidence_gate(loki_dir)
|
|
582
|
+
|
|
383
583
|
deployed_url = os.environ.get("LOKI_DEPLOYED_URL") or None
|
|
384
584
|
|
|
385
585
|
# public_url is the publish-time injection slot: None at generate time so
|
|
@@ -389,27 +589,196 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
|
389
589
|
# so the URL is redacted like every other field and folded into the hash.
|
|
390
590
|
public_url = os.environ.get("LOKI_PROOF_PUBLIC_URL") or None
|
|
391
591
|
|
|
592
|
+
wall_clock_sec = _wall_clock_sec(started_at, generated_at)
|
|
593
|
+
deployment = {"deployed_url": deployed_url, "public_url": public_url}
|
|
594
|
+
provider = {"name": provider_name, "model": model}
|
|
595
|
+
|
|
596
|
+
# ---- v1.1 evidence model -------------------------------------------------
|
|
597
|
+
# FACTS: deterministic, re-derivable, NON-LLM. A skeptic can recompute every
|
|
598
|
+
# one of these from the same .loki state. This is what makes a green receipt
|
|
599
|
+
# impossible to forge: the headline is computed ONLY from these facts.
|
|
600
|
+
git_facts = {
|
|
601
|
+
"base_sha": os.environ.get("_LOKI_ITER_START_SHA", "").strip(),
|
|
602
|
+
"head_sha": _git_head_sha(target_dir),
|
|
603
|
+
"diff": files_changed,
|
|
604
|
+
"diff_sha256": _diff_sha256(files_changed),
|
|
605
|
+
}
|
|
606
|
+
facts = {
|
|
607
|
+
"git": git_facts,
|
|
608
|
+
"build": build,
|
|
609
|
+
"tests": tests,
|
|
610
|
+
"quality_gates": [
|
|
611
|
+
{"name": g.get("name", ""), "status": g.get("status", "not_run")}
|
|
612
|
+
for g in (quality_gates.get("gates") or [])
|
|
613
|
+
],
|
|
614
|
+
"cost": cost,
|
|
615
|
+
"meta": {
|
|
616
|
+
"run_id": run_id,
|
|
617
|
+
"loki_version": loki_version,
|
|
618
|
+
"provider": provider_name,
|
|
619
|
+
"model": model,
|
|
620
|
+
"started_at": started_at,
|
|
621
|
+
"generated_at": generated_at,
|
|
622
|
+
"wall_clock_sec": wall_clock_sec,
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
# ASSESSMENTS: LLM opinions. Explicitly labeled as judgment, NOT proof. A
|
|
627
|
+
# green council verdict is an opinion that can be wrong or gamed; it never
|
|
628
|
+
# contributes to the deterministic headline.
|
|
629
|
+
completion = _read_json(
|
|
630
|
+
os.path.join(loki_dir, "state", "completion.json"), default=None
|
|
631
|
+
)
|
|
632
|
+
claimed = bool(isinstance(completion, dict) and (
|
|
633
|
+
completion.get("completed")
|
|
634
|
+
or str(completion.get("outcome") or "").lower() in (
|
|
635
|
+
"complete", "completed", "success")
|
|
636
|
+
))
|
|
637
|
+
assessments = {
|
|
638
|
+
"_note": "AI judgment, not deterministic proof",
|
|
639
|
+
"council": council,
|
|
640
|
+
"completion_claim": {
|
|
641
|
+
"claimed": claimed,
|
|
642
|
+
"evidence_gate_verdict": evidence_gate.get("verdict", ""),
|
|
643
|
+
},
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# HONESTY: every fact that is not_run/inconclusive/skipped, surfaced loudly,
|
|
647
|
+
# plus a deterministic headline a forger cannot turn green without real
|
|
648
|
+
# exit_code:0 evidence and a non-empty diff.
|
|
649
|
+
degraded = _compute_degraded(facts)
|
|
650
|
+
headline = _compute_headline(facts, degraded)
|
|
651
|
+
honesty = {
|
|
652
|
+
"headline": headline,
|
|
653
|
+
"degraded": degraded,
|
|
654
|
+
"evidence_gate": evidence_gate,
|
|
655
|
+
}
|
|
656
|
+
|
|
392
657
|
# Assemble WITHOUT redaction / verification fields (advisor ordering).
|
|
658
|
+
# Top-level flat keys are RETAINED as a back-compat mirror so existing
|
|
659
|
+
# dashboard/CLI/template readers (schema v1.0 consumers) keep working; the
|
|
660
|
+
# new facts/assessments/honesty blocks are additive.
|
|
393
661
|
proof = {
|
|
394
662
|
"schema_version": SCHEMA_VERSION,
|
|
395
663
|
"run_id": run_id,
|
|
396
664
|
"generated_at": generated_at,
|
|
397
665
|
"loki_version": loki_version,
|
|
398
666
|
"started_at": started_at,
|
|
399
|
-
"wall_clock_sec":
|
|
667
|
+
"wall_clock_sec": wall_clock_sec,
|
|
400
668
|
"spec": spec,
|
|
401
|
-
"provider":
|
|
669
|
+
"provider": provider,
|
|
402
670
|
"iterations": iterations,
|
|
403
671
|
"files_changed": files_changed,
|
|
404
672
|
"diffs": diffs,
|
|
405
673
|
"council": council,
|
|
406
674
|
"quality_gates": quality_gates,
|
|
407
675
|
"cost": cost,
|
|
408
|
-
"deployment":
|
|
676
|
+
"deployment": deployment,
|
|
677
|
+
# v1.1 evidence model (additive).
|
|
678
|
+
"facts": facts,
|
|
679
|
+
"assessments": assessments,
|
|
680
|
+
"honesty": honesty,
|
|
409
681
|
}
|
|
410
682
|
return proof, run_id
|
|
411
683
|
|
|
412
684
|
|
|
685
|
+
def _compute_degraded(facts):
|
|
686
|
+
"""List every fact whose status is not_run / inconclusive / skipped.
|
|
687
|
+
|
|
688
|
+
Each entry is {item, status, reason}. This is the explicit honesty ledger:
|
|
689
|
+
a reader sees exactly what was NOT verified rather than inferring it from
|
|
690
|
+
silence. Deterministic (derived only from facts)."""
|
|
691
|
+
out = []
|
|
692
|
+
# "failed" is included alongside the weak statuses: a hard failure is a gap in
|
|
693
|
+
# the proof of done just as much as a not-run check, and the honesty ledger
|
|
694
|
+
# must SHOW it (otherwise a failed test would render an amber banner whose
|
|
695
|
+
# "items below" list is empty -- the exact misleading state we forbid).
|
|
696
|
+
weak = ("not_run", "inconclusive", "skipped", "failed")
|
|
697
|
+
tests = facts.get("tests") or {}
|
|
698
|
+
if tests.get("status") in weak:
|
|
699
|
+
reason = "no test command recorded" if not tests.get("command") \
|
|
700
|
+
else ("exit_code=%s" % tests.get("exit_code"))
|
|
701
|
+
out.append({"item": "tests", "status": tests.get("status"),
|
|
702
|
+
"reason": reason})
|
|
703
|
+
build = facts.get("build") or {}
|
|
704
|
+
if build.get("status") in weak:
|
|
705
|
+
reason = "build not run" if not build.get("ran") \
|
|
706
|
+
else ("exit_code=%s" % build.get("exit_code"))
|
|
707
|
+
out.append({"item": "build", "status": build.get("status"),
|
|
708
|
+
"reason": reason})
|
|
709
|
+
for g in facts.get("quality_gates") or []:
|
|
710
|
+
if g.get("status") in weak:
|
|
711
|
+
out.append({"item": "quality_gate:%s" % g.get("name", ""),
|
|
712
|
+
"status": g.get("status"),
|
|
713
|
+
"reason": "gate %s" % g.get("status")})
|
|
714
|
+
git = facts.get("git") or {}
|
|
715
|
+
if not (git.get("diff") or {}).get("count"):
|
|
716
|
+
out.append({"item": "git.diff", "status": "not_run",
|
|
717
|
+
"reason": "no file changes detected"})
|
|
718
|
+
return out
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _compute_headline(facts, degraded):
|
|
722
|
+
"""Deterministic headline. NEVER green from an LLM opinion or a bare
|
|
723
|
+
pass:true. Rules:
|
|
724
|
+
- VERIFIED only when tests.status == verified AND there are no degraded
|
|
725
|
+
items AND the diff is non-empty AND tests recorded a real command.
|
|
726
|
+
- VERIFIED WITH GAPS when some facts verified but degraded is non-empty.
|
|
727
|
+
- NOT VERIFIED otherwise.
|
|
728
|
+
"""
|
|
729
|
+
tests = facts.get("tests") or {}
|
|
730
|
+
build = facts.get("build") or {}
|
|
731
|
+
git = facts.get("git") or {}
|
|
732
|
+
diff_nonempty = bool((git.get("diff") or {}).get("count"))
|
|
733
|
+
|
|
734
|
+
# A HARD FAILURE (a test/build that ran and FAILED, or a failed gate) forces
|
|
735
|
+
# NOT VERIFIED -- it is never an amber "gap". A failed check is a stronger
|
|
736
|
+
# negative signal than a not-run one: amber means "we did not check
|
|
737
|
+
# everything", red means "something we checked did not pass". Conflating them
|
|
738
|
+
# would let a failed test render amber, which understates the failure.
|
|
739
|
+
any_failed = (
|
|
740
|
+
tests.get("status") == "failed"
|
|
741
|
+
or build.get("status") == "failed"
|
|
742
|
+
or any(g.get("status") == "failed"
|
|
743
|
+
for g in (facts.get("quality_gates") or []))
|
|
744
|
+
)
|
|
745
|
+
if any_failed:
|
|
746
|
+
return "NOT VERIFIED"
|
|
747
|
+
|
|
748
|
+
tests_verified = (
|
|
749
|
+
tests.get("status") == "verified"
|
|
750
|
+
and bool(tests.get("command"))
|
|
751
|
+
and tests.get("exit_code") == 0
|
|
752
|
+
)
|
|
753
|
+
if tests_verified and not degraded and diff_nonempty:
|
|
754
|
+
return "VERIFIED"
|
|
755
|
+
# Any fact verified at all (tests/build verified, or a passed gate)?
|
|
756
|
+
any_verified = (
|
|
757
|
+
tests.get("status") == "verified"
|
|
758
|
+
or build.get("status") == "verified"
|
|
759
|
+
or any(g.get("status") == "passed"
|
|
760
|
+
for g in (facts.get("quality_gates") or []))
|
|
761
|
+
or diff_nonempty
|
|
762
|
+
)
|
|
763
|
+
if any_verified and degraded:
|
|
764
|
+
return "VERIFIED WITH GAPS"
|
|
765
|
+
return "NOT VERIFIED"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _git_head_sha(target_dir):
|
|
769
|
+
"""Best-effort current HEAD sha for facts.git.head_sha. Empty when non-git."""
|
|
770
|
+
try:
|
|
771
|
+
out = subprocess.run(
|
|
772
|
+
["git", "-C", target_dir, "rev-parse", "HEAD"],
|
|
773
|
+
capture_output=True, text=True, timeout=30,
|
|
774
|
+
)
|
|
775
|
+
if out.returncode == 0:
|
|
776
|
+
return out.stdout.strip()
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
return ""
|
|
780
|
+
|
|
781
|
+
|
|
413
782
|
def _council_ratio(proof):
|
|
414
783
|
"""Return (approve_count, total) mirroring the template's councilSummary:
|
|
415
784
|
council enabled + non-empty reviewers[], counting APPROVE/APPROVED votes.
|
|
@@ -644,6 +1013,17 @@ def generate(args):
|
|
|
644
1013
|
for rv in council_obj.get("reviewers") or []:
|
|
645
1014
|
if isinstance(rv, dict) and isinstance(rv.get("summary"), str):
|
|
646
1015
|
rv["summary"] = rv["summary"][:300]
|
|
1016
|
+
# redact_tree returns fresh copies, so the v1.1 mirror blocks hold an
|
|
1017
|
+
# independent (uncapped) council/cost copy. Re-point them at the capped
|
|
1018
|
+
# top-level objects so the receipt is internally consistent (no divergent
|
|
1019
|
+
# or uncapped duplicate of a reviewer summary or cost value).
|
|
1020
|
+
assess = redacted.get("assessments")
|
|
1021
|
+
if isinstance(assess, dict) and isinstance(council_obj, dict):
|
|
1022
|
+
assess["council"] = council_obj
|
|
1023
|
+
facts_obj = redacted.get("facts")
|
|
1024
|
+
cost_obj = redacted.get("cost")
|
|
1025
|
+
if isinstance(facts_obj, dict) and isinstance(cost_obj, dict):
|
|
1026
|
+
facts_obj["cost"] = cost_obj
|
|
647
1027
|
except Exception:
|
|
648
1028
|
pass
|
|
649
1029
|
|
|
@@ -656,13 +1036,27 @@ def generate(args):
|
|
|
656
1036
|
# Integrity hash over the canonical form INCLUDING redaction but EXCLUDING
|
|
657
1037
|
# verification (advisor ordering). Verifier re-canonicalizes the compact
|
|
658
1038
|
# sort_keys form, never the pretty bytes on disk.
|
|
659
|
-
|
|
660
|
-
|
|
1039
|
+
canonical_bytes = _canonical(redacted).encode("utf-8")
|
|
1040
|
+
digest = hashlib.sha256(canonical_bytes).hexdigest()
|
|
1041
|
+
verification = {
|
|
661
1042
|
"hash": digest,
|
|
662
1043
|
"algo": "sha256",
|
|
663
1044
|
"scope": "integrity",
|
|
664
1045
|
}
|
|
665
1046
|
|
|
1047
|
+
# Optional, env-gated gpg detached signature over the SAME canonical bytes
|
|
1048
|
+
# that were hashed (the pre-verification form a verifier reconstructs).
|
|
1049
|
+
# Default OFF: absent LOKI_PROOF_GPG_KEY -> no signature field, bytes
|
|
1050
|
+
# byte-identical to the unsigned proof. Never an external service, never
|
|
1051
|
+
# required, best-effort (a gpg failure is swallowed: the proof still emits).
|
|
1052
|
+
gpg_key = os.environ.get("LOKI_PROOF_GPG_KEY", "").strip()
|
|
1053
|
+
if gpg_key:
|
|
1054
|
+
sig = _gpg_detached_sign(canonical_bytes, gpg_key)
|
|
1055
|
+
if sig:
|
|
1056
|
+
verification["gpg_signature"] = sig
|
|
1057
|
+
|
|
1058
|
+
redacted["verification"] = verification
|
|
1059
|
+
|
|
666
1060
|
# Determine output dir.
|
|
667
1061
|
if args.out_dir:
|
|
668
1062
|
out_dir = os.path.abspath(args.out_dir)
|