#!/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 | -V               Print the CLI version and exit (same as 'aid version')
#   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|all]            Update to latest; inside repo = CLI + all tools; 'self' = CLI only;
#                                    'all' = bulk-update every registered project (see 'aid update -h')
#   aid remove [<tool>... | self]    Remove; no arg = ALL AID from project; 'self' = the aid CLI
#   aid projects [list|add|remove|scan|help] [path|N] [--local|--shared] [--verbose]
#                                    List (numbered from 1), register, unregister, or scan;
#                                    remove accepts a list number (<N>) or a <path>
#                                    scan: 'aid projects scan -h' for its own flags
#   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>          (add/remove/update only) 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.
#
# Top-level-only flag (bare, no value, handled before subcommand dispatch):
#   --version | -V          Print the CLI version and exit 0. Distinct from the
#                            subcommand --version <v> pin flag above.

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}.)
# ---------------------------------------------------------------------------
# format 2 (was 1): eliminated the per-repo .aid/dashboard/ folder -- home.html is now
# served from the CLI, and kb.html moved to .aid/knowledge/kb.html (aid migrate relocates).
# format 3 (was 2): settings.yml flattened -- top-level name/description/type/
# source_control/minimum_grade/heartbeat_interval + a knowledge: block; the installed
# tools + AID version now live only in the manifest (.aid/.aid-manifest.json).
readonly AID_SUPPORTED_FORMAT=3

# ---------------------------------------------------------------------------
# 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 'aid update all [--version <v>] [--dry-run] [--force]\n'
            printf '  Update to latest.\n'
            printf '  Outside an AID project: updates the CLI only (no-op if already latest).\n'
            printf '  Inside an AID project: 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" or "all" 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 '  all: bulk-update EVERY registered project (see "aid projects list") to one\n'
            printf '        version -- downloads the tool package(s) once and applies the shared\n'
            printf '        cache to each project via the existing --from-bundle path; continues past\n'
            printf '        a per-project failure and prints an end-of-run summary. Does not accept\n'
            printf '        --target (the registry supplies the targets).\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] [--allow-writes] [--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 '  --allow-writes opt in to interactive writes. On loopback writes are always enabled\n'
            printf '                 (this flag is then accepted but redundant, no error); under --remote the\n'
            printf '                 dashboard is read-only unless this flag is also given.\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>]\n'
            printf 'aid projects scan [--path <folder>|--all] [--dry-run] [--depth <n>]\n'
            printf '                  [--include-network] [--include-removable] [--local|--shared] [--verbose]\n'
            printf '  List, register, unregister, or scan for AID projects in the registry.\n'
            printf '  list (default): show all registered projects, numbered from 1, with state,\n'
            printf '    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. If the folder is not yet an AID project,\n'
            printf '    it is initialized as a bare project (.aid/ with no tools installed).\n'
            printf '    Idempotent.  Prints the tier written.\n'
            printf '  remove [path=cwd|<N>]: unregister a project from the registry; no files removed.\n'
            printf '    <N> (all-digits) targets the Nth row from '"'"'aid projects list'"'"'; N < 1 or\n'
            printf '    N greater than the registered count errors to stderr with exit 2.  A <path>\n'
            printf '    that does not resolve to a currently-registered project now errors (exit 2)\n'
            printf '    instead of the prior idempotent no-op.\n'
            printf '  scan: crawl the filesystem for folders containing a .aid/ and register each\n'
            printf '    (register-only -- never installs/updates/migrates; never writes inside a\n'
            printf '    discovered project'"'"'s .aid/). Reports each discovered project'"'"'s version.\n'
            printf '    Scope (default: home; the ONLY mode that enumerates drives is --all):\n'
            printf '      (no flag)         scan the user HOME directory ($HOME / %%USERPROFILE%%)\n'
            printf '      --path <folder>   scan only that folder'"'"'s subtree (not a directory: exit 2)\n'
            printf '      --all             scan the whole machine: Windows local FIXED drives\n'
            printf '                        (network/removable excluded by default); Unix from /\n'
            printf '    --path and --all are mutually exclusive (exit 2).\n'
            printf '    --dry-run          preview; write nothing; exit 0\n'
            printf '    --depth <n>        cap recursion at <n> levels below each root; <n> must be a\n'
            printf '                       non-negative integer (non-integer or negative: exit 2)\n'
            printf '    --include-network    (--all only) include network drives (Windows); Unix:\n'
            printf '                          accepted but inert (drive-type filtering is Windows-only)\n'
            printf '    --include-removable  (--all only) include removable drives (Windows); Unix:\n'
            printf '                          accepted but inert (drive-type filtering is Windows-only)\n'
            printf '    --include-network/--include-removable without --all: exit 2\n'
            printf '    --local/--shared force the tier exactly as in '"'"'aid projects add'"'"'; scan\n'
            printf '      FORCES the user tier by default so a bulk scan never elevates privileges.\n'
            printf '  --local   force user tier for add/scan\n'
            printf '  --shared  force shared tier for add/scan\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 | -V               Print the CLI version and exit (same as "aid version")\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|all]            Update to latest; inside a project = all tools;\n'
            printf '                                    "all" bulk-updates every registered project\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|scan]  List/register/unregister/scan AID projects\n'
            printf '  aid <command> -h | --help        Per-command help\n'
            printf '\n'
            printf 'Flags: --from-bundle, --version <v> (add/remove/update only -- pins a release), --force, --dry-run, --target, --verbose\n'
            printf 'Top-level --version / -V takes NO value and prints the CLI version (distinct from the --version <v> pin above).\n'
            printf "Run 'aid <command> -h' for details.\n"
            ;;
    esac
}

# ---------------------------------------------------------------------------
# Version helper: single-source the CLI version via the VERSION-file read.
# Shared by the 'aid version' subcommand and the top-level bare --version/-V
# flag -- do NOT duplicate this literal read path elsewhere.
# ---------------------------------------------------------------------------
_aid_print_version() {
    local 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
}

# ---------------------------------------------------------------------------
# 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'.
Note: with --allow-writes, any granted identity can also modify this project's state.

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_allow_writes=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 ;;
            --allow-writes)
                if [[ "$verb" == "stop" ]]; then
                    echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
                fi
                _dc_allow_writes=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" "$_dc_allow_writes"
    else
        _dc_stop "$_dc_verbose"
    fi
}

# ---------------------------------------------------------------------------
# Cross-platform process reaping for the dashboard server (start/stop).
#
# On POSIX the server is spawned under `setsid` and reaped via a process-group
# SIGTERM/SIGKILL (see _dc_stop).  On Windows the interpreter is reached only
# through the `python3.bat` / `node` shim under cmd.exe, so the recorded pid is
# the cmd/.bat WRAPPER, not the detached native python.exe/node.exe server --
# and an MSYS/Cygwin process-group kill cannot reach that native child.  Left
# unhandled, every start/stop cycle leaks a live server still bound to the port;
# an old leaked server then keeps serving stale in-memory code (the reader that
# renders each pipeline's tasks/deliveries), so pipelines appear empty.  The
# helpers below reap the real native process tree on Windows (taskkill /F /T)
# and, as a safety net, whatever still listens on the recorded port.
# ---------------------------------------------------------------------------

_dc_is_windows() {
    # True under Git-Bash / MSYS2 / Cygwin -- the shells that run this script on Windows.
    case "$(uname -s 2>/dev/null)" in
        MINGW*|MSYS*|CYGWIN*) return 0 ;;
    esac
    case "${OSTYPE:-}" in
        msys*|cygwin*|win*) return 0 ;;
    esac
    return 1
}

# _dc_reap_port <port> [verbose]
# Windows-only safety net: kill any process still LISTENING on 127.0.0.1:<port>.
# Catches an older orphan whose wrapper pid had already exited (so a tree-kill of
# the recorded pid can no longer reach it).  Scoped to OUR recorded port to bound
# collateral.  No-op on POSIX (the process-group kill in _dc_stop already suffices).
_dc_reap_port() {
    local port="$1"
    local verbose="${2:-0}"
    [[ -n "$port" ]] || return 0
    _dc_is_windows || return 0

    local _pids _p
    _pids="$(MSYS_NO_PATHCONV=1 netstat -ano -p tcp 2>/dev/null \
        | grep -E "127\.0\.0\.1:${port}[^0-9]" \
        | grep -i "listening" \
        | awk '{print $NF}' | sort -u)"
    for _p in $_pids; do
        [[ "$_p" =~ ^[0-9]+$ ]] || continue
        [[ "$_p" == "0" ]] && continue
        [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: reaping orphaned server on :${port} (pid ${_p})" >&2
        MSYS_NO_PATHCONV=1 taskkill /F /T /PID "$_p" >/dev/null 2>&1 || true
    done
}

_dc_start() {
    local runtime="$1"
    local port="$2"
    local remote="$3"
    local verbose="$4"
    local allow_writes="${5:-0}"

    # 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"

    # Fail-safe write gate (Q1/NFR2/C3/AC8, feature-001 task-001):
    #   write_enabled = (loopback) OR (--remote AND --allow-writes).
    # Loopback is always write-enabled; --remote alone is read-only; --remote
    # --allow-writes is write-enabled; --allow-writes on loopback is accepted and
    # redundant (no error). The server only learns write_enabled via the spawn argv
    # below -- it is never read from request/config/env (SEC-1 posture unaffected).
    local write_enabled=0
    if [[ "$remote" -eq 0 || ( "$remote" -eq 1 && "$allow_writes" -eq 1 ) ]]; then
        write_enabled=1
    fi

    # 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).
    local -a _dc_spawn_argv=(--host 127.0.0.1 --port "$port")
    [[ "$write_enabled" -eq 1 ]] && _dc_spawn_argv+=(--allow-writes)
    AID_HOME="$AID_STATE_HOME" setsid "$interp" "$entry_point" "${_dc_spawn_argv[@]}" \
        >"$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/')"
    local existing_port
    existing_port="$(grep '"port"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\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
        # Windows: the recorded pid is the cmd/.bat wrapper; even after it exits the
        # detached native server may still be alive and bound to the port (a POSIX
        # group-kill never reached it). Reap it by port before dropping the record.
        _dc_reap_port "$existing_port" "$verbose"
        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 server.
    if _dc_is_windows; then
        # Windows: the recorded pid is the cmd/python3.bat (or node) WRAPPER; the
        # real server is a detached native python.exe/node.exe child that a POSIX
        # process-group kill cannot reach. Kill the whole native process tree so
        # the server does not survive as an orphan still bound to the port.
        [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: killing native process tree of ${existing_pid}" >&2
        MSYS_NO_PATHCONV=1 taskkill /F /T /PID "$existing_pid" >/dev/null 2>&1 || true
    else
        # POSIX: terminate the process group cleanly (setsid session leader).
        [[ "$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
    fi

    # Safety net (Windows): if a server is still bound to the recorded port (an
    # older orphan whose wrapper had already exited, unreachable by the tree-kill
    # above), reap whatever still listens on that port. No-op on POSIX.
    _dc_reap_port "$existing_port" "$verbose"

    # 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"). Every AID project (tool install or
#                    tool-less scaffold) has a manifest, so it is the single
#                    version source; a malformed value falls through to untracked
#                    (never 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"
    if [[ -f "$_manifest" ]]; then
        local _ver
        # Extract aid_version value; then validate as semver.
        _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
    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 -- ELIMINATE .aid/dashboard/ (format 2): relocate kb.html to
    # .aid/knowledge/ and remove the obsolete per-repo dashboard folder. home.html is
    # now served by the CLI (no per-repo copy). No-clobber; best-effort.
    # ------------------------------------------------------------------
    local _kb_new="${repo}/.aid/knowledge/kb.html"
    local _kb_old="${repo}/.aid/dashboard/kb.html"
    if [[ -f "${_kb_old}" ]]; then
        if [[ ! -f "${_kb_new}" ]]; then
            # Proper place is free: relocate the misplaced kb.html into it.
            mkdir -p "${repo}/.aid/knowledge" 2>/dev/null || true
            mv -n "${_kb_old}" "${_kb_new}" 2>/dev/null || \
                echo "WARN: aid migrate: relocate kb.html failed for ${repo} (continuing)" >&2
        else
            # Proper place already holds a kb.html (authoritative): drop the stale stray.
            rm -f "${_kb_old}" 2>/dev/null || true
        fi
    fi
    if [[ -d "${repo}/.aid/dashboard" ]]; then
        # Drop the now-obsolete home.html, then remove the folder only if it is empty.
        rm -f "${repo}/.aid/dashboard/home.html" 2>/dev/null || true
        rmdir "${repo}/.aid/dashboard" 2>/dev/null || \
            echo "WARN: aid migrate: .aid/dashboard not empty for ${repo}; left in place" >&2
    fi

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

    # ------------------------------------------------------------------
    # STEP 3b -- RETIRE the redundant .aid/.aid-version marker. The AID version
    # is recorded in the manifest (.aid-manifest.json aid_version) for tool
    # installs and in settings.yml (aid_version) for tool-less projects, so the
    # standalone marker is pure duplication. Best-effort delete; idempotent.
    # ------------------------------------------------------------------
    rm -f "${repo}/.aid/.aid-version" 2>/dev/null || true

    # ------------------------------------------------------------------
    # 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: settings.yml already exists (either the OLD nested schema, or the
# NEW flat schema from a prior run of this same function). Read every value
# tolerantly -- NEW flat top-level location first, else the OLD nested
# location, else the documented default -- then rewrite the file from
# scratch in the NEW flat schema (format_version 3).
# This is a full read-then-rewrite, not a targeted line-edit, so the
# function is naturally idempotent (re-running it on its own output
# reproduces byte-identical content) and flattens an old nested file in a
# single pass. Crash-safe: same-directory temp + mv -f; skips the write
# entirely when the rebuilt content is byte-identical to the original.
_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}"

    # ---- 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}"
    }

    # ---- Helper: read a NEW-flat top-level "^key:<rest>" scalar. Returns 0 +
    # the value (possibly empty) when the key line is present at column 0;
    # returns 1 (no output) when absent. ----
    _top_scalar() {
        local _key="$1" _i
        for _i in "${!_lines[@]}"; do
            if [[ "${_lines[$_i]}" =~ ^${_key}: ]]; then
                _get_scalar_value "${_lines[$_i]}" "${_key}"
                return 0
            fi
        done
        return 1
    }

    # ---- Helper: read an OLD-nested "<section>.<key>" scalar. Returns 0 +
    # the value when both the section and the key are present; 1 otherwise. ----
    _nested_scalar() {
        local _sect="$1" _key="$2" _sidx _kidx
        _sidx="$(_find_section "${_sect}")"
        [[ "${_sidx}" -eq -1 ]] && return 1
        _kidx="$(_find_key_in_section "${_sidx}" "${_key}")"
        [[ "${_kidx}" -eq -1 ]] && return 1
        _get_scalar_value "${_lines[$_kidx]}" "${_key}"
        return 0
    }

    # ---- Helper: count leading whitespace of a raw line (indent depth) ----
    _leading_spaces() {
        local _s="$1" _stripped
        _stripped="${_s#"${_s%%[![:space:]]*}"}"
        echo $(( ${#_s} - ${#_stripped} ))
    }

    # ---- Helper: trim surrounding whitespace + one layer of matching quotes ----
    _trim_quote() {
        local _s="$1"
        local _rstrip="${_s##*[![:space:]]}"; _s="${_s%"${_rstrip}"}"
        local _lstrip="${_s%%[![:space:]]*}"; _s="${_s#"${_lstrip}"}"
        if [[ "${_s}" == \"*\" && "${_s}" == *\" ]]; then _s="${_s#\"}"; _s="${_s%\"}"; fi
        if [[ "${_s}" == \'*\' && "${_s}" == *\' ]]; then _s="${_s#\'}"; _s="${_s%\'}"; fi
        echo "${_s}"
    }

    # ---- Helper: list reader. Given a section index + key, print one item
    # per line. Supports inline "[a, b]" and block "- a" / "- b" forms.
    # Returns 1 (no output) when the key is absent or resolves to an empty list. ----
    _read_list_in_section() {
        local _sect_idx="$1" _key="$2" _kidx
        _kidx="$(_find_key_in_section "${_sect_idx}" "${_key}")"
        [[ "${_kidx}" -eq -1 ]] && return 1
        local _ln="${_lines[$_kidx]}"
        local _val="${_ln#*${_key}:}"
        _val="${_val# }"
        _val="${_val%% #*}"
        local _rstrip="${_val##*[![:space:]]}"; _val="${_val%"${_rstrip}"}"
        local _lstrip="${_val%%[![:space:]]*}"; _val="${_val#"${_lstrip}"}"
        local _found=0
        if [[ "${_val}" == \[*\] ]]; then
            # Inline list: "[a, b, c]".
            _val="${_val#\[}"; _val="${_val%\]}"
            local -a _items=()
            IFS=',' read -ra _items <<< "${_val}"
            local _it _clean
            for _it in "${_items[@]}"; do
                _clean="$(_trim_quote "${_it}")"
                if [[ -n "${_clean}" ]]; then printf '%s\n' "${_clean}"; _found=1; fi
            done
        elif [[ -z "${_val}" ]]; then
            # Block list: subsequent "- item" lines indented deeper than the key.
            local _key_indent; _key_indent="$(_leading_spaces "${_ln}")"
            local _n="${#_lines[@]}" _i
            for (( _i=_kidx+1; _i<_n; _i++ )); do
                local _bl="${_lines[$_i]}"
                if [[ -n "${_bl//[[:space:]]/}" ]]; then
                    local _bl_indent; _bl_indent="$(_leading_spaces "${_bl}")"
                    if [[ "${_bl_indent}" -le "${_key_indent}" ]]; then
                        break
                    fi
                fi
                if [[ "${_bl}" =~ ^[[:space:]]*-[[:space:]]*(.+)$ ]]; then
                    local _item; _item="$(_trim_quote "${BASH_REMATCH[1]}")"
                    if [[ -n "${_item}" ]]; then printf '%s\n' "${_item}"; _found=1; fi
                fi
            done
        fi
        [[ "${_found}" -eq 1 ]] && return 0 || return 1
    }

    # ---- Helper: list resolver -- try (sectA,keyA) then (sectB,keyB); first
    # non-empty list wins; returns 1 when neither source has one. ----
    _resolve_list() {
        local _sectA="$1" _keyA="$2" _sectB="$3" _keyB="$4" _sidx
        _sidx="$(_find_section "${_sectA}")"
        if [[ "${_sidx}" -ne -1 ]] && _read_list_in_section "${_sidx}" "${_keyA}"; then
            return 0
        fi
        _sidx="$(_find_section "${_sectB}")"
        if [[ "${_sidx}" -ne -1 ]] && _read_list_in_section "${_sidx}" "${_keyB}"; then
            return 0
        fi
        return 1
    }

    # ------------------------------------------------------------------
    # Resolve each REQUIRED top-level scalar: NEW-flat top-level location
    # wins, else the OLD-nested location, else the documented default.
    # A found-but-invalid value is treated the same as absent (repair).
    # ------------------------------------------------------------------

    # name: blank is not a valid name -- fall through to the next source.
    local _name; _name="$(_top_scalar name)"
    if [[ -z "${_name}" ]]; then
        _name="$(_nested_scalar project name)"
        [[ -z "${_name}" ]] && _name="${_rname}"
    fi

    # description: presence (not non-emptiness) decides -- an explicit empty
    # description ("") is a valid final value, matching the target schema.
    local _description
    if ! _description="$(_top_scalar description)"; then
        if ! _description="$(_nested_scalar project description)"; then
            _description=""
        fi
    fi

    # type: must be brownfield|greenfield; else fall through / default.
    local _type; _type="$(_top_scalar type)"
    if [[ "${_type}" != "brownfield" && "${_type}" != "greenfield" ]]; then
        _type="$(_nested_scalar project type)"
        if [[ "${_type}" != "brownfield" && "${_type}" != "greenfield" ]]; then
            _type="brownfield"
        fi
    fi

    # source_control: top-level value if valid, else detect from .git presence.
    # <repo> is two levels above <repo>/.aid/settings.yml.
    local _source_control; _source_control="$(_top_scalar source_control)"
    if [[ "${_source_control}" != "none" && "${_source_control}" != "git" \
       && "${_source_control}" != "svn" && "${_source_control}" != "mercurial" ]]; then
        local _repo_dir; _repo_dir="$(dirname "$(dirname "${_sf}")")"
        if [[ -e "${_repo_dir}/.git" ]]; then
            _source_control="git"
        else
            _source_control="none"
        fi
    fi

    # minimum_grade: top-level, else review.minimum_grade, else default A.
    local _minimum_grade; _minimum_grade="$(_top_scalar minimum_grade)"
    if ! [[ "${_minimum_grade}" =~ ^[A-F][+-]?$ ]]; then
        _minimum_grade="$(_nested_scalar review minimum_grade)"
        if ! [[ "${_minimum_grade}" =~ ^[A-F][+-]?$ ]]; then
            _minimum_grade="A"
        fi
    fi

    # heartbeat_interval: top-level, else traceability.heartbeat_interval, else 1.
    local _heartbeat_interval; _heartbeat_interval="$(_top_scalar heartbeat_interval)"
    if ! [[ "${_heartbeat_interval}" =~ ^[0-9]+$ ]]; then
        _heartbeat_interval="$(_nested_scalar traceability heartbeat_interval)"
        if ! [[ "${_heartbeat_interval}" =~ ^[0-9]+$ ]]; then
            _heartbeat_interval="1"
        fi
    fi

    # ------------------------------------------------------------------
    # Resolve the OPTIONAL knowledge block. knowledge.source/last_update fall
    # back to kb_baseline.branch/tip_date; doc_set/term_exclusions fall back
    # to discovery.doc_set/discovery.term_exclusions. The whole block is
    # omitted when none of the four sub-values are present.
    # ------------------------------------------------------------------
    local _kn_source; _kn_source="$(_nested_scalar knowledge source)"
    [[ -z "${_kn_source}" ]] && _kn_source="$(_nested_scalar kb_baseline branch)"

    local _kn_last_update; _kn_last_update="$(_nested_scalar knowledge last_update)"
    [[ -z "${_kn_last_update}" ]] && _kn_last_update="$(_nested_scalar kb_baseline tip_date)"

    local _kn_doc_set; _kn_doc_set="$(_resolve_list knowledge doc_set discovery doc_set)" || _kn_doc_set=""
    local _kn_term_exclusions; _kn_term_exclusions="$(_resolve_list knowledge term_exclusions discovery term_exclusions)" || _kn_term_exclusions=""

    local _have_knowledge=0
    [[ -n "${_kn_source}" || -n "${_kn_last_update}" || -n "${_kn_doc_set}" || -n "${_kn_term_exclusions}" ]] && _have_knowledge=1

    # ------------------------------------------------------------------
    # Build the NEW flat output, then write only if it differs from the
    # original file content (idempotent: no change -> no write).
    # ------------------------------------------------------------------
    local -a _out=()
    _out+=("format_version: ${AID_SUPPORTED_FORMAT}")
    _out+=("name: ${_name}")
    if [[ -z "${_description}" ]]; then
        _out+=('description: ""')
    else
        _out+=("description: ${_description}")
    fi
    _out+=("type: ${_type}")
    _out+=("source_control: ${_source_control}")
    _out+=("minimum_grade: ${_minimum_grade}")
    _out+=("heartbeat_interval: ${_heartbeat_interval}")

    if [[ "${_have_knowledge}" -eq 1 ]]; then
        _out+=("")
        _out+=("knowledge:")
        [[ -n "${_kn_source}" ]] && _out+=("  source: ${_kn_source}")
        [[ -n "${_kn_last_update}" ]] && _out+=("  last_update: ${_kn_last_update}")
        if [[ -n "${_kn_doc_set}" ]]; then
            _out+=("  doc_set:")
            local _dsi
            while IFS= read -r _dsi; do
                [[ -n "${_dsi}" ]] && _out+=("    - ${_dsi}")
            done <<< "${_kn_doc_set}"
        fi
        if [[ -n "${_kn_term_exclusions}" ]]; then
            _out+=("  term_exclusions:")
            local _tei
            while IFS= read -r _tei; do
                [[ -n "${_tei}" ]] && _out+=("    - ${_tei}")
            done <<< "${_kn_term_exclusions}"
        fi
    fi

    local _new_content; _new_content="$(printf '%s\n' "${_out[@]}")"
    local _orig_content; _orig_content="$(cat "${_sf}")"
    if [[ "${_new_content}" == "${_orig_content}" ]]; then
        return 0
    fi

    local _tmp
    _tmp="$(mktemp "${_sf}.aid-tmp.XXXXXX")" || return 1
    { printf '%s\n' "${_out[@]}"; } > "${_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 settings.yml (NEW flat schema, format_version 3) when
# none exists yet. name = <repo_name>; description = ""; type = brownfield;
# source_control = detected (.git present -> git, else none); minimum_grade
# = A; heartbeat_interval = 1. No knowledge block (nothing to carry forward).
# <manifest> is accepted for call-site parity (tools/AID-version now live
# only in the manifest -- never written into settings) but is otherwise
# unused here. Crash-safe: same-directory temp + mv -f.
_aid_migrate_synthesize_settings_era_b() {
    local _sf="$1" _rname="$2" _manifest="$3"

    local _repo_dir; _repo_dir="$(dirname "$(dirname "${_sf}")")"
    local _source_control="none"
    [[ -e "${_repo_dir}/.git" ]] && _source_control="git"

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

    {
        printf 'format_version: %s\n' "${AID_SUPPORTED_FORMAT}"
        printf 'name: %s\n' "${_rname}"
        printf 'description: ""\n'
        printf 'type: brownfield\n'
        printf 'source_control: %s\n' "${_source_control}"
        printf 'minimum_grade: A\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
    # Scan-specific flags (work-019-discover-projects task-001): gated to the
    # 'scan' action below -- passing any of these to list/add/remove is a
    # usage error (exit 2).
    local _scan_path="" _scan_all=0 _scan_depth="" _scan_dry_run=0
    local _scan_include_network=0 _scan_include_removable=0
    local _scan_flag_seen=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 ;;
            --path)
                if [[ $# -lt 2 ]]; then
                    echo "ERROR: aid projects: --path requires a value" >&2
                    exit 2
                fi
                _scan_path="$2"; _scan_flag_seen=1; shift 2 ;;
            --all)
                _scan_all=1; _scan_flag_seen=1; shift ;;
            --depth)
                if [[ $# -lt 2 ]]; then
                    echo "ERROR: aid projects: --depth requires a value" >&2
                    exit 2
                fi
                _scan_depth="$2"; _scan_flag_seen=1; shift 2 ;;
            --dry-run)
                _scan_dry_run=1; _scan_flag_seen=1; shift ;;
            --include-network)
                _scan_include_network=1; _scan_flag_seen=1; shift ;;
            --include-removable)
                _scan_include_removable=1; _scan_flag_seen=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

    # Gate scan-specific flags to the 'scan' action -- a scan-only flag passed
    # to list/add/remove is a usage error (Command Surface & CLI Contract).
    # Excludes "help": -h/--help (anywhere in the arg list) already overwrote
    # _action to "help" above, and help must take precedence over this gate --
    # otherwise e.g. 'aid projects scan --dry-run -h' would hit this error
    # (exit 2) instead of showing usage (exit 0).
    if [[ "$_scan_flag_seen" -eq 1 && "$_action" != "scan" && "$_action" != "help" ]]; then
        echo "ERROR: aid projects: --path/--all/--depth/--dry-run/--include-network/--include-removable are scan-only flags (see 'aid projects scan -h')" >&2
        exit 2
    fi
    # 'scan' has no positional <root> form (--path replaces it, FR-3); a
    # stray positional is a usage error rather than a silently-ignored value.
    if [[ "$_action" == "scan" && -n "$_path_arg" ]]; then
        echo "ERROR: aid projects scan: unexpected argument: '${_path_arg}' (there is no positional <root> -- use --path <folder>)" >&2
        exit 2
    fi

    case "$_action" in
        list)    _cmd_projects_list "$_verbose" ;;
        add)     _cmd_projects_add  "$_path_arg" "$_verbose" ;;
        remove)  _cmd_projects_remove "$_path_arg" "$_verbose" ;;
        scan)    _cmd_projects_scan "$_scan_path" "$_scan_all" "$_scan_depth" "$_scan_dry_run" \
                     "$_scan_include_network" "$_scan_include_removable" "$_verbose" ;;
        help)    _aid_usage projects; exit 0 ;;
        *)
            echo "ERROR: aid projects: unknown action: ${_action} (expected: list, add, remove, scan, 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)

    # Pre-compute each project's state (version string) and size the STATE column to
    # the widest value (floor: the historical width of 10) so a long version -- e.g.
    # a pre-release "X.Y.Z-beta.N" -- never overflows a fixed field and shifts the
    # TOOLS/TIER columns out of alignment with the header. Mirror in bin/aid.ps1.
    local -a _states=()
    local _state_w=10
    local _pp _pp_state
    for _pp in "${_paths[@]+"${_paths[@]}"}"; do
        _pp_state="$(_aid_project_state "$_pp")"
        _states+=("$_pp_state")
        [[ "${#_pp_state}" -gt "$_state_w" ]] && _state_w="${#_pp_state}"
    done

    # Column header.
    printf "%3s  %-2s  %-45s  %-${_state_w}s  %-20s  %s\n" "#" " " "PATH" "STATE" "TOOLS" "TIER"
    printf "%3s  %-2s  %-45s  %-${_state_w}s  %-20s  %s\n" "---" "--" "----" "-----" "-----" "----"

    local _cwd_registered=0
    local _entry
    local _num=0
    for _entry in "${_paths[@]+"${_paths[@]}"}"; do
        local _state _tools _tier _marker
        _num=$((_num + 1))
        _state="${_states[$((_num - 1))]}"
        _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 "%3d  %-2s  %-45s  %-${_state_w}s  %-20s  %s\n" \
            "$_num" "$_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"
}

# _aid_scaffold_bare_project <canon>
# Initialize a bare, tool-less AID project at <canon>: create the .aid/ tree and
# a minimal settings.yml (format_version + aid_version + empty tools). Used by
# `aid projects add` when the target folder is not yet an AID project, so any
# folder can be onboarded (e.g. from the dashboard) without first installing a
# host tool. Never clobbers an existing settings.yml. The AID release version is
# recorded in settings.yml (no manifest exists for a tool-less project).
_aid_scaffold_bare_project() {
    local _canon="$1"
    local _name _fmt _ver _sc _now
    _name="$(basename "$_canon")"
    _fmt="${AID_SUPPORTED_FORMAT:-3}"
    _ver=""
    [[ -f "${AID_CODE_HOME}/VERSION" ]] && \
        _ver="$(tr -d '[:space:]' < "${AID_CODE_HOME}/VERSION" 2>/dev/null)"
    # -e (not -d): a git worktree / submodule has .git as a FILE, not a dir.
    if [[ -e "${_canon}/.git" ]]; then _sc="git"; else _sc="none"; fi

    mkdir -p "${_canon}/.aid/connectors" "${_canon}/.aid/knowledge" "${_canon}/.aid/works"

    # Flat, user-owned settings.yml (the AID version + installed tools live in
    # the manifest, not here). format_version stamps the .aid/ layout.
    local _settings="${_canon}/.aid/settings.yml"
    if [[ ! -f "$_settings" ]]; then
        {
            printf 'format_version: %s\n' "$_fmt"
            printf '# .aid/settings.yml - AID pipeline configuration (user-owned project settings).\n'
            printf '# Initialized by `aid projects add` for a tool-less project (no host tool yet).\n'
            printf '# Run /aid-config to configure, or `aid add <tool>` to install a host tool.\n'
            printf 'name: %s\n' "$_name"
            printf 'description: ""\n'
            printf 'type: brownfield\n'
            printf 'source_control: %s\n' "$_sc"
            printf 'minimum_grade: A\n'
            printf 'heartbeat_interval: 1\n'
        } > "$_settings"
    fi

    # Minimal AID-owned manifest: the single version source of truth. tools:{}
    # is populated when `aid add <tool>` installs a host tool.
    local _manifest="${_canon}/.aid/.aid-manifest.json"
    if [[ ! -f "$_manifest" ]]; then
        _now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
        {
            printf '{\n'
            printf '  "format_version": 2,\n'
            [[ -n "$_ver" ]] && printf '  "aid_version": "%s",\n' "$_ver"
            printf '  "installed_at": "%s",\n' "$_now"
            printf '  "tools": {}\n'
            printf '}\n'
        } > "$_manifest"
    fi
}

# _cmd_projects_add [path] [verbose]
# Register a project path (default: cwd) in the deterministic tier. If the folder
# is not yet an AID project, initialize a bare .aid/ (no tools) first.
_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

    # If the folder is not yet an AID project, initialize a bare .aid/ (no tools)
    # rather than refusing. _aid_is_project_dir is also false when .aid/ resolves
    # to the CLI state home -- never scaffold/register that, so distinguish it by
    # the presence of an existing .aid/ dir.
    if ! _aid_is_project_dir "${_canon}"; then
        # _aid_is_project_dir is ALSO false when <dir>/.aid IS the CLI state home.
        # Refuse that explicitly (path compare, existence-agnostic -- mirrors the
        # guard in _aid_is_project_dir) so we never initialize $HOME/.aid or
        # $AID_STATE_HOME as a project. Otherwise the folder just has no .aid/ yet
        # -> initialize a bare, tool-less project.
        local _ca_n _sh_n _hd_n
        _ca_n="$(cd "${_canon}/.aid" 2>/dev/null && pwd -P)" || _ca_n="${_canon}/.aid"
        _sh_n="$(cd "${AID_STATE_HOME}" 2>/dev/null && pwd -P)" || _sh_n="${AID_STATE_HOME}"
        _hd_n="$(cd "${HOME}/.aid" 2>/dev/null && pwd -P)" || _hd_n="${HOME}/.aid"
        if [[ "${_ca_n}" == "${_sh_n}" || "${_ca_n}" == "${_hd_n}" ]]; then
            echo "ERROR: aid projects add: '${_canon}' is the AID state home, not a project." >&2
            exit 2
        fi
        _aid_scaffold_bare_project "${_canon}"
        echo "aid projects: initialized a bare AID project at '${_canon}' (no tools; run 'aid add <tool>' to install a host tool)."
    else
        # Existing AID project: bring it current before registering. Refuse a
        # newer format than this CLI supports; migrate an older one (settings
        # flatten etc.) -- so adding an already-AID folder also updates it,
        # mirroring the format gate on other reaches. Guarded: only when the
        # format-gate/migration helpers are in scope (they live outside the
        # registry unit-test harness's extract range) AND the project actually
        # has a settings.yml to migrate (a bare .aid/ has nothing to migrate);
        # AID_SUPPORTED_FORMAT defaults so the harness never hits an unbound var.
        local _sup="${AID_SUPPORTED_FORMAT:-3}"
        if declare -F _aid_repo_format >/dev/null 2>&1; then
            local _repo_fmt
            _repo_fmt="$(_aid_repo_format "${_canon}")"
            if [[ "${_repo_fmt}" =~ ^[0-9]+$ && "${_repo_fmt}" -gt "${_sup}" ]]; then
                echo "ERROR: aid projects add: '${_canon}' uses a newer AID format (v${_repo_fmt}) than this CLI supports (v${_sup}). Upgrade the aid CLI: aid update self." >&2
                exit 1
            fi
            if [[ -f "${_canon}/.aid/settings.yml" && "${_repo_fmt}" =~ ^[0-9]+$ && "${_repo_fmt}" -lt "${_sup}" ]] \
               && declare -F _aid_migrate_repo >/dev/null 2>&1; then
                echo "aid projects: '${_canon}' uses an older AID format (v${_repo_fmt}); migrating to v${_sup}..."
                _aid_migrate_repo "${_canon}" >/dev/null || \
                    echo "WARN: aid projects add: migration reported an issue for '${_canon}' (continuing)" >&2
            fi
        fi
    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}"
    local _canon

    if [[ "$_raw_path" =~ ^[0-9]+$ ]]; then
        # All-digits: always a 1-based index, never a path -- even if a folder of
        # that literal name exists (AC-13).
        local -a _paths=()
        while IFS= read -r _p; do
            [[ -n "$_p" ]] && _paths+=("$_p")
        done < <(_registry_read_raw_union)
        local _count="${#_paths[@]}"
        # Parse base-10 explicitly: leading-zero forms containing an 8/9 (008, 009)
        # would otherwise trip bash's octal-literal error under plain $((...)).
        local _n=$((10#$_raw_path))
        # Overflow guard: bash's $(( )) arithmetic is a fixed-width (64-bit)
        # integer; a decimal string beyond that range silently wraps (e.g.
        # 18446744073709551618 -> 2) instead of erroring, unlike PowerShell's
        # [long]::TryParse. Detect wraparound by stripping _raw_path's leading
        # zeros and comparing the result to _n re-stringified: a mismatch (or a
        # negative _n, which the ^[0-9]+$ regex can only yield via wraparound)
        # means the value is out of the representable range -- route it to the
        # same out-of-range error as N > count (parity with the PS twin).
        local _stripped="$_raw_path"
        while [[ "$_stripped" == 0* && "${#_stripped}" -gt 1 ]]; do
            _stripped="${_stripped#0}"
        done
        if [[ "$_n" -lt 0 || "$_n" != "$_stripped" ]]; then
            echo "ERROR: aid projects: no project numbered ${_raw_path} (${_count} registered)" >&2
            exit 2
        fi
        if [[ "$_n" -lt 1 ]]; then
            echo "ERROR: aid projects: index must be a positive integer (>= 1): ${_raw_path}" >&2
            exit 2
        fi
        if [[ "$_n" -gt "$_count" ]]; then
            echo "ERROR: aid projects: no project numbered ${_n} (${_count} registered)" >&2
            exit 2
        fi
        _canon="${_paths[$((_n - 1))]}"
    else
        # Contains a non-digit: a path. Canonicalize without requiring existence.
        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

        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

        if [[ "$_found" -eq 0 ]]; then
            echo "ERROR: aid projects: '${_canon}' is not registered (nothing to remove; see 'aid projects list')" >&2
            exit 2
        fi
    fi

    # $_canon now names a currently-registered project; unregister it.
    registry_unregister "$_canon"
    if [[ "$_verbose" -eq 1 ]]; then
        printf "aid projects: removed '%s' from registry.\n" "$_canon"
    fi
    return 0
}

# ---------------------------------------------------------------------------
# 'aid projects scan'  (work-019-discover-projects / task-001)
# Crawls the filesystem for folders containing a .aid/ and registers each in
# the machine project registry -- register-only (never scaffolds, updates,
# installs, or migrates; never writes inside a discovered project's .aid/).
# See SPEC.md (work-019-discover-projects) Data Model / Feature Flow / Layers.
#
# Byte-identical name sets (case-insensitive) are mirrored in bin/aid.ps1
# (task-002) as $script:AidScanPruneDirs / $script:AidScanSystemDirs /
# $script:AidScanMaxDepth -- do not let the two twins diverge.
# ---------------------------------------------------------------------------

# NFR-2: heavy/cache/build directories, matched by BASENAME at ANY depth,
# CASE-INSENSITIVELY, in ALL scan modes (home default, --path, --all). A
# project whose OWN folder name is one of these is still discovered because
# the _aid_is_project_dir check (order step b) precedes this check (step c).
readonly -a _AID_SCAN_PRUNE_DIRS=(
    node_modules .git .hg .svn obj bin logs target dist build
    .venv venv __pycache__ .gradle .m2 .cargo .npm .cache vendor Pods
    # VCS
    .bzr _darcs CVS
    # Package caches
    .nuget .pnpm-store .yarn bower_components npm-cache
    .mypy_cache .pytest_cache .tox .eggs .ruff_cache .ipynb_checkpoints
    .ivy2 .bundle
    # Build outputs
    .next .nuxt .output .svelte-kit .parcel-cache .turbo .angular
    coverage .nyc_output htmlcov
    # Editors
    .vscode .vs .idea .zed
    # AI tools
    .cursor .claude .codex .windsurf .antigravity
    # Eclipse
    .metadata .settings .p2 .eclipse
    # Version managers
    .pyenv .rbenv .nvm .rustup .dotnet .sdkman .jenv .asdf .volta
    mise .goenv .phpenv
    # Generic cache
    cache caches CacheStorage
    # Temp
    tmp temp .tmp .temp
    # OS profile roots (promoted to Tier A)
    AppData Library
    # macOS volume junk
    .Trash .Trashes .Spotlight-V100 .fseventsd .DocumentRevisions-V100
    # Browser/webview caches (spaced entries quoted)
    "User Data" EBWebView WebView2Cache GPUCache "Code Cache" "Service Worker"
    IndexedDB DawnCache Crashpad GrShaderCache ShaderCache D3DSCache
    # Also
    log
)
# NFR-3: OS/system directories, applied ONLY under --all and ONLY as an
# immediate child of a filesystem/drive root (never under the HOME default or
# --path, and never deeper than one level below an --all scan root).
readonly -a _AID_SCAN_SYSTEM_DIRS=(
    proc sys dev run
    Windows "Program Files" "Program Files (x86)" '$Recycle.Bin' "System Volume Information"
    # Windows
    ProgramData '$WinREAgent' '$WINDOWS.~BT' '$WINDOWS.~WS' Recovery PerfLogs
    Windows.old MSOCache "Temporary Internet Files" Recycled RECYCLER
    # Linux
    Trash
)
# NFR-4: hard recursion-depth ceiling, DISTINCT from and INDEPENDENT of the
# user-facing --depth cap -- guarantees termination on a pathological tree.
readonly _AID_SCAN_MAX_DEPTH=40

# FR-3/FR-8: primary user-level scan-config.yml, living beside registry.yml at
# the CLI state home (honors the AID_HOME override via the startup scope
# derivation, ~:65-71). Resolved as a `local` INSIDE each consuming function
# (_aid_scan_read_prune_dirs / _aid_scan_seed_config below) -- NEVER as a
# top-level readonly here -- mirroring the _registry_read_union /
# registry_register precedent (~:1486, ~:1751-1756): a top-level reference to
# $AID_STATE_HOME would be evaluated at SOURCE time, and some test harnesses
# (tests/canonical/test-aid-remote.sh's _make_fn_src) eval a slice of this
# file that starts above the startup derivation (~:65-71) under `set -u`
# without $AID_STATE_HOME set, which would abort the eval. The
# $HOME/.aid/scan-config.yml fallback tier (read-side only, mirroring
# _registry_read_union) is computed ad hoc in _aid_scan_read_prune_dirs below.

# _aid_scan_name_in_set <name> <set-item>...
# Case-insensitive exact-match membership test against the remaining args.
# Uses the bash 4+ builtin lowercase expansion (${var,,}) -- NOT an external
# `printf | tr` subprocess pair -- because this runs for essentially every
# non-project directory in the walk; a fork-per-comparison here measured
# ~5.9s PER CALL (~41s on a 10-dir fixture), making a $HOME/--all scan
# unusably slow. bin/aid already requires bash 4+ (see `declare -A` usage
# elsewhere), so ${var,,} is safe.
_aid_scan_name_in_set() {
    local _name_lc="${1,,}" _item
    shift
    for _item in "$@"; do
        [[ "$_name_lc" == "${_item,,}" ]] && return 0
    done
    return 1
}

# _aid_scan_config_prune_dirs <config-path>
# Single-file line-scan of the `prune_dirs:` block list out of ONE
# scan-config.yml, reusing the exact registry.yml idiom (grep -E
# '^[[:space:]]*-[[:space:]]+' + sed trim, cf. _registry_read_repos
# ~:1466-1472) -- but scoped to the prune_dirs: block only: a sed range
# address enters on the bare "prune_dirs:" line and leaves at the next
# column-0 (non-indented) line, so a sibling top-level key never leaks into
# the list scan. Only the leading "- " marker and TRAILING whitespace are
# stripped -- internal spaces survive, so "Code Cache" reads back intact.
# Returns one name per line; empty when the file is absent or has no
# prune_dirs: key (FR-5/FR-6/NFR-5).
_aid_scan_config_prune_dirs() {
    local _cfg="$1"
    [[ -f "$_cfg" ]] || return 0
    sed -n '/^prune_dirs:[[:space:]]*$/,/^[^[:space:]]/{
        /^prune_dirs:[[:space:]]*$/d
        /^[^[:space:]]/d
        p
    }' "$_cfg" 2>/dev/null \
        | grep -E '^[[:space:]]*-[[:space:]]+' \
        | sed -E 's/^[[:space:]]*-[[:space:]]+//' \
        | sed -E 's/[[:space:]]+$//'
}

# _aid_scan_read_prune_dirs
# Return the prune_dirs: entries from the primary scan-config.yml tier
# ($AID_STATE_HOME/scan-config.yml, resolved as a `local` here -- never a
# top-level constant, see the comment above _aid_scan_name_in_set) plus, when
# $AID_STATE_HOME differs from $HOME/.aid, the $HOME/.aid/scan-config.yml
# fallback tier too -- mirroring _registry_read_union's primary/fallback +
# per-user-collapse resolution (~:1485-1502; FR-8). No dedup here: the
# case-insensitive dedup against the built-in set happens once, in
# _aid_scan_merge_prune_dirs. Empty when no config/prune_dirs: key is found in
# either tier (FR-5).
_aid_scan_read_prune_dirs() {
    local _primary_cfg="${AID_STATE_HOME}/scan-config.yml"
    if [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]]; then
        # Per-user collapse: single-tier; primary == fallback, no double-read.
        _aid_scan_config_prune_dirs "$_primary_cfg"
    else
        # Distinct paths: read both the primary ($AID_STATE_HOME) and the
        # fallback ($HOME/.aid) tiers.
        local _fallback_cfg="${HOME}/.aid/scan-config.yml"
        { _aid_scan_config_prune_dirs "$_primary_cfg"; _aid_scan_config_prune_dirs "$_fallback_cfg"; } \
            | sed '/^$/d'
    fi
}

# _aid_scan_merge_prune_dirs
# Effective Tier-A prune set (FR-4): the case-insensitive deduped union of the
# built-in _AID_SCAN_PRUNE_DIRS and the user-level scan-config.yml
# prune_dirs: entries (_aid_scan_read_prune_dirs). Extend-only -- a config
# entry can never remove a built-in default; a repeated built-in is deduped
# harmlessly (AC-8). Built-ins are emitted first (their documented order),
# then any non-duplicate config entries in read order. Called ONCE by
# _cmd_projects_scan (NFR-1) -- never re-read per directory.
_aid_scan_merge_prune_dirs() {
    local -A _seen=()
    local _item _item_lc
    for _item in "${_AID_SCAN_PRUNE_DIRS[@]}"; do
        _item_lc="${_item,,}"
        [[ -n "${_seen[$_item_lc]:-}" ]] && continue
        _seen["$_item_lc"]=1
        printf '%s\n' "$_item"
    done
    while IFS= read -r _item; do
        [[ -n "$_item" ]] || continue
        _item_lc="${_item,,}"
        [[ -n "${_seen[$_item_lc]:-}" ]] && continue
        _seen["$_item_lc"]=1
        printf '%s\n' "$_item"
    done < <(_aid_scan_read_prune_dirs)
}

# _aid_scan_seed_config
# First-run seed (FR-3/NFR-3): if the primary scan-config.yml
# ($AID_STATE_HOME/scan-config.yml, resolved as a `local` here -- never a
# top-level constant, see the comment above _aid_scan_name_in_set) is absent,
# write it with a header + `schema: 1` + a `prune_dirs:` block of the
# built-in expanded Tier-A defaults, via the SAME atomic temp-file+move idiom
# registry_register uses (~:1751-1799: mkdir -p the target dir, mktemp in
# that same dir so the mv is atomic same-filesystem, write, then
# never-elevate `_aid_priv_run "" mv -f`).
# Same primary/fallback DEGRADE too (~:1754-1766): if $AID_STATE_HOME (the
# primary's directory) is absent/not writable -- e.g. a global/shared install
# such as /var/lib/aid or $ProgramData\aid where the primary tier is not
# user-writable -- the seed degrades to $HOME/.aid/scan-config.yml instead of
# WARNing forever on every non-dry-run scan. This is silent by default
# (visible under --verbose), matching registry_register. The READ side
# already unions the primary + $HOME/.aid fallback tiers (_aid_scan_read_
# prune_dirs), so a seed landing in the fallback is read back correctly.
# Best-effort: a mkdir/mktemp/write/mv failure at whichever tier is resolved
# prints one WARN to stderr and returns 0 -- it never fails the scan.
# Idempotent -- never overwrites an existing file at whichever tier it
# resolves to, so a user's edits always survive. The caller MUST NOT invoke
# this under --dry-run (a dry-run scan makes no writes at all).
_aid_scan_seed_config() {
    local _primary_cfg="${AID_STATE_HOME}/scan-config.yml"
    [[ -f "$_primary_cfg" ]] && return 0
    local _dir _tmp _target
    _dir="$(dirname "$_primary_cfg")"
    _aid_priv_run "" mkdir -p "$_dir" 2>/dev/null || true
    if [[ -w "$_dir" ]]; then
        _target="$_primary_cfg"
    else
        # Primary not writable (global/shared install); degrade to $HOME/.aid,
        # mirroring registry_register's user-tier degrade (~:1754-1766).
        local _fb_dir="${HOME}/.aid"
        mkdir -p "$_fb_dir" 2>/dev/null || true
        [[ "${_AID_VERBOSE:-0}" == "1" ]] && \
            echo "WARN: aid: could not write to ${_dir}; seeding ${_fb_dir}/scan-config.yml instead" >&2
        _target="${_fb_dir}/scan-config.yml"
    fi
    # Idempotent at whichever tier resolved to -- never overwrite an existing file.
    [[ -f "$_target" ]] && return 0
    _tmp="$(mktemp "${_target}.aid-tmp.XXXXXX" 2>/dev/null)" || {
        echo "WARN: aid: could not seed the scan exclusions config (${_target}): mktemp failed" >&2
        return 0
    }
    {
        printf '%s\n' '# scan-config.yml -- user-level directory-prune list for "aid projects scan".'
        printf '%s\n' '# Names here are ADDED to the built-in exclusion set (case-insensitive, EXACT'
        printf '%s\n' '# basename, matched at any depth). Extend-only: a built-in default cannot be'
        printf '%s\n' '# removed here. One "- <name>" per line. Names with spaces need no quotes.'
        printf '%s\n' "schema: 1"
        printf '%s\n' "prune_dirs:"
        local _n
        for _n in "${_AID_SCAN_PRUNE_DIRS[@]}"; do
            printf '  - %s\n' "$_n"
        done
    } > "$_tmp" || {
        rm -f "$_tmp" 2>/dev/null
        echo "WARN: aid: could not seed the scan exclusions config (${_target}): write failed" >&2
        return 0
    }
    _aid_priv_run "" mv -f "$_tmp" "$_target" || {
        rm -f "$_tmp" 2>/dev/null
        echo "WARN: aid: could not seed the scan exclusions config (${_target}): mv failed" >&2
        return 0
    }
    return 0
}

# _aid_scan_windows_drives <include-network 0/1> <include-removable 0/1>
# Emits one MSYS-style drive root per line (e.g. "/c") for local FIXED drives
# (plus Network/Removable when opted in), via the SAME classifier the
# PowerShell twin uses natively: [System.IO.DriveInfo]::GetDrives() filtered
# on DriveType, shelled out to powershell.exe -- so drive classification is
# identical-by-construction (AC-2). Follows the repo's existing
# bash-to-Windows-tool shell-out pattern (MSYS_NO_PATHCONV=1, ~:1151-1159).
# wmic logicaldisk is NOT used (deprecated / absent on current Windows 11).
_aid_scan_windows_drives() {
    local _inc_net="$1" _inc_rem="$2"
    local _types="'Fixed'"
    [[ "$_inc_net" -eq 1 ]] && _types="${_types},'Network'"
    [[ "$_inc_rem" -eq 1 ]] && _types="${_types},'Removable'"

    local _ps_cmd
    _ps_cmd='$t=@('"${_types}"'); [System.IO.DriveInfo]::GetDrives() | Where-Object { $t -contains $_.DriveType.ToString() } | ForEach-Object { $_.Name }'

    local _drives
    _drives="$(MSYS_NO_PATHCONV=1 powershell.exe -NoProfile -Command "$_ps_cmd" 2>/dev/null | tr -d '\r')"

    local _line _letter
    while IFS= read -r _line; do
        [[ -n "$_line" ]] || continue
        # DriveInfo.Name is like "C:\" -- take the drive letter, map to /c.
        _letter="${_line:0:1}"
        [[ "$_letter" =~ [A-Za-z] ]] || continue
        _letter="$(printf '%s' "$_letter" | tr '[:upper:]' '[:lower:]')"
        printf '/%s\n' "$_letter"
    done <<< "$_drives"
}

# _aid_scan_roots <all 0/1> <path-or-empty> <include-network 0/1> <include-removable 0/1>
# Resolves the scan roots by scope (FR-2/FR-3) and validates the scope/drive
# flags, exiting 2 on any usage error:
#   default (no scope flag)  -> the user HOME directory; NO drive enumeration
#   --path <folder>          -> that canonical folder; NO drive enumeration
#   --all                    -> whole machine; the ONLY mode that enumerates
#                                drives (Windows: local FIXED drives, network/
#                                removable excluded by default; Unix: "/")
# Emits one canonical root path per line to stdout.
_aid_scan_roots() {
    local _all="$1" _path="$2" _inc_net="$3" _inc_rem="$4"

    if [[ "$_all" -eq 1 && -n "$_path" ]]; then
        echo "ERROR: aid projects scan: --path and --all are mutually exclusive" >&2
        exit 2
    fi
    if [[ "$_all" -ne 1 && ( "$_inc_net" -eq 1 || "$_inc_rem" -eq 1 ) ]]; then
        echo "ERROR: aid projects scan: --include-network / --include-removable require --all" >&2
        exit 2
    fi

    if [[ -n "$_path" ]]; then
        if [[ ! -d "$_path" ]]; then
            echo "ERROR: aid projects scan: --path is not a directory: ${_path}" >&2
            exit 2
        fi
        local _canon
        _canon="$(cd "$_path" 2>/dev/null && pwd -P)" || _canon="$_path"
        printf '%s\n' "$_canon"
        return 0
    fi

    if [[ "$_all" -eq 1 ]]; then
        if _dc_is_windows; then
            local _any=0 _d
            while IFS= read -r _d; do
                [[ -n "$_d" ]] || continue
                _any=1
                printf '%s\n' "$_d"
            done < <(_aid_scan_windows_drives "$_inc_net" "$_inc_rem")
            if [[ "$_any" -eq 0 ]]; then
                echo "WARN: aid projects scan: no fixed drives detected via powershell.exe; nothing to scan under --all" >&2
            fi
        else
            # NFR-5: no drive-letter model on Unix -- the single root is "/",
            # and network/removable mounts are not auto-classified (documented
            # limitation); the two flags are accepted-but-inert here.
            if [[ "$_inc_net" -eq 1 || "$_inc_rem" -eq 1 ]]; then
                echo "aid projects scan: --include-network/--include-removable are Windows-only-effective (drive-type filtering is a Windows concept); on Unix --all already walks every mount under /." >&2
            fi
            printf '/\n'
        fi
        return 0
    fi

    # Default scope: the user HOME directory, no drive enumeration.
    local _home_canon
    _home_canon="$(cd "${HOME}" 2>/dev/null && pwd -P)" || _home_canon="${HOME}"
    printf '%s\n' "$_home_canon"
}

# _aid_scan_walk_node <dir> <depth-from-root> <all 0/1> <user-depth-or-empty>
# Internal recursive worker for _aid_scan_walk. Applies the FIXED per-folder
# order (NFR-9) to <dir>: (a) unreadable -> skip; (b) a valid .aid/ project ->
# emit the CANONICAL candidate and prune the whole subtree; (c) basename in
# the heavy/cache set (any depth, all modes) -> prune; (c2) under --all only,
# an immediate child of the scan root (depth 1) whose basename is in the
# OS/system set -> prune; (d) else recurse into children, skipping directory
# symlinks (NFR-4) and stopping at the user --depth cap and the hard
# _AID_SCAN_MAX_DEPTH ceiling. Emits one canonical candidate path per line.
_aid_scan_walk_node() {
    local _dir="$1" _depth="$2" _is_all="$3" _user_depth="$4"

    # _scan_dir_count / _scan_progress_stride are `local`s of the calling
    # _cmd_projects_scan, visible here via bash's normal dynamic scoping (and
    # inherited into the process-substitution subshell each root's walk runs
    # in) -- not globals; see _cmd_projects_scan.
    _scan_dir_count=$((_scan_dir_count + 1))
    if (( _scan_dir_count % _scan_progress_stride == 0 )); then
        printf 'aid projects scan: ...%d folder(s) examined so far (current: %s)\n' \
            "$_scan_dir_count" "$_dir" >&2
    fi

    # (a) Not a directory, or unreadable -- skip and continue (NFR-1).
    [[ -d "$_dir" ]] || return 0
    if [[ ! -r "$_dir" || ! -x "$_dir" ]]; then
        return 0
    fi

    # (b) A valid AID project -- emit the canonical candidate and PRUNE the
    # whole subtree (do not recurse into ANY child, incl. .aid/) (NFR-9).
    if _aid_is_project_dir "$_dir"; then
        local _canon
        _canon="$(cd "$_dir" 2>/dev/null && pwd -P)" || _canon="$_dir"
        printf '%s\n' "$_canon"
        return 0
    fi

    # Never descend into the CLI's own state-home subtree (fixes the review
    # finding: classification alone excluded it as a PROJECT via step (b)
    # above, but the walk still recursed INTO it, so a project nested inside
    # $HOME/.aid / $AID_STATE_HOME could still be discovered). Compared as a
    # plain string against _scan_state_home_canon/_scan_home_aid_canon --
    # two `local`s of _cmd_projects_scan, canonicalized ONCE (via the
    # `cd ... && pwd -P` idiom, ~:92-93) before the walk starts, NOT re-forked
    # here per directory: $_dir is already canonical by construction (built
    # from an already-canonical root plus real, non-symlink child names only
    # -- symlinked children are never descended into, see (d) below), so a
    # plain string compare is correct without re-canonicalizing $_dir.
    if [[ "$_dir" == "$_scan_state_home_canon" || "$_dir" == "$_scan_home_aid_canon" ]]; then
        return 0
    fi

    # basename via pure parameter expansion (NOT an external `basename` fork
    # -- this runs for essentially every directory in the walk).
    local _base="${_dir%/}"
    _base="${_base##*/}"
    [[ -n "$_base" ]] || _base="$_dir"   # "/" itself: no trailing component

    # (c) Heavy/cache/build basename match -- any depth, all modes (NFR-2).
    # Tests the run-scoped MERGED set (built-in _AID_SCAN_PRUNE_DIRS unioned
    # with any user-level scan-config.yml prune_dirs: entries, FR-4) computed
    # ONCE by _cmd_projects_scan -- NOT the built-in constant directly.
    # _scan_prune_dirs is a `local -a` of the calling _cmd_projects_scan,
    # visible here via bash's normal dynamic scoping (and inherited into the
    # process-substitution subshell each root's walk runs in), the same
    # mechanism already used for _scan_dir_count / _scan_state_home_canon.
    if _aid_scan_name_in_set "$_base" "${_scan_prune_dirs[@]}"; then
        return 0
    fi

    # (c2) --all only, root-only: an immediate child of the scan root whose
    # basename is an OS/system name (NFR-3). Never applied under the HOME
    # default or --path (any depth), so a top-level ~/dev / <--path>/dev is
    # descended normally.
    if [[ "$_is_all" -eq 1 && "$_depth" -eq 1 ]]; then
        if _aid_scan_name_in_set "$_base" "${_AID_SCAN_SYSTEM_DIRS[@]}"; then
            return 0
        fi
    fi

    # (d) Recurse into children -- skipping directory symlinks (NFR-4) and
    # stopping at the user --depth cap and the hard _AID_SCAN_MAX_DEPTH cap.
    local _next_depth=$((_depth + 1))
    if [[ -n "$_user_depth" && "$_next_depth" -gt "$_user_depth" ]]; then
        return 0
    fi
    if [[ "$_next_depth" -gt "$_AID_SCAN_MAX_DEPTH" ]]; then
        return 0
    fi

    # dotglob/nullglob (so "$_dir"/* also matches dot-directories, and matches
    # nothing rather than the literal "*" on an empty dir) are set ONCE by
    # the caller (_cmd_projects_scan), NOT here: capturing/restoring shopt
    # state via `$(shopt -p ...)` is a command substitution -- i.e. a fork --
    # and this function runs once per directory in the walk, so doing it here
    # would reintroduce a per-directory fork pair (the same class of bug as
    # finding #1). See _cmd_projects_scan for the one-time set/restore.
    local _child
    for _child in "$_dir"/*; do
        [[ -e "$_child" ]] || continue
        [[ -L "$_child" ]] && continue
        [[ -d "$_child" ]] || continue
        _aid_scan_walk_node "$_child" "$_next_depth" "$_is_all" "$_user_depth"
    done
}

# _aid_scan_walk <root> <all 0/1> <user-depth-or-empty>
# Entry point: walks <root> (depth 0) and emits one CANONICAL discovered
# .aid/ project path per line to stdout.
_aid_scan_walk() {
    local _root="$1" _is_all="$2" _user_depth="$3"
    _aid_scan_walk_node "$_root" 0 "$_is_all" "$_user_depth"
}

# _cmd_projects_scan <path> <all> <depth> <dry-run> <include-network> <include-removable> <verbose>
# Orchestrates parse -> roots -> pruned walk -> canonical-dedupe -> register
# -> report (SPEC.md Feature Flow). Register-only: the only write is
# registry_register on a genuinely NEW candidate; an already-registered
# candidate is left byte-unchanged (FR-5). --dry-run replaces the write with
# recording "would-register" (FR-4). Tier is FORCED to the user tier before
# registering, unless --shared was explicitly given (FR-9).
_cmd_projects_scan() {
    local _path="$1" _all="$2" _depth="$3" _dry_run="$4"
    local _inc_net="$5" _inc_rem="$6" _verbose="$7"

    # --depth must be a non-negative integer (AC-3); normalize via base-10 so
    # a leading-zero value (e.g. "008") never trips bash's octal-literal
    # parse error in the arithmetic comparisons below (see the identical
    # precedent at _cmd_projects_remove's index parsing).
    if [[ -n "$_depth" ]]; then
        if [[ ! "$_depth" =~ ^[0-9]+$ ]]; then
            echo "ERROR: aid projects scan: --depth must be a non-negative integer: ${_depth}" >&2
            exit 2
        fi
        _depth=$((10#$_depth))
    fi

    # Resolve roots (validates --path/--all mutual exclusion, non-dir --path,
    # and include-flags-without-all; exits 2 on any usage error). Uses PLAIN
    # command substitution (not process substitution) and explicitly
    # propagates the exit status: _aid_scan_roots may call `exit 2` on a
    # validation failure, and that only terminates a `<(...)` process
    # substitution's subshell silently -- it would NOT reach the CLI's own
    # exit code. `$(...)` assigned to a pre-declared local correctly sets $?
    # to the substituted command's real exit status (avoid the `local
    # var=$(cmd)` combined form, which masks $? with `local`'s own status).
    local _roots_raw _roots_rc
    _roots_raw="$(_aid_scan_roots "$_all" "$_path" "$_inc_net" "$_inc_rem")"
    _roots_rc=$?
    if [[ "$_roots_rc" -ne 0 ]]; then
        exit "$_roots_rc"
    fi
    local -a _roots=()
    local _r
    while IFS= read -r _r; do
        [[ -n "$_r" ]] && _roots+=("$_r")
    done <<< "$_roots_raw"

    if [[ "$_verbose" -eq 1 ]]; then
        printf 'aid projects scan: roots: %s\n' "${_roots[*]}" >&2
    fi

    # Read the registry ONCE (Feature Flow step 3) for O(1) already-registered
    # lookups; never re-read mid-walk.
    declare -A _reg_seen=()
    local _rp
    while IFS= read -r _rp; do
        [[ -n "$_rp" ]] && _reg_seen["$_rp"]=1
    done < <(_registry_read_raw_union)

    # FR-3/NFR-3: seed scan-config.yml with the built-in Tier-A defaults on
    # the first real (non-dry-run) scan when it is absent -- best-effort,
    # idempotent, and never invoked under --dry-run (a dry-run scan makes no
    # writes at all). Then resolve the effective Tier-A set ONCE (FR-4/NFR-1):
    # the case-insensitive deduped union of the built-in set and any
    # scan-config.yml prune_dirs: entries; a missing/unreadable/prune_dirs-
    # less config falls back to exactly the built-in set (FR-5).
    [[ "$_dry_run" -eq 1 ]] || _aid_scan_seed_config
    local -a _scan_prune_dirs=()
    readarray -t _scan_prune_dirs < <(_aid_scan_merge_prune_dirs)

    # FR-9: force the user tier before registering, unless the caller already
    # forced --shared explicitly; never leave the auto-rule to choose.
    if [[ -z "${_AID_TIER_OVERRIDE:-}" ]]; then
        _AID_TIER_OVERRIDE="--local"
    fi

    # State-home canonical paths, computed ONCE here (two forks total for the
    # whole run, not per-directory) so _aid_scan_walk_node can prune the state
    # home's subtree with a plain string compare -- never registered as a
    # project itself, and never DESCENDED INTO either (fixes the review
    # finding: a project nested inside $HOME/.aid / $AID_STATE_HOME was
    # previously still reachable and got registered).
    local _scan_state_home_canon _scan_home_aid_canon
    _scan_state_home_canon="$(cd "${AID_STATE_HOME}" 2>/dev/null && pwd -P)" || _scan_state_home_canon="${AID_STATE_HOME}"
    _scan_home_aid_canon="$(cd "${HOME}/.aid" 2>/dev/null && pwd -P)" || _scan_home_aid_canon="${HOME}/.aid"

    declare -A _scan_seen=()   # run-scoped canonical-key dedupe (NFR-10)
    local -a _report_lines=()
    local _n_new=0 _n_existing=0
    # `local`s (not globals -- coding-standards.md "UPPER_SNAKE = globals,
    # lower = locals"), visible to _aid_scan_walk_node via bash's normal
    # dynamic scoping (and inherited into each root's process-substitution
    # subshell at fork time).
    local _scan_dir_count=0
    local _scan_progress_stride=200

    # dotglob (so "$dir"/* also matches dot-directories) + nullglob (so a glob
    # with no match expands to nothing, not the literal "*") are set ONCE
    # here for the whole walk -- NOT per-directory inside
    # _aid_scan_walk_node, where capturing/restoring shopt state via
    # `$(shopt -p ...)` would fork a subshell on every directory visited (the
    # same class of hot-path-fork bug as finding #1). `shopt -q` is a plain
    # builtin exit-status test -- no fork -- so save/restore here is free.
    local _had_dotglob=0 _had_nullglob=0
    shopt -q dotglob && _had_dotglob=1
    shopt -q nullglob && _had_nullglob=1
    shopt -s dotglob nullglob

    local _root _cand _ver _action _tier
    for _root in "${_roots[@]+"${_roots[@]}"}"; do
        printf 'aid projects scan: scanning %s ...\n' "$_root" >&2
        while IFS= read -r _cand; do
            [[ -n "$_cand" ]] || continue
            # Dedupe within the run: the same real project reached more than
            # once (overlap, symlink, '.'/'..') is considered exactly once.
            [[ -n "${_scan_seen[$_cand]:-}" ]] && continue
            _scan_seen["$_cand"]=1

            _ver="$(_aid_project_state "$_cand")"
            _tier="$(_aid_resolve_tier "$_cand")"

            if [[ -n "${_reg_seen[$_cand]:-}" ]]; then
                _action="already-registered"
                _n_existing=$((_n_existing + 1))
            elif [[ "$_dry_run" -eq 1 ]]; then
                _action="would-register"
                _n_new=$((_n_new + 1))
            else
                registry_register "$_cand" "$_tier" >/dev/null
                _action="registered"
                _n_new=$((_n_new + 1))
            fi
            if [[ "$_verbose" -eq 1 ]]; then
                printf 'aid projects scan: %s  tier=%s  version=%s  action=%s\n' \
                    "$_cand" "$_tier" "$_ver" "$_action" >&2
            fi
            _report_lines+=("$(printf '%s  %s  %s' "$_cand" "$_ver" "$_action")")
        done < <(_aid_scan_walk "$_root" "$_all" "$_depth")
    done

    [[ "$_had_dotglob" -eq 1 ]] || shopt -u dotglob
    [[ "$_had_nullglob" -eq 1 ]] || shopt -u nullglob

    # Final summary (FR-7): counts to stdout, then one path/version/action
    # line per discovered project.
    printf 'aid projects scan: %d newly-registered, %d already-registered.\n' "$_n_new" "$_n_existing"
    local _line
    for _line in "${_report_lines[@]+"${_report_lines[@]}"}"; do
        printf '%s\n' "$_line"
    done

    return 0
}

# ---------------------------------------------------------------------------
# _cmd_update_all  (work-001-update-all / task-001)
# Bulk-update every registered AID project to one version from a single shared
# download.  Parent driver: resolves one version, downloads once per distinct
# tool into a transient cache, and applies it to each registered project via a
# per-project CHILD 'aid update --target <repo> --from-bundle <cache>' process
# invocation (continue-on-error).  Reuses, does not reimplement:
# _registry_read_raw_union, manifest_list_tools, resolve_version/fetch_tarball,
# _aid_update_self_if_stale, and the entire single-project apply path (its
# --from-bundle branch).  See SPEC.md Feature Flow.
#
# Args: the tokens following the 'all' reserved word (--version/--dry-run/--force).
# Returns: 0 all-success or --dry-run; 1 if any project failed; 2 usage error;
# 3 version-resolution failure.
# ---------------------------------------------------------------------------
_cmd_update_all() {
    local _ua_version_arg=""
    local _ua_dry_run=0
    local _ua_force=0

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --version)
                [[ $# -lt 2 ]] && _aid_die "--version requires a value" 2
                _ua_version_arg="$2"; shift 2 ;;
            --dry-run) _ua_dry_run=1; shift ;;
            --force|-y) _ua_force=1; shift ;;
            --target)
                _aid_die "'aid update all' does not accept --target; it updates every registered project (see 'aid projects list')" 2 ;;
            -h|--help)
                _aid_usage update
                return 0 ;;
            -*)
                _aid_die "unknown flag for 'update all': $1" 2 ;;
            *)
                _aid_die "unexpected argument for 'update all': $1" 2 ;;
        esac
    done

    # Resolve the single run version once (decision D7 / FR7 version) -- governs
    # the whole run.  Strip a leading 'v' from an explicit pin, same as the
    # single-project path (bin/aid:3235).
    local _ua_run_version="${_ua_version_arg#v}"
    if [[ -z "$_ua_run_version" ]]; then
        _ua_run_version="$(resolve_version)"
        local _ua_resolve_rc=$?
        [[ "$_ua_resolve_rc" -ne 0 ]] && return "$_ua_resolve_rc"
    fi

    # Self-update preamble EXACTLY once, before enumeration (decision/FR8).
    # Skipped entirely under --dry-run (a self-update is a write; dry-run makes none).
    if [[ "$_ua_dry_run" -eq 0 ]]; then
        _aid_update_self_if_stale
    fi

    # Per-run transient cache (mirrors the single-project _AID_STAGING_BASE pattern).
    local _ua_cache
    _ua_cache="$(mktemp -d "${TMPDIR:-${TMP:-/tmp}}/aid-update-all-XXXXXX")"
    trap 'rm -rf "$_ua_cache"' EXIT

    # Enumerate every registered project -- the exact source 'aid projects list' reads
    # (decision D3 / FR2 / AC2).
    local -a _ua_repos=()
    while IFS= read -r _ua_r; do
        [[ -n "$_ua_r" ]] && _ua_repos+=("$_ua_r")
    done < <(_registry_read_raw_union)

    printf 'aid update all: %d registered project(s), target version %s\n' \
        "${#_ua_repos[@]}" "$_ua_run_version"

    local -A _ua_fetch_failed=()   # tool -> 1 once its download has failed this run
    local -a _ua_updated=()
    local -a _ua_skipped=()
    local -a _ua_failed=()

    if [[ "${#_ua_repos[@]}" -gt 0 ]]; then
        for _ua_repo in "${_ua_repos[@]}"; do
            [[ -n "$_ua_repo" ]] || continue

            # Availability check (AC7): skip (non-fatal) a project whose .aid/ is absent.
            if [[ ! -d "${_ua_repo}/.aid" ]]; then
                _ua_skipped+=("$_ua_repo")
                echo "SKIP: ${_ua_repo} (.aid/ not found)"
                continue
            fi

            local _ua_manifest="${_ua_repo}/.aid/.aid-manifest.json"
            local -a _ua_tools=()
            while IFS= read -r _ua_t; do
                [[ -n "$_ua_t" ]] && _ua_tools+=("$_ua_t")
            done < <(manifest_list_tools "$_ua_manifest")

            # Populate the shared cache download-once per distinct tool (AC1).
            local _ua_repo_fetch_failed=0
            if [[ "${#_ua_tools[@]}" -gt 0 ]]; then
                for _ua_tool in "${_ua_tools[@]}"; do
                    if [[ -n "${_ua_fetch_failed[$_ua_tool]:-}" ]]; then
                        _ua_repo_fetch_failed=1
                        continue
                    fi
                    local _ua_tarball="${_ua_cache}/aid-${_ua_tool}-v${_ua_run_version}.tar.gz"
                    if [[ ! -f "$_ua_tarball" ]]; then
                        if ! fetch_tarball "$_ua_tool" "$_ua_run_version" "$_ua_cache"; then
                            echo "ERROR: aid update all: failed to download ${_ua_tool} v${_ua_run_version} (continuing)" >&2
                            _ua_fetch_failed["$_ua_tool"]=1
                            _ua_repo_fetch_failed=1
                        fi
                    fi
                done
            fi

            if [[ "$_ua_repo_fetch_failed" -eq 1 ]]; then
                _ua_failed+=("$_ua_repo")
                echo "FAILED: ${_ua_repo} (tool download failed)"
                continue
            fi

            # Apply via the existing --from-bundle branch, through a CHILD 'aid update'
            # process (FR4) -- no new fetch/install mechanism.
            local -a _ua_child_args=(update --target "$_ua_repo" --from-bundle "$_ua_cache")
            [[ "$_ua_force" -eq 1 ]] && _ua_child_args+=(--force)
            [[ "$_ua_dry_run" -eq 1 ]] && _ua_child_args+=(--dry-run)

            echo ""
            echo "=== ${_ua_repo} ==="
            bash "$_AID_SELF_REAL" "${_ua_child_args[@]}"
            local _ua_child_rc=$?

            # Record outcome (FR5/FR6): 0 -> updated; non-zero -> failed. Continue
            # regardless (decision D4 -- continue-on-error).
            if [[ "$_ua_child_rc" -eq 0 ]]; then
                _ua_updated+=("$_ua_repo")
            else
                _ua_failed+=("$_ua_repo")
                echo "FAILED: ${_ua_repo} (exit ${_ua_child_rc})"
            fi
        done
    fi

    # End-of-run summary (decision D4 / AC3, AC5).
    echo ""
    echo "--- aid update all summary (version ${_ua_run_version}) ---"
    for _ua_r in "${_ua_updated[@]+"${_ua_updated[@]}"}"; do
        [[ -n "$_ua_r" ]] && echo "  updated: ${_ua_r}"
    done
    for _ua_r in "${_ua_skipped[@]+"${_ua_skipped[@]}"}"; do
        [[ -n "$_ua_r" ]] && echo "  skipped: ${_ua_r} (.aid/ not found)"
    done
    for _ua_r in "${_ua_failed[@]+"${_ua_failed[@]}"}"; do
        [[ -n "$_ua_r" ]] && echo "  failed: ${_ua_r}"
    done
    printf '%d updated, %d skipped, %d failed\n' \
        "${#_ua_updated[@]}" "${#_ua_skipped[@]}" "${#_ua_failed[@]}"

    rm -rf "$_ua_cache"
    trap - EXIT

    [[ "${#_ua_failed[@]}" -gt 0 ]] && return 1
    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
        ;;
    -V|--version)
        # Top-level, bare, no-value flag -- distinct from the subcommand
        # --version <v> pin (add/remove/update, parsed further below). Only
        # fires when it is the FIRST argument, so it never shadows the pin.
        _aid_print_version
        exit 0
        ;;
esac

SUBCMD="$1"
shift

# ---- version ----------------------------------------------------------------
if [[ "$SUBCMD" == "version" ]]; then
    _aid_print_version
    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
    # Check for 'update all' as first positional arg (work-001-update-all / task-001).
    # Consumed here -- alongside 'self' -- BEFORE the generic add/remove/update flag
    # loop and BEFORE the non-'self' positional rejection (FR10, below), so 'all' is
    # never treated as an unknown positional (SPEC AC9).
    if [[ $# -gt 0 && "$1" == "all" ]]; then
        shift
        _cmd_update_all "$@"
        exit $?
    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|scan|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, scan, 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 project." >&2
                    echo "       Existing tools are at v${_FR11_EXISTING_VER}. Either:" >&2
                    echo "         - Omit --version to install at the project version (v${_FR11_EXISTING_VER}), or" >&2
                    echo "         - Run 'aid update --version ${_AID_VERSION_ARG}' first to advance the whole project." >&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 "project is at v${_FR11_EXISTING_VER}; new tool(s) installed at v${_FR11_EXISTING_VER} to keep the project 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 project 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
