loki-mode 11.4.2 → 11.5.1

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 (100) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/chat.sh +6 -3
  4. package/autonomy/lib/engine10-push.sh +19 -1
  5. package/autonomy/lib/help-core.sh +1 -1
  6. package/autonomy/lib/proof-generator.py +23 -5
  7. package/autonomy/lib/proof-verify.py +607 -5
  8. package/autonomy/lib/run-summary.sh +17 -1
  9. package/autonomy/lib/setup-requirements.json +35 -0
  10. package/autonomy/loki +232 -36
  11. package/autonomy/provider-offer.sh +95 -47
  12. package/autonomy/route.sh +38 -6
  13. package/autonomy/run.sh +42 -14
  14. package/autonomy/setup-wizard.sh +19 -10
  15. package/autonomy/verify.sh +40 -1
  16. package/completions/_loki +3 -1
  17. package/completions/loki.bash +9 -3
  18. package/dashboard/__init__.py +1 -1
  19. package/docs/CLI-REFERENCE.md +3 -1
  20. package/docs/EVALUATING.md +27 -0
  21. package/docs/HARNESS-INTELLIGENCE-PLAN.md +20 -0
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/KNOWN-ISSUES.md +10 -1
  24. package/docs/PROOF-PASSPORT.md +114 -0
  25. package/docs/alternative-installations.md +103 -0
  26. package/docs/cost-controls.md +139 -0
  27. package/docs/environment-variables.md +36 -0
  28. package/docs/exit-codes.md +102 -2
  29. package/docs/spec-first-intent.md +29 -0
  30. package/docs/v10/CLI-MODERN.md +2 -2
  31. package/docs/v10/ENGINE.md +32 -0
  32. package/loki-ts/data/model-pricing.json +1 -0
  33. package/loki-ts/dist/loki.js +1095 -1043
  34. package/mcp/__init__.py +1 -1
  35. package/package.json +2 -2
  36. package/packages/control-plane/dist/server.js +1721 -707
  37. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  38. package/plugins/loki-mode/README.md +41 -1
  39. package/plugins/loki-mode/commands/handoff.md +12 -0
  40. package/plugins/loki-mode/commands/loki-verify.md +9 -2
  41. package/plugins/loki-mode/commands/start.md +20 -0
  42. package/plugins/loki-mode/hooks/hooks.json +11 -0
  43. package/plugins/loki-mode/scripts/loki-stop-verify.sh +84 -0
  44. package/web-app/dist/assets/{AdminPage-CYJ2BWRC.js → AdminPage-Cbu3pXL8.js} +1 -1
  45. package/web-app/dist/assets/{Avatar-DdK4wynI.js → Avatar-DGqGGcGx.js} +1 -1
  46. package/web-app/dist/assets/{Badge-CrX3MI7J.js → Badge-Bhlipt6v.js} +1 -1
  47. package/web-app/dist/assets/{Button-709-wwGM.js → Button-CpN2sO61.js} +1 -1
  48. package/web-app/dist/assets/{CockpitPage-BapdTInO.js → CockpitPage-BqeDJwdL.js} +1 -1
  49. package/web-app/dist/assets/{ComparePage-DT99pGvA.js → ComparePage-DUieA2ba.js} +1 -1
  50. package/web-app/dist/assets/{ErrorBoundary-B0uJSys8.js → ErrorBoundary-DTWsYrOm.js} +1 -1
  51. package/web-app/dist/assets/{EvidenceReceiptPanel-BtMFgElP.js → EvidenceReceiptPanel-BU_B9X19.js} +1 -1
  52. package/web-app/dist/assets/{GitHubIssuesPanel-DjJuU941.js → GitHubIssuesPanel-DEZoLOsP.js} +1 -1
  53. package/web-app/dist/assets/{GitHubPRsPanel-HXfEX1BC.js → GitHubPRsPanel-BEsnrq7Y.js} +1 -1
  54. package/web-app/dist/assets/{HomePage-Cqyves-S.js → HomePage-BoUVuq7h.js} +2 -2
  55. package/web-app/dist/assets/{LoginPage-jUbtC7_Y.js → LoginPage-CmOED7vb.js} +1 -1
  56. package/web-app/dist/assets/{MagicPage-DHRsqjgR.js → MagicPage-DATZitrZ.js} +1 -1
  57. package/web-app/dist/assets/{MetricsPage-B-pED05R.js → MetricsPage-DGL1v0I-.js} +1 -1
  58. package/web-app/dist/assets/{NotFoundPage-CsbUs21R.js → NotFoundPage-C2WgOjZd.js} +1 -1
  59. package/web-app/dist/assets/{ProjectPage-BtlLEQwB.js → ProjectPage-BMi1tepj.js} +3 -3
  60. package/web-app/dist/assets/{ProjectsPage-DaRbRjSp.js → ProjectsPage-vc3y5YPh.js} +1 -1
  61. package/web-app/dist/assets/{SettingsPage-DALrtP3F.js → SettingsPage-kbq0Ftvi.js} +1 -1
  62. package/web-app/dist/assets/{ShowcasePage-CoPHSNP0.js → ShowcasePage-Bn6Q8Kbg.js} +1 -1
  63. package/web-app/dist/assets/{SystemSettingsPage-kOuSN50O.js → SystemSettingsPage-Basmhs_b.js} +1 -1
  64. package/web-app/dist/assets/{TeamsPage-DGWK0LMp.js → TeamsPage-BTqOc4TR.js} +1 -1
  65. package/web-app/dist/assets/{TemplatesPage-C-_10CwA.js → TemplatesPage-2P9002VL.js} +1 -1
  66. package/web-app/dist/assets/{TerminalOutput-Cz_vPbXL.js → TerminalOutput-CAoSPBrg.js} +1 -1
  67. package/web-app/dist/assets/{activity-icIsXJVa.js → activity-BcWVWCqU.js} +1 -1
  68. package/web-app/dist/assets/{bell-DbPR2_4r.js → bell-CYRqqgZ6.js} +1 -1
  69. package/web-app/dist/assets/{bot-DvjP3Yh5.js → bot-CU7gdn3g.js} +1 -1
  70. package/web-app/dist/assets/{check-D1OBxHeU.js → check-DLlCpz9V.js} +1 -1
  71. package/web-app/dist/assets/{chevron-left-D09jDZ29.js → chevron-left-CE9Cb30C.js} +1 -1
  72. package/web-app/dist/assets/{circle-alert-DyZMLOcC.js → circle-alert-uou4bjua.js} +1 -1
  73. package/web-app/dist/assets/{clock-CdpdJTId.js → clock-C_dSJcDX.js} +1 -1
  74. package/web-app/dist/assets/{cloud-CoLTlmoj.js → cloud-DZ22ITF6.js} +1 -1
  75. package/web-app/dist/assets/{code-xml-DrkE2ZZY.js → code-xml-BrJS0hhS.js} +1 -1
  76. package/web-app/dist/assets/{database-CMQQuM2R.js → database-D2HOmxtl.js} +1 -1
  77. package/web-app/dist/assets/{dollar-sign-CuioDaOG.js → dollar-sign-CQ1vK_6q.js} +1 -1
  78. package/web-app/dist/assets/{file-code-corner-Cx9j9K1S.js → file-code-corner-w2vpGm5d.js} +1 -1
  79. package/web-app/dist/assets/{file-plus-CYD2zaaI.js → file-plus-DlF6wjkD.js} +1 -1
  80. package/web-app/dist/assets/{globe-6w-hnQkL.js → globe-Dy3lBJg1.js} +1 -1
  81. package/web-app/dist/assets/{hammer-BpqtautL.js → hammer-C3xIiRXN.js} +1 -1
  82. package/web-app/dist/assets/{index-Dzs-9EgJ.js → index-4t-_IW2y.js} +3 -3
  83. package/web-app/dist/assets/{layers-D5hd7jb7.js → layers-Y8Fs8qXI.js} +1 -1
  84. package/web-app/dist/assets/{loader-circle-C6Y9x_7H.js → loader-circle-CxgN1v4m.js} +1 -1
  85. package/web-app/dist/assets/{lock-D51L7rYh.js → lock-DFFNbXDs.js} +1 -1
  86. package/web-app/dist/assets/{package-BJF8hkP8.js → package-5e6qCTUg.js} +1 -1
  87. package/web-app/dist/assets/{plus-D5kUQ79n.js → plus-C0Go_ytj.js} +1 -1
  88. package/web-app/dist/assets/{refresh-cw-BKdlCa7F.js → refresh-cw-D4i-NW3o.js} +1 -1
  89. package/web-app/dist/assets/{rotate-ccw-_bd_YI1e.js → rotate-ccw-YlKkiUW6.js} +1 -1
  90. package/web-app/dist/assets/{scroll-text-DbkegIxF.js → scroll-text-4qXOHTap.js} +1 -1
  91. package/web-app/dist/assets/{server-DAwqFIsq.js → server-BB6zXNp9.js} +1 -1
  92. package/web-app/dist/assets/{shield-alert-BuR1tPHu.js → shield-alert-D4tzjB1H.js} +1 -1
  93. package/web-app/dist/assets/{thumbs-up-BF6p3mRd.js → thumbs-up-DZiRwdFj.js} +1 -1
  94. package/web-app/dist/assets/{trash-2-BVpnkqqz.js → trash-2-Dv_LkRIK.js} +1 -1
  95. package/web-app/dist/assets/{trending-down-DpsDvRwW.js → trending-down-BJYp9PQe.js} +1 -1
  96. package/web-app/dist/assets/{trending-up-mFogTjt3.js → trending-up-BTcXLklK.js} +1 -1
  97. package/web-app/dist/assets/{upload-4T8zpBsi.js → upload-fcHqwOxr.js} +1 -1
  98. package/web-app/dist/assets/{usePolling-DPXQv8WO.js → usePolling-BeJYw_4L.js} +1 -1
  99. package/web-app/dist/assets/{user-BYGIFaTm.js → user-BcDwUCsO.js} +1 -1
  100. package/web-app/dist/index.html +1 -1
@@ -68,6 +68,7 @@ CLI:
68
68
  import hashlib
69
69
  import json
70
70
  import os
71
+ import platform
71
72
  import re
72
73
  import subprocess
73
74
  import sys
@@ -138,12 +139,18 @@ def _canonical(obj):
138
139
  # git helpers
139
140
  # ---------------------------------------------------------------------------
140
141
 
141
- def _git(repo_dir, args, timeout=30):
142
- """Run git in repo_dir. Returns stdout string, or None on any failure."""
142
+ def _git(repo_dir, args, timeout=30, env=None, config=()):
143
+ """Run git in repo_dir. Returns stdout string, or None on any failure.
144
+
145
+ env replaces the child environment; config entries become `-c key=value`.
146
+ """
147
+ cfg = []
148
+ for c in config:
149
+ cfg += ["-c", c]
143
150
  try:
144
151
  out = subprocess.run(
145
- ["git", "-C", repo_dir] + args,
146
- capture_output=True, text=True, timeout=timeout,
152
+ ["git", "-C", repo_dir] + cfg + list(args),
153
+ capture_output=True, text=True, timeout=timeout, env=env,
147
154
  )
148
155
  if out.returncode != 0:
149
156
  return None
@@ -1037,6 +1044,8 @@ def _cli(argv=None):
1037
1044
  return 2
1038
1045
  # Flags are stripped BEFORE positional parsing: proof.ts pipes this
1039
1046
  # command's stdout through verbatim, so --human must not shift repo_dir.
1047
+ if argv[0] in ("label", "summary", "json"):
1048
+ return _vr_cli(argv)
1040
1049
  human = "--human" in argv
1041
1050
  argv = [a for a in argv if a != "--human"]
1042
1051
  if not argv:
@@ -1054,7 +1063,25 @@ def _cli(argv=None):
1054
1063
  except Exception as exc: # defensive: never a traceback-as-UX
1055
1064
  print(json.dumps({"ok": False, "error": "verify failed: %s" % exc}))
1056
1065
  return 2
1057
- print(render_reasons(result) if human else json.dumps(result, indent=2))
1066
+ # Additive: the three-property record (integrity / execution validity /
1067
+ # requirement coverage). Existing fields are untouched.
1068
+ vrec = None
1069
+ try:
1070
+ proof_dir = os.path.dirname(os.path.abspath(proof_path))
1071
+ vrec = build_for_proof(
1072
+ _load_proof(proof_path), repo_dir=repo_dir,
1073
+ loki_dir=os.path.dirname(os.path.dirname(proof_dir)),
1074
+ record_path=proof_path)
1075
+ result["verification"] = vrec
1076
+ except Exception as exc: # never a traceback-as-UX
1077
+ result["verification"] = {"error": "verification record unavailable: %s" % exc}
1078
+ if human:
1079
+ print(render_reasons(result))
1080
+ if vrec is not None:
1081
+ print("")
1082
+ print(render_compact(vrec))
1083
+ else:
1084
+ print(json.dumps(result, indent=2))
1058
1085
  if result.get("ok"):
1059
1086
  return 0
1060
1087
  if drift_unverifiable(result):
@@ -1064,5 +1091,580 @@ def _cli(argv=None):
1064
1091
  return 1
1065
1092
 
1066
1093
 
1094
+ # ===========================================================================
1095
+ # Verification record: what an outcome record proves, as three separate
1096
+ # properties (formerly autonomy/lib/verification_record.py; contract in
1097
+ # docs/PROOF-PASSPORT.md, "Verification record").
1098
+ #
1099
+ # (a) integrity: the record's hash (and signature, when checkable here).
1100
+ # (b) execution validity: the git revision (sha plus dirty flag) and
1101
+ # environment the checks ran against; a HEAD/tree mismatch is STALE.
1102
+ # (c) requirement coverage: <loki_dir>/state/requirements-map.json when
1103
+ # present. Absence is "not recorded", never "covered".
1104
+ # Per-check status is passed, failed, skipped, not_run or stale. The label never
1105
+ # reads as a bare pass when any check did not run. Used by verify.sh (evidence
1106
+ # "verification" block), `loki proof show`, `loki proof verify`, proof.ts and
1107
+ # proof-generator.py. CLI: proof-verify.py <label|summary|json> <file>.
1108
+ # ===========================================================================
1109
+
1110
+ SCHEMA_VERSION = "1.0"
1111
+
1112
+ PASSED, FAILED, SKIPPED, NOT_RUN, STALE = "passed", "failed", "skipped", "not_run", "stale"
1113
+ CHECK_STATUSES = (PASSED, FAILED, SKIPPED, NOT_RUN, STALE)
1114
+
1115
+ _PASS_WORDS = {"pass", "passed", "verified", "ok", "proven", "success", "completed"}
1116
+ _FAIL_WORDS = {"fail", "failed", "blocked", "weakened", "error", "findings"}
1117
+ _SKIP_WORDS = {"skipped", "disabled", "not_applicable", "n/a"}
1118
+
1119
+ # Single definition of "a test file"; proof-generator reads it via _PV.
1120
+ _TEST_FILE = re.compile(
1121
+ r"(^|/)(tests?|__tests__|spec|specs)/|\.(test|spec)\.[\w]+$"
1122
+ r"|(^|/)test_[^/]*\.py$|_test\.(py|go|rs)$|(^|/)conftest\.py$")
1123
+
1124
+
1125
+ def normalize_status(raw):
1126
+ """Map any recorded status word to one of CHECK_STATUSES.
1127
+
1128
+ Unknown, empty and inconclusive all read not_run: a check that did not
1129
+ reach a conclusion has not run to completion, and must not read as passed.
1130
+ """
1131
+ s = str(raw if raw is not None else "").strip().lower().replace("-", "_")
1132
+ if s in _PASS_WORDS:
1133
+ return PASSED
1134
+ if s in _FAIL_WORDS:
1135
+ return FAILED
1136
+ if s in _SKIP_WORDS:
1137
+ return SKIPPED
1138
+ if s == STALE:
1139
+ return STALE
1140
+ return NOT_RUN
1141
+
1142
+
1143
+ # ---------------------------------------------------------------------------
1144
+ # git and environment helpers (explicit minimal env, bounded time)
1145
+ # ---------------------------------------------------------------------------
1146
+
1147
+ def _scoped_env():
1148
+ keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT")
1149
+ env = {k: os.environ[k] for k in keep if k in os.environ}
1150
+ env["GIT_TERMINAL_PROMPT"] = "0"
1151
+ return env
1152
+
1153
+
1154
+ def _run(argv, cwd=None, timeout=10):
1155
+ try:
1156
+ r = subprocess.run(argv, cwd=cwd, capture_output=True, text=True,
1157
+ timeout=timeout, env=_scoped_env())
1158
+ except (OSError, subprocess.SubprocessError, ValueError):
1159
+ return None
1160
+ if r.returncode != 0:
1161
+ return None
1162
+ return r.stdout
1163
+
1164
+
1165
+ def _vr_git_args(repo, args):
1166
+ """Verification-record git call: scoped env, short timeout, raw paths."""
1167
+ return _git(repo or ".", args, timeout=10, env=_scoped_env(),
1168
+ config=("core.quotePath=false",))
1169
+
1170
+
1171
+ def git_head(repo):
1172
+ out = _vr_git_args(repo, ["rev-parse", "HEAD"])
1173
+ return out.strip() if out and out.strip() else ""
1174
+
1175
+
1176
+ def git_dirty(repo):
1177
+ """True/False, or None when git cannot answer. .loki/ is excluded: the
1178
+ verifier writes its own artifacts there."""
1179
+ out = _vr_git_args(repo, ["status", "--porcelain"])
1180
+ if out is None:
1181
+ return None
1182
+ for line in out.splitlines():
1183
+ path = line[3:] if len(line) > 3 else line
1184
+ if path.startswith('"'):
1185
+ path = path.strip('"')
1186
+ if not (path.startswith(".loki/") or path == ".loki"):
1187
+ return True
1188
+ return False
1189
+
1190
+
1191
+ def _tool_version(argv):
1192
+ out = _run(argv, timeout=3)
1193
+ if not out:
1194
+ return None
1195
+ line = out.strip().splitlines()[0] if out.strip() else ""
1196
+ return line[:60] or None
1197
+
1198
+
1199
+ def capture_environment():
1200
+ """Measured, not assumed: only what this process can read right now."""
1201
+ env = {
1202
+ "os": "%s %s" % (platform.system(), platform.release()),
1203
+ "machine": platform.machine(),
1204
+ "python": platform.python_version(),
1205
+ }
1206
+ for key, argv in (("node", ["node", "--version"]),
1207
+ ("bun", ["bun", "--version"]),
1208
+ ("git", ["git", "--version"])):
1209
+ v = _tool_version(argv)
1210
+ if v:
1211
+ env[key] = v
1212
+ return env
1213
+
1214
+
1215
+ def _tree_digest(repo):
1216
+ try:
1217
+ return compute_tree_digest(repo) or None
1218
+ except Exception:
1219
+ return None
1220
+
1221
+
1222
+ # ---------------------------------------------------------------------------
1223
+ # (a) integrity
1224
+ # ---------------------------------------------------------------------------
1225
+
1226
+ def integrity_for_proof(proof):
1227
+ try:
1228
+ res = verify_integrity(proof)
1229
+ except Exception as exc: # never a traceback-as-UX
1230
+ return {"status": "unchecked", "hash_ok": None, "signature": "unchecked",
1231
+ "detail": "integrity check could not run: %s" % exc}
1232
+ verification = proof.get("verification") if isinstance(proof, dict) else None
1233
+ verification = verification if isinstance(verification, dict) else {}
1234
+ if not verification.get("hash"):
1235
+ status = "not_recorded"
1236
+ elif res.get("hash_ok") and res.get("gpg_ok") in (True, "n/a") \
1237
+ and res.get("headline_consistent") is not False \
1238
+ and res.get("cost_coherent") is not False:
1239
+ status = "valid"
1240
+ else:
1241
+ status = "invalid"
1242
+ gpg = res.get("gpg_ok")
1243
+ sig = "gpg verified" if gpg is True else "gpg FAILED" if gpg is False else None
1244
+ if sig is None:
1245
+ sig = ("ed25519 attestation present (not checked here; use loki proof "
1246
+ "verify --jwks)" if verification.get("attestation")
1247
+ or verification.get("seal") else "none")
1248
+ return {
1249
+ "status": status,
1250
+ "hash_ok": bool(res.get("hash_ok")),
1251
+ "signature": sig,
1252
+ "detail": "; ".join(res.get("reasons") or []) or "sha256 of canonical bytes matches",
1253
+ }
1254
+
1255
+
1256
+ def integrity_for_evidence(doc):
1257
+ return {"status": "not_recorded", "hash_ok": None, "signature": "none",
1258
+ "detail": "evidence.json carries no hash or signature"}
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # per-check extraction
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+ def checks_from_proof(proof):
1266
+ facts = proof.get("facts") if isinstance(proof, dict) else None
1267
+ if not isinstance(facts, dict):
1268
+ return []
1269
+ out = []
1270
+
1271
+ def add(cid, status, detail=""):
1272
+ out.append({"id": cid, "status": status, "detail": detail})
1273
+
1274
+ tests = facts.get("tests") or {}
1275
+ add("tests", normalize_status(tests.get("status")),
1276
+ "exit_code=%s" % tests.get("exit_code") if tests.get("command") else "no test command recorded")
1277
+ build = facts.get("build") or {}
1278
+ add("build", normalize_status(build.get("status")),
1279
+ "" if build.get("ran") else "build not run")
1280
+ for g in facts.get("quality_gates") or []:
1281
+ if isinstance(g, dict) and g.get("name"):
1282
+ add(str(g["name"]), normalize_status(g.get("status")),
1283
+ "provenance=%s" % g.get("provenance") if g.get("provenance") else "")
1284
+ sec = facts.get("security") or {}
1285
+ if sec.get("ran"):
1286
+ add("security", FAILED if (sec.get("high_active") or 0) > 0 else PASSED,
1287
+ "%s active HIGH finding(s)" % (sec.get("high_active") or 0))
1288
+ else:
1289
+ add("security", NOT_RUN, "security scan did not run")
1290
+ fn = facts.get("functional") or {}
1291
+ if isinstance(fn, dict) and fn:
1292
+ add("functional", normalize_status(fn.get("functional_status")) if fn.get("ran") else NOT_RUN, "")
1293
+ ti = facts.get("tests_integrity") or {}
1294
+ if ti.get("weakened"):
1295
+ add("tests_integrity", FAILED, "; ".join(ti["weakened"])[:200])
1296
+ return out
1297
+
1298
+
1299
+ def checks_from_evidence(doc):
1300
+ out = []
1301
+ for g in (doc.get("deterministic_gates") or []) if isinstance(doc, dict) else []:
1302
+ if isinstance(g, dict) and g.get("gate"):
1303
+ out.append({"id": str(g["gate"]), "status": normalize_status(g.get("status")),
1304
+ "detail": str(g.get("summary") or "")})
1305
+ return out
1306
+
1307
+
1308
+ # ---------------------------------------------------------------------------
1309
+ # (c) requirement coverage
1310
+ # ---------------------------------------------------------------------------
1311
+
1312
+ def read_requirements(loki_dir, checks):
1313
+ path = os.path.join(loki_dir or ".loki", "state", "requirements-map.json")
1314
+ if not os.path.isfile(path):
1315
+ return {"status": "not_recorded", "total": None, "covered": None,
1316
+ "uncovered": None, "uncovered_ids": [], "covered_but_check_not_passed": [],
1317
+ "detail": "no requirements map recorded; coverage is unknown, not zero"}
1318
+ try:
1319
+ with open(path, "r", encoding="utf-8") as fh:
1320
+ doc = json.load(fh)
1321
+ reqs = doc["requirements"]
1322
+ if not isinstance(reqs, list):
1323
+ raise ValueError("requirements is not a list")
1324
+ except (OSError, ValueError, KeyError, TypeError) as exc:
1325
+ return {"status": "unreadable", "total": None, "covered": None,
1326
+ "uncovered": None, "uncovered_ids": [], "covered_but_check_not_passed": [],
1327
+ "detail": "requirements map unreadable: %s" % exc}
1328
+ by_id = {c["id"]: c["status"] for c in checks}
1329
+ covered, uncovered_ids, weak = 0, [], []
1330
+ for r in reqs:
1331
+ r = r if isinstance(r, dict) else {}
1332
+ rid = str(r.get("id") or "?")
1333
+ if str(r.get("status") or "").lower() == "covered":
1334
+ covered += 1
1335
+ known = [by_id[c] for c in (r.get("checks") or []) if c in by_id]
1336
+ if known and any(s != PASSED for s in known):
1337
+ weak.append(rid)
1338
+ else:
1339
+ uncovered_ids.append(rid)
1340
+ return {"status": "recorded", "total": len(reqs), "covered": covered,
1341
+ "uncovered": len(uncovered_ids), "uncovered_ids": uncovered_ids,
1342
+ "covered_but_check_not_passed": weak, "detail": ""}
1343
+
1344
+
1345
+ # ---------------------------------------------------------------------------
1346
+ # implementer-modified tests
1347
+ # ---------------------------------------------------------------------------
1348
+
1349
+ def modified_tests(repo, base, head=None):
1350
+ """Existing test files the change modified, deleted or renamed.
1351
+
1352
+ Added test files are not listed: adding a test does not weaken one. Status
1353
+ is "unknown" when the diff cannot be derived, never an empty "none".
1354
+ """
1355
+ if not base:
1356
+ return {"status": "unknown", "files": [], "detail": "no base revision recorded"}
1357
+ args = ["diff", "--name-status", "-M", "-z", base]
1358
+ if head:
1359
+ args.append(head)
1360
+ out = _vr_git_args(repo, args + ["--"])
1361
+ if out is None:
1362
+ return {"status": "unknown", "files": [], "detail": "git diff could not be computed"}
1363
+ toks = out.split("\0")
1364
+ files, i = [], 0
1365
+ while i < len(toks) and toks[i]:
1366
+ st = toks[i][:1]
1367
+ if st in "RC" and i + 2 < len(toks):
1368
+ old, new, i = toks[i + 1], toks[i + 2], i + 3
1369
+ else:
1370
+ old = new = toks[i + 1] if i + 1 < len(toks) else ""
1371
+ i += 2
1372
+ if st in ("M", "D", "R", "T") and _TEST_FILE.search(old):
1373
+ files.append({"path": old, "change": {"M": "modified", "D": "deleted",
1374
+ "R": "renamed", "T": "type-changed"}[st]})
1375
+ return {"status": "measured", "files": files, "detail": ""}
1376
+
1377
+
1378
+ # ---------------------------------------------------------------------------
1379
+ # (b) execution validity
1380
+ # ---------------------------------------------------------------------------
1381
+
1382
+ def _short(sha):
1383
+ return (sha or "")[:7] or "none"
1384
+
1385
+
1386
+ def execution_validity(recorded, repo_dir, environment=None):
1387
+ """Compare the recorded revision with the live one.
1388
+
1389
+ recorded: {"head_sha", "dirty", "tree_sha256"} as stored with the record.
1390
+ status: fresh | stale | unknown. Unknown is never promoted to fresh.
1391
+ """
1392
+ cur_head = git_head(repo_dir)
1393
+ cur_dirty = git_dirty(repo_dir) if cur_head else None
1394
+ rec_head = recorded.get("head_sha") or ""
1395
+ rec_tree = recorded.get("tree_sha256") or ""
1396
+ result = {"status": "unknown", "reason": "", "recorded": dict(recorded),
1397
+ "current": {"head_sha": cur_head, "dirty": cur_dirty},
1398
+ "environment": environment or {}}
1399
+ if not rec_head:
1400
+ result["reason"] = "record stores no git revision, so freshness cannot be judged"
1401
+ elif not cur_head:
1402
+ result["reason"] = "current directory is not a git work tree, so freshness cannot be judged"
1403
+ elif rec_head != cur_head:
1404
+ result["status"] = "stale"
1405
+ result["reason"] = "HEAD moved: recorded %s, now %s" % (_short(rec_head), _short(cur_head))
1406
+ elif rec_tree:
1407
+ now_tree = _tree_digest(repo_dir)
1408
+ result["current"]["tree_sha256"] = now_tree
1409
+ if not now_tree:
1410
+ result["reason"] = "tree digest could not be recomputed"
1411
+ elif now_tree != rec_tree:
1412
+ result["status"] = "stale"
1413
+ result["reason"] = "working tree differs from the tree the checks ran against"
1414
+ else:
1415
+ result["status"] = "fresh"
1416
+ elif recorded.get("dirty") is False and cur_dirty is False:
1417
+ result["status"] = "fresh"
1418
+ elif recorded.get("dirty") is False and cur_dirty is True:
1419
+ result["status"] = "stale"
1420
+ result["reason"] = "working tree modified since the checks ran"
1421
+ else:
1422
+ result["reason"] = ("same HEAD, but the checks ran on a dirty tree and no tree "
1423
+ "digest was recorded, so the tree cannot be compared")
1424
+ return result
1425
+
1426
+
1427
+ # ---------------------------------------------------------------------------
1428
+ # assembly and label
1429
+ # ---------------------------------------------------------------------------
1430
+
1431
+ def _counts(checks):
1432
+ c = {s: 0 for s in CHECK_STATUSES}
1433
+ for ch in checks:
1434
+ c[ch["status"]] += 1
1435
+ c["total"] = len(checks)
1436
+ c["ran"] = c[PASSED] + c[FAILED]
1437
+ return c
1438
+
1439
+
1440
+ def _names(checks, *statuses):
1441
+ return [c["id"] for c in checks if c["status"] in statuses]
1442
+
1443
+
1444
+ def compute_label(base, integrity, execution, checks, counts, requirements, mod_tests, complete,
1445
+ kind="proof"):
1446
+ base = (base or "").strip().upper()
1447
+ failed = _names(checks, FAILED)
1448
+ if integrity["status"] == "invalid":
1449
+ return "NOT VERIFIED (integrity failed: record was altered or is inconsistent)"
1450
+ if failed:
1451
+ return "NOT VERIFIED (failed: %s)" % ", ".join(failed)
1452
+ if not complete:
1453
+ return "INCOMPLETE (record has no usable check results)"
1454
+ if kind == "proof" and integrity["status"] != "valid":
1455
+ if integrity["status"] == "unchecked":
1456
+ return "NOT VERIFIED (integrity could not be checked)"
1457
+ return "NOT VERIFIED (integrity not provable: no hash recorded)"
1458
+ if base not in ("VERIFIED", "VERIFIED WITH GAPS"):
1459
+ return base or "NOT VERIFIED"
1460
+ if execution["status"] == "stale":
1461
+ return "STALE (%s; re-run the checks)" % execution["reason"]
1462
+ not_run = _names(checks, NOT_RUN, SKIPPED, STALE)
1463
+ parts = []
1464
+ if kind != "proof" and integrity["status"] != "valid":
1465
+ parts.append("integrity not recorded")
1466
+ if not_run:
1467
+ parts.append("%d of %d checks ran; not run: %s" % (
1468
+ counts["ran"], counts["total"], ", ".join(not_run)))
1469
+ if base == "VERIFIED WITH GAPS" and not not_run:
1470
+ parts.append("recorded gaps")
1471
+ if requirements["status"] == "recorded" and requirements["uncovered"]:
1472
+ parts.append("%d requirement(s) uncovered" % requirements["uncovered"])
1473
+ if requirements.get("covered_but_check_not_passed"):
1474
+ parts.append("covered by a check that did not pass: %s" %
1475
+ ", ".join(requirements["covered_but_check_not_passed"]))
1476
+ if mod_tests["files"]:
1477
+ parts.append("%d existing test file(s) modified" % len(mod_tests["files"]))
1478
+ if execution["status"] == "unknown":
1479
+ parts.append("revision freshness unknown")
1480
+ if parts:
1481
+ return "VERIFIED (scoped: %s)" % "; ".join(parts)
1482
+ return "VERIFIED"
1483
+
1484
+
1485
+ def _assemble(kind, base, integrity, execution, checks, requirements, mod_tests,
1486
+ complete, record_path):
1487
+ if execution["status"] == "stale":
1488
+ for c in checks:
1489
+ if c["status"] == PASSED:
1490
+ c["status"] = STALE
1491
+ counts = _counts(checks)
1492
+ # Label uses pre-stale statuses for "not run" names: recompute from the
1493
+ # current list, where stale checks are named as not run on a stale record.
1494
+ label = compute_label(base, integrity, execution, checks, counts,
1495
+ requirements, mod_tests, complete, kind)
1496
+ return {
1497
+ "schema_version": SCHEMA_VERSION,
1498
+ "kind": kind,
1499
+ "label": label,
1500
+ "base_verdict": base,
1501
+ "integrity": integrity,
1502
+ "execution": execution,
1503
+ "requirements": requirements,
1504
+ "checks": checks,
1505
+ "counts": counts,
1506
+ "modified_tests": mod_tests,
1507
+ "complete": complete,
1508
+ "record_path": record_path,
1509
+ }
1510
+
1511
+
1512
+ def build_for_proof(proof, repo_dir=".", loki_dir=".loki", record_path=""):
1513
+ proof = proof if isinstance(proof, dict) else {}
1514
+ facts = proof.get("facts") if isinstance(proof.get("facts"), dict) else {}
1515
+ git = facts.get("git") if isinstance(facts.get("git"), dict) else {}
1516
+ recorded = {"head_sha": git.get("head_sha") or "", "dirty": git.get("dirty"),
1517
+ "tree_sha256": git.get("tree_sha256") or ""}
1518
+ env = facts.get("environment") if isinstance(facts.get("environment"), dict) else {}
1519
+ checks = checks_from_proof(proof)
1520
+ base = str((proof.get("honesty") or {}).get("headline") or "")
1521
+ complete = bool(facts) and bool(base)
1522
+ mod = modified_tests(repo_dir, git.get("base_sha") or "")
1523
+ if mod["status"] == "unknown" and isinstance(facts.get("tests_integrity"), dict):
1524
+ weak = [w for w in facts["tests_integrity"].get("weakened") or []
1525
+ if str(w).startswith("test file")]
1526
+ if weak:
1527
+ mod = {"status": "measured", "files": [{"path": w, "change": "recorded"} for w in weak],
1528
+ "detail": "from the receipt's tests_integrity"}
1529
+ return _assemble("proof", base, integrity_for_proof(proof),
1530
+ execution_validity(recorded, repo_dir, env), checks,
1531
+ read_requirements(loki_dir, checks), mod, complete, record_path)
1532
+
1533
+
1534
+ def build_for_evidence(doc, repo_dir=".", loki_dir=".loki", record_path="", fresh_capture=False):
1535
+ """Build the record for an evidence.json document.
1536
+
1537
+ fresh_capture=True is the emit-time call: the checks just ran here, so the
1538
+ revision and environment are captured from the live tree. Otherwise the
1539
+ revision recorded in doc["verification"] (or doc["subject"]) is compared
1540
+ with the live tree.
1541
+ """
1542
+ doc = doc if isinstance(doc, dict) else {}
1543
+ subject = doc.get("subject") if isinstance(doc.get("subject"), dict) else {}
1544
+ prior = doc.get("verification") if isinstance(doc.get("verification"), dict) else {}
1545
+ checks = checks_from_evidence(doc)
1546
+ base = str(doc.get("verdict") or "")
1547
+ complete = bool(checks) and bool(base)
1548
+ if fresh_capture:
1549
+ recorded = {"head_sha": subject.get("head_sha") or git_head(repo_dir),
1550
+ "dirty": not subject.get("worktree_clean_at_grade", False),
1551
+ "tree_sha256": _tree_digest(repo_dir) or ""}
1552
+ env = capture_environment()
1553
+ else:
1554
+ rec = (prior.get("execution") or {}).get("recorded") or {}
1555
+ recorded = {"head_sha": rec.get("head_sha") or subject.get("head_sha") or "",
1556
+ "dirty": rec.get("dirty") if "dirty" in rec
1557
+ else (not subject.get("worktree_clean_at_grade", False)),
1558
+ "tree_sha256": rec.get("tree_sha256") or ""}
1559
+ env = (prior.get("execution") or {}).get("environment") or {}
1560
+ mod = modified_tests(repo_dir, subject.get("merge_base_sha") or "",
1561
+ subject.get("head_sha") or None)
1562
+ return _assemble("evidence", base, integrity_for_evidence(doc),
1563
+ execution_validity(recorded, repo_dir, env), checks,
1564
+ read_requirements(loki_dir, checks), mod, complete, record_path)
1565
+
1566
+
1567
+ # ---------------------------------------------------------------------------
1568
+ # compact reviewer summary
1569
+ # ---------------------------------------------------------------------------
1570
+
1571
+ def _list(items, limit=4):
1572
+ items = list(items)
1573
+ shown = ", ".join(items[:limit])
1574
+ return shown + (" (+%d more)" % (len(items) - limit) if len(items) > limit else "")
1575
+
1576
+
1577
+ def render_compact(rec):
1578
+ ex, integ, req, cnt = rec["execution"], rec["integrity"], rec["requirements"], rec["counts"]
1579
+ cur, recd = ex["current"], ex["recorded"]
1580
+ dirty = {True: "dirty", False: "clean", None: "dirty flag unknown"}[recd.get("dirty")]
1581
+ lines = [rec["label"]]
1582
+ if ex["status"] == "fresh":
1583
+ lines.append("revision: %s (%s), fresh: HEAD and tree match" % (_short(recd.get("head_sha")), dirty))
1584
+ elif ex["status"] == "stale":
1585
+ lines.append("revision: %s (%s), STALE: %s" % (_short(recd.get("head_sha")), dirty, ex["reason"]))
1586
+ else:
1587
+ lines.append("revision: %s (%s), freshness unknown: %s" % (
1588
+ _short(recd.get("head_sha")), dirty, ex["reason"]))
1589
+ env = ex.get("environment") or {}
1590
+ lines.append("environment: %s" % (", ".join("%s %s" % (k, v) for k, v in env.items())
1591
+ if env else "not recorded"))
1592
+ lines.append("integrity: %s (%s)" % (integ["status"], integ["signature"]))
1593
+ lines.append("checks: %d passed, %d failed, %d skipped, %d not run, %d stale (of %d)" % (
1594
+ cnt[PASSED], cnt[FAILED], cnt[SKIPPED], cnt[NOT_RUN], cnt[STALE], cnt["total"]))
1595
+ if rec["checks"]:
1596
+ bad = _names(rec["checks"], FAILED, NOT_RUN, SKIPPED, STALE)
1597
+ if bad:
1598
+ lines.append(" not passed: %s" % _list(bad, 6))
1599
+ if req["status"] == "recorded":
1600
+ extra = " (uncovered: %s)" % _list(req["uncovered_ids"]) if req["uncovered"] else ""
1601
+ lines.append("requirements: %d covered, %d uncovered%s" % (req["covered"], req["uncovered"], extra))
1602
+ else:
1603
+ lines.append("requirements: %s" % req["status"].replace("_", " "))
1604
+ mt = rec["modified_tests"]
1605
+ if mt["status"] == "unknown":
1606
+ lines.append("modified tests: unknown (%s)" % mt["detail"])
1607
+ elif mt["files"]:
1608
+ lines.append("modified tests: %d existing test file(s): %s" % (
1609
+ len(mt["files"]), _list("%s %s" % (f["change"], f["path"]) for f in mt["files"])))
1610
+ else:
1611
+ lines.append("modified tests: none")
1612
+ lines.append("A signature proves integrity only, not execution validity or requirement coverage.")
1613
+ if rec.get("record_path"):
1614
+ lines.append("full record: %s" % rec["record_path"])
1615
+ return "\n".join(lines)
1616
+
1617
+
1618
+ # ---------------------------------------------------------------------------
1619
+ # CLI
1620
+ # ---------------------------------------------------------------------------
1621
+
1622
+ def _vr_cli(argv):
1623
+ usage = ("usage: proof-verify.py <label|summary|json> <proof.json|evidence.json> "
1624
+ "[--repo DIR] [--loki-dir DIR] [--embedded]")
1625
+ args = [a for a in argv]
1626
+ # --embedded: render the record already stored in evidence.json instead of
1627
+ # re-deriving it (used right after emit, when re-deriving adds nothing).
1628
+ embedded = "--embedded" in args
1629
+ args = [a for a in args if a != "--embedded"]
1630
+ if len(args) < 2 or args[0] not in ("label", "summary", "json"):
1631
+ sys.stderr.write(usage + "\n")
1632
+ return 2
1633
+ mode, path = args[0], args[1]
1634
+ repo, loki_dir = ".", None
1635
+ rest = args[2:]
1636
+ while rest:
1637
+ flag = rest.pop(0)
1638
+ if flag == "--repo" and rest:
1639
+ repo = rest.pop(0)
1640
+ elif flag == "--loki-dir" and rest:
1641
+ loki_dir = rest.pop(0)
1642
+ else:
1643
+ sys.stderr.write(usage + "\n")
1644
+ return 2
1645
+ try:
1646
+ doc = _load_proof(path)
1647
+ except ProofLoadError as exc:
1648
+ sys.stderr.write("cannot read %s: %s\n" % (path, exc))
1649
+ return 2
1650
+ if loki_dir is None:
1651
+ loki_dir = os.environ.get("LOKI_DIR") or os.path.join(repo, ".loki")
1652
+ is_evidence = isinstance(doc, dict) and "deterministic_gates" in doc
1653
+ if embedded and is_evidence and isinstance(doc.get("verification"), dict) \
1654
+ and doc["verification"].get("checks") is not None:
1655
+ rec = dict(doc["verification"])
1656
+ rec["record_path"] = path
1657
+ else:
1658
+ rec = (build_for_evidence(doc, repo, loki_dir, path) if is_evidence
1659
+ else build_for_proof(doc, repo, loki_dir, path))
1660
+ if mode == "label":
1661
+ print(rec["label"])
1662
+ elif mode == "json":
1663
+ print(json.dumps(rec, indent=2))
1664
+ else:
1665
+ print(render_compact(rec))
1666
+ return 0
1667
+
1668
+
1067
1669
  if __name__ == "__main__":
1068
1670
  sys.exit(_cli())
@@ -66,6 +66,22 @@ except Exception:
66
66
  *) if [ -n "$files" ] && [ "$files" != "0" ]; then next="loki undo"; else next="loki doctor"; fi ;;
67
67
  esac
68
68
 
69
+ # REQTRACE: requirements with no passing check, from .loki/state/requirements-map.json (written by the Bun runner and engine10).
70
+ local unc=""
71
+ if [ -s "$loki_dir/state/requirements-map.json" ]; then
72
+ unc="$(LOKI_RM="$loki_dir/state/requirements-map.json" python3 -I -c "
73
+ import json, os
74
+ try:
75
+ m = json.load(open(os.environ['LOKI_RM']))
76
+ n = sum(1 for r in m.get('requirements', []) if r.get('status') == 'uncovered')
77
+ print(n if n else '')
78
+ except Exception:
79
+ print('')
80
+ " 2>/dev/null || true)"
81
+ fi
82
+ local next_h="$next"
83
+ [ -n "$unc" ] && next_h="${next}; ${unc} requirement(s) uncovered, see: loki plan --requirements"
84
+
69
85
  local mm=$((elapsed / 60)) ss=$((elapsed % 60)) elapsed_h
70
86
  if [ "$elapsed" -lt 60 ]; then elapsed_h="${elapsed}s"; else elapsed_h="${mm}m$(printf '%02d' "$ss")s"; fi
71
87
 
@@ -92,5 +108,5 @@ except Exception:
92
108
  printf 'Cost: %s\n' "$cost_h"
93
109
  printf 'Elapsed: %s\n' "$elapsed_h"
94
110
  [ -n "$receipt" ] && printf 'Receipt: %s\n' "$receipt"
95
- printf 'next: %s (or: loki chat --attach)\n' "$next"
111
+ printf 'next: %s (or: loki chat --attach)\n' "$next_h"
96
112
  }