mlx: break Qwen repetition loop; add /expert; jerbuild migration WIP

ober

5bd60cfd52483045b31418f4c16961d98738fd06

diff --git a/.jerbuild b/.jerbuild
new file mode 100644
index 0000000..95b2ea1
--- /dev/null
+++ b/.jerbuild
@@ -0,0 +1,37 @@
+;; .jerbuild — build a standalone jcode binary with `jerbuild build`.
+;;
+;; jerbuild bundles Chez Scheme + the jerboa stdlib, so NO jerboa source
+;; checkout or external Chez is required. The only external input is the native
+;; Rust lib (libjerboa_native), built from vendor/jerboa-native-rs with just the
+;; features jcode needs (tls + crypto) — see the `native-rs` Makefile target.
+;; Platform link flags (frameworks, -lsqlite3, -lc++, ...) are passed by the
+;; Makefile via `jerbuild build --os-libs "..."` (per-uname).
+;;
+;; Static FFI needs NO source patching: chez-sqlite.sls falls through to (void)
+;; and tui-ffi.ss honors JERBOA_STATIC=1 (set by support/jcode-main.c), so the
+;; linked-in shim symbols resolve via the registered FFI symbols below.
+
+(entry  "main-binary.ss")
+(output "jcode")
+
+;; do-binary-build auto-appends the bundled stdlib; list only project + vendor.
+(libdirs "lib"
+         "vendor/chez-sqlite/src"
+         "vendor/jerboa-websearch/src")
+
+;; Symbols statically registered via Sforeign_symbol (sqlite shim, termbox TUI
+;; shim, jerboa-native tls+crypto). The custom main.c #includes + calls them.
+(ffi-symbols "support/ffi-symbols.list")
+
+;; Stock jerbuild main.c + setenv(JERBOA_STATIC=1); see the file's header.
+(main-c "support/jcode-main.c")
+
+;; C shims compiled into the binary. sqlite3.h is provided by the macOS SDK /
+;; system; add an -I here if a host needs an explicit path.
+(extra-sources
+  "vendor/chez-sqlite/chez_sqlite_shim.c"
+  ("src/jcode/ui/jcode_tui_shim.c" cflags: "-DTB_OPT_ATTR_W=32 -Ivendor/termbox2"))
+
+;; jerboa-native (Rust): tls + crypto only (no duckdb/pcap/postgres). jerbuild
+;; runs cargo and links the resulting libjerboa_native.a.
+(rust-crates ("vendor/jerboa-native-rs/Cargo.toml" features: "tls,crypto"))
diff --git a/Makefile b/Makefile
index b863c38..d41af52 100644
--- a/Makefile
+++ b/Makefile
@@ -1,35 +1,69 @@
+# jerbuild is the self-contained build tool: it bundles Chez Scheme + the
+# jerboa stdlib under ~/.cache/jerbuild/<sha>/, so the host dev loop and the
+# native `binary` need NO jerboa source checkout and NO separately-built Chez.
+JERBUILD ?= jerbuild
+
+# Bundled stdlib path (Chez + jerboa libs live under $(JH)/lib). Resolved by
+# asking the jerbuild binary where it unpacked its bundle.
+JH := $(shell $(JERBUILD) --jerboa-home 2>/dev/null)
+ifeq ($(JH),)
+$(error jerbuild not found on PATH (or '$(JERBUILD) --jerboa-home' failed). Install jerbuild, or set JERBUILD=/path/to/jerbuild)
+endif
+
+# JERBOA_HOME / SCHEME are used ONLY by the cross-compile targets
+# (linux*/freebsd*/android) and the legacy `binary-legacy` fallback — the host
+# dev loop and `make binary` no longer touch them.
 JERBOA_HOME ?= $(HOME)/mine/jerboa
-SCHEME     ?= $(JERBOA_HOME)/.chez/bin/scheme
-LIBDIRS    = --libdirs $(JERBOA_HOME)/lib:./lib:vendor/chez-sqlite/src:vendor/jerboa-websearch/src
-JERBUILD   = $(SCHEME) --libdirs $(JERBOA_HOME)/lib --script $(JERBOA_HOME)/jerbuild.ss
+SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
 FREEBSD_AMD64_CC ?= $(JERBOA_HOME)/support/cross-cc-freebsd-amd64
 
-# Fail early with a clear pointer if the jerboa-local Chez build is missing.
-ifeq ($(wildcard $(SCHEME)),)
-$(error Chez Scheme not found at $(SCHEME). Run 'make chez' in $(JERBOA_HOME) (or set SCHEME=... to override).)
-endif
+LIBDIRS := --libdirs ./lib:vendor/chez-sqlite/src:vendor/jerboa-websearch/src:$(JH)/lib
+JEXEC   := $(JERBUILD) exec $(LIBDIRS)
 
-# Library paths for FFI shared objects (macOS: dylib, Linux: so)
+# Library paths for FFI shared objects (macOS: dylib, Linux: so). The native
+# Rust lib now lives in ./lib (dropped by the native-rs target), matching the
+# (std crypto native-rust) loader's CWD-relative "lib/" fallback.
 SQLITE_LIB_DIR := $(shell brew --prefix sqlite 2>/dev/null)/lib
 SHIM_DIR       := $(CURDIR)/vendor/chez-sqlite
 TUI_SHIM_DIR   := $(CURDIR)/vendor/termbox2
-NATIVE_LIB_DIR := $(JERBOA_HOME)/lib
+NATIVE_LIB_DIR := $(CURDIR)/lib
 LDPATH         := $(SHIM_DIR):$(TUI_SHIM_DIR):$(SQLITE_LIB_DIR):$(NATIVE_LIB_DIR)
 
-.PHONY: all help build gen run test test-providers clean repl binary install tui-shim run-tui jcode-musl linux linux-amd64 linux-arm64 jcode-linux-amd64 linux-docker linux-check linux-local docker test-linux test-linux-amd64 purge-stale sqlite-shim android android-clean vendor-deps vendor-clean
+# Native Rust lib (vendored crate, built with cargo). Only the features jcode
+# uses — tls + crypto; no duckdb/pcap/postgres.
+NATIVE_DIR      := vendor/jerboa-native-rs
+NATIVE_FEATURES ?= tls,crypto
+NATIVE_A        := $(NATIVE_DIR)/target/release/libjerboa_native.a
+NATIVE_DYLIB    := $(NATIVE_DIR)/target/release/libjerboa_native.dylib
+NATIVE_SO       := $(NATIVE_DIR)/target/release/libjerboa_native.so
+
+# Per-OS link flags handed to `jerbuild build --os-libs`. jerbuild appends
+# these after the Chez kernel + our archives when linking the binary.
+UNAME_S := $(shell uname -s)
+ifeq ($(UNAME_S),Darwin)
+JCODE_OS_LIBS := -lm -lpthread -lncurses -liconv -lc++ -L$(SQLITE_LIB_DIR) -lsqlite3 -framework Security -framework CoreFoundation
+else ifeq ($(UNAME_S),FreeBSD)
+JCODE_OS_LIBS := -lm -lpthread -lncurses -L/usr/local/lib -liconv -lsqlite3
+else
+JCODE_OS_LIBS := -lm -ldl -lpthread -luuid -lncurses -lsqlite3 -lstdc++
+endif
+
+.PHONY: all help build gen run test test-providers clean repl binary binary-legacy install tui-shim run-tui native-rs jcode-musl linux linux-amd64 linux-arm64 jcode-linux-amd64 linux-docker linux-check linux-local docker test-linux test-linux-amd64 purge-stale sqlite-shim android android-clean vendor-deps vendor-clean
 
 all: help
 
 help:
 	@echo "Available targets:"
-	@echo "  build        Compile src/ → lib/ and pre-compile imports"
+	@echo "  build        Compile src/ → lib/ and pre-compile imports (jerbuild)"
 	@echo "  test         Run test suite (test/run.ss)"
 	@echo "  test-providers  Live 'say hello' smoke test per configured provider"
 	@echo "  run          Start interactive agent REPL"
 	@echo "  run-tui      Start TUI mode"
 	@echo "  repl         Open a bare Scheme REPL with project libdirs"
-	@echo "  gen          Run jerbuild + rebuild sqlite/TUI shims"
-	@echo "  binary       Build native binary (macOS)"
+	@echo "  gen          Transpile src/ → lib/ + rebuild sqlite shim (jerbuild)"
+	@echo "  native-rs    Build vendored libjerboa_native (cargo, tls+crypto)"
+	@echo "  binary       Build native binary via 'jerbuild build' (.jerbuild)"
+	@echo "  binary-legacy  Fallback binary via build-binary.ss (needs jerboa checkout)"
 	@echo "  install      Install binary to ~/.local/bin"
 	@echo "  linux        Build Linux x86_64 static binary (cross-compile from this host)"
 	@echo "  linux-amd64  Cross-compile static jcode-linux-amd64 (default)"
@@ -86,31 +120,34 @@ sqlite-shim: vendor/chez-sqlite
 	    vendor/chez-sqlite/chez_sqlite_shim.c -lsqlite3; \
 	fi
 
-# ── libjerboa_native.so (Rust FFI) ──────────────────────────────────────────
+# ── libjerboa_native (Rust FFI) ─────────────────────────────────────────────
 # secrets.ss + std/net/request + std/regex etc. all bind FFI symbols from
-# libjerboa_native at library load time, so the .so must exist in
-# $(NATIVE_LIB_DIR) (== $(JERBOA_HOME)/lib) before scheme starts.  Without
-# this, `make build` dies with "no entry for jerboa_last_error" the moment
-# any library importing (std crypto native-rust) is visited.
+# libjerboa_native at library load time, so the shared lib must exist in
+# ./lib before scheme starts. Without it, `make build` dies with
+# "no entry for jerboa_last_error" the moment any library importing
+# (std crypto native-rust) is visited.
 #
-# Defer to jerboa's own `make native` so we don't need to know about the
-# cargo target dir or how the .so gets ad-hoc-signed on macOS.
-NATIVE_SO    := $(NATIVE_LIB_DIR)/libjerboa_native.so
-NATIVE_DYLIB := $(NATIVE_LIB_DIR)/libjerboa_native.dylib
-native-so:
-	@if [ ! -f "$(NATIVE_SO)" ] && [ ! -f "$(NATIVE_DYLIB)" ]; then \
-	  echo "=== libjerboa_native missing — building via $(JERBOA_HOME) ==="; \
-	  $(MAKE) -C $(JERBOA_HOME) native; \
+# We vendor the crate (sparse-clone from the jerboa monorepo) and build it
+# with cargo using ONLY the features jcode needs (tls + crypto) — no
+# dependency on a ~/mine/jerboa checkout. The same crate + features feed the
+# static `binary` build via (rust-crates ...) in .jerbuild.
+native-rs: $(NATIVE_DIR)
+	@command -v cargo >/dev/null 2>&1 || { \
+	  echo "ERROR: cargo not found on PATH. Install rustup from rustup.rs"; exit 1; }
+	@if [ ! -f "$(NATIVE_A)" ] || \
+	    [ -n "$$(find $(NATIVE_DIR)/src -name '*.rs' -newer $(NATIVE_A) 2>/dev/null)" ]; then \
+	  echo "=== Building libjerboa_native (features: $(NATIVE_FEATURES)) ==="; \
+	  ( cd $(NATIVE_DIR) && cargo build --release --features $(NATIVE_FEATURES) ); \
 	fi
-	@# On macOS, `make native` produces only .dylib but the jerboa stdlib
-	@# loaders (std crypto native-rust, std regex-native, std native) only
-	@# try the .so extension. Symlink so visit-time load-shared-object
-	@# succeeds. Without this, freshly recompiling those libraries (e.g.
-	@# after `make purge-stale` or a cross-build) dies with
-	@# "no entry for jerboa_last_error".
-	@if [ ! -f "$(NATIVE_SO)" ] && [ -f "$(NATIVE_DYLIB)" ]; then \
-	  echo "=== symlinking libjerboa_native.so -> libjerboa_native.dylib ==="; \
-	  ln -sf libjerboa_native.dylib "$(NATIVE_SO)"; \
+	@mkdir -p lib
+	@# The dev loop loads a shared object; the binary links the .a (jerbuild
+	@# builds that itself via rust-crates). Drop the dylib/so into ./lib and,
+	@# on macOS, symlink the .so name the loader tries first.
+	@if [ -f "$(NATIVE_DYLIB)" ]; then \
+	  cp -f "$(NATIVE_DYLIB)" lib/libjerboa_native.dylib; \
+	  ln -sf libjerboa_native.dylib lib/libjerboa_native.so; \
+	elif [ -f "$(NATIVE_SO)" ]; then \
+	  cp -f "$(NATIVE_SO)" lib/libjerboa_native.so; \
 	fi
 
 # ─── Vendor Dependencies ─────────────────────────────────────────────────────
@@ -136,19 +173,30 @@ vendor/jerboa-websearch:
 	@echo "=== Cloning jerboa-websearch into vendor/ ==="
 	@git clone --depth 1 git@git.sr.ht:~lisp/jerboa-websearch vendor/jerboa-websearch
 
+# Sparse-clone just the jerboa-native-rs crate out of the jerboa monorepo.
+# Depended on only by native-rs (not vendor-deps), so cross targets — which
+# build their own target-triple native lib — don't pull it.
+$(NATIVE_DIR):
+	@mkdir -p vendor
+	@echo "=== Sparse-cloning jerboa-native-rs from the jerboa monorepo ==="
+	@git clone --depth 1 --filter=blob:none --sparse git@git.sr.ht:~lisp/jerboa vendor/.jerboa-monorepo
+	@git -C vendor/.jerboa-monorepo sparse-checkout set jerboa-native-rs
+	@mv vendor/.jerboa-monorepo/jerboa-native-rs $(NATIVE_DIR)
+	@rm -rf vendor/.jerboa-monorepo
+
 vendor-clean:
 	rm -rf vendor
 
-gen: vendor-deps purge-stale sqlite-shim native-so
-	$(JERBUILD) src lib
+gen: vendor-deps purge-stale sqlite-shim
+	$(JERBUILD) transpile src lib
 
-build: gen
+build: gen native-rs
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) $(LIBDIRS) --compile-imported-libraries --script main.ss < /dev/null
+	$(JERBUILD) compile $(LIBDIRS) main.ss < /dev/null
 
-run:
+run: native-rs
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) $(LIBDIRS) --script main.ss
+	$(JEXEC) main.ss
 
 tui-shim: vendor/termbox2
 	@test -f $(TUI_SHIM_DIR)/jcode_tui_shim.dylib \
@@ -158,27 +206,40 @@ tui-shim: vendor/termbox2
 	    -o $(TUI_SHIM_DIR)/jcode_tui_shim.dylib \
 	    src/jcode/ui/jcode_tui_shim.c
 
-run-tui: build tui-shim
+run-tui: build tui-shim native-rs
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) $(LIBDIRS) --script main.ss --tui
+	$(JEXEC) main.ss --tui
 
 repl:
-	$(SCHEME) $(LIBDIRS)
+	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
+	$(JEXEC) support/repl.ss
 
 test: build
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) $(LIBDIRS) --script test/run.ss
+	$(JEXEC) test/run.ss
 
 # Live smoke test — one round-trip per provider with a configured key.
 # Providers without a key are SKIPped, not failed. Excluded from `test`
 # because it makes real network calls.
 test-providers: build
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) $(LIBDIRS) --script test/run-providers.ss
+	$(JEXEC) test/run-providers.ss
 
 # --- Static binary targets ---
 
-binary: build
+# Self-contained: `jerbuild build` reads .jerbuild (entry, libdirs, ffi-symbols,
+# main-c, C shims, rust-crate), bundles Chez + stdlib, runs cargo for the native
+# .a, and links it all. Per-OS link flags come from --os-libs.
+binary: gen native-rs
+	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
+	$(JERBUILD) build --os-libs "$(JCODE_OS_LIBS)"
+	cp vendor/termbox2/jcode_tui_shim.dylib ./jcode_tui_shim.dylib 2>/dev/null || true
+	cp vendor/termbox2/jcode_tui_shim.so ./jcode_tui_shim.so 2>/dev/null || true
+
+# Fallback: the hand-rolled WPO build. Requires a full ~/mine/jerboa checkout
+# with a locally-built Chez ($(SCHEME)) and `make -C $(JERBOA_HOME) native`
+# already run. Kept until `binary` is proven across hosts, then removed.
+binary-legacy: gen tui-shim
 	JERBOA_HOME=$(JERBOA_HOME) \
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
 	$(SCHEME) -q --libdirs $(JERBOA_HOME)/lib:./lib:vendor/chez-sqlite/src:vendor/jerboa-websearch/src \
@@ -213,7 +274,7 @@ linux-docker: linux-check docker
 linux-check: gen
 	@echo "=== Running linux-check (fast musl-build drift check) ==="
 	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
-	$(SCHEME) -q $(LIBDIRS) --script linux-check.ss
+	$(SCHEME) -q --libdirs "$(XC_LIBDIRS)" --script linux-check.ss
 
 # ── Docker build (canonical static binary, zero runtime deps) ───────────────
 # Use `make linux` to build in Docker (canonical, reproducible).
@@ -356,6 +417,7 @@ clean:
 	find . -name "*.so" -delete
 	find . -name "*.wpo" -delete
 	rm -f jcode jcode-musl jcode-musl.sha256
+	rm -f lib/libjerboa_native.dylib lib/libjerboa_native.so jcode_tui_shim.dylib jcode_tui_shim.so
 
 # ── Android APK (thin client, no embedded binary) ───────────────────────────
 # Build from Termux — no Android Studio / Gradle required.
diff --git a/src/jcode/provider/sampling.ss b/src/jcode/provider/sampling.ss
index 56d6f64..b7ddff0 100644
--- a/src/jcode/provider/sampling.ss
+++ b/src/jcode/provider/sampling.ss
@@ -188,14 +188,22 @@
       (else                 (get-sampling-defaults model)))))
 
 ;; Splat the resolved sampling params onto an OpenAI-style request body. When
-;; no row applies (unknown model, or policy 'off), preserve the legacy mlx
-;; safety net: local mlx-lm LoRAs regress to a degenerate fixed point at
-;; temperature=0 with no repetition penalty, so nudge them off it.
+;; no row applies (unknown model, or policy 'off), apply the mlx safety net.
+;; The mlx model id is a filesystem path (.../Qwen3.6-35B-A3B-TurboQuant-6bit),
+;; so it never matches a card-identity row — every local mlx model lands here.
+;; Nearly all are Qwen3-family, and every Qwen3 card warns that near-greedy
+;; decoding "leads to endless repetitions". The old temp=0.3 / rep=1.05 net was
+;; effectively greedy and the 35B-A3B 6-bit quant degenerated into a token loop;
+;; temp=0.6 + top_p=0.95 + top_k=20 + min_p=0 is Qwen's recommended agentic
+;; preset and repetition_penalty=1.1 is heavy-quant insurance (all keys honored
+;; by mlx-lm's server sampler).
 (def (apply-sampling-to-body! body model provider-name)
   (let ((params (sampling-params-for model)))
     (if (pair? (hash-keys params))
       (hash-for-each (lambda (k v) (hash-put! body k v)) params)
       (when (equal? provider-name "mlx")
-        (hash-put! body "temperature"        0.3)
-        (hash-put! body "top_p"              0.9)
-        (hash-put! body "repetition_penalty" 1.05)))))
+        (hash-put! body "temperature"        0.6)
+        (hash-put! body "top_p"              0.95)
+        (hash-put! body "top_k"              20)
+        (hash-put! body "min_p"              0.0)
+        (hash-put! body "repetition_penalty" 1.1)))))
diff --git a/src/jcode/ui/cli.ss b/src/jcode/ui/cli.ss
index 1cbd8c5..b5d084a 100644
--- a/src/jcode/ui/cli.ss
+++ b/src/jcode/ui/cli.ss
@@ -722,7 +722,7 @@ EXAMPLES:
   (let ((cmd (string-trim (substring input 1 (string-length input)))))
     (cond
       ((equal? cmd "help")
-       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /mcp               Toggle MCP tools on/off\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /undo [N]          Revert last N checkpoint(s) (default 1)\n  /checkpoints       List recent shadow-git checkpoints\n  /forge [on|off]    Show or toggle forge guardrails\n  /forge sampling <off|on|strict>  Per-model sampling policy\n  /forge workflow    Describe + self-test the workflow engine\n  /forge proxy       Describe + self-test the OpenAI-compatible proxy\n  /forge ablation    Describe + self-test the eval/ablation harness\n  /forge verify      Describe + self-test the verify-gate (ATLAS verify+repair)\n  /forge bestofk     Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n  /forge breaker     Describe + self-test the no-progress loop breaker\n  /forge run <task>  Verify-gated coding on your live model (edit→verify→done)\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
+       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /expert <prompt>   Route one prompt to the configured expert model\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /mcp               Toggle MCP tools on/off\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /undo [N]          Revert last N checkpoint(s) (default 1)\n  /checkpoints       List recent shadow-git checkpoints\n  /forge [on|off]    Show or toggle forge guardrails\n  /forge sampling <off|on|strict>  Per-model sampling policy\n  /forge workflow    Describe + self-test the workflow engine\n  /forge proxy       Describe + self-test the OpenAI-compatible proxy\n  /forge ablation    Describe + self-test the eval/ablation harness\n  /forge verify      Describe + self-test the verify-gate (ATLAS verify+repair)\n  /forge bestofk     Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n  /forge breaker     Describe + self-test the no-progress loop breaker\n  /forge run <task>  Verify-gated coding on your live model (edit→verify→done)\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
       ((equal? cmd "model")
        (printf "Provider: ~a~n" (or (current-provider-override) (config-provider)))
        (printf "Model:    ~a~n" (or (current-model-override) (config-model)))
@@ -738,6 +738,24 @@ EXAMPLES:
          (current-provider-override new-provider)
          (current-model-override (config-default-model new-provider))
          (printf "Provider set to: ~a (model: ~a)~n" new-provider (current-model-override))))
+      ((or (equal? cmd "expert") (string-prefix? "expert " cmd))
+       ;; Route one prompt to the configured expert. No thread boundary here
+       ;; (line-mode runs the turn synchronously), so parameterize wraps the
+       ;; agent call directly and the overrides revert when the turn returns.
+       (let ((prompt (if (equal? cmd "expert") ""
+                         (string-trim (substring cmd 7 (string-length cmd)))))
+             (ep (config-ref "expert" "provider"))
+             (em (config-ref "expert" "model")))
+         (cond
+           ((not (and ep em))
+            (printf "No expert configured. Add an \"expert\" block to jcode.json.~n"))
+           ((string=? prompt "")
+            (printf "Usage: /expert <prompt>  — routes one prompt to ~a/~a~n" ep em))
+           (else
+            (printf "Routing to expert: ~a/~a~n" ep em)
+            (parameterize ((current-provider-override ep)
+                           (current-model-override   em))
+              (handle-user-input prompt session-id))))))
       ((equal? cmd "tools")
        (printf "Available tools: ~a~n" (string-join (list-tools) ", ")))
       ((equal? cmd "mcp")
diff --git a/src/jcode/ui/tui-input.ss b/src/jcode/ui/tui-input.ss
index 0643108..692280e 100644
--- a/src/jcode/ui/tui-input.ss
+++ b/src/jcode/ui/tui-input.ss
@@ -43,6 +43,7 @@
   '(("/help"          . "Show help")
     ("/model"         . "Show or set model")
     ("/provider"      . "Show or set provider")
+    ("/expert"        . "Force a prompt to the configured expert model")
     ("/tools"         . "List available tools")
     ("/clear"         . "Start new session")
     ("/sessions"      . "List saved sessions")
diff --git a/support/ffi-symbols.list b/support/ffi-symbols.list
new file mode 100644
index 0000000..3c57a20
--- /dev/null
+++ b/support/ffi-symbols.list
@@ -0,0 +1,98 @@
+# FFI symbols statically registered via Sforeign_symbol() in the jcode binary.
+# One symbol per line; blank lines and # comments ignored.
+# Consumed by `.jerbuild` (ffi-symbols) -> jerbuild emits ffi_symbols.h.
+#
+# These resolve the foreign-procedure calls made by the linked-in C shims
+# (chez_sqlite, termbox TUI) and the jerboa-native Rust lib (tls + crypto).
+# All are present in libjerboa_native.a built with --features tls,crypto and
+# in the two C shims.
+
+# ── chez-sqlite shim ─────────────────────────────────────────────
+chez_sqlite_open
+chez_sqlite_close
+chez_sqlite_exec
+chez_sqlite_prepare
+chez_sqlite_finalize
+chez_sqlite_reset
+chez_sqlite_clear_bindings
+chez_sqlite_step
+chez_sqlite_column_count
+chez_sqlite_column_name
+chez_sqlite_column_type
+chez_sqlite_column_int64
+chez_sqlite_column_double
+chez_sqlite_column_text
+chez_sqlite_column_bytes
+chez_sqlite_column_blob
+chez_sqlite_bind_int64
+chez_sqlite_bind_double
+chez_sqlite_bind_text
+chez_sqlite_bind_blob
+chez_sqlite_bind_null
+chez_sqlite_last_insert_rowid
+chez_sqlite_changes
+chez_sqlite_errmsg
+chez_SQLITE_ROW
+chez_SQLITE_DONE
+chez_SQLITE_OK
+
+# ── termbox2 TUI shim ────────────────────────────────────────────
+jcode_tb_init
+jcode_tb_shutdown
+jcode_tb_width
+jcode_tb_height
+jcode_tb_clear
+jcode_tb_present
+jcode_tb_set_cursor
+jcode_tb_hide_cursor
+jcode_tb_change_cell
+jcode_tb_set_clear_attrs
+jcode_tb_print
+jcode_tb_printf
+jcode_tb_set_input_mode
+jcode_tb_set_output_mode
+jcode_tb_poll_event
+jcode_tb_peek_event
+jcode_tb_event_type
+jcode_tb_event_mod
+jcode_tb_event_key
+jcode_tb_event_ch
+jcode_tb_event_w
+jcode_tb_event_h
+jcode_tb_event_x
+jcode_tb_event_y
+
+# ── jerboa-native (TLS / rustls) ─────────────────────────────────
+jerboa_tls_server_new
+jerboa_tls_server_new_mtls
+jerboa_tls_server_free
+jerboa_tls_accept
+jerboa_tls_connect
+jerboa_tls_connect_pinned
+jerboa_tls_connect_mtls
+jerboa_tls_close
+jerboa_tls_read
+jerboa_tls_write
+jerboa_tls_flush
+jerboa_tls_get_fd
+jerboa_tls_set_nonblock
+jerboa_last_error
+
+# ── jerboa-native (crypto / ring) ────────────────────────────────
+jerboa_sha1
+jerboa_sha256
+jerboa_sha384
+jerboa_sha512
+jerboa_random_bytes
+jerboa_hmac_sha256
+jerboa_hmac_sha256_verify
+jerboa_timing_safe_equal
+jerboa_aead_seal
+jerboa_aead_open
+jerboa_chacha20_seal
+jerboa_chacha20_open
+jerboa_scrypt
+jerboa_pbkdf2_derive
+jerboa_pbkdf2_verify
+jerboa_argon2id_hash
+jerboa_argon2id_verify
diff --git a/support/jcode-main.c b/support/jcode-main.c
new file mode 100644
index 0000000..2e3af47
--- /dev/null
+++ b/support/jcode-main.c
@@ -0,0 +1,54 @@
+/* Custom main.c for the standalone jcode binary (referenced by .jerbuild).
+ *
+ * Identical to jerbuild's stock template, with ONE addition: it sets
+ * JERBOA_STATIC=1 before building the heap. (jcode ui tui-ffi) reads that env
+ * var at library-load time and, when set, trusts the termbox shim symbols that
+ * are linked into the binary (and registered via register_ffi_symbols below)
+ * instead of trying to dlopen a jcode_tui_shim.{so,dylib} that does not exist
+ * in a static build. chez-sqlite needs no such flag — it already falls through
+ * to (void) and resolves its symbols the same way.
+ */
+#include "scheme.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <sys/types.h>
+
+#include "petite_boot.h"
+#include "scheme_boot.h"
+#include "program_boot.h"
+#include "ffi_symbols.h"
+
+static const char *write_program_tmpfile(void) {
+    static char path[] = "/tmp/jcode-prog-XXXXXX";
+    int fd = mkstemp(path);
+    if (fd < 0) { perror("mkstemp"); exit(1); }
+    ssize_t n = write(fd, program_boot_data, program_boot_size);
+    if (n != (ssize_t)program_boot_size) {
+        perror("write"); close(fd); unlink(path); exit(1);
+    }
+    close(fd);
+    return path;
+}
+
+int main(int argc, const char *argv[]) {
+    /* Trust the linked-in termbox shim symbols (see file header). */
+    setenv("JERBOA_STATIC", "1", 1);
+
+    Sscheme_init(NULL);
+    Sregister_boot_file_bytes("petite",
+        (void *)petite_boot_data, petite_boot_size);
+    Sregister_boot_file_bytes("scheme",
+        (void *)scheme_boot_data, scheme_boot_size);
+    Sbuild_heap(NULL, NULL);
+    register_ffi_symbols();
+
+    const char *prog_path = write_program_tmpfile();
+    int status = Sscheme_program(prog_path, argc, argv);
+    unlink(prog_path);
+
+    Sscheme_deinit();
+    return status;
+}
diff --git a/support/repl.ss b/support/repl.ss
new file mode 100644
index 0000000..49f6b33
--- /dev/null
+++ b/support/repl.ss
@@ -0,0 +1,6 @@
+#!chezscheme
+;; Bare REPL with the project libdirs loaded, for `make repl` under jerbuild
+;; (jerbuild exec needs a script; there is no bare-REPL subcommand).
+(import (chezscheme))
+(printf "jcode REPL (jerbuild) — project libdirs loaded. (exit) to quit.~n")
+(new-cafe)