loki-mode 8.0.3 → 8.1.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.
package/README.md CHANGED
@@ -239,18 +239,31 @@ This needs Bun on your PATH (the SDK loop runs on the Bun runtime). `loki doctor
239
239
 
240
240
  **With a coding-agent CLI.** The classic path, and still the default: Loki drives a separate CLI (Claude Code is the recommended one) plus a couple of common tools on your PATH.
241
241
 
242
- **With a different model or provider.** Loki is not tied to Anthropic. Anything
243
- that speaks the Anthropic Messages API works: OpenRouter, Ollama, LiteLLM, vLLM,
244
- or your own gateway. Point `ANTHROPIC_BASE_URL` at the endpoint and name the
245
- model with `LOKI_MODEL_OVERRIDE`:
242
+ **With a different model or provider.** Loki is not tied to Anthropic, but
243
+ *how* you reach another model depends on which API the endpoint speaks. There
244
+ are two routes, and picking the wrong one fails confusingly.
245
+
246
+ *Route 1 -- OpenAI-shaped endpoints (OpenRouter, and most hosted open models).*
247
+ Use a provider that speaks that API natively. `aider` and `cline` both do, and
248
+ Loki now defaults them to open-weight models rather than Claude:
246
249
 
247
250
  ```bash
248
- # OpenRouter (hundreds of models, one key)
249
- export ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
250
- export ANTHROPIC_API_KEY=sk-or-...
251
- export LOKI_MODEL_OVERRIDE=<model-id-from-openrouter.ai/models>
252
- loki start prd.md
251
+ loki provider set aider
252
+ export OPENROUTER_API_KEY=sk-or-...
253
+ loki start prd.md # defaults to deepseek-v3.2
254
+
255
+ export LOKI_AIDER_MODEL=openrouter/z-ai/glm-4.6 # or pick your own
256
+ ```
253
257
 
258
+ OpenRouter serves **only** the OpenAI-shaped `/v1/chat/completions`; it has no
259
+ Anthropic `/v1/messages` endpoint. Pointing `ANTHROPIC_BASE_URL` at it does not
260
+ work, which earlier versions of this README incorrectly suggested.
261
+
262
+ *Route 2 -- Anthropic-protocol gateways.* `ANTHROPIC_BASE_URL` routes Claude
263
+ Code itself, so the endpoint must speak the Anthropic Messages API. LiteLLM,
264
+ Bedrock proxies, and self-hosted gateways can:
265
+
266
+ ```bash
254
267
  # Ollama, fully local (no API key, no per-token cost)
255
268
  export ANTHROPIC_BASE_URL=http://localhost:11434/v1
256
269
  export LOKI_MODEL_OVERRIDE=<model you have pulled, e.g. the output of `ollama list`>
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 v8.0.3
6
+ # Loki Mode v8.1.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
469
469
 
470
470
  ---
471
471
 
472
- **v8.0.3 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.1.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.0.3
1
+ 8.1.0
@@ -82,6 +82,9 @@ council_v2_vote() {
82
82
  local first=true
83
83
  local approve_count=0
84
84
  local reject_count=0
85
+ # Reviewers we could not obtain a verdict from. Never folded into
86
+ # reject_count -- see the TRUST comment in the tally below.
87
+ local inconclusive_count=0
85
88
  for vote_file in "${vote_files[@]}"; do
86
89
  if [ -f "$vote_file" ]; then
87
90
  local vote_content
@@ -95,16 +98,40 @@ council_v2_vote() {
95
98
 
96
99
  local verdict
97
100
  verdict=$(echo "$vote_content" | python3 -c "import sys,json; print(json.load(sys.stdin).get('verdict','').upper())" 2>/dev/null || echo "UNKNOWN")
101
+ # TRUST: a reviewer that could not be REACHED did not vote REJECT.
102
+ #
103
+ # Every provider arm below used to substitute a literal
104
+ # {"verdict":"REJECT","reasoning":"review execution failed"} on any
105
+ # miss -- CLI absent, timeout, transient error, unparseable output --
106
+ # and this tally counted "anything not APPROVE" as a rejection. The
107
+ # engine therefore fabricated a reviewer vote the model never gave,
108
+ # and the run was BLOCKED by it.
109
+ #
110
+ # That is the same defect class as a receipt attesting to a diff stat
111
+ # it did not measure: the artifact claims a fact nobody established.
112
+ # On a weak model that formats poorly it is worse still -- low format
113
+ # compliance becomes a permanent BLOCK that reads to the user as
114
+ # "Loki is broken" rather than "your model could not answer".
115
+ #
116
+ # INCONCLUSIVE is counted separately and never as a rejection.
98
117
  if [ "$verdict" = "APPROVE" ]; then
99
118
  ((approve_count++))
100
- else
119
+ elif [ "$verdict" = "REJECT" ]; then
101
120
  ((reject_count++))
121
+ else
122
+ ((inconclusive_count++))
102
123
  fi
103
124
  fi
104
125
  done
105
126
  votes_json="$votes_json]"
106
127
 
107
- log_info "Blind review results: $approve_count APPROVE / $reject_count REJECT"
128
+ if [ "$inconclusive_count" -gt 0 ]; then
129
+ log_warn "Blind review results: $approve_count APPROVE / $reject_count REJECT / $inconclusive_count INCONCLUSIVE"
130
+ log_warn " $inconclusive_count reviewer(s) produced no verdict (CLI missing, timeout, or unparseable output)."
131
+ log_warn " These are NOT rejections. The council decides on the verdicts it actually obtained."
132
+ else
133
+ log_info "Blind review results: $approve_count APPROVE / $reject_count REJECT"
134
+ fi
108
135
 
109
136
  # Step 4: Sycophancy detection
110
137
  local sycophancy_score
@@ -137,8 +164,13 @@ print('{:.3f}'.format(detect_sycophancy(votes)))
137
164
 
138
165
  if [ -f "$da_vote" ]; then
139
166
  local da_verdict
140
- da_verdict=$(cat "$da_vote" | python3 -c "import sys,json; print(json.load(sys.stdin).get('verdict','').upper())" 2>/dev/null || echo "REJECT")
141
- if [ "$da_verdict" = "REJECT" ]; then
167
+ # Same trust rule as the main tally: an unparseable devil's
168
+ # advocate did not vote REJECT. Defaulting to REJECT here would
169
+ # silently overturn a unanimous APPROVE on a transient failure.
170
+ da_verdict=$(cat "$da_vote" | python3 -c "import sys,json; print(json.load(sys.stdin).get('verdict','').upper())" 2>/dev/null || echo "INCONCLUSIVE")
171
+ if [ "$da_verdict" = "INCONCLUSIVE" ]; then
172
+ log_warn "Devil's advocate produced no verdict -- unanimous approval left UNCHANGED (not overturned)"
173
+ elif [ "$da_verdict" = "REJECT" ]; then
142
174
  log_warn "Devil's advocate REJECTED unanimous approval"
143
175
  approve_count=$((approve_count - 1))
144
176
  reject_count=$((reject_count + 1))
@@ -374,42 +406,42 @@ except Exception:
374
406
  fi
375
407
  # Fall through to the plain text call (+ sed-carve) on any miss.
376
408
  if [ -z "$result" ]; then
377
- result=$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
409
+ result=$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"INCONCLUSIVE","reasoning":"review execution failed (no verdict obtained; NOT a rejection)","issues":[]}')
378
410
  fi
379
411
  else
380
- result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
412
+ result='{"verdict":"INCONCLUSIVE","reasoning":"reviewer CLI unavailable (no verdict obtained; NOT a rejection)","issues":[]}'
381
413
  fi
382
414
  ;;
383
415
  codex)
384
416
  if command -v codex &>/dev/null; then
385
- result=$(codex exec -q "$full_prompt" 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
417
+ result=$(codex exec -q "$full_prompt" 2>/dev/null || echo '{"verdict":"INCONCLUSIVE","reasoning":"review execution failed (no verdict obtained; NOT a rejection)","issues":[]}')
386
418
  else
387
- result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
419
+ result='{"verdict":"INCONCLUSIVE","reasoning":"reviewer CLI unavailable (no verdict obtained; NOT a rejection)","issues":[]}'
388
420
  fi
389
421
  ;;
390
422
  gemini)
391
423
  if command -v gemini &>/dev/null; then
392
- result=$(echo "$full_prompt" | gemini 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
424
+ result=$(echo "$full_prompt" | gemini 2>/dev/null || echo '{"verdict":"INCONCLUSIVE","reasoning":"review execution failed (no verdict obtained; NOT a rejection)","issues":[]}')
393
425
  else
394
- result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
426
+ result='{"verdict":"INCONCLUSIVE","reasoning":"reviewer CLI unavailable (no verdict obtained; NOT a rejection)","issues":[]}'
395
427
  fi
396
428
  ;;
397
429
  cline)
398
430
  if command -v cline &>/dev/null; then
399
- result=$(cline -y "$full_prompt" 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
431
+ result=$(cline -y "$full_prompt" 2>/dev/null || echo '{"verdict":"INCONCLUSIVE","reasoning":"review execution failed (no verdict obtained; NOT a rejection)","issues":[]}')
400
432
  else
401
- result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
433
+ result='{"verdict":"INCONCLUSIVE","reasoning":"reviewer CLI unavailable (no verdict obtained; NOT a rejection)","issues":[]}'
402
434
  fi
403
435
  ;;
404
436
  aider)
405
437
  if command -v aider &>/dev/null; then
406
- result=$(aider --message "$full_prompt" --yes-always --no-auto-commits --no-git 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
438
+ result=$(aider --message "$full_prompt" --yes-always --no-auto-commits --no-git 2>/dev/null || echo '{"verdict":"INCONCLUSIVE","reasoning":"review execution failed (no verdict obtained; NOT a rejection)","issues":[]}')
407
439
  else
408
- result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
440
+ result='{"verdict":"INCONCLUSIVE","reasoning":"reviewer CLI unavailable (no verdict obtained; NOT a rejection)","issues":[]}'
409
441
  fi
410
442
  ;;
411
443
  *)
412
- result='{"verdict":"REJECT","reasoning":"review not supported for this provider","issues":[]}'
444
+ result='{"verdict":"INCONCLUSIVE","reasoning":"review not supported for this provider (no verdict obtained; NOT a rejection)","issues":[]}'
413
445
  ;;
414
446
  esac
415
447
 
@@ -421,7 +453,11 @@ except Exception:
421
453
  extracted=$(echo "$result" | sed 's/^```json//;s/^```//' | sed -n '/^{/,/^}/p' | head -50)
422
454
  fi
423
455
  if [ -z "$extracted" ]; then
424
- extracted='{"verdict":"REJECT","reasoning":"failed to parse review output","issues":[]}'
456
+ # The weak-model case, and the reason this matters most: a model whose
457
+ # prose could not be carved into JSON did not vote REJECT. Recording one
458
+ # here would turn "your model formats poorly" into "your code was
459
+ # rejected" -- a block the user cannot act on and did not earn.
460
+ extracted='{"verdict":"INCONCLUSIVE","reasoning":"failed to parse review output (no verdict obtained; NOT a rejection)","issues":[]}'
425
461
  fi
426
462
 
427
463
  echo "$extracted" > "$output_file"
@@ -224,6 +224,11 @@ loki_prd_enrich() {
224
224
  # improve tasks deterministically (content-derived user_story) so even
225
225
  # offline users get informative tasks, then return without a model call.
226
226
  if ! _loki_prd_enrich_provider_ok; then
227
+ # HONESTY: say so. This used to degrade to the deterministic path with no
228
+ # log line at all, so a user on a non-Claude provider believed their PRD
229
+ # had been model-enriched when it had not. A capability the run did not
230
+ # get must never be indistinguishable from one it did.
231
+ log_warn "PRD enrichment: model-assisted pass SKIPPED (provider='${LOKI_PROVIDER:-claude}'), using the deterministic pass only."
227
232
  _loki_prd_enrich_deterministic "$pending_path"
228
233
  return 0
229
234
  fi
@@ -429,6 +429,19 @@ def _collect_termination(loki_dir, session_exit_code=None):
429
429
  "exit_code": None,
430
430
  "outcome": "",
431
431
  "run_status": "",
432
+ # Which gate stopped the run, if one did. The receipt previously named
433
+ # only a bare outcome ("intervention"), so a user reading the artifact
434
+ # could not tell WHICH gate blocked them or how close it came to its
435
+ # threshold -- the single most actionable fact about a blocked run. The
436
+ # engine already writes it to .loki/signals/GATE_ESCALATION.json and
437
+ # already surfaces it in COMPLETION.txt and PAUSED.md; the signed
438
+ # receipt was the one surface that stayed silent.
439
+ #
440
+ # These are deterministic FACTS read from a file the engine wrote, not
441
+ # an AI assessment, so they belong in the facts block.
442
+ "blocking_gate": "",
443
+ "blocking_gate_failures": None,
444
+ "blocking_gate_threshold": None,
432
445
  }
433
446
  state_paths = [os.path.join(loki_dir, "autonomy-state.json")]
434
447
  sessions_dir = os.path.join(loki_dir, "sessions")
@@ -455,6 +468,20 @@ def _collect_termination(loki_dir, session_exit_code=None):
455
468
  out["status"] = outcome
456
469
  if outcome not in ("complete", "completed", "success"):
457
470
  out["reason"] = outcome
471
+ # Gate escalation: name the gate that stopped the run. Best-effort and
472
+ # non-fatal -- a missing or corrupt signal simply leaves the fields empty,
473
+ # which reads as "no gate escalation recorded", never as a false claim.
474
+ gate = _read_json(
475
+ os.path.join(loki_dir, "signals", "GATE_ESCALATION.json"), default=None
476
+ )
477
+ if isinstance(gate, dict):
478
+ gate_name = str(gate.get("gate") or "").strip()
479
+ if gate_name:
480
+ out["blocking_gate"] = gate_name
481
+ count = gate.get("count")
482
+ thr = gate.get("threshold")
483
+ out["blocking_gate_failures"] = _to_int(count, None)
484
+ out["blocking_gate_threshold"] = _to_int(thr, None)
458
485
  if session_exit_code is not None:
459
486
  out["exit_code"] = session_exit_code
460
487
  if session_exit_code != 0 and not out["reason"]:
@@ -640,6 +640,23 @@
640
640
  ? " (" + esc(tOutcome) + ")" : "") +
641
641
  "</div>";
642
642
  }
643
+ // Name the GATE that stopped the run, with how close it came to its
644
+ // threshold. "intervention" tells a reader that something blocked them;
645
+ // "code_review (failed 3 times, threshold 3)" tells them what to fix. The
646
+ // engine already surfaced this in COMPLETION.txt and PAUSED.md -- the
647
+ // signed receipt was the only surface that stayed silent about it.
648
+ // Display-only, and silent when no gate escalation was recorded.
649
+ var tGate = String(g(p, "facts.execution.blocking_gate", "") || "").trim();
650
+ if (tGate) {
651
+ var gFail = g(p, "facts.execution.blocking_gate_failures", null);
652
+ var gThr = g(p, "facts.execution.blocking_gate_threshold", null);
653
+ var detail = "";
654
+ if (typeof gFail === "number" && typeof gThr === "number") {
655
+ detail = " (failed " + esc(String(gFail)) + " times, threshold " +
656
+ esc(String(gThr)) + ")";
657
+ }
658
+ term += '<div class="h-sub">Blocked by: ' + esc(tGate) + detail + "</div>";
659
+ }
643
660
  el.className = "honesty " + v.cls;
644
661
  el.innerHTML =
645
662
  '<span class="h-mark" aria-hidden="true">' + v.mark + "</span>" +
package/autonomy/loki CHANGED
@@ -954,10 +954,20 @@ show_help() {
954
954
  echo " # Providers + model routing"
955
955
  echo " loki provider list # Show 4 providers (claude/codex/cline/aider)"
956
956
  echo " loki provider set codex # Switch active provider"
957
- echo " # OpenRouter / Ollama routing (Phase I v7.5.25+):"
958
- echo " export ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 \\\\"
959
- echo " LOKI_MODEL_OVERRIDE=anthropic/claude-sonnet-4.5"
960
- echo " loki start ./prd.md # Routes to OpenRouter via Claude Code"
957
+ echo " # Cheap / open models (DeepSeek, GLM, MiniMax, Kimi) via OpenRouter:"
958
+ echo " loki provider set aider # aider + cline reach any OpenRouter model"
959
+ echo " export OPENROUTER_API_KEY=sk-or-..."
960
+ echo " loki start ./prd.md # defaults to deepseek-v3.2 (open weights)"
961
+ echo " # Pick a specific model:"
962
+ echo " export LOKI_AIDER_MODEL=openrouter/z-ai/glm-4.6"
963
+ echo ""
964
+ echo " # Anthropic-PROTOCOL gateways (LiteLLM, Bedrock proxies, self-hosted):"
965
+ echo " export ANTHROPIC_BASE_URL=https://your-gateway/v1 \\\\"
966
+ echo " LOKI_MODEL_OVERRIDE=<model-id-your-gateway-serves>"
967
+ echo " # NOTE: this routes Claude Code itself, so the endpoint must speak the"
968
+ echo " # Anthropic /v1/messages API. OpenRouter does NOT -- it serves"
969
+ echo " # only the OpenAI-shaped /v1/chat/completions. Use the aider or"
970
+ echo " # cline route above for OpenRouter."
961
971
  echo ""
962
972
  echo " # Cross-project context (Phase F v7.5.23+)"
963
973
  echo " # Drop .loki/app.json with {schema_version:1,app_id:myapp,members:[ui,api]}"
@@ -3871,7 +3881,10 @@ cmd_why() {
3871
3881
  fi
3872
3882
 
3873
3883
  if [ "$as_json" = "1" ]; then
3884
+ local _why_json_head_sha
3885
+ _why_json_head_sha="$(git rev-parse HEAD 2>/dev/null || echo "")"
3874
3886
  _LOKI_WHY_STATE="$state_file" _LOKI_WHY_COMPLETION="$completion_file" \
3887
+ _LOKI_WHY_HEAD_SHA="$_why_json_head_sha" \
3875
3888
  _LOKI_WHY_LAST_ERROR="$loki_dir/state/LAST_ERROR.json" python3 - <<'WHYJSON'
3876
3889
  import json, os
3877
3890
  def load(p):
@@ -3881,7 +3894,26 @@ def load(p):
3881
3894
  state = load(os.environ.get("_LOKI_WHY_STATE", ""))
3882
3895
  comp = load(os.environ.get("_LOKI_WHY_COMPLETION", ""))
3883
3896
  last_error = load(os.environ.get("_LOKI_WHY_LAST_ERROR", ""))
3884
- print(json.dumps({"state": state, "completion": comp, "last_error": last_error}, indent=2))
3897
+
3898
+ # The human report labels a stale completion record "(from previous completed
3899
+ # run)". The JSON consumer got the same record with NO such marker, so a script
3900
+ # reading `completion.branch` or `completion.pr_url` after a crashed run would
3901
+ # attribute a PREVIOUS run's branch and PR to the current one -- silently, and
3902
+ # with no field to check. Emit the identical determination as explicit fields.
3903
+ # Kept byte-identical in logic to the human path below; the two must not drift.
3904
+ live_statuses = {"running", "exited"}
3905
+ head_sha = os.environ.get("_LOKI_WHY_HEAD_SHA", "")
3906
+ comp_head = comp.get("head_sha", "")
3907
+ comp_is_stale = bool(state.get("status") in live_statuses) or bool(
3908
+ head_sha and comp_head and comp_head != head_sha
3909
+ )
3910
+ print(json.dumps({
3911
+ "state": state,
3912
+ "completion": comp,
3913
+ "last_error": last_error,
3914
+ "completion_is_stale": comp_is_stale,
3915
+ "head_sha": head_sha or None,
3916
+ }, indent=2))
3885
3917
  WHYJSON
3886
3918
  return 0
3887
3919
  fi
package/autonomy/run.sh CHANGED
@@ -22823,6 +22823,8 @@ check_human_intervention() {
22823
22823
  log_warn "PAUSE file created by budget limit - NOT auto-clearing in perpetual mode"
22824
22824
  log_warn "Budget limit reached. Remove .loki/signals/BUDGET_EXCEEDED and .loki/PAUSE to continue."
22825
22825
  notify_intervention_needed "Budget limit reached - execution paused" 2>/dev/null || true
22826
+ # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
22827
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22826
22828
  # Genuinely blocking pause: write the durable intervention record
22827
22829
  # now (state-only; the ping above already fired). This is the
22828
22830
  # correct site for the durable file because the run actually halts
@@ -22835,6 +22837,7 @@ check_human_intervention() {
22835
22837
  if [ "$pause_result" -eq 1 ]; then
22836
22838
  # STOP requested DURING the pause: relabel the durable record
22837
22839
  # as stopped (state-only; the user typed STOP and is aware).
22840
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22838
22841
  build_completion_summary stopped 2>/dev/null || true
22839
22842
  return 2
22840
22843
  fi
@@ -22849,6 +22852,13 @@ check_human_intervention() {
22849
22852
  fi
22850
22853
  log_warn "PAUSE file detected - pausing execution"
22851
22854
  notify_intervention_needed "Execution paused via PAUSE file"
22855
+ # Refresh STATUS.txt from the queue BEFORE writing the durable record, so
22856
+ # the two files snapshot the same instant. The status monitor refreshes
22857
+ # every 2s but is not running by the time we block here, so STATUS.txt
22858
+ # was frozen at its last tick: measured on a real paused run reporting
22859
+ # "Failed: 0" while COMPLETION.txt -- written now, from the same
22860
+ # queue/failed.json -- reported failed=1. Same source, different age.
22861
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22852
22862
  # Genuinely blocking pause: write the durable intervention record now
22853
22863
  # (state-only; the ping above already fired).
22854
22864
  build_completion_summary intervention 2>/dev/null || true
@@ -22859,6 +22869,7 @@ check_human_intervention() {
22859
22869
  if [ "$pause_result" -eq 1 ]; then
22860
22870
  # STOP was requested during pause: relabel the durable record as
22861
22871
  # stopped (state-only; the user typed STOP and is aware).
22872
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22862
22873
  build_completion_summary stopped 2>/dev/null || true
22863
22874
  return 2
22864
22875
  fi
@@ -22872,6 +22883,8 @@ check_human_intervention() {
22872
22883
  rm -f "$loki_dir/PAUSE_AT_CHECKPOINT"
22873
22884
  notify_intervention_needed "Execution paused at checkpoint"
22874
22885
  touch "$loki_dir/PAUSE"
22886
+ # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
22887
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22875
22888
  # Genuinely blocking pause: write the durable intervention record now
22876
22889
  # (state-only; the ping above already fired).
22877
22890
  build_completion_summary intervention 2>/dev/null || true
@@ -22881,6 +22894,7 @@ check_human_intervention() {
22881
22894
  rm -f "$loki_dir/PAUSE"
22882
22895
  if [ "$pause_result" -eq 1 ]; then
22883
22896
  # STOP requested during pause: relabel as stopped (state-only).
22897
+ (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
22884
22898
  build_completion_summary stopped 2>/dev/null || true
22885
22899
  return 2
22886
22900
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.0.3"
10
+ __version__ = "8.1.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
package/events/emit.sh CHANGED
@@ -44,36 +44,63 @@ safe_append_event_jsonl() {
44
44
  if command -v flock >/dev/null 2>&1; then
45
45
  # flock path: bind FD 9 to the sentinel file (created if absent),
46
46
  # take an exclusive lock, append, release on subshell exit.
47
+ #
48
+ # `-w 5` is LOAD-BEARING: a bare `flock -x 9` blocks FOREVER. A holder
49
+ # that is killed mid-write (a reaped CI run, a Ctrl-C'd build) leaves
50
+ # every subsequent emit hung, and because emit.sh is spawned per CLI
51
+ # invocation those hangs ACCUMULATE. Measured 2026-07-29: 59 orphaned
52
+ # emit.sh processes, the oldest alive 21 HOURS, on a machine at 0% idle.
53
+ # Observability must never outlive the thing it observes.
47
54
  (
48
- flock -x 9
55
+ flock -w 5 -x 9 || exit 1
49
56
  printf '%s\n' "$line" >> "$events_path"
50
57
  ) 9>"$lock_target"
51
- return $?
58
+ local frc=$?
59
+ if [ "$frc" -ne 0 ]; then
60
+ # Lock unavailable within the timeout: append unlocked rather than
61
+ # block. A rare interleaved line is strictly better than a hung CLI.
62
+ printf '%s\n' "$line" >> "$events_path" 2>/dev/null || true
63
+ fi
64
+ return 0
52
65
  fi
53
66
 
54
- # Fallback: mkdir-based mutex. mkdir is atomic on POSIX.
67
+ # Fallback: mkdir-based mutex. mkdir is atomic on POSIX. macOS ships NO
68
+ # flock(1), so this is the path real users take -- it must be the robust one.
55
69
  local lock_dir="${events_path}.lockdir"
56
70
  local attempts=0
57
- local max_attempts=500 # ~5s at 10ms sleep
71
+ local max_attempts=100 # ~1s at 10ms sleep
72
+ local stale_after=30
58
73
  while ! mkdir "$lock_dir" 2>/dev/null; do
74
+ # Check staleness EVERY iteration, not only after exhausting attempts.
75
+ # The old code waited for all 500 attempts before its first staleness
76
+ # check, so a stale lock cost ~5s AND ~500 forked sleep helpers per
77
+ # event; under concurrent emits the locks kept going stale and the
78
+ # processes piled up. Measured before this fix: 10s and ~500 forks for
79
+ # a SINGLE append against an already-stale lock.
80
+ local age=0
81
+ local mtime
82
+ mtime=$(stat -f%m "$lock_dir" 2>/dev/null || stat -c%Y "$lock_dir" 2>/dev/null || echo "")
83
+ if [ -n "$mtime" ]; then
84
+ age=$(( $(date +%s) - mtime ))
85
+ else
86
+ # Lock vanished between the failed mkdir and the stat: retry at once.
87
+ continue
88
+ fi
89
+ if [ "$age" -gt "$stale_after" ]; then
90
+ rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null || true
91
+ continue
92
+ fi
59
93
  attempts=$((attempts + 1))
60
94
  if [ "$attempts" -ge "$max_attempts" ]; then
61
- # Stale lock: if the dir is older than 30s, force-remove it.
62
- local age
63
- age=$(( $(date +%s) - $(stat -f%m "$lock_dir" 2>/dev/null \
64
- || stat -c%Y "$lock_dir" 2>/dev/null \
65
- || echo 0) ))
66
- if [ "$age" -gt 30 ]; then
67
- rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null || true
68
- attempts=0
69
- continue
70
- fi
71
- # Give up -- best-effort write so observability never blocks.
95
+ # Give up fast -- best-effort write so observability never blocks.
72
96
  printf '%s\n' "$line" >> "$events_path" 2>/dev/null || true
73
- return 1
97
+ return 0
74
98
  fi
75
- # Sleep ~10ms (perl avoids `sleep 0.01` portability issues).
76
- perl -e 'select(undef,undef,undef,0.01)' 2>/dev/null || sleep 1
99
+ # Sleep ~10ms WITHOUT forking when the shell supports fractional sleep
100
+ # (bash's `read -t` needs no external process). perl/sleep are fallbacks.
101
+ read -r -t 0.01 _unused_ < /dev/null 2>/dev/null \
102
+ || perl -e 'select(undef,undef,undef,0.01)' 2>/dev/null \
103
+ || sleep 1
77
104
  done
78
105
  # Critical section.
79
106
  printf '%s\n' "$line" >> "$events_path"
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var k_=Object.create;var{getPrototypeOf:x_,defineProperty:oK,getOwnPropertyNames:S_}=Object;var y_=Object.prototype.hasOwnProperty;function b_(Z){return this[Z]}var __,f_,h_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?__??=new WeakMap:f_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?k_(x_(Z)):{};let K=X||!Z||!Z.__esModule?oK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of S_(Z))if(!y_.call(K,$))oK(K,$,{get:b_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var VQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var v_=(Z)=>Z;function g_(Z,X){this[Z]=v_.bind(null,X)}var c0=(Z,X)=>{for(var Q in X)oK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:g_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var EO={};c0(EO,{lokiDir:()=>j0,homeLokiDir:()=>I4,findRepoRootForVersion:()=>tK,REPO_ROOT:()=>Q8});import{resolve as s7,dirname as rK}from"path";import{fileURLToPath as m_}from"url";import{existsSync as qQ}from"fs";import{homedir as u_}from"os";function p_(){let Z=DO;for(let X=0;X<6;X++){if(qQ(s7(Z,"VERSION"))&&qQ(s7(Z,"autonomy/run.sh")))return Z;let Q=rK(Z);if(Q===Z)break;Z=Q}return s7(DO,"..","..","..")}function tK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(qQ(s7(X,"VERSION"))&&qQ(s7(X,"autonomy/run.sh")))return X;let Y=rK(X);if(Y===X)break;X=Y}return s7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??s7(process.cwd(),".loki")}function I4(){return s7(u_(),".loki")}var DO,Q8;var G8=p(()=>{DO=rK(m_(import.meta.url));Q8=p_()});import{readFileSync as d_}from"fs";import{resolve as c_,dirname as l_}from"path";import{fileURLToPath as i_}from"url";function _3(){if(f5!==null)return f5;let Z="8.0.3";if(typeof Z==="string"&&Z.length>0)return f5=Z,f5;try{let X=l_(i_(import.meta.url)),Q=tK(X);f5=d_(c_(Q,"VERSION"),"utf-8").trim()}catch{f5="unknown"}return f5}var f5=null;var GQ=p(()=>{G8()});var kO={};c0(kO,{runOrThrow:()=>Wf,run:()=>D0,readStreamCapped:()=>HQ,commandVersion:()=>qf,commandExists:()=>H9,ShellError:()=>eK,MAX_STDOUT_BYTES:()=>RO});async function HQ(Z,X=RO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function D0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([HQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Wf(Z,X={}){let Q=await D0(Z,X);if(Q.exitCode!==0)throw new eK(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function H9(Z){let X=Vf(Z),Q=await D0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Vf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function qf(Z,X="--version"){if(!await H9(Z))return null;let Y=await D0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var RO=16777216,eK;var k9=p(()=>{eK=class eK extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function n7(Z){return Gf?"":Z}var Gf,M0,k8,l0,gW0,a0,H8,X9,g;var S6=p(()=>{Gf=(process.env.NO_COLOR??"").length>0;M0=n7("\x1B[0;31m"),k8=n7("\x1B[0;32m"),l0=n7("\x1B[1;33m"),gW0=n7("\x1B[0;34m"),a0=n7("\x1B[0;36m"),H8=n7("\x1B[1m"),X9=n7("\x1B[2m"),g=n7("\x1B[0m")});import{existsSync as wf}from"fs";async function F7(){if(R4!==void 0)return R4;let Z="/opt/homebrew/bin/python3.12";if(wf(Z))return R4=Z,Z;let X=await H9("python3.12");if(X)return R4=X,X;let Q=await H9("python3");return R4=Q,Q}async function D7(Z,X={}){let Q=await F7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return D0([Q,"-c",Z],X)}var R4;var o7=p(()=>{k9()});var oO={};c0(oO,{runStatus:()=>of});import{existsSync as Q9,readFileSync as h3,readdirSync as pO,statSync as dO}from"fs";import{resolve as h8,basename as mf}from"path";import{homedir as uf}from"os";function cO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function lO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*BQ/X);if(J>BQ)J=BQ;let z=BQ-J,K=k8;if(Y>=80)K=M0;else if(Y>=50)K=l0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=cO(Z),V=cO(X);return` ${H8}${Q}${g} ${K}[${$}]${g} ${Y}% (${W} / ${V})`}async function df(){if(await H9("jq"))return!0;return process.stdout.write(`${M0}Error: jq is required but not installed.${g}
2
+ var k_=Object.create;var{getPrototypeOf:x_,defineProperty:oK,getOwnPropertyNames:S_}=Object;var y_=Object.prototype.hasOwnProperty;function b_(Z){return this[Z]}var __,f_,h_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?__??=new WeakMap:f_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?k_(x_(Z)):{};let K=X||!Z||!Z.__esModule?oK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of S_(Z))if(!y_.call(K,$))oK(K,$,{get:b_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var VQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var v_=(Z)=>Z;function g_(Z,X){this[Z]=v_.bind(null,X)}var c0=(Z,X)=>{for(var Q in X)oK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:g_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var EO={};c0(EO,{lokiDir:()=>j0,homeLokiDir:()=>I4,findRepoRootForVersion:()=>tK,REPO_ROOT:()=>Q8});import{resolve as s7,dirname as rK}from"path";import{fileURLToPath as m_}from"url";import{existsSync as qQ}from"fs";import{homedir as u_}from"os";function p_(){let Z=DO;for(let X=0;X<6;X++){if(qQ(s7(Z,"VERSION"))&&qQ(s7(Z,"autonomy/run.sh")))return Z;let Q=rK(Z);if(Q===Z)break;Z=Q}return s7(DO,"..","..","..")}function tK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(qQ(s7(X,"VERSION"))&&qQ(s7(X,"autonomy/run.sh")))return X;let Y=rK(X);if(Y===X)break;X=Y}return s7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??s7(process.cwd(),".loki")}function I4(){return s7(u_(),".loki")}var DO,Q8;var G8=p(()=>{DO=rK(m_(import.meta.url));Q8=p_()});import{readFileSync as d_}from"fs";import{resolve as c_,dirname as l_}from"path";import{fileURLToPath as i_}from"url";function _3(){if(f5!==null)return f5;let Z="8.1.0";if(typeof Z==="string"&&Z.length>0)return f5=Z,f5;try{let X=l_(i_(import.meta.url)),Q=tK(X);f5=d_(c_(Q,"VERSION"),"utf-8").trim()}catch{f5="unknown"}return f5}var f5=null;var GQ=p(()=>{G8()});var kO={};c0(kO,{runOrThrow:()=>Wf,run:()=>D0,readStreamCapped:()=>HQ,commandVersion:()=>qf,commandExists:()=>H9,ShellError:()=>eK,MAX_STDOUT_BYTES:()=>RO});async function HQ(Z,X=RO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function D0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([HQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Wf(Z,X={}){let Q=await D0(Z,X);if(Q.exitCode!==0)throw new eK(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function H9(Z){let X=Vf(Z),Q=await D0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Vf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function qf(Z,X="--version"){if(!await H9(Z))return null;let Y=await D0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var RO=16777216,eK;var k9=p(()=>{eK=class eK extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function n7(Z){return Gf?"":Z}var Gf,M0,k8,l0,gW0,a0,H8,X9,g;var S6=p(()=>{Gf=(process.env.NO_COLOR??"").length>0;M0=n7("\x1B[0;31m"),k8=n7("\x1B[0;32m"),l0=n7("\x1B[1;33m"),gW0=n7("\x1B[0;34m"),a0=n7("\x1B[0;36m"),H8=n7("\x1B[1m"),X9=n7("\x1B[2m"),g=n7("\x1B[0m")});import{existsSync as wf}from"fs";async function F7(){if(R4!==void 0)return R4;let Z="/opt/homebrew/bin/python3.12";if(wf(Z))return R4=Z,Z;let X=await H9("python3.12");if(X)return R4=X,X;let Q=await H9("python3");return R4=Q,Q}async function D7(Z,X={}){let Q=await F7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return D0([Q,"-c",Z],X)}var R4;var o7=p(()=>{k9()});var oO={};c0(oO,{runStatus:()=>of});import{existsSync as Q9,readFileSync as h3,readdirSync as pO,statSync as dO}from"fs";import{resolve as h8,basename as mf}from"path";import{homedir as uf}from"os";function cO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function lO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*BQ/X);if(J>BQ)J=BQ;let z=BQ-J,K=k8;if(Y>=80)K=M0;else if(Y>=50)K=l0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=cO(Z),V=cO(X);return` ${H8}${Q}${g} ${K}[${$}]${g} ${Y}% (${W} / ${V})`}async function df(){if(await H9("jq"))return!0;return process.stdout.write(`${M0}Error: jq is required but not installed.${g}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -1203,4 +1203,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1203
1203
  `),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (I_(),E_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
1204
1204
  `),process.stderr.write(P_),2}}mO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var TW0=await MW0(Bun.argv.slice(2));process.exit(TW0);
1205
1205
 
1206
- //# debugId=3C2284D252E647F964756E2164756E21
1206
+ //# debugId=59A2C45B0211620364756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '8.0.3'
60
+ __version__ = '8.1.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "8.0.3",
4
+ "version": "8.1.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "8.0.3",
5
+ "version": "8.1.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "Canonical model catalog. Update this single file when a provider ships a new model. Providers/web-app/docs read from here.",
3
3
  "schema_version": 1,
4
- "updated": "2026-06-30",
4
+ "updated": "2026-07-29",
5
5
  "providers": {
6
6
  "claude": {
7
7
  "latest_planning": "claude-opus-4-8",
@@ -52,31 +52,78 @@
52
52
  "latest_development": "gpt-5.3-codex",
53
53
  "latest_fast": "gpt-5.3-codex",
54
54
  "models": [
55
- { "id": "gpt-5.3-codex", "tier": "planning" },
56
- { "id": "o3", "tier": "planning" },
57
- { "id": "o4-mini", "tier": "fast" }
55
+ {
56
+ "id": "gpt-5.3-codex",
57
+ "tier": "planning"
58
+ },
59
+ {
60
+ "id": "o3",
61
+ "tier": "planning"
62
+ },
63
+ {
64
+ "id": "o4-mini",
65
+ "tier": "fast"
66
+ }
58
67
  ],
59
68
  "notes": "Codex uses a single model with effort level (xhigh/high/low) for tier differentiation"
60
69
  },
61
70
  "cline": {
62
- "latest_planning": "claude-opus-4-8",
63
- "latest_development": "claude-sonnet-5",
64
- "latest_fast": "claude-sonnet-5",
71
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
72
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
73
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
65
74
  "models": [
66
- { "id": "claude-opus-4-8", "tier": "planning" },
67
- { "id": "claude-sonnet-5", "tier": "development" },
68
- { "id": "gpt-4.1", "tier": "development" }
75
+ {
76
+ "id": "openrouter/deepseek/deepseek-v3.2",
77
+ "tier": "planning",
78
+ "open_weights": true
79
+ },
80
+ {
81
+ "id": "openrouter/deepseek/deepseek-v3.2",
82
+ "tier": "development",
83
+ "open_weights": true
84
+ },
85
+ {
86
+ "id": "openrouter/deepseek/deepseek-chat",
87
+ "tier": "fast",
88
+ "open_weights": true
89
+ },
90
+ {
91
+ "id": "openrouter/z-ai/glm-4.6",
92
+ "tier": "development",
93
+ "open_weights": true
94
+ }
69
95
  ]
70
96
  },
71
97
  "aider": {
72
- "latest_planning": "claude-opus-4-8",
73
- "latest_development": "claude-sonnet-5",
74
- "latest_fast": "claude-sonnet-5",
98
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
99
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
100
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
75
101
  "models": [
76
- { "id": "claude-opus-4-8", "tier": "planning" },
77
- { "id": "claude-sonnet-5", "tier": "development" },
78
- { "id": "gpt-4.1", "tier": "development" },
79
- { "id": "ollama_chat/deepseek-coder", "tier": "fast" }
102
+ {
103
+ "id": "openrouter/deepseek/deepseek-v3.2",
104
+ "tier": "planning",
105
+ "open_weights": true
106
+ },
107
+ {
108
+ "id": "openrouter/deepseek/deepseek-v3.2",
109
+ "tier": "development",
110
+ "open_weights": true
111
+ },
112
+ {
113
+ "id": "openrouter/deepseek/deepseek-chat",
114
+ "tier": "fast",
115
+ "open_weights": true
116
+ },
117
+ {
118
+ "id": "openrouter/minimax/minimax-m2.1",
119
+ "tier": "development",
120
+ "open_weights": true
121
+ },
122
+ {
123
+ "id": "ollama_chat/deepseek-coder",
124
+ "tier": "fast",
125
+ "open_weights": true
126
+ }
80
127
  ]
81
128
  }
82
129
  }