loki-mode 9.53.0 → 9.54.2

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 (119) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/completion-council.sh +28 -5
  4. package/autonomy/council-v2.sh +13 -7
  5. package/autonomy/lib/done-recognition.sh +24 -16
  6. package/autonomy/lib/proof-check.sh +11 -3
  7. package/autonomy/lib/proof-generator.py +36 -5
  8. package/autonomy/lib/voter-agents.sh +8 -3
  9. package/autonomy/lib/workspace_diff.py +106 -12
  10. package/autonomy/prd-checklist.sh +19 -11
  11. package/autonomy/run.sh +503 -52
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/api_v2.py +4 -0
  14. package/dashboard/audit.py +9 -1
  15. package/dashboard/auth.py +16 -4
  16. package/dashboard/registry.py +34 -12
  17. package/dashboard/rigour_integration.py +25 -17
  18. package/dashboard/server.py +252 -104
  19. package/dashboard/static/cost.html +55 -26
  20. package/dashboard/static/index.html +475 -1544
  21. package/dashboard/static/proofs.html +1 -1
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/air-gapped.md +7 -2
  24. package/docs/image-provenance.md +4 -2
  25. package/docs/v10/BACKLOG.md +82 -2
  26. package/docs/v10/BOARD.md +105 -0
  27. package/docs/v10/DECISIONS.md +71 -0
  28. package/docs/v10/METRICS.md +2 -1
  29. package/docs/v10/PROGRESS.md +60 -0
  30. package/docs/v10/SWARM-PROMPT.md +98 -0
  31. package/docs/v10/SWARM.md +237 -0
  32. package/loki-ts/dist/loki.js +323 -322
  33. package/mcp/__init__.py +1 -1
  34. package/package.json +1 -1
  35. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  36. package/providers/aider.sh +8 -0
  37. package/providers/cline.sh +8 -0
  38. package/providers/codex.sh +9 -0
  39. package/providers/opencode.sh +9 -0
  40. package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
  41. package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
  42. package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
  43. package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
  44. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
  45. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
  46. package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
  47. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
  48. package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
  49. package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
  50. package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
  51. package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
  52. package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
  53. package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
  54. package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
  55. package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
  56. package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
  57. package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
  58. package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
  59. package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
  60. package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
  61. package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
  62. package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
  63. package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
  64. package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
  65. package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
  66. package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
  67. package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
  68. package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
  69. package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
  70. package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
  71. package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
  72. package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
  73. package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
  74. package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
  75. package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
  76. package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
  77. package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
  78. package/web-app/dist/assets/index-CI54rara.css +1 -0
  79. package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
  80. package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
  81. package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
  82. package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
  83. package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
  84. package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
  85. package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
  86. package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
  87. package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
  88. package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
  89. package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
  90. package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
  91. package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
  92. package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
  93. package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
  94. package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
  95. package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
  96. package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
  97. package/web-app/dist/index.html +2 -2
  98. package/web-app/server.py +122 -47
  99. package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
  100. package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
  101. package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
  102. package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
  103. package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
  104. package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
  105. package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
  106. package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
  107. package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
  108. package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
  109. package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
  110. package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
  111. package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
  112. package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
  113. package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
  114. package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
  115. package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
  116. package/web-app/dist/assets/mail-COu670_W.js +0 -6
  117. package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
  118. package/web-app/dist/assets/save-v8qep5cY.js +0 -6
  119. package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
@@ -3082,8 +3082,10 @@ class FleetRunResponse(BaseModel):
3082
3082
  status: str
3083
3083
  running: bool
3084
3084
  phase: str = ""
3085
- iteration: int = 0
3086
- cost_usd: float = 0.0
3085
+ # None when no state file recorded an iteration: unknown, never 0.
3086
+ iteration: Optional[int] = None
3087
+ # None when the project recorded no cost: unknown, never $0.00.
3088
+ cost_usd: Optional[float] = None
3087
3089
  started_at: Optional[str] = None
3088
3090
  duration_seconds: Optional[int] = None
3089
3091
  port: Optional[int] = None
@@ -3094,7 +3096,10 @@ class FleetSummaryResponse(BaseModel):
3094
3096
  total_runs: int
3095
3097
  running_runs: int
3096
3098
  stopped_runs: int
3097
- total_cost_usd: float
3099
+ # Sum of the runs that recorded a cost; None when none did. Partial when
3100
+ # some runs recorded nothing, so the total is a lower bound.
3101
+ total_cost_usd: Optional[float] = None
3102
+ total_cost_partial: bool = False
3098
3103
 
3099
3104
 
3100
3105
  @app.get(
@@ -3163,7 +3168,7 @@ async def get_fleet_run(identifier: str):
3163
3168
  running=running,
3164
3169
  phase=snap.get("phase", ""),
3165
3170
  iteration=snap.get("iteration", 0),
3166
- cost_usd=snap.get("cost_usd", 0.0),
3171
+ cost_usd=snap.get("cost_usd"),
3167
3172
  started_at=started_at,
3168
3173
  duration_seconds=duration_seconds,
3169
3174
  port=project.get("port"),
@@ -6265,7 +6270,8 @@ async def get_memory_summary():
6265
6270
  if total > 0:
6266
6271
  summary = {
6267
6272
  "episodic": {"count": stats.get("episode_count", 0), "latestDate": None},
6268
- "semantic": {"patterns": stats.get("pattern_count", 0), "antiPatterns": 0},
6273
+ # The SQLite stats carry no anti-pattern count: unknown, not 0.
6274
+ "semantic": {"patterns": stats.get("pattern_count", 0), "antiPatterns": None},
6269
6275
  "procedural": {"skills": stats.get("skill_count", 0)},
6270
6276
  "backend": "sqlite",
6271
6277
  }
@@ -6553,12 +6559,19 @@ async def get_token_economics():
6553
6559
  raw = {}
6554
6560
 
6555
6561
  metrics = raw.get("metrics", {}) if isinstance(raw, dict) else {}
6562
+ if not isinstance(metrics, dict):
6563
+ metrics = {}
6556
6564
  cache_hits = int(metrics.get("cache_hits", 0) or 0)
6557
6565
  cache_misses = int(metrics.get("cache_misses", 0) or 0)
6558
6566
  cache_total = cache_hits + cache_misses
6559
- hit_rate = round(cache_hits / cache_total, 4) if cache_total > 0 else 0.0
6560
- discovery_tokens = int(metrics.get("discovery_tokens", 0) or 0)
6561
- read_tokens = int(metrics.get("read_tokens", 0) or 0)
6567
+ # No lookups means no hit rate, and no economics file means no token
6568
+ # counts: both are null ("--" on the page), never a measured 0.
6569
+ hit_rate = round(cache_hits / cache_total, 4) if cache_total > 0 else None
6570
+ recorded = bool(metrics)
6571
+ discovery_tokens = int(metrics.get("discovery_tokens", 0) or 0) if recorded else None
6572
+ read_tokens = int(metrics.get("read_tokens", 0) or 0) if recorded else None
6573
+ savings = raw.get("savings_percent") if isinstance(raw, dict) else None
6574
+ ratio = raw.get("ratio") if isinstance(raw, dict) else None
6562
6575
 
6563
6576
  # Top-accessed memories: scan episodic + semantic, rank by access_count
6564
6577
  # then importance.
@@ -6626,17 +6639,19 @@ async def get_token_economics():
6626
6639
  "session_id": raw.get("session_id"),
6627
6640
  "discovery_tokens": discovery_tokens,
6628
6641
  "read_tokens": read_tokens,
6629
- "total_tokens": discovery_tokens + read_tokens,
6642
+ "total_tokens": discovery_tokens + read_tokens if recorded else None,
6630
6643
  "cache_hits": cache_hits,
6631
6644
  "cache_misses": cache_misses,
6632
6645
  "hit_rate": hit_rate,
6633
- "ratio": raw.get("ratio", 0.0),
6634
- "savings_percent": raw.get("savings_percent", 0.0),
6635
- "top_patterns": top_patterns,
6646
+ "ratio": ratio,
6647
+ "savings_percent": savings,
6648
+ # Only memories that were actually retrieved: a list of 0x entries
6649
+ # under "Top retrieved" names memories nobody retrieved.
6650
+ "top_patterns": [p for p in top_patterns if p.get("access_count", 0) > 0],
6636
6651
  # Backward-compat aliases (pre-v7.7.21 camelCase consumers)
6637
6652
  "discoveryTokens": discovery_tokens,
6638
6653
  "readTokens": read_tokens,
6639
- "savingsPercent": raw.get("savings_percent", 0.0),
6654
+ "savingsPercent": savings,
6640
6655
  "raw": raw,
6641
6656
  }
6642
6657
 
@@ -7067,16 +7082,20 @@ async def get_learning_metrics(
7067
7082
  # because some legacy events/signals stored confidence as a string, which
7068
7083
  # made sum() raise TypeError: unsupported operand type(s) for +: 'int' and 'str'.
7069
7084
  # B-7 fix (v7.6.1): silently skip non-numeric confidence values.
7070
- def _as_num(v: object) -> float:
7085
+ # An entry with no numeric confidence carries no evidence: it is skipped,
7086
+ # never averaged in as 0.0, and with none at all the average is null.
7087
+ def _as_num(v: object):
7088
+ if isinstance(v, bool):
7089
+ return None
7071
7090
  if isinstance(v, (int, float)):
7072
7091
  return float(v)
7073
7092
  try:
7074
- return float(v) if v is not None else 0.0
7093
+ return float(v) if v is not None else None
7075
7094
  except (TypeError, ValueError):
7076
- return 0.0
7095
+ return None
7077
7096
 
7078
- total_conf = sum(_as_num(e.get("data", {}).get("confidence", 0)) for e in events)
7079
- total_conf += sum(_as_num(s.get("confidence", 0)) for s in all_signals)
7097
+ confs = [c for c in (_as_num(e.get("data", {}).get("confidence")) for e in events) if c is not None]
7098
+ confs += [c for c in (_as_num(s.get("confidence")) for s in all_signals) if c is not None]
7080
7099
 
7081
7100
  # Load aggregation data from file if available
7082
7101
  aggregation = {
@@ -7100,7 +7119,7 @@ async def get_learning_metrics(
7100
7119
  "totalSignals": total_count,
7101
7120
  "signalsByType": by_type,
7102
7121
  "signalsBySource": by_source,
7103
- "avgConfidence": round(total_conf / max(total_count, 1), 4),
7122
+ "avgConfidence": round(sum(confs) / len(confs), 4) if confs else None,
7104
7123
  "aggregation": aggregation,
7105
7124
  }
7106
7125
 
@@ -7188,18 +7207,21 @@ async def get_learning_aggregation():
7188
7207
  data = s.get("data", {})
7189
7208
  tool_name = data.get("tool_name", s.get("action", "unknown"))
7190
7209
  if tool_name not in tool_stats:
7191
- tool_stats[tool_name] = {"count": 0, "total_ms": 0, "successes": 0}
7210
+ tool_stats[tool_name] = {"count": 0, "total_ms": 0, "timed": 0, "successes": 0}
7192
7211
  tool_stats[tool_name]["count"] += 1
7193
- tool_stats[tool_name]["total_ms"] += data.get("duration_ms", 0)
7212
+ if isinstance(data.get("duration_ms"), (int, float)):
7213
+ tool_stats[tool_name]["total_ms"] += data["duration_ms"]
7214
+ tool_stats[tool_name]["timed"] += 1
7194
7215
  if data.get("success", s.get("outcome") == "success"):
7195
7216
  tool_stats[tool_name]["successes"] += 1
7196
7217
  result["tool_efficiencies"] = []
7197
7218
  for tname, stats in sorted(tool_stats.items(), key=lambda x: -x[1]["count"]):
7198
- avg_ms = stats["total_ms"] / stats["count"] if stats["count"] else 0
7219
+ # Average only the calls that recorded a duration; none -> null.
7220
+ avg_ms = stats["total_ms"] / stats["timed"] if stats["timed"] else None
7199
7221
  sr = round(stats["successes"] / stats["count"], 4) if stats["count"] else 0
7200
7222
  result["tool_efficiencies"].append({
7201
7223
  "tool_name": tname, "efficiency_score": sr,
7202
- "count": stats["count"], "avg_execution_time_ms": round(avg_ms, 2),
7224
+ "count": stats["count"], "avg_execution_time_ms": round(avg_ms, 2) if avg_ms is not None else None,
7203
7225
  "success_rate": sr,
7204
7226
  })
7205
7227
 
@@ -7210,7 +7232,8 @@ async def get_learning_aggregation():
7210
7232
  etype = s.get("data", {}).get("error_type", s.get("action", "unknown"))
7211
7233
  error_counts[etype] = error_counts.get(etype, 0) + 1
7212
7234
  result["error_patterns"] = [
7213
- {"error_type": k, "resolution_rate": 0.0, "frequency": v, "confidence": min(1.0, v / 10)}
7235
+ # Resolution is never measured: null, not a 0% resolution rate.
7236
+ {"error_type": k, "resolution_rate": None, "frequency": v, "confidence": min(1.0, v / 10)}
7214
7237
  for k, v in sorted(error_counts.items(), key=lambda x: -x[1])
7215
7238
  ]
7216
7239
 
@@ -7296,12 +7319,14 @@ def _compute_learning_aggregation() -> dict:
7296
7319
 
7297
7320
  elif signal_type == "tool_usage":
7298
7321
  tool_name = data.get("tool_name", "unknown")
7299
- duration = data.get("duration_ms", 0)
7322
+ duration = data.get("duration_ms")
7300
7323
  success = data.get("success", False)
7301
7324
  if tool_name not in tool_stats:
7302
- tool_stats[tool_name] = {"count": 0, "total_ms": 0, "successes": 0}
7325
+ tool_stats[tool_name] = {"count": 0, "total_ms": 0, "timed": 0, "successes": 0}
7303
7326
  tool_stats[tool_name]["count"] += 1
7304
- tool_stats[tool_name]["total_ms"] += duration
7327
+ if isinstance(duration, (int, float)):
7328
+ tool_stats[tool_name]["total_ms"] += duration
7329
+ tool_stats[tool_name]["timed"] += 1
7305
7330
  if success:
7306
7331
  tool_stats[tool_name]["successes"] += 1
7307
7332
  except Exception:
@@ -7309,17 +7334,18 @@ def _compute_learning_aggregation() -> dict:
7309
7334
 
7310
7335
  # Build structured result
7311
7336
  pref_list = [{"preference_key": k, "preferred_value": k, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(preferences.items(), key=lambda x: -x[1])]
7312
- error_list = [{"error_type": k, "resolution_rate": 0.0, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(error_patterns.items(), key=lambda x: -x[1])]
7313
- success_list = [{"pattern_name": k, "avg_duration_seconds": 0, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(success_patterns.items(), key=lambda x: -x[1])]
7337
+ # Resolution rate and pattern duration are never measured: null, not 0.
7338
+ error_list = [{"error_type": k, "resolution_rate": None, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(error_patterns.items(), key=lambda x: -x[1])]
7339
+ success_list = [{"pattern_name": k, "avg_duration_seconds": None, "frequency": v, "confidence": min(1.0, v / 10)} for k, v in sorted(success_patterns.items(), key=lambda x: -x[1])]
7314
7340
  tool_list = []
7315
7341
  for tname, stats in sorted(tool_stats.items(), key=lambda x: -x[1]["count"]):
7316
- avg_ms = stats["total_ms"] / stats["count"] if stats["count"] else 0
7342
+ avg_ms = stats["total_ms"] / stats["timed"] if stats["timed"] else None
7317
7343
  sr = round(stats["successes"] / stats["count"], 4) if stats["count"] else 0
7318
7344
  tool_list.append({
7319
7345
  "tool_name": tname,
7320
7346
  "efficiency_score": sr,
7321
7347
  "count": stats["count"],
7322
- "avg_execution_time_ms": round(avg_ms, 2),
7348
+ "avg_execution_time_ms": round(avg_ms, 2) if avg_ms is not None else None,
7323
7349
  "success_rate": sr,
7324
7350
  })
7325
7351
 
@@ -7822,7 +7848,7 @@ def _compute_cost_snapshot() -> dict:
7822
7848
  by_phase: dict = {}
7823
7849
  by_model: dict = {}
7824
7850
  budget_limit = None
7825
- budget_used = 0.0
7851
+ budget_used = None
7826
7852
  budget_remaining = None
7827
7853
  # Did ANY record carry an observed value? Not "was a file present".
7828
7854
  cost_recorded = False
@@ -7842,8 +7868,13 @@ def _compute_cost_snapshot() -> dict:
7842
7868
  # AttributeError. Skip such files rather than 500 the endpoint.
7843
7869
  if not isinstance(data, dict):
7844
7870
  continue
7845
- if _record_is_measured(data):
7871
+ measured = _record_is_measured(data)
7872
+ if measured:
7846
7873
  cost_recorded = True
7874
+ # Tokens are measured apart from cost: a record that priced an
7875
+ # iteration but carried no token field says nothing about tokens.
7876
+ tokens_measured = _record_is_measured(
7877
+ {k: data.get(k) for k in _MEASURED_FIELDS if k != "cost_usd"})
7847
7878
 
7848
7879
  inp = data.get("input_tokens", 0)
7849
7880
  out = data.get("output_tokens", 0)
@@ -7865,19 +7896,20 @@ def _compute_cost_snapshot() -> dict:
7865
7896
  cost = _calculate_model_cost(model, inp, out, cr, cw)
7866
7897
  estimated_cost += cost
7867
7898
 
7868
- # Aggregate by phase
7869
- if phase not in by_phase:
7870
- by_phase[phase] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
7871
- by_phase[phase]["input_tokens"] += inp
7872
- by_phase[phase]["output_tokens"] += out
7873
- by_phase[phase]["cost_usd"] += cost
7874
-
7875
- # Aggregate by model
7876
- if model not in by_model:
7877
- by_model[model] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
7878
- by_model[model]["input_tokens"] += inp
7879
- by_model[model]["output_tokens"] += out
7880
- by_model[model]["cost_usd"] += cost
7899
+ # Aggregate by phase and model. `measured` is tracked per
7900
+ # bucket for the same reason as cost_recorded globally: a phase
7901
+ # whose records all carried nothing reports cost null, never a
7902
+ # summed $0.00 beside phases that were really measured. The
7903
+ # same goes for tokens (`tokens_measured`).
7904
+ for bucket, name in ((by_phase, phase), (by_model, model)):
7905
+ if name not in bucket:
7906
+ bucket[name] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0,
7907
+ "measured": False, "tokens_measured": False}
7908
+ bucket[name]["input_tokens"] += inp
7909
+ bucket[name]["output_tokens"] += out
7910
+ bucket[name]["cost_usd"] += cost
7911
+ bucket[name]["measured"] = bucket[name]["measured"] or measured
7912
+ bucket[name]["tokens_measured"] = bucket[name]["tokens_measured"] or tokens_measured
7881
7913
  except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
7882
7914
  pass
7883
7915
 
@@ -7913,6 +7945,9 @@ def _compute_cost_snapshot() -> dict:
7913
7945
  by_model[model]["input_tokens"] += inp
7914
7946
  by_model[model]["output_tokens"] += out
7915
7947
  by_model[model]["cost_usd"] += cost
7948
+ # Observed tokens from the tracker (cost_recorded above).
7949
+ by_model[model]["measured"] = True
7950
+ by_model[model]["tokens_measured"] = True
7916
7951
  except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
7917
7952
  pass
7918
7953
 
@@ -7926,7 +7961,10 @@ def _compute_cost_snapshot() -> dict:
7926
7961
  if not isinstance(budget_data, dict):
7927
7962
  budget_data = {}
7928
7963
  budget_limit = budget_data.get("limit")
7929
- if budget_limit is not None:
7964
+ # Spend against the cap only when something was measured: with
7965
+ # nothing recorded, "used" is unknown, not $0.00, and "remaining"
7966
+ # is unknown, not the whole cap.
7967
+ if budget_limit is not None and cost_recorded:
7930
7968
  budget_used = estimated_cost
7931
7969
  budget_remaining = max(0.0, budget_limit - budget_used)
7932
7970
  except (json.JSONDecodeError, KeyError):
@@ -7953,17 +7991,17 @@ def _compute_cost_snapshot() -> dict:
7953
7991
  "estimated_cost_usd": round(estimated_cost, 6) if cost_recorded else None,
7954
7992
  "cost_recorded": cost_recorded,
7955
7993
  "by_phase": {k: {
7956
- "input_tokens": v["input_tokens"],
7957
- "output_tokens": v["output_tokens"],
7958
- "cost_usd": round(v["cost_usd"], 6),
7994
+ "input_tokens": v["input_tokens"] if v.get("tokens_measured") else None,
7995
+ "output_tokens": v["output_tokens"] if v.get("tokens_measured") else None,
7996
+ "cost_usd": round(v["cost_usd"], 6) if v.get("measured") else None,
7959
7997
  } for k, v in by_phase.items()},
7960
7998
  "by_model": {k: {
7961
- "input_tokens": v["input_tokens"],
7962
- "output_tokens": v["output_tokens"],
7963
- "cost_usd": round(v["cost_usd"], 6),
7999
+ "input_tokens": v["input_tokens"] if v.get("tokens_measured") else None,
8000
+ "output_tokens": v["output_tokens"] if v.get("tokens_measured") else None,
8001
+ "cost_usd": round(v["cost_usd"], 6) if v.get("measured") else None,
7964
8002
  } for k, v in by_model.items()},
7965
8003
  "budget_limit": budget_limit,
7966
- "budget_used": round(budget_used, 6) if budget_limit is not None else None,
8004
+ "budget_used": round(budget_used, 6) if budget_limit is not None and budget_used is not None else None,
7967
8005
  "budget_remaining": round(budget_remaining, 6) if budget_remaining is not None else None,
7968
8006
  }
7969
8007
 
@@ -7977,7 +8015,7 @@ async def get_budget():
7977
8015
 
7978
8016
  # Read budget configuration
7979
8017
  budget_limit = None
7980
- budget_used = 0.0
8018
+ budget_used = None
7981
8019
  exceeded = False
7982
8020
  exceeded_at = None
7983
8021
 
@@ -7991,7 +8029,7 @@ async def get_budget():
7991
8029
  if not isinstance(budget_data, dict):
7992
8030
  budget_data = {}
7993
8031
  budget_limit = budget_data.get("limit") or budget_data.get("budget_limit")
7994
- budget_used = budget_data.get("budget_used", 0.0)
8032
+ budget_used = budget_data.get("budget_used")
7995
8033
  exceeded = budget_data.get("exceeded", False)
7996
8034
  exceeded_at = budget_data.get("exceeded_at")
7997
8035
  except (json.JSONDecodeError, KeyError):
@@ -8022,16 +8060,22 @@ async def get_budget():
8022
8060
 
8023
8061
  # Coerce defensively: a budget.json with a non-numeric budget_used/limit
8024
8062
  # (e.g. "n/a", null, a list) parses as valid JSON but would crash float()
8025
- # with ValueError/TypeError. Treat non-numeric values as 0.0 / None so the
8026
- # endpoint returns a clean payload instead of a 500.
8027
- def _to_float(value, default=0.0):
8063
+ # with ValueError/TypeError. Treat non-numeric values as None (unknown) so
8064
+ # the endpoint returns a clean payload instead of a 500.
8065
+ def _to_float(value, default=None):
8028
8066
  try:
8029
8067
  return float(value)
8030
8068
  except (ValueError, TypeError):
8031
8069
  return default
8032
8070
 
8033
8071
  budget_limit_f = _to_float(budget_limit, None) if budget_limit is not None else None
8034
- budget_used_f = _to_float(budget_used, 0.0)
8072
+ # A recorded budget.json spend is a reading only when it is a positive
8073
+ # number. bash check_budget_limit writes "budget_used": 0.0 for a run it
8074
+ # measured nothing for, and a bool or non-numeric value is no reading at
8075
+ # all; each of those is unknown, never $0.00 spent.
8076
+ budget_used_f = None if isinstance(budget_used, bool) else _to_float(budget_used, None)
8077
+ if budget_used_f is not None and not budget_used_f > 0:
8078
+ budget_used_f = None
8035
8079
 
8036
8080
  # current_cost must reflect real live spend, not the static budget.json
8037
8081
  # field which only updates when run.sh persists it. The same divergence
@@ -8040,11 +8084,16 @@ async def get_budget():
8040
8084
  # the single source of truth shared with /api/cost/timeline and the WS
8041
8085
  # push); keep budget.json's value only as a fallback when no live spend
8042
8086
  # has been recorded yet.
8087
+ partial = False
8043
8088
  try:
8044
8089
  snapshot = _compute_budget_snapshot(loki_dir)
8045
8090
  live_used = snapshot.get("used")
8046
- if isinstance(live_used, (int, float)) and live_used > 0:
8091
+ # Live spend wins when positive. A measured live zero stands only when
8092
+ # budget.json holds no positive reading; unmeasured live spend is None
8093
+ # and leaves the recorded fallback (or unknown) in place.
8094
+ if isinstance(live_used, (int, float)) and (live_used > 0 or budget_used_f is None):
8047
8095
  budget_used_f = float(live_used)
8096
+ partial = bool(snapshot.get("partial"))
8048
8097
  if budget_limit_f is None and snapshot.get("limit") is not None:
8049
8098
  budget_limit_f = _to_float(snapshot.get("limit"), None)
8050
8099
  except Exception:
@@ -8053,15 +8102,16 @@ async def get_budget():
8053
8102
  pass
8054
8103
 
8055
8104
  remaining = None
8056
- if budget_limit_f is not None:
8105
+ if budget_limit_f is not None and budget_used_f is not None:
8057
8106
  remaining = max(0.0, budget_limit_f - budget_used_f)
8058
8107
 
8059
8108
  return {
8060
8109
  "budget_limit": budget_limit_f,
8061
- "current_cost": round(budget_used_f, 4),
8110
+ "current_cost": round(budget_used_f, 4) if budget_used_f is not None else None,
8062
8111
  "exceeded": exceeded,
8063
8112
  "exceeded_at": exceeded_at,
8064
8113
  "remaining": round(remaining, 4) if remaining is not None else None,
8114
+ "partial": partial,
8065
8115
  }
8066
8116
 
8067
8117
 
@@ -8071,15 +8121,19 @@ async def get_budget():
8071
8121
  _BUDGET_WARN_FRACTION = 0.80
8072
8122
 
8073
8123
 
8074
- def _budget_status(used: float, limit: Optional[float]) -> str:
8124
+ def _budget_status(used: Optional[float], limit: Optional[float]) -> str:
8075
8125
  """Classify budget usage. Read-time only; no state mutation.
8076
8126
 
8077
- Returns one of: "none" (no limit set), "ok" (<80%), "warn" (>=80% and
8078
- <100%), "exceeded" (>=100%). The warn band is the anti-surprise wedge:
8079
- the user sees it BEFORE the hard cap pauses the run.
8127
+ Returns one of: "none" (no limit set), "unknown" (a limit is set but no
8128
+ spend was measured), "ok" (<80%), "warn" (>=80% and <100%), "exceeded"
8129
+ (>=100%). The warn band is the anti-surprise wedge: the user sees it
8130
+ BEFORE the hard cap pauses the run. Unknown is never "ok": nothing was
8131
+ measured, so nothing is within budget.
8080
8132
  """
8081
8133
  if limit is None or limit <= 0:
8082
8134
  return "none"
8135
+ if used is None:
8136
+ return "unknown"
8083
8137
  if used >= limit:
8084
8138
  return "exceeded"
8085
8139
  if used >= _BUDGET_WARN_FRACTION * limit:
@@ -8100,11 +8154,18 @@ def _compute_budget_snapshot(loki_dir: _Path) -> dict:
8100
8154
  budget_file = loki_dir / "metrics" / "budget.json"
8101
8155
 
8102
8156
  current_total = 0.0
8157
+ # Same predicate as /api/cost: a present record is not a measurement.
8158
+ cost_recorded = False
8159
+ unmeasured = 0
8103
8160
  if efficiency_dir.exists():
8104
8161
  for eff_file in sorted(efficiency_dir.glob("iteration-*.json")):
8105
8162
  data = _safe_json_read(eff_file, default=None)
8106
8163
  if not isinstance(data, dict):
8107
8164
  continue
8165
+ if _record_is_measured(data):
8166
+ cost_recorded = True
8167
+ else:
8168
+ unmeasured += 1
8108
8169
  inp = data.get("input_tokens", 0) or 0
8109
8170
  out = data.get("output_tokens", 0) or 0
8110
8171
  # Cache tiers, same as the /api/cost path. This snapshot drives the
@@ -8143,8 +8204,9 @@ def _compute_budget_snapshot(loki_dir: _Path) -> dict:
8143
8204
  except (TypeError, ValueError):
8144
8205
  budget_limit = None
8145
8206
 
8146
- used = round(current_total, 6)
8147
- if budget_limit is not None and budget_limit > 0:
8207
+ # Unmeasured spend is null (status "unknown"), never $0.00 / 0.0% used.
8208
+ used = round(current_total, 6) if cost_recorded else None
8209
+ if budget_limit is not None and budget_limit > 0 and used is not None:
8148
8210
  remaining = max(0.0, budget_limit - used)
8149
8211
  percent_used = round((used / budget_limit) * 100, 2)
8150
8212
  else:
@@ -8160,6 +8222,9 @@ def _compute_budget_snapshot(loki_dir: _Path) -> dict:
8160
8222
  "status": status,
8161
8223
  "warn_threshold_percent": int(_BUDGET_WARN_FRACTION * 100),
8162
8224
  "exceeded": status == "exceeded",
8225
+ # Some iterations recorded a cost and some did not: used and
8226
+ # percent_used are lower bounds and remaining an upper bound.
8227
+ "partial": cost_recorded and unmeasured > 0,
8163
8228
  }
8164
8229
 
8165
8230
 
@@ -8194,6 +8259,7 @@ def _compute_cost_timeline() -> dict:
8194
8259
  iterations: list = []
8195
8260
  current_total = 0.0
8196
8261
  cost_recorded = False
8262
+ unmeasured = 0
8197
8263
  if efficiency_dir.exists():
8198
8264
  records = []
8199
8265
  for eff_file in sorted(efficiency_dir.glob("iteration-*.json")):
@@ -8215,8 +8281,11 @@ def _compute_cost_timeline() -> dict:
8215
8281
  # codex run wrote reported total_usd $0.00 with cost_recorded True
8216
8282
  # -- the endpoint asserting the run was FREE. Same predicate as
8217
8283
  # /api/cost so the two cost readers cannot disagree.
8218
- if _record_is_measured(data):
8284
+ measured = _record_is_measured(data)
8285
+ if measured:
8219
8286
  cost_recorded = True
8287
+ else:
8288
+ unmeasured += 1
8220
8289
  inp = data.get("input_tokens", 0) or 0
8221
8290
  out = data.get("output_tokens", 0) or 0
8222
8291
  # Cache tiers, same as the /api/cost path. This snapshot drives the
@@ -8239,19 +8308,25 @@ def _compute_cost_timeline() -> dict:
8239
8308
  iterations.append({
8240
8309
  "iteration": data.get("iteration"),
8241
8310
  "timestamp": data.get("timestamp"),
8242
- "model": model,
8311
+ # The "sonnet" default above only picks a price; it is not a
8312
+ # model anyone recorded, so it is never shown as one.
8313
+ "model": model if data.get("model") else None,
8243
8314
  "phase": data.get("phase", "unknown"),
8244
8315
  "provider": data.get("provider"),
8245
- "input_tokens": inp,
8246
- "output_tokens": out,
8247
- "cost_usd": round(cost, 6),
8248
- "cumulative_usd": round(cumulative, 6),
8316
+ # An unmeasured record's tokens are unknown, not 0.
8317
+ "input_tokens": inp if measured else None,
8318
+ "output_tokens": out if measured else None,
8319
+ # An iteration nobody measured has no cost and no point on the
8320
+ # cumulative line (cost.html skips a null point).
8321
+ "cost_usd": round(cost, 6) if measured else None,
8322
+ "cumulative_usd": round(cumulative, 6) if measured else None,
8249
8323
  })
8250
8324
  current_total = cumulative
8251
8325
 
8252
8326
  # --- per-run history: from .loki/proofs/*/proof.json --------------------
8253
8327
  runs: list = []
8254
8328
  project_total = 0.0
8329
+ runs_measured = 0
8255
8330
  proofs_dir = _proofs_dir()
8256
8331
  try:
8257
8332
  entries = sorted(proofs_dir.iterdir())
@@ -8269,6 +8344,7 @@ def _compute_cost_timeline() -> dict:
8269
8344
  try:
8270
8345
  run_cost_num = float(run_cost)
8271
8346
  project_total += run_cost_num
8347
+ runs_measured += 1
8272
8348
  except (TypeError, ValueError):
8273
8349
  run_cost_num = None
8274
8350
  runs.append({
@@ -8293,10 +8369,18 @@ def _compute_cost_timeline() -> dict:
8293
8369
  "iterations": iterations,
8294
8370
  "total_usd": round(current_total, 6) if cost_recorded else None,
8295
8371
  "cost_recorded": cost_recorded,
8372
+ # total_usd sums only the measured iterations when some were not.
8373
+ "partial": cost_recorded and unmeasured > 0,
8374
+ "unmeasured_iterations": unmeasured,
8296
8375
  },
8297
8376
  "runs": runs,
8298
8377
  "runs_count": len(runs),
8299
- "project_total_usd": round(project_total, 6) if runs else 0.0,
8378
+ # Null unless at least one run recorded a cost: runs that each read
8379
+ # "not recorded" do not add up to a $0.00 project. A measured $0.00 run
8380
+ # still makes it 0.0. When only some runs recorded a cost the sum is a
8381
+ # lower bound, and project_total_partial says so.
8382
+ "project_total_usd": round(project_total, 6) if runs_measured else None,
8383
+ "project_total_partial": 0 < runs_measured < len(runs),
8300
8384
  "budget": budget,
8301
8385
  }
8302
8386
 
@@ -8453,11 +8537,18 @@ _PROVIDER_LABELS = {
8453
8537
  # quoting the standard $3/$15 rate: over-estimating the display is the safe
8454
8538
  # direction (a bill can only come in lower than quoted, never higher). This note
8455
8539
  # tells the user why the quote is conservative during the intro window.
8540
+ _UNVERIFIED_RATE_NOTE = (
8541
+ "Unverified placeholder rate, scaled from gpt-5.3; not from OpenAI's pricing page."
8542
+ )
8456
8543
  _MODEL_PRICING_NOTES = {
8457
8544
  "sonnet": (
8458
8545
  "Intro pricing: $2 / $10 per MTok through Aug 31 2026. "
8459
8546
  "Estimates use the standard $3 / $15 rate (conservative)."
8460
8547
  ),
8548
+ # See the UNVERIFIED RATES comment on the pricing table: shown, not hidden.
8549
+ "gpt-5.6-sol": _UNVERIFIED_RATE_NOTE,
8550
+ "gpt-5.6-terra": _UNVERIFIED_RATE_NOTE,
8551
+ "gpt-5.6-luna": _UNVERIFIED_RATE_NOTE,
8461
8552
  }
8462
8553
 
8463
8554
  _MODEL_PROVIDERS = {
@@ -8750,21 +8841,24 @@ async def get_context():
8750
8841
  tracking_file = loki_dir / "context" / "tracking.json"
8751
8842
 
8752
8843
  if not tracking_file.exists():
8844
+ # The tracker never ran: every reading is null (the panels show
8845
+ # "unknown"), never a measured 0 / 0.0% / $0.00. The context tracker
8846
+ # always writes updated_at, so a null updated_at also means unmeasured.
8753
8847
  return {
8754
- "session_id": "",
8755
- "updated_at": "",
8848
+ "session_id": None,
8849
+ "updated_at": None,
8756
8850
  "current": {
8757
- "input_tokens": 0, "output_tokens": 0,
8758
- "cache_read_tokens": 0, "cache_creation_tokens": 0,
8759
- "total_tokens": 0, "context_window_pct": 0.0,
8760
- "estimated_cost_usd": 0.0,
8851
+ "input_tokens": None, "output_tokens": None,
8852
+ "cache_read_tokens": None, "cache_creation_tokens": None,
8853
+ "total_tokens": None, "context_window_pct": None,
8854
+ "estimated_cost_usd": None,
8761
8855
  },
8762
8856
  "compactions": [],
8763
8857
  "per_iteration": [],
8764
8858
  "totals": {
8765
- "total_input": 0, "total_output": 0,
8766
- "total_cost_usd": 0.0, "compaction_count": 0,
8767
- "iterations_tracked": 0,
8859
+ "total_input": None, "total_output": None,
8860
+ "total_cost_usd": None, "compaction_count": None,
8861
+ "iterations_tracked": None,
8768
8862
  },
8769
8863
  }
8770
8864
 
@@ -8863,6 +8957,16 @@ async def update_notification_triggers(request: Request):
8863
8957
  @app.post("/api/notifications/{notification_id}/acknowledge", dependencies=[Depends(auth.require_scope("control"))])
8864
8958
  async def acknowledge_notification(notification_id: str):
8865
8959
  """Mark a notification as acknowledged."""
8960
+ return _set_notification_acknowledged(notification_id, True)
8961
+
8962
+
8963
+ @app.post("/api/notifications/{notification_id}/unacknowledge", dependencies=[Depends(auth.require_scope("control"))])
8964
+ async def unacknowledge_notification(notification_id: str):
8965
+ """Mark a notification as unread again (the notification center's 'mark unread')."""
8966
+ return _set_notification_acknowledged(notification_id, False)
8967
+
8968
+
8969
+ def _set_notification_acknowledged(notification_id: str, acknowledged: bool) -> dict:
8866
8970
  loki_dir = _get_loki_dir()
8867
8971
  active_file = loki_dir / "notifications" / "active.json"
8868
8972
 
@@ -8878,7 +8982,7 @@ async def acknowledge_notification(notification_id: str):
8878
8982
  found = False
8879
8983
  for n in notifications:
8880
8984
  if n.get("id") == notification_id:
8881
- n["acknowledged"] = True
8985
+ n["acknowledged"] = acknowledged
8882
8986
  found = True
8883
8987
  break
8884
8988
 
@@ -10325,18 +10429,6 @@ async def remove_checklist_waiver(item_id: str):
10325
10429
  # Council Hard Gate Endpoint (Phase 4)
10326
10430
  # =============================================================================
10327
10431
 
10328
- _DEFAULT_QUALITY_GATES = [
10329
- {"name": "Static Analysis", "description": "CodeQL, ESLint/Pylint, type-checker findings on the diff", "status": "pending"},
10330
- {"name": "Test Suite", "description": "Project test runner pass/fail (red blocks)", "status": "pending"},
10331
- {"name": "Blind Code Review", "description": "3-reviewer blind review; Critical/High = BLOCK; Medium/Low advisory", "status": "pending"},
10332
- {"name": "Anti-Sycophancy", "description": "Devil's Advocate re-review on unanimous PASS", "status": "pending"},
10333
- {"name": "Mock Integrity", "description": "Tautological-assertion and mock-ratio detection", "status": "pending"},
10334
- {"name": "Test Mutation", "description": "Assertion-churn (test-fitting) detection", "status": "pending"},
10335
- {"name": "Documentation Coverage", "description": "README presence, docs freshness, API docs for exported symbols", "status": "pending"},
10336
- {"name": "Magic Modules Debate", "description": "Spec-vs-implementation debate on generated modules", "status": "pending"},
10337
- ]
10338
-
10339
-
10340
10432
  @app.get("/api/council/gate", dependencies=[Depends(auth.require_scope("read"))])
10341
10433
  async def get_council_gate():
10342
10434
  """Get council hard gate status.
@@ -10354,16 +10446,25 @@ async def get_council_gate():
10354
10446
  gate_file = council_dir / "gate-block.json"
10355
10447
  evidence_file = council_dir / "evidence-block.json"
10356
10448
 
10357
- # Legacy quality gate (backward-compatible top level).
10449
+ # Legacy quality gate (backward-compatible top level). No per-gate result
10450
+ # was recorded unless the file carries a "gates" list (its only writer,
10451
+ # completion-council.sh, never does): the list is then empty with a reason,
10452
+ # never eight constant "pending" rows that read as a checked state. An
10453
+ # unreadable file is unknown (blocked: null), never "not blocked".
10454
+ no_results = "No per-gate results recorded"
10358
10455
  if gate_file.exists():
10359
10456
  try:
10360
10457
  data = json.loads(gate_file.read_text())
10458
+ if not isinstance(data, dict):
10459
+ raise ValueError("gate file is not an object")
10361
10460
  if "gates" not in data:
10362
- data["gates"] = _DEFAULT_QUALITY_GATES
10363
- except (json.JSONDecodeError, IOError):
10364
- data = {"blocked": False, "gates": _DEFAULT_QUALITY_GATES, "error": "Failed to read gate file"}
10461
+ data["gates"] = []
10462
+ data["gates_reason"] = no_results
10463
+ except (json.JSONDecodeError, IOError, ValueError):
10464
+ data = {"blocked": None, "gates": [], "gates_reason": no_results,
10465
+ "error": "Failed to read gate file"}
10365
10466
  else:
10366
- data = {"blocked": False, "gates": _DEFAULT_QUALITY_GATES}
10467
+ data = {"blocked": False, "gates": [], "gates_reason": no_results}
10367
10468
 
10368
10469
  # Verified-completion evidence gate (additive).
10369
10470
  if evidence_file.exists():
@@ -12488,9 +12589,52 @@ async def proofs_summary():
12488
12589
  }
12489
12590
 
12490
12591
 
12592
+ def _proof_integrity_check(proof: dict) -> dict:
12593
+ """Recompute a receipt's integrity with the CLI verifier's own checks.
12594
+
12595
+ Runs autonomy/lib/proof-verify.py verify_integrity() (hash, gpg, headline,
12596
+ cost coherence) plus its schema check, loaded from this install, never from
12597
+ the project. The diff is NOT re-derived: that runs git inside the project,
12598
+ which a read route must not do, so drift_checked is always False and the
12599
+ panel names `loki proof verify <id>` for the full check.
12600
+
12601
+ status: verified (every check here ran and passed), tampered (the recorded
12602
+ hash does not match the bytes), failed (a check ran and said no),
12603
+ not_verified (nothing to check against, or the checks could not run).
12604
+ """
12605
+ out = {"status": "not_verified", "hash_ok": None, "gpg_ok": None,
12606
+ "drift_checked": False, "reasons": [],
12607
+ "checked_by": "autonomy/lib/proof-verify.py verify_integrity"}
12608
+ ver = proof.get("verification")
12609
+ if not isinstance(ver, dict) or not ver.get("hash"):
12610
+ out["reasons"] = ["the receipt records no integrity hash, so there is "
12611
+ "nothing to recompute"]
12612
+ return out
12613
+ try:
12614
+ pv = _build_execution._load_proof_verifier()
12615
+ r = pv.verify_integrity(proof)
12616
+ schema_reason = pv.check_schema_version(proof)
12617
+ except Exception as exc: # the verifier could not run: say so, never pass
12618
+ out["reasons"] = ["the verifier could not run here: %s" % exc]
12619
+ return out
12620
+ out["hash_ok"] = r.get("hash_ok")
12621
+ out["gpg_ok"] = r.get("gpg_ok")
12622
+ out["reasons"] = list(r.get("reasons") or [])
12623
+ if r.get("hash_ok") is not True:
12624
+ out["status"] = "tampered"
12625
+ elif not r.get("ok"):
12626
+ out["status"] = "failed"
12627
+ elif schema_reason:
12628
+ out["reasons"].append(schema_reason)
12629
+ else:
12630
+ out["status"] = "verified"
12631
+ return out
12632
+
12633
+
12491
12634
  @app.get("/api/proofs/{run_id}", dependencies=[Depends(auth.require_scope("read"))])
12492
12635
  async def get_proof(run_id: str):
12493
- """Return the redacted proof.json for one run."""
12636
+ """Return the redacted proof.json for one run, plus integrity_check: the
12637
+ verdict the server computed for it (see _proof_integrity_check)."""
12494
12638
  run_dir = _safe_proof_run_dir(run_id)
12495
12639
  proof_json = run_dir / "proof.json"
12496
12640
  if not proof_json.is_file():
@@ -12498,6 +12642,10 @@ async def get_proof(run_id: str):
12498
12642
  data = _safe_json_read(proof_json, default=None)
12499
12643
  if not isinstance(data, dict):
12500
12644
  raise HTTPException(status_code=500, detail="proof.json unreadable")
12645
+ # Computed on the file's contents BEFORE anything is added to the response;
12646
+ # a to_thread because a gpg signature check shells out to gpg.
12647
+ integrity = await asyncio.to_thread(_proof_integrity_check, dict(data))
12648
+ data["integrity_check"] = integrity
12501
12649
  # Surface the optional PR linkage alongside the proof. The proof.json itself
12502
12650
  # already carries honesty.headline; we only add pr_url so the panel can show
12503
12651
  # "PR #N -> <headline>". Absent pr.json -> pr_url null, never an error.