#!/usr/bin/env bash
# aid - AID CLI dispatcher (Bash side).
#
# Purpose:
#   Persistent global command installed at $AID_CODE_HOME/bin/aid.  Parses
#   subcommands and dispatches to the shared install-core engine located at
#   $AID_CODE_HOME/lib/aid-install-core.sh.  Operates on the current working
#   directory (--target / AID_TARGET overrides).
#
# Usage:
#   aid                              Show the dashboard
#   aid -h | --help                  Show help
#   aid version                      Print the CLI version
#   aid status                       Show AID state of the current project
#   aid add <tool>[,...]             Add tool(s) to the current project
#   aid update [self]                Update to latest; inside repo = CLI + all tools; 'self' = CLI only
#   aid remove [<tool>... | self]    Remove; no arg = ALL AID from project; 'self' = the aid CLI
#   aid projects [list|add|remove|help] [path] [--local|--shared] [--verbose]
#                                    List, register, or unregister AID projects
#   aid <command> -h | --help        Per-command help
#
# Flags (shared across subcommands where applicable):
#   --from-bundle <path>   Offline install from a pre-downloaded tarball / dir.
#   --version <v>          Pin to a specific release version (e.g. 0.7.0).
#   --force                Overwrite differing files / skip confirmation prompts.
#   --target <dir>         Project root (default: current directory).
#   --verbose              Print per-file detail (default: concise summary).
#   --no-path              (bootstrap / update self only) Skip PATH wiring.

set -uo pipefail

# ---------------------------------------------------------------------------
# Bootstrap URL - single place to update when the branch merges to master.
# Override with AID_INSTALL_URL env var for tests.
# ---------------------------------------------------------------------------
AID_INSTALL_URL="${AID_INSTALL_URL:-https://raw.githubusercontent.com/AndreVianna/aid-methodology/master/install.sh}"

# ---------------------------------------------------------------------------
# AID_CODE_HOME: self-locate the read-only code payload (parent of bin/).
# NEVER overridden by an env var. Error-out if unresolvable (Q1 fail-safe).
# ---------------------------------------------------------------------------
_AID_SELF="${BASH_SOURCE[0]:-}"
if [[ -n "$_AID_SELF" && -f "$_AID_SELF" ]]; then
    # Resolve symlinks so we get the real payload root directory.
    _AID_SELF_REAL="$(cd "$(dirname "$_AID_SELF")" && pwd -P)/$(basename "$_AID_SELF")"
    AID_CODE_HOME="$(dirname "$(dirname "$_AID_SELF_REAL")")"
else
    echo "ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair." >&2
    exit 1
fi

# ---------------------------------------------------------------------------
# Scope derivation: global iff AID_CODE_HOME is not writable by the current
# user. Reuses the _aid_priv_run writability approach (no second test).
# AID_STATE_HOME: mutable state home, env-overridable via AID_HOME.
# ---------------------------------------------------------------------------
if [[ -n "$AID_CODE_HOME" && -e "$AID_CODE_HOME" && ! -w "$AID_CODE_HOME" && "$(id -u)" -ne 0 ]]; then
    _AID_SCOPE="global"
    AID_STATE_HOME="${AID_HOME:-${AID_SHARED_STATE_HOME:-/var/lib/aid}}"
else
    _AID_SCOPE="user"
    AID_STATE_HOME="${AID_HOME:-${HOME}/.aid}"
fi

# ---------------------------------------------------------------------------
# _aid_is_project_dir <dir>
# Return 0 (true) iff <dir> has a .aid/ subdirectory AND that subdirectory is
# NOT the CLI state home.  Treats the CLI state home as a non-project dir so
# running 'aid' from $HOME (or any dir whose .aid/ == AID_STATE_HOME) does not
# falsely auto-register or trigger the format gate.
#
# Guard: resolves <dir>/.aid to a real path (tolerates non-existent) and
# compares against realpath($AID_STATE_HOME) and realpath($HOME/.aid).
# ASCII-safe: uses bash built-ins only.
# ---------------------------------------------------------------------------
_aid_is_project_dir() {
    local _dir="$1"
    # Fast out: no .aid/ subdirectory at all.
    [[ -d "${_dir}/.aid" ]] || return 1
    # Resolve to canonical real path, tolerating paths that may not fully exist.
    local _aid_real
    _aid_real="$(cd "${_dir}/.aid" 2>/dev/null && pwd -P)" || _aid_real="${_dir}/.aid"
    # Resolve the two state-home paths.
    local _sh_real _hd_real
    _sh_real="$(cd "${AID_STATE_HOME}" 2>/dev/null && pwd -P)" || _sh_real="${AID_STATE_HOME}"
    _hd_real="$(cd "${HOME}/.aid" 2>/dev/null && pwd -P)" || _hd_real="${HOME}/.aid"
    # If .aid/ resolves to either state-home, this is NOT a project dir.
    if [[ "${_aid_real}" == "${_sh_real}" || "${_aid_real}" == "${_hd_real}" ]]; then
        return 1
    fi
    return 0
}

# ---------------------------------------------------------------------------
# C1: Per-repo format stamp constant.
# The current .aid/ layout version.  Bumped ONLY on a breaking layout change,
# never on every CLI release.  Defined exactly once; all comparisons read this.
# NOTE (work-007 C6): the install-time settings seed also stamps this value.
# On a bump, update ALL carriers together: this line, bin/aid.ps1
# AidSupportedFormat, and lib/AidInstallCore.psm1 $script:_AidSupportedFormat.
# (lib/aid-install-core.sh reads THIS var via ${AID_SUPPORTED_FORMAT:-1}.)
# ---------------------------------------------------------------------------
readonly AID_SUPPORTED_FORMAT=1

# ---------------------------------------------------------------------------
# Source the shared install core from AID_CODE_HOME/lib/.
# ---------------------------------------------------------------------------
_AID_CORE="${AID_CODE_HOME}/lib/aid-install-core.sh"
if [[ ! -f "$_AID_CORE" ]]; then
    echo "ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair." >&2
    exit 1
fi
# shellcheck source=../lib/aid-install-core.sh
source "$_AID_CORE"

# Defensive guard: verify the required core function was defined by the sourced lib.
# This catches an upgrade that left a stale aid-install-core.sh (missing new functions).
if ! declare -F aid_status_body >/dev/null 2>&1; then
    echo "ERROR: aid: CLI core is stale or incomplete at ${_AID_CORE}. Re-run the installer (or 'aid update self')." >&2
    exit 1
fi

# ---------------------------------------------------------------------------
# Usage helper.
# ---------------------------------------------------------------------------
_aid_usage() {
    local sub="${1:-}"
    case "$sub" in
        status)
            printf 'aid status [--verbose] [--target <dir>]\n'
            printf '  Show AID state of the current project (default: cwd).\n'
            printf '  Exit 7 when no AID install is found.\n'
            ;;
        add)
            printf 'aid add <tool>[,<tool>...] [--version <v>] [--from-bundle <path>]\n'
            printf '                           [--force] [--verbose] [--target <dir>]\n'
            printf '  Add tool(s) to the current project.\n'
            printf '  Tools: claude-code, codex, cursor, copilot-cli, antigravity\n'
            ;;
        remove)
            printf 'aid remove [<tool>[,<tool>...]] [--force] [--verbose] [--target <dir>]\n'
            printf 'aid remove self [--force] [--dry-run]\n'
            printf '  Remove tool(s) from the current project (manifest-driven).\n'
            printf '  No args: remove ALL AID from the project (asks for confirmation).\n'
            printf '  self: COMPLETELY remove the aid CLI, channel-aware (asks for confirmation):\n'
            printf '        npm -> npm uninstall -g | pypi -> pipx uninstall | curl -> rm $AID_HOME + unwire PATH.\n'
            printf '        Auto-elevates with sudo only when the install location needs root.\n'
            printf '  --dry-run: print the exact command(s) it would run, then exit (no changes).\n'
            ;;
        update)
            printf 'aid update [--version <v>] [--from-bundle <path>] [--force] [--dry-run] [--target <dir>]\n'
            printf 'aid update self [--from-bundle <path>] [--dry-run]\n'
            printf '  Update to latest.\n'
            printf '  Outside an AID repo: updates the CLI only (no-op if already latest).\n'
            printf '  Inside an AID repo: updates the CLI first, then ALL installed tools to one version.\n'
            printf '  No per-tool selection -- any tool positional is an error (use "self" only).\n'
            printf '  self: COMPLETELY update the aid CLI, channel-aware:\n'
            printf '        npm -> npm i -g | pypi -> pipx upgrade | curl -> re-bootstrap install.sh.\n'
            printf '        Auto-elevates with sudo only when the install location needs root.\n'
            printf '  --version <v>:       pin ALL tools (and CLI) to version v.\n'
            printf '  --from-bundle <path>: install from a local artifact instead of @latest\n'
            printf '        (npm .tgz | pypi .whl | curl release-staging dir with install.sh).\n'
            printf '  --dry-run: print the full plan (tools updated, files copied, paths pruned) and exit.\n'
            ;;
        version)
            printf 'aid version\n'
            printf '  Print the installed aid CLI version and exit 0.\n'
            ;;
        dashboard)
            printf 'aid dashboard start <node|python> [--remote] [--port <n>]\n'
            printf 'aid dashboard stop\n'
            printf '  Start or stop the machine-level pipeline dashboard (serves all registered projects).\n'
            printf '  <node|python>  select the server runtime to launch.\n'
            printf '  --remote       also expose it to authorized users over a private channel (never public);\n'
            printf '                 fails clearly if that mechanism is unavailable -- never binds publicly.\n'
            printf '  --port <n>     listen port on 127.0.0.1 (default 8787).\n'
            printf '  The dashboard binds to 127.0.0.1 only. '"'"'stop'"'"' is idempotent and also tears down --remote.\n'
            printf '  Works from any directory (not tied to the current project).\n'
            ;;
        projects)
            printf 'aid projects [list] [--local|--shared] [--verbose]\n'
            printf 'aid projects add  [<path>] [--local|--shared]\n'
            printf 'aid projects remove [<path>]\n'
            printf '  List, register, or unregister AID projects in the registry.\n'
            printf '  list (default): show all registered projects with state, tools, and tier.\n'
            printf '    The current directory is marked with "*" in the leading marker column.\n'
            printf '    Unregistered cwd with .aid/ present is shown as a footnote.\n'
            printf '  add [path=cwd]: register a project (requires .aid/ to exist); tracking only,\n'
            printf '    no tools are installed.  Idempotent.  Prints the tier written.\n'
            printf '  remove [path=cwd]: unregister a project from the registry; no files removed.\n'
            printf '    Works on stale/missing/no-aid entries.  Idempotent.\n'
            printf '  --local   force user tier for add\n'
            printf '  --shared  force shared tier for add\n'
            printf '  --verbose print extra detail\n'
            ;;
        *)
            printf 'aid - AID CLI\n'
            printf '\n'
            printf 'Usage:\n'
            printf '  aid                              Show the dashboard\n'
            printf '  aid -h | --help                  Show this help\n'
            printf '  aid version                      Print the CLI version\n'
            printf '  aid status                       Show AID state of the current project\n'
            printf '  aid add <tool>[,...]             Add tool(s) to the current project\n'
            printf '  aid update [self]                Update to latest; inside repo = all tools\n'
            printf '  aid remove [<tool>... | self]    Remove; no arg = ALL AID from project\n'
            printf '  aid dashboard start|stop ...     Start/stop the local dashboard\n'
            printf '  aid projects [list|add|remove]   List/register/unregister AID projects\n'
            printf '  aid <command> -h | --help        Per-command help\n'
            printf '\n'
            printf 'Flags: --from-bundle, --version, --force, --dry-run, --target, --verbose\n'
            printf "Run 'aid <command> -h' for details.\n"
            ;;
    esac
}

# ---------------------------------------------------------------------------
# Error helper.
# ---------------------------------------------------------------------------
_aid_die() {
    echo "ERROR: aid: $1" >&2
    exit "${2:-1}"
}

# ---------------------------------------------------------------------------
# Locate the bootstrap install.sh to delegate add/remove/update.
# Prefers the sibling ../install.sh (if aid is run from the release tree),
# then a resolved bootstrap relative to AID_HOME.
# ---------------------------------------------------------------------------
_find_install_sh() {
    # Sibling of the bin/ dir: AID_HOME/../install.sh would be the release root.
    # But installed layout is: AID_HOME/bin/aid + AID_HOME/lib/aid-install-core.sh
    # The install.sh is NOT shipped inside AID_HOME - we use the core functions directly.
    # Return empty string - callers will use the engine functions directly.
    echo ""
}

# ---------------------------------------------------------------------------
# Update check (throttled, cached, non-blocking, opt-out).
# ---------------------------------------------------------------------------

# _aid_check_update
# Compares the installed CLI version ($AID_CODE_HOME/VERSION) against the latest
# GitHub release.  Prints ONE notice line when a newer version is available.
# Fail-silent: any error (no curl, network down, bad JSON) is suppressed.
# Throttle: re-fetches at most once per 24h; caches result in ~/.aid/.update-check.
# Opt-out: AID_NO_UPDATE_CHECK=1 -> skip entirely.
# Test hook: AID_UPDATE_CHECK_URL overrides the fetch URL (and bypasses throttle).
_aid_check_update() {
    # Opt-out.
    [[ "${AID_NO_UPDATE_CHECK:-0}" == "1" ]] && return 0

    # Read installed version.
    local installed_version=""
    local ver_file="${AID_CODE_HOME}/VERSION"
    if [[ -f "$ver_file" ]]; then
        installed_version="$(tr -d '[:space:]' < "$ver_file")"
    fi
    [[ -z "$installed_version" ]] && return 0

    local cache_file="${HOME}/.aid/.update-check"
    local now
    now="$(date +%s 2>/dev/null)" || return 0
    local throttle_secs=86400  # 24 hours

    # Determine the fetch URL (test override or real GitHub API).
    local check_url="${AID_UPDATE_CHECK_URL:-}"
    local use_throttle=1
    if [[ -n "$check_url" ]]; then
        # Test override: bypass throttle so tests run on first invocation.
        use_throttle=0
    else
        check_url="${AID_API_BASE}/releases/latest"
    fi

    # Try to read cache.
    local cached_ts=0
    local cached_latest=""
    if [[ -f "$cache_file" ]]; then
        cached_ts="$(awk 'NR==1{print $1}' "$cache_file" 2>/dev/null)" || cached_ts=0
        cached_latest="$(awk 'NR==2{print $1}' "$cache_file" 2>/dev/null)" || cached_latest=""
    fi

    # Decide whether to fetch.
    local latest_version=""
    local need_fetch=1
    if [[ "$use_throttle" -eq 1 && -n "$cached_latest" ]]; then
        local age=$(( now - ${cached_ts:-0} ))
        if [[ "$age" -lt "$throttle_secs" ]]; then
            need_fetch=0
            latest_version="$cached_latest"
        fi
    fi

    if [[ "$need_fetch" -eq 1 ]]; then
        # Fetch latest release tag - hard 2s timeout, fail-silent.
        local response=""
        if command -v curl >/dev/null 2>&1; then
            response="$(curl --max-time 2 -fsS "$check_url" 2>/dev/null)" || return 0
        else
            return 0
        fi

        # Parse tag_name; strip leading 'v'.
        local tag
        tag="$(printf '%s' "$response" | grep '"tag_name"' | head -1 | \
               sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" || return 0
        [[ -z "$tag" ]] && return 0
        latest_version="${tag#v}"

        # Update cache.
        printf '%s\n%s\n' "$now" "$latest_version" > "$cache_file" 2>/dev/null || true
    fi

    [[ -z "$latest_version" ]] && return 0

    # Compare: show notice only when latest > installed.
    if _semver_lt "$installed_version" "$latest_version"; then
        # `aid update self` is now channel-aware and self-contained (it runs the
        # right package manager + applies migrations), so point at it for every
        # channel instead of a per-channel manual command.
        printf 'A newer aid CLI is available: v%s (you have v%s). Run: aid update self\n' \
            "$latest_version" "$installed_version"
    fi
    return 0
}


# ---------------------------------------------------------------------------
# update self command (formerly self-update).
# ---------------------------------------------------------------------------
# _aid_priv_run <writability-probe-dir> <cmd...>
# Run <cmd>, auto-elevating with sudo ONLY when <probe-dir> exists and is not
# writable by the current user (and we are not already root) -- e.g. a
# root-owned npm global prefix. A user-level prefix / pipx venv stays sudo-free.
# Honors _SELF_DRYRUN=1 (print the resolved command, prefixed with sudo when it
# would elevate, and do nothing). Returns the command's exit code, or 13 when
# elevation is needed but sudo is unavailable.
_aid_priv_run() {
    local probe="$1"; shift
    local need_root=0
    if [[ -n "$probe" && -e "$probe" && ! -w "$probe" && "$(id -u)" -ne 0 ]]; then
        need_root=1
    fi
    if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
        if [[ "$need_root" -eq 1 ]]; then printf '+ sudo %s\n' "$*"; else printf '+ %s\n' "$*"; fi
        return 0
    fi
    if [[ "$need_root" -eq 1 ]]; then
        if command -v sudo >/dev/null 2>&1; then
            printf 'aid: %s is not writable -- elevating this step via sudo...\n' "$probe" >&2
            sudo "$@"; return $?
        fi
        printf 'ERROR: aid: %s is not writable and sudo is unavailable. Run manually:\n  %s\n' "$probe" "$*" >&2
        return 13
    fi
    "$@"
}

# Channel-aware, self-contained CLI self-update. Reads the channel from
# AID_INSTALL_CHANNEL (injected by the npm/pypi shims); the curl/default channel
# re-bootstraps via install.sh. Honors _SELF_FROM_BUNDLE (a local CLI artifact:
# npm .tgz / pypi .whl / curl bundle dir) and _SELF_DRYRUN. The post-update
# migration scan runs in the caller's (user) context -- never under the sudo
# used here for the privileged install step.
_cmd_update_self() {
    # AID_SKIP_SELF_INSTALL: the package manager already (re)installed the CLI
    # (npm postinstall) and only wants the post-update migration to run. Skip the
    # re-install step.
    if [[ "${AID_SKIP_SELF_INSTALL:-0}" == "1" ]]; then
        return 0
    fi
    local channel="${AID_INSTALL_CHANNEL:-}"
    local bundle="${_SELF_FROM_BUNDLE:-}"
    case "$channel" in
        npm)
            command -v npm >/dev/null 2>&1 || { echo "ERROR: aid: npm not found; cannot update the npm-channel CLI" >&2; return 3; }
            local gdir pkg
            gdir="$(npm root -g 2>/dev/null)"
            pkg="aid-installer@latest"; [[ -n "$bundle" ]] && pkg="$bundle"
            printf 'Updating the aid CLI (npm channel)...\n'
            _aid_priv_run "$gdir" npm install -g "$pkg"
            return $?
            ;;
        pypi)
            command -v pipx >/dev/null 2>&1 || { echo "ERROR: aid: pipx not found; cannot update the pypi-channel CLI" >&2; return 3; }
            printf 'Updating the aid CLI (pypi/pipx channel)...\n'
            if [[ -n "$bundle" ]]; then
                _aid_priv_run "" pipx install --force "$bundle"
            else
                _aid_priv_run "" pipx upgrade aid-installer
            fi
            return $?
            ;;
    esac
    # curl / default channel -- re-bootstrap install.sh.
    printf 'Updating the aid CLI...\n'
    if [[ -n "$bundle" ]]; then
        # --from-bundle <dir> on the curl channel: a release-staging dir that
        # carries install.sh + the CLI bundle + SHA256SUMS. Run it offline.
        if [[ -f "${bundle%/}/install.sh" ]]; then
            if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
                printf '+ AID_CLI_BUNDLE_BASE=file://%s AID_LIB_BASE=file://%s bash %s/install.sh\n' "${bundle%/}" "${bundle%/}" "${bundle%/}"
                return 0
            fi
            AID_CLI_BUNDLE_BASE="file://${bundle%/}" AID_LIB_BASE="file://${bundle%/}" \
                bash "${bundle%/}/install.sh"
            return $?
        fi
        echo "ERROR: aid: --from-bundle <dir> for the curl channel must contain install.sh (got: ${bundle})" >&2
        return 2
    fi
    if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
        printf '+ curl -fsSL %s | bash\n' "${AID_INSTALL_URL}"
        return 0
    fi
    if command -v curl >/dev/null 2>&1; then
        curl -fsSL "${AID_INSTALL_URL}" | bash
        return $?
    else
        echo "ERROR: aid: curl not found; cannot update self" >&2
        return 3
    fi
}

# ---------------------------------------------------------------------------
# _aid_update_self_if_stale  (FF-3 preamble / CLI-2 / task-079)
# Self-update-if-needed preamble for the 'aid update [<tool>]' reach.
# Reuses _cmd_update_self's channel logic gated by a skip-if-current check
# (OQ-6 resolved simplest-correct: compare installed $AID_CODE_HOME/VERSION against
# the cached .update-check latest; if stale -> call _cmd_update_self; if
# current or unknown -> silent no-op).
#
# Safety notes (to prevent re-bootstrap/loop hazards):
#   - This is called BEFORE the tool-install loop on the 'update' reach only
#     (not 'update self', not 'add') -- no recursion possible.
#   - _cmd_update_self is channel-aware and self-contained: npm runs
#     `npm install -g`, pypi runs `pipx install/upgrade` (may sudo-prompt only
#     when the install location needs root), curl re-runs the bootstrap (which
#     replaces bin/aid on disk). In every case the current process keeps running
#     the already-loaded script, so the subsequent migration runs under the
#     current code as the invoking user -- acceptable (same pattern as
#     'aid update self' + post-update scan).
#   - WARN-not-fail: a self-update failure is logged and the tool-install
#     continues (NFR12).
# ---------------------------------------------------------------------------
_aid_update_self_if_stale() {
    # Read installed version (same pattern as _aid_check_update).
    local _installed=""
    local _ver_file="${AID_CODE_HOME}/VERSION"
    if [[ -f "${_ver_file}" ]]; then
        _installed="$(tr -d '[:space:]' < "${_ver_file}")"
    fi
    [[ -z "${_installed}" ]] && return 0  # no installed version known -> skip

    # Read cached latest version from .update-check (line 2 of the cache file).
    local _cache_file="${HOME}/.aid/.update-check"
    local _cached_latest=""
    if [[ -f "${_cache_file}" ]]; then
        _cached_latest="$(awk 'NR==2{print $1}' "${_cache_file}" 2>/dev/null)" || _cached_latest=""
    fi
    [[ -z "${_cached_latest}" ]] && return 0  # no cached latest known -> skip (no network call here)

    # Offline / explicit install: when the caller supplied a local bundle, do NOT
    # phone the package channel to self-update. The bundle is the source of truth
    # for this install; reaching out to the registry would defeat an air-gapped or
    # pre-release install (and could replace the running CLI behind the user's back).
    [[ -n "${_AID_FROM_BUNDLE:-}" ]] && return 0

    # Skip if already current.
    if [[ "${_installed}" == "${_cached_latest}" ]]; then
        return 0
    fi

    # Only self-update when the installed CLI is strictly OLDER than the latest
    # (semver-aware). A newer installed version (e.g. an unreleased dev build) must
    # never be downgraded to "latest". sort -V puts the lower version first.
    local _lower
    _lower="$(printf '%s\n%s\n' "${_installed}" "${_cached_latest}" | sort -V | head -1)"
    if [[ "${_lower}" != "${_installed}" ]]; then
        return 0  # installed >= latest -> nothing to do (never downgrade)
    fi

    # Stale: call the channel-appropriate self-update logic.
    # WARN-not-fail: failure here must not abort the tool-update.
    printf 'aid update: CLI is not current (installed: %s, available: %s); self-updating before tool install...\n' \
        "${_installed}" "${_cached_latest}"
    _cmd_update_self || \
        echo "WARN: aid: self-update failed (continuing with tool install)" >&2
    return 0
}

# ---------------------------------------------------------------------------
# Path-wiring helpers (Unix).
# ---------------------------------------------------------------------------

# _wire_one_profile <bin_dir> <profile_file>
# Idempotently write the fenced PATH block into a single profile file.
_wire_one_profile() {
    local bin_dir="$1"
    local profile="$2"

    # Create the profile file if it doesn't exist.
    if [[ ! -f "$profile" ]]; then
        touch "$profile" 2>/dev/null || {
            echo "WARN: aid: could not create ${profile}; PATH not wired." >&2
            printf 'Add "%s" to your PATH manually.\n' "$bin_dir"
            return 0
        }
    fi

    local fence_start='# >>> aid CLI >>>'
    local fence_end='# <<< aid CLI <<<'
    # Duplicate-guarded export: safe when multiple rc files are sourced.
    local path_line="case \":\$PATH:\" in *\":${bin_dir}:\"*) ;; *) export PATH=\"${bin_dir}:\$PATH\" ;; esac"

    if grep -qF "$fence_start" "$profile" 2>/dev/null; then
        # Replace the existing block in-place.
        local tmp_profile
        tmp_profile="$(mktemp "${profile}.aid-tmp.XXXXXX")"
        awk -v fs="$fence_start" -v fe="$fence_end" -v pl="$path_line" '
        BEGIN { skip=0 }
        $0 == fs { skip=1; print fs; print pl; print fe; next }
        skip && $0 == fe { skip=0; next }
        skip { next }
        { print }
        ' "$profile" > "$tmp_profile"
        mv "$tmp_profile" "$profile"
        echo "PATH wiring updated in ${profile}."
    else
        # Append the block.
        printf '\n%s\n%s\n%s\n' "$fence_start" "$path_line" "$fence_end" >> "$profile"
        echo "PATH wiring added to ${profile}."
    fi
}

# _wire_path_unix <aid_bin_dir> [--no-path] [--profile-file <file>]
# Idempotently add $aid_bin_dir to PATH via a fenced block.
# Without --profile-file, wires ALL standard rc files that exist (rustup/nvm pattern).
# When --no-path is given, print the manual instruction and return.
_wire_path_unix() {
    local bin_dir="$1"
    local no_path=0
    local profile_override=""

    shift
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --no-path)           no_path=1; shift ;;
            --profile-file)      profile_override="$2"; shift 2 ;;
            *)                   shift ;;
        esac
    done

    if [[ "$no_path" -eq 1 ]]; then
        printf 'Add "%s" to your PATH manually.\n' "$bin_dir"
        return 0
    fi

    if [[ -n "$profile_override" ]]; then
        _wire_one_profile "$bin_dir" "$profile_override"
        echo "Open a new shell, or run: export PATH=\"${bin_dir}:\$PATH\" (or: source ${profile_override})"
        return 0
    fi

    # Wire every standard rc file that already exists.
    local _wp_candidates=(
        "${ZDOTDIR:-${HOME}}/.zshrc"
        "${HOME}/.bashrc"
        "${HOME}/.bash_profile"
        "${HOME}/.profile"
    )
    local _wp_wired=()
    local _wp_rc
    for _wp_rc in "${_wp_candidates[@]}"; do
        if [[ -f "$_wp_rc" ]]; then
            _wire_one_profile "$bin_dir" "$_wp_rc"
            _wp_wired+=("$_wp_rc")
        fi
    done
    # If none exist, create and wire ~/.profile.
    if [[ "${#_wp_wired[@]}" -eq 0 ]]; then
        _wire_one_profile "$bin_dir" "${HOME}/.profile"
        _wp_wired+=("${HOME}/.profile")
    fi
    # Summarise.
    local _wp_display=""
    local _wp_w
    for _wp_w in "${_wp_wired[@]}"; do
        local _wp_rel="${_wp_w/#${HOME}/~}"
        _wp_display="${_wp_display:+${_wp_display}, }${_wp_rel}"
    done
    echo "PATH wiring added to: ${_wp_display}"
    echo "Open a new shell to pick up the updated PATH."
}

# _unwire_path_unix [--profile-file <file>]
# Remove the fenced PATH block from all standard rc files (or a single explicit file).
_unwire_path_unix() {
    local profile_override=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --profile-file) profile_override="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    local fence_start='# >>> aid CLI >>>'

    _unwire_one() {
        local _uw_f="$1"
        if [[ ! -f "$_uw_f" ]]; then
            return 0
        fi
        if ! grep -qF "$fence_start" "$_uw_f" 2>/dev/null; then
            return 0
        fi
        local tmp_profile
        tmp_profile="$(mktemp "${_uw_f}.aid-tmp.XXXXXX")"
        awk -v start="$fence_start" -v end='# <<< aid CLI <<<' '
        BEGIN { skip=0 }
        $0 == start { skip=1; next }
        skip && $0 == end { skip=0; next }
        skip { next }
        { print }
        ' "$_uw_f" > "$tmp_profile"
        mv "$tmp_profile" "$_uw_f"
        echo "PATH wiring removed from ${_uw_f}."
    }

    if [[ -n "$profile_override" ]]; then
        _unwire_one "$profile_override"
        return 0
    fi

    # Remove from all standard rc files.
    local _uw_rc
    for _uw_rc in \
        "${ZDOTDIR:-${HOME}}/.zshrc" \
        "${HOME}/.bashrc" \
        "${HOME}/.bash_profile" \
        "${HOME}/.profile"
    do
        _unwire_one "$_uw_rc"
    done
}

# ---------------------------------------------------------------------------
# Global CLI install helpers.
# ---------------------------------------------------------------------------

# _install_global_cli <version> <src_bin_aid> <src_lib_core>
# Stage then atomic-move into AID_CODE_HOME (the read-only code payload root).
_install_global_cli() {
    local version="$1"
    local src_bin_aid="$2"
    local src_lib_core="$3"

    local bin_dir="${AID_CODE_HOME}/bin"
    local lib_dir="${AID_CODE_HOME}/lib"

    mkdir -p "$bin_dir" "$lib_dir"

    # Copy the dispatcher.
    cp "$src_bin_aid" "${bin_dir}/aid"
    chmod +x "${bin_dir}/aid"

    # Copy the core lib.
    cp "$src_lib_core" "${lib_dir}/aid-install-core.sh"

    # Write the VERSION file.
    printf '%s\n' "$version" > "${AID_CODE_HOME}/VERSION"

    echo "aid CLI v${version} installed to ${AID_CODE_HOME}."
}

# ---------------------------------------------------------------------------
# remove self (formerly self-uninstall).
# ---------------------------------------------------------------------------

# Channel-aware, self-contained CLI removal. npm/pypi installs are owned by the
# package manager, so removing only $AID_HOME left the wrapper + bin shim behind
# (a dangling entry point). Now each channel does the COMPLETE removal:
#   npm  -> npm uninstall -g aid-installer   (package + vendored tree + shim)
#   pypi -> pipx uninstall aid-installer     (venv + entry point)
#   curl -> rm -rf $AID_HOME + unwire PATH    (unchanged)
# Privileged step (root-owned npm global) auto-elevates via _aid_priv_run.
# Honors --dry-run.
_cmd_remove_self() {
    local force=0
    local no_path=0
    local profile_file=""
    local dryrun=0

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --force|-y)      force=1; shift ;;
            --no-path)       no_path=1; shift ;;
            --profile-file)  profile_file="$2"; shift 2 ;;
            --dry-run)       dryrun=1; shift ;;
            -h|--help)       _aid_usage remove; exit 0 ;;
            *)               _aid_die "unknown flag for 'remove self': $1" 2 ;;
        esac
    done
    _SELF_DRYRUN="$dryrun"; export _SELF_DRYRUN

    # Apply AID_FORCE env-var fallback.
    if [[ "$force" -eq 0 && ( "${AID_FORCE:-0}" == "1" || "${AID_FORCE:-0}" == "true" ) ]]; then
        force=1
    fi

    local channel="${AID_INSTALL_CHANNEL:-}"
    local aid_home="${AID_HOME:-${HOME}/.aid}"

    # Channel-aware description of what will be removed (NFR transparency).
    local what
    case "$channel" in
        npm)  what="the npm global package 'aid-installer' (npm uninstall -g)" ;;
        pypi) what="the pipx app 'aid-installer' (pipx uninstall)" ;;
        *)    what="${aid_home} and its PATH wiring" ;;
    esac

    if [[ "$force" -eq 0 && "$dryrun" -ne 1 ]]; then
        # Skip prompt when non-interactive (piped or no tty).
        if [[ ! -t 0 ]]; then
            force=1
        else
            printf 'Remove the aid CLI -- %s? [y/N] ' "$what"
            local answer
            if [[ -e /dev/tty ]]; then
                read -r answer < /dev/tty
            else
                read -r answer
            fi
            if [[ "$answer" != "y" && "$answer" != "Y" && "$answer" != "yes" && "$answer" != "YES" ]]; then
                echo "Aborted."
                exit 0
            fi
        fi
    fi

    local partial=0
    case "$channel" in
        npm)
            command -v npm >/dev/null 2>&1 || { echo "ERROR: aid: npm not found; cannot remove the npm-channel CLI" >&2; exit 3; }
            local gdir; gdir="$(npm root -g 2>/dev/null)"
            _aid_priv_run "$gdir" npm uninstall -g aid-installer || partial=1
            ;;
        pypi)
            command -v pipx >/dev/null 2>&1 || { echo "ERROR: aid: pipx not found; cannot remove the pypi-channel CLI" >&2; exit 3; }
            if [[ "$dryrun" -eq 1 ]]; then
                printf '+ pipx uninstall aid-installer\n'
            else
                pipx uninstall aid-installer || partial=1
            fi
            ;;
        *)
            # curl / default channel -- the AID_HOME tree + shell-profile PATH wiring.
            if [[ "$dryrun" -eq 1 ]]; then
                [[ "$no_path" -eq 0 ]] && printf '+ (unwire %s/bin from your shell profile)\n' "$aid_home"
                printf '+ rm -rf %s\n' "$aid_home"
            else
                if [[ "$no_path" -eq 0 ]]; then
                    if [[ -n "$profile_file" ]]; then
                        _unwire_path_unix --profile-file "$profile_file" || partial=1
                    else
                        _unwire_path_unix || partial=1
                    fi
                fi
                if [[ -d "$aid_home" ]]; then
                    rm -rf "$aid_home" || {
                        echo "ERROR: aid: failed to remove ${aid_home}" >&2
                        partial=1
                    }
                fi
            fi
            ;;
    esac

    if [[ "$dryrun" -eq 1 ]]; then
        exit 0
    fi
    if [[ "$partial" -eq 1 ]]; then
        echo "aid CLI partially removed. Check the messages above for what remained."
        exit 1
    fi

    echo "aid CLI removed. Per-project AID installs are unaffected; run 'aid remove' in a project before removing the CLI if you also want to remove those."
    exit 0
}

# ---------------------------------------------------------------------------
# Remote exposure helpers (feature-005 / LC-EXP-B).
# SEC-1: These helpers invoke ONLY 'tailscale serve' (tailnet-only). The public
#        exposure verb is never used -- a bare grep for it returns nothing
#        anywhere in this file (structural never-public, C1).
# SEC-6: --remote exposes the CLI home (all registered repos, OQ5/DR-4): a
#        granted tailnet identity sees the full registered-repo list + each
#        repo's home.html/kb.html/api/model. This is the accepted OQ5 trade-off
#        -- a grantee is already a trusted operator of this host. The helpers
#        below, the bind, and the teardown are UNCHANGED; only what the port
#        serves changed (DR-2/task-047). Never-public (C1) and host/user-ACL
#        scoping (C3) hold exactly as before.
# ---------------------------------------------------------------------------

# _aid_remote_expose <port>
# Bring up tailscale serve (tailnet-only) for a loopback port.
# stdout (exit 0): two lines: handle (tailscale-serve:<port>) + https URL.
# stderr:          human messages, errors, FR18 ACL-grant guidance.
# exit:  0=ok  10=mechanism absent  11=non-loopback target  12=serve failed
_aid_remote_expose() {
    local port="$1"

    # Step 1: Re-assert the loopback target (belt-and-suspenders, SEC-1).
    # This function only accepts a bare port number (caller always passes 127.0.0.1:<port>
    # as the server's bind, but exposes only via a port token).  If someone passes a
    # non-numeric or IP-prefixed token the contract is violated.
    if [[ -z "$port" ]] || ! [[ "$port" =~ ^[0-9]+$ ]]; then
        echo "ERROR: aid: dashboard: expose target must be 127.0.0.1 (got: ${port})" >&2
        return 11
    fi

    # Step 2a: availability -- tailscale on PATH?
    if ! command -v tailscale >/dev/null 2>&1; then
        echo "ERROR: aid: dashboard: --remote requested but tailscale is not on PATH; --remote is unavailable" >&2
        return 10
    fi

    # Step 2b: availability -- node logged in and Running?
    local ts_status_out
    ts_status_out="$(tailscale status 2>&1)" || true
    # 'tailscale status' exits nonzero and prints "not running" or similar when the
    # daemon is stopped, or when not logged in.
    if echo "$ts_status_out" | grep -qiE '(not running|logged out|Stopped|NeedsLogin|NoState|not logged in)'; then
        echo "ERROR: aid: dashboard: --remote requested but tailscale is not running or not logged in (tailscale status: ${ts_status_out}); --remote is unavailable" >&2
        return 10
    fi
    # Also check for error/failure exit where output may be empty.
    if [[ -z "$ts_status_out" ]]; then
        echo "ERROR: aid: dashboard: --remote requested but tailscale status returned no output; --remote is unavailable" >&2
        return 10
    fi

    # Step 3: Bring up Serve (tailnet-only; the public exposure verb is never invoked -- SEC-1).
    local serve_err
    serve_err="$(tailscale serve --bg "$port" 2>&1)"
    local serve_rc=$?
    if [[ "$serve_rc" -ne 0 ]]; then
        echo "ERROR: aid: dashboard: tailscale serve failed (rc=${serve_rc}): ${serve_err}" >&2
        # Revert: take down the 443 frontend mapping (if it was partially set).
        tailscale serve --bg --https=443 off >/dev/null 2>&1 || true
        return 12
    fi

    # Step 4: Resolve the private URL from tailscale's Self.DNSName (the MagicDNS name).
    # NOTE: 'tailscale status --json' is PRETTY-PRINTED ("DNSName": "host.tailnet.ts.net."),
    # so the match MUST tolerate whitespace after the colon. '--peers=false' isolates Self so
    # a peer's DNSName is never picked up by mistake. We must NEVER fall back to the machine's
    # own hostname/FQDN for this URL: that resolves to the local/corporate DNS domain (e.g.
    # host.example.com), not the tailnet -- producing a URL that does not work and leaking the
    # wrong domain into the ACL guidance below.
    local ts_json node_fqdn private_url
    ts_json="$(tailscale status --json --peers=false 2>/dev/null)" || ts_json=""
    if [[ -z "$ts_json" ]]; then
        ts_json="$(tailscale status --json 2>/dev/null)" || ts_json=""
    fi
    node_fqdn=""
    if [[ -n "$ts_json" ]]; then
        # Scope the parse to the Self object so a peer DNSName can never be selected
        # regardless of JSON ordering. sed -n prints from "Self": to the first "Peer":
        # line (exclusive); when --peers=false was used there is no "Peer" line so the
        # range runs to EOF -- which is Self-only, the correct and safe result.
        local self_block
        self_block="$(printf '%s' "$ts_json" \
            | sed -n '/"Self"[[:space:]]*:/,/"Peer"[[:space:]]*:/p')"
        [[ -z "$self_block" ]] && self_block="$ts_json"
        node_fqdn="$(printf '%s' "$self_block" \
            | grep -oE '"DNSName"[[:space:]]*:[[:space:]]*"[^"]*"' \
            | head -1 \
            | sed -E 's/.*"DNSName"[[:space:]]*:[[:space:]]*"//; s/"$//; s/\.$//')"
    fi
    if [[ -z "$node_fqdn" ]]; then
        # Defensive fallback: a *.ts.net host reported by 'tailscale serve status --json'.
        local serve_json
        serve_json="$(tailscale serve status --json 2>/dev/null)" || serve_json=""
        if [[ -n "$serve_json" ]]; then
            node_fqdn="$(printf '%s' "$serve_json" \
                | grep -oE '[a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net' | head -1)"
        fi
    fi
    if [[ -n "$node_fqdn" ]]; then
        private_url="https://${node_fqdn}/"
    else
        # Could not resolve the tailnet MagicDNS name. Do NOT fabricate a public-domain URL.
        private_url="(unresolved: run 'tailscale status' to find this host's .ts.net name)"
    fi

    # Resolve display values for the ACL-grant guidance. The grant *src* (who may reach the
    # host) is an identity only you can choose -- your login, a group:, or a tag: -- and a DNS
    # domain is NOT a valid grant selector, so AID shows a placeholder rather than guessing it.
    # The *dst* is correctly THIS host's tailnet short-name.
    local node_short
    node_short="$(printf '%s' "$node_fqdn" | cut -d. -f1)"
    if [[ -z "$node_short" && -n "$ts_json" ]]; then
        # Same Self-scoping applied to HostName extraction to prevent a peer hostname
        # from being selected when the Self-first ordering assumption does not hold.
        local self_block_hn
        self_block_hn="$(printf '%s' "$ts_json" \
            | sed -n '/"Self"[[:space:]]*:/,/"Peer"[[:space:]]*:/p')"
        [[ -z "$self_block_hn" ]] && self_block_hn="$ts_json"
        node_short="$(printf '%s' "$self_block_hn" \
            | grep -oE '"HostName"[[:space:]]*:[[:space:]]*"[^"]*"' \
            | head -1 \
            | sed -E 's/.*"HostName"[[:space:]]*:[[:space:]]*"//; s/"$//' \
            | tr 'A-Z' 'a-z')"
    fi

    # Step 5: Print FR18 ACL-grant guidance to STDERR (informational only).
    local src_placeholder dst_placeholder
    src_placeholder="<you@example.com>"
    dst_placeholder="${node_short:-<this-host>}"

    cat >&2 <<GUIDANCE_EOF

Remote exposure is UP (tailnet-private). Every device on your tailnet can now reach this host.
To restrict access to only you, add a deny-by-default ACL grant in the tailnet policy file:
  https://login.tailscale.com/admin/acls/file
  {"grants":[{"src":["${src_placeholder}"],"dst":["${dst_placeholder}"],"ip":["tcp:443"]}]}
Note: granted identities see all registered project paths/names. See 'aid dashboard --help'.

GUIDANCE_EOF

    # Step 6: Emit handle + URL on stdout, exit 0.
    printf 'tailscale-serve:%s\n' "$port"
    printf '%s\n' "$private_url"
    return 0
}

# _aid_remote_teardown <handle>
# Revert the tailscale serve mapping created by _aid_remote_expose.
# exit: 0=ok/idempotent  13=revert warned
_aid_remote_teardown() {
    local handle="${1:-}"

    # Step 1: Parse the handle; malformed/empty -> idempotent exit 0.
    if [[ -z "$handle" ]]; then
        return 0
    fi
    if ! [[ "$handle" =~ ^tailscale-serve:([0-9]+)$ ]]; then
        # Malformed handle -- nothing to tear down.
        return 0
    fi
    # We don't use the port for teardown (we target the HTTPS:443 frontend, not the backend port).

    # Step 2: If tailscale is gone now -> WARN, exit 0.
    if ! command -v tailscale >/dev/null 2>&1; then
        echo "WARN: aid: dashboard: tailscale not found; cannot revert serve mapping (handle: ${handle})" >&2
        return 0
    fi

    # Step 3: Revert the HTTPS:443 frontend mapping (not a backend port off).
    local off_err
    off_err="$(tailscale serve --bg --https=443 off 2>&1)"
    local off_rc=$?
    if [[ "$off_rc" -ne 0 ]]; then
        # Fallback: check if serve status shows no other mappings; if so, reset.
        local srv_status
        srv_status="$(tailscale serve status 2>/dev/null)" || srv_status=""
        # Count serve entries. If there's nothing else to protect, do a reset.
        local mapping_count
        mapping_count="$(echo "$srv_status" | grep -cE '(https?://|tcp://)' 2>/dev/null || echo "0")"
        if [[ "$mapping_count" -le 1 ]]; then
            tailscale serve reset >/dev/null 2>&1 || true
            # After reset, exit 0 -- best effort.
            return 0
        fi
        echo "WARN: aid: dashboard: tailscale serve --https=443 off failed (rc=${off_rc}): ${off_err}" >&2
        return 13
    fi

    # Step 4: exit 0 on clean revert.
    return 0
}

# ---------------------------------------------------------------------------
# Dashboard control (aid dashboard start|stop).
# ---------------------------------------------------------------------------
_cmd_dashboard_ctl() {
    local verb="${1:-}"
    [[ $# -gt 0 ]] && shift

    # Top-level help.
    if [[ "$verb" == "-h" || "$verb" == "--help" ]]; then
        _aid_usage dashboard
        exit 0
    fi

    if [[ "$verb" != "start" && "$verb" != "stop" ]]; then
        if [[ -z "$verb" ]]; then
            echo "ERROR: aid: dashboard requires a verb: start or stop (e.g. aid dashboard start python)" >&2
            exit 2
        fi
        echo "ERROR: aid: dashboard: unknown verb '${verb}' (expected: start or stop)" >&2
        exit 2
    fi

    # --- shared arg parsing ---
    local _dc_verbose=0
    local _dc_port=8787
    local _dc_remote=0
    local _dc_runtime=""

    if [[ "$verb" == "start" ]]; then
        # First positional after verb is runtime.
        if [[ $# -gt 0 && "$1" != -* ]]; then
            _dc_runtime="$1"
            shift
        fi
    fi

    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                _aid_usage dashboard
                exit 0
                ;;
            --verbose) _dc_verbose=1; shift ;;
            --remote)
                if [[ "$verb" == "stop" ]]; then
                    echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
                fi
                _dc_remote=1; shift ;;
            --port)
                if [[ "$verb" == "stop" ]]; then
                    echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
                fi
                [[ $# -lt 2 ]] && _aid_die "dashboard: --port requires a value" 2
                _dc_port="$2"; shift 2
                # Validate port: integer in 1024..65535.
                if ! [[ "$_dc_port" =~ ^[0-9]+$ ]] || [[ "$_dc_port" -lt 1024 || "$_dc_port" -gt 65535 ]]; then
                    echo "ERROR: aid: dashboard: --port must be an integer in 1024..65535" >&2
                    exit 2
                fi
                ;;
            -*)
                echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2 ;;
            *)
                if [[ "$verb" == "stop" ]]; then
                    echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
                fi
                # Stray positional on start after runtime was consumed.
                echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2 ;;
        esac
    done

    if [[ "$verb" == "start" ]]; then
        _dc_start "$_dc_runtime" "$_dc_port" "$_dc_remote" "$_dc_verbose"
    else
        _dc_stop "$_dc_verbose"
    fi
}

_dc_start() {
    local runtime="$1"
    local port="$2"
    local remote="$3"
    local verbose="$4"

    # Step 1: validate runtime.
    if [[ -z "$runtime" ]]; then
        echo "ERROR: aid: dashboard start requires a runtime: node or python (e.g. aid dashboard start python)" >&2
        exit 2
    fi
    if [[ "$runtime" != "node" && "$runtime" != "python" ]]; then
        echo "ERROR: aid: dashboard: unknown runtime '${runtime}' (expected: node or python)" >&2
        exit 2
    fi

    # pid/log live in the per-user state home (.temp), always writable.
    # FR10 precedent: always per-user $HOME/.aid, never AID_STATE_HOME on global installs.
    local pid_file="${HOME}/.aid/.temp/dashboard.pid"
    local log_file="${HOME}/.aid/.temp/dashboard.log"

    # Step 4: already-running guard (stale-record reclaim included).
    if [[ -f "$pid_file" ]]; then
        local existing_pid existing_port existing_runtime
        existing_pid="$(grep '"pid"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
        existing_port="$(grep '"port"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
        existing_runtime="$(grep '"runtime"' "$pid_file" | sed 's/.*"runtime": *"\([^"]*\)".*/\1/')"
        if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
            echo "aid: dashboard already running (runtime ${existing_runtime}, http://127.0.0.1:${existing_port}); run 'aid dashboard stop' first."
            exit 8
        else
            # Stale record: reclaim silently (or verbosely).
            [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: reclaiming stale record (pid ${existing_pid} is dead)" >&2
            rm -f "$pid_file" "$log_file"
        fi
    fi

    # Step 5: check runtime on PATH.
    local interp
    if [[ "$runtime" == "python" ]]; then
        interp="python3"
        if ! command -v python3 >/dev/null 2>&1; then
            echo "ERROR: aid: dashboard: python3 not found on PATH (install it, or try: aid dashboard start node)" >&2
            exit 9
        fi
    else
        interp="node"
        if ! command -v node >/dev/null 2>&1; then
            echo "ERROR: aid: dashboard: node not found on PATH (install it, or try: aid dashboard start python)" >&2
            exit 9
        fi
    fi

    # Step 6: locate the server entry point.
    # <assets> = $AID_CODE_HOME/dashboard (the co-vendored server+reader unit in the install tree).
    local assets_dir="${AID_CODE_HOME}/dashboard"
    local entry_point
    if [[ "$runtime" == "python" ]]; then
        entry_point="${assets_dir}/server/server.py"
    else
        entry_point="${assets_dir}/server/server.mjs"
    fi
    if [[ ! -f "$entry_point" ]]; then
        echo "ERROR: aid: dashboard: the dashboard server is missing from the install tree (${runtime} entry-point not found at ${entry_point}); run 'aid update' or reinstall aid" >&2
        exit 7
    fi

    # Ensure log dir exists (per-user state home, always writable).
    mkdir -p "${HOME}/.aid/.temp"

    # Step 7: spawn the server child in a new session (clean process-group kill on stop).
    # SEC-1: literal 127.0.0.1 -- never read from input/config/env.
    # The multi-repo server (feature-010) serves every registered repo from the
    # registry under AID_STATE_HOME; export AID_HOME=AID_STATE_HOME so the server
    # resolves the registry via its legacy AID_HOME env var (delivery-008 seam).
    AID_HOME="$AID_STATE_HOME" setsid "$interp" "$entry_point" --host 127.0.0.1 --port "$port" \
        >"$log_file" 2>&1 &
    local child_pid=$!

    [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: spawned ${runtime} server (pid ${child_pid}, port ${port})" >&2

    # Step 8: bounded readiness wait (~5s, poll TCP socket).
    local ready=0
    local attempts=0
    local max_attempts=50   # 50 x 0.1s = 5s
    while [[ "$attempts" -lt "$max_attempts" ]]; do
        # Check child is still alive.
        if ! kill -0 "$child_pid" 2>/dev/null; then
            # Child exited early.
            echo "ERROR: aid: dashboard: server failed to start; last log lines:" >&2
            tail -n 10 "$log_file" >&2 2>/dev/null || true
            rm -f "$log_file"
            exit 3
        fi
        # Try TCP connect to 127.0.0.1:<port>.
        if (: < /dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
            ready=1
            break
        fi
        sleep 0.1
        attempts=$((attempts + 1))
    done

    # Check if child is still alive even if not ready (timeout case).
    if [[ "$ready" -eq 0 ]]; then
        if ! kill -0 "$child_pid" 2>/dev/null; then
            echo "ERROR: aid: dashboard: server failed to start; last log lines:" >&2
            tail -n 10 "$log_file" >&2 2>/dev/null || true
            rm -f "$log_file"
            exit 3
        fi
        # Timeout but pid alive: warn and continue (child may be slow on a large repo).
        echo "WARN: aid: dashboard: server started but not yet responding on :${port}; check ${log_file}" >&2
    fi

    # Step 9: write dashboard.pid JSON record (DM-1) with remote=false initially.
    local started_at
    started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown")"
    cat > "$pid_file" <<EOF
{
  "schema": 1,
  "pid": ${child_pid},
  "runtime": "${runtime}",
  "port": ${port},
  "bind": "127.0.0.1",
  "remote": false,
  "remote_handle": null,
  "started_at": "${started_at}",
  "logfile": "${log_file}"
}
EOF

    # Step 10: --remote: invoke _aid_remote_expose; update record on success.
    if [[ "$remote" -eq 1 ]]; then
        # Capture stdout into a temp file; let stderr (guidance + errors) flow to the user.
        local _expose_tmp expose_rc expose_handle expose_url
        _expose_tmp="$(mktemp)"
        _aid_remote_expose "$port" >"$_expose_tmp"
        expose_rc=$?
        if [[ "$expose_rc" -ne 0 ]]; then
            rm -f "$_expose_tmp"
            # All expose failures (10/11/12) map to user-facing exit 10.
            # dashboard stays local-only (server remains running).
            echo "ERROR: aid: dashboard: --remote requested but the secure remote-exposure mechanism is not available on this host; the dashboard is NOT exposed. Local server still running at http://127.0.0.1:${port}." >&2
            exit 10
        fi
        expose_handle="$(head -1 "$_expose_tmp")"
        expose_url="$(sed -n '2p' "$_expose_tmp")"
        rm -f "$_expose_tmp"
        # Update the record with remote=true and the handle.
        cat > "$pid_file" <<EOF
{
  "schema": 1,
  "pid": ${child_pid},
  "runtime": "${runtime}",
  "port": ${port},
  "bind": "127.0.0.1",
  "remote": true,
  "remote_handle": "${expose_handle}",
  "started_at": "${started_at}",
  "logfile": "${log_file}"
}
EOF
        # Step 11 (remote success): print local URL + remote URL.
        echo "Dashboard (${runtime}) running at http://127.0.0.1:${port} -- stop with: aid dashboard stop"
        if [[ "${expose_url}" == https://* ]]; then
            echo "Remote (private): ${expose_url}"
        else
            echo "Remote exposure is UP (tailnet-private), but the .ts.net URL could not be auto-detected -- run 'tailscale status' on this host to find it."
        fi
        exit 0
    fi

    # Step 11: print success (local-only).
    echo "Dashboard (${runtime}) running at http://127.0.0.1:${port} -- stop with: aid dashboard stop"
    exit 0
}

_dc_stop() {
    local verbose="$1"

    local pid_file="${HOME}/.aid/.temp/dashboard.pid"

    # Step 3: read record; absent or stale -> idempotent exit 0.
    if [[ ! -f "$pid_file" ]]; then
        echo "aid: dashboard: not running (nothing to stop)."
        exit 0
    fi

    local existing_pid
    existing_pid="$(grep '"pid"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
    local log_file
    log_file="$(grep '"logfile"' "$pid_file" | sed 's/.*"logfile": *"\([^"]*\)".*/\1/')"

    if [[ -z "$existing_pid" ]] || ! kill -0 "$existing_pid" 2>/dev/null; then
        [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: record exists but pid ${existing_pid} is dead; cleaning up." >&2
        rm -f "$pid_file" "$log_file"
        echo "aid: dashboard: not running (nothing to stop)."
        exit 0
    fi

    # Step 4: --remote teardown (if the record says remote=true, call _aid_remote_teardown).
    local existing_remote existing_handle
    existing_remote="$(grep '"remote":' "$pid_file" | grep -v '"remote_handle"' | sed 's/.*"remote":[[:space:]]*//' | tr -d '", ')"
    # Extract handle: matches quoted string value; if unquoted (null), returns empty.
    existing_handle="$(grep '"remote_handle"' "$pid_file" | sed -n 's/.*"remote_handle":[[:space:]]*"\([^"]*\)".*/\1/p')"
    if [[ "$existing_remote" == "true" && -n "$existing_handle" ]]; then
        [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: tearing down remote exposure (handle: ${existing_handle})" >&2
        _aid_remote_teardown "$existing_handle"
        local teardown_rc=$?
        if [[ "$teardown_rc" -eq 13 ]]; then
            echo "WARN: aid: dashboard: remote teardown reported a warning; continuing server shutdown" >&2
        fi
    fi

    # Step 5: terminate the process group cleanly.
    [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: sending SIGTERM to process group ${existing_pid}" >&2
    kill -TERM -"$existing_pid" 2>/dev/null || true

    # Wait up to ~5s for exit.
    local waited=0
    while kill -0 "$existing_pid" 2>/dev/null && [[ "$waited" -lt 50 ]]; do
        sleep 0.1
        waited=$((waited + 1))
    done

    # Escalate to SIGKILL if still alive.
    if kill -0 "$existing_pid" 2>/dev/null; then
        [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: escalating to SIGKILL on process group ${existing_pid}" >&2
        kill -KILL -"$existing_pid" 2>/dev/null || true
    fi

    # Step 6: remove record and logfile, print success.
    rm -f "$pid_file" "$log_file"
    echo "aid: dashboard stopped."
    exit 0
}

# ---------------------------------------------------------------------------
# Registry helpers (DR-1 / FF-1 / FR29).
# Implements DM-1 schema, DD-3 atomic write, DD-REG-FMT line-scan.
# ---------------------------------------------------------------------------

# _registry_read_repos <reg-path>
# Print newline-delimited canonical repo paths recorded in registry.yml.
# Returns nothing (empty) when the file is absent or has no items.
_registry_read_repos() {
    local reg="$1"
    [[ -f "$reg" ]] || return 0
    grep -E '^[[:space:]]*-[[:space:]]+' "$reg" 2>/dev/null \
        | sed -E 's/^[[:space:]]*-[[:space:]]+//' \
        | sed -E 's/[[:space:]]+$//'
}

# _registry_read_union
# Return the deduped sort -u union of the primary tier ($AID_STATE_HOME/registry.yml,
# which honors the AID_HOME override via the startup scope derivation) and, when
# $AID_STATE_HOME differs from $HOME/.aid, also the $HOME/.aid/registry.yml
# fallback tier (entries that may have been written there when AID_STATE_HOME was
# non-writable).  Prunes stale entries quietly: a path is emitted only if
# [[ -d "$p/.aid" ]].  Never writes or mutates any registry file on read.
#
# Per-user collapse: when $AID_STATE_HOME == $HOME/.aid the two paths are the
# same file -- the union degenerates to a single-tier read (no double-read, no
# elevation).
_registry_read_union() {
    local _primary_reg="${AID_STATE_HOME}/registry.yml"
    local _raw
    if [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]]; then
        # Per-user collapse: single-tier; primary == fallback, no double-read.
        _raw="$(_registry_read_repos "$_primary_reg")"
    else
        # Distinct paths: union of primary ($AID_STATE_HOME) and fallback ($HOME/.aid).
        local _fallback_reg="${HOME}/.aid/registry.yml"
        _raw="$({ _registry_read_repos "$_primary_reg"; _registry_read_repos "$_fallback_reg"; } \
            | sed '/^$/d' | sort -u)"
    fi
    # Quiet-prune: emit only paths whose .aid/ still exists.
    while IFS= read -r p; do
        [[ -n "$p" ]] || continue
        [[ -d "${p}/.aid" ]] && printf '%s\n' "$p"
    done <<< "$_raw"
}

# _registry_read_raw_union
# Like _registry_read_union but WITHOUT the [[ -d "$p/.aid" ]] quiet-prune.
# Returns EVERY registered path (deduped union of $AID_STATE_HOME/registry.yml
# and the $HOME/.aid/registry.yml fallback, with per-user collapse), including
# paths whose .aid/ is absent or the directory does not exist.
# Used by 'aid projects list' to render no-aid/missing/untracked states.
# Never writes or mutates any registry file on read.
_registry_read_raw_union() {
    local _primary_reg="${AID_STATE_HOME}/registry.yml"
    local _raw
    if [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]]; then
        # Per-user collapse: single-tier; primary == fallback, no double-read.
        _raw="$(_registry_read_repos "$_primary_reg")"
    else
        # Distinct paths: union of primary ($AID_STATE_HOME) and fallback ($HOME/.aid).
        local _fallback_reg="${HOME}/.aid/registry.yml"
        _raw="$({ _registry_read_repos "$_primary_reg"; _registry_read_repos "$_fallback_reg"; } \
            | sed '/^$/d' | sort -u)"
    fi
    # Emit every non-empty path (no prune -- no-aid/missing paths are included).
    while IFS= read -r p; do
        [[ -n "$p" ]] || continue
        printf '%s\n' "$p"
    done <<< "$_raw"
}

# _aid_resolve_tier <canon-path>
# Deterministic, non-interactive tier selection for 'aid projects add' (FR6/AC6).
# Returns "user" or "shared" on stdout.
#
# Auto rule:
#   - Returns "user" if $_AID_SCOPE != "global" (per-user install), OR if the
#     path is under $HOME (any install type).
#   - Otherwise (global install AND path outside $HOME): returns "shared".
#
# Override convention (set before calling; cleared by caller):
#   _AID_TIER_OVERRIDE=""         no override, use auto rule (default)
#   _AID_TIER_OVERRIDE="--local"  force "user" regardless of install type/path
#   _AID_TIER_OVERRIDE="--shared" force "shared"; but on a per-user install
#                                 ($AID_STATE_HOME == $HOME/.aid) there is no
#                                 separate shared tier -- returns "user" and
#                                 prints a one-line notice to stderr.
#
# Never prompts; never blocks; always returns 0.
_aid_resolve_tier() {
    local _canon_path="$1"

    # Detect per-user install (no separate shared tier).
    local _per_user=0
    [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1

    # Handle explicit override flags.
    case "${_AID_TIER_OVERRIDE:-}" in
        --local)
            printf 'user\n'
            return 0
            ;;
        --shared)
            if [[ "$_per_user" -eq 1 ]]; then
                printf 'no shared tier under a per-user install; using user tier\n' >&2
                printf 'user\n'
            else
                printf 'shared\n'
            fi
            return 0
            ;;
    esac

    # Auto rule: user if per-user install OR path is under $HOME.
    local _in_home=0
    case "$_canon_path" in
        "${HOME}/"*|"${HOME}") _in_home=1 ;;
    esac

    if [[ "$_AID_SCOPE" != "global" || "$_in_home" -eq 1 ]]; then
        printf 'user\n'
    else
        printf 'shared\n'
    fi
    return 0
}

# _aid_project_state <path>
# Print the state of an AID project directory:
#   "missing"     -- the directory does not exist
#   "no-aid"      -- directory exists but has no .aid/ subdirectory
#   "untracked"   -- .aid/ exists but no .aid/.aid-manifest.json is present
#   "vX.Y.Z"      -- tracked; semver version string from .aid/.aid-manifest.json
#                    (key "aid_version"), falling back to .aid/.aid-version
# The version is extracted by the same semver regex on both paths -- a malformed
# aid_version value in the manifest falls through to untracked (not returned raw).
# Never errors; always returns 0.
_aid_project_state() {
    local _path="$1"
    if [[ ! -d "$_path" ]]; then
        printf 'missing\n'
        return 0
    fi
    if [[ ! -d "${_path}/.aid" ]]; then
        printf 'no-aid\n'
        return 0
    fi
    local _manifest="${_path}/.aid/.aid-manifest.json"
    local _ver_file="${_path}/.aid/.aid-version"
    if [[ -f "$_manifest" ]]; then
        local _ver
        # Extract aid_version value; then validate as semver (same guard as .aid-version branch).
        _ver="$(grep -o '"aid_version"[[:space:]]*:[[:space:]]*"[^"]*"' "$_manifest" 2>/dev/null \
            | sed -E 's/.*"([^"]+)"[[:space:]]*$/\1/' \
            | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' \
            | head -1)"
        if [[ -n "$_ver" ]]; then
            printf '%s\n' "$_ver"
            return 0
        fi
    fi
    # Fallback: .aid/.aid-version plain-text file.
    if [[ -f "$_ver_file" ]]; then
        local _vf_ver
        _vf_ver="$(grep -Eo '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' "$_ver_file" 2>/dev/null | head -1)"
        if [[ -n "$_vf_ver" ]]; then
            printf '%s\n' "$_vf_ver"
            return 0
        fi
    fi
    printf 'untracked\n'
    return 0
}

# _aid_project_tools <path>
# Print a comma-separated list of tool names installed in an AID project, as
# recorded in <path>/.aid/.aid-manifest.json under the "tools" object.
# The manifest schema is: "tools": { "<tool-name>": { ... }, ... } (object keyed
# by tool name, NO "name" field inside).  This is the schema written by every
# canonical writer in lib/aid-install-core.sh (see the awk extractor at ~:1019).
# Prints an empty string when the manifest is absent or has no tools.
# Used by 'aid projects list' to populate the "tools" column (task-004).
_aid_project_tools() {
    local _path="$1"
    local _manifest="${_path}/.aid/.aid-manifest.json"
    [[ -f "$_manifest" ]] || { printf ''; return 0; }
    # Extract tool names as object keys inside the "tools": { ... } block.
    # Mirrors the canonical awk extractor in lib/aid-install-core.sh:~1019:
    #   /"tools"/{found=1} found && /^    "[a-z]/{gsub(/[^a-zA-Z-]/,"",$1); print $1}
    local _tools
    _tools="$(awk '/"tools"/{found=1} found && /^    "[a-z]/{gsub(/[^a-zA-Z0-9_.-]/,"",$1); if ($1!="") print $1}' \
        "$_manifest" 2>/dev/null \
        | sort -u \
        | tr '\n' ',' \
        | sed -E 's/,+$//')"
    printf '%s' "$_tools"
    return 0
}

# registry_register <canon-path> [<tier>]
# Set-insert <canon-path> into the target tier registry (idempotent; atomic write).
# <tier> is "user" (default) or "shared".
# On a real change prints one concise line.  On failure prints WARN and returns 0
# so the host-tool op is never blocked (NFR10 / DD-3 / CLI-1).
#
# USER tier (default): primary target is $AID_STATE_HOME/registry.yml (which
# honors the AID_HOME override via the startup scope derivation).  If AID_STATE_HOME
# is not user-writable AND is a different path from $HOME/.aid, degrades to
# $HOME/.aid/registry.yml with a WARN (fire-and-continue; never blocks the host
# command).  Per-user collapse: when $AID_STATE_HOME == $HOME/.aid the two paths
# are the same file -- single-tier, no fallback needed.
#
# SHARED tier: writes to $AID_STATE_HOME/registry.yml using a REAL probe of the
# shared dir via _aid_priv_run.  If elevation is declined or there is no TTY,
# the function degrades: skip + WARN + return 0 (the host command is NOT blocked;
# design SS3.3 decision #2 / SPEC AC6).
# Per-user install ($AID_STATE_HOME == ~/.aid): shared-tier argument is treated
# as user-tier (same file, no elevation needed).
registry_register() {
    local repo="$1" tier="${2:-user}" reg tmp existing
    local _shared_reg_dir="${AID_STATE_HOME}"
    local _shared_reg="${AID_STATE_HOME}/registry.yml"
    # Per-user collapse: AID_STATE_HOME is the same path as $HOME/.aid.
    # In this case shared-tier is treated as user-tier (same file, no elevation).
    local _per_user=0
    [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1
    if [[ "$tier" == "shared" && "$_per_user" -eq 0 ]]; then
        # SHARED-tier write: real probe of the shared dir; elevation allowed but
        # degrades on decline / no-TTY rather than blocking.
        # Ensure the shared dir exists (non-prompting best-effort).
        _aid_priv_run "" mkdir -p "$_shared_reg_dir" 2>/dev/null || true
        if [[ ! -w "$_shared_reg_dir" ]]; then
            # Shared dir not writable; real probe will elevate via sudo if available.
            # We attempt the write via _aid_priv_run with the REAL probe (not empty).
            # If elevation is declined or sudo is unavailable, _aid_priv_run returns
            # non-zero -- we catch that and degrade: skip + warn + return 0.
            existing="$(_registry_read_repos "$_shared_reg")"
            if printf '%s\n' "$existing" | grep -qxF "$repo"; then
                [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered in shared tier (no-op)."
                return 0
            fi
            tmp="$(mktemp "/tmp/.aid-reg-tmp.XXXXXX" 2>/dev/null)" || {
                echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mktemp failed" >&2
                return 0
            }
            {
                printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
                printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
                printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
                printf '%s\n' "schema: 1"
                printf '%s\n' "projects:"
                { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
                    | sed '/^$/d' | sort -u \
                    | while IFS= read -r p; do printf '  - %s\n' "$p"; done
            } > "$tmp" || {
                rm -f "$tmp"
                echo "WARN: aid: could not update the shared project registry (${_shared_reg}): write failed" >&2
                return 0
            }
            # Real probe: elevates only when the shared dir is not user-writable.
            # If elevation is declined / no-TTY / sudo unavailable: skip + warn.
            _aid_priv_run "$_shared_reg_dir" mv -f "$tmp" "$_shared_reg" || {
                rm -f "$tmp" 2>/dev/null
                echo "WARN: aid: shared registry write declined or unavailable; project not registered in shared tier (${_shared_reg})" >&2
                return 0
            }
        else
            # Shared dir is user-writable (e.g. group-writable install or test sandbox).
            existing="$(_registry_read_repos "$_shared_reg")"
            if printf '%s\n' "$existing" | grep -qxF "$repo"; then
                [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered in shared tier (no-op)."
                return 0
            fi
            tmp="$(mktemp "${_shared_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
                echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mktemp failed" >&2
                return 0
            }
            {
                printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
                printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
                printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
                printf '%s\n' "schema: 1"
                printf '%s\n' "projects:"
                { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
                    | sed '/^$/d' | sort -u \
                    | while IFS= read -r p; do printf '  - %s\n' "$p"; done
            } > "$tmp" || {
                rm -f "$tmp"
                echo "WARN: aid: could not update the shared project registry (${_shared_reg}): write failed" >&2
                return 0
            }
            _aid_priv_run "" mv -f "$tmp" "$_shared_reg" || {
                rm -f "$tmp" 2>/dev/null
                echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mv failed" >&2
                return 0
            }
        fi
        echo "Registered ${repo} with the AID CLI (shared registry)."
        return 0
    fi
    # USER tier (default) or per-user collapse.
    # Primary: $AID_STATE_HOME (honors AID_HOME override via startup scope derivation).
    # Fallback: $HOME/.aid (when AID_STATE_HOME is not writable and is a different path).
    # Never-elevate: empty probe + ensure-exists.
    _aid_priv_run "" mkdir -p "$AID_STATE_HOME" 2>/dev/null || true
    if [[ -w "$AID_STATE_HOME" ]]; then
        reg="${AID_STATE_HOME}/registry.yml"
    else
        # AID_STATE_HOME not writable; degrade to $HOME/.aid (user fallback).
        # This is the designed fallback for global installs -- silent by default,
        # visible under --verbose.  Hard failures (mktemp/write/mv) stay unconditional.
        local _fb_dir="${HOME}/.aid"
        mkdir -p "$_fb_dir" 2>/dev/null || true
        [[ "${_AID_VERBOSE:-0}" == "1" ]] && \
            echo "WARN: aid: could not write to state home ${AID_STATE_HOME}; using ${_fb_dir}/registry.yml" >&2
        reg="${_fb_dir}/registry.yml"
    fi
    existing="$(_registry_read_repos "$reg")"
    # Idempotent: already registered -> silent no-op.
    if printf '%s\n' "$existing" | grep -qxF "$repo"; then
        [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered (no-op)."
        return 0
    fi
    # mktemp in the chosen (writable) target dir so mv is atomic same-filesystem.
    tmp="$(mktemp "${reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
        echo "WARN: aid: could not update the machine project registry (${reg}): mktemp failed" >&2
        return 0
    }
    {
        printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
        printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
        printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
        printf '%s\n' "schema: 1"
        printf '%s\n' "projects:"
        { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
            | sed '/^$/d' | sort -u \
            | while IFS= read -r p; do printf '  - %s\n' "$p"; done
    } > "$tmp" || {
        rm -f "$tmp"
        echo "WARN: aid: could not update the machine project registry (${reg}): write failed" >&2
        return 0
    }
    # Never-elevate atomic commit: empty probe forces direct (no-sudo) mv.
    _aid_priv_run "" mv -f "$tmp" "$reg" || {
        rm -f "$tmp" 2>/dev/null
        echo "WARN: aid: could not update the machine project registry (${reg}): mv failed" >&2
        return 0
    }
    echo "Registered ${repo} with the AID CLI."
}

# registry_unregister <canon-path>
# Set-remove <canon-path> from whichever tier(s) it appears in (idempotent; atomic write).
# Called only when the repo manifest is now gone (last tool removed).
# On a real change prints one concise line.  On failure prints WARN and returns 0.
#
# Tier-aware: searches user tier and (when global scope) shared tier.  Removes from
# each tier where the entry is found, best-effort.  User-tier write is never-elevate
# (empty-probe _aid_priv_run "" mv -f).  Shared-tier write uses real probe; if not
# user-writable and elevation is unavailable, WARN + skip + return 0.
# Per-user install ($AID_STATE_HOME == ~/.aid): both tiers are the same file; single
# write, no elevation ever.
registry_unregister() {
    local repo="$1" tmp existing
    # Determine the effective registry path, mirroring registry_register's
    # primary/fallback logic: $AID_STATE_HOME is primary (honors AID_HOME override);
    # $HOME/.aid is the fallback when AID_STATE_HOME is not writable.
    # Also search the other tier in case the entry was recorded there.
    local _shared_reg_dir="${AID_STATE_HOME}"
    local _shared_reg="${AID_STATE_HOME}/registry.yml"
    local _user_reg_dir="${HOME}/.aid"
    local _user_reg="${_user_reg_dir}/registry.yml"
    # Per-user collapse: AID_STATE_HOME is the same path as $HOME/.aid.
    local _per_user=0
    [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1
    local _found_any=0
    # --- PRIMARY TIER ($AID_STATE_HOME) ---
    _aid_priv_run "" mkdir -p "$AID_STATE_HOME" 2>/dev/null || true
    if [[ -w "$AID_STATE_HOME" ]]; then
        existing="$(_registry_read_repos "$_shared_reg")"
        if printf '%s\n' "$existing" | grep -qxF "$repo"; then
            _found_any=1
            tmp="$(mktemp "${_shared_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
                echo "WARN: aid: could not update the machine project registry (${_shared_reg}): mktemp failed" >&2
                return 0
            }
            {
                printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
                printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
                printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
                printf '%s\n' "schema: 1"
                printf '%s\n' "projects:"
                { printf '%s\n' "$existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
                    | while IFS= read -r p; do printf '  - %s\n' "$p"; done
            } > "$tmp" || {
                rm -f "$tmp"
                echo "WARN: aid: could not update the machine project registry (${_shared_reg}): write failed" >&2
                return 0
            }
            _aid_priv_run "" mv -f "$tmp" "$_shared_reg" || {
                rm -f "$tmp" 2>/dev/null
                echo "WARN: aid: could not update the machine project registry (${_shared_reg}): mv failed" >&2
                return 0
            }
        fi
    else
        # AID_STATE_HOME not writable; check/operate in fallback $HOME/.aid tier.
        # Degrade WARN is silent by default (designed global-install behavior); visible under --verbose.
        mkdir -p "$_user_reg_dir" 2>/dev/null || true
        existing="$(_registry_read_repos "$_user_reg")"
        if printf '%s\n' "$existing" | grep -qxF "$repo"; then
            _found_any=1
            [[ "${_AID_VERBOSE:-0}" == "1" ]] && \
                echo "WARN: aid: could not write to state home ${AID_STATE_HOME}; using ${_user_reg}" >&2
            tmp="$(mktemp "${_user_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
                echo "WARN: aid: could not update the machine project registry (${_user_reg}): mktemp failed" >&2
                return 0
            }
            {
                printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
                printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
                printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
                printf '%s\n' "schema: 1"
                printf '%s\n' "projects:"
                { printf '%s\n' "$existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
                    | while IFS= read -r p; do printf '  - %s\n' "$p"; done
            } > "$tmp" || {
                rm -f "$tmp"
                echo "WARN: aid: could not update the machine project registry (${_user_reg}): write failed" >&2
                return 0
            }
            _aid_priv_run "" mv -f "$tmp" "$_user_reg" || {
                rm -f "$tmp" 2>/dev/null
                echo "WARN: aid: could not update the machine project registry (${_user_reg}): mv failed" >&2
                return 0
            }
        fi
    fi
    # --- FALLBACK / SECONDARY TIER ($HOME/.aid, global install only) ---
    # When AID_STATE_HOME is writable and != $HOME/.aid, also check if the entry
    # exists in $HOME/.aid (e.g. was registered when AID_STATE_HOME was non-writable).
    if [[ "$_per_user" -eq 0 && -w "$AID_STATE_HOME" ]]; then
        local _fb_existing
        _fb_existing="$(_registry_read_repos "$_user_reg")"
        if printf '%s\n' "$_fb_existing" | grep -qxF "$repo"; then
            _found_any=1
            mkdir -p "$_user_reg_dir" 2>/dev/null || true
            tmp="$(mktemp "${_user_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
                echo "WARN: aid: could not update the machine project registry (${_user_reg}): mktemp failed" >&2
            }
            if [[ -n "$tmp" ]]; then
                {
                    printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
                    printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
                    printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
                    printf '%s\n' "schema: 1"
                    printf '%s\n' "projects:"
                    { printf '%s\n' "$_fb_existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
                        | while IFS= read -r p; do printf '  - %s\n' "$p"; done
                } > "$tmp" || {
                    rm -f "$tmp"
                    echo "WARN: aid: could not update the machine project registry (${_user_reg}): write failed" >&2
                    tmp=""
                }
            fi
            if [[ -n "$tmp" ]]; then
                _aid_priv_run "" mv -f "$tmp" "$_user_reg" || {
                    rm -f "$tmp" 2>/dev/null
                    echo "WARN: aid: could not update the machine project registry (${_user_reg}): mv failed" >&2
                }
            fi
        fi
    fi
    if [[ "$_found_any" -eq 0 ]]; then
        [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} not in registry (no-op)."
        return 0
    fi
    echo "Unregistered ${repo} from the AID CLI."
}

# ---------------------------------------------------------------------------
# C4: _aid_repo_format <repo>
# Read the format_version stamp from <repo>/.aid/settings.yml.
# Greps the FIRST ^format_version: line, replicates the era-a closure strip
# logic inline (prefix strip, trim, inline # comment strip, quote-unwrap),
# validates as ^[0-9]+$; echoes the integer.
# Collapses absent/empty/non-integer/malformed/negative to 0 (legacy default).
# Never returns a value > sup from a garbled stamp (fail-safe).
# ---------------------------------------------------------------------------
_aid_repo_format() {
    local _repo="$1"
    local _settings="${_repo}/.aid/settings.yml"
    if [[ ! -f "${_settings}" ]]; then
        echo "0"
        return 0
    fi
    # First-match read (parity with duplicate-line policy).
    local _raw_line
    _raw_line="$(grep -m1 '^format_version:' "${_settings}" 2>/dev/null)" || true
    if [[ -z "${_raw_line}" ]]; then
        echo "0"
        return 0
    fi
    # Replicate the era-a closure strip logic inline (column-0 key variant).
    # Step 1: strip the "format_version:" prefix.
    local _val="${_raw_line#format_version:}"
    # Step 2: strip one optional leading space (the colon-space separator).
    _val="${_val# }"
    # Step 3: strip inline # comment (first " #" to end of line).
    _val="${_val%% #*}"
    # Step 4: quote-unwrap (double then single).
    _val="${_val%\"}"
    _val="${_val#\"}"
    _val="${_val%%\'}"
    _val="${_val##\'}"
    # Step 5: full trim (ltrim + rtrim remaining whitespace).
    local _lstrip="${_val%%[![:space:]]*}"
    _val="${_val#"${_lstrip}"}"
    local _rstrip="${_val##*[![:space:]]}"
    _val="${_val%"${_rstrip}"}"
    # Step 6: validate non-negative integer; collapse anything else to 0.
    if [[ "${_val}" =~ ^[0-9]+$ ]]; then
        echo "${_val}"
    else
        echo "0"
    fi
    return 0
}

# ---------------------------------------------------------------------------
# C5: _aid_format_gate <repo>
# 3-way classify <repo>'s format stamp vs AID_SUPPORTED_FORMAT:
#   repo > sup  -> refuse (stderr, return 1, no .aid/ write)
#   repo < sup  -> warn + offer aid update (stdout, return 0, non-blocking)
#   repo == sup -> silent (return 0)
# AID_NO_MIGRATE=1 suppresses the warn+offer notice only; never the refuse.
# ---------------------------------------------------------------------------
_aid_format_gate() {
    local _repo="$1"
    local _repo_fmt
    _repo_fmt="$(_aid_repo_format "${_repo}")"
    local _sup="${AID_SUPPORTED_FORMAT}"
    if [[ "${_repo_fmt}" -gt "${_sup}" ]]; then
        printf 'ERROR: aid: project format %s is newer than this CLI supports (%s). Upgrade the aid CLI to operate on this project.\n' \
            "${_repo_fmt}" "${_sup}" >&2
        return 1
    fi
    if [[ "${_repo_fmt}" -lt "${_sup}" ]]; then
        if [[ "${AID_NO_MIGRATE:-0}" != "1" ]] \
          && [[ -f "${_repo}/.aid/.aid-manifest.json" ]]; then
            printf 'WARN: aid: this project uses an older format (v%s; current: v%s). Run: aid update\n' \
                "${_repo_fmt}" "${_sup}"
        fi
        return 0
    fi
    # repo == sup: silent.
    return 0
}

# ---------------------------------------------------------------------------
# _aid_migrate_repo <repo>  (FF-1 / LC-MIG / task-077)
# Per-repo migration core.  Runs DETECT->SETTINGS->ADD->RELOCATE->REGISTER in
# order.  Each step is WARN-not-fail: a step failure logs WARN and the next
# step runs; the function always returns 0 (SEC-4 / NFR12).
# <repo> is a CAN-1 canonical repo base folder (resolved by the caller via
# cd "$repo" && pwd -- identical to bin/aid:1366 / feature-010 SEC-5).
# ---------------------------------------------------------------------------
_aid_migrate_repo() {
    local repo="$1"

    # ------------------------------------------------------------------
    # STEP 0 -- DETECT / QUALIFY (DD-6 / SEC-1) -- read-only, no write.
    # Qualify iff <repo>/.aid/ exists AND at least one era marker is
    # present.  A bare .aid/ with no marker is NOT a candidate.
    # ------------------------------------------------------------------
    if [[ ! -d "${repo}/.aid" ]]; then
        return 0
    fi

    local _era=""
    if [[ -f "${repo}/.aid/settings.yml" ]]; then
        _era="a"
    elif [[ -f "${repo}/.aid/knowledge/DISCOVERY_STATE.md" ]] \
      || [[ -f "${repo}/.aid/knowledge/DISCOVERY-STATE.md" ]] \
      || [[ -f "${repo}/.aid/knowledge/STATE.md" ]] \
      || [[ -f "${repo}/.aid/.aid-manifest.json" ]]; then
        # Era-b: KB-state present, OR a tracked repo (manifest present) that has no
        # settings.yml yet -- the `aid add`-only state. Synthesize a fresh stamped
        # settings.yml so the format gate stops warning every run and the repo is
        # brought current. Without the manifest clause such repos warn forever and
        # are never stamped (gate says "tracked + old"; migrate said "not a candidate").
        _era="b"
    else
        # Bare .aid/ (no settings.yml, no KB state, no manifest) -- not a candidate.
        return 0
    fi

    # ------------------------------------------------------------------
    # STEP 1 -- SETTINGS (DM-1 / task-074 contract)
    # ------------------------------------------------------------------
    local _settings="${repo}/.aid/settings.yml"
    local _manifest="${repo}/.aid/.aid-manifest.json"
    local _repo_name; _repo_name="$(basename "${repo}")"

    if [[ "$_era" == "a" ]]; then
        # Era-a: validate and targeted-repair REQUIRED keys only.
        # Preserves every present kb_baseline.* line and <skill>.minimum_grade
        # line byte-intact (IDIOM-A single-line replace / IDIOM-B append-block).
        _aid_migrate_repair_settings_era_a "${_settings}" "${_repo_name}" || \
            echo "WARN: aid migrate: settings repair failed for ${repo}/.aid/settings.yml (continuing)" >&2
    else
        # Era-b: synthesize a fresh settings.yml from the template defaults.
        _aid_migrate_synthesize_settings_era_b "${_settings}" "${_repo_name}" "${_manifest}" || \
            echo "WARN: aid migrate: settings synthesis failed for ${repo}/.aid/settings.yml (continuing)" >&2
    fi

    # ------------------------------------------------------------------
    # STEP 2 -- ADD home.html (FR40 / RC-2) -- copy-when-absent only.
    # ------------------------------------------------------------------
    local _home_html_dest="${repo}/.aid/dashboard/home.html"
    if [[ ! -f "${_home_html_dest}" ]]; then
        local _home_html_src="${AID_CODE_HOME}/dashboard/home.html"
        if [[ -f "${_home_html_src}" ]]; then
            mkdir -p "${repo}/.aid/dashboard" 2>/dev/null || \
                echo "WARN: aid migrate: mkdir .aid/dashboard failed for ${repo} (continuing)" >&2
            if [[ -d "${repo}/.aid/dashboard" ]]; then
                cp "${_home_html_src}" "${_home_html_dest}" 2>/dev/null || \
                    echo "WARN: aid migrate: copy home.html failed for ${repo} (continuing)" >&2
            fi
        else
            echo "WARN: aid migrate: home.html source not found at ${_home_html_src} (continuing)" >&2
        fi
    fi

    # ------------------------------------------------------------------
    # STEP 3 -- RELOCATE legacy summary (DM-4 / FR31) -- no-clobber mv.
    # Exact idiom from canonical/scripts/summarize/summarize-preflight.sh:102-113
    # ------------------------------------------------------------------
    local _old_summary="${repo}/.aid/knowledge/knowledge-summary.html"
    local _new_summary="${repo}/.aid/dashboard/kb.html"
    if [[ -f "${_old_summary}" ]] && [[ ! -f "${_new_summary}" ]]; then
        mkdir -p "${repo}/.aid/dashboard" 2>/dev/null || \
            echo "WARN: aid migrate: mkdir .aid/dashboard failed for ${repo} (step 3, continuing)" >&2
        if [[ -d "${repo}/.aid/dashboard" ]]; then
            mv -n "${_old_summary}" "${_new_summary}" 2>/dev/null || \
                echo "WARN: aid migrate: relocate legacy summary failed for ${repo} (continuing)" >&2
        fi
    fi

    # ------------------------------------------------------------------
    # STEP 4 -- REGISTER (DM-2 / FR28) -- existing idempotent writer.
    # Canonicalize path (same rule as bin/aid:1366).
    # FR7: deterministic tier via _aid_resolve_tier; never-elevate: if shared would
    # need elevation (shared dir not user-writable), degrade silently to user.
    # ------------------------------------------------------------------
    local _canon_repo
    _canon_repo="$(cd "${repo}" && pwd)" 2>/dev/null || _canon_repo="${repo}"
    local _migrate_tier
    _migrate_tier="$(_aid_resolve_tier "${_canon_repo}")"
    if [[ "$_migrate_tier" == "shared" && ! -w "${AID_STATE_HOME}" ]]; then
        _migrate_tier="user"
    fi
    registry_register "${_canon_repo}" "$_migrate_tier" || true

    return 0
}

# _aid_migrate_repair_settings_era_a <settings_file> <repo_name>
# Era-a: validate/repair REQUIRED keys via targeted edits only.
# A valid file -> no write (idempotent).
# Batch all edits in memory, then write a single temp+mv -f (crash-safe).
_aid_migrate_repair_settings_era_a() {
    local _sf="$1" _rname="$2"
    [[ -f "${_sf}" ]] || return 1

    # Read file into an array (preserves byte content per line).
    local -a _lines=()
    while IFS= read -r _l || [[ -n "${_l}" ]]; do
        _lines+=("${_l}")
    done < "${_sf}"

    local _changed=0

    # ---- Helper: locate a section header line index (col-0 "^<sect>:$") ----
    _find_section() {
        local _sect="$1" _i
        for _i in "${!_lines[@]}"; do
            if [[ "${_lines[$_i]}" =~ ^${_sect}:[[:space:]]*$ ]]; then
                echo "$_i"; return 0
            fi
        done
        echo "-1"
    }

    # ---- Helper: locate an indented key line index inside a section ----
    _find_key_in_section() {
        local _sect_idx="$1" _key="$2" _i
        local _n="${#_lines[@]}"
        for (( _i=_sect_idx+1; _i<_n; _i++ )); do
            local _ln="${_lines[$_i]}"
            # Stop at next col-0 non-comment non-blank line (next section).
            if [[ "${_ln}" =~ ^[a-zA-Z_] ]]; then
                echo "-1"; return 0
            fi
            if [[ "${_ln}" =~ ^[[:space:]]+${_key}:[[:space:]] ]] || \
               [[ "${_ln}" =~ ^[[:space:]]+${_key}:[[:space:]]*$ ]]; then
                echo "$_i"; return 0
            fi
        done
        echo "-1"
    }

    # ---- Helper: get the scalar value of an indented "  key: value" line ----
    _get_scalar_value() {
        local _ln="$1" _key="$2" _val
        # strip leading whitespace + key: (colon only; trailing space is optional so a bare
        # "name:" with no value is also reduced to empty; parity with the PS twin's \s*)
        _val="${_ln#*${_key}:}"
        # strip one optional leading space (was the colon-space separator)
        _val="${_val# }"
        # strip inline comment: first " #" to end of line (YAML inline-comment form).
        # This intentionally matches the first space-hash occurrence (parity with PS twin's
        # \s*#.*$ strip and the reader's _strip_yaml_inline_comment rule).
        _val="${_val%% #*}"
        _val="${_val%\"}"
        _val="${_val#\"}"
        _val="${_val%%\'}"
        _val="${_val##\'}"
        # Full rtrim: remove all trailing whitespace left after the comment strip.
        # The previous "%% " (single-space suffix) only removed ONE trailing space,
        # leaving the alignment padding that precedes " # comment" in lines like
        # "  type: brownfield                  # brownfield | greenfield".
        local _rstrip="${_val##*[![:space:]]}"
        _val="${_val%"${_rstrip}"}"
        # Full ltrim: remove any leading whitespace (e.g. from multi-space colon-separator).
        local _lstrip="${_val%%[![:space:]]*}"
        _val="${_val#"${_lstrip}"}"
        echo "${_val}"
    }

    # ---- Insert an indented line after a given index in _lines ----
    _insert_after() {
        local _idx="$1" _new_line="$2"
        local -a _new_lines=()
        local _i
        for (( _i=0; _i<${#_lines[@]}; _i++ )); do
            _new_lines+=("${_lines[$_i]}")
            if [[ "$_i" -eq "$_idx" ]]; then
                _new_lines+=("${_new_line}")
            fi
        done
        _lines=("${_new_lines[@]}")
        _changed=1
    }

    # ---- Append a block at EOF (IDIOM-B for whole-section missing) ----
    # Always prepends a blank line so the new section is visually separated from
    # the preceding content (matching the template's blank-line-between-sections style).
    # Idempotency is preserved: on a 2nd run the section now exists, so _find_section
    # finds it and this path never executes again.
    _append_block() {
        local _block="$1"
        local -a _block_lines=()
        _block_lines+=("")
        while IFS= read -r _bl; do
            _block_lines+=("${_bl}")
        done <<< "${_block}"
        _lines+=("${_block_lines[@]}")
        _changed=1
    }

    # ---- Replace a single line (IDIOM-A) ----
    _replace_line() {
        local _idx="$1" _new="$2"
        _lines[$_idx]="${_new}"
        _changed=1
    }

    # ------------------------------------------------------------------
    # Validate / repair each REQUIRED key.
    # ------------------------------------------------------------------

    # C3: format_version ensure-key step (top-of-file column-0 prepend).
    # If a ^format_version: line is present anywhere, replace it in-place
    # with the canonical stamp value (IDIOM-A single-line replace).
    # If absent, prepend format_version: <sup> at index 0 of _lines so it
    # sits above project: at column 0 (existing _append_block is EOF-only;
    # _insert_after places indented lines after a header -- neither works
    # here; we prepend directly).
    local _fv_idx=-1 _fi
    for _fi in "${!_lines[@]}"; do
        if [[ "${_lines[$_fi]}" =~ ^format_version: ]]; then
            _fv_idx="$_fi"
            break
        fi
    done
    if [[ "${_fv_idx}" -ge 0 ]]; then
        # Key present: replace with canonical value (IDIOM-A).
        _replace_line "${_fv_idx}" "format_version: ${AID_SUPPORTED_FORMAT}"
    else
        # Key absent: prepend at index 0 (new top-of-file col-0 insert).
        local -a _fv_new_lines=()
        _fv_new_lines+=("format_version: ${AID_SUPPORTED_FORMAT}")
        local _fv_i
        for _fv_i in "${!_lines[@]}"; do
            _fv_new_lines+=("${_lines[$_fv_i]}")
        done
        _lines=("${_fv_new_lines[@]}")
        _changed=1
    fi

    # project section
    local _proj_idx; _proj_idx="$(_find_section "project")"
    if [[ "$_proj_idx" -eq -1 ]]; then
        # Whole project: section missing -> IDIOM-B append.
        _append_block "project:
  name: ${_rname}
  description: <project-description>
  type: brownfield"
    else
        # project.name
        local _name_idx; _name_idx="$(_find_key_in_section "$_proj_idx" "name")"
        if [[ "$_name_idx" -eq -1 ]]; then
            _insert_after "$_proj_idx" "  name: ${_rname}"
        else
            local _name_val; _name_val="$(_get_scalar_value "${_lines[$_name_idx]}" "name")"
            if [[ -z "${_name_val}" ]]; then
                _replace_line "$_name_idx" "  name: ${_rname}"
            fi
        fi

        # project.description (key must exist; value may be placeholder)
        local _desc_idx; _desc_idx="$(_find_key_in_section "$_proj_idx" "description")"
        if [[ "$_desc_idx" -eq -1 ]]; then
            # Find name line to insert after it, else insert after section header.
            local _name_idx2; _name_idx2="$(_find_key_in_section "$_proj_idx" "name")"
            if [[ "$_name_idx2" -ne -1 ]]; then
                _insert_after "$_name_idx2" "  description: <project-description>"
            else
                _insert_after "$_proj_idx" "  description: <project-description>"
            fi
        fi

        # project.type
        local _type_idx; _type_idx="$(_find_key_in_section "$_proj_idx" "type")"
        if [[ "$_type_idx" -eq -1 ]]; then
            # Insert after description (or name, or section header).
            local _desc_idx2; _desc_idx2="$(_find_key_in_section "$_proj_idx" "description")"
            local _ins_after_type
            if [[ "$_desc_idx2" -ne -1 ]]; then
                _ins_after_type="$_desc_idx2"
            else
                local _name_idx3; _name_idx3="$(_find_key_in_section "$_proj_idx" "name")"
                if [[ "$_name_idx3" -ne -1 ]]; then
                    _ins_after_type="$_name_idx3"
                else
                    _ins_after_type="$_proj_idx"
                fi
            fi
            _insert_after "$_ins_after_type" "  type: brownfield"
        else
            local _type_val; _type_val="$(_get_scalar_value "${_lines[$_type_idx]}" "type")"
            if [[ "$_type_val" != "brownfield" && "$_type_val" != "greenfield" ]]; then
                _replace_line "$_type_idx" "  type: brownfield"
            fi
        fi
    fi

    # tools section
    local _tools_idx; _tools_idx="$(_find_section "tools")"
    if [[ "$_tools_idx" -eq -1 ]]; then
        _append_block "tools:
  installed: []"
    else
        local _inst_idx; _inst_idx="$(_find_key_in_section "$_tools_idx" "installed")"
        if [[ "$_inst_idx" -eq -1 ]]; then
            _insert_after "$_tools_idx" "  installed: []"
        fi
        # If installed key exists (any value, even empty list) -> valid; no touch.
    fi

    # review section
    local _rev_idx; _rev_idx="$(_find_section "review")"
    if [[ "$_rev_idx" -eq -1 ]]; then
        _append_block "review:
  minimum_grade: A"
    else
        local _mg_idx; _mg_idx="$(_find_key_in_section "$_rev_idx" "minimum_grade")"
        if [[ "$_mg_idx" -eq -1 ]]; then
            _insert_after "$_rev_idx" "  minimum_grade: A"
        else
            local _mg_val; _mg_val="$(_get_scalar_value "${_lines[$_mg_idx]}" "minimum_grade")"
            if ! [[ "$_mg_val" =~ ^[A-F][+-]?$ ]]; then
                _replace_line "$_mg_idx" "  minimum_grade: A"
            fi
        fi
    fi

    # execution section
    local _exec_idx; _exec_idx="$(_find_section "execution")"
    if [[ "$_exec_idx" -eq -1 ]]; then
        _append_block "execution:
  max_parallel_tasks: 5"
    else
        local _mpt_idx; _mpt_idx="$(_find_key_in_section "$_exec_idx" "max_parallel_tasks")"
        if [[ "$_mpt_idx" -eq -1 ]]; then
            _insert_after "$_exec_idx" "  max_parallel_tasks: 5"
        else
            local _mpt_val; _mpt_val="$(_get_scalar_value "${_lines[$_mpt_idx]}" "max_parallel_tasks")"
            if ! [[ "$_mpt_val" =~ ^[0-9]+$ ]] || [[ "$_mpt_val" -le 0 ]]; then
                _replace_line "$_mpt_idx" "  max_parallel_tasks: 5"
            fi
        fi
    fi

    # traceability section
    local _trace_idx; _trace_idx="$(_find_section "traceability")"
    if [[ "$_trace_idx" -eq -1 ]]; then
        _append_block "traceability:
  heartbeat_interval: 1"
    else
        local _hb_idx; _hb_idx="$(_find_key_in_section "$_trace_idx" "heartbeat_interval")"
        if [[ "$_hb_idx" -eq -1 ]]; then
            _insert_after "$_trace_idx" "  heartbeat_interval: 1"
        else
            local _hb_val; _hb_val="$(_get_scalar_value "${_lines[$_hb_idx]}" "heartbeat_interval")"
            if ! [[ "$_hb_val" =~ ^[0-9]+$ ]]; then
                _replace_line "$_hb_idx" "  heartbeat_interval: 1"
            fi
        fi
    fi

    # ------------------------------------------------------------------
    # Write only if any edit was made (idempotent: no edit -> no write).
    # ------------------------------------------------------------------
    if [[ "$_changed" -eq 0 ]]; then
        return 0
    fi

    local _tmp
    _tmp="$(mktemp "${_sf}.aid-tmp.XXXXXX")" || return 1
    {
        local _wi
        for _wi in "${!_lines[@]}"; do
            printf '%s\n' "${_lines[$_wi]}"
        done
    } > "${_tmp}" || { rm -f "${_tmp}"; return 1; }
    mv -f "${_tmp}" "${_sf}" || { rm -f "${_tmp}" 2>/dev/null; return 1; }
    return 0
}

# _aid_migrate_synthesize_settings_era_b <settings_file> <repo_name> <manifest>
# Era-b: write a fresh template-derived settings.yml when none exists.
# tools.installed from manifest_list_tools (bash) or empty list if no manifest.
# Crash-safe: same-directory temp + mv -f.
_aid_migrate_synthesize_settings_era_b() {
    local _sf="$1" _rname="$2" _manifest="$3"

    # Collect installed tools from manifest (exits 0, prints nothing when absent).
    local -a _tools=()
    while IFS= read -r _tid; do
        [[ -n "${_tid}" ]] && _tools+=("${_tid}")
    done < <(manifest_list_tools "${_manifest}" 2>/dev/null)

    local _tmp
    _tmp="$(mktemp "${_sf}.aid-tmp.XXXXXX")" || return 1

    {
        # C2: format_version stamp is the FIRST line (before project:).
        printf 'format_version: %s\n' "${AID_SUPPORTED_FORMAT}"
        printf 'project:\n'
        printf '  name: %s\n' "${_rname}"
        printf '  description: <project-description>\n'
        printf '  type: brownfield\n'
        printf '\n'
        printf 'tools:\n'
        if [[ "${#_tools[@]}" -eq 0 ]]; then
            printf '  installed: []\n'
        else
            printf '  installed:\n'
            local _t
            for _t in "${_tools[@]}"; do
                printf '    - %s\n' "${_t}"
            done
        fi
        printf '\n'
        printf 'review:\n'
        printf '  minimum_grade: A\n'
        printf '\n'
        printf 'execution:\n'
        printf '  max_parallel_tasks: 5\n'
        printf '\n'
        printf 'traceability:\n'
        printf '  heartbeat_interval: 1\n'
    } > "${_tmp}" || { rm -f "${_tmp}"; return 1; }

    mv -f "${_tmp}" "${_sf}" || { rm -f "${_tmp}" 2>/dev/null; return 1; }
    return 0
}


# ---------------------------------------------------------------------------
# _aid_cwd_classify <target-dir>
# C-table: classify the cwd repo and perform register-on-encounter.
# Called before repo commands (status, update [tool]) when .aid/ exists.
# When called with a dir that has .aid/, this function:
#   1. Checks if already registered (union read); if not, picks tier and registers.
#   2. Does NOT check stale here -- callers use _aid_format_gate for that.
# Returns 0 always (registration is best-effort; never blocks the host command).
_aid_cwd_classify() {
    local _target="$1"
    local _canon_target
    _canon_target="$(cd "$_target" && pwd)" 2>/dev/null || _canon_target="$_target"

    # Check if already registered in the union.
    local _is_registered=0
    while IFS= read -r _reg_p; do
        if [[ "$_reg_p" == "$_canon_target" ]]; then
            _is_registered=1
            break
        fi
    done < <(_registry_read_union)

    if [[ "$_is_registered" -eq 0 ]]; then
        # Not registered -- pick tier and register (best-effort, never blocks).
        # FR7: deterministic, non-interactive tier selection via _aid_resolve_tier.
        # _AID_TIER_OVERRIDE is empty here (auto path; no CLI override in cwd-classify).
        local _reg_tier
        _reg_tier="$(_aid_resolve_tier "$_canon_target")"
        # register is best-effort: failure warns, returns 0, host command proceeds.
        registry_register "$_canon_target" "$_reg_tier" || true
    fi
    return 0
}

# _aid_cwd_no_aid_offer <target-dir>
# C-table last row: .aid/ absent -- print offer + optional non-git note, then exit 0.
# This is the "no hard refuse" rule (decision #5): never errors on missing .aid/.
_aid_cwd_no_aid_offer() {
    local _target="$1"
    local _canon
    _canon="$(cd "$_target" 2>/dev/null && pwd)" || _canon="$_target"
    printf 'no AID project here -- set it up? (aid add)\n'
    # Non-git note (decision #5): a non-git dir can use AID; .aid/ just won't be
    # version-controlled if git is absent.
    if ! git -C "$_canon" rev-parse --git-dir >/dev/null 2>&1; then
        printf 'Note: %s is not a git repository -- .aid/ will not be version-controlled.\n' "$_canon"
    fi
    exit 0
}

# ---------------------------------------------------------------------------
# _cmd_projects -- list/add/remove/help for the project registry.
# ---------------------------------------------------------------------------
_cmd_projects() {
    local _action="${1:-list}"
    local _path_arg=""
    local _verbose=0
    shift || true

    # Parse remaining args: sub-action already consumed above.
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help) _action="help"; shift ;;
            --local)   _AID_TIER_OVERRIDE="--local"; shift ;;
            --shared)  _AID_TIER_OVERRIDE="--shared"; shift ;;
            --verbose) _verbose=1; _AID_VERBOSE=1; shift ;;
            -*)
                echo "ERROR: aid projects: unknown flag: $1 (see 'aid projects -h')" >&2
                exit 2
                ;;
            *)
                if [[ -z "$_path_arg" ]]; then
                    _path_arg="$1"
                fi
                shift ;;
        esac
    done

    case "$_action" in
        list)    _cmd_projects_list "$_verbose" ;;
        add)     _cmd_projects_add  "$_path_arg" "$_verbose" ;;
        remove)  _cmd_projects_remove "$_path_arg" "$_verbose" ;;
        help)    _aid_usage projects; exit 0 ;;
        *)
            echo "ERROR: aid projects: unknown action: ${_action} (expected: list, add, remove, help)" >&2
            exit 2
            ;;
    esac
}

# _cmd_projects_list [verbose]
# Render the raw union as an aligned table: marker, path, state, tools, tier.
# Marks cwd with "*"; footnotes unregistered AID cwd.
# --verbose: also print the registry file each entry was read from.
_cmd_projects_list() {
    local _verbose="${1:-0}"

    # Canonical cwd.
    local _cwd
    _cwd="$(cd . && pwd)"

    # Collect raw union (includes no-aid / missing paths).
    local -a _paths=()
    while IFS= read -r _p; do
        [[ -n "$_p" ]] && _paths+=("$_p")
    done < <(_registry_read_raw_union)

    # Column header.
    printf '%-2s  %-45s  %-10s  %-20s  %s\n' " " "PATH" "STATE" "TOOLS" "TIER"
    printf '%-2s  %-45s  %-10s  %-20s  %s\n' "--" "----" "-----" "-----" "----"

    local _cwd_registered=0
    local _entry
    for _entry in "${_paths[@]+"${_paths[@]}"}"; do
        local _state _tools _tier _marker
        _state="$(_aid_project_state "$_entry")"
        _tools="$(_aid_project_tools "$_entry")"
        _tier="$(_which_tier_holds "$_entry")"
        _marker="  "
        if [[ "$_entry" == "$_cwd" ]]; then
            _marker="* "
            _cwd_registered=1
        fi
        # Truncate long tools string for display.
        local _tools_display="${_tools:--}"
        printf '%-2s  %-45s  %-10s  %-20s  %s\n' \
            "$_marker" "$_entry" "$_state" "$_tools_display" "$_tier"
        if [[ "$_verbose" -eq 1 ]]; then
            local _reg_src
            if [[ "$_tier" == "shared" && "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
                _reg_src="${AID_STATE_HOME}/registry.yml"
            else
                _reg_src="${HOME}/.aid/registry.yml"
            fi
            printf '      registry: %s\n' "$_reg_src"
        fi
    done

    if [[ "${#_paths[@]}" -eq 0 ]]; then
        printf '(no projects registered)\n'
    fi

    # Footnote: unregistered AID cwd (only when cwd is a real project, not the state home).
    if [[ "$_cwd_registered" -eq 0 ]] && _aid_is_project_dir "${_cwd}"; then
        printf '\n'
        printf "(here) -- not registered; run 'aid projects add'\n"
    fi

    # Legend.
    if [[ "${#_paths[@]}" -gt 0 ]]; then
        printf '\n'
        printf '* = current directory\n'
    fi
}

# _which_tier_holds <canon-path>
# Returns "user" or "shared" based on which registry file contains the path.
# Falls back to _aid_resolve_tier if the path is not found in either.
_which_tier_holds() {
    local _p="$1"
    local _primary_reg="${AID_STATE_HOME}/registry.yml"
    local _user_reg="${HOME}/.aid/registry.yml"
    # Check shared/primary first.
    if [[ "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
        if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_p"; then
            printf 'shared\n'
            return 0
        fi
        if _registry_read_repos "$_user_reg" 2>/dev/null | grep -qxF "$_p"; then
            printf 'user\n'
            return 0
        fi
    else
        # Per-user: single file.
        if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_p"; then
            printf 'user\n'
            return 0
        fi
    fi
    # Fallback: derive from tier resolution.
    _aid_resolve_tier "$_p"
}

# _cmd_projects_add [path] [verbose]
# Register a project path (default: cwd) in the deterministic tier.
_cmd_projects_add() {
    local _raw_path="${1:-.}"
    local _verbose="${2:-0}"

    # Canonicalize.
    local _canon
    if ! _canon="$(cd "$_raw_path" 2>/dev/null && pwd)"; then
        echo "ERROR: aid projects add: path does not exist: ${_raw_path}" >&2
        exit 2
    fi

    # Require a real AID project (.aid/ present AND not the CLI state home).
    if ! _aid_is_project_dir "${_canon}"; then
        echo "ERROR: aid projects add: '${_canon}' is not an AID project; run 'aid add <tool>' first." >&2
        exit 2
    fi

    # Resolve tier.
    local _tier
    _tier="$(_aid_resolve_tier "$_canon")"

    # Register (idempotent).
    # Suppress registry_register's own "Registered..." stdout line so we emit a
    # single consolidated message instead of two lines.  stderr (WARN lines) is
    # left to flow through unchanged.
    registry_register "$_canon" "$_tier" >/dev/null
    local _rc=$?
    if [[ $_rc -eq 0 ]]; then
        printf "aid projects: '%s' registered in %s tier.\n" "$_canon" "$_tier"
        if [[ "$_verbose" -eq 1 ]]; then
            local _reg_file
            if [[ "$_tier" == "shared" && "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
                _reg_file="${AID_STATE_HOME}/registry.yml"
            else
                _reg_file="${HOME}/.aid/registry.yml"
            fi
            printf "aid projects: registry file: %s\n" "$_reg_file"
        fi
    fi
    return 0
}

# _cmd_projects_remove [path] [verbose]
# Unregister a project path (default: cwd) from the registry; no .aid/ required.
_cmd_projects_remove() {
    local _raw_path="${1:-.}"
    local _verbose="${2:-0}"

    # Canonicalize without requiring the directory to exist.
    local _canon
    if _canon="$(cd "$_raw_path" 2>/dev/null && pwd)"; then
        : # directory exists; use canonical path
    else
        # Directory absent (stale entry); use the raw path as-is after normalizing.
        _canon="$_raw_path"
    fi

    # Unregister (idempotent; registry_unregister prints on real change).
    # Check first so we can emit the idempotent no-op message ourselves.
    local _primary_reg="${AID_STATE_HOME}/registry.yml"
    local _user_reg="${HOME}/.aid/registry.yml"
    local _found=0
    if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_canon"; then
        _found=1
    elif [[ "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
        if _registry_read_repos "$_user_reg" 2>/dev/null | grep -qxF "$_canon"; then
            _found=1
        fi
    fi

    registry_unregister "$_canon"
    if [[ "$_found" -eq 0 ]]; then
        printf "aid projects: '%s' was not registered (nothing to remove).\n" "$_canon"
    elif [[ "$_verbose" -eq 1 ]]; then
        printf "aid projects: removed '%s' from registry.\n" "$_canon"
    fi
    return 0
}

# ---------------------------------------------------------------------------
# Parse subcommand and dispatch.
# ---------------------------------------------------------------------------

# Shared flag buckets (populated during subcommand-specific arg parsing).
_AID_TOOL_ARG=""
_AID_VERSION_ARG=""
_AID_FROM_BUNDLE=""
_AID_FORCE=0
_AID_TARGET=""
_AID_VERBOSE="${AID_VERBOSE:-0}"
_AID_NO_PATH=0

# ---------------------------------------------------------------------------
# Dashboard (bare 'aid' - no arguments).
# ---------------------------------------------------------------------------
_cmd_dashboard() {
    # Block 1 + 2: Header + description.
    local cli_version="unknown"
    local ver_file="${AID_CODE_HOME}/VERSION"
    if [[ -f "$ver_file" ]]; then
        cli_version="$(tr -d '[:space:]' < "$ver_file")"
    fi
    printf 'AID v%s - AI Integrated Development\n' "$cli_version"
    printf 'Install, update, and manage AID across your projects.\n'

    # C6: format gate for cwd repo (.aid/ is guaranteed present here -- the
    # non-project case is intercepted at the dispatch level above via
    # _aid_cwd_no_aid_offer; register-on-encounter already ran via _aid_cwd_classify).
    # _aid_is_project_dir guards the state-home exclusion (double-check).
    if _aid_is_project_dir "."; then
        _aid_format_gate "." || return $?
    fi

    # Block 3: Installed tools for cwd.
    printf '\n'
    aid_status_body "."

    # Block 4: Usage/help.
    printf '\n'
    _aid_usage

    # Block 5: Update check notice (final line, non-blocking).
    _aid_check_update
}

# Early help check.
if [[ $# -eq 0 ]]; then
    # C-table: if cwd is not an AID project -> offer (no hard refuse, decision #5); exit 0.
    # _aid_is_project_dir excludes the CLI state home from the "is project" classification.
    if ! _aid_is_project_dir "."; then
        _aid_cwd_no_aid_offer "."
        # _aid_cwd_no_aid_offer always exits 0.
    fi
    # C-table register-on-encounter (best-effort, never blocks bare aid).
    _aid_cwd_classify "."
    # Bare 'aid' -> dashboard landing screen.
    _cmd_dashboard
    exit $?
fi

case "$1" in
    -h|--help)
        _aid_usage
        exit 0
        ;;
esac

SUBCMD="$1"
shift

# ---- version ----------------------------------------------------------------
if [[ "$SUBCMD" == "version" ]]; then
    local_version_file="${AID_CODE_HOME}/VERSION"
    if [[ -f "$local_version_file" ]]; then
        cat "$local_version_file"
    else
        echo "unknown (VERSION file not found at ${local_version_file})"
    fi
    exit 0
fi

# ---- help -------------------------------------------------------------------
if [[ "$SUBCMD" == "help" || "$SUBCMD" == "-h" || "$SUBCMD" == "--help" ]]; then
    _aid_usage
    exit 0
fi

# ---- status -----------------------------------------------------------------
if [[ "$SUBCMD" == "status" ]]; then
    # Parse flags for status.
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --target)
                [[ $# -lt 2 ]] && _aid_die "--target requires a value" 2
                _AID_TARGET="$2"; shift 2 ;;
            --verbose) _AID_VERBOSE=1; shift ;;
            -h|--help) _aid_usage status; exit 0 ;;
            -*)        _aid_die "unknown flag for status: $1" 2 ;;
            *)         _aid_die "unexpected argument for status: $1" 2 ;;
        esac
    done
    # Apply env-var fallbacks.
    [[ -z "$_AID_TARGET" && -n "${AID_TARGET:-}" ]] && _AID_TARGET="$AID_TARGET"
    _AID_TARGET="${_AID_TARGET:-.}"
    export AID_VERBOSE="$_AID_VERBOSE"
    # C-table: if target is not an AID project -> offer (no hard refuse, decision #5); exit 0.
    # _aid_is_project_dir excludes the CLI state home from the "is project" classification.
    if ! _aid_is_project_dir "${_AID_TARGET}"; then
        _aid_cwd_no_aid_offer "${_AID_TARGET}"
        # _aid_cwd_no_aid_offer always exits 0.
    fi
    # C-table register-on-encounter (best-effort, never blocks status).
    _aid_cwd_classify "${_AID_TARGET}"
    # C6: format gate for status target (only when target is a real project).
    _aid_format_gate "${_AID_TARGET}" || exit $?
    aid_status "$_AID_TARGET"
    _status_rc=$?
    # Update check notice appended after status output (non-blocking).
    _aid_check_update
    exit $_status_rc
fi

# ---- update -----------------------------------------------------------------
if [[ "$SUBCMD" == "update" ]]; then
    # Check for 'update self' as first positional arg.
    if [[ $# -gt 0 && "$1" == "self" ]]; then
        shift
        # Consume any flags after 'self'.
        _SELF_FROM_BUNDLE=""
        _SELF_DRYRUN=0
        while [[ $# -gt 0 ]]; do
            case "$1" in
                --force|-y) shift ;;  # no-op for update self
                --from-bundle)
                    [[ $# -lt 2 ]] && _aid_die "--from-bundle requires a value" 2
                    _SELF_FROM_BUNDLE="$2"; shift 2 ;;
                --dry-run) _SELF_DRYRUN=1; shift ;;
                -h|--help) _aid_usage update; exit 0 ;;
                *) _aid_die "unknown flag for 'update self': $1" 2 ;;
            esac
        done
        export _SELF_FROM_BUNDLE _SELF_DRYRUN
        _cmd_update_self; _us_rc=$?
        if [[ "${_us_rc}" -ne 0 ]]; then exit "${_us_rc}"; fi
        # Post-update: registry-driven migration (feature-004).
        # Iterate _registry_read_union -- NO scan -- with All/Yes/No/Cancel per-repo
        # consent walk.  Unregistered repos are caught lazily by the per-repo stamp.
        # No .migrated marker is written (removed; stamp in settings.yml is the record).
        # dry-run: the install step already printed its command; skip migration silently.
        if [[ "${_SELF_DRYRUN:-0}" != "1" ]]; then
            _us_migrate_all=0
            _us_migrate_cancel=0
            # Read the union of registered repos (quiet-prunes stale entries).
            _us_repos=()
            while IFS= read -r _us_r; do
                [[ -n "$_us_r" ]] && _us_repos+=("$_us_r")
            done < <(_registry_read_union)
            if [[ "${#_us_repos[@]}" -eq 0 ]]; then
                echo "No registered projects to migrate."
            else
                # Determine interactive mode: AID_MIGRATE_YES=1 is the explicit opt-in for
                # auto-yes.  Non-interactive without opt-in -> no migration (per SPEC: without
                # opt-in, no migration is forced).  Test /dev/tty by attempting to open it.
                _us_auto_yes=0
                _us_have_tty=0
                [[ "${AID_MIGRATE_YES:-0}" == "1" ]] && _us_auto_yes=1
                { exec 3</dev/tty; } 2>/dev/null && { _us_have_tty=1; exec 3>&-; } || true
                if [[ "$_us_auto_yes" -eq 0 && "$_us_have_tty" -eq 0 ]]; then
                    # Non-interactive, no opt-in: skip all (non-interactive default, SPEC edge-cases).
                    echo "Skipping project migration (non-interactive; set AID_MIGRATE_YES=1 to opt in)."
                else
                    for _us_repo in "${_us_repos[@]}"; do
                        if [[ "$_us_migrate_cancel" -eq 1 ]]; then
                            break
                        fi
                        if [[ "$_us_migrate_all" -eq 1 || "$_us_auto_yes" -eq 1 ]]; then
                            _us_answer="y"
                        else
                            printf 'Migrate project %s? [All/Yes/No/Cancel] ' "$_us_repo"
                            _us_answer=""
                            read -r _us_answer < /dev/tty
                        fi
                        case "$_us_answer" in
                            [Aa]|all|All|ALL)
                                _us_migrate_all=1
                                _aid_migrate_repo "$_us_repo"
                                ;;
                            [Yy]|yes|Yes|YES)
                                _aid_migrate_repo "$_us_repo"
                                ;;
                            [Cc]|cancel|Cancel|CANCEL)
                                _us_migrate_cancel=1
                                echo "Migration cancelled."
                                ;;
                            *)
                                echo "Skipped: ${_us_repo}"
                                ;;
                        esac
                    done
                fi
            fi
        fi
        exit 0
    fi
    # Fall through to the shared add/update handler below.
fi

# ---- remove -----------------------------------------------------------------
if [[ "$SUBCMD" == "remove" ]]; then
    # Check for 'remove self' as first positional arg.
    if [[ $# -gt 0 && "$1" == "self" ]]; then
        shift
        _cmd_remove_self "$@"
        # _cmd_remove_self always exits.
    fi

    # Check for 'remove' with no tool args (remove ALL from project).
    # We do this check after parsing flags below, so continue to parse first.
fi

# ---- dashboard --------------------------------------------------------------
if [[ "$SUBCMD" == "dashboard" ]]; then
    _cmd_dashboard_ctl "$@"
    exit $?
fi

# ---- projects ---------------------------------------------------------------
if [[ "$SUBCMD" == "projects" ]]; then
    # Check for -h/--help as first arg before dispatching.
    if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
        _aid_usage projects
        exit 0
    fi
    # Determine sub-action (first positional or default "list").
    # Scan through leading flags to find the action word; unknown positionals are
    # rejected here so errors surface before entering _cmd_projects.
    _PROJ_ACTION="list"
    _PROJ_ARGS=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            list|add|remove|help)
                _PROJ_ACTION="$1"; shift
                _PROJ_ARGS+=("$@")
                set --
                break
                ;;
            -h|--help) _aid_usage projects; exit 0 ;;
            --local|--shared|--verbose)
                _PROJ_ARGS+=("$1"); shift ;;
            -*)
                # Unknown flag: pass through to _cmd_projects for rejection.
                _PROJ_ARGS+=("$1"); shift ;;
            *)
                echo "ERROR: aid projects: unknown action: ${1} (expected: list, add, remove, help)" >&2
                exit 2
                ;;
        esac
    done
    _AID_TIER_OVERRIDE="${_AID_TIER_OVERRIDE:-}"
    _cmd_projects "$_PROJ_ACTION" "${_PROJ_ARGS[@]+"${_PROJ_ARGS[@]}"}"
    exit $?
fi

# ---- __migrate-repo (hidden, callable-core only -- task-077/081) ------------
if [[ "$SUBCMD" == "__migrate-repo" ]]; then
    if [[ $# -lt 1 ]]; then
        echo "ERROR: aid __migrate-repo requires a <repo> path argument" >&2
        exit 2
    fi
    _MIG_TARGET="$1"
    if [[ ! -d "${_MIG_TARGET}" ]]; then
        echo "ERROR: aid __migrate-repo: not a directory: ${_MIG_TARGET}" >&2
        exit 2
    fi
    _MIG_TARGET="$(cd "${_MIG_TARGET}" && pwd)"
    _aid_migrate_repo "${_MIG_TARGET}"
    exit 0
fi

# ---- add / remove / update --------------------------------------------------
# These subcommands all share flag parsing; we then call the engine functions
# (install_tool / uninstall_tool) directly through a per-tool loop, exactly as
# install.sh does.  We build a temporary staging area for install/update, and
# reuse the same prepare_tool_staging + install_tool / uninstall_tool pattern.

# First, validate the subcommand.
case "$SUBCMD" in
    add|remove|update) ;;
    *)
        echo "ERROR: aid: unknown command: ${SUBCMD} (see 'aid -h')" >&2
        exit 2
        ;;
esac


# Collect positional tool args (comma-separated or space-separated before flags).
_AID_POSITIONAL_TOOLS=""
_AID_REMOVE_FORCE=0
_AID_DRY_RUN=0

while [[ $# -gt 0 ]]; do
    case "$1" in
        --from-bundle)
            [[ $# -lt 2 ]] && _aid_die "--from-bundle requires a value" 2
            _AID_FROM_BUNDLE="$2"; shift 2 ;;
        --version)
            [[ $# -lt 2 ]] && _aid_die "--version requires a value" 2
            _AID_VERSION_ARG="$2"; shift 2 ;;
        --force|-y) _AID_FORCE=1; _AID_REMOVE_FORCE=1; shift ;;
        --verbose) _AID_VERBOSE=1; shift ;;
        --target)
            [[ $# -lt 2 ]] && _aid_die "--target requires a value" 2
            _AID_TARGET="$2"; shift 2 ;;
        --no-path) _AID_NO_PATH=1; shift ;;
        --dry-run) _AID_DRY_RUN=1; shift ;;
        -h|--help) _aid_usage "$SUBCMD"; exit 0 ;;
        -*)        _aid_die "unknown flag: $1" 2 ;;
        *)
            # Positional arg: tool name(s).
            if [[ -z "$_AID_POSITIONAL_TOOLS" ]]; then
                _AID_POSITIONAL_TOOLS="$1"
            else
                # Additional space-separated tools: append as comma-list.
                _AID_POSITIONAL_TOOLS="${_AID_POSITIONAL_TOOLS},$1"
            fi
            shift ;;
    esac
done

# Apply env-var fallbacks.
[[ -z "$_AID_TOOL_ARG" && -n "$_AID_POSITIONAL_TOOLS" ]]  && _AID_TOOL_ARG="$_AID_POSITIONAL_TOOLS"
[[ -z "$_AID_TOOL_ARG" && -n "${AID_TOOL:-}" ]]            && _AID_TOOL_ARG="$AID_TOOL"
[[ -z "$_AID_VERSION_ARG" && -n "${AID_VERSION:-}" ]]      && _AID_VERSION_ARG="$AID_VERSION"
[[ -z "$_AID_TARGET" && -n "${AID_TARGET:-}" ]]             && _AID_TARGET="$AID_TARGET"
if [[ "$_AID_FORCE" -eq 0 && ( "${AID_FORCE:-0}" == "1" || "${AID_FORCE:-0}" == "true" ) ]]; then
    _AID_FORCE=1
    _AID_REMOVE_FORCE=1
fi
export AID_VERBOSE="$_AID_VERBOSE"

# FR10: 'update' no longer accepts a per-tool positional (other than 'self' which
# was already consumed above).  Any non-flag positional on 'aid update' is a usage error.
if [[ "$SUBCMD" == "update" && -n "$_AID_TOOL_ARG" ]]; then
    echo "ERROR: aid update: unexpected argument: '${_AID_TOOL_ARG}'" >&2
    echo "       'aid update' updates all installed tools -- no per-tool selection." >&2
    echo "       Use 'aid update self' to update the CLI only." >&2
    echo "       See 'aid update -h' for usage." >&2
    exit 2
fi

_AID_TARGET="${_AID_TARGET:-.}"
# Validate target dir.
if [[ ! -d "$_AID_TARGET" ]]; then
    _aid_die "target directory does not exist: ${_AID_TARGET}" 2
fi
_AID_TARGET="$(cd "$_AID_TARGET" && pwd)"

# ---- FR10: 'update' outside an AID repo -> update the CLI only (not offer-and-exit) ----
# Outside a repo: delegates to the CLI-only update path; no tool loop.
# Inside a repo: fall through to the full tool-update pass below.
# _aid_is_project_dir excludes the CLI state home from the "is project" classification.
if [[ "$SUBCMD" == "update" ]]; then
    if ! _aid_is_project_dir "${_AID_TARGET}"; then
        # FR10 outside-repo: update the CLI only; no tool loop.
        # For a dry-run preview of the CLI self-update, use 'aid update self --dry-run'.
        _UPD_CLI_VER=""
        [[ -f "${AID_CODE_HOME}/VERSION" ]] && _UPD_CLI_VER="$(tr -d '[:space:]' < "${AID_CODE_HOME}/VERSION")"
        # Check if already latest using cached update-check result (no network call).
        _UPD_CACHE_FILE="${HOME}/.aid/.update-check"
        _UPD_CACHED_LATEST=""
        [[ -f "${_UPD_CACHE_FILE}" ]] && _UPD_CACHED_LATEST="$(awk 'NR==2{print $1}' "${_UPD_CACHE_FILE}" 2>/dev/null)" || true
        if [[ -n "$_UPD_CLI_VER" && -n "$_UPD_CACHED_LATEST" && "$_UPD_CLI_VER" == "$_UPD_CACHED_LATEST" ]]; then
            echo "CLI is current (v${_UPD_CLI_VER})"
            exit 0
        fi
        _aid_update_self_if_stale
        exit 0
    fi
fi

# ---- Self-update-if-needed preamble (FF-3 / CLI-2 / task-079) --------------
# For 'update' inside an AID repo only (not 'add', not 'update self').
# Ensures the CLI is current before the per-repo tool-update runs.  WARN-not-fail.
if [[ "$SUBCMD" == "update" ]]; then
    _aid_update_self_if_stale
fi

# Strip leading 'v' from version.
_AID_VERSION_ARG="${_AID_VERSION_ARG#v}"

# --from-bundle and --version are mutually exclusive.
if [[ -n "$_AID_FROM_BUNDLE" && -n "$_AID_VERSION_ARG" ]]; then
    _aid_die "--from-bundle and --version are mutually exclusive" 2
fi

# For 'remove' with no tool arg: confirm, then remove all.
if [[ "$SUBCMD" == "remove" && -z "$_AID_TOOL_ARG" ]]; then
    # Confirmation required (unless --force or non-interactive).
    if [[ "$_AID_REMOVE_FORCE" -eq 0 ]]; then
        if [[ ! -t 0 ]]; then
            # Non-interactive: auto-proceed (don't hang CI).
            _AID_REMOVE_FORCE=1
        else
            printf 'Remove ALL AID from %s? [y/N] ' "$_AID_TARGET"
            _AID_RM_ANSWER=""
            if [[ -e /dev/tty ]]; then
                read -r _AID_RM_ANSWER < /dev/tty
            else
                read -r _AID_RM_ANSWER
            fi
            if [[ "$_AID_RM_ANSWER" != "y" && "$_AID_RM_ANSWER" != "Y" && "$_AID_RM_ANSWER" != "yes" && "$_AID_RM_ANSWER" != "YES" ]]; then
                echo "Aborted."
                exit 0
            fi
        fi
    fi
    # Proceed: fall through to resolve all tools from manifest.
fi

# Validate constraints per subcommand.
case "$SUBCMD" in
    add)
        # Tool is required (or must be auto-detectable / env-var set).
        : ;;  # handled below in _resolve_tools_for_aid
    remove)
        : ;; # tool optional (empty = all installed, confirmed above)
    update)
        : ;; # tool optional (empty = all installed)
esac

# ---------------------------------------------------------------------------
# Resolve tool list (reuses the same logic as install.sh _resolve_tools).
# ---------------------------------------------------------------------------
_AID_MANIFEST="${_AID_TARGET}/.aid/.aid-manifest.json"

_resolve_tools_for_aid() {
    local raw="$1" subcmd="$2" outfile="$3"

    if [[ -z "$raw" ]]; then
        if [[ "$subcmd" == "update" || "$subcmd" == "remove" ]]; then
            # No tool specified -> all tools in manifest.
            if [[ ! -f "$_AID_MANIFEST" ]]; then
                return 0
            fi
            manifest_list_tools "$_AID_MANIFEST" >> "$outfile"
            return 0
        fi
        # auto-detect for 'add'.
        local detected
        detected="$(detect_tool "$_AID_TARGET")"
        local _rc=$?
        if [[ "$_rc" -ne 0 ]]; then
            return "$_rc"
        fi
        echo "$detected" >> "$outfile"
        return 0
    fi

    # Split on comma.
    local -a raw_tools=()
    IFS=',' read -ra raw_tools <<< "$raw"
    for t in "${raw_tools[@]}"; do
        t="$(echo "$t" | tr -d '[:space:]')"
        local canonical
        canonical="$(normalize_tool "$t")"
        local _rc=$?
        if [[ "$_rc" -ne 0 ]]; then
            return "$_rc"
        fi
        echo "$canonical" >> "$outfile"
    done
    return 0
}

# Set up staging area.
_AID_STAGING_BASE="$(mktemp -d /tmp/aid-XXXXXX)"
trap 'rm -rf "$_AID_STAGING_BASE"' EXIT

_TOOLS_TMP="$(mktemp "${_AID_STAGING_BASE}/tools.XXXXXX")"
_resolve_tools_for_aid "$_AID_TOOL_ARG" "$SUBCMD" "$_TOOLS_TMP"
_RESOLVE_RC=$?
if [[ "$_RESOLVE_RC" -ne 0 ]]; then
    rm -rf "$_AID_STAGING_BASE"
    exit "$_RESOLVE_RC"
fi

mapfile -t _AID_TOOLS < "$_TOOLS_TMP"

if [[ "${#_AID_TOOLS[@]}" -eq 0 ]]; then
    case "$SUBCMD" in
        remove)
            echo "ERROR: aid: no manifest at ${_AID_TARGET}/.aid/.aid-manifest.json (exit 6)" >&2
            rm -rf "$_AID_STAGING_BASE"
            exit 6
            ;;
        update)
            echo "ERROR: aid: no manifest at ${_AID_TARGET}/.aid/.aid-manifest.json; nothing to update (exit 6)" >&2
            rm -rf "$_AID_STAGING_BASE"
            exit 6
            ;;
        add)
            echo "ERROR: aid: cannot auto-detect host tool; pass tool name as argument (e.g. aid add codex)" >&2
            rm -rf "$_AID_STAGING_BASE"
            exit 2
            ;;
    esac
fi

# ---------------------------------------------------------------------------
# Prepare staging for install/update (mirrors install.sh prepare_tool_staging).
# ---------------------------------------------------------------------------
_AID_RESOLVED_VERSION=""
_AID_STAGING_DIR=""

_prepare_tool_staging_aid() {
    local tool="$1" version="$2" from_bundle="$3"

    local tool_staging
    tool_staging="$(mktemp -d "${_AID_STAGING_BASE}/staging-${tool}-XXXXXX")"

    if [[ -n "$from_bundle" ]]; then
        local tarball="$from_bundle"
        if [[ -d "$from_bundle" ]]; then
            tarball="$(ls "${from_bundle}"/aid-${tool}-v*.tar.gz 2>/dev/null | head -1)"
            if [[ -z "$tarball" ]]; then
                echo "ERROR: aid: no tarball found for tool '${tool}' in bundle directory: ${from_bundle}" >&2
                exit 1
            fi
        fi
        if [[ ! -f "$tarball" ]]; then
            echo "ERROR: aid: bundle file not found: ${tarball}" >&2
            exit 1
        fi
        verify_bundle_checksum "$tarball" || exit $?
        local tbase
        tbase="$(basename "$tarball")"
        # Derive the version STRICTLY from the canonical tool-bundle name
        # (aid-<tool>-v<semver>.tar.gz). Never stamp a raw filename: a name that
        # does not match this shape is the wrong artifact (e.g. the CLI installer
        # package aid-installer-<ver>.tgz), not a tool bundle. Fall back to an
        # explicit --version, else fail loudly rather than recording garbage.
        if [[ "$tbase" =~ ^aid-${tool}-v([0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.+-]+)?)\.tar\.gz$ ]]; then
            _AID_RESOLVED_VERSION="${BASH_REMATCH[1]}"
        elif [[ -n "${version:-}" ]]; then
            _AID_RESOLVED_VERSION="$version"
        else
            echo "ERROR: aid: '${tbase}' is not a valid AID tool bundle for '${tool}'." >&2
            echo "       Expected a tarball named 'aid-${tool}-v<version>.tar.gz'." >&2
            echo "       (Did you pass the CLI installer package instead of a tool bundle?" >&2
            echo "        Point --from-bundle at a release staging dir or an aid-${tool}-v*.tar.gz file.)" >&2
            exit 2
        fi
        extract_tarball "$tarball" "$tool_staging" || exit $?
    else
        if [[ -z "$version" ]]; then
            _AID_RESOLVED_VERSION="$(resolve_version)" || exit $?
        else
            _AID_RESOLVED_VERSION="$version"
        fi
        local dl_dir
        dl_dir="$(mktemp -d "${_AID_STAGING_BASE}/download-${tool}-XXXXXX")"
        fetch_tarball "$tool" "$_AID_RESOLVED_VERSION" "$dl_dir" || exit $?
        local tarball="${dl_dir}/aid-${tool}-v${_AID_RESOLVED_VERSION}.tar.gz"
        extract_tarball "$tarball" "$tool_staging" || exit $?
    fi

    _AID_STAGING_DIR="$tool_staging"
}

# ---------------------------------------------------------------------------
# Dispatch to engine.
# ---------------------------------------------------------------------------
case "$SUBCMD" in
    add|update)
        # B-table (for 'add'): writability pre-check BEFORE any .aid/ is created.
        # Decision #3: never elevate .aid/ creation -- error if folder is not writable.
        if [[ "$SUBCMD" == "add" ]]; then
            if [[ ! -w "$_AID_TARGET" ]]; then
                echo "ERROR: aid: add: target directory is not writable: ${_AID_TARGET}" >&2
                echo "ERROR: aid: add: AID will not create a root-owned .aid/ -- fix folder permissions and retry." >&2
                rm -rf "$_AID_STAGING_BASE"
                exit 1
            fi
        fi

        # ---------------------------------------------------------------------------
        # FR11: aid add version selection (same-version invariant).
        # First-tool  (no existing tools in manifest): install at the CLI version.
        # Additional-tool (manifest already has >=1 tool): install at the EXISTING
        # tools' version to keep the repo uniform.  add does NOT force a repo-wide
        # update.  --version on add must apply to ALL tools or error (mixed-version
        # repo would result if the requested version differs from the existing one).
        # ---------------------------------------------------------------------------
        if [[ "$SUBCMD" == "add" ]]; then
            _FR11_CLI_VER=""
            [[ -f "${AID_CODE_HOME}/VERSION" ]] && \
                _FR11_CLI_VER="$(tr -d '[:space:]' < "${AID_CODE_HOME}/VERSION")"
            _FR11_EXISTING_VER=""
            if [[ -f "$_AID_MANIFEST" ]]; then
                _FR11_FIRST_TOOL="$(manifest_list_tools "$_AID_MANIFEST" | head -1)"
                if [[ -n "$_FR11_FIRST_TOOL" ]]; then
                    _FR11_EXISTING_VER="$(manifest_read_tool_version "$_AID_MANIFEST" "$_FR11_FIRST_TOOL")"
                fi
            fi

            if [[ -n "$_AID_VERSION_ARG" ]]; then
                # --version on add: validate it won't create a mixed-version repo.
                if [[ -n "$_FR11_EXISTING_VER" && "$_AID_VERSION_ARG" != "$_FR11_EXISTING_VER" ]]; then
                    echo "ERROR: aid add: --version ${_AID_VERSION_ARG} would create a mixed-version repo." >&2
                    echo "       Existing tools are at v${_FR11_EXISTING_VER}. Either:" >&2
                    echo "         - Omit --version to install at the repo version (v${_FR11_EXISTING_VER}), or" >&2
                    echo "         - Run 'aid update --version ${_AID_VERSION_ARG}' first to advance the whole repo." >&2
                    rm -rf "$_AID_STAGING_BASE"
                    exit 2
                fi
                # --version provided and no conflict: apply to all tools (passed through to staging).
            elif [[ -n "$_FR11_EXISTING_VER" ]]; then
                # Additional-tool: pin staging to the existing repo version (not the CLI version).
                _AID_VERSION_ARG="$_FR11_EXISTING_VER"
                # Skew notice when CLI is ahead of the repo version.
                if [[ -n "$_FR11_CLI_VER" ]] && _semver_lt "$_FR11_EXISTING_VER" "$_FR11_CLI_VER"; then
                    echo "repo is at v${_FR11_EXISTING_VER}; new tool(s) installed at v${_FR11_EXISTING_VER} to keep the repo uniform. Run 'aid update' to advance all tools to v${_FR11_CLI_VER}."
                fi
            else
                # First-tool: pin to CLI version (bundle supplies its own version; skip if so).
                if [[ -z "$_AID_FROM_BUNDLE" && -n "$_FR11_CLI_VER" ]]; then
                    _AID_VERSION_ARG="$_FR11_CLI_VER"
                fi
            fi
        fi

        # C-table (for 'update'): register-on-encounter + format gate.
        # The missing-.aid/ case was already intercepted above (pre-resolve-tools).
        if [[ "$SUBCMD" == "update" ]]; then
            # C-table register-on-encounter (best-effort).
            _aid_cwd_classify "${_AID_TARGET}"
            # C6: format gate for the update repo path.
            _aid_format_gate "${_AID_TARGET}" || exit $?
        fi

        # ---------------------------------------------------------------------------
        # FR10 Stage-all-first atomicity (task-009):
        # PHASE 1: Stage ALL tools (resolve version, fetch, checksum-verify, extract
        #          to temp) BEFORE any destination write.  A failure here aborts with
        #          zero destination mutation.
        # ---------------------------------------------------------------------------
        declare -A _STAGE_MAP=()   # tool -> staging_dir
        _STAGE_VERSION=""          # single resolved version for all tools

        for _tool in "${_AID_TOOLS[@]}"; do
            _prepare_tool_staging_aid "$_tool" "$_AID_VERSION_ARG" "$_AID_FROM_BUNDLE"
            _STAGE_MAP["$_tool"]="$_AID_STAGING_DIR"
            # All tools must be at the same version (the first resolved wins, the rest
            # confirm by the same --version / bundle arg).
            if [[ -z "$_STAGE_VERSION" ]]; then
                _STAGE_VERSION="$_AID_RESOLVED_VERSION"
            fi
        done

        # ---------------------------------------------------------------------------
        # FR10 --dry-run: print the plan and exit with no writes.
        # ---------------------------------------------------------------------------
        if [[ "${_AID_DRY_RUN:-0}" -eq 1 ]]; then
            echo "--- aid ${SUBCMD} --dry-run plan (no writes) ---"
            echo "Target: ${_AID_TARGET}"
            echo "Version: ${_STAGE_VERSION:-<current>}"
            for _tool in "${_AID_TOOLS[@]}"; do
                echo ""
                echo "Tool: ${_tool}"
                _dry_staging="${_STAGE_MAP[$_tool]}"
                # List files that would be copied.
                while IFS= read -r -d '' _dry_f; do
                    echo "  copy: ${_dry_f#${_dry_staging}/} -> ${_AID_TARGET}"
                done < <(find "${_dry_staging}" -type f -print0 2>/dev/null | sort -z)
                # List files that would be MOVED TO TRASH by the retired-root migration sweep
                # (marker 1: aid-* prefix; marker 2: inside an aid/ subtree).
                # Uses list_only=1 mode of _migrate_retired_layout (no writes).
                _dry_removed_out="$(_migrate_retired_layout "${_AID_TARGET}" "${_tool}" 1 2>/dev/null)"
                if [[ -n "$_dry_removed_out" ]]; then
                    echo "  Would MOVE TO TRASH (retired-layout migration):"
                    printf '%s\n' "$_dry_removed_out"
                fi
            done
            echo ""
            echo "--- end dry-run plan ---"
            rm -rf "$_AID_STAGING_BASE"
            exit 0
        fi

        # ---------------------------------------------------------------------------
        # PHASE 2: Commit all staged tools.
        # If any commit fails, exit non-zero with a re-run-to-heal message.
        # aid update is idempotent: re-running drives every tool to the target version.
        # ---------------------------------------------------------------------------
        for _tool in "${_AID_TOOLS[@]}"; do
            echo ""
            echo "Installing ${_tool} v${_STAGE_VERSION} -> ${_AID_TARGET}"
            install_tool "${_STAGE_MAP[$_tool]}" "$_tool" "$_AID_TARGET" "$_STAGE_VERSION" "$_AID_FORCE" || {
                _COMMIT_RC=$?
                echo "" >&2
                echo "ERROR: aid ${SUBCMD} failed mid-commit for tool '${_tool}' (rc=${_COMMIT_RC})." >&2
                echo "       The repo may be at mixed versions. Re-run 'aid update' to heal." >&2
                rm -rf "$_AID_STAGING_BASE"
                exit "${_COMMIT_RC}"
            }
        done

        echo ""
        echo "Done. AID ${_STAGE_VERSION:-} installed into: ${_AID_TARGET}"

        # B-table (for 'add'): tier-aware registration after successful install.
        # Decision #3 (unwritable) already handled above with error+abort.
        if [[ "$SUBCMD" == "add" ]]; then
            # FR7: deterministic, non-interactive tier selection via _aid_resolve_tier.
            # Honors _AID_TIER_OVERRIDE (--local/--shared) if already set by caller.
            _btab_tier="$(_aid_resolve_tier "$_AID_TARGET")"
            registry_register "$_AID_TARGET" "$_btab_tier"
        else
            # 'update': C-table register-on-encounter already ran above.
            # The post-install register is idempotent; route via user tier.
            registry_register "$_AID_TARGET" "user"
        fi

        # FF-3 / CLI-2 / task-079: per-repo migration on the 'update' reach only.
        # Runs on the already-CAN-1-canonicalized $_AID_TARGET (cd && pwd above).
        # The registry_register above already ran, so migration step 4 is an
        # idempotent no-op; steps 1-3 run per FF-1.  WARN-not-fail (NFR12):
        # migration never changes the tool-update exit code.
        if [[ "$SUBCMD" == "update" ]]; then
            _aid_migrate_repo "$_AID_TARGET"
        fi
        exit 0
        ;;

    remove)
        manifest_exists "$_AID_MANIFEST" || {
            echo "ERROR: aid: no manifest at ${_AID_TARGET}/.aid/.aid-manifest.json; nothing to uninstall" >&2
            exit 6
        }

        for _tool in "${_AID_TOOLS[@]}"; do
            echo ""
            echo "Uninstalling ${_tool} from ${_AID_TARGET}"
            uninstall_tool "$_AID_MANIFEST" "$_tool" "$_AID_TARGET" || {
                _RC=$?
                [[ "$_RC" -eq 6 ]] && exit 6
                exit "$_RC"
            }
        done

        echo ""
        echo "Uninstall complete."
        # DR-1 registry side-effect: unregister repo only when the manifest is now gone (last tool removed).
        if [[ ! -f "$_AID_MANIFEST" ]]; then
            registry_unregister "$_AID_TARGET"
        fi
        exit 0
        ;;
esac
