loki-mode 11.12.1 → 11.17.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 (114) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/grill.sh +3 -1
  5. package/autonomy/hooks/validate-bash.sh +51 -13
  6. package/autonomy/lib/backlog.py +18 -3
  7. package/autonomy/lib/chat.sh +117 -7
  8. package/autonomy/lib/claude-flags.sh +20 -0
  9. package/autonomy/lib/claude_permissions.py +40 -0
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/engine10-push.sh +56 -7
  12. package/autonomy/lib/prd-enrich.sh +4 -2
  13. package/autonomy/lib/proof-generator.py +21 -0
  14. package/autonomy/lib/proof-verify.py +109 -1
  15. package/autonomy/lib/trust_trajectory.py +139 -0
  16. package/autonomy/lib/voter-agents.sh +3 -1
  17. package/autonomy/lib/workspace.py +2 -3
  18. package/autonomy/loki +427 -74
  19. package/autonomy/quickstart.sh +21 -2
  20. package/autonomy/run.sh +55 -21
  21. package/autonomy/trigger-server.py +461 -32
  22. package/bin/loki +4 -1
  23. package/completions/_loki +1 -1
  24. package/completions/loki.bash +10 -2
  25. package/dashboard/__init__.py +1 -1
  26. package/dashboard/api_start.py +2 -2
  27. package/dashboard/build_supervisor.py +2 -0
  28. package/docs/CLI-REFERENCE.md +6 -4
  29. package/docs/DEPLOYMENT.md +12 -5
  30. package/docs/EVALUATING.md +13 -0
  31. package/docs/INSTALLATION.md +2 -2
  32. package/docs/SIGNED-RECEIPTS.md +7 -0
  33. package/docs/WORKSPACES.md +2 -2
  34. package/docs/cost-controls.md +26 -0
  35. package/docs/merge-queue.md +19 -0
  36. package/docs/sentry-intake.md +4 -0
  37. package/docs/slack.md +2 -1
  38. package/docs/trackers.md +12 -1
  39. package/docs/v10/BOARD.md +148 -123
  40. package/docs/v10/CLI-MODERN.md +5 -5
  41. package/docs/v10/DECISIONS.md +2 -0
  42. package/docs/v10/ENGINE.md +2 -0
  43. package/docs/v10/FAILURE-CLASSES.md +18 -0
  44. package/docs/v10/MODERNIZE.md +32 -1
  45. package/docs/v10/PROGRESS.md +7 -0
  46. package/loki-ts/data/finding-schema.json +2 -1
  47. package/loki-ts/dist/loki.js +1222 -1128
  48. package/magic/core/debate.py +27 -1
  49. package/mcp/__init__.py +1 -1
  50. package/package.json +2 -2
  51. package/packages/control-plane/dist/server.js +5895 -5292
  52. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  53. package/providers/claude.sh +6 -3
  54. package/providers/model_catalog.json +6 -0
  55. package/schemas/loki-yaml.schema.json +5 -0
  56. package/src/policies/claude_permissions.js +114 -0
  57. package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-OVzN6eLm.js} +1 -1
  58. package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-hCCrxrCZ.js} +1 -1
  59. package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-CDcpkhbJ.js} +1 -1
  60. package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-8mwWlhEu.js} +1 -1
  61. package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-D8cf3u22.js} +1 -1
  62. package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CIgkbDRY.js} +1 -1
  63. package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-99kJH9g2.js} +1 -1
  64. package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
  65. package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
  66. package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
  67. package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CcNA24ny.js} +2 -2
  68. package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-BYjlb-IK.js} +1 -1
  69. package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-C0Dwt9Na.js} +1 -1
  70. package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-jDGmdcgg.js} +1 -1
  71. package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-C_ZO8tLE.js} +1 -1
  72. package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-B5wnurfQ.js} +3 -3
  73. package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage--EyKt5-D.js} +1 -1
  74. package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-cB0lnfti.js} +1 -1
  75. package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-Dl16FDkK.js} +1 -1
  76. package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
  77. package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-BFkWVOZe.js} +1 -1
  78. package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-Bf2kmEUM.js} +1 -1
  79. package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-BItv9Wa6.js} +1 -1
  80. package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-Df2Awg6I.js} +1 -1
  81. package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-D-1ztTsJ.js} +1 -1
  82. package/web-app/dist/assets/{bot-DOGeYewg.js → bot-A4qgZsKi.js} +1 -1
  83. package/web-app/dist/assets/{check-zbjPpb3E.js → check-BPfFpF0c.js} +1 -1
  84. package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CfSJvmDi.js} +1 -1
  85. package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-wx2d1dzb.js} +1 -1
  86. package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-pQMai6l-.js} +1 -1
  87. package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-DWL470r6.js} +1 -1
  88. package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-BaEQpQLZ.js} +1 -1
  89. package/web-app/dist/assets/{database-_a22uh7l.js → database-h-N7WkQN.js} +1 -1
  90. package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-B5oYiD27.js} +1 -1
  91. package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-fbTtTy0o.js} +1 -1
  92. package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-PaF6HMo8.js} +1 -1
  93. package/web-app/dist/assets/{globe-2TveFE5l.js → globe-Ct-GlWID.js} +1 -1
  94. package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-C1oZFX51.js} +1 -1
  95. package/web-app/dist/assets/{index-DGnZXUYk.js → index-C2egrRhz.js} +3 -3
  96. package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-Dt5ZK4v2.js} +1 -1
  97. package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DrpMziic.js} +1 -1
  98. package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-DOez8MmK.js} +1 -1
  99. package/web-app/dist/assets/{package-BZtqTIqH.js → package-ClGIkerr.js} +1 -1
  100. package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-C004i9ua.js} +1 -1
  101. package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-Ddfk-7Sy.js} +1 -1
  102. package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-Bu1KgDjS.js} +1 -1
  103. package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-3q1lW5pm.js} +1 -1
  104. package/web-app/dist/assets/{server-DbG8MDv6.js → server-pd6x4HW1.js} +1 -1
  105. package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-B9jGJepT.js} +1 -1
  106. package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-Cdcc9nQf.js} +1 -1
  107. package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-foSojXHK.js} +1 -1
  108. package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-CTTiqlJ1.js} +1 -1
  109. package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-B_MgmRAx.js} +1 -1
  110. package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-g6BfALzG.js} +1 -1
  111. package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-DrHwvzuh.js} +1 -1
  112. package/web-app/dist/assets/{user-CK5-EXIj.js → user-COp75m7l.js} +1 -1
  113. package/web-app/dist/index.html +1 -1
  114. package/autonomy/lib/doctor-fix.py +0 -279
@@ -1441,6 +1441,25 @@ def _collect_journey(loki_dir):
1441
1441
  n = iv.get("count")
1442
1442
  if isinstance(n, int) and n >= 0:
1443
1443
  out["interventions"] = n
1444
+ # by_type / waited_s exist only for counters written by the current
1445
+ # handle_pause. A count-only legacy file yields no detail (unmeasured,
1446
+ # never a fabricated zero). waited_s is wall-clock blocked seconds, an
1447
+ # upper bound on human attention, not active time.
1448
+ bt = iv.get("by_type")
1449
+ ws = iv.get("waited_s")
1450
+ if isinstance(bt, dict) and isinstance(ws, int) and not isinstance(ws, bool) and ws >= 0:
1451
+ out["interventions_detail"] = {
1452
+ "by_type": {
1453
+ k: (bt[k] if isinstance(bt.get(k), int) and not isinstance(bt.get(k), bool) and bt[k] >= 0 else 0)
1454
+ for k in ("steer", "input", "authorization", "repair")
1455
+ },
1456
+ "waited_s": ws,
1457
+ }
1458
+ # active_s (measured active human seconds) rides along only when
1459
+ # recorded; absent stays absent, never a fabricated 0.
1460
+ act = iv.get("active_s")
1461
+ if isinstance(act, int) and not isinstance(act, bool) and act >= 0:
1462
+ out["interventions_detail"]["active_s"] = act
1444
1463
 
1445
1464
  # PR state/URL, written by the consent-gated PR step. "prepared" means a PR
1446
1465
  # body exists locally and GitHub was NOT mutated.
@@ -1847,6 +1866,8 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
1847
1866
  # "unavailable" rather than showing a fabricated zero.
1848
1867
  if isinstance(journey, dict) and isinstance(journey.get("interventions"), int):
1849
1868
  proof["interventions"] = journey["interventions"]
1869
+ if isinstance(journey, dict) and isinstance(journey.get("interventions_detail"), dict):
1870
+ proof["interventions_detail"] = journey["interventions_detail"]
1850
1871
 
1851
1872
  return proof, run_id
1852
1873
 
@@ -1173,10 +1173,34 @@ def git_head(repo):
1173
1173
  return out.strip() if out and out.strip() else ""
1174
1174
 
1175
1175
 
1176
+ def _sparse_included(repo, paths):
1177
+ """Set of `paths` git's own sparse matcher includes; None when git cannot answer.
1178
+
1179
+ One `git sparse-checkout check-rules` call for the whole batch: it echoes
1180
+ the paths the patterns include, for cone, non-cone and negation alike.
1181
+ Known limit: a .git directory writable by the subject can drop paths from
1182
+ info/sparse-checkout. That hole predates this check (core.sparseCheckout=true
1183
+ alone was enough).
1184
+ """
1185
+ enc = [p.encode("utf-8", "surrogateescape") for p in paths]
1186
+ try:
1187
+ r = subprocess.run(
1188
+ ["git", "-C", repo or ".", "sparse-checkout", "check-rules", "-z"],
1189
+ input=b"".join(e + b"\0" for e in enc),
1190
+ capture_output=True, timeout=60, env=_scoped_env())
1191
+ except (OSError, subprocess.SubprocessError, ValueError):
1192
+ return None
1193
+ if r.returncode != 0:
1194
+ return None
1195
+ out = set(r.stdout.split(b"\0"))
1196
+ return set(p for p, e in zip(paths, enc) if e in out)
1197
+
1198
+
1176
1199
  def git_dirty(repo):
1177
1200
  """True/False, or None when git cannot answer. .loki/ is excluded: the
1178
1201
  verifier writes its own artifacts there."""
1179
- out = _vr_git_args(repo, ["status", "--porcelain"])
1202
+ out = _vr_git_args(repo, ["status", "--porcelain", "--untracked-files=all",
1203
+ "--no-renames", "--ignore-submodules=none"])
1180
1204
  if out is None:
1181
1205
  return None
1182
1206
  for line in out.splitlines():
@@ -1185,6 +1209,90 @@ def git_dirty(repo):
1185
1209
  path = path.strip('"')
1186
1210
  if not (path.startswith(".loki/") or path == ".loki"):
1187
1211
  return True
1212
+ # A skip-worktree (S/s) or assume-unchanged (lowercase tag) flag hides edits
1213
+ # from status. A flagged path is dirty only when its content differs from
1214
+ # the index; a skip-worktree path absent on disk is sparse-excluded (clean).
1215
+ flagged = _vr_git_args(repo, ["ls-files", "-v", "-z"])
1216
+ if flagged is None:
1217
+ return None
1218
+ cand = []
1219
+ for ent in flagged.split("\0"):
1220
+ tag, path = ent[:1], ent[2:]
1221
+ if not path or path.startswith(".loki/"):
1222
+ continue
1223
+ if tag in ("S", "s") or tag.islower():
1224
+ cand.append((tag in ("S", "s"), path))
1225
+ if not cand:
1226
+ return False
1227
+ staged = _vr_git_args(repo, ["ls-files", "-s", "-z"])
1228
+ if staged is None:
1229
+ return None
1230
+ blobs = {}
1231
+ for ent in staged.split("\0"):
1232
+ meta, _, p = ent.partition("\t")
1233
+ f = meta.split()
1234
+ if len(f) == 3 and f[2] == "0":
1235
+ blobs[p] = (f[0], f[1])
1236
+ sparse = (_vr_git_args(repo, ["config", "--bool", "core.sparseCheckout"]) or "").strip() == "true"
1237
+ root = repo or "."
1238
+ bad = None
1239
+ todo = []
1240
+ sparse_missing = []
1241
+ for skip, path in cand:
1242
+ full = os.path.join(root, path)
1243
+ mode, want = blobs.get(path, ("", ""))
1244
+ if not os.path.lexists(full):
1245
+ if skip and sparse:
1246
+ # Decided after the loop in one batched git call: clean only when
1247
+ # git's sparse patterns exclude the path. An in-cone path flagged
1248
+ # by hand and deleted is a real deletion; fail closed otherwise.
1249
+ sparse_missing.append(path)
1250
+ continue
1251
+ bad = (skip, path)
1252
+ break
1253
+ if mode == "160000":
1254
+ continue
1255
+ if os.path.islink(full):
1256
+ data = os.readlink(full).encode("utf-8", "surrogateescape")
1257
+ if hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() != want:
1258
+ bad = (skip, path)
1259
+ break
1260
+ continue
1261
+ todo.append((skip, path))
1262
+ if sparse_missing and not bad:
1263
+ included = _sparse_included(repo, sparse_missing)
1264
+ for path in sparse_missing:
1265
+ if included is None or path in included:
1266
+ bad = (True, path)
1267
+ break
1268
+ for i in range(0, len(todo), 200):
1269
+ if bad:
1270
+ break
1271
+ chunk = todo[i:i + 200]
1272
+ out = _vr_git_args(repo, ["hash-object", "--"] + [p for _, p in chunk])
1273
+ if out is None:
1274
+ return None
1275
+ miss = [(s, p) for (s, p), h in zip(chunk, out.split())
1276
+ if h != blobs.get(p, ("", ""))[1]]
1277
+ if miss:
1278
+ # Identical raw bytes mean clean (CRLF in the index plus autocrlf).
1279
+ # The clean filter is not idempotent: the index may hold unfiltered
1280
+ # bytes that a filtered hash of the worktree file never reproduces,
1281
+ # so the filtered hash alone would call such a file dirty.
1282
+ raw = _vr_git_args(repo, ["hash-object", "--no-filters", "--"] + [p for _, p in miss])
1283
+ if raw is None:
1284
+ return None
1285
+ for (skip, path), h in zip(miss, raw.split()):
1286
+ if h != blobs.get(path, ("", ""))[1]:
1287
+ bad = (skip, path)
1288
+ break
1289
+ if bad:
1290
+ skip, path = bad
1291
+ kind, fix = (("skip-worktree", "--no-skip-worktree") if skip
1292
+ else ("assume-unchanged", "--no-assume-unchanged"))
1293
+ sys.stderr.write("dirty: %s is flagged %s and differs from the index, which hides edits from the proof. "
1294
+ "Run: git update-index %s -- %s\n" % (path, kind, fix, path))
1295
+ return True
1188
1296
  return False
1189
1297
 
1190
1298
 
@@ -21,6 +21,14 @@ Direction (up/down/flat) is computed by a median half-split: mean of the later
21
21
  half minus mean of the earlier half. Robust to one noisy run, no float
22
22
  regression needed, and a 2-run series degrades to last-vs-first.
23
23
 
24
+ Outcome effort definitions (shared with loki-ts/src/metrics/trust.ts and
25
+ benchmarks/bench/attribution.py; see docs/EVALUATING.md "Outcome analytics"):
26
+ accepted = verdict VERIFIED and not later contradicted; misleading completion =
27
+ false_verified / VERIFIED-claimed (attribution false_completion); cost per
28
+ accepted = total spend over ALL runs / accepted, None when nothing was accepted
29
+ or any run's cost is unmeasured. Waited seconds are wall-clock blocked time, an
30
+ upper bound on human attention; active minutes are NOT measured.
31
+
24
32
  Public API:
25
33
  compute_trajectory(loki_dir) -> dict (schema_version 1)
26
34
  format_trajectory_human(traj) -> str
@@ -160,6 +168,52 @@ def _interventions_value(proof):
160
168
  return None
161
169
 
162
170
 
171
+ _IV_TYPES = ("steer", "input", "authorization", "repair")
172
+
173
+
174
+ def _cost_value(proof):
175
+ usd = _obj(proof.get("cost")).get("usd")
176
+ if usd is None or isinstance(usd, bool):
177
+ return None
178
+ try:
179
+ n = float(usd)
180
+ except (TypeError, ValueError):
181
+ return None
182
+ return n if n >= 0 and n == n and n != float("inf") else None
183
+
184
+
185
+ def _intervention_detail(proof):
186
+ """(by_type, waited_s, active_s) from proof.interventions_detail; None when absent."""
187
+ d = _obj(proof.get("interventions_detail"))
188
+ bt = _obj(d.get("by_type"))
189
+ out = {}
190
+ any_key = False
191
+ for k in _IV_TYPES:
192
+ try:
193
+ n = int(bt.get(k))
194
+ except (TypeError, ValueError):
195
+ n = 0
196
+ out[k] = n if n >= 0 else 0
197
+ if bt.get(k) is not None:
198
+ any_key = True
199
+ w = d.get("waited_s")
200
+ ws = w if isinstance(w, (int, float)) and not isinstance(w, bool) and w >= 0 else None
201
+ a = d.get("active_s")
202
+ act = a if isinstance(a, int) and not isinstance(a, bool) and a >= 0 else None
203
+ return (out if any_key else None), ws, act
204
+
205
+
206
+ def _verdict_evidence(proof):
207
+ raw = str(proof.get("verdict") or "").strip().upper()
208
+ verdict = raw or None
209
+ later = str(proof.get("later_verdict") or "").strip().upper()
210
+ if proof.get("false_verified") is True:
211
+ return verdict, True
212
+ if verdict is None:
213
+ return verdict, None
214
+ return verdict, verdict == "VERIFIED" and later == "FAILED"
215
+
216
+
163
217
  def _load_runs(loki_dir):
164
218
  """Read every .loki/proofs/<id>/proof.json into a time-ordered run list."""
165
219
  proofs_dir = os.path.join(loki_dir, "proofs")
@@ -176,7 +230,15 @@ def _load_runs(loki_dir):
176
230
  if not isinstance(proof, dict):
177
231
  # Malformed / partial proof: skip, do not fail the trajectory.
178
232
  continue
233
+ verdict, false_verified = _verdict_evidence(proof)
234
+ by_type, waited_s, active_s = _intervention_detail(proof)
179
235
  runs.append({
236
+ "verdict": verdict,
237
+ "false_verified": false_verified,
238
+ "cost_usd": _cost_value(proof),
239
+ "interventions_by_type": by_type,
240
+ "waited_s": waited_s,
241
+ "active_s": active_s,
180
242
  "run_id": str(proof.get("run_id") or name),
181
243
  "generated_at": proof.get("generated_at"),
182
244
  "council_pass_rate": _council_pass_value(proof.get("council")),
@@ -262,6 +324,70 @@ def _direction_for_axis(axis, ordered_values):
262
324
  }
263
325
 
264
326
 
327
+ def outcome_effort(series):
328
+ """Effort-per-outcome block; every unmeasured input yields None, never 0."""
329
+ accepted = sum(1 for r in series if r["verdict"] == "VERIFIED" and r["false_verified"] is not True)
330
+ claimed = sum(1 for r in series if r["verdict"] == "VERIFIED" or r["false_verified"] is True)
331
+ false_v = sum(1 for r in series if r["false_verified"] is True)
332
+ unmeasured = sum(1 for r in series if r["cost_usd"] is None)
333
+ cost = None
334
+ cost_note = None
335
+ if not series:
336
+ cost_note = "no runs"
337
+ elif accepted == 0:
338
+ cost_note = "no accepted run, cost per accepted is undefined"
339
+ elif unmeasured:
340
+ cost_note = "cost unmeasured for %d of %d runs, a partial sum is not a total" % (unmeasured, len(series))
341
+ else:
342
+ cost = round(sum(r["cost_usd"] for r in series) / accepted, 6)
343
+ with_detail = [r for r in series if r["interventions_by_type"] is not None]
344
+ by_type = None
345
+ if with_detail:
346
+ by_type = {k: sum(r["interventions_by_type"][k] for r in with_detail) for k in _IV_TYPES}
347
+ all_iv = bool(series) and all(r["interventions"] is not None for r in series)
348
+ all_waited = bool(series) and all(r["waited_s"] is not None for r in series)
349
+ all_active = bool(series) and all(r["active_s"] is not None for r in series)
350
+ return {
351
+ "runs": len(series),
352
+ "accepted": accepted,
353
+ "claimed_verified": claimed,
354
+ "false_verified": false_v,
355
+ "misleading_completion_rate": round(false_v / claimed, 4) if claimed else None,
356
+ "cost_per_accepted_usd": cost,
357
+ "cost_note": cost_note,
358
+ "interventions_by_type": by_type,
359
+ "interventions_per_accepted": (
360
+ round(sum(r["interventions"] for r in series) / accepted, 4) if accepted and all_iv else None),
361
+ "waited_min_per_accepted": (
362
+ round(sum(r["waited_s"] for r in series) / 60.0 / accepted, 1) if accepted and all_waited else None),
363
+ "active_min_per_accepted": (
364
+ round(sum(r["active_s"] for r in series) / 60.0 / accepted, 1) if accepted and all_active else None),
365
+ }
366
+
367
+
368
+ def _fmt_outcome_effort(e):
369
+ mc = e.get("misleading_completion_rate")
370
+ lines = ["Outcome effort"]
371
+ lines.append(" Accepted (VERIFIED): %d of %d runs" % (e["accepted"], e["runs"]))
372
+ lines.append(" Misleading completion: %s" % (
373
+ "n/a (no VERIFIED claims)" if mc is None else
374
+ "%d of %d VERIFIED claims (%s%%)" % (e["false_verified"], e["claimed_verified"], round(mc * 100, 1))))
375
+ lines.append(" Cost per accepted: %s" % (
376
+ "n/a (%s)" % (e.get("cost_note") or "unmeasured") if e["cost_per_accepted_usd"] is None
377
+ else "$%.2f" % e["cost_per_accepted_usd"]))
378
+ bt = e.get("interventions_by_type")
379
+ lines.append(" Interventions by type: %s" % (
380
+ "not recorded" if bt is None else ", ".join("%s %d" % (k, bt[k]) for k in _IV_TYPES)))
381
+ ipa = e.get("interventions_per_accepted")
382
+ lines.append(" Interventions per accepted: %s" % ("n/a" if ipa is None else ipa))
383
+ w = e.get("waited_min_per_accepted")
384
+ lines.append(" Minutes per accepted: %s" % (
385
+ "n/a" if w is None else "%s (waited, upper bound)" % w))
386
+ am = e.get("active_min_per_accepted")
387
+ lines.append(" Active minutes per accepted: %s" % ("n/a (not measured)" if am is None else am))
388
+ return lines
389
+
390
+
265
391
  def compute_trajectory(loki_dir):
266
392
  """Return the R4 trust trajectory snapshot for a project's .loki dir."""
267
393
  runs = _load_runs(loki_dir)
@@ -276,6 +402,12 @@ def compute_trajectory(loki_dir):
276
402
  "gate_pass_rate": r["gate_pass_rate"],
277
403
  "iterations": r["iterations"],
278
404
  "interventions": r["interventions"],
405
+ "cost_usd": r["cost_usd"],
406
+ "interventions_by_type": r["interventions_by_type"],
407
+ "waited_s": r["waited_s"],
408
+ "active_s": r["active_s"],
409
+ "verdict": r["verdict"],
410
+ "false_verified": r["false_verified"],
279
411
  })
280
412
 
281
413
  axes = {}
@@ -317,6 +449,7 @@ def compute_trajectory(loki_dir):
317
449
  "regressing_axes": regressing_axes,
318
450
  "series": series,
319
451
  "notes": notes,
452
+ "outcome_effort": outcome_effort(series),
320
453
  }
321
454
 
322
455
 
@@ -376,6 +509,9 @@ def format_trajectory_human(traj):
376
509
  lines.append("Not enough history yet.")
377
510
  lines.append("Trust trajectory needs 2+ recorded runs to show a direction.")
378
511
  lines.append("Each `loki start` run writes a proof-of-run; come back after the next run.")
512
+ if traj.get("runs_count") and traj.get("outcome_effort"):
513
+ lines.append("")
514
+ lines.extend(_fmt_outcome_effort(traj["outcome_effort"]))
379
515
  if traj.get("notes"):
380
516
  lines.append("")
381
517
  lines.append("Notes")
@@ -398,6 +534,9 @@ def format_trajectory_human(traj):
398
534
  lines.append("Overall: mixed (%d improving / %d regressing)." % (imp, reg))
399
535
  else:
400
536
  lines.append("Overall: stable.")
537
+ if traj.get("outcome_effort"):
538
+ lines.append("")
539
+ lines.extend(_fmt_outcome_effort(traj["outcome_effort"]))
401
540
  if traj.get("notes"):
402
541
  lines.append("")
403
542
  lines.append("Notes")
@@ -373,8 +373,10 @@ loki_council_dispatch_agents() {
373
373
  # (completion-council.sh:2792). Fail-closed: a hung or timed-out council can
374
374
  # never become a false COMPLETE; it always degrades to the heuristic path
375
375
  # (which has its own timeout + conservative defaults).
376
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
377
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
376
378
  response=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" \
377
- env CAVEMAN_DEFAULT_MODE=off claude --dangerously-skip-permissions \
379
+ env CAVEMAN_DEFAULT_MODE=off claude "${_LOKI_PERM_ARGV[@]}" \
378
380
  -p "$prompt" \
379
381
  --agents "$agents_json" \
380
382
  --json-schema "$schema_content" 2>"$stderr_log") || rc=$?
@@ -222,8 +222,7 @@ def run_integration(ws, run_dir, worktrees, heads):
222
222
  """Run the integration command once; return the evidence dict."""
223
223
  integ = ws.get("integration") or {}
224
224
  cmd = integ.get("command")
225
- ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
226
- "seal": False, "note": "integration evidence is bound by composite.json only when the run seals one"}
225
+ ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured"}
227
226
  if not cmd:
228
227
  return ev
229
228
  timeout = integ.get("timeout_s", 900)
@@ -245,7 +244,7 @@ def run_integration(ws, run_dir, worktrees, heads):
245
244
  p.wait()
246
245
  rc = 124
247
246
  ev["status"] = "failed"
248
- ev["note"] += "; timeout after %ss" % timeout
247
+ ev["note"] = "timeout after %ss" % timeout
249
248
  ev["exit_code"] = rc
250
249
  with open(log_path, "rb") as f:
251
250
  ev["log_sha256"] = hashlib.sha256(f.read()).hexdigest()