#!/bin/bash
# fw - Agentic Engineering Framework CLI
#
# Single entry point for all framework operations.
# Reads .framework.yaml from the project directory to resolve
# FRAMEWORK_ROOT, then routes commands to the appropriate agent.
#
# When run from a project that uses the framework as shared tooling,
# fw reads .framework.yaml to find the framework install path.
# When run from inside the framework repo itself, it auto-detects.

set -euo pipefail

# Version: derived from git tags (major.minor = human tag, patch = commits since tag)
# Falls back to VERSION file for consumer installs without git history
_derive_version() {
    local fw_dir="${BASH_SOURCE[0]%/*}/.."
    local desc=""
    # G-049: only call `git describe` when $fw_dir has its own .git.
    # In vendored mode, $fw_dir/.git is absent and walking up would
    # find the consumer's repo and return the consumer's tag.
    if [ -d "$fw_dir/.git" ] || [ -f "$fw_dir/.git" ]; then
        desc=$(git -C "$fw_dir" describe --tags --match 'v[0-9]*' 2>/dev/null) || true
    fi
    if [ -n "$desc" ]; then
        # v1.4.0 → 1.4.0 | v1.4.0-5-gabc123 → 1.4.5
        desc="${desc#v}"  # strip leading v
        if [[ "$desc" == *-*-* ]]; then
            local base commits
            base="${desc%%-*}"          # 1.4.0
            local rest="${desc#*-}"     # 5-gabc123
            commits="${rest%%-*}"       # 5
            local major_minor="${base%.*}"  # 1.4
            echo "${major_minor}.${commits}"
        else
            echo "$desc"
        fi
    elif [ -f "${BASH_SOURCE[0]%/*}/../VERSION" ]; then
        cat "${BASH_SOURCE[0]%/*}/../VERSION"
    else
        echo "dev"
    fi
}
FW_VERSION="$(_derive_version)"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

# --- Path Resolution ---

# Resolve symlinks to find the real fw location
FW_REAL_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
FW_BIN_DIR="$(cd "$(dirname "$FW_REAL_PATH")" && pwd)"

# Find PROJECT_ROOT by walking up from cwd looking for .framework.yaml or .tasks/.
# T-1841: skip dirs containing .fw-not-a-project sentinel — these are vendored
# framework copies (.agentic-framework/) or rollback dirs (.agentic-framework.rollback/)
# that happen to carry .tasks/templates/ from the vendored framework's own structure.
# Without this skip, an agent CWD inside such a dir would mis-resolve PROJECT_ROOT
# to the vendored copy, triggering boundary-hook blocks on every `cd` to the real
# consumer root.
find_project_root() {
    local dir="$PWD"
    while [ "$dir" != "/" ]; do
        if [ -f "$dir/.fw-not-a-project" ]; then
            dir="$(dirname "$dir")"
            continue
        fi
        if [ -f "$dir/.framework.yaml" ] || [ -d "$dir/.tasks" ]; then
            echo "$dir"
            return 0
        fi
        dir="$(dirname "$dir")"
    done
    return 1
}

# Resolve FRAMEWORK_ROOT
resolve_framework() {
    local candidate
    candidate="$(cd "$FW_BIN_DIR/.." && pwd)"
    local candidate_is_framework_repo=0
    if [ -f "$candidate/FRAMEWORK.md" ] && [ -d "$candidate/agents" ]; then
        candidate_is_framework_repo=1
    fi

    # T-1346-B1: If candidate is a framework repo AND lives INSIDE the consumer
    # project (either = PROJECT_ROOT for self-invocation, or under PROJECT_ROOT
    # for direct-vendored invocation like `.agentic-framework/bin/fw`), use it.
    # This preserves framework-repo-self and direct-vendored paths.
    if [ "$candidate_is_framework_repo" = 1 ] && [ -n "${PROJECT_ROOT:-}" ]; then
        local canon_candidate canon_project
        canon_candidate="$(cd "$candidate" && pwd -P)"
        canon_project="$(cd "$PROJECT_ROOT" && pwd -P)"
        if [ "$canon_candidate" = "$canon_project" ] || [[ "$canon_candidate" == "$canon_project"/* ]]; then
            # T-355/T-359: Homebrew Cellar redirect (preserved from original rule 1)
            if [[ "$candidate" == */Cellar/agentic-fw/*/libexec ]] || [[ "$candidate" == */Cellar/fw/*/libexec ]]; then
                local formula_name="${candidate#*Cellar/}"
                formula_name="${formula_name%%/*}"
                local brew_prefix="${candidate%/Cellar/*/libexec}"
                local opt_path="$brew_prefix/opt/$formula_name/libexec"
                if [ -d "$opt_path" ] && [ -f "$opt_path/FRAMEWORK.md" ]; then
                    echo "$opt_path"
                    return 0
                fi
            fi
            echo "$candidate"
            return 0
        fi
    fi

    # T-498: Prefer project-vendored framework when present (T-1346-B1: now
    # checked before falling back to FW_BIN_DIR-origin, so global-shim
    # invocation in a consumer project picks the vendored copy, not global).
    if [ -n "${PROJECT_ROOT:-}" ] && [ -f "$PROJECT_ROOT/.agentic-framework/FRAMEWORK.md" ]; then
        echo "$PROJECT_ROOT/.agentic-framework"
        return 0
    fi

    # No PROJECT_ROOT or no vendored copy — use candidate if it's a framework repo.
    # (Covers: `bin/fw version` from outside any project, brew-installed fw invoked
    # from a non-framework dir, etc.)
    if [ "$candidate_is_framework_repo" = 1 ]; then
        if [[ "$candidate" == */Cellar/agentic-fw/*/libexec ]] || [[ "$candidate" == */Cellar/fw/*/libexec ]]; then
            local formula_name="${candidate#*Cellar/}"
            formula_name="${formula_name%%/*}"
            local brew_prefix="${candidate%/Cellar/*/libexec}"
            local opt_path="$brew_prefix/opt/$formula_name/libexec"
            if [ -d "$opt_path" ] && [ -f "$opt_path/FRAMEWORK.md" ]; then
                echo "$opt_path"
                return 0
            fi
        fi
        echo "$candidate"
        return 0
    fi

    # Legacy: read framework_path from .framework.yaml (pre-T-498 projects)
    if [ -n "${PROJECT_ROOT:-}" ] && [ -f "$PROJECT_ROOT/.framework.yaml" ]; then
        local fw_path
        fw_path=$(grep "^framework_path:" "$PROJECT_ROOT/.framework.yaml" 2>/dev/null | sed 's/^framework_path:[[:space:]]*//')
        if [ -n "$fw_path" ] && [ -d "$fw_path" ] && [ -f "$fw_path/FRAMEWORK.md" ]; then
            echo "$fw_path"
            return 0
        fi
    fi

    return 1
}

# Resolve PROJECT_ROOT
if [ -z "${PROJECT_ROOT:-}" ]; then
    PROJECT_ROOT=$(find_project_root) || true
fi

# T-1346-B2: Detect which framework copy resolved (vendored, framework-repo, global).
# Must be called AFTER FRAMEWORK_ROOT and PROJECT_ROOT are populated.
# Returns one of: vendored | framework-repo | global | unknown
_detect_fw_mode() {
    local fw="${FRAMEWORK_ROOT:-}"
    local proj="${PROJECT_ROOT:-}"
    [ -z "$fw" ] && { echo "unknown"; return; }

    local canon_fw canon_proj canon_vendored
    canon_fw=$(cd "$fw" 2>/dev/null && pwd -P) || canon_fw="$fw"

    if [ -n "$proj" ]; then
        canon_proj=$(cd "$proj" 2>/dev/null && pwd -P) || canon_proj="$proj"
        if [ -d "$proj/.agentic-framework" ]; then
            canon_vendored=$(cd "$proj/.agentic-framework" 2>/dev/null && pwd -P) \
                || canon_vendored="$proj/.agentic-framework"
            if [ "$canon_fw" = "$canon_vendored" ]; then
                echo "vendored"; return
            fi
        fi
        if [ "$canon_fw" = "$canon_proj" ]; then
            echo "framework-repo"; return
        fi
    fi

    echo "global"
}

# --- Vendor (T-482/T-497, moved before auto-init for T-519) ---
# Copy complete framework into project/.agentic-framework/ for full isolation.
# Must be defined before auto-init dialogue because do_init calls do_vendor.
do_vendor() {
    local dry_run=false
    local target="${PROJECT_ROOT:-.}"
    local source_override=""

    while [[ $# -gt 0 ]]; do
        case $1 in
            --dry-run) dry_run=true; shift ;;
            --target) target="$2"; shift 2 ;;
            --source) source_override="$2"; shift 2 ;;
            -h|--help)
                echo -e "${BOLD}fw vendor${NC} — Copy framework into project for full isolation"
                echo ""
                echo "Usage: fw vendor [options]"
                echo ""
                echo "Options:"
                echo "  --dry-run   Show what would be copied without copying"
                echo "  --target    Target project directory (default: current project)"
                echo "  --source    Framework source directory (default: auto-resolved)"
                echo "  -h, --help  Show this help"
                echo ""
                echo "Copies the complete framework (~7MB) into PROJECT/.agentic-framework/"
                echo "After vendoring, the project is self-contained — no global install needed."
                return 0
                ;;
            *) echo -e "${RED}Unknown option: $1${NC}" >&2; return 1 ;;
        esac
    done

    local dest="$target/.agentic-framework"

    # T-680: Resolve vendor source — explicit override, or FW_BIN_DIR origin, or FRAMEWORK_ROOT
    local vendor_source="${source_override:-$FRAMEWORK_ROOT}"
    if [ -n "$source_override" ]; then
        if [ ! -f "$source_override/FRAMEWORK.md" ]; then
            echo -e "${RED}ERROR${NC}  --source '$source_override' is not a valid framework directory" >&2
            return 1
        fi
        vendor_source="$source_override"
    fi

    # T-680: Detect self-referencing — source and target are the same directory
    local canon_source canon_dest
    canon_source=$(cd "$vendor_source" 2>/dev/null && pwd -P) || canon_source="$vendor_source"
    canon_dest=$(cd "$dest" 2>/dev/null && pwd -P) || canon_dest="$dest"
    if [ "$canon_source" = "$canon_dest" ]; then
        # Source is the vendored copy itself — try to find the real upstream
        # The fw binary's own location reveals the original framework
        local upstream_candidate
        upstream_candidate="$(cd "$FW_BIN_DIR/.." && pwd -P)"
        if [ "$upstream_candidate" != "$canon_source" ] && [ -f "$upstream_candidate/FRAMEWORK.md" ]; then
            echo -e "  ${YELLOW}NOTE${NC}  Source and target are the same directory — using upstream: $upstream_candidate"
            vendor_source="$upstream_candidate"
        else
            echo -e "${RED}ERROR${NC}  Source and target resolve to the same directory:" >&2
            echo -e "         Source: $canon_source" >&2
            echo -e "         Target: $canon_dest" >&2
            echo -e "         Use ${BOLD}fw vendor --source /path/to/framework${NC} to specify a different source" >&2
            return 1
        fi
    fi

    # What to copy (everything needed for development)
    local includes=(
        bin
        lib
        agents
        web
        docs
        .tasks/templates
        FRAMEWORK.md
        metrics.sh
    )

    # What to exclude within copied dirs
    local excludes=(
        __pycache__
        "*.pyc"
        ".DS_Store"
        "lib/ts/src"
        "lib/ts/tsconfig.json"
        "lib/ts/package.json"
        "lib/ts/package-lock.json"
        "lib/ts/node_modules"
    )

    if [ "$dry_run" = true ]; then
        echo -e "${BOLD}fw vendor --dry-run${NC}"
        echo -e "Source:  ${GREEN}$vendor_source${NC}"
        echo -e "Target:  ${GREEN}$dest${NC}"
        echo ""
        echo "Would copy:"
        for item in "${includes[@]}"; do
            if [ -e "$vendor_source/$item" ]; then
                local size
                size=$(du -sh "$vendor_source/$item" 2>/dev/null | cut -f1)
                echo -e "  ${GREEN}+${NC} $item ($size)"
            fi
        done
        echo ""
        echo "Would exclude: .git, .context, .tasks/{active,completed}, .fabric, install.sh"
        echo ""
        echo -e "Version: ${BOLD}$FW_VERSION${NC}"
        return 0
    fi

    # Create destination
    mkdir -p "$dest"

    echo -e "${BOLD}fw vendor${NC} — vendoring framework into project"
    echo -e "Source:  $vendor_source"
    echo -e "Target:  $dest"
    echo ""

    # Build rsync exclude args as array (SC2086)
    local -a rsync_args=()
    for item in "${excludes[@]}"; do
        rsync_args+=(--exclude="$item")
    done

    # Copy each include
    for item in "${includes[@]}"; do
        if [ -e "$vendor_source/$item" ]; then
            local dest_dir
            dest_dir=$(dirname "$dest/$item")
            mkdir -p "$dest_dir"

            if [ -d "$vendor_source/$item" ]; then
                if command -v rsync &>/dev/null; then
                    rsync -a --delete --delete-excluded "${rsync_args[@]}" "$vendor_source/$item/" "$dest/$item/"
                else
                    rm -rf "${dest:?}/${item:?}"
                    cp -r "$vendor_source/$item" "$dest/$item"
                    find "$dest/$item" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
                    find "$dest/$item" -name "*.pyc" -delete 2>/dev/null || true
                    find "$dest/$item" -name ".DS_Store" -delete 2>/dev/null || true
                fi
            else
                cp "$vendor_source/$item" "$dest/$item"
            fi
            echo -e "  ${GREEN}✓${NC} $item"
        else
            echo -e "  ${YELLOW}⊘${NC} $item (not found, skipping)"
        fi
    done

    # Create VERSION file
    echo "$FW_VERSION" > "$dest/VERSION"
    echo -e "  ${GREEN}✓${NC} VERSION ($FW_VERSION)"

    # T-1841: write .fw-not-a-project sentinel so find_project_root walks past
    # this vendored copy when the agent CWD ends up inside it. .tasks/templates/
    # (shipped in vendored framework) would otherwise satisfy the .tasks/
    # detection branch and mis-resolve the vendored dir as PROJECT_ROOT.
    cat > "$dest/.fw-not-a-project" <<'SENTINEL'
This file marks a vendored copy of the Agentic Engineering Framework — NOT a
project root. fw's find_project_root skips any directory containing this file
so an agent CWD inside .agentic-framework/ resolves to the real consumer
project (the parent), not this vendored copy. T-1841.
SENTINEL
    echo -e "  ${GREEN}✓${NC} .fw-not-a-project (T-1841 sentinel)"

    # T-1323: Write .gitignore so runtime-generated __pycache__/ files inside
    # the vendored framework do not leak into the consumer's git index.
    # (do_vendor already excludes these from the COPY step; this prevents
    # them from being added by the consumer's git after Watchtower runs.)
    cat > "$dest/.gitignore" <<'GITIGNORE'
# Vendored Agentic Engineering Framework — runtime artifacts.
# Generated by `fw vendor` / `fw upgrade`. Do not edit by hand.
__pycache__/
*.pyc
*.pyo
.DS_Store
GITIGNORE
    echo -e "  ${GREEN}✓${NC} .gitignore"

    # T-2232: write .upstream sentinel so an in-consumer `fw upgrade` can
    # self-recover when .framework.yaml has no upstream_repo: set (legacy
    # consumers init'd before T-575's auto-detect, or consumers whose
    # framework had no git remote at init time). Resolution chain at upgrade
    # time becomes: --from-upstream flag > .framework.yaml upstream_repo >
    # this vendored sentinel > error. Symmetric with lib/init.sh:212-228
    # (same git origin auto-detect; just runs at vendor-time so the answer
    # travels with the vendored copy). Skip silently when no origin —
    # vendored-without-sentinel is a legitimate state (file:// checkouts,
    # detached forks). Origin: ring20-dashboard .121do field failure
    # (T-2231), durable fix for the in-consumer upgrade path class.
    local _vendor_origin=""
    if [ -d "$vendor_source/.git" ]; then
        _vendor_origin=$(git -C "$vendor_source" remote get-url origin 2>/dev/null) || true
        if [ -z "$_vendor_origin" ]; then
            _vendor_origin=$(git -C "$vendor_source" remote -v 2>/dev/null | grep "(push)" | head -1 | awk '{print $2}') || true
        fi
    fi
    if [ -n "$_vendor_origin" ]; then
        cat > "$dest/.upstream" <<UPSTREAM
# Vendored Agentic Engineering Framework — upstream sentinel (T-2232).
# Records the framework's git origin URL at vendor-time so an in-consumer
# \`fw upgrade\` can self-recover when .framework.yaml has no upstream_repo:.
# Resolution precedence (in lib/upgrade.sh): --from-upstream flag, then
# .framework.yaml upstream_repo:, then this sentinel. After a successful
# sentinel-driven recovery, upgrade.sh persists the URL to .framework.yaml
# so subsequent upgrades skip this fallback. Do not edit by hand.
$_vendor_origin
UPSTREAM
        echo -e "  ${GREEN}✓${NC} .upstream (sentinel: $_vendor_origin)"
    fi

    # Ensure bin/fw is executable
    chmod +x "$dest/bin/fw" 2>/dev/null || true

    echo ""
    local final_size
    final_size=$(du -sh "$dest" 2>/dev/null | cut -f1)
    echo -e "${GREEN}${BOLD}Vendored successfully${NC} ($final_size)"
    echo ""
    echo "The project now contains its own framework at .agentic-framework/"
    echo "Hooks should reference: .agentic-framework/bin/fw hook <name>"
}

# --- Auto-init dialogue ---
# If no project detected and command is not init/help/version, offer guided initialization
if [ -z "$PROJECT_ROOT" ]; then
    _cmd="${1:-}"
    if [ "$_cmd" != "init" ] && [ "$_cmd" != "help" ] && [ "$_cmd" != "-h" ] && \
       [ "$_cmd" != "--help" ] && [ "$_cmd" != "version" ] && [ "$_cmd" != "-v" ] && \
       [ "$_cmd" != "--version" ] && [ "$_cmd" != "update" ] && [ "$_cmd" != "hook" ] && \
       [ "$_cmd" != "vendor" ] && [ -n "$_cmd" ]; then

        # Non-TTY: silently use defaults (cwd + claude)
        if [ ! -t 0 ]; then
            FRAMEWORK_ROOT=$(resolve_framework) || true
            if [ -n "$FRAMEWORK_ROOT" ]; then
                export PROJECT_ROOT="$PWD"
                export FRAMEWORK_ROOT
                FW_LIB_DIR="$FRAMEWORK_ROOT/lib"
                source "$FRAMEWORK_ROOT/lib/init.sh"
                do_init "$PWD" --provider claude 2>/dev/null
                exec "$0" "$@"
            fi
            # Fall through to normal "no framework" error if resolve fails
        else
            echo -e "${YELLOW}No framework project detected in the current directory.${NC}"
            echo ""

            # --- Q1: Where to initialize ---
            _git_root=$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null) || true
            _init_dir="$PWD"

            if [ -n "$_git_root" ] && [ "$_git_root" != "$PWD" ]; then
                # Git root differs from cwd — offer choices
                echo -e "${BOLD}Where would you like to initialize?${NC}"
                echo ""
                echo -e "  ${CYAN}1)${NC} ${_git_root}  ${GREEN}(git root — recommended)${NC}"
                echo -e "  ${CYAN}2)${NC} ${PWD}"
                echo -e "  ${CYAN}3)${NC} Custom path"
                echo -e "  ${CYAN}s)${NC} Skip"
                echo ""
                printf "Choose [1]: "
                read -r _dir_choice </dev/tty
                case "${_dir_choice:-1}" in
                    1) _init_dir="$_git_root" ;;
                    2) _init_dir="$PWD" ;;
                    3)
                        printf "Enter path: "
                        read -r _custom_path </dev/tty
                        if [ -z "$_custom_path" ]; then
                            echo -e "${RED}No path entered.${NC}"
                            exit 1
                        fi
                        _init_dir="${_custom_path/#\~/$HOME}"
                        if [ ! -d "$_init_dir" ]; then
                            echo -e "${YELLOW}Directory does not exist. Creating...${NC}"
                            mkdir -p "$_init_dir"
                        fi
                        ;;
                    s|S|n|N)
                        echo -e "${CYAN}Skipped. Run 'fw init' when you're ready.${NC}"
                        exit 0
                        ;;
                    *)
                        echo -e "${RED}Invalid choice.${NC}"
                        exit 1
                        ;;
                esac
            else
                # cwd IS git root (or no git) — simple Y/n
                printf "Initialize framework in ${BOLD}%s${NC}? [Y/n] " "$PWD"
                read -r _confirm </dev/tty
                if [ -n "$_confirm" ] && [ "$_confirm" != "y" ] && [ "$_confirm" != "Y" ]; then
                    echo -e "${CYAN}Skipped. Run 'fw init' when you're ready.${NC}"
                    exit 0
                fi
            fi

            echo ""

            # --- Q2: Which provider ---
            echo -e "${BOLD}Which AI provider are you using?${NC}"
            echo ""
            echo -e "  ${CYAN}1)${NC} Claude Code  ${GREEN}(recommended)${NC}"
            echo -e "  ${CYAN}2)${NC} Cursor"
            echo -e "  ${CYAN}3)${NC} Generic / other"
            echo ""
            printf "Choose [1]: "
            read -r _prov_choice </dev/tty
            case "${_prov_choice:-1}" in
                1) _provider="claude" ;;
                2) _provider="cursor" ;;
                3) _provider="generic" ;;
                *)
                    echo -e "${YELLOW}Unknown choice, using claude.${NC}"
                    _provider="claude"
                    ;;
            esac

            echo ""

            # --- Run init ---
            FRAMEWORK_ROOT=$(resolve_framework) || true
            if [ -z "$FRAMEWORK_ROOT" ]; then
                echo -e "${RED}ERROR: Cannot find framework installation${NC}" >&2
                exit 1
            fi
            export PROJECT_ROOT="$_init_dir"
            export FRAMEWORK_ROOT
            FW_LIB_DIR="$FRAMEWORK_ROOT/lib"
            source "$FRAMEWORK_ROOT/lib/init.sh"
            do_init "$_init_dir" --provider "$_provider"
            echo ""
            echo -e "${GREEN}Project initialized! Re-running: fw $*${NC}"
            echo ""
            exec "$0" "$@"
        fi
    fi
fi

# Resolve FRAMEWORK_ROOT
# T-2099 (fork-bomb fix, SEV-1): respect a caller-supplied FRAMEWORK_ROOT instead
# of unconditionally re-resolving. Without this, `fw upgrade` from a consumer
# auto-clones upstream, hands off to the cloned bin/fw, and the cloned fw
# re-resolves back to the CONSUMER's vendored copy (T-498 preference) → infinite
# recursion → fork bomb. lib/upgrade.sh now passes env-scoped FRAMEWORK_ROOT to
# the handoff; this branch lets the cloned fw honour it.
if [ -z "${FRAMEWORK_ROOT:-}" ]; then
    FRAMEWORK_ROOT=$(resolve_framework) || true
fi

# Validate we have what we need
if [ -z "$FRAMEWORK_ROOT" ]; then
    echo -e "${RED}ERROR: Cannot find framework installation${NC}" >&2
    echo "" >&2
    echo "fw looks for the framework in two ways:" >&2
    echo "  1. Relative to its own location (bin/fw inside framework repo)" >&2
    echo "  2. Via .framework.yaml in your project root" >&2
    echo "" >&2
    echo "To fix:" >&2
    echo "  - Run fw from inside the framework repo, OR" >&2
    echo "  - Run 'fw init' in your project directory to create .framework.yaml, OR" >&2
    echo "  - Run 'fw vendor /path/to/framework' to link an existing installation" >&2
    exit 1
fi

# If no PROJECT_ROOT found, fall back to FRAMEWORK_ROOT (self-referential mode)
if [ -z "$PROJECT_ROOT" ]; then
    PROJECT_ROOT="$FRAMEWORK_ROOT"
fi

# Export for agents
export PROJECT_ROOT
export FRAMEWORK_ROOT

# --- Agent Directory ---
AGENTS_DIR="$FRAMEWORK_ROOT/agents"
FW_LIB_DIR="$FRAMEWORK_ROOT/lib"

# --- Cross-platform helpers ---
source "$FW_LIB_DIR/compat.sh" 2>/dev/null || true

# --- Help ---

show_help() {
    echo -e "${BOLD}fw${NC} - Agentic Engineering Framework CLI v$FW_VERSION"
    echo ""
    echo -e "${BOLD}Usage:${NC} fw <command> [subcommand] [options]"
    echo ""
    echo -e "${BOLD}Commands:${NC}"
    echo -e "  ${GREEN}audit${NC}                Run framework compliance audit"
    echo -e "  ${GREEN}self-audit${NC}           Standalone integrity check (Layers 1-4, no fw dependency)"
    echo -e "  ${GREEN}test-onboarding${NC}     End-to-end onboarding flow test (8 checkpoints)"
    echo -e "  ${GREEN}context${NC} <cmd>        Manage Context Fabric (init, status, focus, ...)"
    echo -e "  ${GREEN}git${NC} <cmd>            Task-traced git operations (commit, status, log, ...)"
    echo -e "  ${GREEN}push${NC}                Push to all remotes"
    echo -e "  ${GREEN}handover${NC}             Generate session handover document"
    echo -e "  ${GREEN}handover --checkpoint${NC} Mid-session checkpoint (P-009)"
    echo -e "  ${GREEN}healing${NC} <cmd>        Error recovery (diagnose, resolve, patterns, suggest)"
    echo -e "  ${GREEN}resume${NC} <cmd>         Post-compaction recovery (status, sync, quick)"
    echo -e "  ${GREEN}task${NC} <cmd>           Task management (create, update)"
    echo -e "  ${GREEN}inception${NC} <cmd>      Inception phase workflow (start, status, decide)"
    echo -e "  ${GREEN}assumption${NC} <cmd>    Assumption tracking (add, validate, invalidate, list)"
    echo -e "  ${GREEN}promote${NC} <cmd>         Graduation pipeline (suggest, status, L-XXX)"
    echo -e "  ${GREEN}tier0${NC} <cmd>          Tier 0 enforcement (approve, status)"
    echo -e "  ${GREEN}approvals${NC} <cmd>      Approval queue (pending, status, expire)"
    echo -e "  ${GREEN}review-queue${NC}         List tasks awaiting human review (with verdicts)"
    echo -e "  ${GREEN}work-on${NC} <name|T-XXX> Create task + set focus + start (one-step gate)"
    echo -e "  ${GREEN}bus${NC} <cmd>            Result ledger for sub-agent dispatch (post, read, manifest, clear)"
    echo -e "  ${GREEN}dispatch${NC} <cmd>       SSH-based cross-machine communication (send, hosts)"
    echo -e "  ${GREEN}pickup${NC} <cmd>        Cross-project pickup pipeline (process, status, list, send)"
    echo -e "  ${GREEN}consolidate${NC} <cmd>   Memory consolidation (scan, apply, report)"
    echo -e "  ${GREEN}mcp${NC} <cmd>            MCP server process management (reap orphans)"
    echo -e "  ${GREEN}fix-learned${NC} T-XXX \"text\"  Capture bugfix learning (G-016 shortcut)"
    echo -e "  ${GREEN}prompt${NC} <cmd>           Reusable agent prompts (create, list, show, copy)"
    echo -e "  ${GREEN}note${NC} [text]          Lightweight observation capture"
    echo -e "  ${GREEN}notify${NC} <cmd>          Push notifications (setup, test, enable, disable, status)"
    echo -e "  ${GREEN}cron${NC} <cmd>            Cron registry management (generate, status)"
    echo -e "  ${GREEN}peer${NC} <cmd>            v2 peer-consult (subscribe to TermLink inbox.queued + spawn responders)"
    echo -e "  ${GREEN}config${NC} <cmd>           Project config (set, get, list) — persistent settings in .framework.yaml"
    echo -e "  ${GREEN}scan${NC}                  Run watchtower scan (detect opportunities & issues)"
    echo -e "  ${GREEN}serve${NC} [--port N]     Start web UI (default port 3000)"
    echo -e "  ${GREEN}upstream${NC} <cmd>        Report issues to framework upstream (report, config, status)"
    echo -e "  ${GREEN}deploy${NC} <cmd>          Ring20 deployment (scaffold, status, routes, ports)"
    echo -e "  ${GREEN}ask${NC} <query>           Query project knowledge (RAG search)"
    echo -e "  ${GREEN}docs${NC} <cmd>            Documentation lookup (article, show)"
    echo -e "  ${GREEN}enforcement${NC} <cmd>     Enforcement baseline (status, baseline)"
    echo -e "  ${GREEN}fabric${NC} <cmd>          Component Fabric (overview, deps, impact, search, drift, register)"
    echo ""
    echo -e "${BOLD}Setup:${NC}"
    echo -e "  ${GREEN}init${NC} [dir]           Bootstrap a new project (auto-detects interactive mode)"
    echo -e "  ${GREEN}update${NC}               Update framework (vendored or global) to latest version"
    echo -e "  ${GREEN}vendor${NC}               Copy framework into project .agentic-framework/ (full isolation)"
    echo -e "  ${GREEN}hook${NC} <name>          Run a framework hook (used by .claude/settings.json)"
    echo -e "  ${GREEN}hook-enable${NC} <args>   Register a hook in .claude/settings.json (idempotent)"
    echo -e "  ${GREEN}upgrade${NC} [dir]        Sync framework improvements to consumer project"
    echo -e "  ${GREEN}consumer-recover${NC} <host> [path]  Recover legacy vendored consumer (pre-T-2232). Dry-run default; --apply executes."
    echo -e "  ${YELLOW}setup${NC} [dir]          Deprecated — alias for init"
    echo -e "  ${GREEN}build${NC}                Compile TypeScript sources (lib/ts/src → lib/ts/dist)"
    echo -e "  ${GREEN}harvest${NC} [dir]        Harvest learnings from project to framework"
    echo -e "  ${GREEN}termlink${NC} <cmd>       TermLink integration (check|spawn|exec|status|cleanup|dispatch)"
    echo -e "  ${GREEN}onboarding${NC} <cmd>     Onboarding gate (status|skip|reset)"
    echo ""
    echo -e "${BOLD}Discovery:${NC}"
    echo -e "  ${GREEN}task list${NC}          List all tasks (filterable by --status, --type, --component)"
    echo -e "  ${GREEN}task show${NC} T-XXX    Show task detail with episodic summary"
    echo -e "  ${GREEN}decisions${NC}          Show all decisions (architectural + operational)"
    echo -e "  ${GREEN}timeline${NC}           Show session timeline"
    echo -e "  ${GREEN}learnings${NC}          Show learnings with context"
    echo -e "  ${GREEN}patterns${NC}           Show failure/success patterns"
    echo -e "  ${GREEN}practices${NC}          Show graduated principles"
    echo -e "  ${GREEN}search${NC} <keyword>   Search across all artifacts (keyword)"
    echo -e "  ${GREEN}search --semantic${NC}  Semantic vector search (sqlite-vec)"
    echo -e "  ${GREEN}search --hybrid${NC}   Hybrid BM25 + semantic (RRF fusion)"
    echo -e "  ${GREEN}recall${NC} <query>    Query project memory (learnings, patterns, decisions)"
    echo ""
    echo -e "${BOLD}Utility:${NC}"
    echo -e "  ${GREEN}plugin-audit${NC}          Audit plugins for task-system awareness"
    echo -e "  ${GREEN}gaps${NC}                 Show spec-reality gaps being watched"
    echo -e "  ${GREEN}traceability${NC} baseline Set traceability baseline (imported projects)"
    echo -e "  ${GREEN}preflight${NC}            Check OS dependencies (interactive install)"
    echo -e "  ${GREEN}validate-init${NC}        Verify fw init output is correct and complete"
    echo -e "  ${GREEN}doctor${NC}               Check framework health and configuration"
    echo -e "  ${GREEN}verify-acs${NC}           Automated Human AC evidence collection"
    echo -e "  ${GREEN}metrics${NC}              Show project metrics dashboard"
    echo -e "  ${GREEN}metrics predict${NC}      Estimate effort from episodic history"
    echo -e "  ${GREEN}costs${NC}                Token usage tracking (session/project totals)"
    echo -e "  ${GREEN}self-test${NC} [phase]     Run E2E self-test (onboarding|all) [--json]"
    echo -e "  ${GREEN}test${NC} [suite]           Run tests (all|unit|integration|web|lint)"
    echo -e "  ${GREEN}version${NC}              Show version information"
    echo -e "  ${GREEN}version bump${NC} <part>  Bump major|minor|patch (--tag, --dry-run)"
    echo -e "  ${GREEN}version check${NC}        Verify all version sources in sync + staleness"
    echo -e "  ${GREEN}version sync${NC}         Sync all VERSION files to FW_VERSION"
    echo -e "  ${GREEN}help${NC}                 Show this help"
    echo ""
    echo -e "${BOLD}Paths:${NC}"
    echo "  Framework: $FRAMEWORK_ROOT"
    echo "  Project:   $PROJECT_ROOT"
    echo ""
    echo -e "${BOLD}Examples:${NC}"
    echo "  fw audit"
    echo "  fw context init"
    echo '  fw work-on "Fix login bug" --type build   # One-step: create + focus + start'
    echo "  fw work-on T-042                           # Resume existing task"
    echo '  fw git commit -m "T-033: Add fw CLI wrapper"'
    echo "  fw handover --commit"
    echo "  fw healing diagnose T-015"
    echo '  fw task create --name "Fix bug" --type build --owner human'
    echo "  fw resume status"
    echo "  fw init /path/to/project --provider claude"
    echo "  fw upgrade /path/to/project --dry-run"
    echo "  fw harvest /path/to/project --dry-run"
    echo "  fw deploy scaffold --app my-app --pattern swarm --port-prod 5040 --port-dev 5041"
}

show_version() {
    echo "fw v$FW_VERSION"
    echo "Framework: $FRAMEWORK_ROOT"
    echo "Mode:      $(_detect_fw_mode)"
    echo "Project:   $PROJECT_ROOT"

    # Show .framework.yaml version if present
    if [ -f "$PROJECT_ROOT/.framework.yaml" ]; then
        local pinned
        pinned=$(grep "^version:" "$PROJECT_ROOT/.framework.yaml" 2>/dev/null | sed 's/^version:[[:space:]]*//' || true)
        if [ -n "$pinned" ]; then
            echo "Pinned:    $pinned"
            if [ "$pinned" != "$FW_VERSION" ]; then
                echo -e "${YELLOW}WARNING: Pinned version ($pinned) differs from installed ($FW_VERSION)${NC}"
            fi
        fi
    fi
}

# --- Doctor Command ---

do_doctor() {
    echo -e "${BOLD}fw doctor${NC} - Framework Health Check"
    echo ""

    local issues=0
    local warnings=0
    local host_warnings=0

    # T-1707: host-scope findings can't be fixed from this project session —
    # they live at the machine level (system installs, ~/.gitconfig,
    # ~/.local/bin shims, ~/.claude/settings.json). Tagging them makes the
    # boundary unambiguous so an agent doesn't bundle them into project
    # housekeeping. Project-scope is the default; only host findings carry
    # the [host] prefix and explanatory suffix.
    _doctor_warn_host() {
        echo -e "  ${YELLOW}WARN${NC}  [host] $1"
        echo -e "         (host-level — handle from a session at that root)"
        warnings=$((warnings + 1))
        host_warnings=$((host_warnings + 1))
    }

    # Check 1: Framework directory
    if [ -d "$FRAMEWORK_ROOT/agents" ] && [ -f "$FRAMEWORK_ROOT/FRAMEWORK.md" ]; then
        echo -e "  ${GREEN}OK${NC}  Framework installation"
    else
        echo -e "  ${RED}FAIL${NC}  Framework installation - missing agents/ or FRAMEWORK.md"
        issues=$((issues + 1))
    fi

    # Check 1b (T-1346-B2): Active framework mode disclosure
    local _mode
    _mode=$(_detect_fw_mode)
    case "$_mode" in
        vendored|framework-repo)
            echo -e "  ${GREEN}OK${NC}  Active mode: $_mode ($FRAMEWORK_ROOT)"
            ;;
        global)
            if [ -n "${PROJECT_ROOT:-}" ] && [ -d "$PROJECT_ROOT/.agentic-framework" ]; then
                _doctor_warn_host "Active mode: global ($FRAMEWORK_ROOT) — vendored copy exists at $PROJECT_ROOT/.agentic-framework but was not selected"
            else
                echo -e "  ${CYAN}INFO${NC}  Active mode: global ($FRAMEWORK_ROOT)"
            fi
            ;;
        *)
            _doctor_warn_host "Active mode: $_mode ($FRAMEWORK_ROOT)"
            ;;
    esac

    # Check 2: .framework.yaml (only relevant if PROJECT_ROOT != FRAMEWORK_ROOT)
    if [ "$PROJECT_ROOT" != "$FRAMEWORK_ROOT" ]; then
        if [ -f "$PROJECT_ROOT/.framework.yaml" ]; then
            echo -e "  ${GREEN}OK${NC}  .framework.yaml found"
            local pinned
            pinned=$(grep "^version:" "$PROJECT_ROOT/.framework.yaml" 2>/dev/null | sed 's/^version:[[:space:]]*//' || true)
            if [ -n "$pinned" ] && [ "$pinned" != "$FW_VERSION" ]; then
                echo -e "  ${YELLOW}WARN${NC}  Version mismatch: pinned=$pinned installed=$FW_VERSION"
                warnings=$((warnings + 1))
            fi
            # T-1097/G-028: Reconcile upstream_repo vs running FRAMEWORK_ROOT
            local upstream_repo
            upstream_repo=$(grep "^upstream_repo:" "$PROJECT_ROOT/.framework.yaml" 2>/dev/null | sed 's/^upstream_repo:[[:space:]]*//' || true)
            if [ -n "$upstream_repo" ]; then
                local resolved_upstream resolved_fw
                resolved_upstream=$(realpath -m "$upstream_repo" 2>/dev/null || echo "$upstream_repo")
                resolved_fw=$(realpath -m "$FRAMEWORK_ROOT" 2>/dev/null || echo "$FRAMEWORK_ROOT")
                if [ "$resolved_upstream" != "$resolved_fw" ]; then
                    echo -e "  ${YELLOW}WARN${NC}  Framework path ambiguity:"
                    echo -e "         upstream_repo: $upstream_repo"
                    echo -e "         running fw:    $FRAMEWORK_ROOT"
                    warnings=$((warnings + 1))
                fi
            fi
        else
            echo -e "  ${RED}FAIL${NC}  .framework.yaml missing in $PROJECT_ROOT"
            issues=$((issues + 1))
        fi
    else
        echo -e "  ${CYAN}SKIP${NC}  .framework.yaml (running from framework repo)"
    fi

    # Check 2c (T-1747, G-069): Stray /.framework.yaml at filesystem root.
    # A stray marker at / causes web._discover_project_root to silently resolve
    # PROJECT_ROOT=/ when no env var is set, breaking every Watchtower route
    # that reads project-relative content. Host-scope — handled at machine level.
    if [ -f "/.framework.yaml" ]; then
        _doctor_warn_host "Stray /.framework.yaml at filesystem root — remove it (causes silent PROJECT_ROOT=/ resolution)"
        echo -e "         Run: sudo rm /.framework.yaml"
    fi

    # Check 2b (T-1434): Vendored-source drift — framework repo only.
    # Consumer projects don't have both copies; their .agentic-framework IS the framework.
    # Drift = consumers pulling via `fw upgrade` get stale hooks/libs (T-1432, T-1433).
    if [ "$PROJECT_ROOT" = "$FRAMEWORK_ROOT" ] && [ -d "$FRAMEWORK_ROOT/.agentic-framework" ]; then
        # T-2243: count libs and templates classes separately so each class
        # always gets visibility in the WARN list — pre-T-2243 the global
        # "first 5" bucket let large libs drift bury small templates drift
        # (4-test exploration found 64 libs drifts could mask 1 templates
        # drift entirely). Per-class lists mirror the pre-push gate's
        # "would sync N <class>(s)" shape from T-2240/T-2241.
        local _vdrift_libs=0 _vdrift_tpl=0
        local _vdrift_libs_list="" _vdrift_tpl_list=""
        local _f _vf _rel
        # T-1521: Walk the vendored tree itself, not a hand-curated source glob.
        # T-1434's original glob list missed agents/handover, agents/audit,
        # agents/git, lib/reviewer, web/blueprints — all synced by `fw vendor`.
        # Walking the vendor tree guarantees coverage of whatever vendor put there.
        while IFS= read -r _vf; do
            _rel="${_vf#$FRAMEWORK_ROOT/.agentic-framework/}"
            _f="$FRAMEWORK_ROOT/$_rel"
            [ -f "$_f" ] || continue
            if ! diff -q "$_f" "$_vf" >/dev/null 2>&1; then
                _vdrift_libs=$((_vdrift_libs + 1))
                if [ "$_vdrift_libs" -le 5 ]; then
                    _vdrift_libs_list="$_vdrift_libs_list\n           - $_rel"
                fi
            fi
        done < <(find "$FRAMEWORK_ROOT/.agentic-framework"/{bin,lib,agents,web} -type f \
                    \( -name "*.sh" -o -name "*.py" -o -name "fw" -o -name "*.html" \) \
                    -not -path "*/__pycache__/*" \
                    2>/dev/null)
        # T-2243: templates class (T-2241 sibling). _self_vendor_templates()
        # syncs .tasks/templates/*.md into .agentic-framework/.tasks/templates/.
        # Separate find — adding `.md` to the libs find pattern above would
        # also flag lib/templates/*.md and agents/*/AGENT.md files, which
        # aren't part of the self-vendor scope.
        if [ -d "$FRAMEWORK_ROOT/.agentic-framework/.tasks/templates" ]; then
            while IFS= read -r _vf; do
                _rel="${_vf#$FRAMEWORK_ROOT/.agentic-framework/}"
                _f="$FRAMEWORK_ROOT/$_rel"
                [ -f "$_f" ] || continue
                if ! diff -q "$_f" "$_vf" >/dev/null 2>&1; then
                    _vdrift_tpl=$((_vdrift_tpl + 1))
                    if [ "$_vdrift_tpl" -le 5 ]; then
                        _vdrift_tpl_list="$_vdrift_tpl_list\n           - $_rel"
                    fi
                fi
            done < <(find "$FRAMEWORK_ROOT/.agentic-framework/.tasks/templates" -type f -name "*.md" 2>/dev/null)
        fi
        local _vdrift=$((_vdrift_libs + _vdrift_tpl))
        if [ "$_vdrift" -eq 0 ]; then
            echo -e "  ${GREEN}OK${NC}  No vendored-source drift"
        else
            echo -e "  ${YELLOW}WARN${NC}  Vendored-source drift: $_vdrift file(s) out of sync"
            echo -e "         Run: fw vendor  (sync .agentic-framework/ with source)"
            if [ "$_vdrift_libs" -gt 0 ]; then
                echo -e "         libs ($_vdrift_libs) — first $([ $_vdrift_libs -gt 5 ] && echo 5 || echo $_vdrift_libs):$_vdrift_libs_list"
            fi
            if [ "$_vdrift_tpl" -gt 0 ]; then
                echo -e "         templates ($_vdrift_tpl) — first $([ $_vdrift_tpl -gt 5 ] && echo 5 || echo $_vdrift_tpl):$_vdrift_tpl_list"
            fi
            warnings=$((warnings + 1))
        fi
    fi

    # Check 3: Task directories
    if [ -d "$PROJECT_ROOT/.tasks/active" ] && [ -d "$PROJECT_ROOT/.tasks/completed" ]; then
        echo -e "  ${GREEN}OK${NC}  Task directories"
    else
        echo -e "  ${RED}FAIL${NC}  Task directories missing (.tasks/active, .tasks/completed)"
        issues=$((issues + 1))
    fi

    # Check 4: Context directories
    if [ -d "$PROJECT_ROOT/.context" ]; then
        echo -e "  ${GREEN}OK${NC}  Context directory"
    else
        echo -e "  ${YELLOW}WARN${NC}  Context directory missing (.context/)"
        warnings=$((warnings + 1))
    fi

    # Check 5: Git hooks
    local hooks_dir="$PROJECT_ROOT/.git/hooks"
    if [ -f "$hooks_dir/commit-msg" ] && grep -q "Task Reference" "$hooks_dir/commit-msg" 2>/dev/null; then
        echo -e "  ${GREEN}OK${NC}  Git commit-msg hook"
    else
        echo -e "  ${YELLOW}WARN${NC}  Git commit-msg hook not installed (run: fw git install-hooks)"
        warnings=$((warnings + 1))
    fi

    if [ -f "$hooks_dir/pre-push" ] && grep -q "audit" "$hooks_dir/pre-push" 2>/dev/null; then
        echo -e "  ${GREEN}OK${NC}  Git pre-push hook"
    else
        echo -e "  ${YELLOW}WARN${NC}  Git pre-push hook not installed (run: fw git install-hooks)"
        warnings=$((warnings + 1))
    fi

    # T-1845: Large-file gate — surface tracked-file bloat. The pre-commit
    # gate blocks new instances; this audit catches anything that slipped past
    # (legacy state, --no-verify bypasses, or files committed before the gate
    # existed). Block-level findings count as warnings here; the agent should
    # cleanup + gitignore, not let scan-tree FAIL gate the doctor.
    local lf_scanner="$FRAMEWORK_ROOT/agents/git/lib/large-file-scan.sh"
    if [ -x "$lf_scanner" ]; then
        local lf_out lf_blocks lf_warns
        lf_out=$(PROJECT_ROOT="$PROJECT_ROOT" "$lf_scanner" scan-tree 2>&1 || true)
        lf_blocks=$(echo "$lf_out" | grep -c "\[BLOCK\]" || true)
        lf_warns=$(echo "$lf_out" | grep -c "\[WARN\]"  || true)
        if [ "$lf_blocks" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Large-file gate: $lf_blocks tracked file(s) above block threshold"
            echo -e "         Run: bin/fw doctor large-files   (or untrack + .gitignore)"
            warnings=$((warnings + 1))
        elif [ "$lf_warns" -gt 0 ]; then
            echo -e "  ${CYAN}INFO${NC}  Large-file gate: $lf_warns tracked file(s) above warn threshold (under block)"
        else
            echo -e "  ${GREEN}OK${NC}  Large-file gate: no tracked files above warn threshold"
        fi
    fi

    # Check 5b: Git user identity (T-685)
    local git_email git_name
    git_email=$(git -C "$PROJECT_ROOT" config user.email 2>/dev/null || true)
    git_name=$(git -C "$PROJECT_ROOT" config user.name 2>/dev/null || true)
    if [ -z "$git_email" ] || [ -z "$git_name" ]; then
        _doctor_warn_host "Git user identity not configured (commits will fail)"
        echo -e "         Run: git config --global user.email 'you@example.com' && git config --global user.name 'Your Name'"
    fi

    # Check 6: Claude Code hooks — validate paths resolve (G-021 path isolation)
    local settings_file="$PROJECT_ROOT/.claude/settings.json"
    if [ -f "$settings_file" ]; then
        local hook_validation
        hook_validation=$(python3 -c "
import json, os, shutil
with open('$settings_file') as f:
    data = json.load(f)
total = 0
broken = 0
stale_paths = 0
for event, entries in data.get('hooks', {}).items():
    for entry in entries:
        for hook in entry.get('hooks', []):
            cmd = hook.get('command', '')
            total += 1
            parts = cmd.split()
            # Check for portable 'fw hook' format (bare fw in PATH)
            if len(parts) >= 2 and parts[0] == 'fw':
                if shutil.which('fw'):
                    continue  # fw hook — portable, OK
                else:
                    broken += 1
                    continue
            # T-1222: vendored hook format (.agentic-framework/bin/fw hook or bin/fw hook)
            if len(parts) >= 2 and parts[0].endswith('/bin/fw'):
                fw_path = os.path.join('$PROJECT_ROOT', parts[0])
                if os.path.isfile(fw_path) and os.access(fw_path, os.X_OK):
                    continue  # vendored fw hook — OK
                else:
                    broken += 1
                    continue
            # Old-style: absolute path — check if executable exists
            exe = None
            for p in parts:
                if '=' not in p:
                    exe = p
                    break
            if exe and not (os.path.isfile(exe) and os.access(exe, os.X_OK)):
                if not shutil.which(exe or ''):
                    broken += 1
            # Check for hardcoded absolute paths (path isolation violation)
            if '/agents/context/' in cmd or 'PROJECT_ROOT=' in cmd:
                stale_paths += 1
print(f'{total},{broken},{stale_paths}')
" 2>/dev/null || echo "0,0,0")
        local hook_total hook_broken hook_stale
        hook_total=$(echo "$hook_validation" | cut -d, -f1)
        hook_broken=$(echo "$hook_validation" | cut -d, -f2)
        hook_stale=$(echo "$hook_validation" | cut -d, -f3)
        if [ "${hook_broken:-0}" -gt 0 ]; then
            echo -e "  ${RED}FAIL${NC}  Hook path validation: $hook_broken/$hook_total hooks have broken paths"
            echo -e "        Run: fw upgrade (regenerates hooks with portable paths)"
            issues=$((issues + 1))
        elif [ "${hook_stale:-0}" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Hook path isolation: $hook_stale/$hook_total hooks use hardcoded paths (works here, breaks on clone)"
            echo -e "        Run: fw upgrade (migrates to portable 'fw hook' format)"
            warnings=$((warnings + 1))
        elif [ "${hook_total:-0}" -ge 10 ]; then
            echo -e "  ${GREEN}OK${NC}  Hook path validation: $hook_total hooks, all portable"
        else
            echo -e "  ${YELLOW}WARN${NC}  Only $hook_total hooks configured (expected 11+)"
            warnings=$((warnings + 1))
        fi

        # T-1480: Duplicate framework hook scan (root cause of OBS-023).
        # Mirrors T-1479's check from `fw upgrade` so users see it on every
        # `fw doctor` run, not only when upgrading. Read-only — surfaces
        # the overlap; remediation stays the user's call.
        local user_settings_file="$HOME/.claude/settings.json"
        if [ -f "$user_settings_file" ]; then
            local dup_pairs
            dup_pairs=$(USER_FILE="$user_settings_file" PROJ_FILE="$settings_file" python3 -c "
import json, os
def fw_hooks(path):
    out = set()
    try:
        with open(path) as f:
            data = json.load(f)
    except (json.JSONDecodeError, FileNotFoundError, OSError):
        return out
    for event, entries in data.get('hooks', {}).items():
        for entry in entries:
            for hook in entry.get('hooks', []):
                cmd = hook.get('command', '')
                if 'fw hook' in cmd:
                    name = cmd.split('fw hook ')[-1].strip().split()[0]
                elif '.agentic-framework' in cmd:
                    name = cmd.strip().split('/')[-1]
                else:
                    continue
                out.add((event, name))
    return out
user = fw_hooks(os.environ['USER_FILE'])
proj = fw_hooks(os.environ['PROJ_FILE'])
overlap = sorted(user & proj)
print('|'.join(f'{e}:{n}' for e, n in overlap))
" 2>/dev/null || echo "")
            if [ -n "$dup_pairs" ]; then
                local dup_count
                dup_count=$(echo "$dup_pairs" | tr '|' '\n' | wc -l)
                _doctor_warn_host "Duplicate framework hook(s) in $user_settings_file: $dup_count overlap"
                echo -e "         Pairs: $(echo "$dup_pairs" | tr '|' ' ')"
                echo -e "         Both fire on every Claude Code event (cause of OBS-023). Recommend removing duplicates from $user_settings_file."
            fi
        fi
    else
        echo -e "  ${YELLOW}WARN${NC}  No .claude/settings.json found (run: fw init)"
        warnings=$((warnings + 1))
    fi

    # Check 6c: Hook exercise from /tmp (T-1629, B-3a of T-1626).
    # Active probe — runs each configured hook from /tmp (foreign CWD that
    # mimics agent cd-drift) and reports any whose path doesn't resolve.
    # Closes the T-1626 witness scenario: bare-relative paths that "exist"
    # relative to project root but break under cd. Check 6 above is static.
    # L-332: helper lives in lib/doctor-hook-exercise.py, NOT inline heredoc
    # — heredoc-in-$() inside bin/fw is unrecoverable on parse error.
    local exercise_script="$FW_LIB_DIR/doctor-hook-exercise.py"
    if [ -f "$settings_file" ] && [ -f "$exercise_script" ]; then
        local hook_exercise
        hook_exercise=$(SETTINGS_FILE="$settings_file" python3 "$exercise_script" 2>/dev/null)
        if [ -n "$hook_exercise" ]; then
            local hex_total hex_fail
            hex_total=$(echo "$hook_exercise" | head -1 | cut -d'|' -f1)
            hex_fail=$(echo "$hook_exercise" | head -1 | cut -d'|' -f2)
            if [ "${hex_fail:-0}" -gt 0 ]; then
                echo -e "  ${RED}FAIL${NC}  Hook exercise from /tmp: $hex_fail/$hex_total hook(s) failed to resolve"
                echo "$hook_exercise" | tail -n +2 | while IFS='|' read -r _ event tag reason; do
                    echo -e "        ${RED}x${NC} $event/$tag - $reason"
                done
                echo -e "        Run: fw upgrade (regenerates hook paths to absolute form)"
                issues=$((issues + 1))
            elif [ "${hex_total:-0}" -gt 0 ]; then
                echo -e "  ${GREEN}OK${NC}  Hook exercise from /tmp: $hex_total hook(s) resolve from foreign CWD"
            fi
        fi
    fi

    # Check 7: Agent scripts executable
    local agents_ok=true
    for agent in audit/audit.sh context/context.sh git/git.sh handover/handover.sh healing/healing.sh resume/resume.sh task-create/create-task.sh; do
        if [ ! -x "$AGENTS_DIR/$agent" ]; then
            echo -e "  ${RED}FAIL${NC}  Agent not executable: $agent"
            agents_ok=false
            issues=$((issues + 1))
        fi
    done
    if [ "$agents_ok" = true ]; then
        echo -e "  ${GREEN}OK${NC}  All agent scripts executable"
    fi

    # Check 8: Plugin task-awareness
    if [ -x "$AGENTS_DIR/audit/plugin-audit.sh" ]; then
        local bypassing_count
        bypassing_count=$("$AGENTS_DIR/audit/plugin-audit.sh" --doctor-check 2>/dev/null) || true
        if [ "${bypassing_count:-0}" = "0" ]; then
            echo -e "  ${GREEN}OK${NC}  Plugin task-awareness"
        else
            echo -e "  ${YELLOW}WARN${NC}  $bypassing_count plugin skill(s) may bypass task-first rule (run: fw plugin-audit)"
            warnings=$((warnings + 1))
        fi
    else
        echo -e "  ${CYAN}SKIP${NC}  Plugin task-awareness (plugin-audit.sh not found)"
    fi

    # Check 9: Test infrastructure
    # T-574: Skip framework-development checks on consumer projects
    if [ "$FRAMEWORK_ROOT" = "$PROJECT_ROOT" ]; then
        if command -v bats >/dev/null 2>&1; then
            if [ -d "$FRAMEWORK_ROOT/tests/unit" ] && ls "$FRAMEWORK_ROOT/tests/unit/"*.bats >/dev/null 2>&1; then
                local test_count
                test_count=$(bats --count "$FRAMEWORK_ROOT/tests/unit/" 2>/dev/null || echo "0")
                echo -e "  ${GREEN}OK${NC}  Test infrastructure (bats installed, $test_count unit tests)"
            else
                echo -e "  ${YELLOW}WARN${NC}  bats installed but no unit tests found in tests/unit/"
                warnings=$((warnings + 1))
            fi
        else
            _doctor_warn_host "bats not installed (run: git clone https://github.com/bats-core/bats-core && ./install.sh /usr/local)"
        fi

        if command -v shellcheck >/dev/null 2>&1; then
            echo -e "  ${GREEN}OK${NC}  ShellCheck linter"
        else
            _doctor_warn_host "shellcheck not installed (run: apt install shellcheck  # or brew install shellcheck)"
        fi
    else
        echo -e "  ${CYAN}SKIP${NC}  Test infrastructure (consumer project — tests live in framework repo)"
        echo -e "  ${CYAN}SKIP${NC}  ShellCheck linter (consumer project)"
    fi

    # Check N: Orphaned MCP processes (T-180)
    local orphan_count
    orphan_count=$(ps -eo pid,ppid,args 2>/dev/null | \
                   awk '$2 == 1' | \
                   grep -cE "(npm exec.*(mcp|context7|playwright))|(node .*(mcp|context7))" || echo 0)
    orphan_count=$(echo "$orphan_count" | tr -d '[:space:]')
    if [ "$orphan_count" -gt 0 ]; then
        _doctor_warn_host "$orphan_count orphaned MCP process(es) detected — run 'fw mcp reap'"
    else
        echo -e "  ${GREEN}OK${NC}  No orphaned MCP processes"
    fi

    # Check: .mcp.json configuration (T-646, T-1354: servers under `mcpServers` key)
    local mcp_json="$PROJECT_ROOT/.mcp.json"
    if [ -f "$mcp_json" ]; then
        local mcp_check
        mcp_check=$(python3 -c "
import json
with open('$mcp_json') as f:
    data = json.load(f)
servers = data.get('mcpServers') if isinstance(data.get('mcpServers'), dict) else data
if not isinstance(servers, dict):
    servers = {}
total = len(servers)
has_context7 = 'context7' in servers
has_playwright = 'playwright' in servers
missing = []
if not has_context7: missing.append('context7')
if not has_playwright: missing.append('playwright')
print(f'{total}|{\",\".join(missing)}')
" 2>/dev/null || echo "0|parse-error")
        local mcp_total mcp_missing
        mcp_total=$(echo "$mcp_check" | cut -d'|' -f1)
        mcp_missing=$(echo "$mcp_check" | cut -d'|' -f2)
        if [ -n "$mcp_missing" ] && [ "$mcp_missing" != "parse-error" ]; then
            echo -e "  ${YELLOW}WARN${NC}  .mcp.json missing recommended servers: $mcp_missing (run: fw upgrade)"
            warnings=$((warnings + 1))
        elif [ "$mcp_missing" = "parse-error" ]; then
            echo -e "  ${YELLOW}WARN${NC}  .mcp.json exists but failed to parse"
            warnings=$((warnings + 1))
        else
            echo -e "  ${GREEN}OK${NC}  .mcp.json ($mcp_total MCP server(s) configured)"
        fi
    else
        echo -e "  ${YELLOW}WARN${NC}  No .mcp.json found (run: fw upgrade to create)"
        warnings=$((warnings + 1))
    fi

    # Check: Playwright (T-1725) — two surfaces, both essential for UI work
    #   1. pip package (pytest-playwright) — drives fw test playwright / regression suite
    #   2. MCP server (@playwright/mcp) — drives agent UI verification via Claude Code
    # MCP is auto-managed by fw upgrade; pip needs manual install.
    if python3 -c "import playwright" 2>/dev/null; then
        echo -e "  ${GREEN}OK${NC}  Playwright pip package (test runner)"
    else
        echo -e "  ${YELLOW}WARN${NC}  Playwright pip package missing — UI regression tests can't run"
        echo -e "         Install: pip install playwright pytest-playwright && playwright install chromium"
        warnings=$((warnings + 1))
    fi
    if [ -f "$mcp_json" ] && grep -q '"playwright"' "$mcp_json" 2>/dev/null; then
        echo -e "  ${GREEN}OK${NC}  Playwright MCP server (agent UI verification)"
    else
        echo -e "  ${YELLOW}WARN${NC}  Playwright MCP server not in .mcp.json"
        echo -e "         Run: fw upgrade  # auto-adds the MCP block"
        warnings=$((warnings + 1))
    fi

    # Check: Global install deprecation (T-666/T-662)
    local global_dir="$HOME/.agentic-framework"
    local local_fw="$HOME/.local/bin/fw"
    if [ -d "$global_dir" ]; then
        if [ -L "$local_fw" ]; then
            local link_target
            link_target=$(readlink -f "$local_fw" 2>/dev/null || echo "")
            if [[ "$link_target" == *".agentic-framework/bin/fw"* ]]; then
                _doctor_warn_host "Global install: ~/.local/bin/fw symlinks to stale $global_dir"
                echo -e "         Migrate: run install.sh or fw upgrade (installs project-detecting shim)"
            else
                echo -e "  ${GREEN}OK${NC}  fw shim installed (global install at $global_dir is unused)"
            fi
        elif [ -f "$local_fw" ] && grep -q 'find_fw' "$local_fw" 2>/dev/null; then
            echo -e "  ${GREEN}OK${NC}  fw shim installed (global install at $global_dir can be removed)"
        else
            echo -e "  ${GREEN}OK${NC}  No global install dependency detected"
        fi
    else
        echo -e "  ${GREEN}OK${NC}  No global install at $global_dir"
    fi

    # Check: status-transitions.yaml integrity (T-588)
    local transitions_yaml="$FRAMEWORK_ROOT/status-transitions.yaml"
    if [ -f "$transitions_yaml" ]; then
        local trans_check
        trans_check=$(python3 -c "
import yaml, sys
d = yaml.safe_load(open('$transitions_yaml'))
active = set(d.get('statuses', {}).get('active', []))
legacy = set(d.get('statuses', {}).get('legacy', []))
all_statuses = active | legacy
transitions = d.get('transitions', [])
issues = []
for t in transitions:
    if t['from'] not in all_statuses:
        issues.append('orphaned from: %s' % t['from'])
    if t['to'] not in all_statuses:
        issues.append('orphaned to: %s' % t['to'])
# Check unreachable: statuses with no inbound AND no outbound
froms = {t['from'] for t in transitions}
tos = {t['to'] for t in transitions}
for s in all_statuses:
    if s not in froms and s not in tos:
        issues.append('unreachable: %s' % s)
if issues:
    print('|'.join(issues))
else:
    print('OK|%d' % len(transitions))
" 2>/dev/null || echo "parse-error")
        if [ "$trans_check" = "parse-error" ]; then
            echo -e "  ${YELLOW}WARN${NC}  status-transitions.yaml failed to parse"
            warnings=$((warnings + 1))
        elif [[ "$trans_check" == OK* ]]; then
            local trans_count
            trans_count=$(echo "$trans_check" | cut -d'|' -f2)
            echo -e "  ${GREEN}OK${NC}  status-transitions.yaml ($trans_count transitions, no orphaned/unreachable states)"
        else
            echo -e "  ${YELLOW}WARN${NC}  status-transitions.yaml integrity: $trans_check"
            warnings=$((warnings + 1))
        fi
    fi

    # Check: framework MCP manifest (T-2265, arc-010 Slice 2)
    local _mcp_mf="$PROJECT_ROOT/agents/mcp/framework-mcp-manifest.json"
    local _mcp_ts="$PROJECT_ROOT/policy/capability-overlay/tool-set.yaml"
    if [ -f "$_mcp_mf" ]; then
        local _mcp_tools _mcp_gated _mcp_msg
        _mcp_tools=$(python3 -c "import json; m=json.load(open('$_mcp_mf')); print(len(m.get('tools',[])))" 2>/dev/null || echo "?")
        _mcp_gated=$(python3 -c "import json; m=json.load(open('$_mcp_mf')); print(sum(1 for t in m.get('tools',[]) if t.get('gated')))" 2>/dev/null || echo "?")
        local _mcp_pid_f="$PROJECT_ROOT/.context/working/framework-mcp.pid"
        if [ -f "$_mcp_pid_f" ] && kill -0 "$(cat "$_mcp_pid_f" 2>/dev/null)" 2>/dev/null; then
            _mcp_msg="running (pid $(cat "$_mcp_pid_f"))"
        else
            _mcp_msg="stopped (manifest ready; start: fw mcp start --background)"
        fi
        # T-2290: mtime is a cheap fast-path that triggers a content check.
        # Touch/checkout/vendor-sync update mtime without changing content; the
        # mtime-only test produced operator-facing WARN false positives. When
        # mtime suggests staleness, re-emit the manifest in-memory and md5 it
        # against the on-disk file — content match → still OK, content diff → WARN.
        if [ -f "$_mcp_ts" ] && [ "$_mcp_ts" -nt "$_mcp_mf" ]; then
            local _mcp_emitted_md5 _mcp_ondisk_md5
            _mcp_emitted_md5=$(python3 "$PROJECT_ROOT/agents/mcp/manifest.py" show 2>/dev/null | md5sum 2>/dev/null | cut -d' ' -f1)
            _mcp_ondisk_md5=$(md5sum "$_mcp_mf" 2>/dev/null | cut -d' ' -f1)
            if [ -n "$_mcp_emitted_md5" ] && [ "$_mcp_emitted_md5" = "$_mcp_ondisk_md5" ]; then
                echo -e "  ${GREEN}OK${NC}  framework MCP $_mcp_tools tools (gated: $_mcp_gated) — $_mcp_msg"
            else
                echo -e "  ${YELLOW}WARN${NC}  framework MCP manifest stale relative to tool-set.yaml"
                echo -e "         Run: fw mcp emit-manifest"
                warnings=$((warnings + 1))
            fi
        else
            echo -e "  ${GREEN}OK${NC}  framework MCP $_mcp_tools tools (gated: $_mcp_gated) — $_mcp_msg"
        fi
    else
        echo -e "  ${CYAN}SKIP${NC}  framework MCP manifest absent — run: fw mcp emit-manifest"
    fi

    # Check: Watchtower discovery triple (T-1292 / T-1284 B4)
    local _wt_pid_f="$PROJECT_ROOT/.context/working/watchtower.pid"
    local _wt_url_f="$PROJECT_ROOT/.context/working/watchtower.url"
    if [ -f "$_wt_pid_f" ] || [ -f "$_wt_url_f" ]; then
        local _wt_pid _wt_url
        _wt_pid=$(cat "$_wt_pid_f" 2>/dev/null | tr -d '[:space:]')
        _wt_url=$(cat "$_wt_url_f" 2>/dev/null | tr -d '[:space:]')
        if [ -n "$_wt_pid" ] && kill -0 "$_wt_pid" 2>/dev/null && [ -n "$_wt_url" ]; then
            local _wt_ident
            _wt_ident=$(curl -sf --max-time 2 "$_wt_url/api/_identity" 2>/dev/null || echo "")
            if [ -n "$_wt_ident" ] && echo "$_wt_ident" | grep -q "\"service\"[[:space:]]*:[[:space:]]*\"watchtower\"" \
                && echo "$_wt_ident" | grep -q "\"project_root\"[[:space:]]*:[[:space:]]*\"$PROJECT_ROOT\""; then
                echo -e "  ${GREEN}OK${NC}  Watchtower running ($_wt_url, pid $_wt_pid)"
            else
                echo -e "  ${YELLOW}WARN${NC}  Watchtower triple present but identity mismatch or endpoint down"
                echo -e "         Run: bin/watchtower.sh restart"
                warnings=$((warnings + 1))
            fi
        else
            echo -e "  ${YELLOW}WARN${NC}  Stale Watchtower triple (pid $_wt_pid not running)"
            echo -e "         Run: bin/watchtower.sh stop && bin/watchtower.sh start"
            warnings=$((warnings + 1))
        fi
    else
        echo -e "  ${CYAN}SKIP${NC}  Watchtower not running (no triple) — start with: fw serve"
    fi

    # Check: Claude Code hook configuration (CTL-R023 — T-199)
    if [ -f "$settings_file" ]; then
        local hook_result
        hook_result=$(python3 -c "
import json, os, sys

issues = []
warnings = []

try:
    with open('$settings_file') as f:
        data = json.load(f)
except json.JSONDecodeError as e:
    print(f'FAIL|settings.json is not valid JSON: {e}')
    sys.exit(0)

hooks = data.get('hooks', {})
if not hooks:
    print('WARN|No hooks configured in settings.json')
    sys.exit(0)

# Expected hooks (script basename -> event type)
expected = {
    'check-active-task.sh': 'PreToolUse',
    'check-tier0.sh': 'PreToolUse',
    'check-project-boundary.sh': 'PreToolUse',
    'budget-gate.sh': 'PreToolUse',
    'checkpoint.sh': 'PostToolUse',
    'error-watchdog.sh': 'PostToolUse',
}
found = set()

for event_type, hook_list in hooks.items():
    if not isinstance(hook_list, list):
        issues.append(f'{event_type} value is not a list (flat format silently fails)')
        continue
    for i, entry in enumerate(hook_list):
        if not isinstance(entry, dict):
            issues.append(f'{event_type}[{i}] is not an object')
            continue
        # Check nested structure
        inner_hooks = entry.get('hooks', [])
        if not inner_hooks:
            issues.append(f'{event_type}[{i}] missing inner hooks array (will silently not fire)')
            continue
        for j, cmd in enumerate(inner_hooks):
            command = cmd.get('command', '')
            if not command:
                issues.append(f'{event_type}[{i}].hooks[{j}] missing command')
                continue
            # Check script exists (skip env var assignments like PROJECT_ROOT=...)
            parts = command.split()
            script_path = parts[0]
            for p in parts:
                if '=' not in p:
                    script_path = p
                    break
            # Portable 'fw hook' commands — resolve via PATH or vendored path
            # T-573: Also recognize .agentic-framework/bin/fw and other paths ending in /fw
            is_fw = (script_path == 'fw' or script_path.endswith('/fw'))
            if is_fw and 'hook' in parts:
                hook_idx = parts.index('hook')
                # Resolve fw binary: bare 'fw' uses PATH, full path checked directly
                if script_path == 'fw':
                    import shutil
                    if not shutil.which('fw'):
                        issues.append(f'{event_type}: fw not found in PATH (install framework or add to PATH)')
                        continue
                else:
                    # Resolve relative paths against PROJECT_ROOT
                    resolved = script_path
                    if not os.path.isabs(resolved):
                        resolved = os.path.join(os.environ.get('PROJECT_ROOT', ''), resolved)
                    if not os.path.exists(resolved):
                        issues.append(f'{event_type}: fw binary not found: {script_path}')
                        continue
                # Map fw hook name to expected script for tracking
                hook_name = parts[hook_idx + 1] if len(parts) > hook_idx + 1 else 'unknown'
                basename = hook_name + '.sh'
                if basename in expected:
                    found.add(basename)
                continue
            if not os.path.exists(script_path):
                if '/Cellar/' in script_path:
                    issues.append(f'{event_type}: stale Homebrew Cellar path (run fw init --provider claude --force to fix): {os.path.basename(script_path)}')
                else:
                    issues.append(f'{event_type}: script not found: {os.path.basename(script_path)}')
            elif not os.access(script_path, os.X_OK):
                warnings.append(f'{event_type}: script not executable: {os.path.basename(script_path)}')
            elif '/Cellar/' in script_path:
                warnings.append(f'Hook uses Cellar path (will break on brew upgrade). Run: fw init --provider claude --force')
            # Track expected hooks
            basename = os.path.basename(script_path)
            if basename in expected:
                found.add(basename)

# Check expected hooks present
for script, event in expected.items():
    if script not in found:
        warnings.append(f'Expected hook {script} ({event}) not found')

if issues:
    print('FAIL|' + '; '.join(issues))
elif warnings:
    print('WARN|' + '; '.join(warnings))
else:
    print(f'OK|Hook configuration valid ({sum(len(v) for v in hooks.values())} hooks across {len(hooks)} events)')
" 2>/dev/null || echo "WARN|Python3 not available for hook validation")
        local hook_status hook_msg
        hook_status=$(echo "$hook_result" | cut -d'|' -f1)
        hook_msg=$(echo "$hook_result" | cut -d'|' -f2-)
        case "$hook_status" in
            OK)   echo -e "  ${GREEN}OK${NC}  $hook_msg" ;;
            WARN) echo -e "  ${YELLOW}WARN${NC}  Hook config: $hook_msg"; warnings=$((warnings + 1)) ;;
            FAIL) echo -e "  ${RED}FAIL${NC}  Hook config: $hook_msg"; issues=$((issues + 1)) ;;
        esac
    else
        echo -e "  ${YELLOW}WARN${NC}  No .claude/settings.json — Claude Code hooks not configured"
        warnings=$((warnings + 1))
    fi

    # Check: Watchtower smoke test (T-486) — run if server is reachable
    # Use _watchtower_url for port detection (T-1154: no hardcoded 3000)
    source "$FRAMEWORK_ROOT/lib/watchtower.sh" 2>/dev/null || true
    local _doctor_wt_url _doctor_wt_port
    _doctor_wt_url=$(_watchtower_url 2>/dev/null || echo "http://localhost:${FW_PORT:-3000}")
    _doctor_wt_port=$(echo "$_doctor_wt_url" | grep -oP ':\K\d+$' || echo "${FW_PORT:-3000}")
    if curl -sf "${_doctor_wt_url}/health" >/dev/null 2>&1; then
        local smoke_result
        smoke_result=$(python3 "$FRAMEWORK_ROOT/web/smoke_test.py" --port "$_doctor_wt_port" --json 2>/dev/null || echo '{"failed":0,"passed":0,"total":0,"error":"script failed"}')
        local smoke_passed smoke_failed smoke_total
        smoke_passed=$(echo "$smoke_result" | python3 -c "import sys,json;print(json.load(sys.stdin).get('passed',0))" 2>/dev/null || echo 0)
        smoke_failed=$(echo "$smoke_result" | python3 -c "import sys,json;print(json.load(sys.stdin).get('failed',0))" 2>/dev/null || echo 0)
        smoke_total=$(echo "$smoke_result" | python3 -c "import sys,json;print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo 0)
        if [ "${smoke_failed:-0}" = "0" ] && [ "${smoke_passed:-0}" -gt 0 ]; then
            echo -e "  ${GREEN}OK${NC}  Watchtower smoke test ($smoke_passed/$smoke_total endpoints)"
        elif [ "${smoke_failed:-0}" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Watchtower smoke: $smoke_failed/$smoke_total endpoints failed"
            local smoke_errors
            smoke_errors=$(echo "$smoke_result" | python3 -c "
import sys,json
d=json.load(sys.stdin)
for e in d.get('errors',[])[:3]:
    print(f\"         {e['path']}: {e.get('error','?')}\")
" 2>/dev/null || true)
            [ -n "$smoke_errors" ] && echo "$smoke_errors"
            warnings=$((warnings + 1))
        fi
    fi

    # Check: TermLink (optional cross-terminal session communication)
    if command -v termlink >/dev/null 2>&1; then
        local tl_version
        tl_version=$(termlink --version 2>/dev/null | head -1)
        echo -e "  ${GREEN}OK${NC}  TermLink ($tl_version)"
    else
        _doctor_warn_host "TermLink not installed (cargo install termlink)"
    fi

    # Check: pi (Pi mono coding-agent — optional, ADR-0003 worker_kind=pi)
    # T-1694 (Q13): warn-only, no auto-install. Operator runs the install
    # command consciously if any workflow declares worker_kind: pi.
    if command -v pi >/dev/null 2>&1; then
        local pi_version
        pi_version=$(pi --version 2>/dev/null | head -1 || echo "(version unknown)")
        echo -e "  ${GREEN}OK${NC}  pi ($pi_version)"
    else
        # Only warn if some workflow uses worker_kind: pi
        if [ -d "$PROJECT_ROOT/.context/project/workflows" ] && \
           grep -lE "^worker_kind:\s*pi\b" "$PROJECT_ROOT/.context/project/workflows/"*.yaml >/dev/null 2>&1; then
            _doctor_warn_host "pi not installed; workflows declaring worker_kind: pi will fail"
            echo -e "         Install: npm install -g @badlogic/pi-mono  (or see github.com/badlogic/pi-mono)"
        else
            echo -e "  ${CYAN}INFO${NC}  pi not installed (no workflows require it)"
        fi
    fi

    # Check: litellm proxy reachable (T-1700 v1 ollama dispatch substrate).
    # Skip-if-no-consumer: only fires when a workflow declares
    # ANTHROPIC_BASE_URL: http://localhost:4000 (the litellm proxy port).
    # Mirrors T-1694 pi pattern.
    local _wf_dir="$PROJECT_ROOT/.context/project/workflows"
    if [ -d "$_wf_dir" ] && \
       grep -lE "ANTHROPIC_BASE_URL:\s*http://localhost:4000" "$_wf_dir"/*.yaml >/dev/null 2>&1; then
        if curl -sf --max-time 2 http://localhost:4000/health >/dev/null 2>&1; then
            echo -e "  ${GREEN}OK${NC}  litellm-proxy reachable (http://localhost:4000)"
        else
            _doctor_warn_host "litellm-proxy not reachable on http://localhost:4000; workflow(s) declaring ANTHROPIC_BASE_URL will fail"
            echo -e "         Start: pipx run litellm --config .context/litellm-config.yaml --port 4000  (or systemd unit)"
        fi
    fi

    # Check: ollama reachable (T-1700 v1 ollama dispatch substrate).
    # Skip-if-no-consumer: only fires when a workflow declares
    # worker_kind: ollama-loop. Hard-codes the upstream URL the workflows
    # currently target (192.168.10.107:11434). If a workflow points
    # elsewhere, this check needs widening.
    if [ -d "$_wf_dir" ] && \
       grep -lE "^worker_kind:\s*ollama-loop\b" "$_wf_dir"/*.yaml >/dev/null 2>&1; then
        if curl -sf --max-time 2 http://192.168.10.107:11434/api/tags >/dev/null 2>&1; then
            echo -e "  ${GREEN}OK${NC}  ollama reachable (http://192.168.10.107:11434)"
        else
            _doctor_warn_host "ollama not reachable on http://192.168.10.107:11434; ollama-loop workflows will fail"
        fi
    fi

    # Check: TypeScript build health (optional)
    if [ -d "$FRAMEWORK_ROOT/lib/ts/src" ]; then
        shopt -s nullglob
        local _ts_files=("$FRAMEWORK_ROOT/lib/ts/src/"*.ts)
        shopt -u nullglob
        if [ ${#_ts_files[@]} -eq 0 ]; then
            echo -e "  ${GREEN}OK${NC}  TypeScript build (no sources yet)"
        elif command -v node >/dev/null 2>&1; then
            local _ts_stale=0
            for _ts_src in "${_ts_files[@]}"; do
                local _ts_out
                _ts_out="$FRAMEWORK_ROOT/lib/ts/dist/$(basename "${_ts_src%.ts}.js")"
                if [ ! -f "$_ts_out" ] || [ "$_ts_src" -nt "$_ts_out" ]; then
                    _ts_stale=1
                    break
                fi
            done
            if [ "$_ts_stale" -eq 1 ]; then
                echo -e "  ${YELLOW}WARN${NC}  TypeScript build stale (run 'fw build')"
                warnings=$((warnings + 1))
            else
                echo -e "  ${GREEN}OK${NC}  TypeScript build (${#_ts_files[@]} source(s))"
            fi
        else
            _doctor_warn_host "Node.js not found (TS hooks will use Python fallback)"
        fi
    fi

    # Enforcement matcher coverage: G-012 closed as accepted risk (no notebooks in project)

    # Check: Enforcement baseline integrity (B-005 — T-230)
    local baseline_file="$PROJECT_ROOT/.context/project/enforcement-baseline.sha256"
    if [ -f "$baseline_file" ] && [ -f "$settings_file" ]; then
        local stored_hash current_hash
        stored_hash=$(tr -d '[:space:]' < "$baseline_file" 2>/dev/null)
        current_hash=$(python3 -c "
import json, hashlib
with open('$settings_file') as f:
    data = json.load(f)
hooks_str = json.dumps(data.get('hooks', {}), sort_keys=True)
print(hashlib.sha256(hooks_str.encode()).hexdigest())
" 2>/dev/null)
        if [ "$stored_hash" = "$current_hash" ]; then
            echo -e "  ${GREEN}OK${NC}  Enforcement baseline intact"
        else
            echo -e "  ${RED}FAIL${NC}  Enforcement baseline CHANGED — settings.json hooks differ from baseline"
            echo -e "         Run 'fw enforcement baseline' to update after review"
            issues=$((issues + 1))
        fi
    elif [ ! -f "$baseline_file" ]; then
        echo -e "  ${YELLOW}WARN${NC}  No enforcement baseline — run 'fw enforcement baseline' to create"
        warnings=$((warnings + 1))
    fi

    # Check: Token usage (T-806)
    if [ -f "$FW_LIB_DIR/costs.sh" ]; then
        local token_info
        token_info=$(FRAMEWORK_ROOT="$FRAMEWORK_ROOT" PROJECT_ROOT="$PROJECT_ROOT" \
            bash -c 'source "$FRAMEWORK_ROOT/lib/colors.sh" 2>/dev/null; source "$FRAMEWORK_ROOT/lib/costs.sh"; costs_main current 2>/dev/null' 2>/dev/null || true)
        if [ -n "$token_info" ]; then
            local t_total t_turns
            t_total=$(echo "$token_info" | grep "^Total:" | awk '{print $2}')
            t_turns=$(echo "$token_info" | grep "^Turns:" | awk '{print $2}' | tr -d ',')
            if [ -n "$t_total" ]; then
                echo -e "  ${CYAN}INFO${NC}  Session tokens: $t_total ($t_turns turns)"
            fi
        fi
    fi

    # Check: Stale task debt (T-814)
    local stale_count=0
    local active_dir="$PROJECT_ROOT/.tasks/active"
    if [ -d "$active_dir" ]; then
        local cutoff_epoch
        cutoff_epoch=$(date -d "7 days ago" +%s 2>/dev/null || date -v-7d +%s 2>/dev/null || echo 0)
        if [ "$cutoff_epoch" -gt 0 ]; then
            for tf in "$active_dir"/*.md; do
                [ -f "$tf" ] || continue
                local lu
                lu=$(grep '^last_update:' "$tf" 2>/dev/null | head -1 | sed 's/last_update: *//' | tr -d '"' || true)
                if [ -n "$lu" ]; then
                    local lu_epoch
                    lu_epoch=$(date -d "${lu}" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "${lu}" +%s 2>/dev/null || echo 0)
                    if [ "$lu_epoch" -gt 0 ] && [ "$lu_epoch" -lt "$cutoff_epoch" ]; then
                        stale_count=$((stale_count + 1))
                    fi
                fi
            done
        fi
        if [ "$stale_count" -gt 10 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Task debt: $stale_count stale tasks (no updates in 7+ days)"
            echo -e "         Run: fw task stale"
            warnings=$((warnings + 1))
        elif [ "$stale_count" -gt 0 ]; then
            echo -e "  ${CYAN}INFO${NC}  Task debt: $stale_count stale tasks (no updates in 7+ days)"
        fi
    fi

    # Check: Pending-updates registry (T-1268 B2)
    local pending_file="$PROJECT_ROOT/.context/working/pending-updates.yaml"
    if [ -f "$pending_file" ]; then
        local pending_count
        pending_count=$(FW_PENDING_FILE="$pending_file" python3 -c "
import os, yaml
with open(os.environ['FW_PENDING_FILE']) as f:
    data = yaml.safe_load(f) or {}
entries = data.get('pending_updates') or []
print(sum(1 for e in entries if (e.get('status') or 'pending') == 'pending'))
" 2>/dev/null || echo 0)
        if [ "$pending_count" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Pending-updates registry: $pending_count unresolved entry(ies)"
            echo -e "         Run: fw pending list"
            warnings=$((warnings + 1))
        fi
    fi

    # Check: Consumer project fleet health (T-616, T-1195/G-044)
    # Only runs from framework repo — scans FW_CONSUMER_SCAN_DIRS for .framework.yaml
    if [ "$FRAMEWORK_ROOT" = "$PROJECT_ROOT" ]; then
        local consumer_count=0
        local consumer_issues=0
        local fw_settings_file="$FRAMEWORK_ROOT/.claude/settings.json"
        echo ""
        echo -e "  ${BOLD}Consumer Projects${NC}"
        for fw_yaml in $(fw_consumer_yamls); do
            [ -f "$fw_yaml" ] || continue
            local consumer_dir
            consumer_dir=$(dirname "$fw_yaml")
            # Skip the framework itself
            [ "$consumer_dir" = "$FRAMEWORK_ROOT" ] && continue
            consumer_count=$((consumer_count + 1))
            local cname cversion
            cname=$(basename "$consumer_dir")
            cversion=$(grep "^version:" "$fw_yaml" 2>/dev/null | sed 's/^version:[[:space:]]*//' || true)
            # Check version match — distinguish behind vs ahead (T-1838).
            # T-1828 scenario: framework VERSION can be rolled back (tag-counter reset),
            # producing consumer > framework. Suggesting `fw upgrade` in that direction
            # would silently downgrade the consumer's pinned version.
            local version_ok=true
            local version_relation="match"
            if [ -n "$cversion" ] && [ "$cversion" != "$FW_VERSION" ]; then
                version_ok=false
                if [ "$(printf '%s\n%s\n' "$cversion" "$FW_VERSION" | sort -V | tail -1)" = "$cversion" ]; then
                    version_relation="ahead"
                else
                    version_relation="behind"
                fi
            fi
            # Check hooks by type (reuse T-615 logic)
            local consumer_settings="$consumer_dir/.claude/settings.json"
            local hook_info="ok"
            if [ -f "$consumer_settings" ] && [ -f "$fw_settings_file" ]; then
                hook_info=$(FW_FILE="$fw_settings_file" CONSUMER_FILE="$consumer_settings" python3 -c "
import json, os
def extract_hooks(path):
    hooks = set()
    try:
        with open(path) as f:
            data = json.load(f)
        for event, entries in data.get('hooks', {}).items():
            for entry in entries:
                for hook in entry.get('hooks', []):
                    cmd = hook.get('command', '')
                    if 'fw hook' in cmd:
                        name = cmd.split('fw hook ')[-1].strip()
                    else:
                        name = cmd.strip().split('/')[-1]
                    hooks.add((event, name))
    except (json.JSONDecodeError, FileNotFoundError):
        pass
    return hooks
fw_hooks = extract_hooks(os.environ['FW_FILE'])
consumer_hooks = extract_hooks(os.environ['CONSUMER_FILE'])
missing = fw_hooks - consumer_hooks
if missing:
    names = ', '.join(n for _, n in sorted(missing))
    print(f'missing {len(missing)}: {names}')
else:
    print(f'ok {len(consumer_hooks)}/{len(fw_hooks)}')
" 2>/dev/null || echo "parse-error")
            fi
            # Report (T-1838: asymmetric remediation for consumer-ahead case)
            if [ "$version_ok" = true ] && [[ "$hook_info" == ok* ]]; then
                local hook_counts="${hook_info#ok }"
                echo -e "  ${GREEN}OK${NC}  $cname (v$cversion, $hook_counts hooks)"
            else
                local reasons=""
                if [ "$version_relation" = "behind" ]; then
                    reasons="v$cversion → v$FW_VERSION"
                elif [ "$version_relation" = "ahead" ]; then
                    reasons="v$cversion is AHEAD of framework v$FW_VERSION"
                fi
                if [[ "$hook_info" == missing* ]]; then
                    [ -n "$reasons" ] && reasons="$reasons, "
                    reasons="$reasons$hook_info"
                fi
                echo -e "  ${YELLOW}WARN${NC}  $cname ($reasons)"
                if [ "$version_relation" = "ahead" ]; then
                    echo -e "         DO NOT run \`fw upgrade $consumer_dir\` — would downgrade v$cversion → v$FW_VERSION."
                    echo -e "         Framework VERSION likely rolled back (see T-1828). Wait for framework to catch up."
                else
                    echo -e "         Run: fw upgrade $consumer_dir"
                fi
                warnings=$((warnings + 1))
                consumer_issues=$((consumer_issues + 1))
            fi
        done
        if [ "$consumer_count" -eq 0 ]; then
            echo -e "  ${CYAN}SKIP${NC}  No consumer projects found in /opt"
        elif [ "$consumer_issues" -eq 0 ]; then
            echo -e "  ${GREEN}OK${NC}  All $consumer_count consumer(s) current"
        fi
    fi

    # Check: Isolation model health (T-1189/G-031, T-1195/G-044)
    # Detect nested .agentic-framework inside vendored dir (Pattern 6 bug)
    for fw_yaml in $(fw_consumer_yamls); do
        [ -f "$fw_yaml" ] || continue
        local _iso_dir
        _iso_dir=$(dirname "$fw_yaml")
        [ "$_iso_dir" = "$FRAMEWORK_ROOT" ] && continue
        local _nested="$_iso_dir/.agentic-framework/.agentic-framework"
        if [ -d "$_nested" ]; then
            echo -e "  ${YELLOW}WARN${NC}  Nested .agentic-framework in $(basename "$_iso_dir") — remove: rm -rf $_nested"
            warnings=$((warnings + 1))
        fi
    done
    # Detect oversized global install (Pattern 4 legacy bloat)
    local _global_dir="$HOME/.agentic-framework"
    if [ -d "$_global_dir" ]; then
        local _global_mb
        _global_mb=$(du -sm "$_global_dir" 2>/dev/null | cut -f1 || echo "0")
        if [ "${_global_mb:-0}" -gt 100 ]; then
            _doctor_warn_host "Global install at $_global_dir is ${_global_mb}MB (expected <60MB)"
            echo -e "         Consider: rm -rf $_global_dir (shim-based dispatch obsoletes it)"
        fi
    fi

    # Check: Hook crash log (T-821)
    local crash_log="$PROJECT_ROOT/.context/working/.hook-crashes.log"
    if [ -f "$crash_log" ] && [ -s "$crash_log" ]; then
        local crash_count
        crash_count=$(wc -l < "$crash_log" | tr -d '[:space:]')
        local recent_crashes
        recent_crashes=$( { grep "$(date -u +%Y-%m-%d)" "$crash_log" 2>/dev/null || true; } | wc -l | tr -d '[:space:]')
        if [ "${recent_crashes:-0}" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Hook crashes: $recent_crashes today ($crash_count total) — check .context/working/.hook-crashes.log"
            warnings=$((warnings + 1))
        else
            echo -e "  ${CYAN}INFO${NC}  Hook crash log: $crash_count historical entries (none today)"
        fi
    else
        echo -e "  ${GREEN}OK${NC}  No hook crashes logged"
    fi

    # Check: Configuration overrides (T-819)
    source "$FRAMEWORK_ROOT/lib/config.sh"
    local config_overrides
    config_overrides=$(fw_config_list 2>/dev/null | grep -v "^$" | wc -l || true)
    config_overrides=$(echo "$config_overrides" | tr -d '[:space:]')
    if [ "${config_overrides:-0}" -gt 0 ]; then
        echo ""
        echo -e "  ${BOLD}Configuration Overrides${NC}"
        fw_config_list | while read -r line; do
            echo -e "  ${CYAN}SET${NC}   $line"
        done
        # Validate ranges for known settings
        local cw
        cw=$(fw_config_int "CONTEXT_WINDOW" 300000)
        if [ "$cw" -lt 50000 ]; then
            echo -e "  ${YELLOW}WARN${NC}  FW_CONTEXT_WINDOW=$cw is very low — budget gate will fire early"
            warnings=$((warnings + 1))
        elif [ "$cw" -gt 2000000 ]; then
            echo -e "  ${YELLOW}WARN${NC}  FW_CONTEXT_WINDOW=$cw exceeds known model limits"
            warnings=$((warnings + 1))
        fi
        local dl
        dl=$(fw_config_int "DISPATCH_LIMIT" 2)
        if [ "$dl" -gt 10 ]; then
            echo -e "  ${YELLOW}WARN${NC}  FW_DISPATCH_LIMIT=$dl — very high, risk of context explosion"
            warnings=$((warnings + 1))
        fi
    else
        echo -e "  ${GREEN}OK${NC}  Configuration: all defaults (no FW_* overrides)"
    fi

    # Check: Cron registry drift (T-1112/T-1114)
    # Catches the class of divergence where cron-registry.yaml and the
    # installed /etc/cron.d/ file are out of sync — the root cause of
    # T-1112 (pickup-process missing for 5 consumer projects).
    local cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
    if [ -f "$cron_registry" ]; then
        local cron_source="$PROJECT_ROOT/.context/cron/agentic-audit.crontab"
        local cron_target_dir="${FW_CRON_INSTALL_DIR:-/etc/cron.d}"
        local cron_slug
        cron_slug=$(basename "$PROJECT_ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')
        local cron_target="$cron_target_dir/agentic-audit-${cron_slug}"

        # T-1942: registry → generated drift detection (third leg of the
        # three-step sync registry → generated → deployed). The existing
        # checks below cover generated → deployed. Without this, a registry
        # edit that's never regenerated stays invisible to doctor (T-1935
        # origin: bvp-cost-estimator-sweep entry sat 3+ days drifted while
        # doctor reported "in sync"). Approach: dry-run the same generate
        # logic via lib/cron_dry_run.py, content-compare against the on-disk
        # source. T-1944: helper extracted to a real file per L-332/L-408
        # (heredoc-in-$() in bin/fw is the canonical self-lockout shape).
        if [ -f "$cron_source" ]; then
            local _cron_dry_run
            _cron_dry_run=$(python3 "$FRAMEWORK_ROOT/lib/cron_dry_run.py" "$PROJECT_ROOT" "$cron_registry" "$FRAMEWORK_ROOT/bin/fw" 2>/dev/null || echo "__SKIP__")
            if [ "$_cron_dry_run" != "__SKIP__" ] && [ -n "$_cron_dry_run" ]; then
                local _cron_on_disk
                _cron_on_disk=$(cat "$cron_source" 2>/dev/null)
                if [ "$_cron_dry_run" != "$_cron_on_disk" ]; then
                    echo -e "  ${YELLOW}WARN${NC}  Cron registry edited but not generated: $cron_registry is ahead of $cron_source"
                    echo -e "        Run: fw cron generate"
                    warnings=$((warnings + 1))
                fi
            fi
        fi

        if [ -f "$cron_source" ] && [ -f "$cron_target" ]; then
            if diff -q "$cron_source" "$cron_target" >/dev/null 2>&1; then
                echo -e "  ${GREEN}OK${NC}  Cron registry in sync with $cron_target"
            else
                echo -e "  ${YELLOW}WARN${NC}  Cron registry drift: $cron_source differs from $cron_target"
                echo -e "        Run: fw cron install"
                warnings=$((warnings + 1))
            fi
        elif [ -f "$cron_source" ] && [ ! -f "$cron_target" ]; then
            echo -e "  ${YELLOW}WARN${NC}  Cron generated but not installed at $cron_target"
            echo -e "        Run: fw cron install"
            warnings=$((warnings + 1))
        elif [ ! -f "$cron_source" ]; then
            echo -e "  ${YELLOW}WARN${NC}  Cron registry present but not generated — run: fw cron install"
            warnings=$((warnings + 1))
        fi

        # T-1558: flock parity (T-1556 prevention #5). The file-diff above catches
        # post-generate drift, but if the registry is regenerated WITHOUT flock
        # wrappers (the T-1556 regression) and immediately deployed, the diff
        # check stays green while orphan-prevention is silently disabled. Compare
        # the count of `flock -n` directly between registry command fields and
        # the deployed crontab; warn on parity violation.
        if [ -f "$cron_target" ]; then
            local _reg_flock_count _dep_flock_count
            _reg_flock_count=$(grep -cE "command:.*flock -n " "$cron_registry" 2>/dev/null || true)
            _dep_flock_count=$(grep -cE "flock -n " "$cron_target" 2>/dev/null || true)
            _reg_flock_count="${_reg_flock_count:-0}"
            _dep_flock_count="${_dep_flock_count:-0}"
            if [ "$_reg_flock_count" -gt "$_dep_flock_count" ]; then
                echo -e "  ${YELLOW}WARN${NC}  Cron flock parity: registry declares $_reg_flock_count wrapped jobs, deployed crontab has $_dep_flock_count"
                echo -e "        T-1331 orphan-prevention may be partially disabled. Run: fw cron install"
                warnings=$((warnings + 1))
            fi
        fi
    fi

    # Doc-drift check: fw subcommands vs CLAUDE.md Quick Reference (T-1147/T-1104/T-1421)
    if [ -f "$FRAMEWORK_ROOT/CLAUDE.md" ]; then
        local _qr_cmds _fw_cmds _missing _miss_count _prose_list
        # T-1421: extract the verb from any `fw VERB...` backtick context. Old
        # regex only matched `` `fw x` `` / `` `fw x y` `` with closing backtick,
        # which missed pipe-joined forms (`fw cron generate|status|list`) and
        # flag-bearing forms (`fw work-on "name" --type build`). We only need
        # the verb, so match through opening backtick + `fw ` + verb.
        _qr_cmds=$(grep -oE '`fw [a-z][-a-z]*' "$FRAMEWORK_ROOT/CLAUDE.md" 2>/dev/null \
            | sed 's/^`fw //' | sort -u || true)
        # T-1421: secondary catch — the trimmed Quick Reference ends with a
        # prose "rarely-used commands (harvest, promote, …)" paragraph listing
        # verbs without backticks. Parse those too.
        _prose_list=$(grep -oE 'rarely-used commands \([^)]*\)' "$FRAMEWORK_ROOT/CLAUDE.md" 2>/dev/null \
            | sed 's/^.*(//; s/).*$//' | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -E '^[a-z][-a-z]*$' || true)
        # Extract top-level routed subcommands from main routing case
        _fw_cmds=$(sed -n '/^case "\$cmd" in/,/^esac/p' "$FRAMEWORK_ROOT/bin/fw" 2>/dev/null \
            | grep -E '^    [a-z][-a-z]*\)' | sed 's/[[:space:]]*//; s/).*//' | sort -u || true)
        _missing=""
        _miss_count=0
        while IFS= read -r cmd; do
            [ -z "$cmd" ] && continue
            if ! echo "$_qr_cmds" | grep -qw "$cmd" && ! echo "$_prose_list" | grep -qw "$cmd"; then
                _missing="${_missing} ${cmd}"
                _miss_count=$((_miss_count + 1))
            fi
        done <<< "$_fw_cmds"
        if [ "$_miss_count" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Doc drift: $_miss_count fw subcommand(s) missing from CLAUDE.md Quick Reference"
            echo -e "        Missing:${_missing}"
            warnings=$((warnings + 1))
        else
            echo -e "  ${GREEN}OK${NC}  Quick Reference coverage"
        fi
    fi

    # T-1278: Shim masquerade check — a framework repo's bin/fw must be the
    # real CLI (4000+ lines), not the 50-line fw-shim. The shim contains
    # find_fw() and lacks a case/esac routing block; the real CLI has 'do_doctor'
    # and many other functions. Detect shim-over-real quickly.
    if [ -f "$FRAMEWORK_ROOT/FRAMEWORK.md" ] && [ -f "$FRAMEWORK_ROOT/bin/fw" ]; then
        local fw_lines
        fw_lines=$(wc -l < "$FRAMEWORK_ROOT/bin/fw" 2>/dev/null || echo 0)
        if [ "$fw_lines" -lt 200 ] && grep -q '^find_fw()' "$FRAMEWORK_ROOT/bin/fw" 2>/dev/null; then
            echo -e "  ${RED}FAIL${NC}  bin/fw in framework repo is the shim (${fw_lines} lines, has find_fw())"
            echo -e "         Real CLI missing. Restore: git -C $FRAMEWORK_ROOT checkout HEAD -- bin/fw"
            issues=$((issues + 1))
        else
            echo -e "  ${GREEN}OK${NC}  bin/fw is the real CLI (${fw_lines} lines, not the shim)"
        fi
    fi

    # T-1592: Mirror divergence check — compare refs between origin and other
    # remotes. The cascade is: local → origin → github (mirror via OneDev
    # buildspec). If a tag/head SHA on origin doesn't match the mirror, the
    # mirror push is rejected as non-fast-forward and AEF stops reaching GitHub
    # silently. T-1591 root cause was annotated-vs-lightweight tag mismatch
    # going undetected for 50+ failed mirror builds (~22h).
    set +e  # tolerate ls-remote / comm / wc pipe failures (network, empty refs)
    local _all_remotes _has_origin
    _all_remotes=$(git -C "$PROJECT_ROOT" remote 2>/dev/null)
    _has_origin=$(echo "$_all_remotes" | grep -cx 'origin' 2>/dev/null)
    if [ "${_has_origin:-0}" -ge 1 ]; then
        local _other_remotes _origin_refs _origin_dump
        _other_remotes=$(echo "$_all_remotes" | grep -vx 'origin' 2>/dev/null)
        _origin_dump=$(mktemp 2>/dev/null || echo "/tmp/fw-origin-refs.$$")
        timeout 10 git -C "$PROJECT_ROOT" ls-remote origin 'refs/heads/*' 'refs/tags/*' 2>/dev/null | sort -k2 > "$_origin_dump" 2>/dev/null
        _origin_refs=$(wc -l < "$_origin_dump" 2>/dev/null)
        if [ -n "$_other_remotes" ] && [ "${_origin_refs:-0}" -gt 0 ]; then
            local _other _other_dump _diverge _orig_only _mirr_only _orig_names _other_names
            while IFS= read -r _other; do
                [ -z "$_other" ] && continue
                _other_dump=$(mktemp 2>/dev/null || echo "/tmp/fw-${_other}-refs.$$")
                timeout 10 git -C "$PROJECT_ROOT" ls-remote "$_other" 'refs/heads/*' 'refs/tags/*' 2>/dev/null | sort -k2 > "$_other_dump" 2>/dev/null
                if [ ! -s "$_other_dump" ]; then
                    echo -e "  ${CYAN}SKIP${NC}  Mirror divergence ($_other unreachable)"
                    rm -f "$_other_dump"
                    continue
                fi
                _orig_names=$(awk '{print $2}' "$_origin_dump" | sort -u)
                _other_names=$(awk '{print $2}' "$_other_dump"  | sort -u)
                _diverge=0
                while IFS= read -r _ref; do
                    [ -z "$_ref" ] && continue
                    local _o_sha _m_sha
                    _o_sha=$(awk -v r="$_ref" '$2==r{print $1; exit}' "$_origin_dump")
                    _m_sha=$(awk -v r="$_ref" '$2==r{print $1; exit}' "$_other_dump")
                    if [ -n "$_o_sha" ] && [ -n "$_m_sha" ] && [ "$_o_sha" != "$_m_sha" ]; then
                        _diverge=$((_diverge + 1))
                    fi
                done < <(comm -12 <(echo "$_orig_names") <(echo "$_other_names"))
                _orig_only=$(comm -23 <(echo "$_orig_names") <(echo "$_other_names") | grep -c . 2>/dev/null)
                _mirr_only=$(comm -13 <(echo "$_orig_names") <(echo "$_other_names") | grep -c . 2>/dev/null)
                if [ "${_diverge:-0}" -gt 0 ]; then
                    echo -e "  ${YELLOW}WARN${NC}  Mirror divergence: $_diverge ref(s) differ between origin and $_other (T-1591/T-1592)"
                    echo -e "         Investigate: diff <(git ls-remote origin) <(git ls-remote $_other)"
                    warnings=$((warnings + 1))
                elif [ "${_orig_only:-0}" -gt 0 ] || [ "${_mirr_only:-0}" -gt 0 ]; then
                    echo -e "  ${CYAN}INFO${NC}  Mirror asymmetry with $_other: origin-only=${_orig_only:-0}, $_other-only=${_mirr_only:-0} (no SHA conflicts)"
                else
                    echo -e "  ${GREEN}OK${NC}  Mirror parity with $_other (heads + tags aligned)"
                fi
                rm -f "$_other_dump"
            done <<< "$_other_remotes"
        fi
        rm -f "$_origin_dump"
    fi
    set -e

    # Check: Workflow file schema linter (T-1694 / Q14)
    # Lints all .context/project/workflows/*.yaml against ADR-0003 schema:
    #   - inline:true workflows must NOT carry dispatch fields
    #   - non-inline workflows require: task_type, worker_kind, model, effort,
    #     prompt_template, allowed_tools, cost_cap_usd, cwd
    #   - worker_kind in {Task, TermLink, pi}
    #   - prompt_template must resolve to an existing file
    #   - meta_model required iff prompt_strategy == meta-prompted
    #   - default.yaml missing → soft warning
    if [ -d "$PROJECT_ROOT/.context/project/workflows" ]; then
        local _wf_lint_out _wf_errors _wf_warnings _wf_count
        # T-1807: lint logic lives in lib/workflow_lint.py so the pause-field
        # rules can be unit-tested without invoking `fw doctor`.
        _wf_lint_out=$(python3 -c "import sys; sys.path.insert(0, '$FW_LIB_DIR'); from workflow_lint import main; main('$PROJECT_ROOT')" 2>&1)
        _wf_errors=$(echo "$_wf_lint_out" | grep -c '^ERROR|' || true)
        _wf_warnings=$(echo "$_wf_lint_out" | grep -c '^WARN|' || true)
        _wf_count=$(echo "$_wf_lint_out" | grep '^COUNT|' | head -1 | sed 's/^COUNT|//' || echo 0)
        if [ "${_wf_errors:-0}" -gt 0 ]; then
            echo -e "  ${RED}FAIL${NC}  Workflow schema: $_wf_errors error(s) across $_wf_count file(s)"
            echo "$_wf_lint_out" | grep '^ERROR|' | sed 's/^ERROR|/         /'
            issues=$((issues + _wf_errors))
        elif [ "${_wf_warnings:-0}" -gt 0 ]; then
            echo -e "  ${YELLOW}WARN${NC}  Workflow schema: $_wf_count file(s) clean, $_wf_warnings warning(s)"
            echo "$_wf_lint_out" | grep '^WARN|' | sed 's/^WARN|/         /'
            warnings=$((warnings + _wf_warnings))
        else
            echo -e "  ${GREEN}OK${NC}  Workflow schema: $_wf_count file(s) lint clean"
        fi
    else
        echo -e "  ${CYAN}INFO${NC}  No .context/project/workflows/ — orchestrator v1 not yet provisioned"
    fi

    # Check (T-1735): VALID_WORKER_KINDS parity between lib/workflow_lint.py and lib/resolver.py.
    # T-1734 closed a 5-month silent drift between these two tables; this check is
    # the runtime witness that prevents recurrence. After T-1807 the lint logic
    # moved from bin/fw heredoc into lib/workflow_lint.py — the parity comparison
    # now lives between the two python modules directly. T-1946 (2026-05-20)
    # extracted the inline heredoc to lib/worker_kinds_parity.py per L-332/L-408
    # (last remaining heredoc-in-command-substitution site in bin/fw).
    local _wkp_out
    _wkp_out=$(python3 "$FRAMEWORK_ROOT/lib/worker_kinds_parity.py" "$FW_LIB_DIR" 2>/dev/null)
    case "$_wkp_out" in
        OK\|*)
            local _wkp_set="${_wkp_out#OK|}"
            echo -e "  ${GREEN}OK${NC}  Worker-kinds parity (lib/workflow_lint.py ↔ lib/resolver.py): $_wkp_set"
            ;;
        WARN\|*)
            local _wkp_msg="${_wkp_out#WARN|}"
            echo -e "  ${YELLOW}WARN${NC}  Worker-kinds parity: $_wkp_msg"
            warnings=$((warnings + 1))
            ;;
        FAIL\|*)
            local _wkp_err="${_wkp_out#FAIL|}"
            echo -e "  ${RED}FAIL${NC}  Worker-kinds parity check broken: $_wkp_err"
            issues=$((issues + 1))
            ;;
        *)
            echo -e "  ${YELLOW}WARN${NC}  Worker-kinds parity check produced no output (python error?)"
            warnings=$((warnings + 1))
            ;;
    esac

    # T-2185: stale gauge-READY gaps — surface operator-procrastination class.
    # WARN when ≥1 gap has closure_check_command + gauge currently READY for
    # ≥7 days (created/last_reviewed). Mirrors OBS-048 (G-064 READY since
    # 2026-05-15, surfaced by T-2184 handoff doc; closure path now exists
    # via `fw gaps close G-XXX` and the /gaps Close button).
    local _stale_gaps_out _stale_gaps_count
    _stale_gaps_out=$(PROJECT_ROOT="$PROJECT_ROOT" python3 -c "
import sys; sys.path.insert(0, '$FRAMEWORK_ROOT')
try:
    from lib.gaps import stale_ready_gaps
    out = stale_ready_gaps(threshold_days=7)
    for g in out:
        print(f\"{g['gap_id']}\t{g['age_days']}\t{g['title'][:60]}\")
except Exception as e:
    print(f'ERROR\t{e}', file=sys.stderr)
" 2>/dev/null)
    _stale_gaps_count=$(echo "$_stale_gaps_out" | grep -c "^G-" || true)
    if [ "${_stale_gaps_count:-0}" -gt 0 ]; then
        echo -e "  ${YELLOW}WARN${NC}  Gauge-READY gaps not closed: $_stale_gaps_count gap(s) ≥7 days READY"
        while IFS=$'\t' read -r _gid _age _title; do
            [ -n "$_gid" ] || continue
            echo -e "         $_gid (${_age}d READY) — $_title"
        done <<< "$_stale_gaps_out"
        echo -e "         Close via: fw gaps close <gap_id> (or /gaps Close button)"
        warnings=$((warnings + 1))
    else
        echo -e "  ${GREEN}OK${NC}  No stale gauge-READY gaps"
    fi

    # Summary (T-1707: break out host_warnings count when > 0)
    local _scope_breakdown=""
    if [ "$host_warnings" -gt 0 ]; then
        _scope_breakdown=" ($host_warnings host-level)"
    fi
    echo ""
    if [ $issues -eq 0 ] && [ $warnings -eq 0 ]; then
        echo -e "${GREEN}All checks passed${NC}"
    elif [ $issues -eq 0 ]; then
        echo -e "${YELLOW}$warnings warning(s)$_scope_breakdown, no failures${NC}"
    else
        echo -e "${RED}$issues failure(s), $warnings warning(s)$_scope_breakdown${NC}"
        exit 2
    fi
}

# --- Task Subcommand Routing ---

route_task() {
    local subcmd="${1:-}"
    shift || true

    case "$subcmd" in
        create)
            exec "$AGENTS_DIR/task-create/create-task.sh" "$@"
            ;;
        update)
            exec "$AGENTS_DIR/task-create/update-task.sh" "$@"
            ;;
        list)
            FW_TASK_ARGS="$*" python3 << 'PYTASK_LIST'
import os, yaml, sys, shlex

project_root = os.environ.get('PROJECT_ROOT', '.')

# Parse args from env (bash can't pass args to heredoc python)
args = shlex.split(os.environ.get('FW_TASK_ARGS', ''))
status_filter = None
type_filter = None
component_filter = None
tag_filter = None
show_all = False

i = 0
while i < len(args):
    if args[i] == '--status' and i + 1 < len(args):
        status_filter = args[i+1]; i += 2
    elif args[i] == '--type' and i + 1 < len(args):
        type_filter = args[i+1]; i += 2
    elif args[i] == '--component' and i + 1 < len(args):
        component_filter = args[i+1]; i += 2
    elif args[i] == '--tag' and i + 1 < len(args):
        tag_filter = args[i+1]; i += 2
    elif args[i] == '--all':
        show_all = True; i += 1
    else:
        i += 1

GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
BOLD = '\033[1m'
NC = '\033[0m'

def status_color(s):
    s = s.lower() if s else ''
    if s in ('work-completed', 'completed'):
        return GREEN
    if s == 'issues':
        return YELLOW
    if s in ('captured', 'started-work'):
        return CYAN
    return NC

def parse_task(path):
    try:
        with open(path) as f:
            text = f.read()
        if not text.startswith('---'):
            return None
        end = text.index('---', 3)
        block = text[3:end]
        try:
            fm = yaml.safe_load(block)
        except:
            # Fallback: regex parse key fields from malformed YAML
            import re
            fm = {}
            for key in ['id', 'status', 'workflow_type', 'owner', 'name']:
                m = re.search(r'^' + key + r':\s*(.+)', block, re.MULTILINE)
                if m:
                    val = m.group(1).strip().strip('"').strip("'")
                    fm[key] = val
        return fm if fm else None
    except:
        return None

tasks = []
dirs = [os.path.join(project_root, '.tasks', 'active')]
if show_all or status_filter in ('work-completed', 'completed'):
    dirs.append(os.path.join(project_root, '.tasks', 'completed'))

for d in dirs:
    if not os.path.isdir(d):
        continue
    for fn in sorted(os.listdir(d)):
        if not fn.endswith('.md'):
            continue
        fm = parse_task(os.path.join(d, fn))
        if fm and fm.get('id'):
            # Derive status from directory as fallback
            if 'completed' in d and fm.get('status') in ('captured', 'started-work', 'issues'):
                fm['status'] = 'work-completed'
            tasks.append(fm)

if status_filter:
    tasks = [t for t in tasks if t.get('status', '').lower() == status_filter.lower()]
if type_filter:
    tasks = [t for t in tasks if t.get('workflow_type', '').lower() == type_filter.lower()]
if component_filter:
    ep_dir = os.path.join(project_root, '.context', 'episodic')
    matching_ids = set()
    if os.path.isdir(ep_dir):
        for fn in os.listdir(ep_dir):
            if fn.endswith('.yaml') and fn != 'TEMPLATE.yaml':
                try:
                    with open(os.path.join(ep_dir, fn)) as f:
                        ep = yaml.safe_load(f)
                    tags = ep.get('tags', [])
                    if any(component_filter.lower() in str(t).lower() for t in tags):
                        matching_ids.add(ep.get('task_id', ''))
                except:
                    pass
    tasks = [t for t in tasks if t.get('id', '') in matching_ids]
if tag_filter:
    tasks = [t for t in tasks if tag_filter.lower() in [str(tg).lower() for tg in t.get('tags', [])]]

def sort_key(t):
    tid = t.get('id', 'T-999')
    try:
        return int(tid.replace('T-', ''))
    except:
        return 999
tasks.sort(key=sort_key)

if not tasks:
    # Count completed tasks to give useful context
    completed_dir = os.path.join(project_root, '.tasks', 'completed')
    completed_count = 0
    if os.path.isdir(completed_dir):
        completed_count = len([f for f in os.listdir(completed_dir) if f.endswith('.md')])

    if completed_count > 0 and not show_all:
        print(f'{BOLD}No active tasks{NC}')
        print(f'  {completed_count} completed tasks (use --all to show)')
    else:
        print(f'{YELLOW}No tasks found matching filters{NC}')
    sys.exit(0)

print(f'{BOLD}Tasks{NC} ({len(tasks)} found)')
print()
print(f'  {"ID":<8} {"Status":<18} {"Type":<15} {"Owner":<12} {"Name"}')
print(f'  {chr(9472)*8} {chr(9472)*18} {chr(9472)*15} {chr(9472)*12} {chr(9472)*30}')

for t in tasks:
    tid = t.get('id', '?')
    status = t.get('status', '?')
    wtype = t.get('workflow_type', '?')
    owner = t.get('owner', '?')
    name = t.get('name', '?')
    sc = status_color(status)
    print(f'  {tid:<8} {sc}{status:<18}{NC} {wtype:<15} {owner:<12} {name}')
PYTASK_LIST
            ;;
        show)
            local task_id="${1:-}"
            if [ -z "$task_id" ]; then
                echo -e "${RED}Usage: fw task show T-XXX${NC}"
                exit 1
            fi
            python3 - "$task_id" << 'PYTASK_SHOW'
import os, yaml, sys

task_id = sys.argv[1]
project_root = os.environ.get('PROJECT_ROOT', '.')

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

task_file = None
for subdir in ['active', 'completed']:
    d = os.path.join(project_root, '.tasks', subdir)
    if not os.path.isdir(d):
        continue
    for fn in os.listdir(d):
        if fn.startswith(task_id + '-') and fn.endswith('.md'):
            task_file = os.path.join(d, fn)
            break
    if task_file:
        break

if not task_file:
    print(f'\033[0;31mTask {task_id} not found\033[0m')
    sys.exit(1)

with open(task_file) as f:
    text = f.read()

fm = {}
if text.startswith('---'):
    end = text.index('---', 3)
    fm = yaml.safe_load(text[3:end]) or {}

print(f'{BOLD}{task_id}: {fm.get("name", "Unknown")}{NC}')
print()
print(f'  Status:    {fm.get("status", "?")}')
print(f'  Type:      {fm.get("workflow_type", "?")}')
print(f'  Owner:     {fm.get("owner", "?")}')
print(f'  Created:   {fm.get("created", "?")}')
if fm.get('date_finished'):
    print(f'  Finished:  {fm.get("date_finished")}')
if fm.get('tags'):
    print(f'  Tags:      {", ".join(str(t) for t in fm["tags"])}')
if fm.get('related_tasks'):
    print(f'  Related:   {", ".join(str(t) for t in fm["related_tasks"])}')
print()

ep_file = os.path.join(project_root, '.context', 'episodic', f'{task_id}.yaml')
if os.path.isfile(ep_file):
    with open(ep_file) as f:
        ep = yaml.safe_load(f)

    if ep.get('summary'):
        print(f'{BOLD}Summary{NC}')
        for line in ep['summary'].strip().split('\n'):
            print(f'  {line.strip()}')
        print()

    if ep.get('outcomes'):
        print(f'{BOLD}Outcomes{NC}')
        for o in ep['outcomes']:
            print(f'  {GREEN}*{NC} {o}')
        print()

    if ep.get('challenges'):
        print(f'{BOLD}Challenges{NC}')
        for c in ep['challenges']:
            desc = c if isinstance(c, str) else c.get('description', '')
            res = '' if isinstance(c, str) else c.get('resolution', '')
            print(f'  {YELLOW}*{NC} {desc}')
            if res:
                print(f'    Resolution: {res}')
        print()

    if ep.get('decisions'):
        print(f'{BOLD}Decisions{NC}')
        for d in ep['decisions']:
            dec = d if isinstance(d, str) else d.get('decision', '')
            rat = '' if isinstance(d, str) else d.get('rationale', '')
            print(f'  {CYAN}*{NC} {dec}')
            if rat:
                print(f'    Rationale: {rat}')
        print()

    if ep.get('related_tasks'):
        rt = ep['related_tasks']
        parts = []
        if rt.get('spawned'):
            parts.append(f'Spawned: {", ".join(str(t) for t in rt["spawned"])}')
        if rt.get('absorbed'):
            parts.append(f'Absorbed: {", ".join(str(t) for t in rt["absorbed"])}')
        if rt.get('blocked'):
            parts.append(f'Blocked: {", ".join(str(t) for t in rt["blocked"])}')
        if parts:
            print(f'{BOLD}Related Tasks{NC}')
            for p in parts:
                print(f'  {p}')
            print()

    if ep.get('tags'):
        print(f'{BOLD}Tags{NC}')
        print(f'  {", ".join(str(t) for t in ep["tags"])}')
        print()
else:
    desc = fm.get('description', '')
    if desc:
        print(f'{BOLD}Description{NC}')
        for line in desc.strip().split('\n'):
            print(f'  {line.strip()}')
        print()

# Research artifacts (T-633)
reports_dir = os.path.join(project_root, 'docs', 'reports')
if os.path.isdir(reports_dir):
    tid_lower = task_id.lower().replace('-', '')
    artifacts = []
    for fn in sorted(os.listdir(reports_dir)):
        if fn.endswith('.md') and tid_lower in fn.lower().replace('-', ''):
            artifacts.append(fn)
    if artifacts:
        print(f'{BOLD}Research Artifacts{NC}')
        for a in artifacts:
            print(f'  {GREEN}*{NC} docs/reports/{a}')
        print()
PYTASK_SHOW
            ;;
        verify)
            # Two modes:
            # - No args (T-193): List tasks with unchecked human ACs
            # - With task ID (T-070): Run verification commands from task
            #
            # T-1628: T-193 mode supports triage flags for the 100+ task
            # awaiting-review backlog (G-008). Flags are parsed here and
            # exported as FW_VERIFY_* for the embedded python block.
            local task_id=""
            local flag_compact=false
            local flag_by_age=false
            local flag_rubber=false
            local flag_review=false
            while [ $# -gt 0 ]; do
                case "$1" in
                    --compact) flag_compact=true; shift ;;
                    --by-age) flag_by_age=true; shift ;;
                    --rubber-stamp-only) flag_rubber=true; shift ;;
                    --review-only) flag_review=true; shift ;;
                    T-*) task_id="$1"; shift ;;
                    *) shift ;;
                esac
            done

            if [ -n "$task_id" ]; then
                # T-070: Run verification commands for specific task
                local task_file=""
                for subdir in active completed; do
                    for f in "$PROJECT_ROOT/.tasks/$subdir/$task_id"*.md; do
                        if [ -f "$f" ]; then
                            task_file="$f"
                            break 2
                        fi
                    done
                done

                if [ -z "$task_file" ]; then
                    echo -e "${RED}ERROR: Task $task_id not found${NC}" >&2
                    exit 1
                fi

                # Extract ## Verification block
                # T-1541: collapsed 4 greps into one ERE + `|| true` to mirror
                # update-task.sh:197 — the prior BRE pattern `^\`\`\`` is parsed
                # by GNU grep 3.11 as start-of-line + three start-of-buffer
                # assertions, matching every line; under `set -euo pipefail` the
                # empty-result exit 1 aborted the script before the diagnostic
                # branch could run. ERE has no special meaning for backtick.
                local verification
                verification=$(sed -n '/^## Verification/,/^##/p' "$task_file" | \
                              grep -vE '^##|^\s*```|^\s*#|^\s*$' || true)

                if [ -z "$verification" ]; then
                    echo -e "${YELLOW}No verification commands found in task $task_id${NC}"
                    exit 0
                fi

                # Run each command
                local passed=0
                local failed=0
                local total=0

                echo -e "${CYAN}=== Verification: $task_id ===${NC}"
                echo

                while IFS= read -r cmd; do
                    total=$((total + 1))
                    echo -e "[$total] ${BOLD}$cmd${NC}"

                    if eval "$cmd" >/dev/null 2>&1; then
                        echo -e "  ${GREEN}✓ PASS${NC}"
                        passed=$((passed + 1))
                    else
                        echo -e "  ${RED}✗ FAIL${NC}"
                        failed=$((failed + 1))
                    fi
                done <<< "$verification"

                echo
                echo -e "${BOLD}Verification:${NC} $passed/$total passed"

                if [ $failed -gt 0 ]; then
                    exit 1
                else
                    exit 0
                fi
            else
                # T-193: List tasks with unchecked human ACs (awaiting human verification)
                # T-1628: triage flags (--compact, --by-age, --rubber-stamp-only, --review-only)
                FW_VERIFY_COMPACT="$flag_compact" \
                FW_VERIFY_BY_AGE="$flag_by_age" \
                FW_VERIFY_RUBBER="$flag_rubber" \
                FW_VERIFY_REVIEW="$flag_review" \
                python3 << 'PYTASK_VERIFY'
import os, re
from datetime import datetime, timezone

project_root = os.environ.get('PROJECT_ROOT', '.')
active_dir = os.path.join(project_root, '.tasks', 'active')

# T-1628 flags
FLAG_COMPACT = os.environ.get('FW_VERIFY_COMPACT', 'false') == 'true'
FLAG_BY_AGE = os.environ.get('FW_VERIFY_BY_AGE', 'false') == 'true'
FLAG_RUBBER = os.environ.get('FW_VERIFY_RUBBER', 'false') == 'true'
FLAG_REVIEW = os.environ.get('FW_VERIFY_REVIEW', 'false') == 'true'

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

def parse_iso(s):
    if not s: return None
    try:
        s = str(s).replace('Z', '+00:00')
        return datetime.fromisoformat(s)
    except Exception:
        return None

def label_of(item: str) -> str:
    """Return RUBBER-STAMP / REVIEW / NONE based on the leading [TAG] of an item."""
    m = re.match(r'\s*\[(RUBBER-STAMP|REVIEW)\]', item)
    return m.group(1) if m else 'NONE'

now = datetime.now(timezone.utc)
results = []

if os.path.isdir(active_dir):
    for fn in sorted(os.listdir(active_dir)):
        if not fn.endswith('.md'):
            continue
        path = os.path.join(active_dir, fn)
        with open(path) as f:
            text = f.read()

        # Extract frontmatter
        fm = {}
        if text.startswith('---'):
            try:
                end = text.index('---', 3)
                import yaml
                fm = yaml.safe_load(text[3:end]) or {}
            except:
                pass

        # Find ## Acceptance Criteria section
        ac_match = re.search(r'^## Acceptance Criteria\s*\n(.*?)(?=\n## |\Z)', text, re.MULTILINE | re.DOTALL)
        if not ac_match:
            continue

        ac_section = ac_match.group(1)

        # Check for ### Human header
        if '### Human' not in ac_section:
            continue

        # Extract human ACs
        human_match = re.search(r'### Human\s*\n(.*?)(?=\n### |\Z)', ac_section, re.DOTALL)
        if not human_match:
            continue

        human_block = human_match.group(1)
        # G-047: strip HTML <!-- ... --> comment blocks before counting ACs.
        # Default template includes an example "- [ ] [REVIEW] Dashboard renders correctly"
        # inside a comment that must not register as a real unchecked AC.
        human_block_uncommented = re.sub(r'<!--.*?-->', '', human_block, flags=re.DOTALL)
        human_total = len(re.findall(r'^\s*-\s*\[[ x]\]', human_block_uncommented, re.MULTILINE))
        human_checked = len(re.findall(r'^\s*-\s*\[x\]', human_block_uncommented, re.MULTILINE))
        human_unchecked = human_total - human_checked

        if human_unchecked > 0:
            unchecked_items = re.findall(r'^\s*-\s*\[ \]\s*(.*)', human_block_uncommented, re.MULTILINE)
            labels = {label_of(i) for i in unchecked_items}
            if {'RUBBER-STAMP', 'REVIEW'}.issubset(labels):
                kind = 'MIXED'
            elif 'RUBBER-STAMP' in labels:
                kind = 'RUBBER-STAMP'
            elif 'REVIEW' in labels:
                kind = 'REVIEW'
            else:
                kind = 'UNTAGGED'

            last_update = parse_iso(fm.get('last_update'))
            age_days = (now - last_update).days if last_update else 0

            results.append({
                'id': fm.get('id', fn),
                'name': fm.get('name', ''),
                'status': fm.get('status', '?'),
                'total': human_total,
                'checked': human_checked,
                'unchecked': human_unchecked,
                'items': unchecked_items,
                'kind': kind,
                'age_days': age_days,
            })

# T-1628: filter (additive — both flags = OR)
if FLAG_RUBBER or FLAG_REVIEW:
    keep = []
    for r in results:
        kinds = {label_of(i) for i in r['items']}
        if FLAG_RUBBER and 'RUBBER-STAMP' in kinds:
            keep.append(r); continue
        if FLAG_REVIEW and 'REVIEW' in kinds:
            keep.append(r); continue
    results = keep

# T-1628: sort by age desc (oldest first) when --by-age, else preserve alphabetical
if FLAG_BY_AGE:
    results.sort(key=lambda r: -r['age_days'])

if results:
    print(f'{BOLD}Awaiting Human Verification{NC} ({len(results)} task(s))')
    print()
    if FLAG_COMPACT:
        # T-1628: compact one-line-per-task format
        for r in results:
            kind = r['kind']
            kind_color = YELLOW if kind in ('MIXED', 'REVIEW') else GREEN if kind == 'RUBBER-STAMP' else NC
            name = (r['name'] or '')
            if len(name) > 70: name = name[:67] + '...'
            print(f'  {YELLOW}{r["id"]}{NC} {r["age_days"]:3d}d {r["unchecked"]}/{r["total"]} {kind_color}[{kind}]{NC} {name}')
    else:
        for r in results:
            print(f'  {YELLOW}{r["id"]}{NC}: {r["name"]}')
            print(f'    Status: {r["status"]}  Human ACs: {r["checked"]}/{r["total"]} checked')
            for item in r['items']:
                print(f'    {YELLOW}[ ]{NC} {item}')
            print(f'    Finalize: fw task update {r["id"]} --status work-completed')
            print()
else:
    print(f'{GREEN}No tasks awaiting human verification{NC}')
PYTASK_VERIFY
            fi
            ;;
        review-batch)
            # T-2182 / T-2181 Slice 1: emit a markdown table of full Watchtower URLs
            # for multi-task handoff in chat. Composes from emit_review_batch in
            # lib/review.sh. The single-task `fw task review T-XXX` form is unchanged.
            if [ $# -lt 1 ]; then
                echo -e "${RED}Usage: fw task review-batch T-A T-B [...]${NC}" >&2
                exit 1
            fi
            source "$FW_LIB_DIR/review.sh"
            emit_review_batch "$@"
            ;;
        review)
            # T-631/T-634: Print clickable URL + QR code for Human AC review
            # T-2139: --skip-review-link-check "rationale" Tier-2 bypass for the
            # review-link homework gate (per T-2138 GO, Candidate E + Q3-both).
            local task_id=""
            local poll_flag=""
            local skip_link_check_rationale=""
            while [ $# -gt 0 ]; do
                case "$1" in
                    --skip-review-link-check)
                        skip_link_check_rationale="${2:-unspecified}"
                        shift 2
                        ;;
                    --poll)
                        poll_flag="--poll"
                        shift
                        ;;
                    *)
                        if [ -z "$task_id" ]; then
                            task_id="$1"
                        fi
                        shift
                        ;;
                esac
            done

            if [ -z "$task_id" ]; then
                echo -e "${RED}Usage: fw task review T-XXX [--poll] [--skip-review-link-check \"rationale\"]${NC}" >&2
                exit 1
            fi

            # T-2139 bypass: propagate to emit_review via env + record rationale
            # for the validator's Tier-2 log.
            if [ -n "$skip_link_check_rationale" ]; then
                export FW_ALLOW_REVIEW_LINK_HOMEWORK=1
                export FW_BYPASS_RATIONALE="$skip_link_check_rationale"
            fi

            # Find task file
            local task_file=""
            for f in "$PROJECT_ROOT/.tasks/active/$task_id"*.md "$PROJECT_ROOT/.tasks/completed/$task_id"*.md; do
                if [ -f "$f" ]; then
                    task_file="$f"
                    break
                fi
            done
            if [ -z "$task_file" ]; then
                echo -e "${RED}ERROR: Task $task_id not found${NC}" >&2
                exit 1
            fi

            # Placeholder audit chokepoint (T-1111/T-1113) — block review if the
            # task file still contains literal template stubs. Runs BEFORE
            # emit_review so no review marker is created on failure.
            source "$FW_LIB_DIR/task-audit.sh"
            if ! audit_task_placeholders "$task_file"; then
                exit 1
            fi

            # Shared review output (T-634)
            source "$FW_LIB_DIR/review.sh"
            emit_review "$task_id" "$task_file"

            # Optional polling mode
            if [ "$poll_flag" = "--poll" ]; then
                echo -e "${CYAN}Polling for Human AC completion (timeout 10min)...${NC}"
                local timeout=600
                local elapsed=0
                while [ $elapsed -lt $timeout ]; do
                    sleep 5
                    elapsed=$((elapsed + 5))
                    # Count Human ACs from task file
                    local human_total=0 human_checked=0 in_human=false
                    while IFS= read -r line; do
                        if echo "$line" | grep -q '### Human'; then
                            in_human=true; continue
                        fi
                        if $in_human && echo "$line" | grep -qE '^### |^## '; then
                            break
                        fi
                        if $in_human && echo "$line" | grep -qE '^\- \[[ xX]\]'; then
                            human_total=$((human_total + 1))
                            if echo "$line" | grep -qE '^\- \[[xX]\]'; then
                                human_checked=$((human_checked + 1))
                            fi
                        fi
                    done < "$task_file"

                    if [ "$human_checked" -ge "$human_total" ] && [ "$human_total" -gt 0 ]; then
                        echo -e "${GREEN}All Human ACs checked (${human_checked}/${human_total})${NC}"
                        exit 0
                    fi
                    printf "\r  Waiting... %s/%s checked (%ss)" "$human_checked" "$human_total" "$elapsed"
                done
                echo ""
                echo -e "${YELLOW}Timeout — ${human_checked}/${human_total} Human ACs checked${NC}"
                exit 1
            fi
            ;;
        ""|help|-h|--help)
            echo -e "${BOLD}fw task${NC} - Task management"
            echo ""
            echo "Subcommands:"
            echo "  create    Create a new task"
            echo "  update    Update task status (auto-triggers healing/episodic)"
            echo "  list      List all tasks (filterable)"
            echo "  show      Show task detail with episodic summary"
            echo "  stale     Show tasks with no updates in 7+ days (--days N)"
            echo "  verify [T-XXX]    Run verification commands (T-070) or list awaiting tasks (T-193)"
            echo "                    Triage flags (T-1628): --compact, --by-age,"
            echo "                    --rubber-stamp-only, --review-only"
            echo "  review T-XXX      Print clickable URL + QR code for Human AC review"
            echo "  review-batch T-A T-B [...]  Emit markdown table of full URLs for multi-task chat handoff (T-2182)"
            echo "  revisit-due       Show tasks whose revisit_at is <= today UTC (G-053 / T-1452)"
            echo ""
            echo "Usage:"
            echo '  fw task create --name "Fix bug" --type build --owner human'
            echo '  fw task update T-XXX --status issues --reason "API timeout"'
            echo '  fw task update T-XXX --add-tag "watchtower,ui"'
            echo '  fw task update T-XXX --status work-completed'
            echo '  fw task verify T-XXX  # Run verification commands from task'
            echo '  fw task review T-631  # Print URL + QR for Human AC review'
            echo '  fw task review T-631 --poll  # Poll until all Human ACs checked'
            echo '  fw task review-batch T-A T-B T-C  # Markdown table of full URLs (chat handoff)'
            echo '  fw task list --status captured --type build'
            echo '  fw task list --tag watchtower'
            echo '  fw task show T-001'
            echo '  fw task stale            # Show tasks older than 7 days'
            echo '  fw task stale --days 14  # Show tasks older than 14 days'
            echo '  fw task verify  # List tasks awaiting human verification'
            echo '  fw task verify --compact --by-age              # one-line, oldest first'
            echo '  fw task verify --compact --rubber-stamp-only   # mechanical drains first'
            ;;
        stale)
            # T-812: Show tasks that haven't had updates or commits in N days
            FW_TASK_STALE_ARGS="$*" python3 - << 'PYTASK_STALE'
import os, sys, shlex
from datetime import datetime, timezone, timedelta

args = shlex.split(os.environ.get('FW_TASK_STALE_ARGS', ''))
threshold_days = 7
i = 0
while i < len(args):
    if args[i] == '--days' and i + 1 < len(args):
        threshold_days = int(args[i+1]); i += 2
    elif args[i] in ('-h', '--help'):
        print("Usage: fw task stale [--days N]")
        print("  Show active tasks with no updates in the last N days (default: 7)")
        sys.exit(0)
    else:
        i += 1

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
RED = '\033[0;31m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
active_dir = os.path.join(project_root, '.tasks', 'active')

if not os.path.isdir(active_dir):
    print(f'{RED}No active tasks directory{NC}')
    sys.exit(1)

now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=threshold_days)
stale_tasks = []

for fn in sorted(os.listdir(active_dir)):
    if not fn.endswith('.md'):
        continue

    filepath = os.path.join(active_dir, fn)
    try:
        with open(filepath) as f:
            text = f.read()
    except:
        continue

    if not text.startswith('---'):
        continue

    try:
        end = text.index('---', 3)
        block = text[3:end]
    except ValueError:
        continue

    # Parse frontmatter fields
    fm = {}
    for line in block.strip().split('\n'):
        if ':' in line:
            key = line.split(':')[0].strip()
            val = ':'.join(line.split(':')[1:]).strip().strip('"').strip("'")
            fm[key] = val

    task_id = fm.get('id', '?')
    status = fm.get('status', '?')
    name = fm.get('name', '?')
    horizon = fm.get('horizon', '?')
    last_update = fm.get('last_update', fm.get('created', ''))

    # Parse last_update as datetime
    try:
        if 'T' in last_update:
            lu = datetime.fromisoformat(last_update.replace('Z', '+00:00'))
        else:
            lu = datetime.strptime(last_update[:10], '%Y-%m-%d').replace(tzinfo=timezone.utc)
    except:
        lu = None

    if lu and lu < cutoff:
        age_days = (now - lu).days
        stale_tasks.append({
            'id': task_id, 'name': name, 'status': status,
            'horizon': horizon, 'age': age_days, 'last_update': last_update[:10]
        })

# Sort by age descending
stale_tasks.sort(key=lambda t: -t['age'])

if not stale_tasks:
    print(f'{GREEN}No stale tasks (threshold: {threshold_days} days){NC}')
    sys.exit(0)

print(f'{BOLD}Stale Tasks{NC} ({len(stale_tasks)} older than {threshold_days} days)')
print()
print(f'  {"ID":<8} {"Age":>5} {"Last Update":<12} {"Status":<18} {"Hz":<6} {"Name"}')
print(f'  {"─"*8} {"─"*5} {"─"*12} {"─"*18} {"─"*6} {"─"*30}')

for t in stale_tasks:
    age_str = f'{t["age"]}d'
    color = RED if t['age'] > 30 else YELLOW if t['age'] > 14 else NC
    print(f'  {t["id"]:<8} {color}{age_str:>5}{NC} {t["last_update"]:<12} {t["status"]:<18} {t["horizon"]:<6} {t["name"][:50]}')
PYTASK_STALE
            ;;
        archive-eligible)
            # T-1903 / L-403: sweep active/ for status: work-completed tasks whose
            # acceptance criteria are fully ticked (after HTML-comment strip).
            # These got stuck in active/ when a later re-class (T-1894-style:
            # [REVIEW] → [REVIEWER]) drained the Human-AC count to zero — the
            # partial-complete recheck only re-fires when --status work-completed
            # is invoked again. This sweep does that invocation for each candidate.
            FW_TASK_ARCHIVE_ARGS="$*" python3 - << 'PYTASK_ARCHIVE'
import os, sys, re, shlex, subprocess

args = shlex.split(os.environ.get('FW_TASK_ARCHIVE_ARGS', ''))
dry_run = False
verbose = False
i = 0
while i < len(args):
    if args[i] in ('-n', '--dry-run'):
        dry_run = True; i += 1
    elif args[i] in ('-v', '--verbose'):
        verbose = True; i += 1
    elif args[i] in ('-h', '--help'):
        print("Usage: fw task archive-eligible [--dry-run] [--verbose]")
        print("  Sweep .tasks/active/ for status: work-completed tasks whose")
        print("  acceptance criteria are all ticked. Re-runs the partial-complete")
        print("  recheck on each, which moves them to completed/.")
        print("  --dry-run lists candidates without invoking the recheck.")
        print("  Origin: L-403 (T-1903) — re-class operations can leave tasks stuck.")
        sys.exit(0)
    else:
        i += 1

GREEN = '\033[0;32m'; YELLOW = '\033[1;33m'; CYAN = '\033[0;36m'
RED = '\033[0;31m'; BOLD = '\033[1m'; NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
active_dir = os.path.join(project_root, '.tasks', 'active')
fw_bin = os.environ.get('FW_BIN', os.path.join(project_root, 'bin', 'fw'))
if not os.path.isfile(fw_bin):
    fw_bin = os.path.join(project_root, '.agentic-framework', 'bin', 'fw')

if not os.path.isdir(active_dir):
    print(f'{YELLOW}No active/ directory — nothing to sweep.{NC}')
    sys.exit(0)

def is_archive_eligible(path):
    """Return (eligible, task_id, reason) for a task file."""
    try:
        text = open(path).read()
    except OSError:
        return (False, '?', 'unreadable')
    m = re.search(r'^id:\s*(T-\d+)', text, re.MULTILINE)
    task_id = m.group(1) if m else os.path.basename(path)
    m = re.search(r'^status:\s*(\S+)', text, re.MULTILINE)
    if not m or m.group(1) != 'work-completed':
        return (False, task_id, f'status={m.group(1) if m else "?"}')
    ac_match = re.search(r'^## Acceptance Criteria\b(.*?)(?=^## )', text, re.MULTILINE | re.DOTALL)
    if not ac_match:
        return (False, task_id, 'no AC section')
    ac = re.sub(r'<!--.*?-->', '', ac_match.group(1), flags=re.DOTALL)
    unchecked = len(re.findall(r'^\s*-\s*\[ \]', ac, re.MULTILINE))
    total = len(re.findall(r'^\s*-\s*\[[ x]\]', ac, re.MULTILINE))
    if total > 0 and unchecked == 0:
        return (True, task_id, f'{total} AC all ticked')
    return (False, task_id, f'{unchecked}/{total} unchecked')

candidates = []
for fn in sorted(os.listdir(active_dir)):
    if not (fn.startswith('T-') and fn.endswith('.md')):
        continue
    path = os.path.join(active_dir, fn)
    eligible, task_id, reason = is_archive_eligible(path)
    if eligible:
        candidates.append((task_id, path, reason))
    elif verbose:
        print(f'  {CYAN}skip{NC} {task_id}: {reason}')

if not candidates:
    print(f'{GREEN}No stuck partial-complete tasks — sweep is a no-op.{NC}')
    sys.exit(0)

print(f'{BOLD}{len(candidates)} archive-eligible task(s):{NC}')
for task_id, _, reason in candidates:
    print(f'  {YELLOW}•{NC} {task_id} ({reason})')

if dry_run:
    print()
    print(f'{CYAN}(dry-run — no changes made){NC}')
    print(f'Run without --dry-run to invoke partial-complete recheck on each.')
    sys.exit(0)

print()
moved = 0
failed = 0
for task_id, path, _ in candidates:
    print(f'{CYAN}→ {task_id}{NC}: invoking partial-complete recheck...')
    # Re-invoke --status work-completed on the same task. update-task.sh's
    # OLD_STATUS == NEW_STATUS branch (line ~941) re-checks AC totals and
    # moves to completed/ if all are ticked. Pass --switch-focus to bypass
    # the focus-drift hook in case the current focus is some other task.
    r = subprocess.run(
        [fw_bin, 'task', 'update', task_id, '--status', 'work-completed', '--switch-focus'],
        cwd=project_root,
        capture_output=True, text=True
    )
    if r.returncode == 0 and 'Moved to completed/' in r.stdout:
        print(f'  {GREEN}✓ archived{NC}')
        moved += 1
    elif r.returncode == 0:
        print(f'  {YELLOW}? recheck ran but no move — task may still have unchecked ACs{NC}')
        if verbose:
            print('  --- stdout ---'); print(r.stdout)
        failed += 1
    else:
        print(f'  {RED}✗ recheck failed (exit {r.returncode}){NC}')
        if verbose:
            print('  --- stderr ---'); print(r.stderr)
        failed += 1

print()
print(f'{BOLD}Sweep complete:{NC} {GREEN}{moved} moved{NC}, {YELLOW}{failed} skipped{NC}')
if failed > 0:
    sys.exit(1)
PYTASK_ARCHIVE
            ;;
        reid)
            # T-1367: Safely rename a task's ID — updates filename + frontmatter atomically.
            # Intended for repairing duplicate-ID pairs surfaced by `fw audit` (G-052).
            old_id="${1:-}"; new_id="${2:-}"
            if [ -z "$old_id" ] || [ -z "$new_id" ]; then
                echo -e "${RED}Usage: fw task reid <OLD-ID> <NEW-ID>${NC}" >&2
                echo "Example: fw task reid T-1278 T-1369" >&2
                exit 1
            fi
            # Validate ID format
            if ! [[ "$old_id" =~ ^T-[0-9]+$ ]]; then
                echo -e "${RED}ERROR: OLD-ID must match T-NNNN (got: $old_id)${NC}" >&2
                exit 1
            fi
            if ! [[ "$new_id" =~ ^T-[0-9]+$ ]]; then
                echo -e "${RED}ERROR: NEW-ID must match T-NNNN (got: $new_id)${NC}" >&2
                exit 1
            fi
            # Locate the old file
            old_file=""
            for d in active completed; do
                for f in "$PROJECT_ROOT/.tasks/$d/$old_id"-*.md; do
                    [ -f "$f" ] || continue
                    if [ -n "$old_file" ]; then
                        echo -e "${YELLOW}NOTE: Multiple files match $old_id:${NC}" >&2
                        echo "  $old_file" >&2
                        echo "  $f" >&2
                        echo "Renaming the first match. Re-run reid for the second." >&2
                        break 2
                    fi
                    old_file="$f"
                done
            done
            if [ -z "$old_file" ]; then
                echo -e "${RED}ERROR: No task file found for $old_id${NC}" >&2
                exit 1
            fi
            # Check NEW-ID is free
            for d in active completed; do
                for f in "$PROJECT_ROOT/.tasks/$d/$new_id"-*.md; do
                    [ -f "$f" ] || continue
                    echo -e "${RED}ERROR: $new_id already exists: $f${NC}" >&2
                    echo "Pick a different NEW-ID or remove the existing file first." >&2
                    exit 1
                done
            done
            # Compute new filename (preserve the slug portion)
            old_basename="$(basename "$old_file")"
            slug_plus_ext="${old_basename#$old_id-}"
            new_file="$(dirname "$old_file")/$new_id-$slug_plus_ext"
            # Perform the rename: git-mv if tracked, else plain mv; then update frontmatter
            if git -C "$PROJECT_ROOT" ls-files --error-unmatch "$old_file" >/dev/null 2>&1; then
                git -C "$PROJECT_ROOT" mv "$old_file" "$new_file"
            else
                mv "$old_file" "$new_file"
            fi
            # Update `id:` in frontmatter (first occurrence only — it's in the YAML block)
            python3 - "$new_file" "$old_id" "$new_id" <<'PYREID'
import sys, datetime
path, old_id, new_id = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path) as f:
    text = f.read()
# Replace id: line (first match only, within frontmatter)
lines = text.split('\n')
in_fm = False
replaced = False
ts = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
for i, line in enumerate(lines):
    if line.strip() == '---':
        in_fm = not in_fm
        continue
    if in_fm and not replaced and line.startswith('id:'):
        lines[i] = f'id: {new_id}'
        replaced = True
        break
# Append update entry
update_note = f'\n### {ts} — reid [fw-task]\n- **Change:** id {old_id} → {new_id} (via `fw task reid`)\n- **Context:** Duplicate-ID repair or manual rename\n'
# Prefer to append inside ## Updates; otherwise at EOF
joined = '\n'.join(lines)
if '## Updates' in joined:
    joined = joined.rstrip() + update_note
else:
    joined = joined.rstrip() + '\n\n## Updates\n' + update_note
with open(path, 'w') as f:
    f.write(joined)
print(f'OK — renamed {old_id} → {new_id} in frontmatter')
PYREID
            echo -e "${GREEN}Task reid complete:${NC}"
            echo "  OLD: $old_file"
            echo "  NEW: $new_file"
            ;;
        revisit-due)
            # T-1453: On-demand revisit_at scan. Reuses T-1452 cron scanner so
            # the CLI and the daily handover banner share one implementation.
            # The scan rewrites .context/working/.revisits-due.txt fresh each
            # call, so output reflects current frontmatter — not yesterday's cron.
            SCAN="$AGENTS_DIR/context/revisit-due-scan.sh"
            if [ ! -x "$SCAN" ]; then
                echo -e "${RED}ERROR: revisit-due-scan.sh missing or not executable at $SCAN${NC}" >&2
                exit 1
            fi
            PROJECT_ROOT="$PROJECT_ROOT" "$SCAN"
            OUT="$PROJECT_ROOT/.context/working/.revisits-due.txt"
            today=$(date -u +%Y-%m-%d)
            if [ -s "$OUT" ]; then
                ripe_count=$(wc -l < "$OUT" | tr -d ' ')
                echo -e "${BOLD}Ripe revisits ($today UTC) — ${ripe_count} task(s):${NC}"
                echo ""
                cat "$OUT"
            else
                echo "No revisits due today ($today UTC)"
            fi
            ;;
        *)
            echo -e "${RED}Unknown task subcommand: $subcmd${NC}"
            echo "Run 'fw task help' for usage"
            exit 1
            ;;
    esac
}

# --- Main Command Routing ---

cmd="${1:-}"
shift || true

case "$cmd" in
    ask)
        exec "$FW_LIB_DIR/ask.sh" "$@"
        ;;
    audit)
        exec "$AGENTS_DIR/audit/audit.sh" "$@"
        ;;
    reviewer)
        # T-1443 v1.0–v1.4: machine-reviewer (anti-pattern static-scan + audit + overrides)
        if [ "${1:-}" = "" ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
            echo "Usage:"
            echo "  fw reviewer T-XXX [--json] [--no-write]                      Scan one task (inline)"
            echo "  fw reviewer T-XXX --dispatch [--timeout N] [--json]         Scan via isolated TermLink worker (T-1951)"
            echo "  fw reviewer audit [--pass-a [--baseline [--force]] | --pass-b]"
            echo "                    [--limit N] [--quiet] [--timeout N]"
            echo "                                                               Layer 3 daily corpus re-scan"
            echo "                                                               (default: static-scan;"
            echo "                                                                --pass-a: v1.5 drift signal;"
            echo "                                                                --pass-b: v1.5 reverify)"
            echo "  fw reviewer drift T-XXX                                      v1.5 Pass A — file-hash drift"
            echo "  fw reviewer reverify T-XXX                                   v1.5 Pass B — worktree re-execution"
            echo "  fw reviewer override add T-XXX --pattern X [--ac N] --reason '...' [--ttl 90]"
            echo "  fw reviewer override list                                    Active overrides + days remaining"
            echo "  fw reviewer override prune                                   Drop expired entries"
            echo "  fw reviewer override remove OV-XXXX                          Remove specific override"
            echo ""
            echo "Scan: writes verdict to task body under '## Reviewer Verdict (vX.Y)'."
            echo "Audit (default): static-scan re-scan; writes .context/audits/reviewer/YYYY-MM-DD.yaml"
            echo "Audit --pass-a: v1.5 drift signal corpus scan; writes YYYY-MM-DD-pass-a.yaml"
            echo "  --baseline: one-shot init (write hashes); --force: overwrite existing baselines"
            echo "Audit --pass-b: v1.5 worktree re-execution corpus mode; writes YYYY-MM-DD-pass-b.yaml"
            echo "  --limit N caps tasks (cron budget); --quiet suppresses per-task lines; --timeout N (default 30s)"
            echo "Drift (v1.5 Pass A): hashes files referenced in ## Verification, compares vs baseline."
            echo "Reverify (v1.5 Pass B): re-executes verification commands inside a worktree at the"
            echo "  task's completion SHA. Network-dependent lines are skipped."
            echo "Overrides: per-(task,pattern,ac?) TTL'd waivers stored in"
            echo "  .context/working/reviewer-overrides.yaml. Suppress known false positives"
            echo "  for a bounded window; suppressions are logged to feedback-stream.yaml."
            exit 0
        fi
        # T-1642: PYTHONPATH=$FRAMEWORK_ROOT so `python3 -m lib.reviewer.*` resolves
        # in vendored consumer projects where lib/ lives at .agentic-framework/lib/
        # rather than at the project root. Framework-repo-self is unaffected
        # (PROJECT_ROOT == FRAMEWORK_ROOT there, no behaviour change).
        # T-1951: dispatch mode — forward to dispatch_cli if --dispatch is in any arg
        _has_dispatch=0
        for _rarg in "$@"; do
            if [ "$_rarg" = "--dispatch" ]; then _has_dispatch=1; break; fi
        done
        if [ "$_has_dispatch" = "1" ]; then
            # Strip --dispatch from args before forwarding (dispatch_cli argparse
            # does not know about --dispatch; bin/fw owns the routing decision).
            _dispatch_args=()
            for _rarg in "$@"; do
                [ "$_rarg" != "--dispatch" ] && _dispatch_args+=("$_rarg")
            done
            exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
                PYTHONPATH="$FRAMEWORK_ROOT" \
                python3 -m lib.reviewer.dispatch_cli "${_dispatch_args[@]}"
        fi
        if [ "${1:-}" = "audit" ]; then
            shift
            exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
                PYTHONPATH="$FRAMEWORK_ROOT" \
                python3 -m lib.reviewer.audit "$@"
        fi
        if [ "${1:-}" = "override" ]; then
            shift
            exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
                PYTHONPATH="$FRAMEWORK_ROOT" \
                python3 -m lib.reviewer.override_cli "$@"
        fi
        if [ "${1:-}" = "drift" ]; then
            shift
            exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
                PYTHONPATH="$FRAMEWORK_ROOT" \
                python3 -m lib.reviewer.drift_cli "$@"
        fi
        if [ "${1:-}" = "reverify" ]; then
            shift
            exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
                PYTHONPATH="$FRAMEWORK_ROOT" \
                python3 -m lib.reviewer.reverify_cli "$@"
        fi
        exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
            PYTHONPATH="$FRAMEWORK_ROOT" \
            python3 -m lib.reviewer.static_scan "$@"
        ;;
    ux-review)
        # T-2002: UX-review capture engine (approach C of inception T-2000) —
        # browser-driving console + interaction + guide-coherence review of an
        # interactive render surface. Executes page JS; informs the human [REVIEW].
        exec env PROJECT_ROOT="$PROJECT_ROOT" FRAMEWORK_ROOT="$FRAMEWORK_ROOT" \
            python3 "$AGENTS_DIR/ux-review/ux-review.py" "$@"
        ;;
    self-audit)
        exec "$AGENTS_DIR/audit/self-audit.sh" "$@"
        ;;
    gpu)
        # T-1182: GPU coordination — Layer 2 of T-1180 GO. Reactive cross-process kick.
        gpu_subcmd="${1:-}"
        shift || true
        case "$gpu_subcmd" in
            recover)
                exec bash "$AGENTS_DIR/gpu-recover/recover.sh" "$@"
                ;;
            ""|-h|--help)
                echo "Usage: fw gpu <subcommand> [options]"
                echo ""
                echo "Subcommands:"
                echo "  recover    Free VRAM by killing heaviest non-ollama consumer"
                echo ""
                echo "Run 'fw gpu recover --help' for recover-specific options."
                exit 0
                ;;
            *)
                echo -e "${RED}Unknown gpu subcommand: $gpu_subcmd${NC}" >&2
                echo "Run 'fw gpu' for usage." >&2
                exit 1
                ;;
        esac
        ;;
    test-onboarding)
        exec "$AGENTS_DIR/onboarding-test/test-onboarding.sh" "$@"
        ;;
    plugin-audit)
        exec "$AGENTS_DIR/audit/plugin-audit.sh" "$@"
        ;;
    context)
        exec "$AGENTS_DIR/context/context.sh" "$@"
        ;;
    arc)
        # T-1661: Arc system — first-class workspace grouping tasks by theme.
        # Verbs: create / focus / list / show / tag / close / migrate / help
        # shellcheck disable=SC1091
        . "$FRAMEWORK_ROOT/lib/arc.sh"
        arc_dispatch "$@"
        exit $?
        ;;
    bvp)
        # T-1919 (arc-006): Business Value Points read-only CLI.
        # Verbs: <empty>=rank, T-<id>=detail, arcs, --quadrant FILTER, --help
        # Mutating verbs (weight/driver/confirm) land in T-1920/T-1924.
        # shellcheck disable=SC1091
        . "$FRAMEWORK_ROOT/lib/bvp.sh"
        bvp_dispatch "$@"
        exit $?
        ;;
    write-set)
        # T-2337 (arc-011 M1 §3): disjoint write-set validator.
        # Verbs: check <T-A> <T-B> — compare two tasks' declared write_set: frontmatter
        # Exit codes: 0=disjoint, 1=overlap, 2=undecidable
        ws_subcmd="${1:-}"
        shift || true
        case "$ws_subcmd" in
            check)
                if [ $# -lt 2 ]; then
                    echo "usage: fw write-set check <T-A> <T-B>" >&2
                    exit 64
                fi
                exec env PROJECT_ROOT="$PROJECT_ROOT" python3 "$FRAMEWORK_ROOT/lib/write_set.py" check "$1" "$2"
                ;;
            ""|--help|-h|help)
                cat <<'EOF'
fw write-set check <T-A> <T-B>

Compare two tasks' declared `write_set:` frontmatter and report whether
they are disjoint, overlap, or undecidable. Used by the arc-011 orchestrator
before emitting parallel dispatch (T-2337, arc-011 M1 §3).

Exit codes:
  0  disjoint    — both write-sets declared, no path overlap
  1  overlap     — both declared, at least one shared path
  2  undecidable — at least one task lacks write_set: frontmatter

Examples:
  fw write-set check T-PAR-A T-PAR-B    # Two disjoint tasks → exit 0
  fw write-set check T-COL-A T-COL-B    # Two overlapping tasks → exit 1
EOF
                exit 0
                ;;
            *)
                echo "ERROR: unknown write-set subcommand: $ws_subcmd" >&2
                echo "Run 'fw write-set --help' for usage." >&2
                exit 64
                ;;
        esac
        ;;
    cron)
        # T-448: Cron registry management
        cron_subcmd="${1:-}"
        shift || true
        case "$cron_subcmd" in
            generate)
                # Regenerate crontab from registry YAML
                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                if [ ! -f "$_cron_registry" ]; then
                    echo -e "${RED}ERROR:${NC} No cron registry found at $_cron_registry"
                    exit 1
                fi
                python3 -c "
import yaml, os, re, sys

project_root = '$PROJECT_ROOT'
fw_path = '$FRAMEWORK_ROOT/bin/fw'
registry_file = '$_cron_registry'

data = yaml.safe_load(open(registry_file))
jobs = data.get('jobs', [])

slug = os.path.basename(project_root).lower()
slug = re.sub(r'[^a-z0-9_-]', '-', slug)
cron_source = os.path.join(project_root, '.context', 'cron', 'agentic-audit.crontab')
cron_install = f'/etc/cron.d/agentic-audit-{slug}'

lines = [
    f'# Agentic Engineering Framework — Scheduled Jobs (managed by cron-registry.yaml)',
    f'# Source of truth: {cron_source} (git-tracked)',
    f'# Installed to: {cron_install}',
    f'# Project: {project_root}',
    'SHELL=/bin/bash',
    'PATH=/usr/local/bin:/usr/bin:/bin',
    '',
]

active = paused = 0
for job in jobs:
    schedule = job.get('schedule', '')
    command = job.get('command', '')
    name = job.get('name', '')
    status = job.get('status', 'active')

    # T-1249: Resolve ALL occurrences of bare 'fw' (not just leading)
    # T-1720: cd into PROJECT_ROOT for fw commands too — fw subcommands that
    # invoke `python3 -m lib.X` need cwd=PROJECT_ROOT (cron's default cwd is
    # HOME). Without this the module import fails and the line silently no-ops.
    if 'fw ' in command:
        resolved = re.sub(r'\bfw\b', f'\"{fw_path}\"', command)
        resolved = f'cd \"{project_root}\" && PROJECT_ROOT=\"{project_root}\" {resolved}'
    else:
        # All non-fw commands run from project root (relative paths need it)
        resolved = f'cd \"{project_root}\" && {command}'

    # T-1720: pipe stderr+stdout to syslog instead of /dev/null so cron-time
    # failures are discoverable. /opt/termlink cron lines already use this.
    if '2>&1 | logger' not in resolved and '2>/dev/null' not in resolved:
        resolved += ' 2>&1 | logger -t agentic-cron'
    elif '2>/dev/null' in resolved:
        resolved = resolved.replace('2>/dev/null', '2>&1 | logger -t agentic-cron')

    lines.append(f'# {name}')
    if status == 'paused':
        lines.append(f'# PAUSED: {schedule} root {resolved}')
        paused += 1
    else:
        lines.append(f'{schedule} root {resolved}')
        active += 1
    lines.append('')

os.makedirs(os.path.dirname(cron_source), exist_ok=True)
with open(cron_source, 'w') as f:
    f.write('\n'.join(lines))

print(f'Generated {cron_source}')
print(f'  {active} active, {paused} paused ({len(jobs)} total)')
print(f'  Install with: fw audit schedule install')
"
                ;;
            status)
                # Show registry status
                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                if [ ! -f "$_cron_registry" ]; then
                    echo "No cron registry at $_cron_registry"
                    exit 0
                fi
                python3 -c "
import yaml
data = yaml.safe_load(open('$_cron_registry'))
jobs = data.get('jobs', [])
active = sum(1 for j in jobs if j.get('status') == 'active')
paused = sum(1 for j in jobs if j.get('status') == 'paused')
print(f'Cron registry: {len(jobs)} jobs ({active} active, {paused} paused)')
for j in jobs:
    s = j.get('status', 'active')
    icon = '  ✓' if s == 'active' else '  ‖'
    print(f'{icon} {j[\"id\"]:25s} {j[\"schedule\"]:20s} [{s}]  {j.get(\"name\",\"\")}')
"
                ;;
            list)
                # Alias for status
                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                if [ ! -f "$_cron_registry" ]; then
                    echo "No cron registry at $_cron_registry"
                    exit 0
                fi
                python3 -c "
import yaml
data = yaml.safe_load(open('$_cron_registry'))
jobs = data.get('jobs', [])
active = sum(1 for j in jobs if j.get('status') == 'active')
paused = sum(1 for j in jobs if j.get('status') == 'paused')
print(f'Cron registry: {len(jobs)} jobs ({active} active, {paused} paused)')
for j in jobs:
    s = j.get('status', 'active')
    icon = '  ✓' if s == 'active' else '  ‖'
    print(f'{icon} {j[\"id\"]:25s} {j[\"schedule\"]:20s} [{s}]  {j.get(\"name\",\"\")}')
"
                ;;
            run)
                # Run a job immediately (T-654)
                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                _job_id="${1:-}"
                if [ -z "$_job_id" ] || [ ! -f "$_cron_registry" ]; then
                    if [ ! -f "$_cron_registry" ]; then
                        echo -e "${RED}ERROR:${NC} No cron registry at $_cron_registry"
                        exit 1
                    fi
                    echo -e "${RED}Usage:${NC} fw cron run <job-id>"
                    echo ""
                    echo "Available jobs:"
                    python3 -c "
import yaml
data = yaml.safe_load(open('$_cron_registry'))
for j in data.get('jobs', []):
    print(f'  {j[\"id\"]:25s} {j.get(\"name\",\"\")}')
"
                    exit 1
                fi
                python3 -c "
import yaml, subprocess, time, sys, os

data = yaml.safe_load(open('$_cron_registry'))
job = None
for j in data.get('jobs', []):
    if j.get('id') == '$_job_id':
        job = j
        break

if not job:
    print(f'ERROR: Job \"$_job_id\" not found in registry', file=sys.stderr)
    sys.exit(1)

command = job.get('command', '')
if not command:
    print('ERROR: Job has no command', file=sys.stderr)
    sys.exit(1)

# Resolve fw commands
if command.startswith('fw '):
    command = f'$FRAMEWORK_ROOT/bin/fw {command[3:]}'
elif command.startswith('find '):
    command = f'cd \"$PROJECT_ROOT\" && {command}'

print(f'Running: {job.get(\"name\", \"\")} ({job[\"id\"]})')
print(f'Command: {command}')
print()

start = time.time()
try:
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True,
        timeout=120, cwd='$PROJECT_ROOT',
        env={**os.environ, 'PROJECT_ROOT': '$PROJECT_ROOT'}
    )
    elapsed = time.time() - start
    if result.stdout:
        # Show last 10 lines of output
        lines = result.stdout.strip().split('\n')
        for line in lines[-10:]:
            print(f'  {line}')
    if result.stderr and result.returncode != 0:
        for line in result.stderr.strip().split('\n')[-5:]:
            print(f'  STDERR: {line}')
    print()
    print(f'Exit: {result.returncode} ({elapsed:.1f}s)')
    sys.exit(result.returncode)
except subprocess.TimeoutExpired:
    print('ERROR: Command timed out after 120s', file=sys.stderr)
    sys.exit(124)
"
                ;;
            pause|resume)
                # Pause or resume a job (T-655)
                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                _job_id="${1:-}"
                _action="$cron_subcmd"
                _new_status="paused"
                [ "$_action" = "resume" ] && _new_status="active"

                if [ -z "$_job_id" ] || [ ! -f "$_cron_registry" ]; then
                    if [ ! -f "$_cron_registry" ]; then
                        echo -e "${RED}ERROR:${NC} No cron registry at $_cron_registry"
                        exit 1
                    fi
                    echo -e "${RED}Usage:${NC} fw cron $_action <job-id>"
                    echo ""
                    echo "Available jobs:"
                    python3 -c "
import yaml
data = yaml.safe_load(open('$_cron_registry'))
for j in data.get('jobs', []):
    s = j.get('status', 'active')
    print(f'  {j[\"id\"]:25s} [{s}]  {j.get(\"name\",\"\")}')
"
                    exit 1
                fi
                python3 -c "
import yaml, sys

data = yaml.safe_load(open('$_cron_registry'))
job = None
for j in data.get('jobs', []):
    if j.get('id') == '$_job_id':
        job = j
        break

if not job:
    print(f'ERROR: Job \"$_job_id\" not found', file=sys.stderr)
    sys.exit(1)

old_status = job.get('status', 'active')
new_status = '$_new_status'
if old_status == new_status:
    print(f'Job \"{job[\"id\"]}\" is already {new_status}')
    sys.exit(0)

job['status'] = new_status
with open('$_cron_registry', 'w') as f:
    yaml.dump(data, f, default_flow_style=False, sort_keys=False)

print(f'{\"Paused\" if new_status == \"paused\" else \"Resumed\"}: {job.get(\"name\", job[\"id\"])}')
print(f'  Status: {old_status} → {new_status}')
print(f'  Run \"fw cron generate\" to update the crontab file')
"
                ;;
            install)
                # T-1114: Unified generate + install chokepoint. Replaces the
                # two-step "fw cron generate" + "fw audit schedule install"
                # dance that silently clobbered the registry-sourced crontab
                # with a hardcoded template (see T-1112 RCA).
                _install_dry_run=false
                while [ $# -gt 0 ]; do
                    case "$1" in
                        --dry-run) _install_dry_run=true; shift ;;
                        *) shift ;;
                    esac
                done

                _cron_registry="$PROJECT_ROOT/.context/cron-registry.yaml"
                if [ ! -f "$_cron_registry" ]; then
                    echo -e "${RED}ERROR:${NC} No cron registry found at $_cron_registry" >&2
                    exit 1
                fi

                _cron_source="$PROJECT_ROOT/.context/cron/agentic-audit.crontab"
                _cron_target_dir="${FW_CRON_INSTALL_DIR:-/etc/cron.d}"
                _cron_slug=$(basename "$PROJECT_ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')
                _cron_target="$_cron_target_dir/agentic-audit-${_cron_slug}"

                # Step 1: regenerate crontab from registry (re-using fw cron generate logic)
                if ! "$0" cron generate >/dev/null; then
                    echo -e "${RED}ERROR:${NC} fw cron generate failed" >&2
                    exit 1
                fi
                if [ ! -f "$_cron_source" ]; then
                    echo -e "${RED}ERROR:${NC} Expected $_cron_source after generate" >&2
                    exit 1
                fi

                # Step 2: compute diff vs currently installed
                if [ -f "$_cron_target" ] && diff -q "$_cron_source" "$_cron_target" >/dev/null 2>&1; then
                    echo "Cron in sync: $_cron_target"
                    echo "  Source: $_cron_source"
                    exit 0
                fi

                echo "Pending cron changes:"
                if [ -f "$_cron_target" ]; then
                    diff -u "$_cron_target" "$_cron_source" | sed 's/^/  /' || true
                else
                    echo "  (new install — $_cron_target does not exist)"
                    sed -n '1,20p' "$_cron_source" | sed 's/^/  /'
                    echo "  ..."
                fi

                if $_install_dry_run; then
                    echo ""
                    echo "(dry-run — no changes made)"
                    echo "Run without --dry-run to apply."
                    exit 0
                fi

                # Step 3: install atomically with sudo degradation
                mkdir -p "$_cron_target_dir" 2>/dev/null || true
                if [ "$(id -u)" = "0" ] || [ "$_cron_target_dir" != "/etc/cron.d" ]; then
                    install -m 0644 "$_cron_source" "$_cron_target"
                    echo "Installed: $_cron_target"
                elif command -v sudo >/dev/null 2>&1; then
                    sudo install -m 0644 "$_cron_source" "$_cron_target"
                    echo "Installed (via sudo): $_cron_target"
                else
                    echo ""
                    echo "NOTE: Root permissions required to install cron."
                    echo "Run this command manually:"
                    echo ""
                    echo "  sudo install -m 0644 \"$_cron_source\" \"$_cron_target\""
                    echo ""
                    exit 1
                fi
                ;;
            ""|help|-h|--help)
                echo -e "${BOLD}fw cron${NC} — Cron registry management (T-448)"
                echo ""
                echo "Subcommands:"
                echo "  generate          Regenerate crontab from registry YAML"
                echo "  install [--dry-run]  Generate + install to /etc/cron.d/ atomically"
                echo "  status            Show registry status (jobs, active/paused)"
                echo "  list              Alias for status"
                echo "  run <id>          Run a job immediately"
                echo "  pause <id>        Pause a job (comments out in crontab)"
                echo "  resume <id>       Resume a paused job"
                echo ""
                echo "Registry: .context/cron-registry.yaml"
                echo "Web UI:   Watchtower /cron page (fw serve to start)"
                echo ""
                echo "Env overrides:"
                echo "  FW_CRON_INSTALL_DIR  Target directory (default /etc/cron.d)"
                ;;
            *)
                echo -e "${RED}Unknown cron subcommand:${NC} $cron_subcmd"
                echo "Run 'fw cron help' for usage."
                exit 1
                ;;
        esac
        ;;
    docs)
        if [ "${1:-}" = "article" ]; then
            shift
            exec "$AGENTS_DIR/docgen/generate-article.sh" "$@"
        fi
        exec "$AGENTS_DIR/docgen/generate-component.sh" "$@"
        ;;
    fabric)
        exec "$AGENTS_DIR/fabric/fabric.sh" "$@"
        ;;
    git)
        exec "$AGENTS_DIR/git/git.sh" "$@"
        ;;
    push)
        # T-1153: Push to all remotes with per-remote status.
        # T-1513: align with handover hook's T-1255 logic — when `origin` exists
        # and multiple remotes are configured, push only to origin (mirrors are
        # OneDev's job via PushRepository). Also skip remotes whose push URL is
        # the sentinel `no_push` (set via `git remote set-url --push X no_push`).
        echo -e "${BOLD}fw push${NC} — Push to all remotes"
        echo ""
        _push_ok=0
        _push_fail=0
        _push_skipped=0
        _remote_count=$(git -C "$PROJECT_ROOT" remote 2>/dev/null | wc -l)
        if git -C "$PROJECT_ROOT" remote 2>/dev/null | grep -qx 'origin'; then
            _has_origin=true
        else
            _has_origin=false
        fi
        while IFS= read -r _remote; do
            [ -z "$_remote" ] && continue
            if [ "$_has_origin" = true ] && [ "$_remote_count" -gt 1 ] && [ "$_remote" != "origin" ]; then
                echo -e "  $_remote: ${CYAN}skipped (mirrored from origin)${NC}"
                _push_skipped=$((_push_skipped + 1))
                continue
            fi
            _push_url=$(git -C "$PROJECT_ROOT" remote get-url --push "$_remote" 2>/dev/null)
            if [ "$_push_url" = "no_push" ]; then
                echo -e "  $_remote: ${CYAN}skipped (push disabled)${NC}"
                _push_skipped=$((_push_skipped + 1))
                continue
            fi
            echo -n "  $_remote: "
            if git -C "$PROJECT_ROOT" push "$_remote" HEAD "$@" 2>&1 | tail -1; then
                _push_ok=$((_push_ok + 1))
            else
                echo -e "${RED}FAILED${NC}"
                _push_fail=$((_push_fail + 1))
            fi
        done < <(git -C "$PROJECT_ROOT" remote 2>/dev/null)
        echo ""
        if [ "$_push_fail" -gt 0 ]; then
            echo -e "${YELLOW}$_push_ok pushed, $_push_skipped skipped, $_push_fail failed${NC}"
            exit 1
        else
            echo -e "${GREEN}$_push_ok pushed, $_push_skipped skipped ✓${NC}"
        fi
        ;;
    handover)
        exec "$AGENTS_DIR/handover/handover.sh" "$@"
        ;;
    healing)
        exec "$AGENTS_DIR/healing/healing.sh" "$@"
        ;;
    resume)
        exec "$AGENTS_DIR/resume/resume.sh" "$@"
        ;;
    inception)
        source "$FW_LIB_DIR/tasks.sh"
        source "$FW_LIB_DIR/inception.sh"
        do_inception "$@"
        ;;
    orchestrator)
        # T-1695: namespace stub for v2 self-improvement (ADR-0003 v2-readiness).
        # v1 captures dispatch telemetry; v2 will add a learner that mines the log
        # and rewrites templates. Reserve the namespace now so it can't be
        # claimed by an unrelated feature.
        _orch_subcmd="${1:-}"
        case "$_orch_subcmd" in
            next-dispatch)
                # T-2339 (arc-011 M1 §1): build the dispatch graph from the
                # active task pool and emit (task_id, mode) per line.
                shift
                exec env PROJECT_ROOT="$PROJECT_ROOT" python3 "$FRAMEWORK_ROOT/agents/orchestrator/orchestrator-graph.py" next-dispatch "$@"
                ;;
            pre-flight)
                # T-2340 (arc-011 M1 §6): pre-dispatch disjointness gate.
                # Refuses if any in-flight dispatch has write_set overlap with
                # the candidate task. Exit codes: 0=allowed, 1=refused,
                # 2=task-not-found, 64=usage.
                shift
                exec env PROJECT_ROOT="$PROJECT_ROOT" python3 "$FRAMEWORK_ROOT/agents/orchestrator/orchestrator-graph.py" pre-flight "$@"
                ;;
            improve)
                shift
                cat <<EOF
fw orchestrator improve — v2: not yet implemented

This subcommand is reserved for the v2 self-improvement loop (ADR-0003).
v1 only captures dispatch telemetry; the v2 learner is not built.

Data is being captured at:
  .context/dispatches.jsonl        (append-only per-dispatch log; T-1696)
  .context/dispatch-outcomes.jsonl (append-only per-outcome log; T-1697)
  .context/dispatch-blobs/         (per-dispatch prompt blobs)

When v2 lands, this command will mine that data, propose template rewrites,
and (with operator approval) update files in .context/project/workflows/
and prompts/.

Track via: T-1687 (orchestrator-rethink arc), ADR-0003.
EOF
                exit 0
                ;;
            routes)
                # T-1789: CLI mirror of web /orchestrator's route-cache view.
                # Reads route-cache.json (TermLink hub-written) and surfaces
                # learned per-task-type model preferences. Pairs with
                # `fw orchestrator status` — status shows what got dispatched,
                # routes shows what the orchestrator has learned wins.
                # T-1790: --task-type X filter narrows to one task_type.
                shift
                _emit_json=0
                _filter_task_type=""
                _prev=""
                for _arg in "$@"; do
                    [ "$_arg" = "--json" ] && _emit_json=1
                    [ "$_prev" = "--task-type" ] && _filter_task_type="$_arg"
                    _prev="$_arg"
                done
                python3 - "$_emit_json" "$_filter_task_type" <<'PYEOF'
import json
import os
import sys
from collections import defaultdict
from pathlib import Path

emit_json = sys.argv[1] == "1"
filter_task_type = sys.argv[2] if len(sys.argv) > 2 else ""


def _route_cache_path() -> Path:
    """Match web/blueprints/orchestrator.py _route_cache_path() exactly."""
    xdg = os.environ.get("XDG_RUNTIME_DIR")
    if xdg:
        return Path(xdg) / "termlink" / "route-cache.json"
    return Path("/var/lib/termlink/route-cache.json")


def _route_cache_summary() -> dict:
    """Aggregate route-cache model_stats by task_type. Mirrors web side
    (_route_cache_learned). Shape:
      {available, path, by_task_type: [{task_type, best, candidates}], total_stats}
    """
    path = _route_cache_path()
    if not path.is_file():
        return {"available": False, "path": str(path), "by_task_type": [], "total_stats": 0}
    try:
        cache = json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        # Treat malformed/unreadable as unavailable — never crash the CLI.
        return {"available": False, "path": str(path), "by_task_type": [], "total_stats": 0}
    stats = cache.get("model_stats") or {}
    if not isinstance(stats, dict):
        stats = {}
    by_tt: dict = defaultdict(list)
    for stat in stats.values():
        if not isinstance(stat, dict):
            continue
        tt = stat.get("task_type")
        model = stat.get("model")
        if not tt or not model:
            continue
        succ = int(stat.get("successes", 0) or 0)
        fail = int(stat.get("failures", 0) or 0)
        total = succ + fail
        if total <= 0:
            continue
        by_tt[tt].append({
            "model": model,
            "successes": succ,
            "failures": fail,
            "total": total,
            "rate": succ / total,
            "last_used": stat.get("last_used"),
        })
    rows = []
    for tt, candidates in sorted(by_tt.items()):
        # Sort: rate desc, total desc, model alpha.
        candidates.sort(key=lambda c: (-c["rate"], -c["total"], c["model"]))
        rows.append({
            "task_type": tt,
            "best": candidates[0],
            "candidates": candidates,
        })
    return {
        "available": True,
        "path": str(path),
        "by_task_type": rows,
        "total_stats": sum(len(r["candidates"]) for r in rows),
    }


summary = _route_cache_summary()

# T-1790: --task-type filter narrows the by_task_type list before render.
# Applied after summary build so total_stats reflects the filter scope.
if filter_task_type and summary["available"]:
    summary["by_task_type"] = [
        r for r in summary["by_task_type"]
        if r["task_type"] == filter_task_type
    ]
    summary["total_stats"] = sum(len(r["candidates"]) for r in summary["by_task_type"])

if emit_json:
    print(json.dumps(summary, indent=2))
    sys.exit(0)

if not summary["available"]:
    print("no route cache yet")
    print(f"(read path: {summary['path']})")
    sys.exit(0)

if filter_task_type and not summary["by_task_type"]:
    # T-1790: empty filter result — distinct from "no model_stats yet".
    print(f"no route cache entries for task_type {filter_task_type}")
    print(f"(read path: {summary['path']})")
    sys.exit(0)

if not summary["by_task_type"]:
    # File exists, JSON valid, but no usable model_stats. Distinguishes
    # "never wrote anything" from "wrote, but no completed dispatches yet".
    print("route cache has no model_stats yet")
    print(f"(read path: {summary['path']})")
    sys.exit(0)

print(f"Route cache (learned preferences) — {summary['total_stats']} candidates")
print(f"  source: {summary['path']}")
print()
for row in summary["by_task_type"]:
    tt = row["task_type"]
    best = row["best"]
    total = sum(c["total"] for c in row["candidates"])
    print(f"task_type={tt} (best={best['model']}, total {total})")
    for c in row["candidates"]:
        last = c.get("last_used") or "-"
        rate_pct = c["rate"] * 100
        print(
            f"  {c['model']:30s} {c['successes']}/{c['failures']} "
            f"({rate_pct:.0f}%) last_used={last}"
        )
    print()
PYEOF
                exit 0
                ;;
            status)
                # T-1699: substrate observability — dispatch + outcome summary.
                # T-1749: --outcomes flag adds per-task-type outcome quality view.
                shift
                _emit_json=0
                _emit_outcomes=0
                _filter_task=""
                _filter_since=""
                _filter_worker_kind=""
                _filter_task_type=""
                _filter_model=""
                _filter_resolved_via=""
                _recent_n=""
                # T-1784: --task. T-1785: --since. T-1786: --worker-kind.
                # T-1787: --recent. T-1790: --task-type. T-1791: --model.
                # T-1793: --workflow-resolved-via.
                _prev=""
                for _arg in "$@"; do
                    [ "$_arg" = "--json" ] && _emit_json=1
                    [ "$_arg" = "--outcomes" ] && _emit_outcomes=1
                    [ "$_prev" = "--task" ] && _filter_task="$_arg"
                    [ "$_prev" = "--since" ] && _filter_since="$_arg"
                    [ "$_prev" = "--worker-kind" ] && _filter_worker_kind="$_arg"
                    [ "$_prev" = "--task-type" ] && _filter_task_type="$_arg"
                    [ "$_prev" = "--model" ] && _filter_model="$_arg"
                    [ "$_prev" = "--workflow-resolved-via" ] && _filter_resolved_via="$_arg"
                    [ "$_prev" = "--recent" ] && _recent_n="$_arg"
                    _prev="$_arg"
                done
                python3 - "$_emit_json" "$_emit_outcomes" "$_filter_task" "$_filter_since" "$_filter_worker_kind" "$_recent_n" "$_filter_task_type" "$_filter_model" "$_filter_resolved_via" <<'PYEOF'
import json
import os
import sys
from collections import Counter, defaultdict
from pathlib import Path

emit_json = sys.argv[1] == "1"
emit_outcomes = sys.argv[2] == "1"
filter_task = sys.argv[3] if len(sys.argv) > 3 else ""
filter_since_arg = sys.argv[4] if len(sys.argv) > 4 else ""
filter_worker_kind = sys.argv[5] if len(sys.argv) > 5 else ""
recent_n_arg = sys.argv[6] if len(sys.argv) > 6 else ""
filter_task_type = sys.argv[7] if len(sys.argv) > 7 else ""
filter_model = sys.argv[8] if len(sys.argv) > 8 else ""
filter_resolved_via = sys.argv[9] if len(sys.argv) > 9 else ""

# T-1787: --recent N parsing + validation. Default 5 preserves the
# pre-flag behavior; explicit N>=1 integer required.
recent_n = 5
if recent_n_arg:
    try:
        recent_n = int(recent_n_arg)
    except ValueError:
        print(
            f"orchestrator status: invalid --recent '{recent_n_arg}' "
            "(expected positive integer)",
            file=sys.stderr,
        )
        sys.exit(1)
    if recent_n < 1:
        print(
            f"orchestrator status: --recent must be >= 1 (got {recent_n})",
            file=sys.stderr,
        )
        sys.exit(1)


def _parse_duration(s):
    """T-1785: parse <int><unit> where unit ∈ {m,h,d}. Returns seconds or None."""
    import re as _re
    m = _re.fullmatch(r"([1-9]\d*)([mhd])", s or "")
    if not m:
        return None
    n = int(m.group(1))
    unit = m.group(2)
    mult = {"m": 60, "h": 3600, "d": 86400}[unit]
    return n * mult


filter_since_seconds = None
if filter_since_arg:
    filter_since_seconds = _parse_duration(filter_since_arg)
    if filter_since_seconds is None:
        print(
            f"orchestrator status: invalid --since '{filter_since_arg}' "
            "(expected <int><m|h|d>, e.g. 1h, 24h, 7d)",
            file=sys.stderr,
        )
        sys.exit(1)
PROJECT_ROOT = Path(os.environ.get("PROJECT_ROOT", os.getcwd()))
DISPATCHES = PROJECT_ROOT / ".context" / "dispatches.jsonl"
OUTCOMES = PROJECT_ROOT / ".context" / "dispatch-outcomes.jsonl"


def _read_jsonl(p: Path):
    if not p.exists():
        return []
    rows = []
    with p.open() as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return rows


dispatches_all = _read_jsonl(DISPATCHES)
outcomes = _read_jsonl(OUTCOMES)
dispatch_ids_with_outcomes = {o.get("dispatch_id") for o in outcomes if o.get("dispatch_id")}


# T-1712: split synthetic stress rows from real dispatches. Synthetic rows
# (task_id ^T-stress-) inflate the enrichment denominator and pollute the
# task_type / worker_kind breakdowns with "?" values. They have no outcome
# telemetry possible (task_type/worker_kind null), so excluding them keeps
# the headline metric tied to real arc-substrate signal.
def _is_synthetic(row):
    tid = row.get("task_id") or ""
    return tid.startswith("T-stress-")


dispatches = [d for d in dispatches_all if not _is_synthetic(d)]
synthetic = [d for d in dispatches_all if _is_synthetic(d)]

# T-1784: --task filter. T-1785: --since filter. T-1786: --worker-kind.
# T-1790: --task-type. T-1791: --model. All apply BEFORE aggregation so
# every downstream breakdown sees only matching rows. AND-composition when
# multiple set.
if filter_task:
    dispatches = [d for d in dispatches if d.get("task_id") == filter_task]
if filter_worker_kind:
    dispatches = [d for d in dispatches if d.get("worker_kind") == filter_worker_kind]
if filter_task_type:
    dispatches = [d for d in dispatches if d.get("task_type") == filter_task_type]
if filter_model:
    dispatches = [d for d in dispatches if d.get("model") == filter_model]
if filter_resolved_via:
    dispatches = [d for d in dispatches if d.get("workflow_resolved_via") == filter_resolved_via]
if filter_since_seconds is not None:
    from datetime import datetime as _dt, timezone as _tz, timedelta as _td
    cutoff = _dt.now(_tz.utc) - _td(seconds=filter_since_seconds)
    cutoff_iso = cutoff.isoformat()

    def _ts_after_cutoff(row):
        ts = row.get("ts", "")
        # Lexicographic ISO 8601 comparison works for sortable timestamps.
        return ts >= cutoff_iso

    dispatches = [d for d in dispatches if _ts_after_cutoff(d)]
else:
    cutoff_iso = None

if filter_task or filter_since_seconds is not None or filter_worker_kind or filter_task_type or filter_model or filter_resolved_via:
    # Outcomes are filtered to match the (now-narrowed) dispatch_ids.
    matching_dids = {d.get("dispatch_id") for d in dispatches if d.get("dispatch_id")}
    outcomes = [o for o in outcomes if o.get("dispatch_id") in matching_dids]
    dispatch_ids_with_outcomes = {o.get("dispatch_id") for o in outcomes if o.get("dispatch_id")}

stats = {
    "dispatch_total": len(dispatches),
    "synthetic_total": len(synthetic),
    "outcome_total": len(outcomes),
    "enriched_dispatches": sum(1 for d in dispatches if d.get("dispatch_id") in dispatch_ids_with_outcomes),
    "enrichment_ratio": 0.0,
    "by_task_type": dict(Counter(d.get("task_type") or "?" for d in dispatches).most_common(5)),
    "by_worker_kind": dict(Counter(d.get("worker_kind") or "?" for d in dispatches)),
    # T-1788: model is the orchestrator's routing decision. Counter excludes
    # rows where model is missing (legacy or unenriched) so the breakdown
    # reflects only dispatches with a recorded routing choice.
    "by_model": dict(Counter(d.get("model") for d in dispatches if d.get("model"))),
    "recent": [],
}
if stats["dispatch_total"]:
    stats["enrichment_ratio"] = round(stats["enriched_dispatches"] / stats["dispatch_total"], 3)


# T-1779: terminal_event breakdown surfaces the data T-1777 persists into
# dispatch rows. Three counters:
#   by_terminal_type — distribution of terminal_event["type"]
#   terminal_retryable — for "error" events, retryable True/False split
#   terminal_is_error — for "result" events, is_error True/False split
# All omitted from text output when empty (legacy data, graceful).
_by_terminal_type: Counter = Counter()
_terminal_retryable: Counter = Counter()
_terminal_is_error: Counter = Counter()
for d in dispatches:
    te = d.get("terminal_event")
    if not isinstance(te, dict):
        continue
    ttype = te.get("type")
    if not ttype:
        continue
    _by_terminal_type[ttype] += 1
    if ttype == "error" and "retryable" in te:
        _terminal_retryable[bool(te["retryable"])] += 1
    elif ttype == "result" and "is_error" in te:
        _terminal_is_error[bool(te["is_error"])] += 1
stats["by_terminal_type"] = dict(_by_terminal_type)
stats["terminal_retryable"] = {str(k): v for k, v in _terminal_retryable.items()}
stats["terminal_is_error"] = {str(k): v for k, v in _terminal_is_error.items()}


# T-1749: per-task-type outcome quality aggregation. Routes by outcome
# shape, not hardcoded evaluator name — verdict-style (escalation-scan-v0.5)
# and verification-style (default evaluator) are both surfaced.
# T-1757: dedupe by dispatch_id (latest ts wins) so replay-evaluator rows
# supersede their originals in the rollup. Substrate is append-only; the
# rollup honors supersession.
def _aggregate_outcomes_by_task_type():
    # Map dispatch_id → task_type for non-synthetic dispatches.
    did_to_type = {
        d.get("dispatch_id"): (d.get("task_type") or "?")
        for d in dispatches if d.get("dispatch_id")
    }
    # Dedup pass: keep only the latest outcome per dispatch_id.
    latest_per_did: dict = {}
    for o in outcomes:
        did = o.get("dispatch_id")
        if not did or did not in did_to_type:
            continue  # Outcome for synthetic or unknown dispatch — skip.
        ts = o.get("ts") or ""
        prev = latest_per_did.get(did)
        if prev is None or (o.get("ts") or "") > (prev.get("ts") or ""):
            latest_per_did[did] = o
    by_type: dict = defaultdict(lambda: {
        "evaluators": Counter(),
        "verdicts": Counter(),
        "verification_passed": Counter(),
        "ac_satisfied": Counter(),
        "outcome_total": 0,
    })
    for did, o in latest_per_did.items():
        ttype = did_to_type[did]
        outcome_body = o.get("outcome", {}) or {}
        bucket = by_type[ttype]
        bucket["outcome_total"] += 1
        ev = outcome_body.get("evaluator")
        if ev:
            bucket["evaluators"][ev] += 1
        # Shape detection — verdict-style or verification-style.
        verdict = outcome_body.get("verdict")
        if verdict:
            bucket["verdicts"][verdict] += 1
        if "verification_passed" in outcome_body:
            bucket["verification_passed"][bool(outcome_body["verification_passed"])] += 1
        if "ac_satisfied" in outcome_body:
            bucket["ac_satisfied"][bool(outcome_body["ac_satisfied"])] += 1
    # Convert Counters to dicts for JSON serializability.
    return {
        ttype: {
            "evaluators": dict(b["evaluators"]),
            "verdicts": dict(b["verdicts"]),
            "verification_passed": {str(k): v for k, v in b["verification_passed"].items()},
            "ac_satisfied": {str(k): v for k, v in b["ac_satisfied"].items()},
            "outcome_total": b["outcome_total"],
        }
        for ttype, b in by_type.items()
    }


if emit_outcomes:
    stats["by_task_type_outcomes"] = _aggregate_outcomes_by_task_type()

# Last 5 dispatches with outcome status (real only — synthetic rows have
# no telemetry so they're dropped from "recent" too).
for d in dispatches[-recent_n:]:
    did = d.get("dispatch_id", "?")
    matched = [o for o in outcomes if o.get("dispatch_id") == did]
    out = matched[-1].get("outcome", {}) if matched else None
    stats["recent"].append({
        "dispatch_id": did[:8] if did else "?",
        "ts": d.get("ts", "?"),
        "task_id": d.get("task_id"),
        "task_type": d.get("task_type"),
        "worker_kind": d.get("worker_kind"),
        # T-1788: model is the orchestrator's routing decision per dispatch.
        # None when row predates T-1664 / model unset.
        "model": d.get("model"),
        "verification_passed": out.get("verification_passed") if out else None,
        "ac_satisfied": out.get("ac_satisfied") if out else None,
        # T-1781: include terminal_event inline so the default surface
        # answers "did this complete cleanly?" without a separate lookup.
        "terminal_event": d.get("terminal_event"),
    })

if emit_json:
    print(json.dumps(stats, indent=2))
    sys.exit(0)

if (filter_task or filter_since_seconds is not None or filter_worker_kind or filter_task_type or filter_model or filter_resolved_via) and stats["dispatch_total"] == 0:
    # T-1784 / T-1785 / T-1786 / T-1790 / T-1791: empty filter result — separate
    # notice (real dispatches may exist, just none matching the filter).
    # Synthetic gating skipped.
    if emit_json:
        # JSON callers get the empty stats dict (valid, machine-parseable).
        print(json.dumps(stats, indent=2))
    else:
        parts = []
        if filter_task:
            parts.append(f"task {filter_task}")
        if filter_task_type:
            parts.append(f"task_type {filter_task_type}")
        if filter_worker_kind:
            parts.append(f"worker_kind {filter_worker_kind}")
        if filter_model:
            parts.append(f"model {filter_model}")
        if filter_resolved_via:
            parts.append(f"workflow_resolved_via {filter_resolved_via}")
        if filter_since_seconds is not None:
            parts.append(f"the last {filter_since_arg}")
        scope = " in ".join(parts)
        print(f"no dispatches captured for {scope}")
    sys.exit(0)

if stats["dispatch_total"] == 0 and stats["outcome_total"] == 0 and stats["synthetic_total"] == 0:
    print("no dispatches captured yet")
    print(f"(write path: {DISPATCHES.relative_to(PROJECT_ROOT)})")
    sys.exit(0)

print(f"Dispatch substrate (T-1687 arc, v1)")
if filter_task:
    # T-1784: make the narrowing visible — operator should not wonder
    # whether they're looking at the full substrate or one task's slice.
    print(f"  Filter:            task={filter_task}")
if filter_task_type:
    # T-1790: surface task_type narrowing.
    print(f"  Filter:            task_type={filter_task_type}")
if filter_worker_kind:
    # T-1786: same visibility for worker_kind narrowing.
    print(f"  Filter:            worker_kind={filter_worker_kind}")
if filter_model:
    # T-1791: surface model narrowing (routing-decision filter).
    print(f"  Filter:            model={filter_model}")
if filter_resolved_via:
    # T-1793: surface workflow_resolved_via narrowing (fallback-detection filter).
    print(f"  Filter:            workflow_resolved_via={filter_resolved_via}")
if filter_since_seconds is not None:
    # T-1785: surface the cutoff so operators see what window they're in.
    print(f"  Filter:            since={filter_since_arg} (>= {cutoff_iso})")
print(f"  Dispatches:        {stats['dispatch_total']}")
print(f"  Outcome events:    {stats['outcome_total']}")
if stats["dispatch_total"]:
    print(f"  Enriched:          {stats['enriched_dispatches']}/{stats['dispatch_total']} "
          f"({stats['enrichment_ratio']*100:.0f}%)")
else:
    print(f"  Enriched:          0/0 (no real dispatches yet)")
if stats["synthetic_total"]:
    print(f"  Synthetic:         {stats['synthetic_total']} (T-stress-* — excluded from headline)")
print()
print("By task_type:")
for k, v in stats["by_task_type"].items():
    print(f"  {k:30s} {v}")
print()
print("By worker_kind:")
for k, v in stats["by_worker_kind"].items():
    print(f"  {k:30s} {v}")
print()
# T-1788: by_model — the routing decision per dispatch. Omitted when
# no row carries a model (legacy data; mirror of terminal_event gating).
if stats["by_model"]:
    print("By model:")
    for k, v in sorted(stats["by_model"].items(), key=lambda x: -x[1]):
        print(f"  {k:30s} {v}")
    print()
# T-1779: terminal_event breakdown — only render when at least one row
# carries the persisted field (T-1777). Legacy-data rows get no noise.
if stats["by_terminal_type"]:
    print("By terminal event:")
    for k, v in sorted(stats["by_terminal_type"].items(), key=lambda x: -x[1]):
        line = f"  {k:30s} {v}"
        if k == "error" and stats["terminal_retryable"]:
            t = stats["terminal_retryable"].get("True", 0)
            f_ = stats["terminal_retryable"].get("False", 0)
            line += f"   (retryable={t} / non-retryable={f_})"
        print(line)
        if k == "result" and stats["terminal_is_error"]:
            t = stats["terminal_is_error"].get("True", 0)
            f_ = stats["terminal_is_error"].get("False", 0)
            print(f"      is_error: True={t} False={f_}")
    print()
print("Recent dispatches:")
for r in stats["recent"]:
    flag = "·"
    if r["verification_passed"] and r["ac_satisfied"]:
        flag = "✓"
    elif r["verification_passed"] is False or r["ac_satisfied"] is False:
        flag = "✗"
    # T-1781: append terminal=<type> with conditional suffix. error gets
    # full retryable state (both branches matter); result gets `(is_error)`
    # only on failure (success is the common case — no suffix noise).
    term_suffix = ""
    te = r.get("terminal_event")
    if isinstance(te, dict) and te.get("type"):
        ttype = te["type"]
        suffix = ""
        if ttype == "error" and "retryable" in te:
            suffix = "(retryable)" if te["retryable"] else "(non-retryable)"
        elif ttype == "result" and te.get("is_error") is True:
            suffix = "(is_error)"
        term_suffix = f" terminal={ttype}{suffix}"
    # T-1788: surface the routing decision (model) inline. "?" when
    # missing keeps the column-shape stable across legacy/new rows.
    model_str = r.get("model") or "?"
    print(f"  {flag} {r['ts']} [{r['dispatch_id']}] "
          f"task={r['task_id']} type={r['task_type']} worker={r['worker_kind']} "
          f"model={model_str}"
          f"{term_suffix}")

# T-1749: --outcomes view — per-task-type outcome quality.
if emit_outcomes:
    by_type_outcomes = stats.get("by_task_type_outcomes", {})
    print()
    print("Outcome quality (by task_type):")
    if not by_type_outcomes:
        print("  (no outcomes captured yet)")
    else:
        for ttype in sorted(by_type_outcomes.keys()):
            b = by_type_outcomes[ttype]
            evals = ", ".join(f"{k}({v})" for k, v in b["evaluators"].items()) or "—"
            print(f"  {ttype} — {b['outcome_total']} outcomes, evaluators: {evals}")
            if b["verdicts"]:
                # Sort by count desc for readability.
                for v, n in sorted(b["verdicts"].items(), key=lambda x: -x[1]):
                    pct = 100.0 * n / b["outcome_total"] if b["outcome_total"] else 0.0
                    print(f"      verdict {v:<25s} {n:>4d} ({pct:5.1f}%)")
            if b["verification_passed"]:
                t = b["verification_passed"].get("True", 0)
                f = b["verification_passed"].get("False", 0)
                print(f"      verification_passed: True={t} False={f}")
            if b["ac_satisfied"]:
                t = b["ac_satisfied"].get("True", 0)
                f = b["ac_satisfied"].get("False", 0)
                print(f"      ac_satisfied:        True={t} False={f}")
PYEOF
                exit $?
                ;;
            ""|-h|--help)
                cat <<EOF
fw orchestrator — v1 dispatch substrate (ADR-0001..0003)

Usage:
  fw orchestrator status     Show substrate state (dispatches + outcomes)
  fw orchestrator status --json   Same as above, JSON output
  fw orchestrator improve    Reserve namespace for v2 self-improvement (stub)

v1 wires the data substrate (dispatches.jsonl + dispatch-outcomes.jsonl +
blob storage + template SHAs + A/B variants); v2 self-improvement is
deferred. See ADR-0003.
EOF
                exit 0
                ;;
            *)
                echo "fw orchestrator: unknown subcommand '$_orch_subcmd'" >&2
                echo "Try: fw orchestrator --help" >&2
                exit 1
                ;;
        esac
        ;;
    resolver)
        # T-1696: workflow lookup + prompt assembly + telemetry capture.
        # Spawn-side primitive consumed by T-1697 (outcome enrichment),
        # T-1698 (litellm proxy), T-1699 (pi backend). Routes to lib/resolver.py
        # via the shell shim. See docs/reports/T-1689-resolver-inception.md.
        exec "$FW_LIB_DIR/resolver.sh" "$@"
        ;;
    outcome)
        # T-1697: default outcome evaluator + append-only back-prop.
        # Hook fires from update-task.sh on --status work-completed.
        # Reads dispatches.jsonl (append-only), writes dispatch-outcomes.jsonl
        # (also append-only). v2 read-path joins by dispatch_id.
        exec "$FW_LIB_DIR/outcome.sh" "$@"
        ;;
    pause)
        # T-1809 / dispatch-safety slice 5: capture operator's answer to a
        # paused dispatch (pause_requested terminal event) and fire a retry
        # via Resolver with retry_of_dispatch_id + pause_resolution context.
        exec "$FW_LIB_DIR/pause.sh" "$@"
        ;;
    peer)
        # T-1818 / v2 peer-consult slice 1 framework-half: long-poll TermLink
        # `inbox.queued` events and spawn responder via `fw termlink dispatch`.
        # Pairs with TermLink T-1636 (inbox.queued emitter, hub-side).
        exec python3 "$FW_LIB_DIR/peer.py" "$@"
        ;;
    promote)
        source "$FW_LIB_DIR/promote.sh"
        do_promote "$@"
        ;;
    assumption)
        source "$FW_LIB_DIR/assumption.sh"
        do_assumption "$@"
        ;;
    bus)
        source "$FW_LIB_DIR/bus.sh"
        do_bus "$@"
        ;;
    dispatch)
        source "$FW_LIB_DIR/dispatch.sh"
        do_dispatch "$@"
        ;;
    pickup)
        source "$FW_LIB_DIR/pickup.sh"
        do_pickup "$@"
        ;;
    pending)
        source "$FW_LIB_DIR/pending.sh"
        do_pending "$@"
        ;;
    upstream)
        source "$FW_LIB_DIR/upstream.sh"
        do_upstream "$@"
        ;;
    consolidate)
        exec python3 "$AGENTS_DIR/context/consolidate.py" "$@"
        ;;
    mcp)
        mcp_subcmd="${1:-}"
        shift || true
        # T-2265 (arc-010 Slice 2): framework MCP server lifecycle + manifest.
        _mcp_pid_file="$PROJECT_ROOT/.context/working/framework-mcp.pid"
        _mcp_log_file="$PROJECT_ROOT/.context/working/framework-mcp.log"
        _mcp_manifest="$PROJECT_ROOT/agents/mcp/framework-mcp-manifest.json"
        _mcp_server="$AGENTS_DIR/mcp/framework_mcp_server.py"
        _mcp_emitter="$AGENTS_DIR/mcp/manifest.py"
        case "$mcp_subcmd" in
            reap)
                exec "$AGENTS_DIR/mcp/mcp-reaper.sh" "$@"
                ;;
            emit-manifest|manifest)
                # Emit manifest WITHOUT starting the server. Used by audit + ci.
                exec python3 "$_mcp_emitter" emit "$@"
                ;;
            manifest-show|show)
                exec python3 "$_mcp_emitter" show "$@"
                ;;
            check)
                # T-2293: focused exit-code drift check (sibling to fw vendor self --dry-run).
                # 0 → in sync, 1 → drift, 2 → manifest absent. For CI / pre-commit / scripts.
                exec python3 "$_mcp_emitter" check "$@"
                ;;
            wire-fragment)
                # T-2272: print the .mcp.json fragment operator copies into project .mcp.json.
                # Contract: agents/mcp/framework-mcp.mcp-fragment.json (static, version-controlled).
                _frag="$PROJECT_ROOT/agents/mcp/framework-mcp.mcp-fragment.json"
                if [ ! -f "$_frag" ]; then
                    echo "fragment file missing: $_frag" >&2
                    exit 2
                fi
                cat "$_frag"
                ;;
            start)
                # Foreground stdio server (Claude Code spawns this via .mcp.json).
                # For background server use: fw mcp start --background
                _bg=0
                for _a in "$@"; do
                    [ "$_a" = "--background" ] || [ "$_a" = "--bg" ] && _bg=1
                done
                if [ "$_bg" -eq 1 ]; then
                    if [ -f "$_mcp_pid_file" ] && kill -0 "$(cat "$_mcp_pid_file" 2>/dev/null)" 2>/dev/null; then
                        echo "framework MCP server already running (pid $(cat "$_mcp_pid_file"))"
                        exit 0
                    fi
                    mkdir -p "$(dirname "$_mcp_pid_file")"
                    nohup python3 "$_mcp_server" >"$_mcp_log_file" 2>&1 &
                    echo "$!" > "$_mcp_pid_file"
                    sleep 0.2
                    echo "framework MCP server started (pid $(cat "$_mcp_pid_file")), log: $_mcp_log_file"
                    exit 0
                fi
                exec python3 "$_mcp_server"
                ;;
            stop)
                if [ -f "$_mcp_pid_file" ]; then
                    _pid=$(cat "$_mcp_pid_file" 2>/dev/null)
                    if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
                        kill "$_pid" 2>/dev/null && echo "stopped framework MCP server (pid $_pid)"
                    else
                        echo "framework MCP server pid $_pid not running"
                    fi
                    rm -f "$_mcp_pid_file"
                else
                    echo "framework MCP server not running (no pid file)"
                fi
                ;;
            status)
                if [ -f "$_mcp_manifest" ]; then
                    _tools=$(python3 -c "import json; m=json.load(open('$_mcp_manifest')); print(len(m.get('tools',[])))" 2>/dev/null || echo "?")
                    _gated=$(python3 -c "import json; m=json.load(open('$_mcp_manifest')); print(sum(1 for t in m.get('tools',[]) if t.get('gated')))" 2>/dev/null || echo "?")
                    echo "manifest:    $_mcp_manifest"
                    echo "tools:       ${_tools} (gated: ${_gated})"
                else
                    echo "manifest:    ABSENT — run 'fw mcp emit-manifest'"
                fi
                if [ -f "$_mcp_pid_file" ] && kill -0 "$(cat "$_mcp_pid_file" 2>/dev/null)" 2>/dev/null; then
                    echo "server:      running (pid $(cat "$_mcp_pid_file"))"
                else
                    echo "server:      stopped"
                fi
                ;;
            ""|help|-h|--help)
                echo -e "${BOLD}fw mcp${NC} — MCP server lifecycle + manifest (T-2265, arc-010)"
                echo ""
                echo "Subcommands:"
                echo "  emit-manifest                          Read tool-set.yaml → write framework-mcp-manifest.json"
                echo "  manifest-show                          Print manifest JSON to stdout (no file write)"
                echo "  check                                  Exit 0/1/2 = sync/drift/absent — for CI/pre-commit (T-2293)"
                echo "  wire-fragment                          Print .mcp.json fragment for operator-side wiring (T-2272)"
                echo "  start [--background]                   Run server (foreground stdio by default)"
                echo "  stop                                   Stop background server"
                echo "  status                                 Show manifest + pid state"
                echo "  reap [--dry-run] [--force] [--age N]   Kill orphaned MCP processes"
                echo ""
                echo "Source of truth: policy/capability-overlay/tool-set.yaml (T-2258)"
                echo "Consumed by:     agents/audit/orchestrator-mcp-scan.sh probe_framework_tools() (T-2260)"
                ;;
            *)
                echo -e "${RED}Unknown mcp subcommand: $mcp_subcmd${NC}"
                echo "Run 'fw mcp help' for usage"
                exit 1
                ;;
        esac
        ;;
    fix-learned)
        # Shortcut: fw fix-learned T-XXX "what was learned"
        fl_task="${1:-}"
        fl_text="${2:-}"
        if [ -z "$fl_task" ] || [ -z "$fl_text" ]; then
            echo -e "${RED}Usage: fw fix-learned T-XXX \"what was learned from this bugfix\"${NC}"
            echo ""
            echo "Shortcut for capturing learnings during bugfix cycles (G-016)."
            echo "Equivalent to: fw context add-learning \"text\" --task T-XXX --source P-001"
            exit 1
        fi
        exec "$AGENTS_DIR/context/context.sh" add-learning "$fl_text" --task "$fl_task" --source P-001
        ;;
    note)
        exec "$AGENTS_DIR/observe/observe.sh" "$@"
        ;;
    recall)
        RECALL_QUERY="${*}"
        if [ -z "$RECALL_QUERY" ]; then
            echo -e "${RED}Usage: fw recall <query>${NC}"
            echo "  Query project memory for relevant learnings, patterns, and decisions."
            echo ""
            echo "Examples:"
            echo "  fw recall \"audit enforcement\""
            echo "  fw recall \"context budget management\""
            exit 1
        fi
        exec python3 "$AGENTS_DIR/context/lib/memory-recall.py" --query "$RECALL_QUERY"
        ;;
    scan)
        if ! python3 -c "import yaml" 2>/dev/null; then
            echo -e "${RED}ERROR: PyYAML is not installed${NC}" >&2
            echo "  pip install -r $FRAMEWORK_ROOT/web/requirements.txt" >&2
            exit 1
        fi
        cd "$FRAMEWORK_ROOT" && exec python3 -m web.watchtower "$@"
        ;;
    serve)
        # Delegate to watchtower.sh for reliable start/stop/restart
        # If first arg looks like an option (--port, --debug), treat as 'start'
        if [ $# -gt 0 ] && [[ "$1" == --* ]]; then
            exec "$FRAMEWORK_ROOT/bin/watchtower.sh" start "$@"
        fi
        # Default to 'start' if no args
        if [ $# -eq 0 ]; then
            exec "$FRAMEWORK_ROOT/bin/watchtower.sh" start
        fi
        exec "$FRAMEWORK_ROOT/bin/watchtower.sh" "$@"
        ;;
    watchtower)
        # Public accessor namespace for Watchtower (T-1376 B5)
        # fw watchtower {port|url|status|start|stop|restart}
        if [ $# -eq 0 ]; then
            exec "$FRAMEWORK_ROOT/bin/watchtower.sh" --help
        fi
        exec "$FRAMEWORK_ROOT/bin/watchtower.sh" "$@"
        ;;
    deploy)
        DEPLOYER="/opt/claude-shared-toolkit/skills/infrastructure/ring20-deployer/scripts/ring20_deployer.py"
        if [ ! -f "$DEPLOYER" ]; then
            echo -e "${RED}ERROR: ring20-deployer not found at $DEPLOYER${NC}" >&2
            echo "  Install: git clone <toolkit-repo> /opt/claude-shared-toolkit" >&2
            exit 1
        fi

        # Passthrough subcommands that don't need gates
        deploy_subcmd="${1:-}"
        case "$deploy_subcmd" in
            status|ports|routes|--help|-h|"")
                exec python3 "$DEPLOYER" "$@"
                ;;
            scaffold)
                # Scaffold generates files — run pre-deploy audit first (T-275)
                echo -e "${CYAN}=== Pre-Deploy Audit ===${NC}"
                AUDIT_EXIT=0
                "$FRAMEWORK_ROOT/agents/audit/audit.sh" --section deployment || AUDIT_EXIT=$?

                if [ "$AUDIT_EXIT" -eq 2 ]; then
                    echo -e "${RED}Pre-deploy audit FAILED — deployment blocked${NC}" >&2
                    echo "  Fix the failures above, then retry: fw deploy $*" >&2
                    exit 1
                elif [ "$AUDIT_EXIT" -eq 1 ]; then
                    echo -e "${YELLOW}Pre-deploy audit has warnings — proceeding${NC}"
                fi

                # Run the deployer
                python3 "$DEPLOYER" "$@"
                DEPLOY_EXIT=$?

                # Log deployment record
                DEPLOY_DIR="$PROJECT_ROOT/.context/deployments"
                mkdir -p "$DEPLOY_DIR"
                DEPLOY_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
                DEPLOY_FILE="$DEPLOY_DIR/$(date +%Y-%m-%d-%H%M).yaml"
                FOCUS_TASK=$(grep -E '^(task_id|current_task):' "$PROJECT_ROOT/.context/working/focus.yaml" 2>/dev/null | head -1 | awk '{print $2}' | tr -d '"')
                HEAD_COMMIT=$(cd "$PROJECT_ROOT" && git rev-parse --short HEAD 2>/dev/null)

                cat > "$DEPLOY_FILE" << DEPLOYEOF
# Deployment Record — $(date +%Y-%m-%d %H:%M)
timestamp: $DEPLOY_TS
app: $(echo "$@" | sed -n 's/.*--app \([^ ]*\).*/\1/p' || echo "unknown")
command: fw deploy $*
task: ${FOCUS_TASK:-none}
commit: ${HEAD_COMMIT:-unknown}
gates:
  audit_exit: $AUDIT_EXIT
  deploy_exit: $DEPLOY_EXIT
result: $([ "$DEPLOY_EXIT" -eq 0 ] && echo "success" || echo "failed")
DEPLOYEOF
                echo -e "${GREEN}Deployment record: $DEPLOY_FILE${NC}"
                exit $DEPLOY_EXIT
                ;;
            *)
                # Unknown subcommand — pass through
                exec python3 "$DEPLOYER" "$@"
                ;;
        esac
        ;;
    tier0)
        # Tier 0 enforcement — approve or inspect blocked commands
        tier0_subcmd="${1:-}"
        shift || true
        case "$tier0_subcmd" in
            approve)
                PENDING_FILE="$PROJECT_ROOT/.context/working/.tier0-approval.pending"
                APPROVAL_FILE="$PROJECT_ROOT/.context/working/.tier0-approval"
                if [ ! -f "$PENDING_FILE" ]; then
                    echo -e "${YELLOW}No pending Tier 0 block to approve${NC}"
                    echo "A Tier 0 block must occur first before you can approve it."
                    exit 1
                fi
                PENDING_HASH=$(awk '{print $1}' "$PENDING_FILE")
                echo -e "${BOLD}Tier 0 Approval${NC}"
                echo ""
                echo -e "  Approving command with hash: ${CYAN}${PENDING_HASH:0:16}...${NC}"
                echo ""
                echo -e "  ${YELLOW}This allows a blocked destructive command to execute once.${NC}"
                echo -e "  The approval expires in 5 minutes and is logged for audit."
                echo ""
                # Write the approval (hash + current timestamp)
                echo "$PENDING_HASH $(date +%s)" > "$APPROVAL_FILE"
                rm -f "$PENDING_FILE"
                echo -e "${GREEN}Approved.${NC} Retry the command now."
                ;;
            status)
                PENDING_FILE="$PROJECT_ROOT/.context/working/.tier0-approval.pending"
                APPROVAL_FILE="$PROJECT_ROOT/.context/working/.tier0-approval"
                LOG_FILE="$PROJECT_ROOT/.context/bypass-log.yaml"
                echo -e "${BOLD}Tier 0 Enforcement Status${NC}"
                echo ""
                if [ -f "$PENDING_FILE" ]; then
                    echo -e "  ${YELLOW}Pending block:${NC} A command is waiting for approval"
                    echo "    Run 'fw tier0 approve' to allow it"
                elif [ -f "$APPROVAL_FILE" ]; then
                    echo -e "  ${GREEN}Active approval:${NC} One command approved (not yet used)"
                else
                    echo -e "  ${GREEN}Clean:${NC} No pending blocks or approvals"
                fi
                echo ""
                if [ -f "$LOG_FILE" ]; then
                    tier0_count=$(python3 -c "
import yaml
with open('$LOG_FILE') as f:
    data = yaml.safe_load(f) or {}
tier0 = [b for b in data.get('bypasses', []) if b.get('tier') == 0]
print(len(tier0))
" 2>/dev/null || echo 0)
                    echo -e "  Tier 0 approvals logged: $tier0_count"
                fi
                ;;
            ""|help|-h|--help)
                echo -e "${BOLD}fw tier0${NC} — Tier 0 enforcement management"
                echo ""
                echo "Subcommands:"
                echo "  approve    Approve a pending Tier 0 blocked command"
                echo "  status     Show Tier 0 enforcement status"
                echo ""
                echo "Tier 0 commands are destructive operations that require"
                echo "explicit human approval before an AI agent can execute them."
                echo "See: 011-EnforcementConfig.md"
                ;;
            *)
                echo -e "${RED}Unknown tier0 subcommand: $tier0_subcmd${NC}"
                echo "Run 'fw tier0 help' for usage"
                exit 1
                ;;
        esac
        ;;
    approvals)
        # Approval queue management — pending/status/expire (T-612)
        approvals_subcmd="${1:-}"
        shift || true
        APPROVAL_DIR="$PROJECT_ROOT/.context/approvals"
        case "$approvals_subcmd" in
            pending)
                echo -e "${BOLD}Pending Approvals${NC}"
                echo ""
                if [ ! -d "$APPROVAL_DIR" ]; then
                    echo -e "  ${GREEN}No approvals directory — nothing pending${NC}"
                    exit 0
                fi
                found=0
                now=$(date +%s)
                ttl="${TIER0_WATCHTOWER_TTL:-3600}"
                for f in "$APPROVAL_DIR"/pending-*.yaml; do
                    [ -f "$f" ] || continue
                    # Check expiry and display
                    T0_FILE="$f" T0_NOW="$now" T0_TTL="$ttl" python3 -c "
import yaml, re, os, sys
from datetime import datetime, timezone

f = os.environ['T0_FILE']
now = int(os.environ['T0_NOW'])
ttl = int(os.environ['T0_TTL'])

try:
    with open(f) as fh:
        data = yaml.safe_load(fh) or {}
except yaml.YAMLError:
    # Fallback: parse key fields with regex for malformed YAML
    with open(f) as fh:
        raw = fh.read()
    data = {}
    for key in ('timestamp', 'status', 'risk', 'command_preview', 'command_hash'):
        m = re.search(rf'^{key}:\s*[\"'']?(.+?)[\"'']?\s*$', raw, re.MULTILINE)
        if m:
            data[key] = m.group(1)

ts = data.get('timestamp', '')
status = data.get('status', 'pending')
risk = str(data.get('risk', 'unknown'))[:80]
preview = str(data.get('command_preview', ''))[:60]
chash = str(data.get('command_hash', ''))[:12]

age_str = '?'
expired = False
if ts:
    try:
        dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00'))
        age = now - dt.timestamp()
        if age > ttl:
            expired = True
            status = 'expired'
        mins = int(age / 60)
        if mins < 60:
            age_str = f'{mins}m ago'
        else:
            age_str = f'{mins // 60}h {mins % 60}m ago'
    except:
        pass

color = '\033[1;33m' if not expired else '\033[0;31m'
nc = '\033[0m'
print(f'  {color}{status.upper()}{nc}  {chash}  ({age_str})')
print(f'    Risk: {risk}')
print(f'    Cmd:  {preview}')
print()
" 2>/dev/null
                    found=$((found + 1))
                done
                if [ "$found" -eq 0 ]; then
                    echo -e "  ${GREEN}No pending approvals${NC}"
                fi
                ;;
            status)
                echo -e "${BOLD}Approval History${NC}"
                echo ""
                if [ ! -d "$APPROVAL_DIR" ]; then
                    echo -e "  ${GREEN}No approvals directory${NC}"
                    exit 0
                fi
                # Show pending count
                pending_count=0
                for f in "$APPROVAL_DIR"/pending-*.yaml; do
                    [ -f "$f" ] && pending_count=$((pending_count + 1))
                done
                echo -e "  Pending: ${YELLOW}${pending_count}${NC}"
                echo ""
                # Show recent resolved
                echo -e "  ${BOLD}Recent resolved:${NC}"
                found=0
                while IFS= read -r f; do
                    [ -n "$f" ] || continue
                    [ -f "$f" ] || continue
                    T0_FILE="$f" python3 -c "
import yaml, os

f = os.environ['T0_FILE']
with open(f) as fh:
    data = yaml.safe_load(fh) or {}

status = data.get('status', '?')
chash = data.get('command_hash', '')[:12]
risk = data.get('risk', '')[:60]
resp = data.get('response', {})
mechanism = resp.get('mechanism', '?')
responded = resp.get('responded_at', '?')

colors = {'approved': '\033[0;32m', 'rejected': '\033[0;31m', 'consumed': '\033[0;36m', 'expired': '\033[1;33m'}
c = colors.get(status, '\033[0m')
nc = '\033[0m'
print(f'    {c}{status.upper():10}{nc}  {chash}  via {mechanism}  at {responded}')
print(f'              {risk}')
" 2>/dev/null
                    found=$((found + 1))
                done < <(find "$APPROVAL_DIR" -maxdepth 1 -name 'resolved-*.yaml' -type f -print0 2>/dev/null | xargs -r -0 ls -t 2>/dev/null | head -10 || true)
                if [ "$found" -eq 0 ]; then
                    echo "    (none)"
                fi
                echo ""
                ;;
            expire)
                # Manually expire stale pending approvals
                if [ ! -d "$APPROVAL_DIR" ]; then
                    echo "No approvals directory"
                    exit 0
                fi
                now=$(date +%s)
                ttl="${TIER0_WATCHTOWER_TTL:-3600}"
                expired_count=0
                for f in "$APPROVAL_DIR"/pending-*.yaml; do
                    [ -f "$f" ] || continue
                    is_expired=$(T0_FILE="$f" T0_NOW="$now" T0_TTL="$ttl" python3 -c "
import yaml, re, os, sys
from datetime import datetime, timezone
f = os.environ['T0_FILE']
now = int(os.environ['T0_NOW'])
ttl = int(os.environ['T0_TTL'])
try:
    with open(f) as fh:
        data = yaml.safe_load(fh) or {}
except yaml.YAMLError:
    with open(f) as fh:
        raw = fh.read()
    data = {}
    for key in ('timestamp', 'status', 'command_hash', 'risk', 'command_preview', 'type'):
        m = re.search(rf'^{key}:\s*[\"'']?(.+?)[\"'']?\s*$', raw, re.MULTILINE)
        if m:
            data[key] = m.group(1)
ts = data.get('timestamp', '')
if ts:
    try:
        dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00'))
        age = now - dt.timestamp()
        if age > ttl:
            data['status'] = 'expired'
            chash = str(data.get('command_hash', ''))[:12]
            resolved = f.replace('pending-', 'resolved-')
            with open(resolved, 'w') as fh2:
                yaml.dump(data, fh2, default_flow_style=False, sort_keys=False)
            os.unlink(f)
            print('1')
            sys.exit(0)
    except:
        pass
print('0')
" 2>/dev/null)
                    if [ "$is_expired" = "1" ]; then
                        expired_count=$((expired_count + 1))
                    fi
                done
                echo -e "Expired ${YELLOW}${expired_count}${NC} pending approval(s)"
                ;;
            ""|help|-h|--help)
                echo -e "${BOLD}fw approvals${NC} — Approval queue management (T-612)"
                echo ""
                echo "Subcommands:"
                echo "  pending    Show outstanding approval requests"
                echo "  status     Show recent approval history (approved/rejected/expired)"
                echo "  expire     Manually expire stale pending approvals (TTL: 1hr)"
                echo ""
                echo "Approvals are created when check-tier0.sh blocks a destructive command."
                echo "They can be approved via 'fw tier0 approve' (CLI) or via Watchtower web UI."
                echo ""
                echo "Environment:"
                echo "  TIER0_WATCHTOWER_TTL  Approval TTL in seconds (default: 3600)"
                ;;
            *)
                echo -e "${RED}Unknown approvals subcommand: $approvals_subcmd${NC}"
                echo "Run 'fw approvals help' for usage"
                exit 1
                ;;
        esac
        ;;
    review-queue)
        # T-1536: terminal mirror of Watchtower /approvals "Awaiting Human ACs"
        # Lists active tasks with unchecked Human ACs, prefixed by agent verdict.
        rq_arg="${1:-}"
        if [ "$rq_arg" = "--help" ] || [ "$rq_arg" = "-h" ] || [ "$rq_arg" = "help" ]; then
            echo -e "${BOLD}fw review-queue${NC} — list tasks awaiting human review (T-1536)"
            echo ""
            echo "Shows two sections matching Watchtower /approvals:"
            echo "  DECISIONS — pending inception GO/NO-GO/DEFER decisions (T-1571)"
            echo "  VERDICT   — active tasks with unchecked Human ACs"
            echo ""
            echo "Each row is prefixed by the agent's recommendation verdict (GO/DEFER/"
            echo "NO-GO/?). Companion to Watchtower /approvals."
            echo ""
            echo "Sort: GO first (rubber-stamp), then DEFER, then NO-GO, then unknown."
            echo "Within each verdict: oldest first (stale > fresh)."
            echo ""
            echo "Usage:"
            echo "  fw review-queue           Show queue"
            echo "  fw review-queue --help    This message"
            exit 0
        fi
        PROJECT_ROOT="$PROJECT_ROOT" python3 - <<'PYREVQ'
import os, re, sys, time
from pathlib import Path
from datetime import datetime

# T-1533: shared helper. Robust to running from /opt/999-... where web/ is on the path.
sys.path.insert(0, os.environ.get("PROJECT_ROOT", "."))
try:
    from web.shared import extract_recommendation_state, count_unchecked_human_acs
except ImportError:
    # Inline fallback if web/ isn't importable (e.g. consumer project).
    # T-1576: emit NO-REC when section is missing/empty so callers can
    # distinguish 'agent owes a recommendation' from 'verdict unparseable'.
    def extract_recommendation_state(body):
        if not body:
            return "NO-REC"
        m = re.search(r"^## Recommendation\s*$(.*?)(?=^#{2,} |\Z)", body,
                      re.MULTILINE | re.DOTALL)
        if not m:
            return "NO-REC"
        body2 = re.sub(r"<!--.*?-->", "", m.group(1), flags=re.DOTALL).strip()
        if not body2:
            return "NO-REC"
        v = re.search(r"\*\*Recommendation:\*\*\s*(NO-GO|GO|DEFER)\b", body2, re.IGNORECASE)
        return v.group(1).upper() if v else "?"
    # T-2075 (T-2064 GO) inline fallback — kept in sync with web.shared.count_unchecked_human_acs.
    # Same predicate the queue uses; consumer projects without web/ get equivalent behaviour.
    def count_unchecked_human_acs(body):
        if not body:
            return 0
        ac_m = re.search(r"^## Acceptance Criteria\s*$(.*?)(?=^## |\Z)", body,
                         re.MULTILINE | re.DOTALL)
        if not ac_m:
            return 0
        human_m = re.search(r"^### Human\s*$(.*?)(?=^### |\Z)", ac_m.group(1),
                            re.MULTILINE | re.DOTALL)
        if not human_m:
            return 0
        human_text = re.sub(r"<!--.*?-->", "", human_m.group(1), flags=re.DOTALL)
        return len(re.findall(r"^\s*-\s*\[ \]", human_text, re.MULTILINE))

ROOT = Path(os.environ.get("PROJECT_ROOT", ".")).resolve()
ACTIVE = ROOT / ".tasks" / "active"
if not ACTIVE.is_dir():
    print("No .tasks/active directory.", file=sys.stderr)
    sys.exit(0)

now = time.time()
rows = []
decision_rows = []
# T-1571 / F5: precompute pending-inception scan once, reuse below for both passes
# Pattern matches the canonical block emitted by lib/inception.sh do_inception_decide.
DECISION_RE = re.compile(r"^\*\*Decision\*\*:\s*(GO|NO-GO|DEFER)\b", re.M)
for f in sorted(ACTIVE.glob("*.md")):
    text = f.read_text(errors="replace")
    # Frontmatter (used by both passes)
    fm_m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
    fm_text = fm_m.group(1) if fm_m else ""
    wf_m = re.search(r"^workflow_type:\s*(\S+)", fm_text, re.MULTILINE)
    workflow_type = wf_m.group(1).strip() if wf_m else ""
    status_m = re.search(r"^status:\s*(\S+)", fm_text, re.MULTILINE)
    status = status_m.group(1).strip() if status_m else ""
    tid_m = re.search(r"^id:\s*(\S+)", fm_text, re.MULTILINE)
    tid_v = tid_m.group(1).strip() if tid_m else ""
    tname_m = re.search(r'^name:\s*"?(.+?)"?\s*$', fm_text, re.MULTILINE)
    name = tname_m.group(1).strip() if tname_m else ""

    # T-1571 / F5: DECISIONS pass — pending inception decisions
    if workflow_type == "inception" and not DECISION_RE.search(text):
        # F4 inclusion: drop captured/unexplored without Recommendation, keep
        # started-work even when Recommendation is empty (visibility for stuck agent).
        rec_m = re.search(r"^## Recommendation\s*$(.*?)(?=^#{2,} |\Z)",
                          text, re.MULTILINE | re.DOTALL)
        rec_section = rec_m.group(1) if rec_m else ""
        rec_clean = re.sub(r"<!--.*?-->", "", rec_section, flags=re.DOTALL).strip()
        rec_substantive = len(rec_clean) >= 20
        if rec_substantive or status == "started-work":
            d_age_days = 0
            for field in ("last_update", "created"):
                m = re.search(rf"^{field}:\s*(\S+)", fm_text, re.MULTILINE)
                if m:
                    try:
                        dt = datetime.fromisoformat(m.group(1).replace("Z", "+00:00"))
                        d_age_days = int((now - dt.timestamp()) / 86400)
                        break
                    except (ValueError, OSError):
                        pass
            d_verdict = extract_recommendation_state(text)
            decision_rows.append((d_verdict, d_age_days, tid_v, name))

    # VERDICT pass — find ### Human section with unchecked ACs.
    # T-2075 (T-2064 GO): predicate now shared with /approvals via web.shared.
    # The previous inline scan accumulated three drift-prone rules (HTML-comment
    # strip, Human-subsection regex, unchecked-`[ ]` count) — each surface
    # owned a copy. Centralised at the queue-build layer so both /approvals
    # and `fw review-queue` see the same set of tasks.
    unchecked = count_unchecked_human_acs(text)
    if unchecked == 0:
        continue
    # T-1540 iter1 originally filtered to (owner=human OR status=work-completed) so
    # tasks with template-only Human ACs (the placeholder example AC inside the
    # `<!-- ... -->` block) wouldn't surface as spurious entries with verdict=?.
    # T-1581 fixed the root cause by stripping HTML comments before the unchecked-AC
    # count above. With the comment fix in place, the owner/status filter is both
    # redundant (template-only sections now drop out at unchecked==0) AND harmful
    # (it excludes started-work tasks with real Human ACs + agent recommendation,
    # e.g. T-1574/T-1575 — agent has handed off, awaiting human verification).
    # Removed; cockpit's `get_human_verify_tasks` uses the same canonical-parse
    # behavior — surfaces stay aligned.
    # Age
    age_days = 0
    for field in ("date_finished", "last_update", "created"):
        m = re.search(rf"^{field}:\s*(\S+)", fm_text, re.MULTILINE)
        if m:
            try:
                dt = datetime.fromisoformat(m.group(1).replace("Z", "+00:00"))
                age_days = int((now - dt.timestamp()) / 86400)
                break
            except (ValueError, OSError):
                pass
    verdict = extract_recommendation_state(text)
    rows.append((verdict, age_days, tid_v, name, unchecked))

# T-1808: PAUSED section — workers awaiting operator resolution (dispatch-safety slice 4).
paused_rows = []
try:
    sys.path.insert(0, str(ROOT / "lib"))
    from dispatch_pause import list_paused_dispatches, format_age, truncate as _trunc
    paused_rows = list_paused_dispatches(ROOT)
except Exception:
    paused_rows = []

if not rows and not decision_rows and not paused_rows:
    print("No tasks awaiting human review.")
    sys.exit(0)

# Sort: verdict priority then age desc (oldest first)
# T-1576: NO-REC sorts after ? (truly unactionable — agent owes a recommendation).
order = {"GO": 0, "DEFER": 1, "NO-GO": 2, "?": 3, "NO-REC": 4}
rows.sort(key=lambda r: (order.get(r[0], 9), -r[1]))
decision_rows.sort(key=lambda r: (order.get(r[0], 9), -r[1]))

NAME_W = 60

def _verdict_color(v):
    # T-1576: NO-REC in cyan (call-to-action — agent should write a recommendation),
    # distinct from grey '?' (deferred/unknown verdict in an existing block).
    return {"GO": "\033[32m", "DEFER": "\033[33m", "NO-GO": "\033[31m",
            "NO-REC": "\033[36m"}.get(v, "\033[90m")

# T-1571 / F5: DECISIONS section — pending inception GO/NO-GO/DEFER decisions.
# Mirrors /approvals' Decisions group; same urgency tier as Tier-0/Human-ACs.
if decision_rows:
    print(f"\033[1mDECISIONS — pending inception GO/NO-GO ({len(decision_rows)})\033[0m")
    print(f"\033[1m{'VERDICT':<7}  {'AGE':>4}  {'ID':<8}  NAME\033[0m")
    for verdict, age_days, tid_v, name in decision_rows:
        truncated = (name[:NAME_W] + "...") if len(name) > NAME_W else name
        col = _verdict_color(verdict)
        print(f"{col}{verdict:<7}\033[0m  {str(age_days)+'d':>4}  {tid_v:<8}  {truncated}")
    print()

# T-1808: PAUSED dispatches — Workers exited with pause_requested, awaiting answer.
# Sits between DECISIONS and VERDICT (same urgency tier — blocks further work on that task).
if paused_rows:
    print(f"\033[1mPAUSED — Workers awaiting resolution ({len(paused_rows)})\033[0m")
    print(f"\033[1m{'AGE':>5}  {'DISPATCH':<10}  {'TASK':<8}  QUESTION\033[0m")
    for pr in paused_rows:
        age_label = format_age(pr["age_seconds"])
        did_short = (pr["dispatch_id"][:8] + "..") if len(pr["dispatch_id"]) > 8 else pr["dispatch_id"]
        q = _trunc(pr["question"] or "(no question)", 60)
        sev = pr["severity"] or "?"
        # severity color: high=red, medium=yellow, low=grey
        sev_col = {"high": "\033[31m", "medium": "\033[33m", "low": "\033[90m"}.get(sev, "\033[90m")
        print(f"\033[36m{age_label:>5}\033[0m  {did_short:<10}  {pr['task_id']:<8}  {sev_col}[{sev}]\033[0m {q}")
    print()

if rows:
    print(f"\033[1mVERDICT — Human ACs awaiting verification ({len(rows)})\033[0m")
    print(f"\033[1m{'VERDICT':<7}  {'AGE':>4}  {'ID':<8}  NAME\033[0m")
    for verdict, age_days, tid_v, name, _unchecked in rows:
        truncated = (name[:NAME_W] + "...") if len(name) > NAME_W else name
        col = _verdict_color(verdict)
        print(f"{col}{verdict:<7}\033[0m  {str(age_days)+'d':>4}  {tid_v:<8}  {truncated}")
go = sum(1 for r in rows if r[0] == "GO")
defer = sum(1 for r in rows if r[0] == "DEFER")
nogo = sum(1 for r in rows if r[0] == "NO-GO")
unk = sum(1 for r in rows if r[0] == "?")
no_rec = sum(1 for r in rows if r[0] == "NO-REC")
parts = []
if go: parts.append(f"\033[32m{go} GO\033[0m")
if defer: parts.append(f"\033[33m{defer} DEFER\033[0m")
if nogo: parts.append(f"\033[31m{nogo} NO-GO\033[0m")
if unk: parts.append(f"\033[90m{unk} ?\033[0m")
if no_rec: parts.append(f"\033[36m{no_rec} NO-REC\033[0m")
total = len(rows) + len(decision_rows) + len(paused_rows)
verif_only = f"{len(rows)} task(s) awaiting human review" if rows else ""
dec_only = f"{len(decision_rows)} pending decision(s)" if decision_rows else ""
paused_only = f"{len(paused_rows)} paused dispatch(es)" if paused_rows else ""
summary = " · ".join(s for s in (dec_only, paused_only, verif_only) if s)
if parts:
    print(f"\n\033[2m{summary}\033[0m  ({' / '.join(parts)})")
elif summary:
    print(f"\n\033[2m{summary}\033[0m")
# T-1539 (blind-reviewer finding): never hardcode localhost — read triple-file → fw_config → 3000 fallback
url_file = ROOT / ".context" / "working" / "watchtower.url"
wt_url = url_file.read_text().strip() if url_file.exists() else f"http://localhost:{os.environ.get('FW_PORT', '3000')}"
print(f"\033[2mOpen in Watchtower: {wt_url}/approvals\033[0m")
PYREVQ
        ;;
    work-on)
        # Single-step gate: create/resume task + set focus + start work
        wo_target="${1:-}"
        if [ -z "$wo_target" ]; then
            echo -e "${RED}Usage:${NC}"
            echo '  fw work-on "Fix login bug" --type build                  # Create new task + start'
            echo '  fw work-on "Fix login bug" --description "Details here"  # With description'
            echo '  fw work-on T-042                                         # Resume existing task'
            exit 1
        fi

        if [[ "$wo_target" =~ ^T-[0-9]+$ ]]; then
            # T-2036: Detect "completed before commit" deadlock before falling into
            # the silent swallow. work-completed is a terminal lifecycle state
            # (lib/enums.sh) — work-on cannot reopen it, so previously the
            # update-task.sh call would fail invisibly (`2>/dev/null || true`),
            # work-on would print "Ready to work on..." (false success), and the
            # agent would assume focus was restored.
            # L-387: no pipe — `ls ... | head -1` SIGPIPEs the upstream under
            # `set -eo pipefail` when the glob misses. Use a bounded for-loop.
            wo_active_file=""
            for wo_glob in "$PROJECT_ROOT"/.tasks/active/"$wo_target"-*.md; do
                if [ -e "$wo_glob" ]; then wo_active_file="$wo_glob"; break; fi
            done
            wo_completed_file=""
            for wo_glob in "$PROJECT_ROOT"/.tasks/completed/"$wo_target"-*.md; do
                if [ -e "$wo_glob" ]; then wo_completed_file="$wo_glob"; break; fi
            done
            unset wo_glob
            if [ -z "$wo_active_file" ] && [ -n "$wo_completed_file" ]; then
                echo -e "${RED}=== work-on refused: $wo_target is in .tasks/completed/ ===${NC}" >&2
                echo "" >&2
                echo "  File: $wo_completed_file" >&2
                echo "  Status: work-completed (terminal — no transition back to started-work)" >&2
                echo "" >&2
                echo "  This is the T-2036 / P-002 'completed before commit' deadlock pattern:" >&2
                echo "  the task was closed before its code was committed; focus is now nulled" >&2
                echo "  and the active-task gate blocks Bash mutations." >&2
                echo "" >&2
                echo "  Recovery options:" >&2
                echo "    1. Anchor on a DIFFERENT active task and commit the orphaned changes:" >&2
                echo "         bin/fw work-on T-<other-active>" >&2
                echo "         git add <files>" >&2
                echo "         FW_SWITCH_FOCUS=1 git commit -m \"$wo_target: ...\"" >&2
                echo "    2. If the close was premature (work not actually done), reopen by hand:" >&2
                echo "         mv $wo_completed_file .tasks/active/" >&2
                echo "         # edit frontmatter status: work-completed → started-work, clear date_finished" >&2
                echo "" >&2
                exit 1
            fi
            if [ -z "$wo_active_file" ] && [ -z "$wo_completed_file" ]; then
                echo -e "${RED}work-on: task $wo_target not found in .tasks/active/ or .tasks/completed/${NC}" >&2
                exit 1
            fi
            # T-2286 (OBS-057): demo-target structural guard. Tasks marked
            # `demo_target: true` are reserved for an orchestrated demo worker
            # (e.g. arc-010 HM-A via `fw termlink dispatch`). The parent session
            # MUST NOT consume the captured→started-work transition by running
            # `fw work-on T-XXX` directly — doing so burns the headline-mechanic
            # acceptance ("agent dispatches via mcp__fw__work_on") before the
            # demo worker spawns. Bypass via flag (CLI) or env (git/wrapper)
            # mirrors the producer/consumer parity discipline of L-399.
            wo_demo_target=$(awk '/^---$/{c++; next} c==1 && /^demo_target:[[:space:]]*true[[:space:]]*$/{print "true"; exit}' "$wo_active_file" 2>/dev/null)
            if [ "$wo_demo_target" = "true" ]; then
                wo_is_demo_orch=false
                for wo_arg_idx in "$@"; do
                    [ "$wo_arg_idx" = "--i-am-demo-orchestrator" ] && wo_is_demo_orch=true
                done
                unset wo_arg_idx
                if [ "${FW_I_AM_DEMO_ORCHESTRATOR:-0}" = "1" ]; then
                    wo_is_demo_orch=true
                fi
                if [ "$wo_is_demo_orch" != true ]; then
                    echo -e "${RED}=== work-on refused: $wo_target is a demo-target task ===${NC}" >&2
                    echo "" >&2
                    echo "  File: $wo_active_file" >&2
                    echo "  Frontmatter: demo_target: true" >&2
                    echo "" >&2
                    echo "  This task is reserved for an orchestrated demo worker (e.g. the" >&2
                    echo "  arc-010 HM-A demo dispatches its target via mcp__fw__work_on)." >&2
                    echo "  Running 'fw work-on $wo_target' from the parent session would consume" >&2
                    echo "  the captured→started-work transition that the demo worker expects" >&2
                    echo "  to drive — burning half the headline-mechanic acceptance condition" >&2
                    echo "  before the worker spawns (origin OBS-057, prevented by T-2286)." >&2
                    echo "" >&2
                    echo "  If you ARE the demo orchestrator, bypass with EITHER:" >&2
                    echo "    flag (CLI direct):   bin/fw work-on $wo_target --i-am-demo-orchestrator" >&2
                    echo "    env  (git/wrapper):  FW_I_AM_DEMO_ORCHESTRATOR=1 bin/fw work-on $wo_target" >&2
                    echo "" >&2
                    echo "  Both log Tier-2 bypass entries to .context/working/.gate-bypass-log.yaml." >&2
                    echo "" >&2
                    exit 1
                fi
                # Bypass active — log Tier-2 entry (mirrors lib/review.sh:_log_empty_recommendation_bypass)
                wo_bypass_log_dir="${PROJECT_ROOT:-.}/.context/working"
                mkdir -p "$wo_bypass_log_dir" 2>/dev/null || true
                wo_bypass_log_file="$wo_bypass_log_dir/.gate-bypass-log.yaml"
                wo_bypass_ts=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
                wo_bypass_mechanism="flag:--i-am-demo-orchestrator"
                [ "${FW_I_AM_DEMO_ORCHESTRATOR:-0}" = "1" ] && wo_bypass_mechanism="env:FW_I_AM_DEMO_ORCHESTRATOR"
                {
                    echo "- timestamp: '${wo_bypass_ts}'"
                    echo "  task: '${wo_target}'"
                    echo "  category: 'demo-target-bypass'"
                    echo "  mechanism: '${wo_bypass_mechanism}'"
                    echo "  caller: 'fw work-on'"
                    echo "  file: '${wo_active_file}'"
                    echo "  reason: 'demo orchestrator bypass'"
                } >> "$wo_bypass_log_file" 2>/dev/null || true
                unset wo_bypass_log_dir wo_bypass_log_file wo_bypass_ts wo_bypass_mechanism
            fi
            unset wo_demo_target wo_is_demo_orch
            # Resume existing task
            echo -e "${CYAN}=== Resuming $wo_target ===${NC}"
            # T-2036: Surface real update-task.sh failures instead of swallowing them
            # (legitimate idempotent re-resume — already started-work — exits 0 in
            # update-task.sh, so we only swallow nothing critical here).
            if ! "$AGENTS_DIR/task-create/update-task.sh" "$wo_target" --status started-work; then
                echo -e "${YELLOW}work-on: status transition for $wo_target failed (see message above) — focus will NOT be set${NC}" >&2
                exit 1
            fi
            "$AGENTS_DIR/context/context.sh" focus "$wo_target"
            echo ""
            echo -e "${GREEN}Ready to work on $wo_target${NC}"
        else
            # Create new task — pass remaining args (--type, --owner, --description, etc.)
            shift
            # Build default args, then let explicit args override
            wo_has_desc=false
            wo_has_owner=false
            wo_is_inception=false
            for wo_arg_idx in "$@"; do
                [ "$wo_arg_idx" = "--description" ] && wo_has_desc=true
                [ "$wo_arg_idx" = "--owner" ] && wo_has_owner=true
            done
            # Detect --type inception and route through inception_start (T-1716):
            # the filing-time --recommendation/--rationale gate fires there.
            wo_prev=""
            for wo_arg_idx in "$@"; do
                if [ "$wo_prev" = "--type" ] && [ "$wo_arg_idx" = "inception" ]; then
                    wo_is_inception=true
                fi
                wo_prev="$wo_arg_idx"
            done
            if [ "$wo_is_inception" = true ]; then
                # Strip --type inception (and --description default) from args, then
                # forward remaining flags (--owner, --recommendation, --rationale,
                # --i-am-human) to inception start which enforces T-1716 gate.
                wo_inc_args=()
                wo_skip_next=false
                for wo_arg_idx in "$@"; do
                    if [ "$wo_skip_next" = true ]; then
                        wo_skip_next=false
                        continue
                    fi
                    if [ "$wo_arg_idx" = "--type" ]; then
                        wo_skip_next=true
                        continue
                    fi
                    if [ "$wo_arg_idx" = "--description" ]; then
                        wo_skip_next=true
                        continue
                    fi
                    wo_inc_args+=("$wo_arg_idx")
                done
                source "$FW_LIB_DIR/inception.sh"
                do_inception_start "$wo_target" "${wo_inc_args[@]}"
                exit $?
            fi
            wo_defaults=()
            [ "$wo_has_desc" = false ] && wo_defaults+=(--description "$wo_target")
            [ "$wo_has_owner" = false ] && wo_defaults+=(--owner agent)
            wo_output=$("$AGENTS_DIR/task-create/create-task.sh" --name "$wo_target" --start "${wo_defaults[@]}" "$@" 2>&1)
            echo "$wo_output"

            # Extract task ID from output
            wo_task_id=$(echo "$wo_output" | grep "^ID:" | sed 's/ID:[[:space:]]*//')
            if [ -n "$wo_task_id" ]; then
                "$AGENTS_DIR/context/context.sh" focus "$wo_task_id"
                echo ""
                echo -e "${GREEN}Ready to work on $wo_task_id: $wo_target${NC}"
            else
                echo -e "${YELLOW}Task created but could not extract ID for focus${NC}"
            fi
        fi
        ;;
    task)
        route_task "$@"
        ;;
    preflight)
        source "$FW_LIB_DIR/preflight.sh"
        do_preflight "$@"
        ;;
    init)
        source "$FW_LIB_DIR/init.sh"
        do_init "$@"
        ;;
    validate-init)
        source "$FW_LIB_DIR/validate-init.sh"
        do_validate_init "$@"
        ;;
    update)
        source "$FW_LIB_DIR/update.sh"
        do_update "$@"
        ;;
    upgrade)
        source "$FW_LIB_DIR/init.sh"
        source "$FW_LIB_DIR/upgrade.sh"
        do_upgrade "$@"
        ;;
    consumer-recover)
        # T-2233 / T-2235 — one-command recovery for legacy vendored consumers
        # (pre-T-1634 / pre-T-2232 consumers that cannot self-heal via fw upgrade).
        source "$FW_LIB_DIR/consumer-recover.sh"
        do_consumer_recover "$@"
        ;;
    setup)
        echo -e "${YELLOW}NOTE: 'fw setup' is deprecated — use 'fw init' instead.${NC}"
        echo "  fw init auto-detects interactive mode and runs first-run walkthrough."
        echo ""
        source "$FW_LIB_DIR/init.sh"
        do_init "$@"
        ;;
    build)
        exec "$FRAMEWORK_ROOT/lib/build.sh" "$@"
        ;;
    harvest)
        source "$FW_LIB_DIR/harvest.sh"
        do_harvest "$@"
        ;;
    prompt)
        source "$FW_LIB_DIR/prompt.sh"
        do_prompt "$@"
        ;;
    termlink)
        exec "$AGENTS_DIR/termlink/termlink.sh" "$@"
        ;;
    onboarding)
        ONBOARDING_MARKER="$PROJECT_ROOT/.context/working/.onboarding-complete"
        subcmd="${1:-status}"
        shift 2>/dev/null || true
        case "$subcmd" in
            status)
                if [ -f "$ONBOARDING_MARKER" ]; then
                    echo -e "${GREEN}Onboarding complete${NC}"
                    cat "$ONBOARDING_MARKER"
                    exit 0
                fi
                echo -e "${BOLD}Onboarding Tasks${NC}"
                echo ""
                found=0
                for tf in "$PROJECT_ROOT"/.tasks/active/T-*.md; do
                    [ -f "$tf" ] || continue
                    if head -20 "$tf" | grep -q '^tags:.*onboarding' 2>/dev/null; then
                        tf_id=$({ grep "^id:" "$tf" 2>/dev/null || true; } | head -1 | sed 's/id:[[:space:]]*//')
                        tf_name=$({ grep "^name:" "$tf" 2>/dev/null || true; } | head -1 | sed 's/name:[[:space:]]*//' | tr -d '"')
                        tf_status=$({ grep "^status:" "$tf" 2>/dev/null || true; } | head -1 | sed 's/status:[[:space:]]*//')
                        if [ "$tf_status" = "work-completed" ]; then
                            echo -e "  ${GREEN}✓${NC}  ${tf_id}: ${tf_name}"
                        else
                            echo -e "  ${YELLOW}○${NC}  ${tf_id}: ${tf_name} (${tf_status})"
                        fi
                        found=$((found + 1))
                    fi
                done
                if [ "$found" -eq 0 ]; then
                    echo "  No onboarding tasks found."
                fi
                ;;
            skip)
                if [ -f "$ONBOARDING_MARKER" ]; then
                    echo "Onboarding already complete."
                    exit 0
                fi
                echo -e "${YELLOW}Skipping onboarding tasks.${NC}"
                echo "You can still complete them later: fw task list --tag onboarding"
                mkdir -p "$(dirname "$ONBOARDING_MARKER")"
                echo "skipped: $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$ONBOARDING_MARKER"
                echo "reason: manual skip via fw onboarding skip" >> "$ONBOARDING_MARKER"
                # Log to bypass-log
                BYPASS_LOG="$PROJECT_ROOT/.context/bypass-log.yaml"
                if [ -f "$BYPASS_LOG" ]; then
                    python3 -c "
import yaml, datetime
with open('$BYPASS_LOG') as f:
    data = yaml.safe_load(f) or {}
entries = data.get('entries', [])
entries.append({
    'timestamp': datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    'type': 'onboarding-skip',
    'reason': 'Manual skip via fw onboarding skip',
    'authorized_by': 'human'
})
data['entries'] = entries
with open('$BYPASS_LOG', 'w') as f:
    yaml.dump(data, f, default_flow_style=False)
" 2>/dev/null
                fi
                echo -e "${GREEN}Onboarding gate disabled.${NC}"
                ;;
            reset)
                rm -f "$ONBOARDING_MARKER"
                echo "Onboarding gate re-enabled."
                ;;
            *)
                echo "Usage: fw onboarding {status|skip|reset}"
                exit 1
                ;;
        esac
        ;;
    gaps)
        # T-397: Register renamed gaps.yaml → concerns.yaml; fall back to old name.
        GAPS_FILE="$PROJECT_ROOT/.context/project/concerns.yaml"
        [ -f "$GAPS_FILE" ] || GAPS_FILE="$PROJECT_ROOT/.context/project/gaps.yaml"
        if [ ! -f "$GAPS_FILE" ]; then
            echo -e "${YELLOW}No gaps register found${NC}"
            echo "Create one at: .context/project/concerns.yaml"
            exit 0
        fi
        # T-2185: subcommand routing — `fw gaps close <id>` flips gauge-READY gaps.
        if [ "${1:-}" = "close" ]; then
            shift
            GAP_ID="${1:-}"
            RATIONALE=""
            OVERRIDE_FLAG=""
            shift 2>/dev/null || true
            while [ $# -gt 0 ]; do
                case "$1" in
                    --rationale) RATIONALE="${2:-}"; shift 2 ;;
                    --override)  OVERRIDE_FLAG="1"; shift ;;
                    *)           shift ;;
                esac
            done
            if [ -z "$GAP_ID" ]; then
                echo -e "${YELLOW}Usage:${NC} fw gaps close <gap_id> [--rationale \"...\"] [--override]"
                echo
                echo "Closure-eligible gaps (status:watching with gauge=READY):"
                PROJECT_ROOT="$PROJECT_ROOT" python3 -c "
import sys; sys.path.insert(0, '$FRAMEWORK_ROOT')
from lib.gaps import stale_ready_gaps
for g in stale_ready_gaps(threshold_days=0):
    print(f'  {g[\"gap_id\"]}  ({g[\"age_days\"]}d READY)  {g[\"title\"][:60]}')
" 2>/dev/null
                exit 2
            fi
            PROJECT_ROOT="$PROJECT_ROOT" GAP_ID="$GAP_ID" RATIONALE="$RATIONALE" OVERRIDE_FLAG="$OVERRIDE_FLAG" python3 -c "
import json, os, sys
sys.path.insert(0, '$FRAMEWORK_ROOT')
from lib.gaps import close_gap, GapCloseError
gap_id = os.environ['GAP_ID']
rationale = os.environ.get('RATIONALE') or None
override = bool(os.environ.get('OVERRIDE_FLAG'))
try:
    result = close_gap(gap_id, rationale=rationale, override=override, actor='cli')
    print(f'\033[0;32mClosed\033[0m {gap_id} — verdict={result[\"verdict\"]}, closed_date={result[\"closed_date\"]}')
    print(f'Audit: {result[\"audit_path\"]}')
    sys.exit(0)
except GapCloseError as e:
    print(f'\033[0;31mRefused\033[0m ({e.code}) {e.message}', file=sys.stderr)
    sys.exit(1)
"
            exit $?
        fi
        python3 << PYEOF
import yaml, json, os, subprocess

GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
RED = '\033[0;31m'
NC = '\033[0m'

with open("$GAPS_FILE") as f:
    data = yaml.safe_load(f)

# T-397: 'concerns' is the canonical key; 'gaps' is legacy fallback.
gaps = data.get('concerns', data.get('gaps', []))
watching = [g for g in gaps if g.get('status') == 'watching']
closed = [g for g in gaps if g.get('status') in ('closed', 'decided-build', 'decided-simplify')]

print(f"\033[1mGaps Register\033[0m — {len(watching)} watching, {len(closed)} resolved")
print()


def _render_closure_check(cmd: str) -> str:
    """T-1752: run a gap's closure_check_command and return a one-line verdict.

    Contract: the command emits JSON with at least 'verdict' (READY/NOT_READY).
    Optional keys 'cron_firings', 'cron_firing_dates', 'closure_threshold_dates'
    are surfaced as compact counters. Failures render as ERROR but never raise.
    """
    project_root = os.environ.get('PROJECT_ROOT', '.')
    try:
        proc = subprocess.run(
            cmd, shell=True, capture_output=True, text=True,
            timeout=10, cwd=project_root,
        )
    except subprocess.TimeoutExpired:
        return f"    Closure: {RED}ERROR (timeout >10s){NC}"
    except Exception as e:
        return f"    Closure: {RED}ERROR ({type(e).__name__}){NC}"
    if proc.returncode != 0:
        # Many gauges use --strict to flag NOT_READY via exit 1; still try to
        # parse stdout as JSON before reporting ERROR.
        pass
    out = (proc.stdout or "").strip()
    if not out:
        return f"    Closure: {RED}ERROR (empty output){NC}"
    try:
        d = json.loads(out)
    except json.JSONDecodeError:
        return f"    Closure: {RED}ERROR (non-JSON output){NC}"
    verdict = d.get('verdict', '?')
    color = GREEN if verdict == 'READY' else (YELLOW if verdict == 'NOT_READY' else RED)
    counters = []
    if 'cron_firing_dates' in d and 'closure_threshold_dates' in d:
        have = len(d['cron_firing_dates']) if isinstance(d['cron_firing_dates'], list) else d.get('cron_firings', 0)
        need = d['closure_threshold_dates']
        counters.append(f"{have}/{need}")
    counter_str = f" ({', '.join(counters)})" if counters else ""
    return f"    Closure: {color}{verdict}{counter_str}{NC}"


for gap in watching:
    sev_colors = {'high': '\033[0;31m', 'medium': '\033[1;33m', 'low': '\033[0;36m'}
    sev = gap.get('severity', 'unknown')
    color = sev_colors.get(sev, '\033[0m')
    # T-1840: defensive .get() — legacy consumer concerns.yaml entries
    # predating the title-field requirement crashed fw gaps with KeyError.
    print(f"  {color}{gap.get('id', '?')}\033[0m [{sev}]  {gap.get('title', '<untitled>')}")
    trigger = gap.get('decision_trigger', '').strip().split('\n')[0]
    print(f"    Trigger: {trigger}")
    # T-1752: render closure-readiness verdict if a check is configured.
    cmd = gap.get('closure_check_command')
    if cmd:
        print(_render_closure_check(cmd))
    print()

if not watching:
    print("  \033[0;32mNo gaps being watched\033[0m")
PYEOF
        ;;
    traceability)
        # T-590: Traceability baseline for imported projects
        BASELINE_FILE="$PROJECT_ROOT/.context/project/traceability-baseline"
        subcmd="${1:-status}"
        shift 2>/dev/null || true
        case "$subcmd" in
            baseline)
                head_sha=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null)
                if [ -z "$head_sha" ]; then
                    echo -e "${RED}ERROR:${NC} Not a git repository"
                    exit 1
                fi
                mkdir -p "$(dirname "$BASELINE_FILE")"
                echo "$head_sha" > "$BASELINE_FILE"
                commit_count=$(git -C "$PROJECT_ROOT" log --oneline 2>/dev/null | wc -l | tr -d ' ')
                echo -e "${GREEN}Traceability baseline set${NC}"
                echo "  Commit: $head_sha"
                echo "  Commits excluded: $commit_count (everything up to and including HEAD)"
                echo "  File: $BASELINE_FILE"
                echo ""
                echo "Audit will now only check commits AFTER this point for task references."
                ;;
            status)
                if [ -f "$BASELINE_FILE" ]; then
                    baseline_sha=$(tr -d '[:space:]' < "$BASELINE_FILE")
                    echo -e "${GREEN}Traceability baseline active${NC}"
                    echo "  Baseline: $baseline_sha"
                    after_count=$(git -C "$PROJECT_ROOT" log --oneline "${baseline_sha}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
                    echo "  Commits after baseline: $after_count"
                else
                    echo -e "${YELLOW}No traceability baseline set${NC}"
                    echo "  Audit checks ALL commits for task references."
                    echo "  For imported projects: fw traceability baseline"
                fi
                ;;
            reset)
                rm -f "$BASELINE_FILE"
                echo "Traceability baseline removed. Audit will check all commits."
                ;;
            *)
                echo "Usage: fw traceability {baseline|status|reset}"
                exit 1
                ;;
        esac
        ;;
    decisions)
        python3 << 'PYDECISIONS'
import os, yaml, re

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')

op_decisions = []
dec_file = os.path.join(project_root, '.context', 'project', 'decisions.yaml')
if os.path.isfile(dec_file):
    with open(dec_file) as f:
        data = yaml.safe_load(f) or {}
    for d in data.get('decisions', []):
        op_decisions.append({
            'id': d.get('id', '?'),
            'decision': d.get('decision', ''),
            'directives': ', '.join(str(x) for x in d.get('directives_served', [])),
            'date': str(d.get('date', '?')),
        })

arch_decisions = []
dd_file = os.path.join(project_root, '005-DesignDirectives.md')
if os.path.isfile(dd_file):
    with open(dd_file) as f:
        lines = f.readlines()
    for line in lines:
        m = re.match(r'\|\s*(AD-\d+)\s*\|\s*(\S+)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|', line)
        if m:
            arch_decisions.append({
                'id': m.group(1),
                'decision': m.group(3).strip(),
                'directives': m.group(4).strip(),
                'date': m.group(2).strip(),
            })

total = len(arch_decisions) + len(op_decisions)
print(f'{BOLD}Decisions{NC} ({total} total: {len(arch_decisions)} architectural, {len(op_decisions)} operational)')
print()

if arch_decisions:
    print(f'{BOLD}Architectural Decisions{NC}')
    print(f'  {"ID":<8} {"Date":<12} {"Directives":<14} {"Decision"}')
    print(f'  {chr(9472)*8} {chr(9472)*12} {chr(9472)*14} {chr(9472)*60}')
    for d in arch_decisions:
        dec = d['decision'][:60] + ('...' if len(d['decision']) > 60 else '')
        print(f'  {CYAN}{d["id"]:<8}{NC} {d["date"]:<12} {d["directives"]:<14} {dec}')
    print()

if op_decisions:
    print(f'{BOLD}Operational Decisions{NC}')
    print(f'  {"ID":<8} {"Date":<12} {"Directives":<14} {"Decision"}')
    print(f'  {chr(9472)*8} {chr(9472)*12} {chr(9472)*14} {chr(9472)*60}')
    for d in op_decisions:
        dec = d['decision'][:60] + ('...' if len(d['decision']) > 60 else '')
        print(f'  {GREEN}{d["id"]:<8}{NC} {d["date"]:<12} {d["directives"]:<14} {dec}')
    print()
PYDECISIONS
        ;;
    timeline)
        python3 << 'PYTIMELINE'
import os, yaml

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
handover_dir = os.path.join(project_root, '.context', 'handovers')

if not os.path.isdir(handover_dir):
    print(f'{YELLOW}No handovers directory found{NC}')
    exit(0)

sessions = []
for fn in sorted(os.listdir(handover_dir)):
    if not fn.startswith('S-') or not fn.endswith('.md'):
        continue
    path = os.path.join(handover_dir, fn)
    try:
        with open(path) as f:
            text = f.read()
        if not text.startswith('---'):
            continue
        end = text.index('---', 3)
        fm = yaml.safe_load(text[3:end]) or {}
        sessions.append({
            'session_id': fm.get('session_id', fn.replace('.md', '')),
            'timestamp': str(fm.get('timestamp', '?')),
            'touched': fm.get('tasks_touched', []),
            'completed': fm.get('tasks_completed', []),
            'owner': fm.get('owner', '?'),
        })
    except:
        continue

sessions.sort(key=lambda s: s['timestamp'], reverse=True)

print(f'{BOLD}Session Timeline{NC} ({len(sessions)} sessions)')
print()
print(f'  {"Session ID":<22} {"Date":<22} {"Touched":<10} {"Completed":<10} {"Owner"}')
print(f'  {chr(9472)*22} {chr(9472)*22} {chr(9472)*10} {chr(9472)*10} {chr(9472)*12}')

for s in sessions:
    touched_n = len(s['touched']) if isinstance(s['touched'], list) else 0
    completed_n = len(s['completed']) if isinstance(s['completed'], list) else 0
    comp_color = GREEN if completed_n > 0 else NC
    print(f'  {CYAN}{s["session_id"]:<22}{NC} {s["timestamp"]:<22} {touched_n:<10} {comp_color}{completed_n:<10}{NC} {s["owner"]}')
print()
PYTIMELINE
        ;;
    learnings)
        LEARNINGS_FILE="$PROJECT_ROOT/.context/project/learnings.yaml"
        if [ ! -f "$LEARNINGS_FILE" ]; then
            echo -e "${YELLOW}No learnings file found${NC}"
            echo "Expected at: .context/project/learnings.yaml"
            exit 0
        fi
        python3 << 'PYLEARNINGS'
import os, yaml

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
path = os.path.join(project_root, '.context', 'project', 'learnings.yaml')

with open(path) as f:
    data = yaml.safe_load(f) or {}

learnings = data.get('learnings', [])
candidates = data.get('candidates', [])

print(f'{BOLD}Learnings{NC} ({len(learnings)} confirmed, {len(candidates)} candidates)')
print()
print(f'  {"ID":<8} {"Task":<8} {"Date":<12} {"Learning"}')
print(f'  {chr(9472)*8} {chr(9472)*8} {chr(9472)*12} {chr(9472)*50}')

for l in learnings:
    lid = l.get('id', '?')
    task = l.get('task', '?')
    date = str(l.get('date', '?'))
    learning = l.get('learning', '?')
    print(f'  {GREEN}{lid:<8}{NC} {task:<8} {date:<12} {learning}')

if candidates:
    print()
    print(f'{BOLD}Candidates{NC}')
    for c in candidates:
        print(f'  {YELLOW}*{NC} {c.get("learning", c) if isinstance(c, dict) else c}')
print()
PYLEARNINGS
        ;;
    patterns)
        PATTERNS_FILE="$PROJECT_ROOT/.context/project/patterns.yaml"
        if [ ! -f "$PATTERNS_FILE" ]; then
            echo -e "${YELLOW}No patterns file found${NC}"
            echo "Expected at: .context/project/patterns.yaml"
            exit 0
        fi
        python3 << 'PYPATTERNS'
import os, yaml

BOLD = '\033[1m'
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
path = os.path.join(project_root, '.context', 'project', 'patterns.yaml')

with open(path) as f:
    data = yaml.safe_load(f) or {}

failure = data.get('failure_patterns', [])
success = data.get('success_patterns', [])
workflow = data.get('workflow_patterns', [])

total = len(failure) + len(success) + len(workflow)
print(f'{BOLD}Patterns{NC} ({total} total: {len(failure)} failure, {len(success)} success, {len(workflow)} workflow)')
print()

if failure:
    print(f'{BOLD}Failure Patterns{NC}')
    for p in failure:
        pid = p.get('id', '?')
        desc = p.get('description', p.get('pattern', '?'))
        mitigation = p.get('mitigation', '?')
        task = p.get('learned_from', '?')
        print(f'  {RED}{pid}{NC} [{task}] {p.get("pattern", "?")}')
        print(f'    {desc}')
        print(f'    {GREEN}Mitigation:{NC} {mitigation}')
        print()

if success:
    print(f'{BOLD}Success Patterns{NC}')
    for p in success:
        pid = p.get('id', '?')
        desc = p.get('description', '?')
        ctx = p.get('context', '')
        task = p.get('learned_from', '?')
        print(f'  {GREEN}{pid}{NC} [{task}] {p.get("pattern", "?")}')
        print(f'    {desc}')
        if ctx:
            print(f'    Context: {ctx}')
        print()

if workflow:
    print(f'{BOLD}Workflow Patterns{NC}')
    for p in workflow:
        pid = p.get('id', '?')
        desc = p.get('description', '?')
        example = p.get('example', '')
        task = p.get('learned_from', '?')
        print(f'  {CYAN}{pid}{NC} [{task}] {p.get("pattern", "?")}')
        print(f'    {desc}')
        if example:
            print(f'    Example: {example}')
        print()
PYPATTERNS
        ;;
    practices)
        PRACTICES_FILE="$PROJECT_ROOT/.context/project/practices.yaml"
        if [ ! -f "$PRACTICES_FILE" ]; then
            echo -e "${YELLOW}No practices file found${NC}"
            echo "Expected at: .context/project/practices.yaml"
            exit 0
        fi
        python3 << 'PYPRACTICES'
import os, yaml

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
path = os.path.join(project_root, '.context', 'project', 'practices.yaml')

with open(path) as f:
    data = yaml.safe_load(f) or {}

practices = data.get('practices', [])
active = [p for p in practices if p.get('status') == 'active']

print(f'{BOLD}Practices{NC} ({len(active)} active of {len(practices)} total)')
print()
print(f'  {"ID":<8} {"Derived":<12} {"Apps":<6} {"Name"}')
print(f'  {chr(9472)*8} {chr(9472)*12} {chr(9472)*6} {chr(9472)*40}')

for p in practices:
    pid = p.get('id', '?')
    derived = p.get('derived_from', '?')
    if isinstance(derived, list):
        derived = ', '.join(str(d) for d in derived)
    apps = p.get('applications', 0)
    name = p.get('name', '?')
    status = p.get('status', '?')
    color = GREEN if status == 'active' else YELLOW
    print(f'  {color}{pid:<8}{NC} {str(derived):<12} {apps:<6} {name}')

print()
for p in practices:
    pid = p.get('id', '?')
    desc = p.get('description', '')
    anti = p.get('anti_pattern', '')
    if desc:
        short_desc = desc[:80] + ('...' if len(desc) > 80 else '')
        print(f'  {BOLD}{pid}{NC}: {short_desc}')
        if anti:
            short_anti = anti[:70] + ('...' if len(anti) > 70 else '')
            print(f'    {YELLOW}Anti-pattern:{NC} {short_anti}')
print()
PYPRACTICES
        ;;
    search)
        # Check for --semantic or --hybrid flags
        SEARCH_MODE="keyword"
        SEARCH_ARGS=()
        for arg in "$@"; do
            case "$arg" in
                --semantic) SEARCH_MODE="semantic" ;;
                --hybrid)   SEARCH_MODE="hybrid" ;;
                *)          SEARCH_ARGS+=("$arg") ;;
            esac
        done
        SEARCH_TERM="${SEARCH_ARGS[*]:-}"
        if [ -z "$SEARCH_TERM" ]; then
            echo -e "${RED}Usage: fw search [--semantic|--hybrid] <query>${NC}"
            exit 1
        fi
        if [ "$SEARCH_MODE" != "keyword" ]; then
            python3 - "$SEARCH_MODE" "$SEARCH_TERM" << 'PYVECSEARCH'
import sys, os
os.chdir(os.environ.get('FRAMEWORK_ROOT', '.'))
mode = sys.argv[1]
query = sys.argv[2]

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

if mode == 'semantic':
    from web.embeddings import search
    results = search(query)
elif mode == 'hybrid':
    from web.embeddings import hybrid_search
    results = hybrid_search(query)

print(f'{BOLD}Search ({mode}):{NC} "{query}"')
print()

if not results['results']:
    print(f'{YELLOW}No results found{NC}')
    sys.exit(0)

for item in results['results']:
    score = item['score']
    cat = item.get('category', '')
    tid = item.get('task_id', '')
    title = item['title'][:60]
    path = item['path']
    prefix = f'{GREEN}{tid}{NC} ' if tid else ''
    print(f'  {score:6.3f}  {prefix}{title}')
    print(f'         {CYAN}{path}{NC}')
    if item.get('snippet'):
        # Strip HTML tags for terminal
        import re
        snippet = re.sub(r'</?b>', '', item['snippet'])[:120]
        print(f'         {snippet}')
    print()

print(f'{len(results["results"])} result(s) found')
PYVECSEARCH
            exit $?
        fi
        python3 - "$SEARCH_TERM" << 'PYSEARCH'
import os, subprocess, sys, re

term = sys.argv[1]
project_root = os.environ.get('PROJECT_ROOT', '.')

BOLD = '\033[1m'
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

search_areas = [
    (os.path.join(project_root, '.tasks'), 'Tasks'),
    (os.path.join(project_root, '.context'), 'Context'),
]

spec_docs = []
for fn in os.listdir(project_root):
    if fn.startswith('0') and fn.endswith('.md'):
        spec_docs.append(os.path.join(project_root, fn))

print(f'{BOLD}Search:{NC} "{term}"')
print()

total_results = 0
max_total = 50
max_per_file = 5

for area_path, area_label in search_areas:
    if not os.path.isdir(area_path):
        continue
    try:
        result = subprocess.run(
            ['grep', '-rn', '--include=*.yaml', '--include=*.md', '-i', term, area_path],
            capture_output=True, text=True, timeout=10
        )
        lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
    except:
        continue

    if not lines:
        continue

    by_file = {}
    for line in lines:
        parts = line.split(':', 2)
        if len(parts) >= 3:
            fname = parts[0]
            if fname not in by_file:
                by_file[fname] = []
            by_file[fname].append(line)

    printed = False
    for fname, flines in sorted(by_file.items()):
        if total_results >= max_total:
            break
        rel_path = os.path.relpath(fname, project_root)
        shown = 0
        for line in flines:
            if total_results >= max_total or shown >= max_per_file:
                break
            if not printed:
                print(f'{BOLD}{area_label}{NC}')
                printed = True
            parts = line.split(':', 2)
            if len(parts) >= 3:
                lineno = parts[1]
                content = parts[2].strip()
                highlighted = re.sub(
                    f'({re.escape(term)})',
                    f'{YELLOW}\\1{NC}',
                    content,
                    flags=re.IGNORECASE
                )
                print(f'  {CYAN}{rel_path}{NC}:{lineno}: {highlighted}')
                total_results += 1
                shown += 1
        if shown < len(flines) and total_results < max_total:
            remaining = len(flines) - shown
            print(f'  {CYAN}{rel_path}{NC}: ... {remaining} more match(es)')
    if printed:
        print()

if spec_docs and total_results < max_total:
    printed = False
    for doc_path in sorted(spec_docs):
        if total_results >= max_total:
            break
        try:
            result = subprocess.run(
                ['grep', '-n', '-i', term, doc_path],
                capture_output=True, text=True, timeout=10
            )
            lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
        except:
            continue

        if not lines:
            continue

        if not printed:
            print(f'{BOLD}Spec Documents{NC}')
            printed = True

        rel_path = os.path.relpath(doc_path, project_root)
        shown = 0
        for line in lines:
            if total_results >= max_total or shown >= max_per_file:
                break
            parts = line.split(':', 1)
            if len(parts) >= 2:
                lineno = parts[0]
                content = parts[1].strip()
                highlighted = re.sub(
                    f'({re.escape(term)})',
                    f'{YELLOW}\\1{NC}',
                    content,
                    flags=re.IGNORECASE
                )
                print(f'  {CYAN}{rel_path}{NC}:{lineno}: {highlighted}')
                total_results += 1
                shown += 1
        if shown < len(lines) and total_results < max_total:
            remaining = len(lines) - shown
            print(f'  {CYAN}{rel_path}{NC}: ... {remaining} more match(es)')
    if printed:
        print()

if total_results == 0:
    print(f'{YELLOW}No results found{NC}')
else:
    print(f'{total_results} result(s) found')
PYSEARCH
        ;;
    vendor)
        # T-2095 (T-2078 V1-D, F2): `fw vendor self` subcommand routes to the
        # self-vendor helper extracted from lib/upgrade.sh:do_upgrade. Provides
        # an explicit entry point for cron / pre-push / manual invocation
        # without firing the full 10-step do_upgrade flow. All other `fw vendor`
        # args pass through to do_vendor unchanged (consumer-direction vendor).
        if [ "${1:-}" = "self" ]; then
            shift
            # Source lib/upgrade.sh to pick up _self_vendor_libs() — already
            # sourced at bin/fw startup for do_upgrade, but be defensive in
            # case this code path runs before that.
            command -v _self_vendor_libs >/dev/null 2>&1 || source "$FRAMEWORK_ROOT/lib/upgrade.sh"
            if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
                echo "fw vendor self - Refresh framework's vendored copies from FRAMEWORK_ROOT/"
                echo ""
                echo "Usage: fw vendor self [--dry-run]"
                echo ""
                echo "Refreshes vendored classes from \$FRAMEWORK_ROOT:"
                echo "  - .agentic-framework/lib/                 ← lib/*.sh           (T-2095 V1-D)"
                echo "  - .agentic-framework/.tasks/templates/    ← .tasks/templates/  (T-2241)"
                echo "  - .agentic-framework/policy/              ← policy/{value-drivers.yaml,bvp-scoring-rubric.md} (T-2263)"
                echo "  - .agentic-framework/bin/                 ← bin/fw             (T-2264)"
                echo "  - .agentic-framework/agents/              ← agents/**/*.{sh,py} (T-2266)"
                echo "  - .agentic-framework/web/                 ← web/**/*.{sh,py}    (T-2267)"
                echo ""
                echo "T-1217 / T-2078 §F2 / T-2095 V1-D / T-2241. Extracted from do_upgrade so it can be"
                echo "invoked explicitly (cron, pre-push, manual) without firing the full upgrade flow."
                echo ""
                echo "Structural consumer-safety: when invoked from a consumer's vendored bin/fw,"
                echo "each helper early-returns because consumer's .agentic-framework/ has no nested"
                echo ".agentic-framework/lib/ or .agentic-framework/.tasks/templates/. Idempotent —"
                echo "only touches files that diff."
                exit 0
            fi
            _vs_dry=false
            [ "${1:-}" = "--dry-run" ] && _vs_dry=true
            _self_vendor_libs "$_vs_dry"
            # T-2241: sibling — templates drift class. T-2240 pre-push gate
            # greps for "would sync" across all four helpers' output with one regex.
            _self_vendor_templates "$_vs_dry"
            # T-2263: sibling — BVP-policy drift class (arc-006 Slice 2C).
            _self_vendor_policy "$_vs_dry"
            # T-2264: sibling — bin/fw shim drift class.
            _self_vendor_shim "$_vs_dry"
            # T-2266: sibling — agents/ drift class (5th class).
            _self_vendor_agents "$_vs_dry"
            # T-2267: sibling — web/ drift class (6th class).
            _self_vendor_web "$_vs_dry"
            exit $?
        fi
        do_vendor "$@"
        ;;
    hook)
        # fw hook <name> [args...] — runtime path resolution for Claude Code hooks
        # Resolves FRAMEWORK_ROOT from symlink, PROJECT_ROOT from cwd.
        # Replaces hardcoded absolute paths in .claude/settings.json (G-021/T-496).
        _hook_name="${1:-}"
        shift 2>/dev/null || true
        # T-1437: --help / -h print usage, not treated as a hook name
        # (would miss agents/context/--help.sh and log a spurious crash).
        if [ -z "$_hook_name" ] || [ "$_hook_name" = "--help" ] || [ "$_hook_name" = "-h" ]; then
            _hook_exit=1
            [ "${_hook_name:-}" = "--help" ] || [ "${_hook_name:-}" = "-h" ] && _hook_exit=0
            echo "Usage: fw hook <name> [args...]" >&2
            # T-1186/G-042: Generate hook list from filesystem (single source of truth)
            _hooks=$(ls "$AGENTS_DIR/context/"*.sh 2>/dev/null | xargs -I{} basename {} .sh | sort | tr '\n' ', ' | sed 's/,$//' | sed 's/,/, /g')
            echo "Available hooks: $_hooks" >&2
            exit $_hook_exit
        fi
        _hook_script="$AGENTS_DIR/context/${_hook_name}.sh"
        if [ ! -f "$_hook_script" ]; then
            # T-1360 / G-053-B: missing hook script = config drift, NOT a reason to
            # block the agent's tool surface. Exit 2 would hard-block PreToolUse and
            # cascade across every Bash/Write/Edit. Log once to hook-crashes.log so
            # `fw doctor` can surface the drift, then exit 0 (allow).
            echo "WARNING: Hook script not found: $_hook_script (degrading to allow — fix hook config)" >&2
            if [ -n "${PROJECT_ROOT:-}" ] && [ -d "$PROJECT_ROOT/.context/working" ]; then
                _crash_log="$PROJECT_ROOT/.context/working/.hook-crashes.log"
                echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) missing-hook $_hook_name script=$_hook_script" >> "$_crash_log" 2>/dev/null || true
            fi
            # T-1628 (B-2 of T-1626): record the missing-hook event as a failure
            # so .hook-failure-counter surfaces it. This is the exact witness
            # scenario from T-1626 (ring20-dashboard 2026-04-30): a path-broken
            # hook that "succeeds" with exit 0 was invisible to every health
            # surface. By marking it as a fire with synthetic exit 127 (sh
            # convention for command-not-found), B-3's threshold scan + doctor
            # check have a deterministic signal to alert on.
            if [ -f "$FW_LIB_DIR/hook-telemetry.sh" ]; then
                # shellcheck disable=SC1091
                source "$FW_LIB_DIR/hook-telemetry.sh"
                fw_record_hook_fire "$_hook_name" 127 2>/dev/null || true
            fi
            exit 0
        fi
        export FRAMEWORK_ROOT PROJECT_ROOT
        # T-863: Use bash explicitly — don't require -x (core.filemode=false drops execute bits)
        # T-1628 (B-2 of T-1626): record per-hook fire + non-zero-exit telemetry to
        # .context/working/.hook-counter / .hook-failure-counter. The original `exec`
        # replaced this process; we now run-and-capture so post-exit telemetry can
        # observe the exit code. Telemetry overhead is <0.2ms per fire (mapfile-based,
        # no subprocess); the extra fork from non-exec is negligible vs. hook payload.
        # Source is best-effort — if telemetry helper is missing, skip silently and
        # fall back to plain exec (preserves behaviour on degraded installs).
        if [ -f "$FW_LIB_DIR/hook-telemetry.sh" ]; then
            # shellcheck disable=SC1091
            source "$FW_LIB_DIR/hook-telemetry.sh"
            bash "$_hook_script" "$@"
            _hook_rc=$?
            fw_record_hook_fire "$_hook_name" "$_hook_rc" 2>/dev/null || true
            exit "$_hook_rc"
        else
            exec bash "$_hook_script" "$@"
        fi
        ;;
    hook-enable)
        # fw hook-enable — register a framework hook in .claude/settings.json (T-1189)
        _he_script="$FRAMEWORK_ROOT/bin/hook-enable.sh"
        if [ ! -f "$_he_script" ]; then
            echo "ERROR: hook-enable.sh not found at $_he_script" >&2
            exit 2
        fi
        export FRAMEWORK_ROOT PROJECT_ROOT
        exec bash "$_he_script" "$@"
        ;;
    doctor)
        do_doctor "$@"
        ;;
    verify-acs)
        source "$FW_LIB_DIR/verify-acs.sh"
        do_verify_acs "$@"
        ;;
    self-test)
        # Detect subcommand vs flags — flags start with --
        st_subcmd="all"
        if [ $# -gt 0 ] && [[ ! "$1" == --* ]]; then
            st_subcmd="$1"
            shift
        fi
        case "$st_subcmd" in
            onboarding)
                "$FRAMEWORK_ROOT/tests/e2e/onboarding-test.sh" "$@"
                ;;
            gates)
                "$FRAMEWORK_ROOT/tests/e2e/gates-test.sh" "$@"
                ;;
            lifecycle)
                "$FRAMEWORK_ROOT/tests/e2e/lifecycle-test.sh" "$@"
                ;;
            upgrade)
                "$FRAMEWORK_ROOT/tests/e2e/upgrade-test.sh" "$@"
                ;;
            all)
                st_exit=0
                for st_phase in gates lifecycle upgrade onboarding; do
                    st_script="$FRAMEWORK_ROOT/tests/e2e/${st_phase}-test.sh"
                    if [ -x "$st_script" ]; then
                        "$st_script" "$@" || st_exit=1
                        echo ""
                    fi
                done
                exit $st_exit
                ;;
            help|--help|-h)
                echo "Usage: fw self-test [onboarding|gates|lifecycle|upgrade|all] [--json] [--port N] [--verbose]"
                ;;
            *)
                echo "Unknown self-test phase: $st_subcmd"
                echo "Usage: fw self-test [onboarding|gates|lifecycle|upgrade|all] [--json] [--port N] [--verbose]"
                exit 1
                ;;
        esac
        ;;
    enforcement)
        enforcement_subcmd="${1:-status}"
        shift 2>/dev/null || true
        EF_SETTINGS="$PROJECT_ROOT/.claude/settings.json"
        EF_BASELINE="$PROJECT_ROOT/.context/project/enforcement-baseline.sha256"
        case "$enforcement_subcmd" in
            baseline)
                # Save current settings.json hooks hash as enforcement baseline
                if [ ! -f "$EF_SETTINGS" ]; then
                    echo -e "${RED}No .claude/settings.json found${NC}"
                    exit 1
                fi
                mkdir -p "$(dirname "$EF_BASELINE")"
                EF_HASH=$(python3 -c "
import json, hashlib
with open('$EF_SETTINGS') as f:
    data = json.load(f)
hooks_str = json.dumps(data.get('hooks', {}), sort_keys=True)
print(hashlib.sha256(hooks_str.encode()).hexdigest())
" 2>/dev/null)
                if [ -z "$EF_HASH" ]; then
                    echo -e "${RED}Failed to compute hash${NC}"
                    exit 1
                fi
                echo "$EF_HASH" > "$EF_BASELINE"
                echo -e "${GREEN}Enforcement baseline saved${NC}"
                echo "  Hash: ${EF_HASH:0:16}..."
                echo "  File: $EF_BASELINE"
                ;;
            status)
                echo -e "${BOLD}fw enforcement status${NC}"
                echo ""
                echo "Layer 1: Claude Code Hooks"
                if [ -f "$EF_SETTINGS" ]; then
                    python3 -c "
import json
with open('$EF_SETTINGS') as f:
    data = json.load(f)
hooks = data.get('hooks', {})
for event, entries in hooks.items():
    for e in entries:
        matcher = e.get('matcher', '*')
        for h in e.get('hooks', []):
            cmd = h.get('command', '').split('/')[-1].split()[0]
            print(f'  {event:15s} [{matcher or \"*\":20s}] → {cmd}')
" 2>/dev/null
                else
                    echo "  Not configured"
                fi
                echo ""
                echo "Layer 2: Git Hooks"
                HOOKS_DIR="$PROJECT_ROOT/.git/hooks"
                for hook in commit-msg post-commit pre-push; do
                    if [ -f "$HOOKS_DIR/$hook" ] && grep -q "Agentic" "$HOOKS_DIR/$hook" 2>/dev/null; then
                        echo -e "  ${GREEN}✓${NC} $hook"
                    else
                        echo -e "  ${RED}✗${NC} $hook (not installed)"
                    fi
                done
                echo ""
                echo "Layer 3: Enforcement Baseline"
                if [ -f "$EF_BASELINE" ]; then
                    echo -e "  ${GREEN}✓${NC} Baseline set ($(cut -c1-16 < "$EF_BASELINE")...)"
                else
                    echo -e "  ${YELLOW}!${NC} No baseline — run 'fw enforcement baseline'"
                fi
                ;;
            *)
                echo "Usage: fw enforcement {baseline|status}"
                echo ""
                echo "  baseline  Save current settings.json hooks hash"
                echo "  status    Show all enforcement layers"
                ;;
        esac
        ;;
    metrics)
        metrics_subcmd="${1:-dashboard}"
        case "$metrics_subcmd" in
            predict|estimate)
                shift
                python3 - "$@" << 'PYPREDICT'
import os, yaml, sys

BOLD = '\033[1m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
CYAN = '\033[0;36m'
NC = '\033[0m'

project_root = os.environ.get('PROJECT_ROOT', '.')
ep_dir = os.path.join(project_root, '.context', 'episodic')

# Parse args
type_filter = None
tag_filter = None
args = sys.argv[1:]
i = 0
while i < len(args):
    if args[i] == '--type' and i + 1 < len(args):
        type_filter = args[i+1]; i += 2
    elif args[i] == '--tag' and i + 1 < len(args):
        tag_filter = args[i+1]; i += 2
    elif args[i] in ('-h', '--help'):
        print(f'{BOLD}fw metrics predict{NC} — Estimate task effort from historical data')
        print()
        print('Options:')
        print('  --type <type>   Filter by workflow type (build, test, refactor, ...)')
        print('  --tag <tag>     Filter by tag')
        print()
        print('Examples:')
        print('  fw metrics predict --type build')
        print('  fw metrics predict --tag watchtower')
        sys.exit(0)
    else:
        i += 1

if not os.path.isdir(ep_dir):
    print(f'{YELLOW}No episodic memory found{NC}')
    sys.exit(0)

# Load all episodics with metrics
episodes = []
for fn in os.listdir(ep_dir):
    if not fn.endswith('.yaml') or fn == 'TEMPLATE.yaml':
        continue
    try:
        with open(os.path.join(ep_dir, fn)) as f:
            ep = yaml.safe_load(f)
        if not isinstance(ep, dict) or not ep.get('metrics'):
            continue
        episodes.append(ep)
    except:
        continue

# Apply filters
if type_filter:
    episodes = [e for e in episodes if e.get('workflow_type', '').lower() == type_filter.lower()]
if tag_filter:
    episodes = [e for e in episodes if tag_filter.lower() in [str(t).lower() for t in e.get('tags', [])]]

if not episodes:
    label = ''
    if type_filter:
        label += f' type={type_filter}'
    if tag_filter:
        label += f' tag={tag_filter}'
    print(f'{YELLOW}No episodic data with metrics found{label}{NC}')
    print(f'Complete more tasks to build prediction data.')
    sys.exit(0)

# Compute statistics
def stats(values):
    values = [v for v in values if v is not None and v > 0]
    if not values:
        return {'min': 0, 'max': 0, 'avg': 0, 'median': 0, 'count': 0}
    values.sort()
    n = len(values)
    median = values[n // 2] if n % 2 == 1 else (values[n // 2 - 1] + values[n // 2]) / 2
    return {
        'min': min(values),
        'max': max(values),
        'avg': sum(values) / n,
        'median': median,
        'count': n,
    }

minutes = stats([e.get('metrics', {}).get('wall_clock_minutes', 0) for e in episodes])
commits = stats([e.get('metrics', {}).get('commits', 0) for e in episodes])
lines_a = stats([e.get('metrics', {}).get('lines_added', 0) for e in episodes])
lines_r = stats([e.get('metrics', {}).get('lines_removed', 0) for e in episodes])
files_c = stats([e.get('metrics', {}).get('files_changed', 0) for e in episodes])

label = ''
if type_filter:
    label += f' type={type_filter}'
if tag_filter:
    label += f' tag={tag_filter}'
if not label:
    label = ' (all types)'

print(f'{BOLD}Effort Prediction{NC}{label} — {len(episodes)} completed task(s)')
print()
print(f'  {"Metric":<20} {"Min":>8} {"Median":>8} {"Avg":>8} {"Max":>8}  {"(n)":>4}')
print(f'  {chr(9472)*20} {chr(9472)*8} {chr(9472)*8} {chr(9472)*8} {chr(9472)*8}  {chr(9472)*4}')

def row(label, s, unit=''):
    if s['count'] == 0:
        print(f'  {label:<20} {"—":>8} {"—":>8} {"—":>8} {"—":>8}  {0:>4}')
    else:
        print(f'  {label:<20} {s["min"]:>7.0f}{unit} {s["median"]:>7.0f}{unit} {s["avg"]:>7.0f}{unit} {s["max"]:>7.0f}{unit}  {s["count"]:>4}')

row('Wall clock (min)', minutes)
row('Commits', commits)
row('Files changed', files_c)
row('Lines added', lines_a)
row('Lines removed', lines_r)

print()
print(f'{BOLD}Prediction:{NC} A similar task will likely take:')
if minutes['count'] > 0:
    print(f'  {GREEN}~{minutes["median"]:.0f} min{NC} (median), {minutes["avg"]:.0f} min (avg)')
    print(f'  ~{commits["median"]:.0f} commits, ~{lines_a["median"]:.0f} lines added')
else:
    print(f'  {YELLOW}Insufficient data{NC}')

print()
print(f'{CYAN}Source tasks:{NC}')
for e in episodes:
    m = e.get('metrics', {})
    tid = e.get('task_id', '?')
    name = e.get('task_name', '?')
    wmin = m.get('wall_clock_minutes', 0)
    cmt = m.get('commits', 0)
    la = m.get('lines_added', 0)
    print(f'  {tid}: {name} ({wmin}min, {cmt} commits, +{la} lines)')
PYPREDICT
                ;;
            dashboard|"")
                shift 2>/dev/null || true
                if [ -f "$PROJECT_ROOT/metrics.sh" ]; then
                    exec "$PROJECT_ROOT/metrics.sh" "$@"
                elif [ -f "$FRAMEWORK_ROOT/metrics.sh" ]; then
                    exec "$FRAMEWORK_ROOT/metrics.sh" "$@"
                else
                    echo -e "${RED}ERROR: metrics.sh not found${NC}" >&2
                    exit 1
                fi
                ;;
            api-usage)
                # T-1304: Reads $TERMLINK_RUNTIME_DIR/rpc-audit.jsonl, tallies
                # per-method calls over a time window, reports legacy-primitive
                # percentage. Used as the T-1166 entry gate.
                shift
                api_usage_script="$FRAMEWORK_ROOT/agents/metrics/api-usage.sh"
                if [ ! -f "$api_usage_script" ]; then
                    echo -e "${RED}ERROR: api-usage.sh not found at $api_usage_script${NC}" >&2
                    exit 1
                fi
                exec bash "$api_usage_script" "$@"
                ;;
            *)
                echo -e "${RED}Unknown metrics subcommand: $metrics_subcmd${NC}" >&2
                echo "Subcommands: dashboard, predict, api-usage" >&2
                exit 1
                ;;
        esac
        ;;
    costs)
        # T-801: Token usage tracking from JSONL transcripts
        source "$FW_LIB_DIR/costs.sh"
        costs_main "$@"
        ;;
    release)
        # T-1256: Release tagging + push + GitHub Release
        source "$FW_LIB_DIR/release.sh"
        release_main "$@"
        ;;
    mirror)
        # T-1594 (T-1591 Prevention #3): Mirror cascade auto-recovery
        source "$FW_LIB_DIR/mirror.sh"
        mirror_main "$@"
        ;;
    test)
        _test_exit=0

        case "${1:-all}" in
            --lint|lint)
                # ShellCheck linting
                if ! command -v shellcheck >/dev/null 2>&1; then
                    echo -e "${RED}ERROR: shellcheck is not installed${NC}" >&2
                    exit 1
                fi
                echo -e "${BOLD}=== ShellCheck Lint ===${NC}"
                _lint_files=()
                while IFS= read -r -d '' f; do
                    _lint_files+=("$f")
                done < <(find "$FRAMEWORK_ROOT" -name "*.sh" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/test_helper*" -print0)
                _lint_fail=0
                _lint_pass=0
                _lint_warn=0
                for f in "${_lint_files[@]}"; do
                    _rel="${f#"$FRAMEWORK_ROOT"/}"
                    if _sc_output=$(shellcheck -S warning "$f" 2>&1); then
                        _lint_pass=$((_lint_pass + 1))
                    else
                        _severity="warning"
                        echo "$_sc_output" | grep -q "error" && _severity="error"
                        if [ "$_severity" = "error" ]; then
                            echo -e "  ${RED}FAIL${NC} $_rel"
                            _lint_fail=$((_lint_fail + 1))
                        else
                            echo -e "  ${YELLOW}WARN${NC} $_rel"
                            _lint_warn=$((_lint_warn + 1))
                        fi
                        echo "$_sc_output" | head -5 | sed 's/^/       /'
                    fi
                done
                echo ""
                echo -e "${BOLD}Lint:${NC} ${_lint_pass} pass, ${_lint_warn} warn, ${_lint_fail} fail (${#_lint_files[@]} files)"
                [ "$_lint_fail" -gt 0 ] && _test_exit=1
                ;;
            --unit|unit)
                # Bats unit tests only
                if ! command -v bats >/dev/null 2>&1; then
                    echo -e "${RED}ERROR: bats is not installed${NC}" >&2
                    exit 1
                fi
                shift
                [ "${1:-}" = "--" ] && shift
                echo -e "${BOLD}=== Bats Unit Tests ===${NC}"
                # T-1588: extra args (file paths, glob patterns) are passed through.
                if [ "$#" -gt 0 ]; then
                    bats "$@" || _test_exit=1
                else
                    bats "$FRAMEWORK_ROOT/tests/unit/" || _test_exit=1
                fi
                ;;
            --integration|integration)
                # Bats integration tests only
                if ! command -v bats >/dev/null 2>&1; then
                    echo -e "${RED}ERROR: bats is not installed${NC}" >&2
                    exit 1
                fi
                shift
                [ "${1:-}" = "--" ] && shift
                echo -e "${BOLD}=== Bats Integration Tests ===${NC}"
                if [ "$#" -gt 0 ]; then
                    bats "$@" || _test_exit=1
                elif [ -d "$FRAMEWORK_ROOT/tests/integration/" ] && ls "$FRAMEWORK_ROOT/tests/integration/"*.bats >/dev/null 2>&1; then
                    bats "$FRAMEWORK_ROOT/tests/integration/" || _test_exit=1
                else
                    echo "  No integration tests found."
                fi
                ;;
            --governance|governance)
                # Red-team governance harness (T-1601 arc: T-1606/T-1607/T-1608)
                if ! command -v bats >/dev/null 2>&1; then
                    echo -e "${RED}ERROR: bats is not installed${NC}" >&2
                    exit 1
                fi
                shift
                [ "${1:-}" = "--" ] && shift
                echo -e "${BOLD}=== Bats Governance Tests (red-team harness) ===${NC}"
                if [ "$#" -gt 0 ]; then
                    bats "$@" || _test_exit=1
                elif [ -d "$FRAMEWORK_ROOT/tests/governance/" ] && ls "$FRAMEWORK_ROOT/tests/governance/"*.bats >/dev/null 2>&1; then
                    bats "$FRAMEWORK_ROOT/tests/governance/" || _test_exit=1
                else
                    echo "  No governance tests found."
                fi
                ;;
            --web|web)
                # Pytest web tests
                if ! python3 -c "import pytest" 2>/dev/null; then
                    echo -e "${RED}ERROR: pytest is not installed${NC}" >&2
                    exit 1
                fi
                shift
                [ "${1:-}" = "--" ] && shift
                echo -e "${BOLD}=== Web Tests (pytest) ===${NC}"
                if [ "$#" -gt 0 ]; then
                    cd "$FRAMEWORK_ROOT" && python3 -m pytest "$@" -v || _test_exit=1
                else
                    cd "$FRAMEWORK_ROOT" && python3 -m pytest web/test_app.py -v || _test_exit=1
                fi
                ;;
            --playwright|playwright|ui)
                # Playwright UI tests (T-969)
                if ! python3 -c "import playwright" 2>/dev/null; then
                    echo -e "${RED}ERROR: playwright is not installed${NC}" >&2
                    echo "  Install: pip install playwright && playwright install chromium"
                    exit 1
                fi
                if ! python3 -c "import pytest" 2>/dev/null; then
                    echo -e "${RED}ERROR: pytest is not installed${NC}" >&2
                    exit 1
                fi
                if [ ! -d "$FRAMEWORK_ROOT/tests/playwright" ]; then
                    echo -e "${YELLOW}No Playwright tests found (tests/playwright/ missing)${NC}"
                    exit 0
                fi
                shift
                [ "${1:-}" = "--" ] && shift
                echo -e "${BOLD}=== Playwright UI Tests ===${NC}"
                if [ "$#" -gt 0 ]; then
                    cd "$FRAMEWORK_ROOT" && python3 -m pytest "$@" -v || _test_exit=1
                else
                    cd "$FRAMEWORK_ROOT" && python3 -m pytest tests/playwright/ -v || _test_exit=1
                fi
                ;;
            all)
                # Run everything
                echo -e "${BOLD}=== Framework Test Suite ===${NC}"
                echo ""

                # 1. Bats unit tests
                if command -v bats >/dev/null 2>&1; then
                    echo -e "${BOLD}--- Bats Unit Tests ---${NC}"
                    bats "$FRAMEWORK_ROOT/tests/unit/" || _test_exit=1
                    echo ""
                else
                    echo -e "${YELLOW}SKIP: bats not installed${NC}"
                fi

                # 2. Bats integration tests
                if command -v bats >/dev/null 2>&1; then
                    if [ -d "$FRAMEWORK_ROOT/tests/integration/" ] && ls "$FRAMEWORK_ROOT/tests/integration/"*.bats >/dev/null 2>&1; then
                        echo -e "${BOLD}--- Bats Integration Tests ---${NC}"
                        bats "$FRAMEWORK_ROOT/tests/integration/" || _test_exit=1
                        echo ""
                    fi
                fi

                # 2b. Bats governance tests (T-1601 red-team harness)
                if command -v bats >/dev/null 2>&1; then
                    if [ -d "$FRAMEWORK_ROOT/tests/governance/" ] && ls "$FRAMEWORK_ROOT/tests/governance/"*.bats >/dev/null 2>&1; then
                        echo -e "${BOLD}--- Bats Governance Tests (red-team harness) ---${NC}"
                        bats "$FRAMEWORK_ROOT/tests/governance/" || _test_exit=1
                        echo ""
                    fi
                fi

                # 3. Pytest web tests
                if python3 -c "import pytest" 2>/dev/null; then
                    echo -e "${BOLD}--- Web Tests (pytest) ---${NC}"
                    cd "$FRAMEWORK_ROOT" && python3 -m pytest web/test_app.py -v || _test_exit=1
                    echo ""
                else
                    echo -e "${YELLOW}SKIP: pytest not installed${NC}"
                fi

                # 4. Playwright UI tests (T-969, T-1725)
                # T-1725: split the SKIP into actionable cases — pip-missing vs tests-missing
                # are different problems with different fixes. Also look in PROJECT_ROOT first
                # so consumer projects' own UI tests run.
                _pw_dir=""
                if [ -d "$PROJECT_ROOT/tests/playwright" ]; then
                    _pw_dir="$PROJECT_ROOT/tests/playwright"
                elif [ -d "$FRAMEWORK_ROOT/tests/playwright" ]; then
                    _pw_dir="$FRAMEWORK_ROOT/tests/playwright"
                fi
                if ! python3 -c "import playwright" 2>/dev/null; then
                    echo -e "${YELLOW}SKIP: pytest-playwright not installed (essential for UI regression tests)${NC}"
                    echo -e "       Install: ${BOLD}pip install playwright pytest-playwright && playwright install chromium${NC}"
                    echo -e "       Note: this is the pip package — separate from the @playwright/mcp npm server"
                elif [ -z "$_pw_dir" ]; then
                    echo -e "${YELLOW}SKIP: no tests/playwright/ found in project or framework${NC}"
                else
                    echo -e "${BOLD}--- Playwright UI Tests (${_pw_dir#$PROJECT_ROOT/}) ---${NC}"
                    cd "$PROJECT_ROOT" && python3 -m pytest "$_pw_dir" -v || _test_exit=1
                    echo ""
                fi
                ;;
            *)
                echo -e "${BOLD}Usage:${NC} fw test [all|unit|integration|governance|web|playwright|lint]"
                echo ""
                echo "  all           Run all tests (default)"
                echo "  unit          Bats unit tests only"
                echo "  integration   Bats integration tests only"
                echo "  governance    Bats red-team governance harness (T-1601 arc)"
                echo "  web           Pytest web tests only"
                echo "  playwright    Playwright UI tests (alias: ui)"
                echo "  lint          ShellCheck linting on all .sh files"
                exit 0
                ;;
        esac
        exit $_test_exit
        ;;
    notify)
        # T-710: Push notification management
        _notify_sub="${1:-}"
        shift || true
        _notify_config="$PROJECT_ROOT/.context/notify-config.yaml"

        _notify_status() {
            local enabled="false"
            if [ -f "$_notify_config" ]; then
                enabled=$(python3 -c "import yaml; d=yaml.safe_load(open('$_notify_config')); print(str(d.get('enabled','false')).lower())" 2>/dev/null || echo "false")
            fi
            local dispatcher="${SKILLS_DISPATCHER:-/opt/150-skills-manager/skills/alerts/alert_dispatcher.py}"
            local dispatcher_ok="missing"
            [ -f "$dispatcher" ] && dispatcher_ok="found"

            echo -e "${BOLD}Notification Status${NC}"
            echo ""
            if [ "$enabled" = "true" ]; then
                echo -e "  Enabled:    ${GREEN}true${NC}"
            else
                echo -e "  Enabled:    ${YELLOW}false${NC}"
            fi
            echo -e "  Config:     $_notify_config"
            echo -e "  Dispatcher: $dispatcher ($dispatcher_ok)"
            echo ""
            if [ "$enabled" != "true" ]; then
                echo -e "  Run ${CYAN}fw notify enable${NC} to activate notifications"
            fi
        }

        _notify_enable() {
            mkdir -p "$(dirname "$_notify_config")"
            if [ -f "$_notify_config" ]; then
                python3 -c "
import yaml
with open('$_notify_config') as f:
    d = yaml.safe_load(f) or {}
d['enabled'] = True
with open('$_notify_config', 'w') as f:
    yaml.dump(d, f, default_flow_style=False)
"
            else
                cat > "$_notify_config" << 'YAMLEOF'
# Notification configuration (T-710)
# Managed by: fw notify enable/disable
enabled: true
YAMLEOF
            fi
            echo -e "${GREEN}Notifications enabled${NC}"
            echo "  Config: $_notify_config"
        }

        _notify_disable() {
            if [ -f "$_notify_config" ]; then
                python3 -c "
import yaml
with open('$_notify_config') as f:
    d = yaml.safe_load(f) or {}
d['enabled'] = False
with open('$_notify_config', 'w') as f:
    yaml.dump(d, f, default_flow_style=False)
"
            else
                mkdir -p "$(dirname "$_notify_config")"
                cat > "$_notify_config" << 'YAMLEOF'
# Notification configuration (T-710)
# Managed by: fw notify enable/disable
enabled: false
YAMLEOF
            fi
            echo -e "${YELLOW}Notifications disabled${NC}"
        }

        _notify_test() {
            # Check if enabled
            local enabled="false"
            if [ -f "$_notify_config" ]; then
                enabled=$(python3 -c "import yaml; d=yaml.safe_load(open('$_notify_config')); print(str(d.get('enabled','false')).lower())" 2>/dev/null || echo "false")
            fi
            if [ "$enabled" != "true" ]; then
                echo -e "${YELLOW}Notifications are disabled.${NC} Enable first:"
                echo -e "  ${CYAN}fw notify enable${NC}"
                exit 1
            fi

            source "$FRAMEWORK_ROOT/lib/notify.sh"
            echo -e "Sending test notification..."
            NTFY_ENABLED=true fw_notify \
                "Framework Test" \
                "Test notification from $(basename "$PROJECT_ROOT") at $(date '+%H:%M:%S')" \
                "manual" \
                "framework"
            # Wait briefly for background process
            sleep 1
            echo -e "${GREEN}Test notification sent${NC}"
            echo "  Check your ntfy app for the notification"
        }

        _notify_setup() {
            echo -e "${BOLD}Notification Setup${NC}"
            echo ""
            echo "The framework uses ntfy push notifications via the skills-manager"
            echo "alert dispatcher. Notifications are opt-in and fire-and-forget."
            echo ""
            echo -e "${BOLD}Prerequisites:${NC}"
            local dispatcher="${SKILLS_DISPATCHER:-/opt/150-skills-manager/skills/alerts/alert_dispatcher.py}"
            if [ -f "$dispatcher" ]; then
                echo -e "  ${GREEN}OK${NC}  Skills-manager alert dispatcher found"
            else
                echo -e "  ${RED}MISSING${NC}  Alert dispatcher not found at:"
                echo "         $dispatcher"
                echo ""
                echo "  The alert dispatcher routes notifications to ntfy topics."
                echo "  Set SKILLS_DISPATCHER env var to override the default path."
                exit 1
            fi
            echo ""
            echo -e "${BOLD}Steps:${NC}"
            echo "  1. fw notify enable     — activate notifications for this project"
            echo "  2. fw notify test       — send a test push to verify delivery"
            echo "  3. fw notify status     — check current configuration"
            echo ""
            if [ -f "$_notify_config" ]; then
                local enabled
                enabled=$(python3 -c "import yaml; d=yaml.safe_load(open('$_notify_config')); print(str(d.get('enabled','false')).lower())" 2>/dev/null || echo "false")
                if [ "$enabled" = "true" ]; then
                    echo -e "  ${GREEN}Already configured and enabled.${NC}"
                else
                    echo -e "  Config exists but notifications are ${YELLOW}disabled${NC}."
                    echo -e "  Run ${CYAN}fw notify enable${NC} to activate."
                fi
            else
                echo -e "  No config file yet. Run ${CYAN}fw notify enable${NC} to create one."
            fi
        }

        case "$_notify_sub" in
            status)
                _notify_status
                ;;
            enable)
                _notify_enable
                ;;
            disable)
                _notify_disable
                ;;
            test)
                _notify_test
                ;;
            setup)
                _notify_setup
                ;;
            ""|--help|-h)
                echo -e "${BOLD}Usage:${NC} fw notify <command>"
                echo ""
                echo -e "${BOLD}Commands:${NC}"
                echo "  status    Show notification configuration"
                echo "  enable    Enable push notifications"
                echo "  disable   Disable push notifications"
                echo "  test      Send a test notification"
                echo "  setup     Setup guide and prerequisite check"
                echo ""
                echo "Notifications use ntfy via the skills-manager alert dispatcher."
                echo "Disabled by default — run 'fw notify enable' to activate."
                ;;
            *)
                echo -e "${RED}Unknown notify subcommand: $_notify_sub${NC}" >&2
                echo "Usage: fw notify {status|enable|disable|test|setup}" >&2
                exit 1
                ;;
        esac
        ;;
    config)
        source "$FW_LIB_DIR/config-file.sh"
        do_config "$@"
        ;;
    version|-v|--version)
        _ver_sub="${1:-}"
        case "$_ver_sub" in
            bump)
                shift
                source "$FW_LIB_DIR/version.sh"
                do_version_bump "$@"
                ;;
            check)
                shift
                source "$FW_LIB_DIR/version.sh"
                do_version_check "$@"
                ;;
            sync)
                shift
                source "$FW_LIB_DIR/version.sh"
                do_version_sync "$@"
                ;;
            ""|--help|-h|-v|--version)
                show_version
                ;;
            *)
                echo -e "${RED}Unknown version subcommand: $_ver_sub${NC}" >&2
                echo "Usage: fw version [bump|check|sync]" >&2
                exit 1
                ;;
        esac
        ;;
    help|-h|--help)
        show_help
        ;;
    "")
        show_help
        exit 1
        ;;
    *)
        echo -e "${RED}Unknown command: $cmd${NC}" >&2
        echo "Run 'fw help' for usage" >&2
        exit 1
        ;;
esac
