loki-mode 7.30.0 → 7.31.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.
@@ -26,10 +26,20 @@
26
26
  # * The ONLY command run on the user's behalf is, after explicit consent:
27
27
  # <venv>/bin/pip install -r mcp/requirements.txt
28
28
  # The exact command is printed before it runs.
29
- # * Non-interactive / CI: NEVER install. Print the manual command to stderr
30
- # and exit 2 (mirrors autonomy/provider-offer.sh gate semantics).
29
+ # * Non-interactive / CI: NEVER install by default. Print the manual command
30
+ # to stderr and exit 2 (mirrors autonomy/provider-offer.sh gate semantics).
31
+ # EXCEPTION: LOKI_MCP_AUTO_BOOTSTRAP (1/true/yes/on, case-insensitive) is
32
+ # explicit-env written consent. MCP
33
+ # clients (Claude Desktop etc.) spawn the server non-interactively over piped
34
+ # stdio; a user who writes LOKI_MCP_AUTO_BOOTSTRAP=1 into their client config
35
+ # has consented in advance. On that flag, a missing-SDK non-TTY launch
36
+ # bootstraps the venv exactly like the interactive consent path, but with ALL
37
+ # progress on STDERR (stdout stays clean for JSON-RPC: the client is already
38
+ # attached to it), then execs the server. LOKI_NO_INSTALL_OFFER=1 still wins
39
+ # (explicit no beats explicit yes).
31
40
  # * Opt-out: LOKI_NO_INSTALL_OFFER=1 -> never prompt, print manual command,
32
41
  # exit 2. --yes / LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM=true -> auto-accept.
42
+ # LOKI_MCP_AUTO_BOOTSTRAP=1 -> non-interactive written consent (see above).
33
43
  #
34
44
  # Self-containment: depends only on bash builtins + python3 on PATH. Defines
35
45
  # its own colors so it behaves identically whether sourced by autonomy/loki or
@@ -58,10 +68,20 @@ _ml_repo_root() {
58
68
  (cd "$self_dir/.." && pwd)
59
69
  }
60
70
 
71
+ # _ml_truthy <value>: true (0) when the value is a conventional affirmative
72
+ # spelling (1/true/yes/on/y), case-insensitive. Centralizes consent parsing so
73
+ # every knob accepts the same spellings rather than each hard-coding "1".
74
+ _ml_truthy() {
75
+ case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
76
+ 1|true|yes|on) return 0 ;;
77
+ *) return 1 ;;
78
+ esac
79
+ }
80
+
61
81
  # _ml_assume_yes: true when the user opted into unattended confirmation.
62
82
  _ml_assume_yes() {
63
- [ "${LOKI_ASSUME_YES:-}" = "1" ] && return 0
64
- [ "${LOKI_AUTO_CONFIRM:-}" = "true" ] && return 0
83
+ _ml_truthy "${LOKI_ASSUME_YES:-}" && return 0
84
+ _ml_truthy "${LOKI_AUTO_CONFIRM:-}" && return 0
65
85
  return 1
66
86
  }
67
87
 
@@ -109,10 +129,13 @@ _ml_sdk_importable() {
109
129
  # requirements.txt is shipped under the install root.
110
130
  _ml_print_manual() {
111
131
  local root="$1" venv="$2"
132
+ # Display-only quoting: single-quote the substituted paths so the printed
133
+ # commands copy-paste correctly even when the project root or venv path
134
+ # contains spaces. This is presentation only; nothing is executed here.
112
135
  printf 'Install the MCP server dependencies manually:\n' >&2
113
- printf ' python3 -m venv %s\n' "$venv" >&2
114
- printf ' %s/bin/pip install -r %s/mcp/requirements.txt\n' "$venv" "$root" >&2
115
- printf ' PYTHONPATH=%s %s/bin/python -m mcp.server\n' "$root" "$venv" >&2
136
+ printf " python3 -m venv '%s'\n" "$venv" >&2
137
+ printf " '%s/bin/pip' install -r '%s/mcp/requirements.txt'\n" "$venv" "$root" >&2
138
+ printf " PYTHONPATH='%s' '%s/bin/python' -m mcp.server\n" "$root" "$venv" >&2
116
139
  }
117
140
 
118
141
  _ml_help() {
@@ -135,27 +158,72 @@ Options:
135
158
  --help, -h Show this help and exit.
136
159
 
137
160
  Environment:
138
- LOKI_MCP_VENV=/abs/path Use a custom venv location instead of .loki/mcp-venv.
139
- LOKI_NO_INSTALL_OFFER=1 Never prompt to install; print the manual command.
140
- --yes / LOKI_ASSUME_YES=1 Auto-accept the dependency install.
161
+ LOKI_MCP_VENV=/abs/path Use a custom venv location instead of .loki/mcp-venv.
162
+ LOKI_NO_INSTALL_OFFER=1 Never prompt to install; print the manual command.
163
+ Wins over LOKI_MCP_AUTO_BOOTSTRAP (explicit no beats
164
+ explicit yes).
165
+ LOKI_MCP_AUTO_BOOTSTRAP=1 Written consent for non-interactive bootstrap. MCP
166
+ clients (Claude Desktop etc.) spawn the server over
167
+ piped stdio with no TTY; set this in your client
168
+ config to authorize the one-time venv bootstrap when
169
+ the SDK is missing. Progress goes to stderr only so
170
+ stdout stays clean for JSON-RPC. On a TTY it also
171
+ skips the consent prompt (consent already given).
172
+ Accepts 1/true/yes/on (case-insensitive).
173
+ --yes / LOKI_ASSUME_YES=1 Auto-accept the dependency install. --yes is a
174
+ launcher flag (equivalent to LOKI_ASSUME_YES=1); it
175
+ is consumed here and never forwarded to the server.
176
+ LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM accept
177
+ 1/true/yes/on (case-insensitive).
141
178
 
142
- Behavior in non-interactive / CI shells: never installs. Prints the manual
179
+ Argument handling: launcher flags (--help, --yes) are consumed here; every other
180
+ argument is forwarded verbatim to the server, which accepts --transport/--port.
181
+ A bare `--` ends launcher parsing so anything after it reaches the server as-is.
182
+
183
+ Behavior in non-interactive / CI shells: never installs UNLESS
184
+ LOKI_MCP_AUTO_BOOTSTRAP is set (1/true/yes/on). Without it, prints the manual
143
185
  install command to stderr and exits 2.
144
186
  EOF
145
187
  }
146
188
 
147
189
  # mcp_launch_main: dispatcher invoked by cmd_mcp() (autonomy/loki) or directly.
148
190
  mcp_launch_main() {
149
- # Parse only flags we own; everything else is forwarded to the server.
191
+ # Split argv into launcher-owned flags (consumed here) and server argv
192
+ # (forwarded verbatim to `python -m mcp.server`). The server's argparse only
193
+ # accepts --transport/--port/--check-sdk; forwarding a launcher flag like
194
+ # --yes would make it abort with exit 2, so launcher flags MUST be stripped.
195
+ # A bare `--` ends launcher parsing: everything after it is forwarded as-is
196
+ # (escape hatch for any future server flag that collides with a launcher one).
150
197
  local arg
198
+ local _ml_server_argv=()
199
+ local _ml_after_sep=0
151
200
  for arg in "$@"; do
201
+ if [ "$_ml_after_sep" -eq 1 ]; then
202
+ _ml_server_argv+=("$arg")
203
+ continue
204
+ fi
152
205
  case "$arg" in
206
+ --)
207
+ _ml_after_sep=1
208
+ ;;
153
209
  --help|-h|help)
154
210
  _ml_help
155
211
  return 0
156
212
  ;;
213
+ --yes)
214
+ # Launcher-owned: equivalent to LOKI_ASSUME_YES=1. Consumed here,
215
+ # never forwarded to the server.
216
+ LOKI_ASSUME_YES=1
217
+ ;;
218
+ *)
219
+ _ml_server_argv+=("$arg")
220
+ ;;
157
221
  esac
158
222
  done
223
+ # Replace the positional parameters with the filtered server argv so every
224
+ # downstream `exec ... "$@"` forwards only server-valid arguments. Safe
225
+ # empty-array expansion (bash 3.2 + set -u when no server args remain).
226
+ set -- ${_ml_server_argv[@]+"${_ml_server_argv[@]}"}
159
227
 
160
228
  local root
161
229
  root="$(_ml_repo_root)"
@@ -175,6 +243,17 @@ mcp_launch_main() {
175
243
  local venv="${LOKI_MCP_VENV:-$PWD/${LOKI_DIR:-.loki}/mcp-venv}"
176
244
  local venv_py="$venv/bin/python"
177
245
 
246
+ # Progress destination for the bootstrap. On an interactive TTY, progress goes
247
+ # to stdout (the user is watching a terminal). On the non-interactive
248
+ # auto-bootstrap path (LOKI_MCP_AUTO_BOOTSTRAP=1 over piped stdio), stdout is
249
+ # the JSON-RPC channel to the MCP client and MUST stay clean, so ALL progress
250
+ # (printfs AND the stdout of venv/pip) is routed to fd 2. Keyed on
251
+ # non-interactive, not on the flag: TTY+flag still prints to the terminal.
252
+ local out_fd=1
253
+ if _ml_non_interactive; then
254
+ out_fd=2
255
+ fi
256
+
178
257
  # 3. If the venv already has the SDK, use it directly. The server is launched
179
258
  # with PYTHONPATH=$root (NOT by cd-ing) so the user's cwd is preserved for
180
259
  # .loki resolution; see _ml_sdk_importable for why.
@@ -193,21 +272,40 @@ mcp_launch_main() {
193
272
  fi
194
273
 
195
274
  # 5. SDK missing. Decide whether we may bootstrap.
196
- if [ "${LOKI_NO_INSTALL_OFFER:-}" = "1" ]; then
275
+ # Precedence: LOKI_NO_INSTALL_OFFER (explicit no) wins over
276
+ # LOKI_MCP_AUTO_BOOTSTRAP (explicit yes).
277
+ if _ml_truthy "${LOKI_NO_INSTALL_OFFER:-}"; then
278
+ if _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}"; then
279
+ printf 'LOKI_NO_INSTALL_OFFER overrides LOKI_MCP_AUTO_BOOTSTRAP (explicit no beats explicit yes); not installing.\n' >&2
280
+ fi
197
281
  printf '%sMCP SDK not installed.%s\n' "$_ML_YELLOW" "$_ML_NC" >&2
198
282
  _ml_print_manual "$root" "$venv"
199
283
  return 2
200
284
  fi
201
285
 
286
+ # Track whether we reached the bootstrap via non-interactive written consent.
287
+ # When true, the consent prompt is skipped and all progress goes to fd 2.
288
+ local auto_consent=0
202
289
  if _ml_non_interactive; then
203
- printf '%sMCP SDK not installed%s and this is a non-interactive shell, so Loki will not install it automatically.\n' "$_ML_YELLOW" "$_ML_NC" >&2
204
- _ml_print_manual "$root" "$venv"
205
- return 2
290
+ if _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}"; then
291
+ # Written consent: bootstrap non-interactively, progress to stderr
292
+ # only (stdout is the client's JSON-RPC channel). Fall through to the
293
+ # bootstrap below with auto-accept.
294
+ printf '%sMCP SDK not installed.%s LOKI_MCP_AUTO_BOOTSTRAP set: bootstrapping the project venv non-interactively (progress on stderr; stdout reserved for JSON-RPC).\n' "$_ML_YELLOW" "$_ML_NC" >&2
295
+ auto_consent=1
296
+ else
297
+ printf '%sMCP SDK not installed%s and this is a non-interactive shell, so Loki will not install it automatically. Set LOKI_MCP_AUTO_BOOTSTRAP=1 (also accepts true/yes) to authorize this for MCP clients.\n' "$_ML_YELLOW" "$_ML_NC" >&2
298
+ _ml_print_manual "$root" "$venv"
299
+ return 2
300
+ fi
206
301
  fi
207
302
 
208
- # 6. Interactive TTY: offer the consent-gated bootstrap.
303
+ # 6. Offer the consent-gated bootstrap. Consent is already given when:
304
+ # - we arrived via the non-interactive auto-bootstrap path (auto_consent), or
305
+ # - LOKI_MCP_AUTO_BOOTSTRAP=1 is set on a TTY (explicit yes skips the prompt), or
306
+ # - --yes / LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM.
209
307
  local answer=""
210
- if _ml_assume_yes; then
308
+ if [ "$auto_consent" -eq 1 ] || _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}" || _ml_assume_yes; then
211
309
  answer="y"
212
310
  else
213
311
  printf '\n'
@@ -239,8 +337,10 @@ mcp_launch_main() {
239
337
  _ml_print_manual "$root" "$venv"
240
338
  return 2
241
339
  fi
242
- printf 'Creating virtualenv (%s) ...\n' "$venv"
243
- if ! "$base_py" -m venv "$venv"; then
340
+ printf 'Creating virtualenv (%s) ...\n' "$venv" >&"$out_fd"
341
+ # Route venv's stdout to out_fd (stderr on the auto path) so the JSON-RPC
342
+ # channel stays clean; its stderr is left as-is for real diagnostics.
343
+ if ! "$base_py" -m venv "$venv" >&"$out_fd"; then
244
344
  printf '%sFailed to create virtualenv at %s.%s\n' "$_ML_RED" "$venv" "$_ML_NC" >&2
245
345
  _ml_print_manual "$root" "$venv"
246
346
  return 2
@@ -253,9 +353,11 @@ mcp_launch_main() {
253
353
  printf '%smcp/requirements.txt not found at %s.%s\n' "$_ML_RED" "$req" "$_ML_NC" >&2
254
354
  return 2
255
355
  fi
256
- printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req"
356
+ printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req" >&"$out_fd"
257
357
  local code=0
258
- "$venv/bin/pip" install -r "$req" || code=$?
358
+ # pip writes its progress to stdout; route it to out_fd (stderr on the auto
359
+ # path) so the JSON-RPC channel stays clean. pip's stderr is left as-is.
360
+ "$venv/bin/pip" install -r "$req" >&"$out_fd" || code=$?
259
361
  if [ "$code" -ne 0 ]; then
260
362
  printf '%sInstall failed (pip exited %s).%s You can retry manually:\n' "$_ML_RED" "$code" "$_ML_NC" >&2
261
363
  _ml_print_manual "$root" "$venv"
package/autonomy/run.sh CHANGED
@@ -1809,9 +1809,31 @@ get_provider_tier_param() {
1809
1809
  case "${PROVIDER_NAME:-claude}" in
1810
1810
  claude)
1811
1811
  case "$tier" in
1812
- planning) echo "${PROVIDER_MODEL_PLANNING:-opus}" ;;
1812
+ planning)
1813
+ # Evidence-based routing (scoped): the official model-config
1814
+ # docs explicitly name "architecture decisions" and
1815
+ # "root-cause investigations" as where Fable 5's extra
1816
+ # investigation and self-verification pay off. So the
1817
+ # planning/architecture tier may opt in to Fable via
1818
+ # LOKI_FABLE_ARCHITECT=1. Default OFF because Fable is 2x
1819
+ # Opus per token; reserve it for the REASON/architecture
1820
+ # iterations the user explicitly wants. An explicit
1821
+ # PROVIDER_MODEL_PLANNING still wins (operator override).
1822
+ if [ -n "${PROVIDER_MODEL_PLANNING:-}" ]; then
1823
+ echo "${PROVIDER_MODEL_PLANNING}"
1824
+ elif [ "${LOKI_FABLE_ARCHITECT:-0}" = "1" ]; then
1825
+ echo "fable"
1826
+ else
1827
+ echo "opus"
1828
+ fi
1829
+ ;;
1813
1830
  development) echo "${PROVIDER_MODEL_DEVELOPMENT:-opus}" ;;
1814
1831
  fast) echo "${PROVIDER_MODEL_FAST:-sonnet}" ;;
1832
+ # Honor the fable lever here too: without this arm an
1833
+ # unsourced-claude.sh environment (this static fallback) would
1834
+ # silently downgrade a fable-pinned tier to sonnet via the `*`
1835
+ # default. Matches resolve_model_for_tier's explicit fable) arm.
1836
+ fable) echo "fable" ;;
1815
1837
  *) echo "sonnet" ;;
1816
1838
  esac
1817
1839
  ;;
@@ -3803,6 +3825,8 @@ _write_pricing_json() {
3803
3825
  "updated": "${updated}",
3804
3826
  "source": "static",
3805
3827
  "models": {
3828
+ "fable": {"input": 10.00, "output": 50.00, "label": "Fable 5 (top, 2x Opus)", "provider": "claude"},
3829
+ "claude-fable-5": {"input": 10.00, "output": 50.00, "label": "Fable 5 (top, 2x Opus)", "provider": "claude"},
3806
3830
  "opus": {"input": 5.00, "output": 25.00, "label": "Opus (latest)", "provider": "claude"},
3807
3831
  "sonnet": {"input": 3.00, "output": 15.00, "label": "Sonnet (latest)", "provider": "claude"},
3808
3832
  "haiku": {"input": 1.00, "output": 5.00, "label": "Haiku (latest)", "provider": "claude"},
@@ -7648,6 +7672,21 @@ BUILD_PROMPT
7648
7672
  prompt_text=$(cat "$review_prompt_file")
7649
7673
  case "${PROVIDER_NAME:-claude}" in
7650
7674
  claude)
7675
+ # SECURITY-REVIEW MODEL GUARD (evidence-based routing, item 4b):
7676
+ # Reviewers deliberately do NOT pass --model, so they run on
7677
+ # the account default model and are NEVER routed to Fable by a
7678
+ # mid-flight model override or LOKI_FABLE_ARCHITECT (those only
7679
+ # rewrite the iteration's tier_param, not this dispatch). This
7680
+ # must stay true. The official model-config docs CONTRADICT
7681
+ # routing security review to Fable: Fable's safety classifiers
7682
+ # refuse cybersecurity content, and in non-interactive (-p)
7683
+ # mode a flagged request ends the turn with stop_reason
7684
+ # "refusal" instead of a transparent Opus re-run. A refused
7685
+ # security reviewer would return no VERDICT and break the
7686
+ # unanimous-council gate. Defensive-cyber capability lives in
7687
+ # Mythos 5 (Project Glasswing), not Fable. If a future change
7688
+ # adds --model here, the security-sentinel reviewer must be
7689
+ # pinned to opus, never fable.
7651
7690
  claude --dangerously-skip-permissions -p "$prompt_text" \
7652
7691
  --output-format text > "$review_output" 2>/dev/null
7653
7692
  ;;
@@ -9058,6 +9097,8 @@ check_budget_limit() {
9058
9097
  import json, glob
9059
9098
  total = 0.0
9060
9099
  pricing = {
9100
+ 'fable': {'input': 10.00, 'output': 50.00},
9101
+ 'claude-fable-5': {'input': 10.00, 'output': 50.00},
9061
9102
  'opus': {'input': 5.00, 'output': 25.00},
9062
9103
  'sonnet': {'input': 3.00, 'output': 15.00},
9063
9104
  'haiku': {'input': 1.00, 'output': 5.00},
@@ -12052,6 +12093,23 @@ run_autonomous() {
12052
12093
  _LOKI_RUN_START_SHA="$(cat "$_start_sha_file" 2>/dev/null || echo "")"
12053
12094
  export _LOKI_RUN_START_SHA
12054
12095
 
12096
+ # Session-scope the mid-flight model override (model-honesty fix). The
12097
+ # override file (.loki/state/model-override) is a LIVE-RUN control: the
12098
+ # dashboard UI and docs state it "applies to the current run". A leftover
12099
+ # file from a previous run must NOT silently pin every future `loki start`
12100
+ # to that model (and to its cost). So clear it once at the start of a FRESH
12101
+ # run (ITERATION_COUNT==0). A genuine resume (ITERATION_COUNT>0) and any
12102
+ # mid-flight switch made at iteration>0 are preserved, because the clear is
12103
+ # guarded on the fresh-run condition only.
12104
+ if [ "${ITERATION_COUNT:-0}" -eq 0 ] && [ -f ".loki/state/model-override" ]; then
12105
+ local _stale_override
12106
+ _stale_override="$(cat .loki/state/model-override 2>/dev/null | tr -d '[:space:]')"
12107
+ rm -f ".loki/state/model-override" 2>/dev/null || true
12108
+ if [ -n "$_stale_override" ]; then
12109
+ log_info "Cleared leftover model override ('$_stale_override') at session start; the override applies to the current run only."
12110
+ fi
12111
+ fi
12112
+
12055
12113
  # Trust-metrics instrumentation marker: record one run_start event per
12056
12114
  # fresh run so the trust-metrics denominator counts ONLY instrumented runs.
12057
12115
  # This is what lets the aggregator distinguish "0 blocks measured" from
@@ -12274,10 +12332,39 @@ except Exception as exc:
12274
12332
  opus) CURRENT_TIER="planning" ;;
12275
12333
  sonnet) CURRENT_TIER="development" ;;
12276
12334
  haiku) CURRENT_TIER="fast" ;;
12335
+ fable) CURRENT_TIER="fable" ;;
12277
12336
  planning|development|fast) CURRENT_TIER="${LOKI_SESSION_MODEL}" ;;
12278
12337
  *) CURRENT_TIER="${LOKI_SESSION_MODEL}" ;;
12279
12338
  esac
12280
12339
  fi
12340
+ # Architect opt-in (LOKI_FABLE_ARCHITECT=1): route ONLY the first
12341
+ # iteration (the architecture/REASON pass) to Fable, then fall back to
12342
+ # the session tier for all later iterations. This is the honest
12343
+ # implementation of "fable for architecture only": run.sh is the only
12344
+ # scope that has ITERATION_COUNT, so the decision lives here (not in the
12345
+ # stateless provider resolver). An EXPLICIT planning-model override still
12346
+ # wins, and the LOKI_MAX_TIER ceiling clamps fable down via the resolver.
12347
+ # Default OFF (Fable is 2x Opus). Without this scoping, a session pinned
12348
+ # to opus would route EVERY iteration to fable.
12349
+ #
12350
+ # NOTE on the index: ITERATION_COUNT is incremented at the TOP of the
12351
+ # loop (see "((ITERATION_COUNT++))" above), so the FIRST in-loop pass
12352
+ # has ITERATION_COUNT==1, not 0. The guard matches 1 so the architecture
12353
+ # iteration actually fires (a -eq 0 guard here would be a silent no-op,
12354
+ # the exact bug this fix removes). The estimator models this same first
12355
+ # iteration as its 0-indexed range() i==0, so quote and run agree.
12356
+ #
12357
+ # PRECEDENCE: a mid-flight model override (.loki/state/model-override,
12358
+ # applied later in this iteration body) WINS over this architect pin.
12359
+ # Deliberate: a live user action in the dashboard outranks an env
12360
+ # opt-in set at launch. The override is still clamped by LOKI_MAX_TIER.
12361
+ if [ "${ITERATION_COUNT:-0}" -eq 1 ] \
12362
+ && [ "${LOKI_FABLE_ARCHITECT:-0}" = "1" ] \
12363
+ && [ -z "${LOKI_CLAUDE_MODEL_PLANNING:-}" ] \
12364
+ && [ -z "${LOKI_MODEL_PLANNING:-}" ]; then
12365
+ CURRENT_TIER="fable"
12366
+ log_info "LOKI_FABLE_ARCHITECT=1: routing the first (architecture) iteration to fable; later iterations use the session tier"
12367
+ fi
12281
12368
  # Export LOKI_CURRENT_TIER so provider helper functions
12282
12369
  # can resolve the correct model.
12283
12370
  # Without this, LOKI_CURRENT_TIER is always empty and defaults to "planning".
@@ -12285,6 +12372,66 @@ except Exception as exc:
12285
12372
  export LOKI_CURRENT_TIER
12286
12373
  local rarv_phase=$(get_rarv_phase_name "$ITERATION_COUNT")
12287
12374
  local tier_param=$(get_provider_tier_param "$CURRENT_TIER")
12375
+ # Mid-flight model override: the dashboard (POST /api/session/model) or a
12376
+ # CLI user may rewrite .loki/state/model-override between iterations to
12377
+ # change the model a live run uses. Read it here, after tier_param is
12378
+ # resolved and before the claude argv is built (--model "$tier_param" is
12379
+ # assembled below), so the override flows through effort/budget/fallback
12380
+ # with no other change. Each iteration spawns a fresh `claude -p`, so the
12381
+ # switch takes effect at THIS iteration boundary and never mid-invocation
12382
+ # (claude -p fixes the model per call). Clearing/emptying the file reverts
12383
+ # to the tier mapping. The file is fed straight into --model, so only an
12384
+ # allowlisted alias is honored; invalid content is ignored with one warn.
12385
+ # The override applies ONLY to the claude provider; other providers map
12386
+ # tier_param to effort/model strings and have no fable equivalent.
12387
+ if [ "${PROVIDER_NAME:-claude}" = "claude" ] && [ -s ".loki/state/model-override" ]; then
12388
+ local _loki_override_file _loki_override_alias
12389
+ _loki_override_file="$(cat .loki/state/model-override 2>/dev/null)"
12390
+ # Canonical normalization shared with the dashboard + estimator
12391
+ # (trim + lowercase + exact allowlist). "fab le" and other non-exact
12392
+ # values normalize to empty and are rejected, so all three readers
12393
+ # agree on what the file means. Falls back to a local case only if
12394
+ # the provider helper is somehow not in scope.
12395
+ if type loki_normalize_model_alias >/dev/null 2>&1; then
12396
+ _loki_override_alias="$(loki_normalize_model_alias "$_loki_override_file")"
12397
+ else
12398
+ # Fallback only if the provider helper is not sourced. Mirror the
12399
+ # canonical rule EXACTLY: trim ends + lowercase + exact allowlist,
12400
+ # so interior whitespace ("fab le") is REJECTED here too (do NOT
12401
+ # use `tr -d [:space:]`, which would collapse it into a false
12402
+ # accept and re-introduce the normalization divergence).
12403
+ _loki_override_alias=""
12404
+ local _loki_ov_trim="$_loki_override_file"
12405
+ _loki_ov_trim="${_loki_ov_trim#"${_loki_ov_trim%%[![:space:]]*}"}"
12406
+ _loki_ov_trim="${_loki_ov_trim%"${_loki_ov_trim##*[![:space:]]}"}"
12407
+ _loki_ov_trim="$(printf '%s' "$_loki_ov_trim" | tr '[:upper:]' '[:lower:]')"
12408
+ case "$_loki_ov_trim" in
12409
+ haiku|sonnet|opus|fable) _loki_override_alias="$_loki_ov_trim" ;;
12410
+ esac
12411
+ fi
12412
+ if [ -n "$_loki_override_alias" ]; then
12413
+ # Apply the SAME LOKI_MAX_TIER ceiling the tier resolver uses, so
12414
+ # a mid-flight override cannot silently bypass the operator's cost
12415
+ # cap. Clamp via the shared helper when available.
12416
+ local _loki_override_effective="$_loki_override_alias"
12417
+ if type loki_apply_max_tier_clamp >/dev/null 2>&1; then
12418
+ _loki_override_effective="$(loki_apply_max_tier_clamp "$_loki_override_alias" "$_loki_override_alias")"
12419
+ fi
12420
+ if [ "$_loki_override_effective" != "$_loki_override_alias" ]; then
12421
+ tier_param="$_loki_override_effective"
12422
+ log_warn "model override '$_loki_override_alias' exceeds LOKI_MAX_TIER=${LOKI_MAX_TIER}; clamped to $tier_param (applies this iteration)"
12423
+ echo "=== Model override: $_loki_override_alias clamped to $tier_param by LOKI_MAX_TIER=${LOKI_MAX_TIER} (applies this iteration $ITERATION_COUNT) ===" | tee -a "$log_file" "$agent_log"
12424
+ else
12425
+ tier_param="$_loki_override_effective"
12426
+ log_info "model override: $tier_param (applies this iteration)"
12427
+ echo "=== Model override: $tier_param (applies this iteration $ITERATION_COUNT) ===" | tee -a "$log_file" "$agent_log"
12428
+ fi
12429
+ elif [ -z "$(printf '%s' "$_loki_override_file" | tr -d '[:space:]')" ]; then
12430
+ : # empty file means no override; fall back to tier mapping
12431
+ else
12432
+ log_warn "Ignoring invalid model override '$_loki_override_file' (allowed: haiku, sonnet, opus, fable); using tier $tier_param"
12433
+ fi
12434
+ fi
12288
12435
  echo "=== RARV Phase: $rarv_phase, Tier: $CURRENT_TIER ($tier_param) ===" | tee -a "$log_file" "$agent_log"
12289
12436
  log_info "RARV Phase: $rarv_phase -> Tier: $CURRENT_TIER ($tier_param)"
12290
12437
 
package/bin/loki CHANGED
@@ -112,6 +112,25 @@ if ! command -v bun &>/dev/null; then
112
112
  exec "$BASH_CLI" "$@"
113
113
  fi
114
114
 
115
+ # CLI consolidation (Phase A): `trust detail` is the grouped form of the
116
+ # trust-metrics breakdown. The Bun `trust` handler only knows the trajectory
117
+ # view (it rejects unknown args), and Phase A moves no handler logic, so the
118
+ # `detail` subcommand lives only in bash (cmd_trust -> cmd_trust_metrics).
119
+ # Force the bash route whenever `detail` appears as a trust arg (flag-anywhere:
120
+ # `trust detail`, `trust --json detail`, `trust detail --json` all resolve
121
+ # identically to the bash `trust-metrics` breakdown). Without this, only the
122
+ # exact `trust detail` order routed to bash and `trust --json detail` hit the
123
+ # Bun handler, which rejected `detail` to stderr while bash rejects to stdout --
124
+ # divergent error channels for the same malformed input. Bare `trust` /
125
+ # `trust --json` (no `detail`) stay on the Bun-native trajectory path above.
126
+ if [ "${1:-}" = "trust" ]; then
127
+ for _trust_arg in "${@:2}"; do
128
+ if [ "$_trust_arg" = "detail" ]; then
129
+ exec "$BASH_CLI" "$@"
130
+ fi
131
+ done
132
+ fi
133
+
115
134
  # Commands ported in Phase 2 -- route to Bun. Everything else goes to bash.
116
135
  # Two-token routes (provider show/list, memory list/index) match on the first
117
136
  # token only; the Bun dispatcher handles subcommand routing internally.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.30.0"
10
+ __version__ = "7.31.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: