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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +28 -5
- package/autonomy/council-v2.sh +13 -7
- package/autonomy/lib/done-recognition.sh +24 -16
- package/autonomy/lib/proof-check.sh +11 -3
- package/autonomy/lib/proof-generator.py +36 -5
- package/autonomy/lib/voter-agents.sh +8 -3
- package/autonomy/lib/workspace_diff.py +106 -12
- package/autonomy/prd-checklist.sh +19 -11
- package/autonomy/run.sh +503 -52
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_v2.py +4 -0
- package/dashboard/audit.py +9 -1
- package/dashboard/auth.py +16 -4
- package/dashboard/registry.py +34 -12
- package/dashboard/rigour_integration.py +25 -17
- package/dashboard/server.py +252 -104
- package/dashboard/static/cost.html +55 -26
- package/dashboard/static/index.html +475 -1544
- package/dashboard/static/proofs.html +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/air-gapped.md +7 -2
- package/docs/image-provenance.md +4 -2
- package/docs/v10/BACKLOG.md +82 -2
- package/docs/v10/BOARD.md +105 -0
- package/docs/v10/DECISIONS.md +71 -0
- package/docs/v10/METRICS.md +2 -1
- package/docs/v10/PROGRESS.md +60 -0
- package/docs/v10/SWARM-PROMPT.md +98 -0
- package/docs/v10/SWARM.md +237 -0
- package/loki-ts/dist/loki.js +323 -322
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +8 -0
- package/providers/cline.sh +8 -0
- package/providers/codex.sh +9 -0
- package/providers/opencode.sh +9 -0
- package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
- package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
- package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
- package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
- package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
- package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
- package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
- package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
- package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
- package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
- package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
- package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
- package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
- package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
- package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
- package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
- package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
- package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
- package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
- package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
- package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
- package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
- package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
- package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
- package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
- package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
- package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
- package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
- package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
- package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
- package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
- package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
- package/web-app/dist/assets/index-CI54rara.css +1 -0
- package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
- package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
- package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
- package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
- package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
- package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
- package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
- package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
- package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
- package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
- package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
- package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
- package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
- package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +122 -47
- package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
- package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
- package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
- package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
- package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
- package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
- package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
- package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
- package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
- package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
- package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
- package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
- package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
- package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
- package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
- package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
- package/web-app/dist/assets/mail-COu670_W.js +0 -6
- package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
- package/web-app/dist/assets/save-v8qep5cY.js +0 -6
- package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
package/dashboard/server.py
CHANGED
|
@@ -3082,8 +3082,10 @@ class FleetRunResponse(BaseModel):
|
|
|
3082
3082
|
status: str
|
|
3083
3083
|
running: bool
|
|
3084
3084
|
phase: str = ""
|
|
3085
|
-
iteration:
|
|
3086
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
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":
|
|
6634
|
-
"savings_percent":
|
|
6635
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
7093
|
+
return float(v) if v is not None else None
|
|
7075
7094
|
except (TypeError, ValueError):
|
|
7076
|
-
return
|
|
7095
|
+
return None
|
|
7077
7096
|
|
|
7078
|
-
|
|
7079
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
7313
|
-
|
|
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["
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
by_phase
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
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
|
-
|
|
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 =
|
|
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"
|
|
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
|
|
8026
|
-
# endpoint returns a clean payload instead of a 500.
|
|
8027
|
-
def _to_float(value, default=
|
|
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
|
-
|
|
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
|
-
|
|
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), "
|
|
8078
|
-
|
|
8079
|
-
|
|
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
|
-
|
|
8147
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
8246
|
-
"
|
|
8247
|
-
"
|
|
8248
|
-
|
|
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
|
-
|
|
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":
|
|
8758
|
-
"cache_read_tokens":
|
|
8759
|
-
"total_tokens":
|
|
8760
|
-
"estimated_cost_usd":
|
|
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":
|
|
8766
|
-
"total_cost_usd":
|
|
8767
|
-
"iterations_tracked":
|
|
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"] =
|
|
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"] =
|
|
10363
|
-
|
|
10364
|
-
|
|
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":
|
|
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.
|