loki-mode 7.84.0 → 7.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ 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.84.0
6
+ # Loki Mode v7.85.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.84.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.85.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.84.0
1
+ 7.85.0
@@ -27,7 +27,7 @@ import subprocess
27
27
  import sys
28
28
  from datetime import datetime, timezone
29
29
 
30
- SCHEMA_VERSION = "1.0"
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
- status = "unknown"
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 = "passed" if val.get("passed") else "failed"
206
+ status = _norm_gate_status(val.get("passed"))
188
207
  elif "status" in val:
189
- status = str(val.get("status"))
208
+ status = _norm_gate_status(val.get("status"))
209
+ else:
210
+ status = "not_run"
190
211
  else:
191
- status = str(val)
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": _wall_clock_sec(started_at, generated_at),
667
+ "wall_clock_sec": wall_clock_sec,
400
668
  "spec": spec,
401
- "provider": {"name": provider_name, "model": model},
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": {"deployed_url": deployed_url, "public_url": public_url},
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
- digest = hashlib.sha256(_canonical(redacted).encode("utf-8")).hexdigest()
660
- redacted["verification"] = {
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)