loki-mode 7.30.0 → 7.32.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
 
@@ -93,15 +113,22 @@ _ml_python() {
93
113
  # `--check-sdk` probe, which runs the exact loader the server uses and exits 0
94
114
  # only when FastMCP loaded.
95
115
  #
96
- # Critical: we set PYTHONPATH to the install root and DO NOT cd into it, so the
97
- # probe exercises the SAME module resolution as the real launch (which preserves
98
- # the user's cwd). The redirect of stdin from /dev/null is insurance: if the
99
- # pip SDK's own `mcp.server` were ever reached, its stub starts a stdio receive
100
- # loop; the EOF makes it exit instead of hanging.
116
+ # Critical: we probe with the SAME FILE-EXEC form the launch uses
117
+ # (`"$root/mcp/server.py"`, NOT `-m mcp.server`), with PYTHONPATH set to the
118
+ # install root and WITHOUT cd-ing into it, so the probe exercises byte-identical
119
+ # module resolution to the real launch (which preserves the user's cwd). This
120
+ # matters because `-m mcp.server` puts the user's cwd at sys.path[0] AHEAD of
121
+ # PYTHONPATH=$root, so a cwd that happens to contain a regular `mcp/` python
122
+ # package would shadow Loki's server during the probe (false SDK-missing) while
123
+ # the file-exec launch -- immune to cwd shadowing -- would succeed. Probing by
124
+ # file path keeps probe and launch resolving the IDENTICAL module. The redirect
125
+ # of stdin from /dev/null is insurance: if the pip SDK's own `mcp.server` were
126
+ # ever reached, its stub starts a stdio receive loop; the EOF makes it exit
127
+ # instead of hanging.
101
128
  _ml_sdk_importable() {
102
129
  local py="$1" root="$2"
103
130
  PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" \
104
- "$py" -m mcp.server --check-sdk </dev/null >/dev/null 2>&1
131
+ "$py" "$root/mcp/server.py" --check-sdk </dev/null >/dev/null 2>&1
105
132
  }
106
133
 
107
134
  # _ml_print_manual <root> <venv>: print the honest manual install commands.
@@ -109,10 +136,13 @@ _ml_sdk_importable() {
109
136
  # requirements.txt is shipped under the install root.
110
137
  _ml_print_manual() {
111
138
  local root="$1" venv="$2"
139
+ # Display-only quoting: single-quote the substituted paths so the printed
140
+ # commands copy-paste correctly even when the project root or venv path
141
+ # contains spaces. This is presentation only; nothing is executed here.
112
142
  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
143
+ printf " python3 -m venv '%s'\n" "$venv" >&2
144
+ printf " '%s/bin/pip' install -r '%s/mcp/requirements.txt'\n" "$venv" "$root" >&2
145
+ printf " PYTHONPATH='%s' '%s/bin/python' '%s/mcp/server.py'\n" "$root" "$venv" "$root" >&2
116
146
  }
117
147
 
118
148
  _ml_help() {
@@ -135,27 +165,73 @@ Options:
135
165
  --help, -h Show this help and exit.
136
166
 
137
167
  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.
168
+ LOKI_MCP_VENV=/abs/path Use a custom venv location instead of .loki/mcp-venv.
169
+ LOKI_NO_INSTALL_OFFER=1 Never prompt to install; print the manual command.
170
+ Wins over LOKI_MCP_AUTO_BOOTSTRAP (explicit no beats
171
+ explicit yes).
172
+ LOKI_MCP_AUTO_BOOTSTRAP=1 Written consent for non-interactive bootstrap. MCP
173
+ clients (Claude Desktop etc.) spawn the server over
174
+ piped stdio with no TTY; set this in your client
175
+ config to authorize the one-time venv bootstrap when
176
+ the SDK is missing. Progress goes to stderr only so
177
+ stdout stays clean for JSON-RPC. On a TTY it also
178
+ skips the consent prompt (consent already given).
179
+ Accepts 1/true/yes/on (case-insensitive).
180
+ --yes / LOKI_ASSUME_YES=1 Auto-accept the dependency install. --yes is a
181
+ launcher flag (equivalent to LOKI_ASSUME_YES=1); it
182
+ is consumed here and never forwarded to the server.
183
+ LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM accept
184
+ 1/true/yes/on (case-insensitive).
141
185
 
142
- Behavior in non-interactive / CI shells: never installs. Prints the manual
186
+ Argument handling: launcher flags (--help, --yes) are consumed here; every other
187
+ argument is forwarded verbatim to the server, which accepts --transport/--port.
188
+ A bare `--` ends launcher parsing so anything after it reaches the server as-is.
189
+
190
+ Behavior in non-interactive / CI shells: never installs UNLESS
191
+ LOKI_MCP_AUTO_BOOTSTRAP is set (1/true/yes/on). Without it, prints the manual
143
192
  install command to stderr and exits 2.
144
193
  EOF
145
194
  }
146
195
 
147
196
  # mcp_launch_main: dispatcher invoked by cmd_mcp() (autonomy/loki) or directly.
148
197
  mcp_launch_main() {
149
- # Parse only flags we own; everything else is forwarded to the server.
198
+ # Split argv into launcher-owned flags (consumed here) and server argv
199
+ # (forwarded verbatim to the file-exec launch `python "$root/mcp/server.py"`).
200
+ # The server's argparse only
201
+ # accepts --transport/--port/--check-sdk; forwarding a launcher flag like
202
+ # --yes would make it abort with exit 2, so launcher flags MUST be stripped.
203
+ # A bare `--` ends launcher parsing: everything after it is forwarded as-is
204
+ # (escape hatch for any future server flag that collides with a launcher one).
150
205
  local arg
206
+ local _ml_server_argv=()
207
+ local _ml_after_sep=0
151
208
  for arg in "$@"; do
209
+ if [ "$_ml_after_sep" -eq 1 ]; then
210
+ _ml_server_argv+=("$arg")
211
+ continue
212
+ fi
152
213
  case "$arg" in
214
+ --)
215
+ _ml_after_sep=1
216
+ ;;
153
217
  --help|-h|help)
154
218
  _ml_help
155
219
  return 0
156
220
  ;;
221
+ --yes)
222
+ # Launcher-owned: equivalent to LOKI_ASSUME_YES=1. Consumed here,
223
+ # never forwarded to the server.
224
+ LOKI_ASSUME_YES=1
225
+ ;;
226
+ *)
227
+ _ml_server_argv+=("$arg")
228
+ ;;
157
229
  esac
158
230
  done
231
+ # Replace the positional parameters with the filtered server argv so every
232
+ # downstream `exec ... "$@"` forwards only server-valid arguments. Safe
233
+ # empty-array expansion (bash 3.2 + set -u when no server args remain).
234
+ set -- ${_ml_server_argv[@]+"${_ml_server_argv[@]}"}
159
235
 
160
236
  local root
161
237
  root="$(_ml_repo_root)"
@@ -175,39 +251,72 @@ mcp_launch_main() {
175
251
  local venv="${LOKI_MCP_VENV:-$PWD/${LOKI_DIR:-.loki}/mcp-venv}"
176
252
  local venv_py="$venv/bin/python"
177
253
 
254
+ # Progress destination for the bootstrap. On an interactive TTY, progress goes
255
+ # to stdout (the user is watching a terminal). On the non-interactive
256
+ # auto-bootstrap path (LOKI_MCP_AUTO_BOOTSTRAP=1 over piped stdio), stdout is
257
+ # the JSON-RPC channel to the MCP client and MUST stay clean, so ALL progress
258
+ # (printfs AND the stdout of venv/pip) is routed to fd 2. Keyed on
259
+ # non-interactive, not on the flag: TTY+flag still prints to the terminal.
260
+ local out_fd=1
261
+ if _ml_non_interactive; then
262
+ out_fd=2
263
+ fi
264
+
178
265
  # 3. If the venv already has the SDK, use it directly. The server is launched
179
- # with PYTHONPATH=$root (NOT by cd-ing) so the user's cwd is preserved for
266
+ # by FILE PATH ($root/mcp/server.py) rather than `-m mcp.server`, with
267
+ # PYTHONPATH=$root (NOT by cd-ing) so the user's cwd is preserved for
180
268
  # .loki resolution; see _ml_sdk_importable for why.
181
- # Known narrow residual: if the user's cwd itself contains a Python
182
- # package literally named mcp/ with a server submodule, python -m puts
183
- # the cwd ahead of PYTHONPATH and that package wins. Essentially never
184
- # true for real projects; documented rather than fought.
269
+ # Running the file directly avoids the runpy RuntimeWarning that `-m`
270
+ # emits (the local mcp/ package is imported during SDK-namespace setup
271
+ # before runpy executes mcp.server). server.py uses only absolute imports
272
+ # (e.g. `from mcp._sdk_loader import ...`), which resolve via PYTHONPATH=$root
273
+ # under file execution. File-exec also removes the old narrow cwd-shadowing
274
+ # residual: an explicit path can never be shadowed by a cwd `mcp/` package.
185
275
  if [ -x "$venv_py" ] && _ml_sdk_importable "$venv_py" "$root"; then
186
- exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
276
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" "$root/mcp/server.py" "$@"
187
277
  fi
188
278
 
189
279
  # 4. If the BASE python already has the SDK (e.g. user pip-installed it),
190
280
  # use it -- no venv needed.
191
281
  if _ml_sdk_importable "$base_py" "$root"; then
192
- exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$base_py" -m mcp.server "$@"
282
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$base_py" "$root/mcp/server.py" "$@"
193
283
  fi
194
284
 
195
285
  # 5. SDK missing. Decide whether we may bootstrap.
196
- if [ "${LOKI_NO_INSTALL_OFFER:-}" = "1" ]; then
286
+ # Precedence: LOKI_NO_INSTALL_OFFER (explicit no) wins over
287
+ # LOKI_MCP_AUTO_BOOTSTRAP (explicit yes).
288
+ if _ml_truthy "${LOKI_NO_INSTALL_OFFER:-}"; then
289
+ if _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}"; then
290
+ printf 'LOKI_NO_INSTALL_OFFER overrides LOKI_MCP_AUTO_BOOTSTRAP (explicit no beats explicit yes); not installing.\n' >&2
291
+ fi
197
292
  printf '%sMCP SDK not installed.%s\n' "$_ML_YELLOW" "$_ML_NC" >&2
198
293
  _ml_print_manual "$root" "$venv"
199
294
  return 2
200
295
  fi
201
296
 
297
+ # Track whether we reached the bootstrap via non-interactive written consent.
298
+ # When true, the consent prompt is skipped and all progress goes to fd 2.
299
+ local auto_consent=0
202
300
  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
301
+ if _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}"; then
302
+ # Written consent: bootstrap non-interactively, progress to stderr
303
+ # only (stdout is the client's JSON-RPC channel). Fall through to the
304
+ # bootstrap below with auto-accept.
305
+ 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
306
+ auto_consent=1
307
+ else
308
+ 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
309
+ _ml_print_manual "$root" "$venv"
310
+ return 2
311
+ fi
206
312
  fi
207
313
 
208
- # 6. Interactive TTY: offer the consent-gated bootstrap.
314
+ # 6. Offer the consent-gated bootstrap. Consent is already given when:
315
+ # - we arrived via the non-interactive auto-bootstrap path (auto_consent), or
316
+ # - LOKI_MCP_AUTO_BOOTSTRAP=1 is set on a TTY (explicit yes skips the prompt), or
317
+ # - --yes / LOKI_ASSUME_YES / LOKI_AUTO_CONFIRM.
209
318
  local answer=""
210
- if _ml_assume_yes; then
319
+ if [ "$auto_consent" -eq 1 ] || _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}" || _ml_assume_yes; then
211
320
  answer="y"
212
321
  else
213
322
  printf '\n'
@@ -239,8 +348,10 @@ mcp_launch_main() {
239
348
  _ml_print_manual "$root" "$venv"
240
349
  return 2
241
350
  fi
242
- printf 'Creating virtualenv (%s) ...\n' "$venv"
243
- if ! "$base_py" -m venv "$venv"; then
351
+ printf 'Creating virtualenv (%s) ...\n' "$venv" >&"$out_fd"
352
+ # Route venv's stdout to out_fd (stderr on the auto path) so the JSON-RPC
353
+ # channel stays clean; its stderr is left as-is for real diagnostics.
354
+ if ! "$base_py" -m venv "$venv" >&"$out_fd"; then
244
355
  printf '%sFailed to create virtualenv at %s.%s\n' "$_ML_RED" "$venv" "$_ML_NC" >&2
245
356
  _ml_print_manual "$root" "$venv"
246
357
  return 2
@@ -253,9 +364,11 @@ mcp_launch_main() {
253
364
  printf '%smcp/requirements.txt not found at %s.%s\n' "$_ML_RED" "$req" "$_ML_NC" >&2
254
365
  return 2
255
366
  fi
256
- printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req"
367
+ printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req" >&"$out_fd"
257
368
  local code=0
258
- "$venv/bin/pip" install -r "$req" || code=$?
369
+ # pip writes its progress to stdout; route it to out_fd (stderr on the auto
370
+ # path) so the JSON-RPC channel stays clean. pip's stderr is left as-is.
371
+ "$venv/bin/pip" install -r "$req" >&"$out_fd" || code=$?
259
372
  if [ "$code" -ne 0 ]; then
260
373
  printf '%sInstall failed (pip exited %s).%s You can retry manually:\n' "$_ML_RED" "$code" "$_ML_NC" >&2
261
374
  _ml_print_manual "$root" "$venv"
@@ -272,7 +385,7 @@ mcp_launch_main() {
272
385
  return 2
273
386
  fi
274
387
  printf "%sMCP dependencies ready. Launching server over stdio ...%s\n" "$_ML_BOLD" "$_ML_NC" >&2
275
- exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
388
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" "$root/mcp/server.py" "$@"
276
389
  }
277
390
 
278
391
  # Executed directly (tests, manual): run the dispatcher.
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
@@ -12270,14 +12328,60 @@ except Exception as exc:
12270
12328
  # helpers (which expect tier names) resolve correctly. Unknown
12271
12329
  # model strings are passed through as-is; provider loaders fall
12272
12330
  # back to a sane default.
12273
- case "${LOKI_SESSION_MODEL:-sonnet}" in
12331
+ #
12332
+ # Normalize case + surrounding whitespace BEFORE the match so
12333
+ # 'OPUS' and ' opus ' resolve identically to 'opus'. We do NOT use
12334
+ # loki_normalize_model_alias here: that helper is the narrow
12335
+ # OVERRIDE-file allowlist (haiku|sonnet|opus|fable) and would strip
12336
+ # the documented tier-name pins (planning|development|fast) to
12337
+ # empty, collapsing them onto the default tier. The session pin
12338
+ # legitimately accepts tier names (skills/model-selection.md), and
12339
+ # the estimator + dashboard mirror this exact tier route, so the
12340
+ # canonical session-pin rule is trim+lowercase WITHOUT the alias
12341
+ # allowlist. Interior whitespace is preserved (so 'fab le' stays a
12342
+ # junk value that falls through the '*' default arm), matching the
12343
+ # estimator/dashboard ports.
12344
+ local _session_pin="${LOKI_SESSION_MODEL:-sonnet}"
12345
+ _session_pin="${_session_pin#"${_session_pin%%[![:space:]]*}"}"
12346
+ _session_pin="${_session_pin%"${_session_pin##*[![:space:]]}"}"
12347
+ _session_pin="$(printf '%s' "$_session_pin" | tr '[:upper:]' '[:lower:]')"
12348
+ case "$_session_pin" in
12274
12349
  opus) CURRENT_TIER="planning" ;;
12275
12350
  sonnet) CURRENT_TIER="development" ;;
12276
12351
  haiku) CURRENT_TIER="fast" ;;
12277
- planning|development|fast) CURRENT_TIER="${LOKI_SESSION_MODEL}" ;;
12278
- *) CURRENT_TIER="${LOKI_SESSION_MODEL}" ;;
12352
+ fable) CURRENT_TIER="fable" ;;
12353
+ planning|development|fast) CURRENT_TIER="$_session_pin" ;;
12354
+ *) CURRENT_TIER="$_session_pin" ;;
12279
12355
  esac
12280
12356
  fi
12357
+ # Architect opt-in (LOKI_FABLE_ARCHITECT=1): route ONLY the first
12358
+ # iteration (the architecture/REASON pass) to Fable, then fall back to
12359
+ # the session tier for all later iterations. This is the honest
12360
+ # implementation of "fable for architecture only": run.sh is the only
12361
+ # scope that has ITERATION_COUNT, so the decision lives here (not in the
12362
+ # stateless provider resolver). An EXPLICIT planning-model override still
12363
+ # wins, and the LOKI_MAX_TIER ceiling clamps fable down via the resolver.
12364
+ # Default OFF (Fable is 2x Opus). Without this scoping, a session pinned
12365
+ # to opus would route EVERY iteration to fable.
12366
+ #
12367
+ # NOTE on the index: ITERATION_COUNT is incremented at the TOP of the
12368
+ # loop (see "((ITERATION_COUNT++))" above), so the FIRST in-loop pass
12369
+ # has ITERATION_COUNT==1, not 0. The guard matches 1 so the architecture
12370
+ # iteration actually fires (a -eq 0 guard here would be a silent no-op,
12371
+ # the exact bug this fix removes). The estimator models this same first
12372
+ # iteration as its 0-indexed range() i==0, so quote and run agree.
12373
+ #
12374
+ # PRECEDENCE: a mid-flight model override (.loki/state/model-override,
12375
+ # applied later in this iteration body) WINS over this architect pin.
12376
+ # Deliberate: a live user action in the dashboard outranks an env
12377
+ # opt-in set at launch. The override is still clamped by LOKI_MAX_TIER.
12378
+ if [ "${ITERATION_COUNT:-0}" -eq 1 ] \
12379
+ && [ "${LOKI_FABLE_ARCHITECT:-0}" = "1" ] \
12380
+ && [ -z "${LOKI_CLAUDE_MODEL_PLANNING:-}" ] \
12381
+ && [ -z "${LOKI_MODEL_PLANNING:-}" ]; then
12382
+ CURRENT_TIER="fable"
12383
+ log_info "LOKI_FABLE_ARCHITECT=1: routing the first (architecture) iteration to fable; later iterations use the session tier"
12384
+ fi
12281
12385
  # Export LOKI_CURRENT_TIER so provider helper functions
12282
12386
  # can resolve the correct model.
12283
12387
  # Without this, LOKI_CURRENT_TIER is always empty and defaults to "planning".
@@ -12285,6 +12389,66 @@ except Exception as exc:
12285
12389
  export LOKI_CURRENT_TIER
12286
12390
  local rarv_phase=$(get_rarv_phase_name "$ITERATION_COUNT")
12287
12391
  local tier_param=$(get_provider_tier_param "$CURRENT_TIER")
12392
+ # Mid-flight model override: the dashboard (POST /api/session/model) or a
12393
+ # CLI user may rewrite .loki/state/model-override between iterations to
12394
+ # change the model a live run uses. Read it here, after tier_param is
12395
+ # resolved and before the claude argv is built (--model "$tier_param" is
12396
+ # assembled below), so the override flows through effort/budget/fallback
12397
+ # with no other change. Each iteration spawns a fresh `claude -p`, so the
12398
+ # switch takes effect at THIS iteration boundary and never mid-invocation
12399
+ # (claude -p fixes the model per call). Clearing/emptying the file reverts
12400
+ # to the tier mapping. The file is fed straight into --model, so only an
12401
+ # allowlisted alias is honored; invalid content is ignored with one warn.
12402
+ # The override applies ONLY to the claude provider; other providers map
12403
+ # tier_param to effort/model strings and have no fable equivalent.
12404
+ if [ "${PROVIDER_NAME:-claude}" = "claude" ] && [ -s ".loki/state/model-override" ]; then
12405
+ local _loki_override_file _loki_override_alias
12406
+ _loki_override_file="$(cat .loki/state/model-override 2>/dev/null)"
12407
+ # Canonical normalization shared with the dashboard + estimator
12408
+ # (trim + lowercase + exact allowlist). "fab le" and other non-exact
12409
+ # values normalize to empty and are rejected, so all three readers
12410
+ # agree on what the file means. Falls back to a local case only if
12411
+ # the provider helper is somehow not in scope.
12412
+ if type loki_normalize_model_alias >/dev/null 2>&1; then
12413
+ _loki_override_alias="$(loki_normalize_model_alias "$_loki_override_file")"
12414
+ else
12415
+ # Fallback only if the provider helper is not sourced. Mirror the
12416
+ # canonical rule EXACTLY: trim ends + lowercase + exact allowlist,
12417
+ # so interior whitespace ("fab le") is REJECTED here too (do NOT
12418
+ # use `tr -d [:space:]`, which would collapse it into a false
12419
+ # accept and re-introduce the normalization divergence).
12420
+ _loki_override_alias=""
12421
+ local _loki_ov_trim="$_loki_override_file"
12422
+ _loki_ov_trim="${_loki_ov_trim#"${_loki_ov_trim%%[![:space:]]*}"}"
12423
+ _loki_ov_trim="${_loki_ov_trim%"${_loki_ov_trim##*[![:space:]]}"}"
12424
+ _loki_ov_trim="$(printf '%s' "$_loki_ov_trim" | tr '[:upper:]' '[:lower:]')"
12425
+ case "$_loki_ov_trim" in
12426
+ haiku|sonnet|opus|fable) _loki_override_alias="$_loki_ov_trim" ;;
12427
+ esac
12428
+ fi
12429
+ if [ -n "$_loki_override_alias" ]; then
12430
+ # Apply the SAME LOKI_MAX_TIER ceiling the tier resolver uses, so
12431
+ # a mid-flight override cannot silently bypass the operator's cost
12432
+ # cap. Clamp via the shared helper when available.
12433
+ local _loki_override_effective="$_loki_override_alias"
12434
+ if type loki_apply_max_tier_clamp >/dev/null 2>&1; then
12435
+ _loki_override_effective="$(loki_apply_max_tier_clamp "$_loki_override_alias" "$_loki_override_alias")"
12436
+ fi
12437
+ if [ "$_loki_override_effective" != "$_loki_override_alias" ]; then
12438
+ tier_param="$_loki_override_effective"
12439
+ log_warn "model override '$_loki_override_alias' exceeds LOKI_MAX_TIER=${LOKI_MAX_TIER}; clamped to $tier_param (applies this iteration)"
12440
+ 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"
12441
+ else
12442
+ tier_param="$_loki_override_effective"
12443
+ log_info "model override: $tier_param (applies this iteration)"
12444
+ echo "=== Model override: $tier_param (applies this iteration $ITERATION_COUNT) ===" | tee -a "$log_file" "$agent_log"
12445
+ fi
12446
+ elif [ -z "$(printf '%s' "$_loki_override_file" | tr -d '[:space:]')" ]; then
12447
+ : # empty file means no override; fall back to tier mapping
12448
+ else
12449
+ log_warn "Ignoring invalid model override '$_loki_override_file' (allowed: haiku, sonnet, opus, fable); using tier $tier_param"
12450
+ fi
12451
+ fi
12288
12452
  echo "=== RARV Phase: $rarv_phase, Tier: $CURRENT_TIER ($tier_param) ===" | tee -a "$log_file" "$agent_log"
12289
12453
  log_info "RARV Phase: $rarv_phase -> Tier: $CURRENT_TIER ($tier_param)"
12290
12454
 
package/bin/loki CHANGED
@@ -65,6 +65,21 @@ elif [ "${BUN_FROM_SOURCE:-0}" = "1" ] || [ "${BUN_FROM_SOURCE:-}" = "true" ]; t
65
65
  fi
66
66
  elif [ -f "$REPO_ROOT/loki-ts/dist/loki.js" ]; then
67
67
  BUN_CLI="$REPO_ROOT/loki-ts/dist/loki.js"
68
+ # Stale-dist freshness guard. dist/loki.js is gitignored (loki-ts/.gitignore)
69
+ # and rebuilt by package.json's prepack and the release Docker build, so on
70
+ # released channels (npm/Docker/brew) src/cli.ts is absent and this guard is
71
+ # a no-op -- dist is always current there. On a DEV machine / worktree the
72
+ # gitignored dist can predate the current dispatcher: e.g. a new shim->Bun
73
+ # route (`report kpis`) added in src that the old dist bundle does not know,
74
+ # which would make the canonical form fail with "Unknown command" while a
75
+ # deprecated alias the old dist still knows silently works -- the exact
76
+ # deprecation inversion the report-kpis route exists to prevent. So when src
77
+ # exists AND is newer than dist (`-nt`, available on bash 3.2), prefer the src
78
+ # form so dev runs never execute a stale dispatcher. Released channels have no
79
+ # src, so they keep using dist unchanged.
80
+ if [ -f "$REPO_ROOT/loki-ts/src/cli.ts" ] && [ "$REPO_ROOT/loki-ts/src/cli.ts" -nt "$REPO_ROOT/loki-ts/dist/loki.js" ]; then
81
+ BUN_CLI="$REPO_ROOT/loki-ts/src/cli.ts"
82
+ fi
68
83
  elif [ -f "$REPO_ROOT/loki-ts/src/cli.ts" ]; then
69
84
  BUN_CLI="$REPO_ROOT/loki-ts/src/cli.ts"
70
85
  else
@@ -112,6 +127,62 @@ if ! command -v bun &>/dev/null; then
112
127
  exec "$BASH_CLI" "$@"
113
128
  fi
114
129
 
130
+ # CLI consolidation (Phase A): `trust detail` is the grouped form of the
131
+ # trust-metrics breakdown. The Bun `trust` handler only knows the trajectory
132
+ # view (it rejects unknown args), and Phase A moves no handler logic, so the
133
+ # `detail` subcommand lives only in bash (cmd_trust -> cmd_trust_metrics).
134
+ # Force the bash route whenever `detail` appears as a trust arg (flag-anywhere:
135
+ # `trust detail`, `trust --json detail`, `trust detail --json` all resolve
136
+ # identically to the bash `trust-metrics` breakdown). Without this, only the
137
+ # exact `trust detail` order routed to bash and `trust --json detail` hit the
138
+ # Bun handler, which rejected `detail` to stderr while bash rejects to stdout --
139
+ # divergent error channels for the same malformed input. Bare `trust` /
140
+ # `trust --json` (no `detail`) stay on the Bun-native trajectory path above.
141
+ if [ "${1:-}" = "trust" ]; then
142
+ for _trust_arg in "${@:2}"; do
143
+ if [ "$_trust_arg" = "detail" ]; then
144
+ exec "$BASH_CLI" "$@"
145
+ fi
146
+ done
147
+ fi
148
+
149
+ # CLI consolidation (Phase B): `report kpis` is the canonical form of the
150
+ # Bun-only KPI snapshot. The `report` noun is otherwise bash-owned (every other
151
+ # subcommand -- session/metrics/cost/export/share/dogfood -- routes to bash
152
+ # cmd_report), so it is NOT in the Bun allowlist below. But kpis has no bash
153
+ # implementation (it reuses the canonical cost arithmetic in runner/budget.ts),
154
+ # so the canonical `report kpis` must reach the Bun handler -- otherwise the
155
+ # canonical form would print the honest "requires Bun" message on a Bun machine
156
+ # while the deprecated `kpis` alias actually worked, inverting the deprecation.
157
+ # Route `report kpis` to Bun when `kpis` is the report SUBCOMMAND, i.e. the FIRST
158
+ # non-flag token after `report`. This satisfies `report kpis`, `report kpis
159
+ # --json`, and `report --json kpis` (the flag-anywhere orderings the trust-detail
160
+ # precedent established), while NOT hijacking `kpis` when it appears as a
161
+ # positional VALUE of a different report subcommand. `report export json kpis`
162
+ # (kpis = the export output filename) must keep working exactly as on main
163
+ # (v7.31.0): exit 0, file `kpis` created. So we scan past leading flags, take the
164
+ # first real token, and only route to Bun if it is literally `kpis`. Fire the
165
+ # same cli_command telemetry the Bun case-arm below fires (command=report), so a
166
+ # Bun-routed `report kpis` is not invisible to usage analytics (the v7.8.2 parity
167
+ # the case-arm comment documents). Backgrounded, FD-detached, opt-out honored by
168
+ # loki_telemetry itself.
169
+ if [ "${1:-}" = "report" ]; then
170
+ _report_first_sub=""
171
+ for _report_arg in "${@:2}"; do
172
+ case "$_report_arg" in
173
+ -*) continue ;;
174
+ *) _report_first_sub="$_report_arg"; break ;;
175
+ esac
176
+ done
177
+ if [ "$_report_first_sub" = "kpis" ]; then
178
+ if command -v curl &>/dev/null && [ -f "$REPO_ROOT/autonomy/telemetry.sh" ]; then
179
+ ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
180
+ disown 2>/dev/null || true
181
+ fi
182
+ exec bun "$BUN_CLI" "$@"
183
+ fi
184
+ fi
185
+
115
186
  # Commands ported in Phase 2 -- route to Bun. Everything else goes to bash.
116
187
  # Two-token routes (provider show/list, memory list/index) match on the first
117
188
  # 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.32.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: