loki-mode 9.54.0 → 9.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/completion-council.sh +120 -35
  5. package/autonomy/crash.sh +6 -6
  6. package/autonomy/lib/done-recognition.sh +7 -0
  7. package/autonomy/lib/prd-enrich.sh +7 -0
  8. package/autonomy/lib/proof-generator.py +36 -5
  9. package/autonomy/lib/proof-verify.py +93 -21
  10. package/autonomy/lib/voter-agents.sh +7 -0
  11. package/autonomy/loki +404 -119
  12. package/autonomy/quickstart.sh +7 -0
  13. package/autonomy/run.sh +836 -58
  14. package/autonomy/verify.sh +114 -8
  15. package/bin/loki +12 -0
  16. package/dashboard/__init__.py +1 -1
  17. package/dashboard/api_v2.py +4 -0
  18. package/dashboard/audit.py +9 -1
  19. package/dashboard/auth.py +16 -4
  20. package/dashboard/registry.py +34 -12
  21. package/dashboard/rigour_integration.py +25 -17
  22. package/dashboard/server.py +252 -104
  23. package/dashboard/static/cost.html +55 -26
  24. package/dashboard/static/index.html +475 -1544
  25. package/dashboard/static/proofs.html +1 -1
  26. package/docs/INSTALLATION.md +7 -6
  27. package/docs/air-gapped.md +38 -11
  28. package/docs/enterprise/security.md +10 -13
  29. package/docs/environment-variables.md +1 -0
  30. package/docs/exit-codes.md +76 -2
  31. package/docs/image-provenance.md +4 -2
  32. package/docs/v10/BACKLOG.md +64 -2
  33. package/docs/v10/BOARD.md +134 -0
  34. package/docs/v10/CONTROL.md +36 -0
  35. package/docs/v10/DECISIONS.md +102 -0
  36. package/docs/v10/METRICS.md +78 -1
  37. package/docs/v10/PROGRESS.md +240 -0
  38. package/docs/v10/SWARM-PROMPT.md +98 -0
  39. package/docs/v10/SWARM.md +237 -0
  40. package/loki-ts/dist/loki.js +7 -4
  41. package/mcp/__init__.py +1 -1
  42. package/package.json +1 -1
  43. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  44. package/providers/aider.sh +8 -0
  45. package/providers/cline.sh +8 -0
  46. package/providers/codex.sh +9 -0
  47. package/providers/loader.sh +10 -0
  48. package/providers/opencode.sh +9 -0
  49. package/tools/receipt-attest.py +1 -1
  50. package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
  51. package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
  52. package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
  53. package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
  54. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
  55. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
  56. package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
  57. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
  58. package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
  59. package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
  60. package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
  61. package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
  62. package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
  63. package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
  64. package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
  65. package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
  66. package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
  67. package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
  68. package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
  69. package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
  70. package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
  71. package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
  72. package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
  73. package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
  74. package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
  75. package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
  76. package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
  77. package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
  78. package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
  79. package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
  80. package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
  81. package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
  82. package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
  83. package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
  84. package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
  85. package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
  86. package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
  87. package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
  88. package/web-app/dist/assets/index-CI54rara.css +1 -0
  89. package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
  90. package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
  91. package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
  92. package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
  93. package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
  94. package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
  95. package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
  96. package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
  97. package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
  98. package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
  99. package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
  100. package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
  101. package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
  102. package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
  103. package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
  104. package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
  105. package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
  106. package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
  107. package/web-app/dist/index.html +2 -2
  108. package/web-app/server.py +122 -47
  109. package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
  110. package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
  111. package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
  112. package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
  113. package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
  114. package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
  115. package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
  116. package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
  117. package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
  118. package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
  119. package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
  120. package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
  121. package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
  122. package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
  123. package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
  124. package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
  125. package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
  126. package/web-app/dist/assets/mail-COu670_W.js +0 -6
  127. package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
  128. package/web-app/dist/assets/save-v8qep5cY.js +0 -6
  129. package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
@@ -1689,14 +1689,26 @@ verify_gate_runtime() {
1689
1689
  local boot_log="$artifact_dir/boot.log"
1690
1690
 
1691
1691
  # Boot the app in the background, bounded by the timeout wrapper. The whole
1692
- # process group is killed on teardown. Env is inherited (no secrets injected).
1693
- # PORT is exported to the resolved port so 12-factor apps (node, many python
1694
- # frameworks) listen where the probe looks; apps that hardcode a port ignore
1695
- # it harmlessly. This keeps boot-port and probe-port consistent.
1692
+ # process group is killed on teardown. Env is inherited (no secrets injected)
1693
+ # PLUS one new marker: LOKI_VERIFY_RUN_TOKEN, a per-run random value used
1694
+ # ONLY as an ownership proof at teardown (see _verify_runtime_teardown) --
1695
+ # it identifies "a process this exact verify run launched" even after a
1696
+ # setsid()'d daemon detaches into its own session/process group, which
1697
+ # defeats every pgid/ppid-based check (round-4 council finding: v7.109.0's
1698
+ # daemonize-leak fixture uses child_process.spawn({detached:true}), and no
1699
+ # pgid/ppid arm can ever match a setsid'd process). Environment variables
1700
+ # survive setsid() and fork()/spawn() regardless of process-group changes,
1701
+ # so this closes exactly the gap those checks cannot. PORT is exported to
1702
+ # the resolved port so 12-factor apps (node, many python frameworks) listen
1703
+ # where the probe looks; apps that hardcode a port ignore it harmlessly.
1704
+ # This keeps boot-port and probe-port consistent.
1705
+ local run_token=""
1706
+ run_token="verify-$$-${RANDOM}-${RANDOM}-$(date +%s 2>/dev/null || echo 0)"
1696
1707
  local app_pid=""
1697
1708
  (
1698
1709
  cd "$tree" || exit 127
1699
1710
  export PORT="$port"
1711
+ export LOKI_VERIFY_RUN_TOKEN="$run_token"
1700
1712
  exec "$timeout_bin" "$boot_timeout" sh -c "$method"
1701
1713
  ) >"$boot_log" 2>&1 &
1702
1714
  app_pid=$!
@@ -1769,7 +1781,7 @@ verify_gate_runtime() {
1769
1781
  # Reclaim BOTH the detected port and the actually-bound port (scraped from the
1770
1782
  # boot log) so a server that daemonized onto a different port than we guessed
1771
1783
  # does not leak. scraped_port may be empty (no banner) -- teardown handles it.
1772
- _verify_runtime_teardown "$app_pid" "$port" "$scraped_port"
1784
+ _verify_runtime_teardown "$app_pid" "$port" "$scraped_port" "$run_token"
1773
1785
 
1774
1786
  # Interpret the result.
1775
1787
  if [ "$answered" != "true" ]; then
@@ -1907,8 +1919,34 @@ SMOKE_SCRIPT
1907
1919
 
1908
1920
  # Teardown: terminate the boot launcher and any process still holding the port.
1909
1921
  # Bounded and best effort; never blocks the gate.
1922
+ # Does pid's environment carry LOKI_VERIFY_RUN_TOKEN=<tok> exactly? Portable
1923
+ # ownership proof that survives setsid() (see the fourth arm below for why
1924
+ # pgid/ppid checks cannot). Fails CLOSED: an empty token, an unreadable
1925
+ # environment, or any ambiguity returns "no match" -- never a false positive
1926
+ # that would authorize a kill. Checked directly (not assumed) on this
1927
+ # codebase's target platforms: Linux via /proc/<pid>/environ (NUL-separated,
1928
+ # same-uid readable); macOS/BSD via `ps -E -ww -o command=` (the `-E` flag
1929
+ # specifically requests the environment block; `-e` on BSD ps means "all
1930
+ # processes" and shows nothing extra -- do not confuse the two).
1931
+ _verify_runtime_pid_has_token() {
1932
+ local pid="$1" tok="$2"
1933
+ case "$pid" in ''|*[!0-9]*) return 1 ;; esac
1934
+ [ -n "$tok" ] || return 1
1935
+ if [ -r "/proc/$pid/environ" ]; then
1936
+ tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -qxF "LOKI_VERIFY_RUN_TOKEN=$tok"
1937
+ return $?
1938
+ fi
1939
+ if command -v ps >/dev/null 2>&1; then
1940
+ case " $(ps -E -ww -o command= -p "$pid" 2>/dev/null) " in
1941
+ *" LOKI_VERIFY_RUN_TOKEN=$tok "*) return 0 ;;
1942
+ *) return 1 ;;
1943
+ esac
1944
+ fi
1945
+ return 1
1946
+ }
1947
+
1910
1948
  _verify_runtime_teardown() {
1911
- local app_pid="$1" port="$2" scraped_port="${3:-}"
1949
+ local app_pid="$1" port="$2" scraped_port="${3:-}" run_token="${4:-}"
1912
1950
  if [ -n "$app_pid" ]; then
1913
1951
  # Best: kill the launcher's whole process GROUP so a server it spawned in
1914
1952
  # a subshell (e.g. `vite &`) dies too. GUARDED against self-suicide: in a
@@ -1943,6 +1981,22 @@ _verify_runtime_teardown() {
1943
1981
  # Reclaim BOTH the detected port and the actually-bound (scraped) port from
1944
1982
  # any orphan that outlived the launcher -- a daemonized server that bound a
1945
1983
  # different port than we guessed would otherwise leak. Bounded, best effort.
1984
+ #
1985
+ # SCOPING (D14/D15/D16 class): `lsof -ti tcp:$port` alone, with no
1986
+ # -sTCP:LISTEN filter, matches EVERY socket referencing that port number,
1987
+ # including a CLIENT with an established connection whose remote port
1988
+ # happens to be $port (e.g. a browser tab open to localhost:$port) -- that
1989
+ # process gets SIGKILL'd even though it never bound anything. And with no
1990
+ # ownership check at all, ANY process (this run's or not) listening on the
1991
+ # reused port gets killed. Fixed to (1) filter to LISTEN sockets only, and
1992
+ # (2) verify each candidate is actually part of THIS launcher's tree before
1993
+ # killing it: its pgid matches $child_pgid (the group this teardown just
1994
+ # tried to signal above), or its direct parent is $app_pid. A holder that
1995
+ # matches neither is left alone -- this run never bound that port, so it
1996
+ # never reclaims it. Trade-off: a server that called setsid() into a fresh
1997
+ # session AND was reparented away from app_pid (neither condition holds)
1998
+ # will leak here; failing closed on an unverified kill is the safer
1999
+ # direction (see docs/v10/DECISIONS.md D14/D15/D16).
1946
2000
  if command -v lsof >/dev/null 2>&1; then
1947
2001
  # Build a unique, non-empty port list (scraped only if it differs).
1948
2002
  local _ports="$port"
@@ -1951,10 +2005,62 @@ _verify_runtime_teardown() {
1951
2005
  for _rp in $_ports; do
1952
2006
  [ -z "$_rp" ] && continue
1953
2007
  local holders
1954
- holders="$(lsof -ti tcp:"$_rp" 2>/dev/null || true)"
2008
+ holders="$(lsof -ti tcp:"$_rp" -sTCP:LISTEN 2>/dev/null || true)"
1955
2009
  if [ -n "$holders" ]; then
1956
2010
  printf '%s\n' "$holders" | while IFS= read -r pid; do
1957
- [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
2011
+ [ -z "$pid" ] && continue
2012
+ local _h_pgid="" _h_ppid=""
2013
+ _h_pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
2014
+ _h_ppid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
2015
+ # THIRD ARM (council round 2, REJECT reproduced and fixed):
2016
+ # GNU `timeout` (this codebase's launcher, verify.sh:1699)
2017
+ # makes ITSELF the process-group leader, so the group's
2018
+ # pgid equals timeout's own pid, which equals app_pid (the
2019
+ # subshell execs straight into timeout with no fork). If
2020
+ # the launched command daemonizes -- forks a server and
2021
+ # the parent (timeout, then sh -c) exits -- by teardown
2022
+ # time: child_pgid is EMPTY (timeout already exited, so
2023
+ # `ps -o pgid= -p "$app_pid"` finds nothing); the orphan's
2024
+ # ppid is 1, not app_pid; but its pgid is STILL app_pid,
2025
+ # the value it inherited when timeout created the group.
2026
+ # Neither of the two arms above can ever match this case,
2027
+ # so the classic "daemonized" leak this function's own
2028
+ # comments describe was never reclaimed. Reproduced
2029
+ # directly: own daemon listener LEAKED pre-this-arm,
2030
+ # reclaimed after. Safe as an ownership proof, not a
2031
+ # loosening: a process group's pgid cannot be reassigned
2032
+ # to an unrelated group while any member of the original
2033
+ # group (here, the orphan itself, still alive and
2034
+ # reporting this exact pgid) still exists -- POSIX pgid
2035
+ # reuse is blocked until the whole group is empty.
2036
+ # FOURTH ARM (council round 4, 2 of 4 reviewers REJECTed
2037
+ # with an identical reproduction; verified independently
2038
+ # before applying): a process launched via
2039
+ # child_process.spawn({detached:true}) (Node's setsid()
2040
+ # wrapper) becomes its OWN session and process group
2041
+ # leader -- its pgid equals its OWN pid, not app_pid or
2042
+ # child_pgid, and its ppid is 1. None of the three arms
2043
+ # above can ever match that shape, which is exactly
2044
+ # test-runtime-gate.sh case I's fixture (the v7.109.0
2045
+ # daemonize-leak regression test) and a real pattern many
2046
+ # servers use to daemonize. Environment variables survive
2047
+ # setsid()/fork()/exec() regardless of process-group
2048
+ # changes, so LOKI_VERIFY_RUN_TOKEN (exported into the
2049
+ # launched app's env at boot, see the launch site above)
2050
+ # is checked here as a portability-verified ownership
2051
+ # proof (see _verify_runtime_pid_has_token; confirmed
2052
+ # directly on macOS with `ps -E -ww`, not assumed).
2053
+ # Residual, disclosed gap: a daemon that scrubs its own
2054
+ # environment (env -i, some process supervisors) before
2055
+ # detaching still leaks -- no ownership proof survives
2056
+ # that, and failing closed on an unverified kill remains
2057
+ # the safer direction.
2058
+ if { [ -n "$child_pgid" ] && [ -n "$_h_pgid" ] && [ "$_h_pgid" = "$child_pgid" ]; } \
2059
+ || { [ -n "$app_pid" ] && [ -n "$_h_ppid" ] && [ "$_h_ppid" = "$app_pid" ]; } \
2060
+ || { [ -n "$app_pid" ] && [ -n "$_h_pgid" ] && [ "$_h_pgid" = "$app_pid" ]; } \
2061
+ || _verify_runtime_pid_has_token "$pid" "$run_token"; then
2062
+ kill -9 "$pid" 2>/dev/null || true
2063
+ fi
1958
2064
  done
1959
2065
  fi
1960
2066
  done
package/bin/loki CHANGED
@@ -348,6 +348,18 @@ if [ "${1:-}" = "trust" ]; then
348
348
  done
349
349
  fi
350
350
 
351
+ # `doctor --airgap` (egress audit) lives only in bash (_loki_airgap_audit).
352
+ # The Bun doctor rejected the flag, so the default route had no audit at all.
353
+ # Routed here, flag-anywhere, so the committed dist needs no rebuild and bash
354
+ # stays the single implementation; bash main() fires the one cli_command event.
355
+ if [ "${1:-}" = "doctor" ]; then
356
+ for _doctor_arg in "${@:2}"; do
357
+ if [ "$_doctor_arg" = "--airgap" ]; then
358
+ exec "$BASH_CLI" "$@"
359
+ fi
360
+ done
361
+ fi
362
+
351
363
  # CLI consolidation (Phase B): `report kpis` is the canonical form of the
352
364
  # Bun-only KPI snapshot. The `report` noun is otherwise bash-owned (every other
353
365
  # subcommand -- session/metrics/cost/export/share/dogfood -- routes to bash
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.54.0"
10
+ __version__ = "9.55.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -894,6 +894,10 @@ async def verify_audit_integrity():
894
894
 
895
895
  return {
896
896
  "valid": aggregate["valid"],
897
+ # verified/status: whether anything was checked (valid stays True for
898
+ # an empty chain); see audit.verify_all_logs_in_dir.
899
+ "verified": aggregate["verified"],
900
+ "status": aggregate["status"],
897
901
  "files_checked": aggregate["files_checked"],
898
902
  "files_skipped": aggregate.get("files_skipped", 0),
899
903
  "entries_checked": aggregate.get("entries_checked", 0),
@@ -657,7 +657,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
657
657
  if not audit_dir.exists():
658
658
  return {"valid": True, "files_checked": 0, "files_skipped": 0,
659
659
  "entries_checked": 0, "first_tampered_file": None,
660
- "first_tampered_line": None, "genesis_file": None}
660
+ "first_tampered_line": None, "genesis_file": None,
661
+ "verified": False, "status": "nothing_checked"}
661
662
  # v7.7.15 council fix (Opus 2): rotated files have name shape
662
663
  # `audit-YYYY-MM-DD.HHMMSS.jsonl` (from `_rotate_logs_if_needed` at
663
664
  # line 167). Lexicographic sort puts `audit-2026-05-04.123456.jsonl`
@@ -689,8 +690,13 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
689
690
  "first_tampered_file": str(log_file),
690
691
  "first_tampered_line": result.get("first_tampered_line"),
691
692
  "genesis_file": genesis_file,
693
+ "verified": False,
694
+ "status": "tampered",
692
695
  }
693
696
  prev_hash = result.get("last_hash", prev_hash)
697
+ # `valid` means "no break found" and stays True for an empty chain (the
698
+ # cross-chain verifier reads it). `verified` and `status` say whether
699
+ # anything was actually checked: zero files checked is not a verified chain.
694
700
  return {
695
701
  "valid": True,
696
702
  "files_checked": files_checked,
@@ -699,6 +705,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
699
705
  "first_tampered_file": None,
700
706
  "first_tampered_line": None,
701
707
  "genesis_file": genesis_file,
708
+ "verified": files_checked > 0,
709
+ "status": "verified" if files_checked > 0 else "nothing_checked",
702
710
  }
703
711
 
704
712
 
package/dashboard/auth.py CHANGED
@@ -343,6 +343,18 @@ def list_tokens(include_revoked: bool = False) -> list[dict]:
343
343
  return result
344
344
 
345
345
 
346
+ def _deadline_passed(value) -> bool:
347
+ """True when an ISO-8601 deadline is in the past, or cannot be read.
348
+
349
+ Fails closed: an unparseable value, or a naive timestamp that cannot be
350
+ ordered against an aware "now", is treated as passed.
351
+ """
352
+ try:
353
+ return datetime.now(timezone.utc) > datetime.fromisoformat(str(value))
354
+ except (TypeError, ValueError):
355
+ return True
356
+
357
+
346
358
  def validate_token(raw_token: str) -> Optional[dict]:
347
359
  """
348
360
  Validate a raw token.
@@ -372,10 +384,10 @@ def validate_token(raw_token: str) -> Optional[dict]:
372
384
  if matched_token.get("revoked"):
373
385
  return None
374
386
 
375
- # Check expiration
376
- if matched_token.get("expires_at"):
377
- expires = datetime.fromisoformat(matched_token["expires_at"])
378
- if datetime.now(timezone.utc) > expires:
387
+ # Check expiration, and the grace period of a rotated-out key
388
+ # (api_keys.rotate_key sets rotation_expires_at on the old key).
389
+ for field in ("expires_at", "rotation_expires_at"):
390
+ if matched_token.get(field) and _deadline_passed(matched_token[field]):
379
391
  return None
380
392
 
381
393
  # Update last used
@@ -16,6 +16,8 @@ from pathlib import Path
16
16
  from typing import Iterator, Optional
17
17
  import hashlib
18
18
 
19
+ from .api_runs import _efficiency_module
20
+
19
21
 
20
22
  # Registry file location
21
23
  REGISTRY_DIR = Path.home() / ".loki" / "dashboard"
@@ -686,13 +688,14 @@ def _read_project_run_snapshot(path: str) -> dict:
686
688
  .loki/context/tracking.json fallback (totals.total_cost_usd)
687
689
 
688
690
  Returns a dict with phase, iteration, cost_usd, started_at, and ended_at
689
- (best-effort; missing values default to None/0). Never raises: any file
690
- problem degrades the affected field to its default.
691
+ (best-effort; missing values default to None). iteration and cost_usd are
692
+ None, never 0 / 0.0, when nothing recorded them. Never raises: any file problem degrades the
693
+ affected field to its default.
691
694
  """
692
695
  snap = {
693
696
  "phase": "",
694
- "iteration": 0,
695
- "cost_usd": 0.0,
697
+ "iteration": None,
698
+ "cost_usd": None,
696
699
  "started_at": None,
697
700
  "ended_at": None,
698
701
  }
@@ -708,8 +711,8 @@ def _read_project_run_snapshot(path: str) -> dict:
708
711
  if isinstance(state, dict):
709
712
  _p = state.get("phase", "")
710
713
  snap["phase"] = _p if isinstance(_p, str) else ""
711
- _i = state.get("iteration", 0)
712
- snap["iteration"] = _i if isinstance(_i, int) else 0
714
+ _i = state.get("iteration")
715
+ snap["iteration"] = _i if isinstance(_i, int) and not isinstance(_i, bool) else None
713
716
  except (json.JSONDecodeError, OSError, ValueError):
714
717
  pass
715
718
 
@@ -727,6 +730,15 @@ def _read_project_run_snapshot(path: str) -> dict:
727
730
  pass
728
731
 
729
732
  # Cost: sum per-iteration efficiency files; fall back to context tracking.
733
+ # A present record is not a measurement: only a record that carried an
734
+ # observed value (the canonical record_is_measured, the same predicate as
735
+ # dashboard/server.py _record_is_measured) and a numeric cost counts, so an
736
+ # all-zero or field-less record, or the 0.0 a fresh tracking.json is seeded
737
+ # with, reads as unknown instead of $0.00. A measured $0.00 stays 0.0.
738
+ # ponytail: a measured record with tokens but no cost_usd adds nothing (the
739
+ # fleet does not price tokens); price it here if unpriced records appear.
740
+ eff_mod = _efficiency_module()
741
+ measured = eff_mod.record_is_measured if eff_mod is not None else (lambda _rec: False)
730
742
  cost = 0.0
731
743
  found_cost = False
732
744
  eff_dir = loki_dir / "metrics" / "efficiency"
@@ -738,7 +750,7 @@ def _read_project_run_snapshot(path: str) -> dict:
738
750
  if not isinstance(data, dict):
739
751
  continue
740
752
  c = data.get("cost_usd")
741
- if isinstance(c, (int, float)):
753
+ if isinstance(c, (int, float)) and not isinstance(c, bool) and measured(data):
742
754
  cost += float(c)
743
755
  found_cost = True
744
756
  except (json.JSONDecodeError, OSError, ValueError):
@@ -754,11 +766,15 @@ def _read_project_run_snapshot(path: str) -> dict:
754
766
  totals = ctx.get("totals", {})
755
767
  if isinstance(totals, dict):
756
768
  tc = totals.get("total_cost_usd")
757
- if isinstance(tc, (int, float)):
769
+ if isinstance(tc, (int, float)) and not isinstance(tc, bool) and measured({
770
+ "cost_usd": tc,
771
+ "input_tokens": totals.get("total_input"),
772
+ "output_tokens": totals.get("total_output")}):
758
773
  cost = float(tc)
774
+ found_cost = True
759
775
  except (json.JSONDecodeError, OSError, ValueError):
760
776
  pass
761
- snap["cost_usd"] = round(cost, 6)
777
+ snap["cost_usd"] = round(cost, 6) if found_cost else None
762
778
  return snap
763
779
 
764
780
 
@@ -836,7 +852,7 @@ def get_fleet_runs(include_inactive: bool = True) -> list[dict]:
836
852
  "running": running,
837
853
  "phase": snap.get("phase", ""),
838
854
  "iteration": snap.get("iteration", 0),
839
- "cost_usd": snap.get("cost_usd", 0.0),
855
+ "cost_usd": snap.get("cost_usd"),
840
856
  "started_at": started_at,
841
857
  "duration_seconds": duration_seconds,
842
858
  "port": p.get("port"),
@@ -862,16 +878,22 @@ def get_fleet_summary(include_inactive: bool = True) -> dict:
862
878
  Returns counts (total / running / stopped) and a summed cost across all
863
879
  registered projects. v1 polls the shared metadata store (see
864
880
  get_fleet_runs); not a controller. Never raises.
881
+
882
+ total_cost_usd sums only runs that recorded a cost and is None when none
883
+ did; total_cost_partial is True when some runs recorded no cost, so the
884
+ total is a lower bound, not the whole bill.
865
885
  """
866
886
  runs = get_fleet_runs(include_inactive=include_inactive)
867
887
  total = len(runs)
868
888
  running = sum(1 for r in runs if r.get("running"))
869
- total_cost = round(sum(float(r.get("cost_usd") or 0.0) for r in runs), 6)
889
+ costs = [float(r["cost_usd"]) for r in runs
890
+ if isinstance(r.get("cost_usd"), (int, float)) and not isinstance(r.get("cost_usd"), bool)]
870
891
  return {
871
892
  "total_runs": total,
872
893
  "running_runs": running,
873
894
  "stopped_runs": total - running,
874
- "total_cost_usd": total_cost,
895
+ "total_cost_usd": round(sum(costs), 6) if costs else None,
896
+ "total_cost_partial": 0 < len(costs) < total,
875
897
  }
876
898
 
877
899
 
@@ -245,32 +245,36 @@ class RigourIntegration:
245
245
  for sev in findings:
246
246
  findings[sev] = int(_extract_num(raw_findings, sev, 0))
247
247
 
248
- # Extract category scores
248
+ # Extract category scores. A category Rigour did not report is None
249
+ # (the panel shows "--"), never a scored 0.
249
250
  raw_categories = raw.get("categories", raw.get("scores", {}))
251
+ if not isinstance(raw_categories, dict):
252
+ raw_categories = {}
250
253
  categories = {
251
- "security": _extract_num(raw_categories, "security", 0),
252
- "code_quality": _extract_num(raw_categories, "code_quality", _extract_num(raw_categories, "codeQuality", 0)),
253
- "compliance": _extract_num(raw_categories, "compliance", 0),
254
- "best_practices": _extract_num(raw_categories, "best_practices", _extract_num(raw_categories, "bestPractices", 0)),
254
+ "security": _extract_num(raw_categories, "security", None),
255
+ "code_quality": _extract_num(raw_categories, "code_quality", _extract_num(raw_categories, "codeQuality", None)),
256
+ "compliance": _extract_num(raw_categories, "compliance", None),
257
+ "best_practices": _extract_num(raw_categories, "best_practices", _extract_num(raw_categories, "bestPractices", None)),
255
258
  }
256
259
 
257
- # Compute overall score
260
+ # Compute overall score: the reported one, else the average of the
261
+ # categories that were reported, else None (no score, no grade).
258
262
  raw_score = raw.get("score", raw.get("overall_score"))
263
+ score = None
259
264
  if raw_score is not None:
260
265
  try:
261
266
  score = float(raw_score)
262
267
  except (ValueError, TypeError):
263
- score = 0.0
268
+ score = None
264
269
  else:
265
- # Average of non-zero categories
266
- non_zero = [v for v in categories.values() if v > 0]
267
- score = round(sum(non_zero) / len(non_zero), 1) if non_zero else 0.0
270
+ reported = [v for v in categories.values() if v is not None]
271
+ score = round(sum(reported) / len(reported), 1) if reported else None
268
272
 
269
273
  result: dict[str, Any] = {
270
274
  "available": True,
271
- "score": round(score, 1),
275
+ "score": round(score, 1) if score is not None else None,
272
276
  "max_score": 100,
273
- "grade": _score_to_grade(score),
277
+ "grade": _score_to_grade(score) if score is not None else None,
274
278
  "timestamp": datetime.now(timezone.utc).isoformat(),
275
279
  "preset": preset,
276
280
  "findings": findings,
@@ -301,8 +305,9 @@ class RigourIntegration:
301
305
  "grade": None,
302
306
  "timestamp": datetime.now(timezone.utc).isoformat(),
303
307
  "preset": preset,
304
- "findings": {"critical": 0, "major": 0, "minor": 0, "info": 0},
305
- "categories": {"security": 0, "code_quality": 0, "compliance": 0, "best_practices": 0},
308
+ # Nothing was scanned: no counts and no category scores.
309
+ "findings": {"critical": None, "major": None, "minor": None, "info": None},
310
+ "categories": {"security": None, "code_quality": None, "compliance": None, "best_practices": None},
306
311
  "reason": reason,
307
312
  }
308
313
 
@@ -325,9 +330,12 @@ class RigourIntegration:
325
330
  }
326
331
 
327
332
 
328
- def _extract_num(d: dict, key: str, default: float = 0) -> float:
329
- """Safely extract a numeric value from a dict."""
333
+ def _extract_num(d: dict, key: str, default: Optional[float] = 0) -> Optional[float]:
334
+ """Safely extract a numeric value from a dict; `default` when absent or not numeric."""
335
+ v = d.get(key)
336
+ if v is None or isinstance(v, bool):
337
+ return default
330
338
  try:
331
- return float(d.get(key, default))
339
+ return float(v)
332
340
  except (ValueError, TypeError):
333
341
  return default