loki-mode 9.66.0 → 9.68.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 (76) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/done-recognition.sh +12 -0
  4. package/autonomy/loki +101 -9
  5. package/autonomy/run.sh +38 -7
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/migration_engine.py +38 -13
  8. package/dashboard/prompt_optimizer.py +19 -6
  9. package/dashboard/server.py +1 -1
  10. package/dashboard/static/index.html +66 -66
  11. package/docs/INSTALLATION.md +1 -1
  12. package/docs/v10/ARCHITECT-CUTS.md +89 -0
  13. package/docs/v10/BOARD.md +53 -31
  14. package/docs/v10/METRICS.md +57 -0
  15. package/docs/v10/PROGRESS.md +14 -0
  16. package/loki-ts/dist/loki.js +2 -2
  17. package/mcp/__init__.py +1 -1
  18. package/package.json +1 -1
  19. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  20. package/web-app/dist/assets/{AdminPage-ChUvLzkF.js → AdminPage-DVx_wD7J.js} +1 -1
  21. package/web-app/dist/assets/{Avatar-Dpy96tAc.js → Avatar-B96e5b-p.js} +1 -1
  22. package/web-app/dist/assets/{Badge-BOHHYZTu.js → Badge-CYIMEysJ.js} +1 -1
  23. package/web-app/dist/assets/{Button-B_XqEF7a.js → Button-CFkLj4fj.js} +1 -1
  24. package/web-app/dist/assets/{CockpitPage-C0z5QwNJ.js → CockpitPage-CD3N0t4G.js} +1 -1
  25. package/web-app/dist/assets/{ComparePage-KWL6acQk.js → ComparePage-Dfb-kBKl.js} +1 -1
  26. package/web-app/dist/assets/{ErrorBoundary-BbyBJ4ZP.js → ErrorBoundary-DxBJ42I1.js} +1 -1
  27. package/web-app/dist/assets/{EvidenceReceiptPanel-Btblkwfr.js → EvidenceReceiptPanel-30CPS1Mt.js} +1 -1
  28. package/web-app/dist/assets/{GitHubIssuesPanel-7Bi1t04Q.js → GitHubIssuesPanel-CW-er50w.js} +1 -1
  29. package/web-app/dist/assets/{GitHubPRsPanel-BxZtNprq.js → GitHubPRsPanel-DYHxxH3B.js} +1 -1
  30. package/web-app/dist/assets/{HomePage-BxDh3-CD.js → HomePage-KSJPrzJw.js} +2 -2
  31. package/web-app/dist/assets/{LoginPage-DpXq4rQe.js → LoginPage-Bz9xYiFY.js} +1 -1
  32. package/web-app/dist/assets/{MagicPage-DP2k0ioE.js → MagicPage-kn-ESajN.js} +1 -1
  33. package/web-app/dist/assets/{MetricsPage-Dus04BUR.js → MetricsPage-ne6rjAjb.js} +1 -1
  34. package/web-app/dist/assets/{NotFoundPage--54yr9Cy.js → NotFoundPage-BY9GRHUS.js} +1 -1
  35. package/web-app/dist/assets/{ProjectPage-Cs6LOI7R.js → ProjectPage-C6wd7nwM.js} +3 -3
  36. package/web-app/dist/assets/{ProjectsPage-5cpa0X1d.js → ProjectsPage-DCZFtBYI.js} +1 -1
  37. package/web-app/dist/assets/{SettingsPage-Duc5ApRj.js → SettingsPage-BxBtwIEH.js} +1 -1
  38. package/web-app/dist/assets/{ShowcasePage-C1wrPZgl.js → ShowcasePage-rj7G7hEn.js} +1 -1
  39. package/web-app/dist/assets/{SystemSettingsPage-CoD3LARj.js → SystemSettingsPage-C6ulpcUC.js} +1 -1
  40. package/web-app/dist/assets/{TeamsPage-GuQ4YDV5.js → TeamsPage-CtnFtvT9.js} +1 -1
  41. package/web-app/dist/assets/{TemplatesPage-CvWecmzl.js → TemplatesPage-Btrc0vGx.js} +1 -1
  42. package/web-app/dist/assets/{TerminalOutput-DFZw34kD.js → TerminalOutput-DNOHB6r6.js} +1 -1
  43. package/web-app/dist/assets/{activity-BvHkXBYj.js → activity-nzowoOR9.js} +1 -1
  44. package/web-app/dist/assets/{bell-Bw2j-HI7.js → bell-D_z8w7hF.js} +1 -1
  45. package/web-app/dist/assets/{bot-BRuEmshQ.js → bot-BIUaGOa_.js} +1 -1
  46. package/web-app/dist/assets/{check-B5uToWdF.js → check-BDJjUhRA.js} +1 -1
  47. package/web-app/dist/assets/{chevron-left-D-tXeEPI.js → chevron-left-D012PxLS.js} +1 -1
  48. package/web-app/dist/assets/{circle-alert-p3-Bg3sx.js → circle-alert-CSwiL_Ci.js} +1 -1
  49. package/web-app/dist/assets/{clock-CJc_91vp.js → clock-D998ijVs.js} +1 -1
  50. package/web-app/dist/assets/{cloud-lhmojUN8.js → cloud-DRi3zhWw.js} +1 -1
  51. package/web-app/dist/assets/{code-xml-DMvQ2jw1.js → code-xml-5-tMr26k.js} +1 -1
  52. package/web-app/dist/assets/{database-EdwkplCN.js → database-BJyZFH_L.js} +1 -1
  53. package/web-app/dist/assets/{dollar-sign-BQiPo9Co.js → dollar-sign-DV3VUIZD.js} +1 -1
  54. package/web-app/dist/assets/{file-code-corner-CXUzs2z1.js → file-code-corner-Dt1RB6AK.js} +1 -1
  55. package/web-app/dist/assets/{file-plus-hS0xkTL5.js → file-plus-BK6e_Aa4.js} +1 -1
  56. package/web-app/dist/assets/{globe-B6Xk69Fj.js → globe-BLD9gQs7.js} +1 -1
  57. package/web-app/dist/assets/{hammer-B_evoVEr.js → hammer-BpgnUNfT.js} +1 -1
  58. package/web-app/dist/assets/{index-XZrgDphJ.js → index-B9_MlKR6.js} +2 -2
  59. package/web-app/dist/assets/{layers-Bb295b4u.js → layers-KtTIakvg.js} +1 -1
  60. package/web-app/dist/assets/{loader-circle-C8cKZhjP.js → loader-circle-B1xKf0Rv.js} +1 -1
  61. package/web-app/dist/assets/{lock-DcC9B8sE.js → lock-CjDPMVtQ.js} +1 -1
  62. package/web-app/dist/assets/{package-BFz4FYcR.js → package-BJeOvQMj.js} +1 -1
  63. package/web-app/dist/assets/{plus-aS4YpFPG.js → plus-Hz9gp1EB.js} +1 -1
  64. package/web-app/dist/assets/{refresh-cw-B9_ksndc.js → refresh-cw-DQ_mu77L.js} +1 -1
  65. package/web-app/dist/assets/{rotate-ccw-CBogZEoT.js → rotate-ccw-BfLD_AC3.js} +1 -1
  66. package/web-app/dist/assets/{scroll-text-DMGgO6uq.js → scroll-text-fPCd2SRe.js} +1 -1
  67. package/web-app/dist/assets/{server-CsEqUM8R.js → server-B5Vp_LNG.js} +1 -1
  68. package/web-app/dist/assets/{shield-alert-Btnj6tmQ.js → shield-alert-CyIXZM2p.js} +1 -1
  69. package/web-app/dist/assets/{thumbs-up-C_NLSPqa.js → thumbs-up-CfvcaIBe.js} +1 -1
  70. package/web-app/dist/assets/{trash-2-zqZc9cSK.js → trash-2-DpV8R-rX.js} +1 -1
  71. package/web-app/dist/assets/{trending-down-BGQh2rVE.js → trending-down-BUPL60c2.js} +1 -1
  72. package/web-app/dist/assets/{trending-up-C31TrzUt.js → trending-up-C10V0yZr.js} +1 -1
  73. package/web-app/dist/assets/{upload-CLwWKjCG.js → upload-D0psWSbr.js} +1 -1
  74. package/web-app/dist/assets/{usePolling-8wwOP53Y.js → usePolling-zDjbIVGY.js} +1 -1
  75. package/web-app/dist/assets/{user-DUkbxibF.js → user-R0DRj17j.js} +1 -1
  76. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.66.0
6
+ # Loki Mode v9.68.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.66.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.68.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.66.0
1
+ 9.68.0
@@ -461,8 +461,20 @@ def tests_axis(path):
461
461
  if ec != 0:
462
462
  return "red"
463
463
  # exit 0 alone is green only if nothing else contradicts it (checked below).
464
+ def _nonzero_int(v):
465
+ return isinstance(v, int) and not isinstance(v, bool) and v != 0
466
+ # Zero-test record: nothing actually ran, regardless of self-reported
467
+ # pass/fail. Checked before the pass:true/false branches below so a
468
+ # plausible {"pass": true, "total": 0} shape cannot slip through the
469
+ # pass:true branch as a false green.
470
+ if isinstance(d.get("total"), int) and d.get("total") == 0:
471
+ return "unknown"
464
472
  p = d.get("pass")
465
473
  if p is True:
474
+ # A self-reported pass contradicted by a nonzero failure count (under
475
+ # either key production/legacy shapes use) is not authoritative green.
476
+ if _nonzero_int(d.get("failed")) or _nonzero_int(d.get("failed_count")):
477
+ return "red"
466
478
  # A clean pass:true with no contradicting red signal is authoritative green.
467
479
  if d.get("failed") in (None, 0) and (ec in (None, 0)):
468
480
  return "green"
package/autonomy/loki CHANGED
@@ -856,8 +856,48 @@ PY
856
856
  # absent measurement is not evidence, and inventing a PASS here would be worse
857
857
  # than inventing a FAIL.
858
858
  loki_remote_attestation_status() {
859
- local pj="$1" url="$2"
859
+ local pj="$1" url="$2" _jh
860
860
  [ -n "$url" ] || { printf ''; return 0; }
861
+ # A JWKS key set travels over the same channel as the receipt it certifies.
862
+ # Plain http lets anyone on the network path swap in their own key set, and
863
+ # the attestation would then "verify" against a forged key -- a MITM the
864
+ # TLS the API already uses is meant to prevent. Only https is trusted;
865
+ # loopback http and file:// are the exceptions, because neither one leaves
866
+ # this machine for anyone to sit on. Checked here, before python is even
867
+ # invoked, so a rejected URL is never fetched -- not "fetched and then
868
+ # distrusted". Same check in loki_proof_attestation_check below -- change
869
+ # both.
870
+ #
871
+ # The host is bounded exactly (up to the first "/", then stripped of any
872
+ # port) rather than matched with a trailing glob, and a userinfo "@" is
873
+ # rejected outright -- "http://127.0.0.1:80@evil.com" is host evil.com to
874
+ # any real URL parser (userinfo before the @, host after), and a loose
875
+ # `http://127.0.0.1:*` glob would wrongly wave that straight through.
876
+ case "$url" in
877
+ https://*|file://*) ;;
878
+ http://*)
879
+ _jh="${url#http://}"; _jh="${_jh%%/*}"
880
+ case "$_jh" in
881
+ *@*) printf ''; return 0 ;;
882
+ esac
883
+ # A bracketed IPv6 literal must be the WHOLE host: "[::1]" or
884
+ # "[::1]:port", never a prefix, or "[::1].evil.com" (not itself
885
+ # resolvable as loopback, but not rejected either) would parse as
886
+ # loopback "::1" with everything after the first "]" silently
887
+ # dropped instead of validated.
888
+ case "$_jh" in
889
+ \[*\]) _jh="${_jh#\[}"; _jh="${_jh%\]}" ;;
890
+ \[*\]:*) _jh="${_jh#\[}"; _jh="${_jh%%\]:*}" ;;
891
+ \[*) printf ''; return 0 ;;
892
+ *) _jh="${_jh%%:*}" ;;
893
+ esac
894
+ case "$_jh" in
895
+ 127.0.0.1|localhost|::1) ;;
896
+ *) printf ''; return 0 ;;
897
+ esac
898
+ ;;
899
+ *) printf ''; return 0 ;;
900
+ esac
861
901
  command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
862
902
  local tok
863
903
  # jq's `//` treats JSON false the same as null/missing, which is exactly
@@ -954,8 +994,53 @@ PY
954
994
  # testing found exactly this: a mutation applied to the first copy left every
955
995
  # assertion of the second suite green.
956
996
  loki_proof_attestation_check() {
957
- local pj="$1" src="$2"
997
+ local pj="$1" src="$2" _jh
958
998
  [ -n "$src" ] || { printf ''; return 0; }
999
+ # Same MITM guard as loki_remote_attestation_status above -- change both.
1000
+ # Only applies to a URL source; a local file (the third-party offline case)
1001
+ # never travels a network and needs no scheme at all. The host is bounded
1002
+ # exactly and a userinfo "@" is rejected outright -- see the sibling
1003
+ # function's comment for why a trailing glob is not safe here.
1004
+ #
1005
+ # A recognized scheme prefix is checked FIRST, before any "-f" test: the
1006
+ # process cwd is the checkout being verified (attacker-controlled), and a
1007
+ # repo can ship a file whose literal name IS a URL string (for example
1008
+ # "https:/evil.example.com/.well-known/jwks.json" as a real path on disk).
1009
+ # Testing "-f" first would treat that shadow file as the local-file form
1010
+ # and hand its content straight to the caller with no network involved at
1011
+ # all -- silently defeating the "only https is trusted" guarantee this
1012
+ # function exists to provide. Scheme detection must win over the
1013
+ # filesystem every time, not just when no file happens to collide.
1014
+ case "$src" in
1015
+ https://*|http://*|file://*)
1016
+ case "$src" in
1017
+ https://*|file://*) ;;
1018
+ http://*)
1019
+ _jh="${src#http://}"; _jh="${_jh%%/*}"
1020
+ case "$_jh" in
1021
+ *@*) printf ''; return 0 ;;
1022
+ esac
1023
+ # See loki_remote_attestation_status above for why the
1024
+ # bracketed-IPv6 match must bound the closing bracket.
1025
+ case "$_jh" in
1026
+ \[*\]) _jh="${_jh#\[}"; _jh="${_jh%\]}" ;;
1027
+ \[*\]:*) _jh="${_jh#\[}"; _jh="${_jh%%\]:*}" ;;
1028
+ \[*) printf ''; return 0 ;;
1029
+ *) _jh="${_jh%%:*}" ;;
1030
+ esac
1031
+ case "$_jh" in
1032
+ 127.0.0.1|localhost|::1) ;;
1033
+ *) printf ''; return 0 ;;
1034
+ esac
1035
+ ;;
1036
+ esac
1037
+ ;;
1038
+ *)
1039
+ # A plain path is decided in python below, which reports an
1040
+ # unsigned receipt as "none" BEFORE it looks at the key file, so a
1041
+ # missing key file on an unsigned receipt still fails (exit 1).
1042
+ ;;
1043
+ esac
959
1044
  command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
960
1045
  python3 -E - "$pj" "$src" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
961
1046
  # The cwd is the checkout being verified: drop it from sys.path before any
@@ -984,15 +1069,20 @@ if tok is None or tok == "" or tok is False:
984
1069
  print("none"); sys.exit(0) # a FACT about the receipt, not an error
985
1070
  src = sys.argv[2]
986
1071
  try:
987
- if os.path.isfile(src):
988
- jwks = json.load(open(src))
989
- else:
990
- if not src.startswith(("http://", "https://")):
991
- sys.exit(1)
1072
+ # Scheme check first, same as the bash gate above -- a "-f" test run
1073
+ # first would let a local file whose literal name IS an http(s) URL
1074
+ # string (the process cwd is the checkout being verified) silently stand
1075
+ # in for a real fetch. A recognized scheme always means "URL", even if a
1076
+ # same-named file happens to exist on disk.
1077
+ if src.startswith(("http://", "https://")):
992
1078
  url = src if src.rstrip("/").endswith("jwks.json") else \
993
1079
  src.rstrip("/") + "/.well-known/jwks.json"
994
1080
  with urllib.request.urlopen(url, timeout=10) as r:
995
1081
  jwks = json.loads(r.read())
1082
+ elif os.path.isfile(src):
1083
+ jwks = json.load(open(src))
1084
+ else:
1085
+ sys.exit(1)
996
1086
  except Exception:
997
1087
  sys.exit(1) # "" : unreachable/unreadable, no verdict
998
1088
  # A key set that is not {"keys": [{...}, ...]} -- INCLUDING an empty list -- is
@@ -1177,7 +1267,8 @@ loki_remote_verify_receipt() {
1177
1267
  # gpg-unavailable branch above: NOT CHECKED, return 0, provenance unproven.
1178
1268
  if [ -z "$_attest" ] && jq -e '.verification.attestation // empty' "$pj" >/dev/null 2>&1; then
1179
1269
  echo -e " ${YELLOW}NOT CHECKED${NC}: the receipt carries an attestation, but it could not be checked"
1180
- echo " against ${_attest_url:-<no url>} (JWKS unreachable, or python cryptography missing)."
1270
+ echo " against ${_attest_url:-<no url>} (JWKS unreachable, python cryptography missing, or"
1271
+ echo " the JWKS URL is not https -- only loopback is allowed over plain http)."
1181
1272
  echo " Its integrity hash matches. Provenance is unproven here, not disproven."
1182
1273
  echo " Install python3 cryptography, or re-run where the build server is reachable."
1183
1274
  return 0
@@ -37014,7 +37105,8 @@ print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))
37014
37105
  # read as either a pass or an accusation, so it exits 2
37015
37106
  # (could not check). A tamper/drift 1 from the base
37016
37107
  # verifier is a finding and is never softened to 2.
37017
- echo -e "${YELLOW}attestation: NOT CHECKED -- could not check against ${_pv_jwks} (key set unreadable, or verifier dependency missing).${NC}" >&2
37108
+ echo -e "${YELLOW}attestation: NOT CHECKED -- could not check against ${_pv_jwks} (key set unreadable,${NC}" >&2
37109
+ echo -e "${YELLOW} verifier dependency missing, or the JWKS URL is not https -- only loopback is allowed over plain http).${NC}" >&2
37018
37110
  echo " This is not a failure of the receipt. Nothing was proven either way (exit 2)." >&2
37019
37111
  if [ "$_pv_rc" -eq 0 ]; then _pv_rc=2; fi
37020
37112
  _pv_attestation_field="NOT CHECKED"
package/autonomy/run.sh CHANGED
@@ -5911,6 +5911,27 @@ _loki_trusted_push() {
5911
5911
  log_warn "Not pushing branch '$_branch': origin changed during the run (it was $(_loki_github_repo_from_url "$_LOKI_PINNED_ORIGIN" 2>/dev/null || echo "not a GitHub repository") when the run started). Loki does not push to a destination the agent could have chosen."
5912
5912
  return 2
5913
5913
  fi
5914
+ # S-100: never push a default branch, for every caller (on_run_complete,
5915
+ # create_session_pr): the branch name can come from agent-writable state.
5916
+ # The default branch is resolved for the origin just validated (the pinned
5917
+ # one when pinned), gh run from / with an explicit OWNER/REPO. An
5918
+ # unresolvable default branch refuses: fail closed.
5919
+ local _repo _def=""
5920
+ case "$_branch" in
5921
+ main|master|HEAD)
5922
+ log_warn "Not pushing branch '$_branch': Loki never pushes directly to a default branch."
5923
+ return 2 ;;
5924
+ esac
5925
+ _repo="$(_loki_github_repo_from_url "$_url")"
5926
+ _def="$(_loki_run_neutral "$_repo" "$_runner" gh repo view "$_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null)" || _def=""
5927
+ if [ -z "$_def" ]; then
5928
+ log_warn "Not pushing branch '$_branch': could not resolve the default branch of $_repo, and Loki never risks a direct push to it."
5929
+ return 2
5930
+ fi
5931
+ if [ "$_branch" = "$_def" ]; then
5932
+ log_warn "Not pushing branch '$_branch': it is the default branch of $_repo, and Loki never pushes directly to the default branch."
5933
+ return 2
5934
+ fi
5914
5935
  _tmp="$(mktemp -d "${TMPDIR:-/tmp}/loki-push.XXXXXX")" || return 1
5915
5936
  # --update-shallow: a shallow agent repo's history ends in shallow roots,
5916
5937
  # which a plain fetch rejects while still exiting 0. The rev-parse makes a
@@ -6070,6 +6091,8 @@ on_run_complete() {
6070
6091
  case "$branch" in
6071
6092
  ""|main|master|HEAD) return 0 ;;
6072
6093
  esac
6094
+ # S-100: the repository's ACTUAL default branch (develop, trunk, ...) is
6095
+ # refused inside _loki_trusted_push, for every caller.
6073
6096
  log_info "LOKI_DELEGATE_PR=1: opening a local pull request for branch '$branch'..."
6074
6097
  # Push, then create. Non-interactive (no tty in --bg). Best-effort, each
6075
6098
  # network call bounded by the timeout guard above.
@@ -21516,9 +21539,14 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
21516
21539
  mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
21517
21540
  # Starts at iteration 0: drop the previous session's test
21518
21541
  # evidence, as the iteration-0 block at the end does.
21542
+ # S-124 follow-up: static-analysis.pass has no freshness
21543
+ # marker of its own (same reasoning as the block below), so
21544
+ # this corrupted-state restart must drop it too, or a stale
21545
+ # copy survives this sibling iteration-0 trigger.
21519
21546
  rm -f "${TARGET_DIR:-.}/.loki/quality/.test-results.iter" \
21520
21547
  "${TARGET_DIR:-.}/.loki/quality/unit-tests.pass" \
21521
- "${TARGET_DIR:-.}/.loki/quality/test-results.json" 2>/dev/null || true
21548
+ "${TARGET_DIR:-.}/.loki/quality/test-results.json" \
21549
+ "${TARGET_DIR:-.}/.loki/quality/static-analysis.pass" 2>/dev/null || true
21522
21550
  return
21523
21551
  fi
21524
21552
 
@@ -21589,16 +21617,19 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
21589
21617
  RETRY_COUNT=0
21590
21618
  fi
21591
21619
  # A session that starts at iteration 0 has run nothing yet, so a leftover
21592
- # freshness marker or unit-tests.pass is a previous session's evidence.
21593
- # Iterations restart at 0, so keeping them would let an old pass:true read
21594
- # as this iteration's result.
21620
+ # freshness marker, unit-tests.pass, or static-analysis.pass is a previous
21621
+ # session's evidence. Iterations restart at 0, so keeping them would let
21622
+ # an old pass:true read as this iteration's result.
21595
21623
  if [ "${ITERATION_COUNT:-0}" = "0" ]; then
21596
- # Same path the writer (enforce_test_coverage) and the freshness
21597
- # readers use.
21624
+ # Same path the writer (enforce_test_coverage, enforce_static_analysis)
21625
+ # and the freshness readers use.
21598
21626
  local _q="${TARGET_DIR:-.}/.loki/quality"
21599
21627
  # test-results.json too: with the marker gone the receipt's quality
21600
21628
  # gates fall back to its status, reporting a previous session's run.
21601
- rm -f "$_q/.test-results.iter" "$_q/unit-tests.pass" "$_q/test-results.json" 2>/dev/null || true
21629
+ # static-analysis.pass has no freshness marker of its own, so a stale
21630
+ # copy from a previous session reads as a pass of this session's
21631
+ # exogenous static_analysis gate unless it is dropped here too.
21632
+ rm -f "$_q/.test-results.iter" "$_q/unit-tests.pass" "$_q/test-results.json" "$_q/static-analysis.pass" 2>/dev/null || true
21602
21633
  fi
21603
21634
  }
21604
21635
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.66.0"
10
+ __version__ = "9.68.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -766,19 +766,29 @@ class MigrationPipeline:
766
766
  current_phase = phase
767
767
  overall_status = "failed"
768
768
 
769
- # Feature stats
770
- features_total = 0
771
- features_passing = 0
769
+ # Feature stats. Missing/corrupt is NOT the same as a real empty
770
+ # plan (0 features): counts go to None with a reason so a consumer
771
+ # can tell "no features yet" apart from "could not read the file".
772
+ features_total: Optional[int] = 0
773
+ features_passing: Optional[int] = 0
774
+ features_error: Optional[str] = None
772
775
  try:
773
776
  features = self.load_features()
774
777
  features_total = len(features)
775
778
  features_passing = sum(1 for f in features if f.passes)
776
- except (FileNotFoundError, json.JSONDecodeError, TypeError):
777
- pass
778
-
779
- # Step stats
780
- steps_total = 0
781
- steps_completed = 0
779
+ except FileNotFoundError:
780
+ features_total = None
781
+ features_passing = None
782
+ features_error = "features.json not found"
783
+ except (json.JSONDecodeError, TypeError) as exc:
784
+ features_total = None
785
+ features_passing = None
786
+ features_error = f"features.json is corrupt: {exc}"
787
+
788
+ # Step stats. Same missing/corrupt-vs-empty distinction as features.
789
+ steps_total: Optional[int] = 0
790
+ steps_completed: Optional[int] = 0
791
+ steps_error: Optional[str] = None
782
792
  current_step = None
783
793
  current_step_index = 0
784
794
  try:
@@ -790,8 +800,14 @@ class MigrationPipeline:
790
800
  current_step = in_progress[0].id
791
801
  # Current step index: completed + 1 (1-based) or completed if all done
792
802
  current_step_index = min(steps_completed + 1, steps_total) if steps_total else 0
793
- except (FileNotFoundError, json.JSONDecodeError, TypeError):
794
- pass
803
+ except FileNotFoundError:
804
+ steps_total = None
805
+ steps_completed = None
806
+ steps_error = "migration-plan.json not found"
807
+ except (json.JSONDecodeError, TypeError) as exc:
808
+ steps_total = None
809
+ steps_completed = None
810
+ steps_error = f"migration-plan.json is corrupt: {exc}"
795
811
 
796
812
  # Last checkpoint metadata
797
813
  last_checkpoint_data: Optional[dict[str, Any]] = None
@@ -849,8 +865,17 @@ class MigrationPipeline:
849
865
  "source": source_path,
850
866
  "target": target_name,
851
867
  "current_step": current_step,
852
- "features": {"passing": features_passing, "total": features_total},
853
- "steps": {"current": current_step_index, "completed": steps_completed, "total": steps_total},
868
+ "features": {
869
+ "passing": features_passing,
870
+ "total": features_total,
871
+ **({"reason": features_error} if features_error else {}),
872
+ },
873
+ "steps": {
874
+ "current": current_step_index,
875
+ "completed": steps_completed,
876
+ "total": steps_total,
877
+ **({"reason": steps_error} if steps_error else {}),
878
+ },
854
879
  "last_checkpoint": last_checkpoint_data,
855
880
  "checkpoints_count": len(manifest.checkpoints),
856
881
  "seams": seams_data,
@@ -66,7 +66,11 @@ class PromptOptimizer:
66
66
  """Get the current (latest) prompt optimization version.
67
67
 
68
68
  Returns:
69
- The latest version data, or a default structure if none exists.
69
+ The latest version data on a clean read. If no version file
70
+ exists yet, a "never_ran" marker with null fields (distinct
71
+ from a real version 0). If the file exists but cannot be
72
+ parsed, an "error" marker (also null fields) so a corrupt file
73
+ is never mistaken for "never ran".
70
74
  """
71
75
  latest = self._latest_file()
72
76
  if latest.is_file():
@@ -74,13 +78,20 @@ class PromptOptimizer:
74
78
  return json.loads(latest.read_text(encoding="utf-8"))
75
79
  except (json.JSONDecodeError, OSError) as exc:
76
80
  logger.warning("Failed to read latest prompt version: %s", exc)
81
+ return self._sentinel("error")
77
82
 
83
+ return self._sentinel("never_ran")
84
+
85
+ @staticmethod
86
+ def _sentinel(status: str) -> dict[str, Any]:
87
+ """Build a never-ran/error marker. Never a fake version 0 or a fake count."""
78
88
  return {
79
- "version": 0,
89
+ "version": None,
80
90
  "generated_at": None,
81
- "based_on_sessions": 0,
82
- "failures_analyzed": 0,
91
+ "based_on_sessions": None,
92
+ "failures_analyzed": None,
83
93
  "changes": [],
94
+ "status": status,
84
95
  }
85
96
 
86
97
  def get_prompt_for_agent(self, agent_type: str) -> dict[str, Any] | None:
@@ -95,7 +106,7 @@ class PromptOptimizer:
95
106
  Dict with agent-specific prompt changes, or None if no optimizations exist.
96
107
  """
97
108
  current = self.get_current_version()
98
- if current["version"] == 0:
109
+ if not current["version"]:
99
110
  return None
100
111
 
101
112
  agent_changes = [c for c in current.get("changes", []) if c.get("agent_type") == agent_type]
@@ -225,7 +236,9 @@ class PromptOptimizer:
225
236
  changes = self._generate_changes(failure_data)
226
237
 
227
238
  with self._file_lock:
228
- current_version = self.get_current_version()["version"]
239
+ # Never-ran and error sentinels both carry version None; either
240
+ # way there is no real prior version, so the next one is 1.
241
+ current_version = self.get_current_version()["version"] or 0
229
242
  new_version = current_version + 1
230
243
 
231
244
  result = {
@@ -10350,7 +10350,7 @@ async def get_checklist_waivers():
10350
10350
  try:
10351
10351
  return json.loads(waivers_file.read_text())
10352
10352
  except (json.JSONDecodeError, IOError):
10353
- return {"waivers": [], "error": "Failed to read waivers file"}
10353
+ return JSONResponse(status_code=500, content={"error": "Failed to read waivers file"})
10354
10354
 
10355
10355
 
10356
10356
  @app.post("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("control"))])