loki-mode 7.29.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.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env bash
2
+ # mcp-launch.sh -- launch the Loki Mode MCP server, bootstrapping its Python
3
+ # dependencies on first run (task 562).
4
+ #
5
+ # Why this exists: a fresh `npm install -g loki-mode` ships mcp/server.py and
6
+ # mcp/requirements.txt but installs NO Python packages and exposes only the
7
+ # `loki` bin. So `python3 -m mcp.server` exits because the MCP SDK is absent.
8
+ # `loki mcp` closes that gap: it checks for python3 + the MCP SDK, and when the
9
+ # SDK is missing it offers a consent-gated bootstrap into a project-local
10
+ # virtualenv (.loki/mcp-venv), then execs the server over stdio using THAT
11
+ # venv's python so the SDK is actually importable.
12
+ #
13
+ # Design (least-invasive, honest):
14
+ # * Venv location: <user-cwd>/.loki/mcp-venv (the project Loki is invoked in,
15
+ # NOT the install root). Project-local, no global site-packages pollution,
16
+ # no sudo, no curl-pipe-bash, no root-owned writes under a global install.
17
+ # Removing the project's .loki fully uninstalls. Override with
18
+ # LOKI_MCP_VENV=/abs/path. Honors LOKI_DIR (defaults to .loki).
19
+ # * The server is launched with PYTHONPATH set to the install root (NOT by
20
+ # cd-ing into it) so the user's cwd is preserved: mcp/server.py resolves the
21
+ # project .loki from os.getcwd(). Without PYTHONPATH, `import mcp` from an
22
+ # arbitrary cwd resolves to the pip MCP SDK's own `mcp` package (zero Loki
23
+ # tools); PYTHONPATH puts the install root first so the LOCAL mcp/server.py
24
+ # wins, while server.py's namespace juggle still hands the real SDK its own
25
+ # `mcp.*` subtree.
26
+ # * The ONLY command run on the user's behalf is, after explicit consent:
27
+ # <venv>/bin/pip install -r mcp/requirements.txt
28
+ # The exact command is printed before it runs.
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).
40
+ # * Opt-out: LOKI_NO_INSTALL_OFFER=1 -> never prompt, print manual command,
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).
43
+ #
44
+ # Self-containment: depends only on bash builtins + python3 on PATH. Defines
45
+ # its own colors so it behaves identically whether sourced by autonomy/loki or
46
+ # run standalone.
47
+
48
+ # Guard against double-source.
49
+ if [ -n "${_LOKI_MCP_LAUNCH_SOURCED:-}" ]; then
50
+ return 0 2>/dev/null || true
51
+ fi
52
+ _LOKI_MCP_LAUNCH_SOURCED=1
53
+
54
+ # --- Self-contained colors (honor NO_COLOR) --------------------------------
55
+ if [ -n "${NO_COLOR:-}" ] || [ ! -t 1 ]; then
56
+ _ML_RED=''; _ML_YELLOW=''; _ML_BOLD=''; _ML_NC=''
57
+ else
58
+ _ML_RED=$'\033[0;31m'
59
+ _ML_YELLOW=$'\033[1;33m'
60
+ _ML_BOLD=$'\033[1m'
61
+ _ML_NC=$'\033[0m'
62
+ fi
63
+
64
+ # Repo root = parent of the directory holding this script (autonomy/..).
65
+ _ml_repo_root() {
66
+ local self_dir
67
+ self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
68
+ (cd "$self_dir/.." && pwd)
69
+ }
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
+
81
+ # _ml_assume_yes: true when the user opted into unattended confirmation.
82
+ _ml_assume_yes() {
83
+ _ml_truthy "${LOKI_ASSUME_YES:-}" && return 0
84
+ _ml_truthy "${LOKI_AUTO_CONFIRM:-}" && return 0
85
+ return 1
86
+ }
87
+
88
+ # _ml_non_interactive: true when we must NEVER prompt (non-TTY or CI).
89
+ _ml_non_interactive() {
90
+ [ ! -t 0 ] && return 0
91
+ [ ! -t 1 ] && return 0
92
+ [ -n "${CI:-}" ] && return 0
93
+ return 1
94
+ }
95
+
96
+ # _ml_python: echo the best base python3 for creating the venv, or empty.
97
+ _ml_python() {
98
+ local p
99
+ for p in python3 python3.12 python3.11; do
100
+ if command -v "$p" >/dev/null 2>&1; then
101
+ printf '%s' "$p"
102
+ return 0
103
+ fi
104
+ done
105
+ return 1
106
+ }
107
+
108
+ # _ml_sdk_importable <python> <root>: true (0) only if the real MCP SDK's
109
+ # FastMCP can actually be CONSTRUCTED -- not merely that the SDK files exist on
110
+ # disk. A file-exists check is a false positive: under the local-vs-SDK `mcp`
111
+ # namespace collision the package-dir FastMCP can be present yet fail to import
112
+ # (`No module named 'mcp.types'`). We delegate to mcp/server.py's own
113
+ # `--check-sdk` probe, which runs the exact loader the server uses and exits 0
114
+ # only when FastMCP loaded.
115
+ #
116
+ # Critical: we set PYTHONPATH to the install root and DO NOT cd into it, so the
117
+ # probe exercises the SAME module resolution as the real launch (which preserves
118
+ # the user's cwd). The redirect of stdin from /dev/null is insurance: if the
119
+ # pip SDK's own `mcp.server` were ever reached, its stub starts a stdio receive
120
+ # loop; the EOF makes it exit instead of hanging.
121
+ _ml_sdk_importable() {
122
+ local py="$1" root="$2"
123
+ PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" \
124
+ "$py" -m mcp.server --check-sdk </dev/null >/dev/null 2>&1
125
+ }
126
+
127
+ # _ml_print_manual <root> <venv>: print the honest manual install commands.
128
+ # The venv lives in the user's project (.loki/mcp-venv by default), while
129
+ # requirements.txt is shipped under the install root.
130
+ _ml_print_manual() {
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.
135
+ printf 'Install the MCP server dependencies manually:\n' >&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
139
+ }
140
+
141
+ _ml_help() {
142
+ cat <<'EOF'
143
+ Loki Mode -- launch the MCP (Model Context Protocol) server
144
+
145
+ Usage: loki mcp [--transport stdio|http] [--port N] [--help]
146
+
147
+ Starts the Loki Mode MCP server so MCP-aware clients (Claude Code, IDEs)
148
+ can call Loki's tools (memory, task queue, code search, build management).
149
+
150
+ On first run, if the Python MCP SDK is not installed, Loki offers to create
151
+ a project-local virtualenv at .loki/mcp-venv and install mcp/requirements.txt
152
+ into it (with your consent). The exact pip command is printed before it runs.
153
+ It then launches the server using that venv's python.
154
+
155
+ Options:
156
+ --transport stdio|http Transport to use (default: stdio).
157
+ --port N Port for http transport (default: 8421).
158
+ --help, -h Show this help and exit.
159
+
160
+ Environment:
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).
178
+
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
185
+ install command to stderr and exits 2.
186
+ EOF
187
+ }
188
+
189
+ # mcp_launch_main: dispatcher invoked by cmd_mcp() (autonomy/loki) or directly.
190
+ mcp_launch_main() {
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).
197
+ local arg
198
+ local _ml_server_argv=()
199
+ local _ml_after_sep=0
200
+ for arg in "$@"; do
201
+ if [ "$_ml_after_sep" -eq 1 ]; then
202
+ _ml_server_argv+=("$arg")
203
+ continue
204
+ fi
205
+ case "$arg" in
206
+ --)
207
+ _ml_after_sep=1
208
+ ;;
209
+ --help|-h|help)
210
+ _ml_help
211
+ return 0
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
+ ;;
221
+ esac
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[@]}"}
227
+
228
+ local root
229
+ root="$(_ml_repo_root)"
230
+
231
+ # 1. python3 presence.
232
+ local base_py
233
+ if ! base_py="$(_ml_python)"; then
234
+ printf '%sNo python3 found on PATH.%s The MCP server needs Python 3.\n' "$_ML_RED" "$_ML_NC" >&2
235
+ printf 'Install Python 3 (https://www.python.org/downloads), then re-run: loki mcp\n' >&2
236
+ return 2
237
+ fi
238
+
239
+ # 2. venv location. Lives in the USER'S project (their cwd), NOT the install
240
+ # root: $root may be a root-owned global npm prefix where we must never
241
+ # write. LOKI_DIR (default .loki) keeps this consistent with every other
242
+ # .loki artifact; LOKI_MCP_VENV overrides outright.
243
+ local venv="${LOKI_MCP_VENV:-$PWD/${LOKI_DIR:-.loki}/mcp-venv}"
244
+ local venv_py="$venv/bin/python"
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
+
257
+ # 3. If the venv already has the SDK, use it directly. The server is launched
258
+ # with PYTHONPATH=$root (NOT by cd-ing) so the user's cwd is preserved for
259
+ # .loki resolution; see _ml_sdk_importable for why.
260
+ # Known narrow residual: if the user's cwd itself contains a Python
261
+ # package literally named mcp/ with a server submodule, python -m puts
262
+ # the cwd ahead of PYTHONPATH and that package wins. Essentially never
263
+ # true for real projects; documented rather than fought.
264
+ if [ -x "$venv_py" ] && _ml_sdk_importable "$venv_py" "$root"; then
265
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
266
+ fi
267
+
268
+ # 4. If the BASE python already has the SDK (e.g. user pip-installed it),
269
+ # use it -- no venv needed.
270
+ if _ml_sdk_importable "$base_py" "$root"; then
271
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$base_py" -m mcp.server "$@"
272
+ fi
273
+
274
+ # 5. SDK missing. Decide whether we may bootstrap.
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
281
+ printf '%sMCP SDK not installed.%s\n' "$_ML_YELLOW" "$_ML_NC" >&2
282
+ _ml_print_manual "$root" "$venv"
283
+ return 2
284
+ fi
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
289
+ if _ml_non_interactive; then
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
301
+ fi
302
+
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.
307
+ local answer=""
308
+ if [ "$auto_consent" -eq 1 ] || _ml_truthy "${LOKI_MCP_AUTO_BOOTSTRAP:-}" || _ml_assume_yes; then
309
+ answer="y"
310
+ else
311
+ printf '\n'
312
+ printf 'The MCP server needs Python dependencies that are not installed.\n'
313
+ printf 'Loki can create a project-local virtualenv and install them:\n'
314
+ printf ' python3 -m venv %s\n' "$venv"
315
+ printf ' %s/bin/pip install -r %s/mcp/requirements.txt\n' "$venv" "$root"
316
+ printf '\n'
317
+ printf 'Nothing is installed globally and no sudo is used. Proceed? [Y/n] '
318
+ read -r answer || answer="n"
319
+ fi
320
+
321
+ case "$answer" in
322
+ ""|y|Y|yes|YES) ;;
323
+ *)
324
+ printf 'Skipped.\n'
325
+ _ml_print_manual "$root" "$venv"
326
+ return 2
327
+ ;;
328
+ esac
329
+
330
+ # 7. Create the venv if needed. Ensure the parent .loki exists in the user's
331
+ # project first (never write under the install root).
332
+ if [ ! -x "$venv_py" ]; then
333
+ local venv_parent
334
+ venv_parent="$(dirname "$venv")"
335
+ if [ ! -d "$venv_parent" ] && ! mkdir -p "$venv_parent"; then
336
+ printf '%sCannot create %s (no write access).%s\n' "$_ML_RED" "$venv_parent" "$_ML_NC" >&2
337
+ _ml_print_manual "$root" "$venv"
338
+ return 2
339
+ fi
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
344
+ printf '%sFailed to create virtualenv at %s.%s\n' "$_ML_RED" "$venv" "$_ML_NC" >&2
345
+ _ml_print_manual "$root" "$venv"
346
+ return 2
347
+ fi
348
+ fi
349
+
350
+ # 8. Install requirements into the venv.
351
+ local req="$root/mcp/requirements.txt"
352
+ if [ ! -f "$req" ]; then
353
+ printf '%smcp/requirements.txt not found at %s.%s\n' "$_ML_RED" "$req" "$_ML_NC" >&2
354
+ return 2
355
+ fi
356
+ printf 'Installing MCP dependencies (%s/bin/pip install -r %s) ...\n' "$venv" "$req" >&"$out_fd"
357
+ local code=0
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=$?
361
+ if [ "$code" -ne 0 ]; then
362
+ printf '%sInstall failed (pip exited %s).%s You can retry manually:\n' "$_ML_RED" "$code" "$_ML_NC" >&2
363
+ _ml_print_manual "$root" "$venv"
364
+ return 2
365
+ fi
366
+
367
+ # 9. Verify, then exec the server using the venv python (critical: the
368
+ # site-packages walk in server.py only finds the SDK if we run the
369
+ # venv's interpreter, not the ambient python3). PYTHONPATH=$root keeps the
370
+ # user's cwd intact for .loki resolution; see _ml_sdk_importable.
371
+ if ! _ml_sdk_importable "$venv_py" "$root"; then
372
+ printf '%sDependencies installed but the MCP SDK still is not importable.%s\n' "$_ML_RED" "$_ML_NC" >&2
373
+ _ml_print_manual "$root" "$venv"
374
+ return 2
375
+ fi
376
+ printf "%sMCP dependencies ready. Launching server over stdio ...%s\n" "$_ML_BOLD" "$_ML_NC" >&2
377
+ exec env PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" "$venv_py" -m mcp.server "$@"
378
+ }
379
+
380
+ # Executed directly (tests, manual): run the dispatcher.
381
+ # When sourced by autonomy/loki, this block does not run.
382
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
383
+ mcp_launch_main "$@"
384
+ fi
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.29.0"
10
+ __version__ = "7.31.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: