#!/usr/bin/env bash
# vibe — user-facing launcher for the selfhost vibe toolchain.
#
# Architecture (docs/release-roadmap.md テーマ1):
#   * a self-built wasmtime runner (`viberun`) executes wasm,
#   * the vibe compiler ships as a portable wasm artifact (`vibe-cli.wasm`),
#   * at install time the compiler wasm is AOT-compiled to a host-specific
#     `vibe-cli.cwasm` so the compiler is not re-JITed on every invocation,
#   * the runner and the compiler wasm version independently — `vibe self
#     update` can refresh the compiler wasm (+ .cwasm) without touching the
#     runner.
#
# Subcommands:
#   vibe run     <file.vibex> [-- args]   compile fixed `main`, then run
#   vibe run --trace <file.vibex>         run + print the function-call trace
#                                         (each entry annotated with file:line)
#   vibe run --break <fn>[,<fn>...] <file.vibex>
#                                         pause at each named function's entry,
#                                         print the call stack, then continue.
#                                         At a pause type a step command (stdin):
#                                           s/step  = step into next function
#                                           n/next  = step over (same/shallower)
#                                           o/finish= finish current frame
#                                           c/empty = continue
#                                           q/quit  = abort the run
#   vibe run --mem <file.vibex>           run + report bump-heap usage (peak /
#                                         total allocated) to stderr after exit
#   vibe run --alloc-site[=N] <file.vibex>  run + report per-line alloc
#                                         attribution (top N sites) to stderr
#   vibe compile <file.vibe> -o <out>     compile to a .wasm
#   vibe compile --wit <file.vibe>        emit the WIT world for the file's
#                                         effect surface (docs/effect-wit-mapping.md)
#   vibe build   <file.vibe> -o <out>     alias of compile
#   vibe compile|build --minify ...       post-optimize the output with the
#                                         standalone vibe-opt.wasm artifact
#                                         (scripts/build_vibe_opt.sh, #1107)
#   vibe compile|build --jobs N ...       best-effort parallel pre-warm of the
#                                         type-env cache before the (unchanged)
#                                         serial compile (#906 Phase 2, dev
#                                         checkout only -- see
#                                         docs/compiler-parallelism.md)
#   vibe serve   <handler.vibe> [--port N] compile + compose with the wasi-http
#                                         P3 adapter + `wasmtime serve` (#537)
#   vibe check   <file.vibe>              parse + typecheck (no output kept)
#   vibe type-at <file.vibe> <line> <col> print the inferred type at a position
#   vibe doc-at  <file.vibe> <line> <col> print the `///` doc comment at a position (#854)
#   vibe binding-at <file.vibe> <line> <col> print binding occurrence spans (START END per line)
#   vibe symbols <file.vibe>              print declaration outline (NAME KIND START END per line)
#   vibe escapes <file.vibe>              print the `let mut` bindings a closure
#                                         captures -- the ones codegen boxes into
#                                         a heap ref cell instead of a wasm local
#                                         (NAME START END per line; empty = none)
#   vibe diagnostics <file.vibe>          print all syntax/type diagnostics
#   vibe normalize [--check|--stdout] <file.vibe>
#                                         canonicalize a source file in place
#                                         (--check: exit 1 if not normalized;
#                                          --stdout: print, don't write)
#   vibe test    <file_test.vibe>...      compile + run test {} blocks
#   vibe test --update <file_test.vibe>... auto-patch stale inspect(value,
#                                         content) snapshots to the actual
#                                         value on a failing run, then
#                                         recompile+rerun (MoonBit
#                                         inspect/--update-style, #1061)
#   vibe bench   <file.vibe> [--iters N] [--warmup N]
#                                         run bench {} blocks; report ns/op
#                                         (min/p50/p95/mean), ops/sec, bytes/op
#   vibe shell   [file.vibe]              interactive compiled REPL: declarations
#                                         accumulate, each line recompiles+runs
#                                         (type :help inside for commands)
#   vibe new     <dir>                    scaffold a starter project
#   vibe add     <name> <url> [dir]       add a dependency to vibe.deps + fetch
#   vibe fetch   [--frozen] [dir]        vendor git/URL deps from vibe.deps + lock
#   vibe verify  [dir]                    re-check vendored deps against vibe.lock
#   vibe pkg     publish <pkg_dir>        publish a package: semver gate, CAS,
#                                         transparency-log record (#805)
#   vibe pkg     install <name>@<ver> [--store] [--allow-yanked]
#                                         materialize a published version
#                                         (verifies the log inclusion proof)
#   vibe pkg     add <source-spec> [#pkg:sha1:<hex>] [--store]
#                                         fetch straight from git, hash-verified
#   vibe pkg     yank <name>@<ver>        mark a version yanked (append-only)
#   vibe pkg     update <name> [--store]  switch to the newest non-yanked
#                                         version, showing the contract diff
#   vibe lsp                              start the stdio LSP server (diagnostics)
#   vibe context-pack [--out FILE]        emit cheatsheet + verified golden
#                                         examples as one file (AI-harness context, #820)
#   vibe version                          print toolchain versions
#   vibe self update [--cli-wasm <path>]  refresh compiler wasm + rebuild .cwasm
#   vibe help                             this message
set -euo pipefail

# Toolchain release version (0.3.0 GA — docs/release-notes-0.3.0.md).
VIBE_VERSION="0.3.0"

# Resolve install root. Two layouts are supported (rustup-style toolchains
# landed with the #755-era installer; the flat layout stays recognized so old
# installs keep working):
#
#   toolchain: $VIBE_HOME/toolchains/<name>/bin/{vibe,viberun}
#              $VIBE_HOME/toolchains/<name>/lib/{vibe-cli.wasm,vibe-cli.cwasm,…}
#              $VIBE_HOME/{lib/@scope/name…, cache/…}   (SHARED state: package
#              root #751 / fetch cache #754 — versioned per toolchain is the
#              artifacts, not the packages)
#   flat:      $VIBE_HOME/bin/{vibe,viberun}
#              $VIBE_HOME/lib/{vibe-cli.wasm,vibe-cli.cwasm}
#
# TOOLCHAIN_DIR points at this launcher's own artifact root in both layouts.
# Resolve this script's real path WITHOUT `readlink -f` (a GNU extension that
# macOS/BSD readlink lacks — `readlink -f` there errors and breaks every install).
# Walk symlinks portably instead.
_src="${BASH_SOURCE[0]}"
while [ -L "$_src" ]; do
  _dir="$(cd -P "$(dirname "$_src")" && pwd)"
  _src="$(readlink "$_src")"
  case "$_src" in /*) ;; *) _src="$_dir/$_src" ;; esac
done
LAUNCHER="$(cd -P "$(dirname "$_src")" && pwd)/$(basename "$_src")"
SELF="$(cd -P "$(dirname "$LAUNCHER")" && pwd)"
TOOLCHAIN_DIR="$(dirname "$SELF")"
if [ "$(basename "$(dirname "$TOOLCHAIN_DIR")")" = "toolchains" ]; then
  VIBE_HOME="${VIBE_HOME:-$(dirname "$(dirname "$TOOLCHAIN_DIR")")}"
else
  VIBE_HOME="${VIBE_HOME:-$TOOLCHAIN_DIR}"
fi
RUNNER="${VIBE_RUNNER:-$TOOLCHAIN_DIR/bin/viberun}"
CLI_WASM="${VIBE_CLI_WASM:-$TOOLCHAIN_DIR/lib/vibe-cli.wasm}"
CLI_CWASM="${VIBE_CLI_CWASM:-$TOOLCHAIN_DIR/lib/vibe-cli.cwasm}"

# Portable single-file sha256 (macOS ships `shasum`, not `sha256sum`).
_sha256_file() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | cut -d' ' -f1
  else
    shasum -a 256 "$1" | awk '{print $1}'
  fi
}

die() { echo "vibe: $*" >&2; exit 1; }

# --- semver constraint resolution for git deps -------------------------------
# A git ref of `#<constraint>` (^1.2, ~1.2.3, >=1.0, 1.x, *, ...) resolves to the
# highest matching tag on the remote. A bare exact version (1.2.3) or any
# non-semver string (a branch / commit / `v`-tag) is used literally.

# Echo "MAJOR MINOR PATCH" for a version/tag (strips leading v, pre-release/build).
ver_parts() {
  local v="${1#[vV]}"; v="${v%%+*}"; v="${v%%-*}"
  local IFS=.; set -- $v; echo "${1:-0} ${2:-0} ${3:-0}"
}
# Echo -1/0/1 for ($1 < / = / > $2).
ver_cmp() {
  local a b; a="$(ver_parts "$1")"; b="$(ver_parts "$2")"
  local a1 a2 a3 b1 b2 b3
  read -r a1 a2 a3 <<EOF
$a
EOF
  read -r b1 b2 b3 <<EOF
$b
EOF
  local p
  for p in "$a1 $b1" "$a2 $b2" "$a3 $b3"; do
    set -- $p
    [ "$1" -lt "$2" ] && { echo -1; return; }
    [ "$1" -gt "$2" ] && { echo 1; return; }
  done
  echo 0
}
ver_ge() { [ "$(ver_cmp "$1" "$2")" != "-1" ]; }
ver_gt() { [ "$(ver_cmp "$1" "$2")" = "1" ]; }
ver_lt() { [ "$(ver_cmp "$1" "$2")" = "-1" ]; }
ver_le() { [ "$(ver_cmp "$1" "$2")" != "1" ]; }

# Does a concrete version $1 satisfy constraint $2? Supports ^ ~ >= > <= < =,
# partial (1, 1.2), and * / "" (any).
ver_satisfies() {
  local v="$1" c="$2" maj min pat base
  case "$c" in ""|"*"|x|X) return 0 ;; esac
  case "$c" in
    ^*) base="${c#^}"; read -r maj min pat <<EOF
$(ver_parts "$base")
EOF
        ver_ge "$v" "$base" || return 1
        if [ "$maj" -gt 0 ]; then ver_lt "$v" "$((maj+1)).0.0"
        elif [ "$min" -gt 0 ]; then ver_lt "$v" "0.$((min+1)).0"
        else ver_lt "$v" "0.0.$((pat+1))"; fi ;;
    ~*) base="${c#\~}"; read -r maj min pat <<EOF
$(ver_parts "$base")
EOF
        ver_ge "$v" "$base" && ver_lt "$v" "$maj.$((min+1)).0" ;;
    ">="*) ver_ge "$v" "${c#>=}" ;;
    "<="*) ver_le "$v" "${c#<=}" ;;
    ">"*)  ver_gt "$v" "${c#>}" ;;
    "<"*)  ver_lt "$v" "${c#<}" ;;
    "="*)  [ "$(ver_cmp "$v" "${c#=}")" = "0" ] ;;
    *)
      # Partial (1 or 1.2) → range; full x.y.z → exact.
      case "$c" in
        *.*.*) [ "$(ver_cmp "$v" "$c")" = "0" ] ;;
        *.*)   read -r maj min pat <<EOF
$(ver_parts "$c")
EOF
               ver_ge "$v" "$maj.$min.0" && ver_lt "$v" "$maj.$((min+1)).0" ;;
        *)     case "$c" in *[!0-9]*) return 1 ;; esac
               ver_ge "$v" "$c.0.0" && ver_lt "$v" "$((c+1)).0.0" ;;
      esac ;;
  esac
}
# Is $1 a semver constraint (vs a plain branch/commit/exact tag)?
is_constraint() {
  case "$1" in
    "*"|x|X) return 0 ;;
    ^*|\~*|">="*|"<="*|">"*|"<"*|"="*) return 0 ;;
    *[!0-9.vV]*) return 1 ;;           # contains letters/other → branch or sha
    *.*.*) return 1 ;;                 # full x.y.z → exact, treat literally
    *.*|[0-9]*) return 0 ;;            # partial 1 or 1.2 → constraint
    *) return 1 ;;
  esac
}
# Resolve constraint $2 against the tags of remote $1; echo the winning tag.
resolve_git_constraint() {
  local remote="$1" constraint="$2" best="" bestv="" tag v
  while IFS= read -r tag; do
    [ -n "$tag" ] || continue
    case "$tag" in *'^{}'*) continue ;; esac    # skip deref tags
    v="${tag#[vV]}"
    case "$v" in ''|*[!0-9.]*) continue ;; esac   # keep numeric-dotted only
    if ver_satisfies "$v" "$constraint"; then
      if [ -z "$best" ] || ver_gt "$v" "$bestv"; then best="$tag"; bestv="$v"; fi
    fi
  done <<EOF
$(git ls-remote --tags --refs "$remote" 2>/dev/null | sed 's#.*refs/tags/##')
EOF
  [ -n "$best" ] && printf '%s' "$best"
}
# -----------------------------------------------------------------------------

# Deterministic content digest of a vendored directory: hash each file as
# "<relpath>\0<sha256>" over a sorted file list, then hash the stream. Lets
# `vibe verify` detect tampering of a git dep (whose lock records a commit sha,
# not a content hash) without needing the original .git. Vendor artifacts
# (./deps/ and lock files) are excluded so the digest covers only the dep's own
# source — they appear after the digest is taken (transitive resolution) and are
# verified separately by recursing into their own lock.
tree_digest() {
  ( cd "$1" && find . -type f \
        ! -path './.git/*' ! -path './deps/*' \
        ! -name 'vibe.lock' ! -name 'vibe.lock.tmp' -print0 \
      | LC_ALL=C sort -z \
      | while IFS= read -r -d '' f; do
          printf '%s\0' "$f"; sha256sum "$f" | cut -d' ' -f1
        done \
      | sha256sum | cut -d' ' -f1 )
}

# Codex review (PR #1162): `context-pack` is pure shell (no wasm execution
# at all -- see its case block below), so it must not be blocked by a
# missing/unbuilt runner. Every other subcommand still requires it.
if [ "${1:-help}" != "context-pack" ]; then
  [ -x "$RUNNER" ] || die "runner not found or not executable: $RUNNER"
fi

# Pick the AOT .cwasm when it is present and at least as new as both the runner
# and the compiler wasm (a stale .cwasm built against a different wasmtime is
# UB, so fall back to the portable wasm if anything looks newer).
pick_cli() {
  # Use the .cwasm only when NOTHING it depends on is newer than it: neither the
  # runner (a different wasmtime would make the AOT image UB) nor the portable
  # wasm (if the wasm was refreshed without re-precompiling, the .cwasm is stale
  # — fall back to it). Using `! older-than` also accepts an equal-mtime .cwasm
  # built right after the wasm. (A previous `-ef $CLI_CWASM` self-comparison was
  # always true and defeated the wasm-staleness guard entirely.)
  if [ -f "$CLI_CWASM" ] && [ ! "$RUNNER" -nt "$CLI_CWASM" ] && \
     { [ ! -f "$CLI_WASM" ] || [ ! "$CLI_WASM" -nt "$CLI_CWASM" ]; }; then
    printf '%s' "$CLI_CWASM"
  elif [ -f "$CLI_WASM" ]; then
    printf '%s' "$CLI_WASM"
  else
    die "compiler wasm not found: $CLI_WASM (run 'vibe self update')"
  fi
}

# Compile $1 (.vibe) → $2 (.wasm) with entry $3, resolving imports from the FS.
# Returns non-zero on a parse/type/codegen error so callers can report cleanly.
compile_to() {
  local src="$1" out="$2" entry="$3" debug="${4:-}" brk="${5:-}" keep_names="${6:-}"
  local cli; cli="$(pick_cli)"
  mkdir -p "$(dirname "$out")"
  rm -f "$out" "$out.diag"
  local rerr; rerr="$(mktemp -t vibe-rerr-XXXXXX)"
  # `vibe run --trace` sets debug=1 so the compiler instruments user-function
  # entries (VIBE_DEBUG=1 -> `vibe.trace` custom section + in-memory trace log).
  # `vibe run --break` sets brk=1 so the compiler emits a `vibe::dbg_break` host
  # hook at each user-function entry (VIBE_DEBUG_BREAK=1). Default (neither flag)
  # leaves both unset, so codegen is unchanged.
  # A variable assignment produced by expansion ($debug_env) is NOT treated as
  # an assignment prefix by bash, so route it through `env` (which parses
  # VAR=val arguments). Empty $debug_env expands to nothing → unchanged default.
  local debug_env=""
  [ "$debug" = "1" ] && debug_env="VIBE_DEBUG=1"
  [ "$brk" = "1" ] && debug_env="VIBE_DEBUG_BREAK=1"
  # Binary-size (#1100): the compiler strips the name section from release
  # executables by default. Dev-loop commands (`vibe run`/`vibe shell`) pass
  # keep_names=1 so runtime trap backtraces keep naming user functions (the
  # funcmap annotator below matches frames by those names).
  local names_env=""
  [ "$keep_names" = "1" ] && names_env="VIBE_WASM_NAMES=1"
  env VIBE_FS_COMPILE=1 VIBE_IMPORT_ABI=raw $debug_env $names_env \
    "$RUNNER" "$cli" "$src" "$out" "$entry" >/dev/null 2>"$rerr" || true
  if [ -s "$out" ]; then
    rm -f "$out.diag" "$rerr"
    return 0
  fi
  # Compile failed. Prefer the structured diagnostic the compiler wrote to the
  # `<out>.diag` sidecar (real message + location); fall back to runner stderr.
  if [ -s "$out.diag" ]; then
    echo "error: $(cat "$out.diag")" >&2
  elif [ -s "$rerr" ]; then
    cat "$rerr" >&2
  fi
  rm -f "$out.diag" "$rerr"
  return 1
}

# #906 Phase 2 (real-build wiring): best-effort pre-warm of the persistent
# type-env cache before compile_to's serial walk. `$1` jobs, `$2` entry
# source file. This ONLY ever helps or no-ops -- it never changes what
# compile_to below produces, and it never fails the build: any missing
# prerequisite (node, the dev-repo driver scripts) or any failure inside
# the driver itself (discovery error, worker crash, publish failure) is
# reported to stderr and swallowed, falling straight through to the
# unconditional serial compile that already runs after every call site.
#
# The driver (scripts/parallel_frontend_warm.mjs) lives in this repo's own
# `scripts/` directory, not in the installed-toolchain layout `vibe` also
# runs from -- so this is only ever active in a dev checkout, gated on the
# script actually being found next to TOOLCHAIN_DIR.
maybe_warm_frontend_cache() {
  local jobs="$1" src="$2"
  [ "$jobs" -gt 1 ] || return 0
  # Preferred path (#1239 step 4(D)): the process-pool coordinator. It needs
  # only bash and the runner this launcher already uses, so unlike the node
  # driver below it works in an installed toolchain -- install.sh puts it in
  # lib/ next to vibe_pkg.sh, and a dev checkout finds it under scripts/.
  #
  # It also gets to use pick_cli's choice, which the node driver cannot: a
  # .cwasm is a wasmtime AOT image that only viberun can load, and that is the
  # difference between ~8ms and ~485ms of startup per module job (#1248). Every
  # worker here is a fresh process, so that cost is paid once per job.
  local pool=""
  local cand
  for cand in "$TOOLCHAIN_DIR/lib/parallel_warm_pool.sh" \
              "$TOOLCHAIN_DIR/scripts/parallel_warm_pool.sh"; do
    [ -f "$cand" ] && { pool="$cand"; break; }
  done
  if [ -n "$pool" ]; then
    local pool_log; pool_log="$(mktemp -t vibe-jobs-pool-XXXXXX)"
    local pool_status=0
    # `$src` verbatim, and the same guarded-timeout dispatch as below.
    if command -v timeout >/dev/null 2>&1; then
      timeout 600 bash "$pool" "$(pick_cli)" "$src" "$jobs" "$RUNNER" >"$pool_log" 2>&1 || pool_status=$?
    elif command -v gtimeout >/dev/null 2>&1; then
      gtimeout 600 bash "$pool" "$(pick_cli)" "$src" "$jobs" "$RUNNER" >"$pool_log" 2>&1 || pool_status=$?
    else
      bash "$pool" "$(pick_cli)" "$src" "$jobs" "$RUNNER" >"$pool_log" 2>&1 || pool_status=$?
    fi
    if [ "$pool_status" -eq 0 ]; then
      rm -f "$pool_log"
      return 0
    fi
    # Same advisory contract as the node driver: report and fall through. The
    # node path below is tried next, and failing that the serial compile that
    # runs after every call site produces the identical result anyway.
    echo "vibe: --jobs=$jobs process-pool pre-warm failed, trying the node driver: $(tail -1 "$pool_log")" >&2
    rm -f "$pool_log"
  fi
  local driver="$TOOLCHAIN_DIR/scripts/parallel_frontend_warm.mjs"
  local node_runner="$TOOLCHAIN_DIR/scripts/run_wasm_vibe_host_runner.sh"
  if [ ! -f "$driver" ] || [ ! -f "$node_runner" ]; then
    echo "vibe: --jobs=$jobs needs the dev-repo parallel driver scripts (not found under $TOOLCHAIN_DIR/scripts); compiling serially" >&2
    return 0
  fi
  if ! command -v node >/dev/null 2>&1; then
    echo "vibe: --jobs=$jobs needs node on PATH for the parallel frontend driver; compiling serially" >&2
    return 0
  fi
  # The Node worker pool always talks to the wasm through the Node runner
  # (worker_threads is a Node API) regardless of which runner this launcher
  # itself uses for the real compile -- a .cwasm AOT image is wasmtime-
  # specific and cannot be loaded here, so this always uses the portable
  # $CLI_WASM even when pick_cli would have chosen the .cwasm below.
  #
  # `$src` is passed VERBATIM (not resolved to an absolute path) -- it must
  # be the exact same string compile_to below receives. The driver resolves
  # every import relative to this path (dir_of_path), and build_fingerprint
  # folds each dependency's PATH, not just its content hash, into the
  # importer's own fingerprint (runtime/typecheck_fs.vibe). An absolute path
  # here and a relative one at compile_to would make the two passes derive
  # DIFFERENT fingerprints for the same importer, so every warmed non-leaf
  # entry would sit under a key the serial walk never looks up -- silently
  # making `--jobs` a no-op for exactly the common `vibe build src/main.vibe`
  # case (Codex review, PR #1144).
  local out_log; out_log="$(mktemp -t vibe-jobs-warm-XXXXXX)"
  # macOS has no GNU `timeout` by default (only available via `gtimeout` from
  # Homebrew coreutils) -- dispatch through whichever is present, or run
  # unguarded (the job/CI-level timeout still bounds a true hang). Same
  # idiom as scripts/test_vibe_library.sh's run_guarded.
  local warm_status=0
  if command -v timeout >/dev/null 2>&1; then
    timeout 600 node "$driver" "$CLI_WASM" "$src" "$jobs" "$PWD" "$node_runner" >"$out_log" 2>&1 || warm_status=$?
  elif command -v gtimeout >/dev/null 2>&1; then
    gtimeout 600 node "$driver" "$CLI_WASM" "$src" "$jobs" "$PWD" "$node_runner" >"$out_log" 2>&1 || warm_status=$?
  else
    node "$driver" "$CLI_WASM" "$src" "$jobs" "$PWD" "$node_runner" >"$out_log" 2>&1 || warm_status=$?
  fi
  if [ "$warm_status" -ne 0 ]; then
    echo "vibe: --jobs=$jobs frontend pre-warm failed, compiling serially: $(tail -1 "$out_log")" >&2
  fi
  rm -f "$out_log"
}

# Live, line-buffered funcmap annotator for the runner's stderr (replaces an awk
# filter whose block-buffered FIFO input deadlocked interactive `--break` / DAP
# stepping — see the FIFO comment in the `run` case). Reads stdin, writes the
# annotated stream to stdout, appending ` (<base>:<line>)` to every trace/frame
# line that names a funcmap function. A bash `read` loop is line-buffered by
# construction, so a paused runner's frames flush immediately.
#
# Per-frame lookup MUST be O(1): the DAP server (clients/js/dap_server.js) resets a
# 30ms idle-flush timer on each frame line and emits `stopped` once no further
# frame arrives within that window. A funcmap can hold thousands of entries (the
# generated selfhost adapter), so an O(n) scan per frame could open a >30ms gap
# between frames, truncating the stack the client sees. We therefore mirror the
# original awk's associative-array lookup using bash variable indirection (one
# `_fmln_<name>` variable per entry), which is O(1) and works on bash 3.2 (macOS)
# where associative arrays do not exist. Names are validated to a safe identifier
# shape before they touch `eval`/indirection (funcmap names are top-level vibe
# functions, i.e. `[A-Za-z_][A-Za-z0-9_]*`).
#   $1 = funcmap path ("" / missing => pass input through unannotated)
#   $2 = entry source basename (the (<base>:<line>) suffix)
annotate_run_stream() {
  local fm="$1" base="$2"
  local _nm _ln line rest name varname hit tab
  tab="$(printf '\t')"
  # Load the funcmap (one "name<TAB>declLine" per line) into per-name variables
  # `_fmln_<name>` for O(1) indirect lookup. Runs in the backgrounded subshell
  # that calls this function, so these never leak into the launcher's scope.
  if [ -n "$fm" ] && [ -s "$fm" ]; then
    while IFS="$tab" read -r _nm _ln || [ -n "$_nm" ]; do
      _ln="${_ln%%[[:space:]]*}"          # keep only the leading line number
      case "$_nm" in [A-Za-z_]*) ;; *) continue ;; esac
      case "$_nm" in *[!A-Za-z0-9_]*) continue ;; esac
      case "$_ln" in ""|*[!0-9]*) continue ;; esac
      eval "_fmln_${_nm}=${_ln}"
    done < "$fm"
  fi
  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in
      "trace: "*) rest="${line#trace: }"; name="${rest%%[[:space:]]*}" ;;
      "  at "*)   rest="${line#  at }";   name="${rest%%[[:space:]]*}" ;;
      *"!"*)      rest="${line#*!}";      name="${rest%%[[:space:]]*}" ;;
      *)          printf '%s\n' "$line"; continue ;;
    esac
    hit=""
    # Only plain identifiers can be funcmap keys; anything else (e.g. `Env::get`)
    # is never in the map, so skip the lookup. `${!varname+x}` is set-u safe even
    # when the target is unset (the `+` test never dereferences an unset name).
    case "$name" in
      [A-Za-z_]*) case "$name" in *[!A-Za-z0-9_]*) name="" ;; esac ;;
      *) name="" ;;
    esac
    if [ -n "$name" ]; then
      varname="_fmln_${name}"
      if [ -n "${!varname+x}" ]; then hit="${!varname}"; fi
    fi
    if [ -n "$hit" ]; then
      printf '%s (%s:%s)\n' "$line" "$base" "$hit"
    else
      printf '%s\n' "$line"
    fi
  done
}

# #948: condense a failed `vibe test` run's captured stderr — a full
# wasmtime/anyhow trap dump (wasm backtrace + 15+ Rust runner frames down to
# pthread_create.c) — to the lines that matter: which test failed (the
# `__test_<name>` frame), why (the `wasm trap:` reason), and the user-function
# frames, each annotated ` (<base>:<line>)` via the same `.funcmap` sidecar
# `vibe run` uses for trap annotation. If nothing recognizable is found the
# raw stream is replayed verbatim so information is never lost.
#   $1 = captured stderr file, $2 = funcmap path (""/missing ok), $3 = entry basename
condense_test_trap() {
  local errf="$1" fm="$2" base="$3" out
  [ -s "$errf" ] || return 0
  [ -f "$fm" ] || fm=""
  out="$(awk -v base="$base" -v fmfile="$fm" '
    BEGIN {
      if (fmfile != "") {
        while ((getline l < fmfile) > 0) {
          n = split(l, a, "\t")
          if (n >= 2 && a[1] != "" && a[2]+0 > 0) fmln[a[1]] = a[2]+0
        }
        close(fmfile)
      }
    }
    !seen_test && match($0, /__test_[A-Za-z0-9_]+/) {
      seen_test = 1
      failing = substr($0, RSTART + 7, RLENGTH - 7)
    }
    !seen_reason && /RuntimeError:|wasm trap:/ {
      seen_reason = 1
      reason = $0
      sub(/^[[:space:]]+/, "", reason)
      sub(/^[0-9]+: /, "", reason)
      sub(/^viberun: /, "", reason)
    }
    nframes < 6 {
      fn = ""
      if (match($0, /^[[:space:]]+at [A-Za-z0-9_$.]+ \(wasm:/)) {
        fn = $0; sub(/^[[:space:]]+at /, "", fn); sub(/ \(wasm:.*/, "", fn)
      } else if ($0 ~ /<unknown>!/ && match($0, /![A-Za-z0-9_]+/)) {
        fn = substr($0, RSTART + 1, RLENGTH - 1)
      }
      if (fn != "" && fn != "_start" && fn !~ /^__test_/) {
        nframes++
        if (fn in fmln) frames[nframes] = "  at " fn " (" base ":" fmln[fn] ")"
        else            frames[nframes] = "  at " fn
      }
    }
    END {
      if (failing != "") print "  failing test: " failing
      if (reason != "")  print "  trap: " reason
      for (i = 1; i <= nframes; i++) print frames[i]
    }
  ' "$errf")"
  if [ -n "$out" ]; then
    printf '%s\n' "$out"
  else
    cat "$errf"
  fi
}

cmd="${1:-help}"; shift || true
case "$cmd" in
  run)
    # `--trace` (debugger / DAP P1 groundwork): compile with function-call
    # execution-trace instrumentation and dump the entry sequence (each line
    # annotated with its source location via the funcmap, like trap frames).
    trace=0
    brk=0
    mem=0
    mem_sample=""
    allocsite=0
    allocsite_top=""
    break_spec=""
    args=()
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --trace) trace=1; shift ;;
        --break) brk=1; break_spec="$2"; shift 2 ;;
        --break=*) brk=1; break_spec="${1#--break=}"; shift ;;
        --mem) mem=1; shift ;;
        --mem-sample) mem_sample=1; shift ;;
        --mem-sample=*) mem_sample="${1#--mem-sample=}"; shift ;;
        --alloc-site) allocsite=1; shift ;;
        --alloc-site=*) allocsite=1; allocsite_top="${1#--alloc-site=}"; shift ;;
        *) args+=("$1"); shift ;;
      esac
    done
    set -- "${args[@]}"
    [ "$#" -ge 1 ] || die "usage: vibe run [--trace] [--break <fn>[,<fn>...]] [--mem] [--alloc-site[=N]] <file.vibex> [-- args]"
    [ "$brk" = "1" ] && [ -z "$break_spec" ] && die "usage: vibe run --break <fn>[,<fn>...] <file.vibex>"
    src="$1"
    case "$src" in
      *.vibex) ;;
      *) die "vibe run requires a .vibex executable root: $src" ;;
    esac
    # ADR-0075: `main` is the only user-visible entry. A second positional
    # value is no longer interpreted as an ABI symbol; guest argv starts only
    # after the literal `--` separator.
    [ "$#" -lt 2 ] || [ "$2" = "--" ] || die ".vibex entry is always main; arbitrary entry names are not allowed"
    entry="main"
    shift 1
    [ "$#" -ge 1 ] && [ "$1" = "--" ] && shift 1
    extra_args=("$@")
    [ -f "$src" ] || die "not found: $src"
    out="$(mktemp -t vibe-run-XXXXXX.wasm)"
    trap 'rm -f "$out" "$out.funcmap"' EXIT
    # `--alloc-site` reuses the break-mode codegen (statement-boundary `dbg_line`
    # hooks) WITHOUT any breakpoints — the runner attributes heap growth per line.
    # So compile with break instrumentation if either `--break` or `--alloc-site`.
    compile_brk="$brk"
    [ "$allocsite" = "1" ] && compile_brk=1
    compile_to "$src" "$out" "$entry" "$trace" "$compile_brk" 1 || die "compilation failed: $src"
    # debugger テーマ3 / M2: runtime traps point at the SOURCE LINE. The wasm
    # "name" custom section already makes trap backtrace frames name the user
    # function (e.g. `<unknown>!boom`). The FS compile also wrote a `<out>.funcmap`
    # sidecar (name<TAB>line) for the entry file's top-level functions. We run the
    # runner capturing stderr, and for each frame naming a funcmap function append
    # ` (<basename>:<line>)`, e.g. `<unknown>!boom (prog.vibex:1)`. stdout and the
    # exit status pass through unchanged; with no funcmap/no match stderr is
    # untouched. (See scripts/test_vibe_trace.sh.)
    status=0
    trace_env=""
    [ "$trace" = "1" ] && trace_env="VIBE_TRACE_OUT=1"
    # `--break`: pass the breakpoint spec via VIBE_BREAK and keep stdin attached
    # so the runner can read step commands (s/n/o/c/q) at each pause.
    # VIBE_BREAK_AUTO=1 makes the runner auto-continue without reading stdin. The
    # expansion-produced assignment must go through `env`, not a bare `$var`
    # prefix (bash would treat it as a command).
    break_env=""
    [ "$brk" = "1" ] && break_env="VIBE_BREAK=$break_spec"
    # `--mem`: report bump-allocator heap usage (peak/total) after the run. The
    # runner reads `__heap_ptr` before/after `_start` and prints the delta.
    mem_env=""
    [ "$mem" = "1" ] && mem_env="VIBE_MEM=1"
    # `--mem-sample[=MS]`: tier-3 heap sampling every MS ms (default 1).
    [ -n "$mem_sample" ] && mem_env="$mem_env VIBE_MEM_SAMPLE_MS=$mem_sample"
    # `--alloc-site[=N]`: tier-4 per-line allocation attribution. The runner reads
    # `__heap_ptr` at each `dbg_line` and credits the bump delta to the prior line;
    # N caps how many top sites are reported (default 20). No VIBE_BREAK is set, so
    # the break-instrumented build runs straight through without pausing.
    alloc_env=""
    [ "$allocsite" = "1" ] && alloc_env="VIBE_ALLOC_SITE=1"
    [ -n "$allocsite_top" ] && alloc_env="$alloc_env VIBE_ALLOC_SITE_TOP=$allocsite_top"
    # span-arc step5: line-granularity breakpoints. A `--break <file>:<line>` (or
    # bare `<line>`) spec passes through VIBE_BREAK unchanged; the runner resolves
    # the entering function's source line via the `.funcmap` sidecar (name<TAB>
    # declLine) we already generate for trap annotation. Hand the runner the
    # funcmap path + the entry file's basename so it can confirm the spec's file.
    # Function-name `--break <fn>` specs are unaffected (no line match attempted).
    # `--alloc-site` also needs the funcmap to resolve each attributed function to
    # its declaration line in the report.
    funcmap_env=""
    breakfile_env=""
    if { [ "$brk" = "1" ] || [ "$allocsite" = "1" ]; } && [ -s "$out.funcmap" ]; then
      funcmap_env="VIBE_FUNCMAP=$out.funcmap"
      breakfile_env="VIBE_BREAK_FILE=$(basename "$src")"
    fi
    # Stream the runner's stderr through the funcmap annotator LIVE via a FIFO,
    # so an interactive `--break` pause (and the VS Code DAP adapter) sees
    # "breakpoint hit:" / frames the moment they happen — not buffered until the
    # runner exits, which would deadlock interactive stepping (the runner pauses
    # waiting for stdin while its prompt sits unflushed). stdin stays attached so
    # step commands drive the runner; the annotator runs in the background
    # reading the FIFO line by line; we wait on it to drain.
    #
    # NOTE: the annotator MUST be line-buffered on INPUT, not just output. An awk
    # filter (mawk) opens the FIFO as a block-buffered stdio stream: it waits to
    # fill a full block before yielding the first line, so the runner's flushed
    # "breakpoint hit:" sits unread until the FIFO gets more bytes / closes — i.e.
    # only once the program continues. That silently reinstates the exact deadlock
    # this FIFO exists to avoid (the DAP `stopped` event never fires while paused,
    # so VS Code can't inspect a breakpoint). `awk`'s `fflush()` only fixes its
    # OUTPUT side and cannot fix this. A bash `read` loop is line-buffered by
    # construction, so each frame is emitted the instant the runner prints it.
    rerr_fifo="$(mktemp -u -t vibe-run-err-XXXXXX)"; mkfifo "$rerr_fifo"
    annotate_run_stream "$out.funcmap" "$(basename "$src")" < "$rerr_fifo" >&2 &
    annot_pid=$!
    env $trace_env $break_env $mem_env $alloc_env $funcmap_env $breakfile_env "$RUNNER" "$out" \
      ${extra_args[@]+"${extra_args[@]}"} 2>"$rerr_fifo" || status=$?
    wait "$annot_pid" 2>/dev/null || true
    rm -f "$rerr_fifo"
    exit "$status"
    ;;
  compile|build)
    [ "$#" -ge 1 ] || die "usage: vibe $cmd <file.vibe|file.vibex> [-o <out.wasm>] [--entry <name>] [--wit] [--minify] [--jobs N]"
    src=""; out=""; entry="main"; entry_override=0; wit=0; minify=0; jobs=1
    while [ "$#" -gt 0 ]; do
      case "$1" in
        -o|--output) out="$2"; shift 2 ;;
        --entry) entry="$2"; entry_override=1; shift 2 ;;
        --wit) wit=1; shift ;;
        --minify) minify=1; shift ;;
        --jobs) [ "$#" -ge 2 ] || die "--jobs requires a value"; jobs="$2"; shift 2 ;;
        *) src="$1"; shift ;;
      esac
    done
    [ -n "$src" ] || die "no input file"
    case "$jobs" in
      ''|*[!0-9]*) die "--jobs must be a positive integer, got: $jobs" ;;
    esac
    [ "$jobs" -ge 1 ] || die "--jobs must be a positive integer, got: $jobs"
    [ -f "$src" ] || die "not found: $src"
    case "$src" in
      *.vibex)
        [ "$entry_override" = "0" ] || die ".vibex entry is always main; --entry is not allowed"
        entry="main"
        ;;
    esac
    if [ "$wit" = "1" ]; then
      case "$src" in
        *.vibex) die "--wit for .vibex requires semantic contract emission" ;;
      esac
      # #537: emit the WIT world for the file's effect surface (exported
      # functions -> world exports, their `with { E }` rows -> world imports).
      # Contract: docs/effect-wit-mapping.md.
      [ -n "$out" ] || out="${src%.vibe}.wit"
      cli="$(pick_cli)"
      mkdir -p "$(dirname "$out")"
      rm -f "$out" "$out.diag"
      env VIBE_EMIT_WIT=1 VIBE_IMPORT_ABI=raw \
        "$RUNNER" "$cli" "$src" "$out" main >/dev/null 2>&1 || true
      if [ ! -s "$out" ]; then
        [ -s "$out.diag" ] && echo "error: $(cat "$out.diag")" >&2
        rm -f "$out" "$out.diag"
        die "wit generation failed: $src"
      fi
      rm -f "$out.diag"
      echo "wit $src -> $out"
      exit 0
    fi
    if [ -z "$out" ]; then
      case "$src" in
        *.vibex) out="${src%.vibex}.wasm" ;;
        *) out="${src%.vibe}.wasm" ;;
      esac
    fi
    maybe_warm_frontend_cache "$jobs" "$src"
    compile_to "$src" "$out" "$entry" || die "compilation failed: $src"
    # #1107 Phase 2: `--minify` post-processes the executable with the
    # standalone size optimizer (`vibe-opt.wasm`, lib/@vibe/optimizer built as
    # its own artifact — see scripts/build_vibe_opt.sh). Deliberately opt-in
    # while it accumulates mileage; scripts/minify_gate.sh is the
    # semantics-preservation gate (stdout compare + wasmtime validate).
    if [ "$minify" = "1" ]; then
      opt_wasm="${VIBE_OPT_WASM:-}"
      if [ -z "$opt_wasm" ]; then
        for cand in "$TOOLCHAIN_DIR/lib/vibe-opt.wasm" "$PWD/_build/vibe-opt.wasm"; do
          [ -f "$cand" ] && { opt_wasm="$cand"; break; }
        done
      fi
      [ -n "$opt_wasm" ] && [ -f "$opt_wasm" ] || die "--minify: vibe-opt.wasm not found; build it with scripts/build_vibe_opt.sh or set VIBE_OPT_WASM"
      # Converge one round per invocation (#1109): the optimizer allocates via
      # the bump allocator and never frees, so in-process converge exhausts
      # guest memory on large modules; a fresh instance per round resets it.
      # Feature-detect --single-round in the artifact (same grep trick as the
      # VIBE_CHECK_ONLY probe above) — an older vibe-opt.wasm would read the
      # flag as its input path, so fall back to one in-process converge there.
      if grep -aq -- "--single-round" "$opt_wasm" 2>/dev/null; then
        mtmp="$(mktemp -t vibe-minify-XXXXXX.wasm)"
        mback="$(mktemp -t vibe-minify-backup-XXXXXX.wasm)"
        mprev=$(wc -c <"$out")
        mrounds=0
        # #1122: back up before each round and restore on a non-shrinking
        # round instead of committing it to $out first and only then
        # checking — a round that doesn't shrink must not become the final
        # output (mirrors the same fix in scripts/minify_wasm.sh).
        while [ "$mrounds" -lt "${VIBE_MINIFY_MAX_ROUNDS:-16}" ]; do
          cp "$out" "$mback"
          "$RUNNER" "$opt_wasm" --single-round "$out" "$mtmp" >/dev/null || { rm -f "$mtmp" "$mback"; die "--minify failed for $out"; }
          msize=$(wc -c <"$mtmp")
          mrounds=$((mrounds + 1))
          if [ "$msize" -ge "$mprev" ]; then
            mv "$mback" "$out"
            break
          fi
          mv "$mtmp" "$out"
          mprev="$msize"
        done
        rm -f "$mtmp" "$mback"
      else
        "$RUNNER" "$opt_wasm" "$out" "$out" || die "--minify failed for $out"
      fi
    fi
    echo "compiled $src -> $out"
    ;;
  serve)
    # #537: serve a wasi-http P3 handler. The compiler (VIBE_SERVE_COMPONENT=1)
    # produces ONLY the handler component (+ WIT sidecar); this runner layer
    # owns adapter resolution, composition (wac), and `wasmtime serve`.
    #
    # Handler contract (scripts/build_wasi_http_p3_full_adapter.sh):
    #   export let handler = (method: String, url: String, headers: String, body: String) -> String
    # returning "STATUS\n<Header: value lines>\n\n<body>".
    [ "$#" -ge 1 ] || die "usage: vibe serve <handler.vibe> [--addr host:port] [--port N] [-o out.component.wasm] [--adapter adapter.component.wasm] [--no-run]"
    src=""; out=""; addr="127.0.0.1:8080"; adapter="${VIBE_HTTP_ADAPTER:-}"; no_run=0
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --addr) addr="$2"; shift 2 ;;
        --port) addr="127.0.0.1:$2"; shift 2 ;;
        -o|--output) out="$2"; shift 2 ;;
        --adapter) adapter="$2"; shift 2 ;;
        --no-run) no_run=1; shift ;;
        *) src="$1"; shift ;;
      esac
    done
    [ -n "$src" ] || die "no input file"
    [ -f "$src" ] || die "not found: $src"
    [ -n "$out" ] || out="${src%.vibe}.component.wasm"
    # 1. handler component + WIT sidecar (compiler artifact step)
    cli="$(pick_cli)"
    mkdir -p "$(dirname "$out")"
    rm -f "$out" "$out.diag"
    env VIBE_SERVE_COMPONENT=1 VIBE_SERVE_WIT_OUT="${out%.component.wasm}.wit" \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" main >/dev/null 2>&1 || true
    if [ ! -s "$out" ]; then
      [ -s "$out.diag" ] && echo "error: $(cat "$out.diag")" >&2
      rm -f "$out" "$out.diag"
      die "serve: handler componentization failed: $src"
    fi
    rm -f "$out.diag"
    echo "component $src -> $out"
    [ "$no_run" = "1" ] && exit 0
    # 2. P3 HTTP adapter (imports `handler`, exports wasi:http/handler@0.3)
    if [ -z "$adapter" ]; then
      for cand in "$TOOLCHAIN_DIR/lib/vibe_http_p3_full_adapter.component.wasm" \
                  "$PWD/_build/http_adapter/vibe_http_p3_full_adapter.component.wasm"; do
        [ -f "$cand" ] && { adapter="$cand"; break; }
      done
    fi
    [ -n "$adapter" ] && [ -f "$adapter" ] || die "serve: P3 HTTP adapter not found; build it with scripts/build_wasi_http_p3_full_adapter.sh (needs cargo + wasm-tools) or pass --adapter / set VIBE_HTTP_ADAPTER"
    # 3. compose: plug the handler component's export into the adapter's import
    command -v wac >/dev/null 2>&1 || die "serve: 'wac' not found (cargo install wac-cli)"
    composed="${out%.component.wasm}.serve.wasm"
    wac plug --plug "$out" "$adapter" -o "$composed" || die "serve: wac plug failed"
    echo "composed -> $composed"
    # 4. serve (wasmtime 45 P3 flags; docs/spec/wasi-p3-async.md §4.1)
    command -v wasmtime >/dev/null 2>&1 || die "serve: 'wasmtime' not found (https://wasmtime.dev)"
    echo "serving http://$addr/ (Ctrl-C to stop)"
    exec wasmtime serve -Sp3 -Shttp -W exceptions=y -W concurrency-support=y \
      -W component-model-async=y -W component-model-async-stackful=y \
      --addr "$addr" "$composed"
    ;;
  check)
    if [ "${1:-}" = "--missing-vpkg" ]; then
      # #897 (ADR-0070): `vibe check --missing-vpkg <root>` -- recursively
      # report every directory under <root> still on the old index.vibei /
      # bare index.vibe spelling. Mirrors the `hash)` case's env-var +
      # adapter invocation shape below.
      root="${2:-}"
      [ -n "$root" ] || die "usage: vibe check --missing-vpkg <root>"
      [ -d "$root" ] || die "not found: $root"
      cli="$(pick_cli)"
      mout="$(mktemp -t vibe-check-vpkg-XXXXXX)"
      env VIBE_MISSING_VPKG_SCAN=1 "$RUNNER" "$cli" "$root" "$mout" __no_entry__ >/dev/null 2>&1 || true
      if [ -s "$mout.diag" ]; then
        cat "$mout.diag" >&2
        rm -f "$mout" "$mout.diag"
        die "missing-vpkg scan failed for $root"
      fi
      cat "$mout"
      rc=1
      grep -q '^ok: no directories missing index.vpkg' "$mout" 2>/dev/null && rc=0
      rm -f "$mout" "$mout.diag"
      exit "$rc"
    fi
    if [ "${1:-}" = "--deps-missing" ]; then
      # #1145 follow-up 2: `vibe check --deps-missing <root>` -- recursively
      # report every #1128-migrated index.vpkg whose own imports reference
      # an external package absent from its `deps` block. Mirrors
      # `--missing-vpkg` immediately above.
      root="${2:-}"
      [ -n "$root" ] || die "usage: vibe check --deps-missing <root>"
      [ -d "$root" ] || die "not found: $root"
      cli="$(pick_cli)"
      dout="$(mktemp -t vibe-check-deps-XXXXXX)"
      env VIBE_DEPS_MISSING_SCAN=1 "$RUNNER" "$cli" "$root" "$dout" __no_entry__ >/dev/null 2>&1 || true
      if [ -s "$dout.diag" ]; then
        cat "$dout.diag" >&2
        rm -f "$dout" "$dout.diag"
        die "deps-missing scan failed for $root"
      fi
      cat "$dout"
      rc=1
      grep -q '^ok: no missing deps declarations' "$dout" 2>/dev/null && rc=0
      rm -f "$dout" "$dout.diag"
      exit "$rc"
    fi
    src=""; jobs=1
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --jobs) [ "$#" -ge 2 ] || die "--jobs requires a value"; jobs="$2"; shift 2 ;;
        *) [ -z "$src" ] || die "usage: vibe check [--jobs N] <file.vibe>"; src="$1"; shift ;;
      esac
    done
    [ -n "$src" ] || die "usage: vibe check [--jobs N] <file.vibe>"
    case "$jobs" in
      ''|*[!0-9]*) die "--jobs must be a positive integer, got: $jobs" ;;
    esac
    [ "$jobs" -ge 1 ] || die "--jobs must be a positive integer, got: $jobs"
    [ -f "$src" ] || die "not found: $src"
    # #906 Phase 2 (real-check wiring): same best-effort pre-warm `build`
    # already gets (maybe_warm_frontend_cache above) -- `check` walks the
    # exact same import graph via the exact same persistent type-env cache,
    # it just stops before codegen, so the warm pass helps identically and
    # is equally a no-op/never-fails when --jobs is left at its default 1.
    maybe_warm_frontend_cache "$jobs" "$src"
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    # #946(3): parse + typecheck ONLY (imports resolved from the FS) — no
    # codegen, no entry-function requirement. Previously this reused the full
    # build path (compile_to, VIBE_FS_COMPILE), so a file with genuinely
    # nothing to check (empty / comments-only) failed with "no functions found
    # to compile" — a real BUILD constraint (you cannot emit a runnable wasm
    # module with zero functions) that has nothing to do with whether the
    # source itself parses and typechecks cleanly. `vibe diagnostics` on that
    # same file correctly reported clean; the two verbs contradicted each
    # other on identical input. VIBE_CHECK_ONLY (cli_adapter.vibe)
    # runs check_linked_file (Gate v2 #492: parse+typecheck, stops before
    # codegen) instead.
    #
    # Feature-detect: an OLDER compiler wasm (e.g. the committed bootstrap
    # seed, which predates this) does not recognize VIBE_CHECK_ONLY at all —
    # it silently falls through every env-var branch to the DEFAULT single-
    # file compile (no FS import resolution), which then fails on a
    # perfectly good multi-file program. `grep` the wasm's own string data for
    # the literal env-var name it would have to reference to support this
    # mode — present only in a compiler built after #946(3) — and fall back
    # to the historical full-compile check for anything older, same as this
    # command behaved before this fix.
    if grep -aq "VIBE_CHECK_ONLY" "$cli" 2>/dev/null; then
      out="$(mktemp -t vibe-check-XXXXXX)"
      trap 'rm -f "$out" "$out.diag"' EXIT
      env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_TYPE_AT -u VIBE_DOC_AT -u VIBE_BINDING_AT -u VIBE_ESCAPES \
          -u VIBE_SYMBOLS -u VIBE_NORMALIZE -u VIBE_COVERAGE -u VIBE_DEBUG \
          -u VIBE_DEBUG_BREAK -u VIBE_EMIT_MODULE_SOURCE \
        VIBE_CHECK_ONLY=1 \
        VIBE_IMPORT_ABI=raw \
        "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
      if [ -s "$out" ]; then
        # #deprecated marker warnings (#1262 / ADR-0101): the adapter writes
        # `warning:` lines before the final `ok`. Non-fatal — surface on
        # stderr, keep exit 0.
        grep '^warning: ' "$out" >&2 || true
        echo "ok: $src"
        rm -f "$out" "$out.diag"
      else
        if [ -s "$out.diag" ]; then
          echo "error: $(cat "$out.diag")" >&2
        fi
        rm -f "$out" "$out.diag"
        die "check failed: $src"
      fi
    else
      out="$(mktemp -t vibe-check-XXXXXX.wasm)"
      trap 'rm -f "$out"' EXIT
      # A successful compile implies a clean parse + typecheck; surface failures.
      if compile_to "$src" "$out" main; then
        echo "ok: $src"
      else
        die "check failed: $src"
      fi
    fi
    ;;
  type-at)
    # vibe type-at <file.vibe> <line> <col>
    # Print the inferred type of the identifier at a 1-based (line, col). Empty
    # output means there is no env-visible identifier there. First LSP-hover
    # consumer of the real EIdent source offsets.
    [ "$#" -ge 3 ] || die "usage: vibe type-at <file.vibe> <line> <col>"
    src="$1"; ta_line="$2"; ta_col="$3"
    [ -f "$src" ] || die "not found: $src"
    # Use the portable .wasm (not the AOT .cwasm) for this query: the recovering
    # parser / type-error path trapped under the precompiled image on some hosts,
    # and these LSP queries are latency-amortized, so prefer correctness.
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-typeat-XXXXXX)"
    trap 'rm -f "$out"' EXIT
    # Clear any inherited compile-mode envs so this query can't be diverted to
    # the compile path (which would emit wasm bytes instead of the type string).
    # Don't `die` on a non-zero exit — emit whatever (possibly empty) result was
    # written so the LSP degrades gracefully instead of the whole query failing.
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_NORMALIZE -u VIBE_ESCAPES \
        -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_TYPE_AT=1 VIBE_TYPE_LINE="$ta_line" VIBE_TYPE_COL="$ta_col" \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  doc-at)
    # vibe doc-at <file.vibe> <line> <col>
    # Print the Rust-`///`-style doc comment attached to the identifier's
    # declaration at a 1-based (line, col). Empty output means there is no
    # doc comment there (#854). Same single-file-scope limitation as type-at.
    [ "$#" -ge 3 ] || die "usage: vibe doc-at <file.vibe> <line> <col>"
    src="$1"; da_line="$2"; da_col="$3"
    [ -f "$src" ] || die "not found: $src"
    # Use the portable .wasm (not the AOT .cwasm) for this query: the recovering
    # parser / type-error path trapped under the precompiled image on some hosts,
    # and these LSP queries are latency-amortized, so prefer correctness.
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-docat-XXXXXX)"
    trap 'rm -f "$out"' EXIT
    # Clear any inherited compile-mode envs so this query can't be diverted to
    # the compile path (which would emit wasm bytes instead of the doc text).
    # Don't `die` on a non-zero exit — emit whatever (possibly empty) result was
    # written so the LSP degrades gracefully instead of the whole query failing.
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_NORMALIZE -u VIBE_TYPE_AT \
        -u VIBE_BINDING_AT -u VIBE_SYMBOLS -u VIBE_ESCAPES -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK \
        -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_DOC_AT=1 VIBE_TYPE_LINE="$da_line" VIBE_TYPE_COL="$da_col" \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  binding-at)
    # vibe binding-at <file.vibe> <line> <col>
    # Print the source spans of every occurrence of the identifier binding under
    # a 1-based (line, col), one `START END` (two char offsets) per line. AST-
    # accurate (no string/comment/substring false matches). Empty output means
    # there is no identifier there. Groundwork for scope-precision rename /
    # references (name-based MVP, no shadowing analysis yet).
    [ "$#" -ge 3 ] || die "usage: vibe binding-at <file.vibe> <line> <col>"
    src="$1"; ba_line="$2"; ba_col="$3"
    [ -f "$src" ] || die "not found: $src"
    # Use the portable .wasm (not the AOT .cwasm): the recovering parser path
    # trapped under the precompiled image on some hosts, and these LSP queries are
    # latency-amortized, so prefer correctness.
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-bindat-XXXXXX)"
    trap 'rm -f "$out"' EXIT
    # Clear any inherited compile-mode envs so this query can't be diverted to the
    # compile path (which would emit wasm bytes instead of the occurrence list).
    # Don't `die` on a non-zero exit — emit whatever (possibly empty) result was
    # written so the LSP degrades gracefully instead of the whole query failing.
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_NORMALIZE -u VIBE_TYPE_AT -u VIBE_DOC_AT -u VIBE_ESCAPES \
        -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_BINDING_AT=1 VIBE_TYPE_LINE="$ba_line" VIBE_TYPE_COL="$ba_col" \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  symbols)
    # vibe symbols <file.vibe>
    # Print the declaration outline, one `NAME KIND START END` per line (KIND =
    # LSP SymbolKind int, START/END = char offsets of the name). AST-accurate
    # (no line-regex scan): handles multi-line decls and module-nested symbols.
    # Empty output means the file declares no top-level symbols. Powers LSP
    # document-outline / go-to-definition.
    [ "$#" -ge 1 ] || die "usage: vibe symbols <file.vibe>"
    src="$1"
    [ -f "$src" ] || die "not found: $src"
    # Use the portable .wasm (not the AOT .cwasm): the recovering-parser path
    # trapped under the precompiled image on some hosts, and these LSP queries
    # are latency-amortized, so prefer correctness.
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-symbols-XXXXXX)"
    trap 'rm -f "$out" "$out.diag"' EXIT
    # Clear any inherited compile-mode envs so this query can't be diverted to the
    # compile path (which would emit wasm bytes instead of the symbol list).
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_NORMALIZE -u VIBE_TYPE_AT -u VIBE_DOC_AT \
        -u VIBE_BINDING_AT -u VIBE_ESCAPES -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK \
        -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_SYMBOLS=1 \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    # #946: a lex/parse error crashing `symbol_spans` used to leave `$out`
    # empty with no way to tell that apart from "genuinely no symbols" (both
    # looked identical to a caller). The adapter now routes a thrown Error to
    # `$out.diag` (same sidecar convention as `check)` below) instead of
    # `$out` itself, so stdout here stays a pure outline (no risk of a
    # "plausible but wrong" symbol line) while stderr surfaces the failure.
    if [ -s "$out.diag" ]; then
      echo "error: $(cat "$out.diag")" >&2
    fi
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  escapes)
    # vibe escapes <file.vibe>
    # Print the `let mut` bindings that ESCAPE their declaring scope by being
    # captured in a closure, one `NAME START END` per line (char offsets of the
    # binding's own name). Those are exactly the ones codegen lowers to a heap
    # ref cell instead of a plain wasm local -- 3.8-4.9x the local form, and
    # the only ones whose writes are observable from outside the declaring
    # scope (#1262, docs/side-effect-consolidation.md). Empty output means
    # every `let mut` in the file stays a local. A report, never an error.
    [ "$#" -ge 1 ] || die "usage: vibe escapes <file.vibe>"
    src="$1"
    [ -f "$src" ] || die "not found: $src"
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-escapes-XXXXXX)"
    trap 'rm -f "$out" "$out.diag"' EXIT
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_NORMALIZE -u VIBE_TYPE_AT -u VIBE_DOC_AT \
        -u VIBE_BINDING_AT -u VIBE_SYMBOLS -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK \
        -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_ESCAPES=1 \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    if [ -s "$out.diag" ]; then
      echo "error: $(cat "$out.diag")" >&2
    fi
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  diagnostics)
    # vibe diagnostics [--json] <file.vibe>
    # Print ALL diagnostics, one per line: every top-level syntax error (the
    # recovering parser resynchronizes past each failed statement) or, when the
    # file parses clean, the single located type error. Empty output = clean.
    # `--json` (#820 sub-item 1) emits the same diagnostics as a JSON array of
    # LSP-shaped Diagnostic objects (range/severity/source/message) instead --
    # a machine-readable form for tooling/agents, sharing its parser with the
    # `vibe lsp` publishDiagnostics path (lsp_diagnostics_json_string).
    diag_json=0
    if [ "${1:-}" = "--json" ]; then
      diag_json=1
      shift
    fi
    [ "$#" -ge 1 ] || die "usage: vibe diagnostics [--json] <file.vibe>"
    src="$1"
    [ -f "$src" ] || die "not found: $src"
    # Use the portable .wasm (not the AOT .cwasm): the recovering-parser /
    # type-error path trapped under the precompiled image on some hosts.
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-diag-XXXXXX)"
    trap 'rm -f "$out" "$out.diag"' EXIT
    # Clear any inherited compile-mode envs so a leaked VIBE_FS_COMPILE (etc.)
    # can't divert this into compiling and emitting wasm bytes as "diagnostics".
    # Don't `die` on a non-zero exit — emit whatever was written (a report, not a
    # failure) so the LSP treats empty as "no diagnostics" / falls back.
    diag_json_env=""
    [ "$diag_json" = "1" ] && diag_json_env=1
    env -u VIBE_FS_COMPILE -u VIBE_TYPE_AT -u VIBE_DOC_AT -u VIBE_NORMALIZE -u VIBE_ESCAPES \
        -u VIBE_COVERAGE -u VIBE_DEBUG -u VIBE_DEBUG_BREAK -u VIBE_EMIT_MODULE_SOURCE \
      VIBE_DIAGNOSTICS=1 \
      VIBE_DIAGNOSTICS_JSON="$diag_json_env" \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>&1 || true
    # #946(4): a pathologically deep expression can overflow the native call
    # stack while type-checking — a host-level crash (wasmtime's graceful
    # `Trap::StackOverflow` in runtime/viberun, or a JS RangeError in
    # scripts/wasm_vibe_host_runner.js) rather than an ordinary diagnostic —
    # that used to leave `$out` empty, reported as "clean" exactly like a
    # genuinely error-free file. Both runners now write that crash to the same
    # `.diag` sidecar the checker's own error paths use; surface it as a
    # diagnostic line instead of silently treating the crash as clean.
    # In --json mode this crash text never went through cli_adapter.vibe (the
    # process trapped at the host level before it could run), so it isn't
    # valid JSON on its own -- wrap it into a minimal one-entry array instead
    # of leaking raw text to a JSON consumer.
    if [ -s "$out.diag" ]; then
      if [ "$diag_json" = "1" ]; then
        diag_msg="$(cat "$out.diag" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ')"
        printf '[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":1,"source":"vibe","message":"%s"}]\n' "$diag_msg"
      else
        cat "$out.diag"
      fi
    elif [ "$diag_json" = "1" ] && [ ! -s "$out" ]; then
      # Clean file, JSON mode: still emit valid JSON (an empty array), not
      # empty output -- a JSON consumer must always get parseable JSON.
      echo "[]"
    fi
    # A clean file yields empty output; this subcommand always exits 0 (a report,
    # not a failure), so the LSP can treat empty as "no diagnostics".
    if [ -s "$out" ]; then cat "$out"; fi
    ;;
  normalize)
    # vibe normalize [--check|--stdout] <file.vibe>  (#882)
    # Canonicalize a source file via the in-compiler normalize engine
    # (parse -> module-flatten -> DCE from exported roots -> section layout,
    # lib/@vibe/compiler/normalize/index.vibe). Default rewrites in place;
    # --check exits 1 without writing when the file is not normalized;
    # --stdout prints the result without writing. Same engine
    # scripts/vibe_normalize.sh drives in-repo.
    nmode="write"
    case "${1:-}" in
      --check) nmode="check"; shift ;;
      --stdout) nmode="stdout"; shift ;;
      -*) die "vibe normalize: unknown flag: $1" ;;
    esac
    [ "$#" -ge 1 ] || die "usage: vibe normalize [--check|--stdout] <file.vibe>"
    src="$1"
    [ -f "$src" ] || die "not found: $src"
    cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
    out="$(mktemp -t vibe-normalize-XXXXXX.vibe)"
    trap 'rm -f "$out" "$out.diag" "$out.err"' EXIT
    # Clear inherited adapter-mode envs. Compile-mode leaks (VIBE_FS_COMPILE
    # etc.) would divert this into compiling wasm bytes as the "normalized
    # source"; worse, the adapter handles VIBE_HASH / VIBE_HASH_WRITE /
    # VIBE_MISSING_VPKG_SCAN / VIBE_DEPS_MISSING_SCAN / VIBE_FILL_PINS /
    # VIBE_PUBLISH_CHECK BEFORE VIBE_NORMALIZE, so a leaked selector would
    # write a hash/report as $out and the default write mode would copy it
    # OVER the user's source file.
    env -u VIBE_FS_COMPILE -u VIBE_DIAGNOSTICS -u VIBE_TYPE_AT -u VIBE_DOC_AT \
        -u VIBE_BINDING_AT -u VIBE_SYMBOLS -u VIBE_ESCAPES -u VIBE_COVERAGE -u VIBE_DEBUG \
        -u VIBE_DEBUG_BREAK -u VIBE_EMIT_MODULE_SOURCE \
        -u VIBE_HASH -u VIBE_HASH_WRITE \
        -u VIBE_MISSING_VPKG_SCAN -u VIBE_DEPS_MISSING_SCAN -u VIBE_FILL_PINS \
        -u VIBE_PUBLISH_CHECK \
      VIBE_NORMALIZE=1 \
      VIBE_IMPORT_ABI=raw \
      "$RUNNER" "$cli" "$src" "$out" >/dev/null 2>"$out.err" || true
    if [ ! -s "$out" ]; then
      # The normalize adapter branch has no .diag sidecar; the runner's stderr
      # (parse error etc.) is the only detailed diagnostic — replay it.
      [ -s "$out.diag" ] && cat "$out.diag" >&2
      [ -s "$out.err" ] && cat "$out.err" >&2
      die "normalize failed: $src"
    fi
    case "$nmode" in
      stdout) cat "$out" ;;
      check)
        if cmp -s "$src" "$out"; then
          exit 0
        else
          echo "not normalized: $src" >&2
          exit 1
        fi
        ;;
      write) cp "$out" "$src" ;;
    esac
    ;;
  test)
    # `vibe test [--no-cache] [--update] <file_test.vibe>...`
    #
    # Pure-test result cache (#634, ADR-0026): a file whose `test {}` blocks are
    # all transitively effect-free is a deterministic function of its source +
    # deps + compiler, so a prior PASS can be reused instead of re-running. The
    # compiler writes a `<out>.testmeta` verdict ("1" = cacheable) via
    # VIBE_TESTMETA_OUT; the key is sha256 of the compiled wasm (deterministic
    # compilation already folds source+deps+compiler into those bytes). Only PASS
    # is cached — a fail always re-runs. `--no-cache` bypasses the cache entirely.
    #
    # --update (#1061 follow-up, #1235 review): MoonBit `inspect`/`--update`
    # style inline-snapshot auto-update for lib/@vibe/core/assert.vibe's
    # `inspect(value, content)`. On a failing run, patch the stale `content`
    # literal to the diagnosed actual value (VIBE_INSPECT_UPDATE=1 mode on the
    # SAME cli_adapter.vibe::cli_main every other subcommand here already
    # uses — see that file's comment for why this isn't a separate compiled
    # entry file) and recompile+rerun, looping per file until it passes or a
    # run fails without a recognizable inspect() mismatch to patch. Implies
    # --no-cache (a file whose source is being rewritten under us must not
    # read or write the pass cache keyed by its old compiled bytes).
    no_cache=0
    update=0
    jobs=1
    files=""
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --no-cache) no_cache=1; shift ;;
        --update) update=1; no_cache=1; shift ;;
        --jobs) [ "$#" -ge 2 ] || die "--jobs requires a value"; jobs="$2"; shift 2 ;;
        *) files="$files $1"; shift ;;
      esac
    done
    case "$jobs" in
      ''|*[!0-9]*) die "--jobs must be a positive integer, got: $jobs" ;;
    esac
    [ "$jobs" -ge 1 ] || die "--jobs must be a positive integer, got: $jobs"
    # shellcheck disable=SC2086
    set -- $files
    [ "$#" -ge 1 ] || die "usage: vibe test [--no-cache] [--update] [--jobs N] <file_test.vibe> [more_test.vibe...]"
    # Codex review, PR #1175: validate every path up front and abort before
    # running anything, rather than discovering a missing file mid-run. The
    # original serial loop's own `[ -f "$src" ] || die ...` only fail-fast
    # aborted from the missing file onward -- files earlier in the argument
    # list had already compiled and run (potentially executing effectful
    # tests) by the time a later typo'd path was discovered. Checking all
    # paths first is strictly fail-faster than that, and gives the serial
    # and parallel drivers below identical semantics for a bad invocation.
    for src in "$@"; do
      [ -f "$src" ] || die "not found: $src"
    done
    tcache="${VIBE_TEST_CACHE:-$VIBE_HOME/cache/test}"

    # Runs one test file's compile+run+cache-check, printing its own ok/FAIL
    # line(s) exactly as the old fully-serial loop did. Returns pass/fail via
    # exit code (0/1) instead of mutating a shared `failed` var, so the
    # parallel driver below can run it inside a background subshell -- a
    # write to a parent-scope var from a background subshell would not be
    # visible to the parent anyway.
    # `--update`'s per-file patch loop: run the compiled test wasm, and on a
    # failing run try to patch the stale inspect() snapshot via cli_adapter's
    # VIBE_INSPECT_UPDATE mode, then signal the caller (_test_run_one) to
    # recompile+retry. Returns 0 on patch applied (caller should loop), 1 on
    # "give up, report FAIL as-is" (no recognizable mismatch, or the patch
    # step made no change -- e.g. the same content literal already patched
    # by an earlier iteration and something else in the file still fails).
    # $1 = src, $2 = captured stdout file from the failing run just now.
    _test_try_update_patch() {
      local usrc="$1" ucaptured="$2"
      local ucli="$CLI_WASM"; [ -f "$ucli" ] || ucli="$(pick_cli)"
      local upatched; upatched="$(mktemp -t vibe-test-patched-XXXXXX.vibe)"
      if env VIBE_INSPECT_UPDATE=1 VIBE_INSPECT_UPDATE_STDOUT="$ucaptured" VIBE_IMPORT_ABI=raw \
          "$RUNNER" "$ucli" "$usrc" "$upatched" >/dev/null 2>/dev/null \
          && [ -s "$upatched" ] && ! cmp -s "$usrc" "$upatched"; then
        cp "$upatched" "$usrc"
        rm -f "$upatched"
        return 0
      fi
      rm -f "$upatched"
      return 1
    }

    _test_run_one() {
      src="$1"
      warm_jobs="$2"
      [ -f "$src" ] || { echo "vibe: not found: $src" >&2; return 1; }
      # #906 Phase 2 (real-test wiring): same best-effort pre-warm `build`/
      # `check` get -- each test file is its own compile entry with its own
      # import graph, so warm it before this file's own compile_to below
      # (never changes output, only ever helps or no-ops; see
      # maybe_warm_frontend_cache's own doc comment above). `warm_jobs` is
      # forced to 1 (a no-op) from the parallel file-level driver -- see its
      # own comment for why nesting an inner pre-warm pool under outer
      # file-level parallelism would only add overhead, not speed.
      maybe_warm_frontend_cache "$warm_jobs" "$src"
      # --update: bounded convergence loop (mirrors the read-only path below
      # for iter 1; a file whose inspect() calls all already match runs this
      # loop body exactly once). Plain (non-update) runs fall straight
      # through via the `update_max=1` bound.
      update_patches=0
      update_max=1
      [ "$update" = "1" ] && update_max=50
      update_iter=0
      while :; do
        update_iter=$((update_iter + 1))
        out="$(mktemp -t vibe-test-XXXXXX.wasm)"
        meta="$out.testmeta"
        rm -f "$meta"
        # `__no_entry__` makes the compiler emit a `_start` that runs every
        # `test {}` block in the file; a failing assert traps the module. Ask
        # the compiler for the cache verdict in the same pass (unless
        # --no-cache, which --update implies).
        if [ "$no_cache" = "0" ]; then
          export VIBE_TESTMETA_OUT="$meta"
        else
          unset VIBE_TESTMETA_OUT
        fi
        if ! compile_to "$src" "$out" "__no_entry__"; then
          unset VIBE_TESTMETA_OUT
          echo "FAIL (compile): $src"; rm -f "$out" "$meta"; return 1
        fi
        unset VIBE_TESTMETA_OUT
        # #948: flag files with zero lowered `__test_` functions (a typo'd
        # block otherwise passes silently via an empty `_start`). Zero tests
        # stays ok (annotated, exit 0) so helper-only-file suites keep working.
        n_tests="$({ grep -ao '__test_' "$out" 2>/dev/null || true; } | wc -l | tr -d '[:space:]')"
        case "$n_tests" in ''|*[!0-9]*) n_tests=0 ;; esac
        note=""
        [ "$n_tests" = "0" ] && note=" (no tests found)"
        cacheable=0
        [ -s "$meta" ] && [ "$(cat "$meta" 2>/dev/null)" = "1" ] && cacheable=1
        key=""
        if [ "$no_cache" = "0" ] && [ "$cacheable" = "1" ]; then
          key="$(_sha256_file "$out")"
          if [ -n "$key" ] && [ -f "$tcache/$key.pass" ]; then
            echo "ok (cached): $src$note"
            rm -f "$out" "$meta"; return 0
          fi
        fi
        # #948: capture the runner's stderr. On success replay it verbatim
        # (tests may legitimately write to stderr); on failure condense the
        # trap dump to the failing test name + trap reason + funcmap-
        # annotated frames instead of 20+ lines of raw backtrace. --update
        # additionally captures stdout (discarded otherwise) since that's
        # where inspect()'s actual/expected mismatch diagnostic prints.
        terr="$(mktemp -t vibe-testerr-XXXXXX)"
        tout=""
        rc=0
        if [ "$update" = "1" ]; then
          tout="$(mktemp -t vibe-testout-XXXXXX)"
          "$RUNNER" "$out" >"$tout" 2>"$terr" || rc=1
        else
          "$RUNNER" "$out" 2>"$terr" || rc=1
        fi
        if [ "$rc" = "0" ]; then
          [ -s "$terr" ] && cat "$terr" >&2
          if [ "$update_patches" -gt 0 ]; then
            echo "ok:   $src$note (updated $update_patches snapshot(s))"
          else
            echo "ok:   $src$note"
          fi
          if [ -n "$key" ]; then
            mkdir -p "$tcache" && : > "$tcache/$key.pass"
          fi
          rm -f "$out" "$out.funcmap" "$meta" "$terr" "$tout"
          return 0
        fi
        # Failed. --update: try to patch the stale snapshot and loop; a
        # patch found and applied recompiles+reruns from the top instead of
        # reporting FAIL. Bounded by update_max so a file whose inspect()
        # calls can never all agree (or two calls with identical content
        # ping-ponging) fails loudly instead of looping forever.
        if [ "$update" = "1" ] && [ "$update_iter" -lt "$update_max" ] && _test_try_update_patch "$src" "$tout"; then
          update_patches=$((update_patches + 1))
          rm -f "$out" "$out.funcmap" "$meta" "$terr" "$tout"
          continue
        fi
        echo "FAIL: $src"
        condense_test_trap "$terr" "$out.funcmap" "$(basename "$src")" >&2
        rm -f "$out" "$out.funcmap" "$meta" "$terr" "$tout"
        return 1
      done
    }

    failed=0
    if [ "$jobs" -le 1 ] || [ "$#" -le 1 ]; then
      # Serial path -- unchanged behavior (including the per-file frontend
      # pre-warm, itself a no-op for jobs<=1 per maybe_warm_frontend_cache's
      # own gate).
      for src in "$@"; do
        _test_run_one "$src" "$jobs" || failed=1
      done
    else
      # #906/#1168 follow-up: `--jobs N` previously only pre-warmed each
      # file's OWN frontend typecheck cache (maybe_warm_frontend_cache) --
      # the compile+run step below it stayed fully serial regardless of N,
      # so --jobs never sped up the part of `vibe test` that actually
      # dominates wall time (compiling AND running each file's test blocks).
      # This runs up to `jobs` files' compile+run concurrently instead, in
      # same-sized batches (level-order, same shape as #1170's discovery-loop
      # fix) -- each batch's output is buffered per-file and printed in the
      # ORIGINAL file order after the whole batch finishes, not
      # launch-completion order, so `vibe test a b c --jobs 4` prints
      # identically to the serial path regardless of which file's wasmtime
      # process happens to finish first.
      #
      # Per-file frontend pre-warm is intentionally skipped (warm_jobs=1, a
      # no-op) rather than nested under this outer pool: with N files already
      # running as N separate host processes, an inner N-way Node worker pool
      # per file would oversubscribe the machine for no benefit -- #1169's
      # own KPI measurement found the pre-warm's discovery loop is a net
      # wall-time loss even un-nested; stacking it under file-level
      # parallelism here would only make that worse. Running compiles/runs
      # concurrently at all is safe only because #1173 made the persistent
      # cache's write path (both runners) atomic (temp+rename), closing the
      # exact partial-write race this concurrency would otherwise expose.
      while [ "$#" -gt 0 ]; do
        batch_pids=()
        batch_out_logs=()
        batch_err_logs=()
        n=0
        while [ "$#" -gt 0 ] && [ "$n" -lt "$jobs" ]; do
          src="$1"; shift
          out_log="$(mktemp -t vibe-test-batch-out-XXXXXX.log)"
          err_log="$(mktemp -t vibe-test-batch-err-XXXXXX.log)"
          # Codex review, PR #1175: buffer stdout/stderr separately (not a
          # merged 2>&1 log) so a caller that captures or parses the two
          # streams independently (e.g. `vibe test --jobs 4 ... 2>/dev/null`
          # expecting only ok/FAIL on stdout) sees the same split it would
          # from the serial path -- compiler diagnostics and condensed traps
          # stay on stderr instead of leaking onto stdout here.
          ( _test_run_one "$src" 1 > "$out_log" 2> "$err_log" ) &
          batch_pids+=("$!")
          batch_out_logs+=("$out_log")
          batch_err_logs+=("$err_log")
          n=$((n + 1))
        done
        for pid in "${batch_pids[@]}"; do
          wait "$pid" || failed=1
        done
        for i in "${!batch_out_logs[@]}"; do
          cat "${batch_out_logs[$i]}"
          cat "${batch_err_logs[$i]}" >&2
          rm -f "${batch_out_logs[$i]}" "${batch_err_logs[$i]}"
        done
      done
    fi
    exit "$failed"
    ;;
  bench)
    # `vibe bench <file.vibe> [--iters N] [--warmup N]` — compile the file's
    # `bench {}` blocks (via the `__no_entry__` test entry) then run the warm
    # instance N times, reporting ns/op (min/p50/p95/mean), ops/sec, and bytes/op
    # (bump-heap delta / iters). The harness lives in the runner (`--bench`).
    iters=""; warmup=""; bsrc=""
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --iters) iters="$2"; shift 2 ;;
        --iters=*) iters="${1#--iters=}"; shift ;;
        --warmup) warmup="$2"; shift 2 ;;
        --warmup=*) warmup="${1#--warmup=}"; shift ;;
        *) bsrc="$1"; shift ;;
      esac
    done
    [ -n "$bsrc" ] || die "usage: vibe bench <file.vibe> [--iters N] [--warmup N]"
    [ -f "$bsrc" ] || die "not found: $bsrc"
    out="$(mktemp -t vibe-bench-XXXXXX.wasm)"
    trap 'rm -f "$out"' EXIT
    compile_to "$bsrc" "$out" "__no_entry__" || die "compilation failed: $bsrc"
    bench_env="VIBE_BENCH_LABEL=$(basename "$bsrc")"
    [ -n "$iters" ] && bench_env="$bench_env VIBE_BENCH_ITERS=$iters"
    [ -n "$warmup" ] && bench_env="$bench_env VIBE_BENCH_WARMUP=$warmup"
    env $bench_env "$RUNNER" --bench "$out"
    ;;
  shell)
    # #805: `vibe shell [file.vibe]` — minimal compiled REPL.
    #
    # ADR-0034 mandates compiled-only execution (no interpreter), so the REPL
    # is "accumulate + recompile": the session is a buffer of top-level
    # declarations kept in a temp dir, and EVERY input line triggers a full
    # recompile of that buffer through the same compile_to path as `vibe run`:
    #   * a declaration line appends to the buffer, is validated by compiling
    #     the whole buffer, and is ROLLED BACK on any diagnostic — the buffer
    #     can never become poisoned;
    #   * an expression line is wrapped in a synthetic entry (the ADR-0069
    #     wrap-in-main story), compiled against the buffer, and the produced
    #     wasm is executed. Because each evaluation is a fresh program run,
    #     earlier side effects re-run on every line (see docs/cli-commands.md).
    # stdin not a tty => no prompt is printed (bash `read -p` only prompts on a
    # terminal), so `printf '...' | vibe shell` is deterministic for scripting.
    repl_file=""
    if [ "$#" -ge 1 ]; then repl_file="$1"; fi
    repl_dir="$(mktemp -d -t vibe-shell-XXXXXX)"
    trap 'rm -rf "$repl_dir"' EXIT
    repl_buf="$repl_dir/session.vibe"
    : > "$repl_buf"
    # Stdlib imports (`import ./lib/@vibe/...`) resolve inside the session dir
    # the same way the doctest harness resolves them: a `lib` symlink to the
    # install/checkout lib root. (Relative imports of OTHER user files do not
    # move with the session — load such files from their own directory.)
    [ -d "$VIBE_HOME/lib" ] && ln -sfn "$VIBE_HOME/lib" "$repl_dir/lib"

    # Validate the current buffer: compile buffer + an anchor fn with the test
    # entry (`__no_entry__`), so the WHOLE buffer is parse+type checked, not
    # just the decls reachable from one entry. The anchor mirrors the doctest
    # harness's __doctest_anchor: a declaration-only buffer otherwise fails
    # with "no functions found to compile". Diagnostics go to stderr via
    # compile_to; line N in the reported location = line N of the buffer
    # (`:list` shows it), because the anchor is appended after it.
    repl_validate() {
      local f="$repl_dir/__validate__.vibe" w="$repl_dir/__validate__.wasm"
      { cat "$repl_buf"
        printf 'export let __repl_anchor__: () -> Int = () -> { 0 }\n'
      } > "$f"
      compile_to "$f" "$w" "__no_entry__"
    }

    # Evaluate an expression: wrap it in a synthetic Int-returning let-entry
    # and let the runner's entry-return print convention (ADR-0069 `let main`
    # rule — the same internal channel used by the REPL today) show
    # the value. This is the ONLY printing channel that works on every
    # runner/compile-mode combination today:
    #   - prelude/io.vibe's stdout_write drags the io unit's whole `vibe::*`
    #     host-import surface into the wasm — viberun refuses to instantiate
    #     (unknown import `vibe::stdin_read_char`); the PR #927 CI failure.
    #   - the `println`/`print` checker builtins have no codegen lowering in
    #     bare FS-mode compiles ("undefined variable (local): println @call").
    #   - `Stdout::write_char` writes the raw TAGGED value (42 prints as
    #     "hd") on both runners.
    # Consequence: Int expressions print; non-Int expressions fall back to
    # the effects-only wrapper below with an honest notice. Richer printing
    # is blocked on the println-in-FS-mode gap (tracked in the #805 REPL
    # follow-ups).
    repl_eval() {
      local expr="$1" rc=0
      local f="$repl_dir/__eval__.vibe" w="$repl_dir/__eval__.wasm"
      local err1="$repl_dir/__eval__.err1"
      { cat "$repl_buf"
        printf 'export let __repl_main__: () -> Int = () -> {\n'
        printf '  (\n%s\n  )\n' "$expr"
        printf '}\n'
      } > "$f"
      if compile_to "$f" "$w" "__repl_main__" "" "" 1 2>"$err1"; then
        "$RUNNER" "$w" || rc=$?
        [ "$rc" -ne 0 ] && echo "vibe shell: runtime error (exit $rc)" >&2
        return 0
      fi
      # Fallback wrapper is deliberately UNANNOTATED so the effect row is
      # inferred — it absorbs non-Int expressions and expressions whose row
      # does not fit the Int wrapper. Cost: the runner prints the entry's
      # unit return as a trailing `0` line (annotated-Unit entries don't).
      { cat "$repl_buf"
        printf 'export let __repl_main__ = () -> {\n'
        printf '  let _ = (\n%s\n  )\n  ()\n}\n' "$expr"
      } > "$f"
      if compile_to "$f" "$w" "__repl_main__" "" "" 1 2>/dev/null; then
        echo "vibe shell: non-Int value; evaluated for effects only (use :type to inspect)" >&2
        "$RUNNER" "$w" || rc=$?
        [ "$rc" -ne 0 ] && echo "vibe shell: runtime error (exit $rc)" >&2
        return 0
      fi
      cat "$err1" >&2
      return 1
    }

    # `:type <expr>` — bind the expression to a top-level probe let appended
    # after the buffer, then ask the type-at editor primitive (the LSP hover
    # backend) for the probe's inferred type. col 5 = first char of
    # `__repl_t__` in `let __repl_t__ = (...)`. Empty output (bad expr /
    # query miss) degrades to a notice, never kills the session.
    repl_type() {
      local expr="$1"
      local f="$repl_dir/__type__.vibe" tline tout
      cp "$repl_buf" "$f"
      tline=$(( $(wc -l < "$f") + 1 ))
      printf 'let __repl_t__ = (%s)\n' "$expr" >> "$f"
      tout="$("$LAUNCHER" type-at "$f" "$tline" 5 2>/dev/null || true)"
      if [ -n "$tout" ]; then
        printf '%s\n' "$tout"
      else
        echo "vibe shell: could not infer a type for: $expr" >&2
      fi
    }

    if [ -n "$repl_file" ]; then
      [ -f "$repl_file" ] || die "not found: $repl_file"
      cat "$repl_file" >> "$repl_buf"
      printf '\n' >> "$repl_buf"
      repl_validate || die "shell: preloaded file does not compile: $repl_file"
    fi

    while IFS= read -r -p 'vibe> ' repl_line; do
      # trim surrounding whitespace; skip blank lines
      repl_trimmed="${repl_line#"${repl_line%%[![:space:]]*}"}"
      repl_trimmed="${repl_trimmed%"${repl_trimmed##*[![:space:]]}"}"
      [ -n "$repl_trimmed" ] || continue
      case "$repl_trimmed" in
        :q|:quit)
          break
          ;;
        :help)
          cat <<'REPL_HELP'
vibe shell — compiled REPL (accumulate + recompile; no interpreter)
  :help            show this help
  :quit, :q        exit
  :list            print the session buffer (accumulated declarations)
  :clear           reset the session buffer
  :load <file>     append a file's declarations to the buffer (validated)
  :type <expr>     print the inferred type of an expression
Any line starting with a declaration keyword (fn/let/struct/enum/type/
import/effect/impl/trait/export/suberror/test) is appended to the session
buffer; the append is validated by a full recompile and rolled back on
error. Any other line is compiled as an expression against the buffer and
executed. Declarations must fit on one line. Each evaluation runs a fresh
program, so earlier side effects re-run on every expression line.
REPL_HELP
          ;;
        :list)
          cat "$repl_buf"
          ;;
        :clear)
          : > "$repl_buf"
          echo "session cleared"
          ;;
        :load)
          echo "usage: :load <file.vibe>" >&2
          ;;
        :load\ *)
          repl_lf="${repl_trimmed#:load }"
          if [ ! -f "$repl_lf" ]; then
            echo "vibe shell: not found: $repl_lf" >&2
            continue
          fi
          cp "$repl_buf" "$repl_buf.bak"
          cat "$repl_lf" >> "$repl_buf"
          printf '\n' >> "$repl_buf"
          if repl_validate; then
            rm -f "$repl_buf.bak"
            echo "loaded $repl_lf"
          else
            mv -f "$repl_buf.bak" "$repl_buf"
            echo "vibe shell: load rejected (buffer unchanged): $repl_lf" >&2
          fi
          ;;
        :type)
          echo "usage: :type <expr>" >&2
          ;;
        :type\ *)
          repl_type "${repl_trimmed#:type }"
          ;;
        :*)
          echo "vibe shell: unknown command: ${repl_trimmed%% *} (try :help)" >&2
          ;;
        fn\ *|let\ *|struct\ *|enum\ *|type\ *|import\ *|effect\ *|impl\ *|trait\ *|export\ *|suberror\ *|test\ *|//*)
          cp "$repl_buf" "$repl_buf.bak"
          printf '%s\n' "$repl_line" >> "$repl_buf"
          if repl_validate; then
            rm -f "$repl_buf.bak"
          else
            mv -f "$repl_buf.bak" "$repl_buf"
            echo "vibe shell: declaration rejected (buffer unchanged)" >&2
          fi
          ;;
        *)
          repl_eval "$repl_trimmed" || true
          ;;
      esac
    done
    exit 0
    ;;
  new)
    # vibe new <dir>  — scaffold a starter project (main.vibex + empty vibe.deps)
    [ "$#" -ge 1 ] || die "usage: vibe new <dir>"
    ndir="$1"
    [ -e "$ndir" ] && die "already exists: $ndir"
    mkdir -p "$ndir"
    # #1429 retired the braced effect row (`with { Stdout }`) and the
    # console-exception-rowvar-2026-08-06 seed bump (db4f05c5) started enforcing
    # it, so the scaffold has to emit the one surviving spelling. 4f7fb728
    # chased the same follow-up in scripts/vibe_normalize_smoke.sh but missed
    # this template, which is what makes `smoke (install)` fail on
    # `vibe run scaffold` with an empty result. This line is the FIRST vibe
    # source a new user sees AND it is compiled by the toolchain this launcher
    # installs, so it can never lag the surface syntax.
    printf 'fn main with Stdout {\n  Stdout::write_stream("42\\n")\n}\n' > "$ndir/main.vibex"
    printf '# vibe dependencies: one `<name> <url>` per line (see `vibe add`).\n' > "$ndir/vibe.deps"
    echo "created $ndir (run: vibe run $ndir/main.vibex)"
    ;;
  add)
    # vibe add <name> <url> [project_dir]
    # Append a dependency to <dir>/vibe.deps (creating it if needed) and fetch.
    [ "$#" -ge 2 ] || die "usage: vibe add <name> <url> [project_dir]"
    aname="$1"; aurl="$2"; adir="${3:-.}"
    amanifest="$adir/vibe.deps"
    mkdir -p "$adir"
    # Skip if an identical name is already declared; else append.
    if [ -f "$amanifest" ] && awk -v n="$aname" '$1==n {found=1} END{exit !found}' "$amanifest"; then
      die "dependency '$aname' already declared in $amanifest"
    fi
    printf '%s %s\n' "$aname" "$aurl" >> "$amanifest"
    echo "added $aname -> $aurl ($amanifest)"
    exec "$LAUNCHER" fetch "$adir"
    ;;
  fetch)
    # vibe fetch [--frozen] [project_dir]
    # Vendor remote (git/URL 分散) deps declared in <dir>/vibe.deps into
    # <dir>/deps/ and write a content-addressed <dir>/vibe.lock. Each manifest
    # line is `<name> <url>` (# comments allowed). Import a vendored dep with
    # `import ./deps/<name>.vibe { ... }`. With --frozen, git deps are pinned to
    # the commit recorded in the existing vibe.lock (reproducible builds).
    frozen=0
    while [ "$#" -gt 0 ] && case "$1" in --frozen) frozen=1; shift; true ;; *) false ;; esac; do :; done
    dir="${1:-.}"
    manifest="$dir/vibe.deps"
    [ -f "$manifest" ] || die "no manifest: $manifest (declare deps as '<name> <url>' lines)"
    cache="${VIBE_CACHE:-$VIBE_HOME/cache}"
    mkdir -p "$cache" "$dir/deps"
    lock="$dir/vibe.lock"
    # Read any existing lock so --frozen can pin git deps to recorded commits.
    locked_commit() { [ -f "$lock" ] && awk -v n="$1" '$1==n {print $3}' "$lock" | sed 's/^git://'; }
    : > "$lock.tmp"
    while read -r name url _rest || [ -n "$name" ]; do
      case "$name" in ""|\#*) continue ;; esac
      [ -n "$url" ] || die "manifest line for '$name' has no url"
      case "$url" in
        git+*)
          # git dependency: `git+<remote>[#<ref>]`, vendored as a directory
          # deps/<name>/; import e.g. `import ./deps/<name>/index.vibe`.
          remote="${url#git+}"; ref=""
          case "$remote" in *"#"*) ref="${remote##*#}"; remote="${remote%#*}" ;; esac
          # With --frozen, the recorded commit (a full SHA, not a branch) wins
          # over any manifest ref so the build reproduces exactly.
          pinned=""
          if [ "$frozen" = "1" ]; then
            pinned="$(locked_commit "$name")"
            [ -n "$pinned" ] || die "--frozen: no locked commit for '$name' (run 'vibe fetch' first)"
            ref="$pinned"
          fi
          command -v git >/dev/null 2>&1 || die "git is required for git+ deps"
          # A semver constraint ref (^1.2, ~1.2.3, >=1.0, 1.x, ...) resolves to
          # the highest matching tag on the remote. Exact tags / branches /
          # commits pass through literally. Skipped when --frozen (commit wins).
          if [ -z "$pinned" ] && [ -n "$ref" ] && is_constraint "$ref"; then
            resolved="$(resolve_git_constraint "$remote" "$ref")"
            [ -n "$resolved" ] || die "no tag on $remote satisfies '$ref' (dep '$name')"
            echo "resolved $name: $ref -> $resolved"
            ref="$resolved"
          fi
          gtmp="$(mktemp -d -t vibe-git-XXXXXX)"
          if [ -n "$pinned" ]; then
            # A full SHA can't be reached by --branch/--depth 1; clone fully then
            # check it out so a moved upstream HEAD still resolves the pin.
            git clone --quiet "$remote" "$gtmp" || die "git clone failed: $remote"
            ( cd "$gtmp" && git checkout --quiet "$pinned" ) \
              || die "--frozen: locked commit $pinned not found in $remote"
          elif [ -n "$ref" ]; then
            git clone --quiet --depth 1 --branch "$ref" "$remote" "$gtmp" 2>/dev/null \
              || git clone --quiet "$remote" "$gtmp" || die "git clone failed: $remote"
            ( cd "$gtmp" && git checkout --quiet "$ref" 2>/dev/null ) || true
          else
            git clone --quiet --depth 1 "$remote" "$gtmp" || die "git clone failed: $remote"
          fi
          gsha="$( cd "$gtmp" && git rev-parse HEAD 2>/dev/null || echo unknown )"
          rm -rf "$gtmp/.git"
          gcache="$cache/git/$gsha"
          rm -rf "$gcache"; mkdir -p "$gcache"; cp -R "$gtmp/." "$gcache/"
          rm -rf "$dir/deps/$name"; mkdir -p "$dir/deps/$name"; cp -R "$gcache/." "$dir/deps/$name/"
          rm -rf "$gtmp"
          gtree="$(tree_digest "$dir/deps/$name")"
          printf '%s\t%s\tgit:%s\ttree:%s\n' "$name" "$url" "$gsha" "$gtree" >> "$lock.tmp"
          echo "fetched $name <- $url (git:${gsha:0:12})"
          # Transitive resolution: if the git dep declares its own dependencies,
          # vendor them under the dep's own deps/ (relative imports nest), unless
          # disabled. Guard against runaway recursion with VIBE_FETCH_DEPTH.
          if [ "${VIBE_NO_TRANSITIVE:-0}" != "1" ] && [ -f "$dir/deps/$name/vibe.deps" ]; then
            depth="${VIBE_FETCH_DEPTH:-0}"
            if [ "$depth" -lt "${VIBE_FETCH_MAX_DEPTH:-16}" ]; then
              echo "  resolving transitive deps of $name"
              fzarg=""; [ "$frozen" = "1" ] && [ -f "$dir/deps/$name/vibe.lock" ] && fzarg="--frozen"
              VIBE_FETCH_DEPTH="$((depth + 1))" "$LAUNCHER" fetch $fzarg "$dir/deps/$name" >/dev/null \
                || die "transitive fetch failed for $name"
            else
              die "transitive dependency depth exceeded at $name (cycle?)"
            fi
          fi
          continue
          ;;
      esac
      tmp="$(mktemp -t vibe-fetch-XXXXXX)"
      case "$url" in
        file://*) cp "${url#file://}" "$tmp" || die "fetch failed: $url" ;;
        http://*|https://*) curl -fsSL "$url" -o "$tmp" || die "fetch failed: $url" ;;
        *) cp "$url" "$tmp" || die "fetch failed (local path): $url" ;;
      esac
      sha="$(sha256sum "$tmp" | cut -d' ' -f1)"
      cp -f "$tmp" "$cache/$sha"
      mkdir -p "$(dirname "$dir/deps/$name.vibe")"
      cp -f "$cache/$sha" "$dir/deps/$name.vibe"
      rm -f "$tmp"
      printf '%s\t%s\tsha256:%s\n' "$name" "$url" "$sha" >> "$lock.tmp"
      echo "fetched $name <- $url (sha256:${sha:0:12})"
    done < "$manifest"
    mv "$lock.tmp" "$lock"
    echo "wrote $lock"
    ;;
  verify)
    # vibe verify [dir]
    # Re-check every vendored dep against the hash recorded in vibe.lock
    # (supply-chain integrity). Single-file deps are checked by sha256; git
    # deps by the recorded tree digest. Exits non-zero on any mismatch or
    # missing vendored file. Recurses into nested locks of git deps.
    vdir="${1:-.}"
    vlock="$vdir/vibe.lock"
    [ -f "$vlock" ] || die "no lock: $vlock (run 'vibe fetch' first)"
    vfail=0; vok=0
    while IFS=$'\t' read -r vname vurl vhash vtree _ || [ -n "$vname" ]; do
      case "$vname" in ""|\#*) continue ;; esac
      case "$vhash" in
        sha256:*)
          want="${vhash#sha256:}"; file="$vdir/deps/$vname.vibe"
          if [ ! -f "$file" ]; then
            echo "MISSING $vname ($file)" >&2; vfail=$((vfail+1)); continue
          fi
          got="$(sha256sum "$file" | cut -d' ' -f1)"
          if [ "$got" = "$want" ]; then vok=$((vok+1))
          else echo "TAMPERED $vname (sha256 ${got:0:12} != ${want:0:12})" >&2; vfail=$((vfail+1)); fi
          ;;
        git:*)
          ddir="$vdir/deps/$vname"
          if [ ! -d "$ddir" ]; then
            echo "MISSING $vname ($ddir)" >&2; vfail=$((vfail+1)); continue
          fi
          case "$vtree" in
            tree:*)
              want="${vtree#tree:}"; got="$(tree_digest "$ddir")"
              if [ "$got" = "$want" ]; then vok=$((vok+1))
              else echo "TAMPERED $vname (tree ${got:0:12} != ${want:0:12})" >&2; vfail=$((vfail+1)); fi
              ;;
            *) echo "info: $vname predates tree digest; skipping content check" ;;
          esac
          # Verify the dep's own lock too, if it vendored transitive deps.
          if [ -f "$ddir/vibe.lock" ]; then
            "$LAUNCHER" verify "$ddir" || vfail=$((vfail+1))
          fi
          ;;
        *) echo "info: $vname has unrecognized lock hash '$vhash'; skipping" ;;
      esac
    done < "$vlock"
    if [ "$vfail" -eq 0 ]; then
      echo "verified $vok dep(s) in $vlock"
    else
      die "integrity check failed for $vfail dep(s) in $vlock"
    fi
    ;;
  lsp)
    # `vibe lsp --selfhost` (#lsp-selfhost): the self-hosted server
    # (lib/@vibe/compiler/lsp_server.vibe, compiled straight into the CLI
    # wasm like `type-at`/`symbols`/`diagnostics`) -- no subprocess per
    # request, calls the compiler's editor-query primitives directly.
    # Covers initialize/didOpen/didChange/didClose/hover/definition/
    # references/rename/documentSymbol/diagnostics. Does NOT yet cover
    # workspace/symbol, callHierarchy, or the `vibe/graph` visualizer (those
    # are backed by clients/js/symbol_index.js's/graph_query.js's
    # whole-workspace index in the node server, not yet ported) -- opt-in
    # via the flag until that gap closes, rather than replacing the default.
    for lsp_arg in "$@"; do
      if [ "$lsp_arg" = "--selfhost" ]; then
        cli="$CLI_WASM"; [ -f "$cli" ] || cli="$(pick_cli)"
        exec env VIBE_LSP=1 VIBE_IMPORT_ABI=raw "$RUNNER" "$cli"
      fi
    done
    # Default: start the stdio LSP server (diagnostics). Editors configure
    # `vibe lsp`. The node server is JSON-RPC plumbing; it drives this same
    # launcher's `vibe check`/`type-at`/etc. for the actual compile.
    # (docs/release-roadmap.md テーマ4 MVP.)
    server="$TOOLCHAIN_DIR/lib/lsp_server.js"
    [ -f "$server" ] || server="$SELF/../../clients/js/lsp_server.js"
    [ -f "$server" ] || die "lsp server not found (looked in $TOOLCHAIN_DIR/lib and repo clients/js)"
    command -v node >/dev/null 2>&1 || die "node is required for 'vibe lsp'"
    exec env VIBE_BIN="$LAUNCHER" node "$server" "$@"
    ;;
  hash)
    # `vibe hash [--write] <pkg_dir|index.vibei>` — print the
    # contract/package content hashes (ADR-0063 §5). The require-pin
    # spelling is `#<package-line>`. `--write` (#1145 follow-up 3) instead
    # rewrites the target index.vpkg's `generated_hash = ` directive in
    # place with the computed package hash.
    write=0
    if [ "${1:-}" = "--write" ]; then
      write=1
      shift
    fi
    target="${1:-}"
    [ -n "$target" ] || die "usage: vibe hash [--write] <pkg_dir|index.vibei>"
    if [ -d "$target" ]; then
      # #897 (ADR-0070): a directory argument may be on either spelling --
      # .vpkg is the preferred/current one, .vibei the legacy one still
      # accepted during migration (same dual-acceptance the loader itself
      # already implements).
      if [ -f "$target/index.vpkg" ]; then
        target="$target/index.vpkg"
      else
        target="$target/index.vibei"
      fi
    fi
    [ -f "$target" ] || die "not found: $target"
    cli="$(pick_cli)"
    if [ "$write" = "1" ]; then
      case "$target" in
        *.vpkg) ;;
        *) die "vibe hash --write requires an index.vpkg target (got $target); generated_hash is a #1128 directive, the legacy index.vibei format has no such field" ;;
      esac
      wout="$(mktemp -t vibe-hash-write-XXXXXX)"
      env VIBE_HASH_WRITE=1 "$RUNNER" "$cli" "$target" "$wout" __no_entry__ >/dev/null 2>&1 || true
      if [ ! -s "$wout" ] || [ -s "$wout.diag" ]; then
        cat "$wout.diag" 2>/dev/null >&2 || true
        rm -f "$wout" "$wout.diag"
        die "hash write failed for $target"
      fi
      cp "$wout" "$target"
      grep '^generated_hash = ' "$target" | sed "s|^|wrote to $target: |"
      rm -f "$wout" "$wout.diag"
    else
      hout="$(mktemp -t vibe-hash-XXXXXX)"
      env VIBE_HASH=1 "$RUNNER" "$cli" "$target" "$hout" __no_entry__ >/dev/null 2>&1 || true
      if ! grep -q '^package ' "$hout" 2>/dev/null; then
        cat "$hout.diag" 2>/dev/null >&2 || true
        rm -f "$hout" "$hout.diag"
        die "hash computation failed for $target"
      fi
      cat "$hout"
      rm -f "$hout" "$hout.diag"
    fi
    ;;
  pkg)
    # vibe pkg publish|install|add|yank|update — the registry lane
    # (#754/#755/#805) over $VIBE_HOME/{cache,lib,log}. Delegates to
    # vibe_pkg.sh: the copy installed into the toolchain lib/ first
    # (standalone install, no checkout), the repo copy when running from a
    # checkout. The launcher hands over its own runner + compiler wasm via
    # VIBE_PKG_RUNNER/VIBE_PKG_CLI_WASM, so the script needs no repo-local
    # node host runner. Distinct from `vibe add/fetch/verify` (the
    # vibe.deps/vibe.lock vendoring lane): `vibe pkg` is the content-
    # addressed @scope/name package lane (ADR-0065).
    pkg_sh=""
    for cand in "$TOOLCHAIN_DIR/lib/vibe_pkg.sh" "$SELF/../scripts/vibe_pkg.sh"; do
      [ -f "$cand" ] && { pkg_sh="$cand"; break; }
    done
    [ -n "$pkg_sh" ] || die "vibe pkg: vibe_pkg.sh not found (looked in $TOOLCHAIN_DIR/lib and the repo scripts/)"
    cli="$(pick_cli)"
    exec env VIBE_PKG_RUNNER="$RUNNER" VIBE_PKG_CLI_WASM="$cli" VIBE_HOME="$VIBE_HOME" \
      bash "$pkg_sh" "$@"
    ;;
  context-pack)
    # vibe context-pack [--out FILE]  (#820 sub-item 3)
    # Emit the bundled language cheatsheet + verified golden-example corpus
    # as one file, for feeding into an AI harness as context. Prefers the
    # toolchain's pre-built asset (installed layout, generated once at
    # install time by scripts/install.sh); falls back to generating it live
    # from a dev checkout's docs/eval tree when no such asset was shipped
    # (e.g. this launcher running straight out of the repo).
    cpout="-"
    if [ "${1:-}" = "--out" ]; then
      cpout="${2:?usage: vibe context-pack [--out FILE]}"
      shift 2
    fi
    cpasset="$TOOLCHAIN_DIR/lib/context-pack.md"
    cpgen="$TOOLCHAIN_DIR/scripts/gen_context_pack.sh"
    if [ -f "$cpasset" ]; then
      if [ "$cpout" = "-" ]; then cat "$cpasset"; else cp "$cpasset" "$cpout"; fi
    elif [ -f "$cpgen" ]; then
      if [ "$cpout" = "-" ]; then
        bash "$cpgen" "$TOOLCHAIN_DIR"
      else
        bash "$cpgen" "$TOOLCHAIN_DIR" > "$cpout"
      fi
    else
      die "vibe context-pack: no pre-built asset ($cpasset) and no generator ($cpgen) found -- this installation doesn't ship the context pack"
    fi
    ;;
  version|--version|-V)
    echo "vibe $VIBE_VERSION (selfhost launcher)"
    echo "toolchain:   $TOOLCHAIN_DIR"
    echo "runner:      $RUNNER"
    "$RUNNER" --version 2>/dev/null || true
    cli="$(pick_cli 2>/dev/null || true)"
    echo "compiler:    ${cli:-<missing>}"
    ;;
  self)
    sub="${1:-}"; shift || true
    case "$sub" in
      update)
        new_wasm=""
        while [ "$#" -gt 0 ]; do
          case "$1" in
            --cli-wasm) new_wasm="$2"; shift 2 ;;
            *) die "unknown self update flag: $1" ;;
          esac
        done
        [ -n "$new_wasm" ] || die "usage: vibe self update --cli-wasm <path-to-vibe-cli.wasm>"
        [ -f "$new_wasm" ] || die "not found: $new_wasm"
        mkdir -p "$(dirname "$CLI_WASM")"
        cp -f "$new_wasm" "$CLI_WASM"
        echo "vibe: updated compiler wasm -> $CLI_WASM"
        # Rebuild the host-specific AOT artifact against this runner.
        "$RUNNER" --precompile "$CLI_WASM" -o "$CLI_CWASM"
        echo "vibe: rebuilt AOT compiler -> $CLI_CWASM"
        ;;
      *) die "usage: vibe self update --cli-wasm <path>" ;;
    esac
    ;;
  help|--help|-h)
    # Content-anchored (not fixed line numbers): print the whole Subcommands
    # block up to the `vibe help` line, so newly documented subcommands can't
    # silently fall outside a stale hardcoded range (Codex review, PR #925 —
    # the old '13,34p' already cut off everything after `vibe check`).
    sed -n '/^# Subcommands:/,/^#   vibe help /p' "$LAUNCHER" | sed 's/^# \{0,1\}//'
    ;;
  *)
    die "unknown command: $cmd (try 'vibe help')"
    ;;
esac
