loki-mode 9.49.2 → 9.49.4

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 (83) hide show
  1. package/.github/workflows/loki-issue-to-pr.yml +1 -1
  2. package/README.md +2 -2
  3. package/SKILL.md +2 -2
  4. package/VERSION +1 -1
  5. package/autonomy/lib/claude-flags.sh +18 -2
  6. package/autonomy/lib/proof-verify.py +4 -2
  7. package/autonomy/notification-checker.py +45 -17
  8. package/autonomy/run.sh +43 -4
  9. package/dashboard/__init__.py +1 -1
  10. package/dashboard/server.py +21 -8
  11. package/docs/INSTALLATION.md +1 -1
  12. package/docs/P0-SWEEP-PLAN.md +1 -1
  13. package/docs/cost-controls.md +1 -1
  14. package/docs/cursor-comparison.md +1 -1
  15. package/docs/environment-variables.md +1 -1
  16. package/loki-ts/dist/loki.js +240 -240
  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/skills/quality-gates.md +1 -1
  21. package/web-app/dist/assets/{AdminPage-DpV2vW3y.js → AdminPage-y6_b2Xo4.js} +1 -1
  22. package/web-app/dist/assets/{Avatar-BXeMYK1_.js → Avatar-BFPkXhIJ.js} +1 -1
  23. package/web-app/dist/assets/{Badge-D8WddQk1.js → Badge-BF3mhT57.js} +1 -1
  24. package/web-app/dist/assets/{Button-CFzTTVeJ.js → Button-BDp9aJ9q.js} +1 -1
  25. package/web-app/dist/assets/{CockpitPage-D1eQe9QF.js → CockpitPage-5TKICCoA.js} +1 -1
  26. package/web-app/dist/assets/{ComparePage-uX3OPphr.js → ComparePage-BSuNsUHy.js} +1 -1
  27. package/web-app/dist/assets/{ErrorBoundary-wE3oxaG6.js → ErrorBoundary-ut3wrUmu.js} +1 -1
  28. package/web-app/dist/assets/{EvidenceReceiptPanel-B_Wouz6N.js → EvidenceReceiptPanel-CcaBwEI-.js} +1 -1
  29. package/web-app/dist/assets/{GitHubIssuesPanel-DRlEeFuw.js → GitHubIssuesPanel-DJeYgzAa.js} +1 -1
  30. package/web-app/dist/assets/{GitHubPRsPanel-CCv6_I74.js → GitHubPRsPanel-DurZgSaj.js} +1 -1
  31. package/web-app/dist/assets/{HomePage-EG-jq8nR.js → HomePage-Bs451LAy.js} +1 -1
  32. package/web-app/dist/assets/{LoginPage-CEOnBICH.js → LoginPage-HeRGJeZy.js} +1 -1
  33. package/web-app/dist/assets/{MagicPage-DeWaFdsG.js → MagicPage-CUKrqsZs.js} +1 -1
  34. package/web-app/dist/assets/{MetricsPage-CG8CuSSJ.js → MetricsPage-DvY1csgz.js} +1 -1
  35. package/web-app/dist/assets/{NotFoundPage-Mz5rOat6.js → NotFoundPage-BZDZ-B77.js} +1 -1
  36. package/web-app/dist/assets/{ProjectPage-D2Fh6tVw.js → ProjectPage-CqHkEZkl.js} +25 -25
  37. package/web-app/dist/assets/{ProjectsPage-B8cRWkWa.js → ProjectsPage-CLvCK1Y8.js} +1 -1
  38. package/web-app/dist/assets/{SettingsPage-DKpsst6x.js → SettingsPage-DP7OmdwP.js} +1 -1
  39. package/web-app/dist/assets/{ShowcasePage-qab065PM.js → ShowcasePage-BGS2jTGH.js} +1 -1
  40. package/web-app/dist/assets/{SystemSettingsPage-9qmEH2Y6.js → SystemSettingsPage-P_rlLh0x.js} +1 -1
  41. package/web-app/dist/assets/{TeamsPage-CW6lvW-0.js → TeamsPage-DxtBDcBC.js} +1 -1
  42. package/web-app/dist/assets/{TemplatesPage-B-a61T6a.js → TemplatesPage-5e-faH3W.js} +1 -1
  43. package/web-app/dist/assets/{TerminalOutput-CJy13rAn.js → TerminalOutput-togzv9Zl.js} +1 -1
  44. package/web-app/dist/assets/{activity-CA4moIvC.js → activity-Dreyb_Kr.js} +1 -1
  45. package/web-app/dist/assets/{bell-JyOeobLN.js → bell-DWH0drES.js} +1 -1
  46. package/web-app/dist/assets/{bot-Cu4UwP5w.js → bot-78MoCuDl.js} +1 -1
  47. package/web-app/dist/assets/{check-Drzd8-9c.js → check-ikfylPst.js} +1 -1
  48. package/web-app/dist/assets/{chevron-left-ClvdXm3Y.js → chevron-left-DXUqkd8J.js} +1 -1
  49. package/web-app/dist/assets/{circle-alert-CstyZfKG.js → circle-alert-Da74VO8P.js} +1 -1
  50. package/web-app/dist/assets/{clock-Bnz6tlQN.js → clock-mlGCFq8l.js} +1 -1
  51. package/web-app/dist/assets/{cloud-IPIgE4P3.js → cloud-B_Y5ZrIO.js} +1 -1
  52. package/web-app/dist/assets/{code-xml-BOZbQiOt.js → code-xml-Bbom7YWV.js} +1 -1
  53. package/web-app/dist/assets/{database-CHclPjog.js → database-CLcofiVp.js} +1 -1
  54. package/web-app/dist/assets/{dollar-sign-D6RFNgdZ.js → dollar-sign-C5mkjXIM.js} +1 -1
  55. package/web-app/dist/assets/{file-code-corner-CNayCvOY.js → file-code-corner-CVrkaOQP.js} +1 -1
  56. package/web-app/dist/assets/{file-plus-BmVfFwsX.js → file-plus-BV9240gX.js} +1 -1
  57. package/web-app/dist/assets/{folder-open-ChzGfLRr.js → folder-open-D_6yV78G.js} +1 -1
  58. package/web-app/dist/assets/{globe-DtXgCG3Z.js → globe-aZ0Zso4I.js} +1 -1
  59. package/web-app/dist/assets/{hammer-DCa0I_7V.js → hammer-EgOQNukG.js} +1 -1
  60. package/web-app/dist/assets/index-BSgmPwC0.css +1 -0
  61. package/web-app/dist/assets/{index-Cu-njJ8o.js → index-DvZ0Kk2x.js} +2 -2
  62. package/web-app/dist/assets/{layers-DxIhn7NZ.js → layers-BOVKmepE.js} +1 -1
  63. package/web-app/dist/assets/{lightbulb-D7Pzhd06.js → lightbulb-DdASgqOG.js} +1 -1
  64. package/web-app/dist/assets/{loader-circle-DXgc_u5I.js → loader-circle-0K8yLgHl.js} +1 -1
  65. package/web-app/dist/assets/{lock-qRAVU53b.js → lock-XzlDMy8M.js} +1 -1
  66. package/web-app/dist/assets/{mail-CHe3KyCw.js → mail-COu670_W.js} +1 -1
  67. package/web-app/dist/assets/{minus-B8p0FoYZ.js → minus-bQpUt9ff.js} +1 -1
  68. package/web-app/dist/assets/{package-C5LDn8SI.js → package-BtXTKusI.js} +1 -1
  69. package/web-app/dist/assets/{plus-CRd_FHOA.js → plus-PxeiD8ju.js} +1 -1
  70. package/web-app/dist/assets/{refresh-cw-DKoSN8s3.js → refresh-cw-CjqzYczt.js} +1 -1
  71. package/web-app/dist/assets/{rotate-ccw-Nf8zB8G0.js → rotate-ccw-CY0os4DH.js} +1 -1
  72. package/web-app/dist/assets/{save-B8ytQLgW.js → save-v8qep5cY.js} +1 -1
  73. package/web-app/dist/assets/{server-D_VRDkaD.js → server-pIIs9vNp.js} +1 -1
  74. package/web-app/dist/assets/{shield-alert-PsDkSyFD.js → shield-alert-CsDqKXwA.js} +1 -1
  75. package/web-app/dist/assets/{thumbs-up-Da1QQhqo.js → thumbs-up-CAh3HVzH.js} +1 -1
  76. package/web-app/dist/assets/{trash-2-ChoylpGQ.js → trash-2-Dt6urTyY.js} +1 -1
  77. package/web-app/dist/assets/{trending-down-CX2fUeho.js → trending-down-DrtBd_C9.js} +1 -1
  78. package/web-app/dist/assets/{trending-up-CGyYgZH7.js → trending-up-D-DbCHXm.js} +1 -1
  79. package/web-app/dist/assets/{upload-KkeVyg67.js → upload-D4l6DCOt.js} +1 -1
  80. package/web-app/dist/assets/{usePolling-C5f4xrXD.js → usePolling-D0aoTX80.js} +1 -1
  81. package/web-app/dist/assets/{user-WCGccIST.js → user-BQf_XJO_.js} +1 -1
  82. package/web-app/dist/index.html +2 -2
  83. package/web-app/dist/assets/index-D98GzaW8.css +0 -1
@@ -61,7 +61,7 @@ jobs:
61
61
  # Pinned to a TAG, not @main. A moving ref would hand every user
62
62
  # whatever main holds at the moment their issue is labeled, including a
63
63
  # half-landed release. Bump this line deliberately.
64
- uses: asklokesh/loki-mode/.github/actions/issue-to-pr@v9.49.2
64
+ uses: asklokesh/loki-mode/.github/actions/issue-to-pr@v9.49.4
65
65
  env:
66
66
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
67
67
  with:
package/README.md CHANGED
@@ -532,14 +532,14 @@ Required:
532
532
 
533
533
  - An agent provider CLI: [Claude Code](https://docs.claude.com/en/docs/claude-code) (`claude`, Tier 1, recommended and E2E-verified - the provider Loki Mode is built for). Cline, Codex, Aider, and opencode are supported as experimental providers (wiring in place; not yet E2E-verified by us). Loki cannot run a build without one of these installed and authenticated.
534
534
  - Python 3.10+ (`python3`) for the dashboard, memory system, and orchestration helpers.
535
+ - Node.js 18+ (`node`) for the npm install path and the bundled runtime.
536
+ - `jq` for the JSON that the shell flows and the quality gates parse.
535
537
  - Git 2.x (`git`) for checkpoints and worktrees.
536
538
  - `curl` for installation and network calls.
537
539
 
538
540
  Recommended:
539
541
 
540
542
  - Bun 1.3.0+ (`bun`) for the fast runtime (the recommended install path above installs it).
541
- - Node.js 18+ and npm if you install via npm instead of Bun.
542
- - `jq` for nicer JSON handling in shell flows.
543
543
  - Docker if you want Loki's App Runner to run containerized projects, or to run Loki itself from the published image.
544
544
 
545
545
  You also need credentials for whichever provider you use (for Claude Code, an authenticated `claude` login or `ANTHROPIC_API_KEY`). `loki doctor` flags a missing or unauthenticated provider as the first thing to fix.
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.49.2
6
+ # Loki Mode v9.49.4
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.49.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.49.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.49.2
1
+ 9.49.4
@@ -51,7 +51,21 @@ loki_effort_for_tier() {
51
51
 
52
52
  # ---------- Remaining budget ----------
53
53
  # Compute remaining budget = LOKI_BUDGET_LIMIT - cumulative spend so far.
54
- # Spend lives in .loki/metrics/budget.json under "current_spend" (per autonomy/run.sh:8167+).
54
+ # Spend lives in .loki/metrics/budget.json under "budget_used".
55
+ #
56
+ # It was read as "current_spend" until v9.50.0, a key NO production writer has
57
+ # ever emitted. All six writers of that file (run.sh:6581,17851,17869,
58
+ # loki-ts/src/runner/budget.ts:267, dashboard/server.py:7953, autonomy/loki)
59
+ # write "budget_used". The old citation pointed at run.sh:8167+, which is
60
+ # per-iteration token parsing and writes no budget key at all.
61
+ #
62
+ # Effect of the bug: spend always read 0, so --max-budget-usd was handed the
63
+ # FULL cap on every call instead of the remainder, and the per-call backstop
64
+ # never tightened as spend accumulated. Only reachable for users who set
65
+ # LOKI_BUDGET_LIMIT explicitly (run.sh sets BUDGET_LIMIT, not LOKI_*), i.e.
66
+ # exactly the cost-conscious users who asked for the cap.
67
+ # "current_spend" is still accepted as a fallback so a budget.json written by
68
+ # an in-flight older run still parses.
55
69
  # Emit empty string when budget is unlimited (LOKI_BUDGET_LIMIT unset or 0).
56
70
  # Emit empty when remaining <= 0 (caller decides what to do; we never emit 0).
57
71
  loki_remaining_budget() {
@@ -71,7 +85,9 @@ import json, os, sys
71
85
  try:
72
86
  with open(os.environ["_LOKI_BUDGET_FILE"]) as f:
73
87
  d = json.load(f)
74
- v = d.get("current_spend", 0)
88
+ v = d.get("budget_used")
89
+ if v is None:
90
+ v = d.get("current_spend", 0)
75
91
  print(float(v))
76
92
  except Exception:
77
93
  print(0)
@@ -324,7 +324,8 @@ def _compute_headline(facts, degraded):
324
324
  or tests.get("status") == "failed"
325
325
  or build.get("status") == "failed"
326
326
  or any(g.get("status") == "failed"
327
- for g in (facts.get("quality_gates") or []))
327
+ for g in (facts.get("quality_gates") or [])
328
+ if _is_exogenous(g))
328
329
  or sec_high
329
330
  or fn_failed
330
331
  )
@@ -342,7 +343,8 @@ def _compute_headline(facts, degraded):
342
343
  tests.get("status") == "verified"
343
344
  or build.get("status") == "verified"
344
345
  or any(g.get("status") == "passed"
345
- for g in (facts.get("quality_gates") or []))
346
+ for g in (facts.get("quality_gates") or [])
347
+ if _is_exogenous(g))
346
348
  )
347
349
  if any_verified and degraded:
348
350
  return "VERIFIED WITH GAPS"
@@ -165,7 +165,15 @@ def check_budget_threshold(trigger, loki_dir, iteration, notifications):
165
165
  state = json.loads(state_file.read_text())
166
166
  budget = state.get("budget", {})
167
167
  limit = budget.get("limit", 0)
168
- used = budget.get("used", 0)
168
+ # run.sh:7084 nests .loki/metrics/budget.json verbatim under "budget",
169
+ # and that file's spend key is "budget_used". Reading only "used" meant
170
+ # this trigger never fired in production: verified against the exact
171
+ # state run.sh writes, 0 notifications were produced at 85% of cap.
172
+ # "used" stays as a fallback for the /api/cost-shaped payload
173
+ # (autonomy/loki:28122), which does write that key.
174
+ used = budget.get("budget_used")
175
+ if used is None:
176
+ used = budget.get("used", 0)
169
177
  if limit <= 0:
170
178
  return None
171
179
  pct = (used / limit) * 100
@@ -241,24 +249,44 @@ def check_file_access(trigger, loki_dir, iteration, notifications):
241
249
 
242
250
 
243
251
  def check_quality_gate(trigger, loki_dir, iteration, notifications):
244
- """Check for quality gate failures. Reports ALL failed gates, not just first."""
245
- state_file = Path(loki_dir) / "dashboard-state.json"
246
- if not state_file.exists():
247
- return []
248
-
252
+ """Check for quality gate failures. Reports ALL failed gates, not just first.
253
+
254
+ Reads .loki/quality/gate-failures.txt, which is what run.sh actually writes
255
+ (:24100, :24142, :24553) as a comma-terminated list of failing gate names,
256
+ e.g. "mock_integrity,".
257
+
258
+ This previously read state["qualityGates"] out of dashboard-state.json,
259
+ sourced from .loki/state/quality-gates.json (run.sh:6972). NOTHING writes
260
+ that file: all seven repo references are readers, autonomy/hooks/
261
+ quality-gate.sh:12 guards on `[ ! -f ]`, and proof-generator.py:345 records
262
+ the same finding as issue #125 and already works around it by reading these
263
+ per-gate artifacts. The aggregate was therefore always absent, qualityGates
264
+ was always null, and this trigger could never fire -- the same dead-reader
265
+ defect as the budget key, in the same file.
266
+
267
+ gate-failures.txt is the canonical failure source used by
268
+ proof-generator.py:413 and loki-ts build_prompt_helpers.ts:250, so this
269
+ reuses a proven contract rather than introducing a second one.
270
+ """
271
+ failures_file = Path(loki_dir) / "quality" / "gate-failures.txt"
249
272
  results = []
250
273
  try:
251
- state = json.loads(state_file.read_text())
252
- gates = state.get("qualityGates", {})
253
- if isinstance(gates, dict):
254
- for gate_name, gate_data in gates.items():
255
- if isinstance(gate_data, dict) and gate_data.get("status") == "failed":
256
- msg = trigger["message"].format(gate=gate_name)
257
- results.append(make_notification(
258
- trigger["id"], trigger["severity"], msg, iteration,
259
- {"gate": gate_name},
260
- ))
261
- except (json.JSONDecodeError, OSError, KeyError):
274
+ if failures_file.exists():
275
+ # Cap the read: a pathological file must not be loaded whole
276
+ # (same reason as build_prompt.ts:707).
277
+ raw = failures_file.read_text(errors="replace")[:8000]
278
+ seen = set()
279
+ for name in raw.replace("\n", ",").split(","):
280
+ gate_name = name.strip()
281
+ if not gate_name or gate_name in seen:
282
+ continue
283
+ seen.add(gate_name)
284
+ msg = trigger["message"].format(gate=gate_name)
285
+ results.append(make_notification(
286
+ trigger["id"], trigger["severity"], msg, iteration,
287
+ {"gate": gate_name},
288
+ ))
289
+ except (OSError, KeyError):
262
290
  pass
263
291
  return results if results else None
264
292
 
package/autonomy/run.sh CHANGED
@@ -1181,10 +1181,12 @@ PERPETUAL_MODE=${LOKI_PERPETUAL_MODE:-false}
1181
1181
  #
1182
1182
  # MEASURED, not chosen. Real per-iteration wall clock across every recorded run
1183
1183
  # on this machine: median 718s, max 1746s. So the 1000 default is an 8.3-DAY
1184
- # ceiling -- and it is the ONLY backstop, because the other two valves ship
1185
- # disabled: LOKI_BUDGET_LIMIT defaults to "" (check_budget_limit returns
1186
- # immediately) and LOKI_MAX_DURATION defaults to 0 (check_max_duration returns
1187
- # "never stop").
1184
+ # ceiling. When this analysis was written it was also the ONLY backstop,
1185
+ # because the other two valves shipped disabled. That is no longer true of the
1186
+ # budget: since v9.42.0 LOKI_BUDGET_LIMIT defaults to "100.00" (see :527 --
1187
+ # note `-` not `:-`, so only an EXPLICIT "" means unlimited), and breaching it
1188
+ # pauses the run. LOKI_MAX_DURATION does still default to 0 (check_max_duration
1189
+ # returns "never stop" at :18278).
1188
1190
  #
1189
1191
  # It also contradicts our own documentation. SETUP.md tells users to RAISE the
1190
1192
  # budget for large work with LOKI_MAX_ITERATIONS=40, and the demo uses 10 -- so
@@ -17860,6 +17862,43 @@ BUDGETUPD_EOF
17860
17862
  return 0
17861
17863
  fi
17862
17864
 
17865
+ # LIVE COST VISIBILITY. The loop has always computed cumulative spend here,
17866
+ # every iteration, and printed NOTHING until 80% of the cap. That silence is
17867
+ # the "predatory / un-capped" feeling users report about agentic tools: the
17868
+ # enforcement was never missing (the cap pauses at 100.00 by default), only
17869
+ # the visibility was, so spend was discoverable solely after the fact.
17870
+ #
17871
+ # Reuses the $current_cost computed above; deliberately NOT a second reader.
17872
+ # Two readers of one number disagreeing is the exact defect class fixed in
17873
+ # 405da630, and a per-iteration cost line is not worth reintroducing it.
17874
+ #
17875
+ # Safe on stdout: this function has exactly two non-comment references in
17876
+ # the file -- its own definition, and the single loop call site, which
17877
+ # tests the exit code and never captures output. Verified with a
17878
+ # comment-excluding grep plus a positive control.
17879
+ #
17880
+ # This comment deliberately does NOT spell out the call-site expression.
17881
+ # tests/test-max-duration.sh:151 counts that exact string across this file
17882
+ # and asserts there is exactly ONE, so quoting it here made the count 2 and
17883
+ # turned a green suite red -- a text guard firing on prose written to
17884
+ # explain it. Describe the call site; never reproduce it.
17885
+ #
17886
+ # log_info is additionally gated by _loki_log_enabled, so quiet modes stay
17887
+ # quiet.
17888
+ # The zero test must be NUMERIC. `[ "$current_cost" != "0" ]` is a string
17889
+ # compare and the python above emits "0.0", so a run with nothing recorded
17890
+ # would announce "Cost so far: $0.0" -- claiming a measurement we do not
17891
+ # have, which is the exact fabrication this codebase deletes on sight.
17892
+ #
17893
+ # No else-branch: BUDGET_LIMIT empty means unlimited, and check_budget_limit
17894
+ # returns at its first line in that case, so any "no cap set" arm here would
17895
+ # be unreachable code advertising a message that can never print. The
17896
+ # no-cap disclosure already lives at run-start (show_run_start_estimate).
17897
+ if [ -n "$current_cost" ] \
17898
+ && awk -v c="$current_cost" 'BEGIN{exit !(c+0 > 0)}' 2>/dev/null; then
17899
+ log_info "Cost so far: \$${current_cost} of \$${BUDGET_LIMIT} cap (iteration ${ITERATION_COUNT:-0})."
17900
+ fi
17901
+
17863
17902
  # Update budget.json with current usage (not exceeded)
17864
17903
  if [ -n "$current_cost" ] && [ "$current_cost" != "0" ]; then
17865
17904
  cat > ".loki/metrics/budget.json" << BUDGETUPD_EOF
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.49.2"
10
+ __version__ = "9.49.4"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -261,16 +261,16 @@ def _real_client_host(request: Request):
261
261
  client = getattr(request, "client", None)
262
262
  host = getattr(client, "host", None) if client else None
263
263
  if host is None:
264
- return None
264
+ return None, False
265
265
  if host in _trusted_proxies():
266
266
  fwd = request.headers.get("x-forwarded-for", "")
267
267
  first = fwd.split(",")[0].strip()
268
268
  if first:
269
- return first
270
- return host
269
+ return first, True
270
+ return host, False
271
271
 
272
272
 
273
- def _is_local_caller(host) -> bool:
273
+ def _is_local_caller(host, from_forwarded: bool = False) -> bool:
274
274
  """True only for a caller we can positively identify as non-routable.
275
275
 
276
276
  A peer that is not an IP literal (ASGI test transports report
@@ -285,7 +285,18 @@ def _is_local_caller(host) -> bool:
285
285
  try:
286
286
  return ipaddress.ip_address(host).is_loopback
287
287
  except ValueError:
288
- return True
288
+ # A DIRECT peer that is not an IP literal is genuinely local: ASGI test
289
+ # transports report "testclient" and UDS transports report names, and
290
+ # refusing those closes no hole while breaking 17 tests.
291
+ #
292
+ # A FORWARDED value is different in kind. It arrived inside a request
293
+ # header, so treating an unparseable one as local lets any caller behind
294
+ # a trusted proxy pass the local check by sending a non-IP token.
295
+ # Measured: with LOKI_TRUSTED_PROXIES set, "not-an-ip", "unknown" and
296
+ # "testclient" all returned local, while an empty value correctly did
297
+ # not. "unknown" is a literal some proxies emit when the client address
298
+ # is unavailable, so this is reachable without an attacker choosing it.
299
+ return not from_forwarded
289
300
 
290
301
 
291
302
  def require_local_or_authenticated(request: Request) -> None:
@@ -316,13 +327,13 @@ def require_local_or_authenticated(request: Request) -> None:
316
327
  """
317
328
  if auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED:
318
329
  return
319
- host = _real_client_host(request)
330
+ host, host_from_forwarded = _real_client_host(request)
320
331
  if host is None:
321
332
  raise HTTPException(
322
333
  status_code=403,
323
334
  detail="control requires an identifiable client; enable "
324
335
  "LOKI_ENTERPRISE_AUTH to allow remote access")
325
- if _is_local_caller(host):
336
+ if _is_local_caller(host, host_from_forwarded):
326
337
  return
327
338
  raise HTTPException(
328
339
  status_code=403,
@@ -1211,14 +1222,16 @@ class WebSocketBoundaryMiddleware:
1211
1222
  if not (auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED):
1212
1223
  client = scope.get("client")
1213
1224
  host = client[0] if client else None
1225
+ host_from_forwarded = False
1214
1226
  if host in _trusted_proxies():
1215
1227
  for raw_name, raw_value in scope.get("headers", []):
1216
1228
  if raw_name == b"x-forwarded-for":
1217
1229
  first = raw_value.decode("latin-1").split(",")[0].strip()
1218
1230
  if first:
1219
1231
  host = first
1232
+ host_from_forwarded = True
1220
1233
  break
1221
- if not _is_local_caller(host):
1234
+ if not _is_local_caller(host, host_from_forwarded):
1222
1235
  # 1008 = policy violation. Closing with a code beats an
1223
1236
  # accept-then-drop, which reads to a client as a flaky network.
1224
1237
  await send({"type": "websocket.close", "code": 1008})
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v9.49.2
5
+ **Version:** v9.49.4
6
6
 
7
7
  ---
8
8
 
@@ -42,7 +42,7 @@ Honest count after this sweep: 8 gates.
42
42
  | 5 | Mock Integrity Detector | enforce_mock_integrity -> tests/detect-mock-problems.sh | Yes (HIGH blocks) | LOKI_GATE_MOCK=false |
43
43
  | 6 | Test Mutation Detector | enforce_mutation_integrity -> tests/detect-test-mutations.sh | Yes (HIGH blocks) | LOKI_GATE_MUTATION=false |
44
44
  | 7 | Documentation Coverage | run_doc_quality_gate (run.sh:7388) | Yes | LOKI_GATE_DOC_COVERAGE=false |
45
- | 8 | Magic Modules Debate | run_magic_debate_gate (run.sh:7495) | Yes (BLOCK sev) | LOKI_GATE_MAGIC_DEBATE=false |
45
+ | 8 | Magic Modules Debate | run_magic_debate_gate (run.sh:7495) | No, advisory by default. NOTE added 2026-09-13: this row said "Yes (BLOCK sev)" and that was never true on either route; bash enforces only with LOKI_GATE_MAGIC_DEBATE_BLOCKING=true and Bun self-skips unless LOKI_GATE_MAGIC_DEBATE="true". Line numbers in this document are as grep-verified in June 2026 and are left unchanged. | LOKI_GATE_MAGIC_DEBATE=false |
46
46
 
47
47
  Conditional auditor (documented separately, NOT numbered): Backward-Compatibility
48
48
  / legacy-healing-auditor (healing mode only). Removed: Input/Output Guardrails.
@@ -25,7 +25,7 @@ They bound different things, and a run that stalls needs all three.
25
25
 
26
26
  | Cap | Bounds | Default |
27
27
  |---|---|---|
28
- | `LOKI_BUDGET_LIMIT` | Total spend, in USD | unset (no cap) |
28
+ | `LOKI_BUDGET_LIMIT` | Total spend, in USD | `100.00` (breaching pauses the run) |
29
29
  | `LOKI_MAX_ITERATIONS` | Number of iterations | 1000 |
30
30
  | `LOKI_MAX_DURATION` | Wall-clock time | unset (no cap) |
31
31
 
@@ -76,7 +76,7 @@ velocity_quality_balance:
76
76
  5. Mock Integrity Detector - HIGH blocks
77
77
  6. Test Mutation Detector - HIGH blocks
78
78
  7. Documentation Coverage
79
- 8. Magic Modules Debate - BLOCK severity
79
+ 8. Magic Modules Debate - advisory by default on both routes (bash needs LOKI_GATE_MAGIC_DEBATE_BLOCKING=true; Bun self-skips unless LOKI_GATE_MAGIC_DEBATE="true")
80
80
 
81
81
  Conditional auditor (not numbered): backward-compatibility / legacy-healing-auditor (healing mode only).
82
82
 
@@ -23,7 +23,7 @@ with nowhere to look them up.
23
23
  |---|---|---|
24
24
  | `LOKI_PRD_FILE` | none | Path to a spec file, as an alternative to the positional argument. |
25
25
  | `LOKI_MAX_ITERATIONS` | `1000` | Hard cap on iterations. Reaching it is a deterministic terminal failure, not a success. Also in `config schema`. |
26
- | `LOKI_BUDGET_LIMIT` | unset (no cap) | Spend cap in USD. On exhaustion the run stops and reports a terminal failure -- see [exit codes](./exit-codes.md). |
26
+ | `LOKI_BUDGET_LIMIT` | `100.00` | Spend cap in USD. Breaching it **pauses** the run (writes `.loki/PAUSE` and saves state); it never kills work or discards a deliverable. Remove `.loki/PAUSE` to resume, or raise the cap. Set to `""` for no cap. A warning is logged at 80%. |
27
27
  | `LOKI_MAX_DURATION` | unset (no cap) | Wall-clock cap in seconds. Stops cleanly at the next iteration boundary with a `max_duration_reached` terminal status. `loki start --max-duration` also accepts `90m` / `2h`. |
28
28
  | `LOKI_AUTO_CONFIRM` | unset | `true`/`false` to control prompts. Takes precedence over `CI`. |
29
29
  | `LOKI_CONFIG_DUMP` | `0` | `1` prints the resolved configuration and exits **without starting a run or spending anything**. |