Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/bin/nornir.rs

//! `nornir` — human-facing CLI driver over the nornir library.
//!
//! Reads the consumer's `nornir.toml` (autodiscovered or `--config`)
//! and dispatches to lib APIs. Mirror image of `nornir-mcp`, which
//! exposes the same surface to LLM agents over MCP.

// Multicore-friendly global allocator (fat-mode deep-scan parity with the
// server): per-thread heaps so 32-worker columnization doesn't serialize on the
// glibc arena lock. The path is still zero-alloc/zero-copy where we can get it —
// this just stops the allocs we DO make from contending.
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, CommandFactory, Parser, Subcommand};

use nornir::bench;
use nornir::config::{self, Loaded};
use nornir::docs;
use nornir::guard;
use nornir::index;
use nornir::introspect;
use nornir::release;
use nornir::warehouse;

#[derive(Parser)]
#[command(name = "nornir", version = nornir::version_full(env!("CARGO_BIN_NAME")), about = "Companion to cargo: bench, docs, guard (release orchestration → `dwarves release`).")]
struct Cli {
    /// Path to nornir.toml (default: autodiscover by walking up from cwd).
    #[arg(long, global = true)]
    config: Option<PathBuf>,

    /// Run heavy tests (#[ignore]'d container/corpus/network/model/scale-matrix
    /// arms). Heavy tests are OFF by default everywhere (release, `test`, viz) —
    /// this opts IN. Sets NORNIR_RUN_HEAVY for the spawned `cargo test`/matrix.
    #[arg(long, global = true)]
    heavy: bool,

    /// Skip heavy tests. DEPRECATED / no-op: heavy is already OFF by default —
    /// kept so existing scripts/CI passing `--skip-heavy` don't break. Use
    /// `--heavy` to opt IN. Sets NORNIR_SKIP_HEAVY for back-compat.
    #[arg(long, global = true)]
    skip_heavy: bool,

    /// Thin-client target: connect to this nornir-server, e.g. `http://oden:7878`
    /// (a bare `host:port` gets `http://` prepended). Works on EVERY subcommand —
    /// sets `NORNIR_SERVER` for the whole invocation so the command routes to the
    /// server instead of the local warehouse. Falls back to `$NORNIR_SERVER`.
    #[arg(long, global = true)]
    server: Option<String>,

    /// Bearer token for `--server`. Falls back to `$NORNIR_SERVER_TOKEN`, then the
    /// user-readable `<home>/.nornir/token`.
    #[arg(long, global = true)]
    token: Option<String>,

    /// Target workspace by name (the `workspace_<name>` suffix, e.g. `nordisk`),
    /// for when you are NOT standing in a `workspace_<name>/` dir. Sets
    /// `NORNIR_WORKSPACE` for the whole invocation. In thin-client mode the name
    /// must be one the `--server` serves (the client lists the served names if
    /// not). Falls back to `$NORNIR_WORKSPACE`, then the cwd's `workspace_<name>`.
    #[arg(long, global = true)]
    workspace: Option<String>,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// AI-agent guard rails (chmod -w forbidden paths).
    Guard {
        #[command(subcommand)]
        op: GuardOp,
    },
    /// List every CLI command — the recursive clap leaf paths (`bench history`,
    /// `workspace ls`, …). This is the CLI's self-description, the autopilot's
    /// surface source (the CLI twin of MCP `tools/list`). `--json` emits a uniform
    /// `CommandOutcome` for tooling (AUT9). A migrated command — emits its data
    /// through `CommandOutcome`, so the autopilot's sane-data check can assert it.
    Commands {
        /// Emit a machine-readable `CommandOutcome{command,ok,data,human}` instead
        /// of the plain list.
        #[arg(long)]
        json: bool,
    },
    /// Benchmark HISTORY — and, as an alias, the runner that now lives on the forge.
    ///
    /// `bench history-show` is warehouse work and stays here: it is a read of
    /// `bench_runs`, which nornir owns. `bench run` is FORGE work — it builds and
    /// runs a measurement over a product — and its home is now `dwarves bench run`.
    /// It is kept here as a working alias that prints where it moved, because a
    /// verb that silently stops existing is worse than one that points.
    Bench {
        #[command(subcommand)]
        op: BenchOp,
    },
    /// Release gates and publish.
    Release {
        #[command(subcommand)]
        op: ReleaseOp,
    },
    /// Standardized test-matrix (C6): run a repo's native `cargo test` /
    /// `cargo nextest run`, parse per-test pass/fail + duration, and persist
    /// each run to the warehouse `test_results` table. The viz Test pane reads
    /// it back as a per-repo green/red matrix.
    Test {
        #[command(subcommand)]
        op: TestOp,
    },
    /// Source-based **line/region coverage**, maven-report style (feature N).
    /// Runs `cargo-llvm-cov` (LLVM source-based instrumentation) over a repo and
    /// emits per-crate + per-file + per-function coverage, persisting the run to
    /// the warehouse `coverage`/`coverage_fn` tables (time-travellable, feeds the
    /// viz 🧪 Test pane + the docs `nornir:gen:coverage` table). A dedicated
    /// `--boundary` view lists the ui / gRPC / emitter layer-boundary functions
    /// and whether each is exercised. CLI twin of the viz coverage panel.
    Coverage(CoverageArgs),
    /// Run the embedded holger proxy (`/cache` crates.io mirror + `/sparring`) as a
    /// tracked nornir job — a local registry cargo can build through, writing into
    /// the znippy archive.
    Cache {
        #[command(subcommand)]
        op: CacheOp,
    },
    /// Documentation assembly.
    Docs {
        #[command(subcommand)]
        op: DocsOp,
    },
    /// Code introspection (call graph, dep graph, public API).
    Introspect {
        #[command(subcommand)]
        op: IntrospectOp,
    },
    /// Binary graph extraction (EPIC item 8): reconstruct the dependency +
    /// symbol graph `G = (V, E)` from compiled **ELF** artefacts — the airgap /
    /// vendored-`.so` case where no source is checked out. Pure Rust (the
    /// `object` crate), never shells out. Emitted node/edge rows carry the
    /// `binary_graph` provenance marker so they stay distinguishable from
    /// source-derived facts.
    Graph {
        #[command(subcommand)]
        op: GraphOp,
    },
    /// Binary analysis of a COMPILED artifact (task #65): size (per-section /
    /// per-symbol / per-crate) + hardening (checksec: RELRO/PIE/NX/canary/
    /// stripped/debug). Pure-Rust (`object`+`gimli`), persisted into Mimir
    /// (`binary_facts`/`section_sizes`/`symbol_sizes`) keyed by `content_sha256`.
    /// The artifact-side twin of `nornir knowledge`.
    Bin {
        #[command(subcommand)]
        op: BinOp,
    },
    #[cfg(feature = "vector")]
    /// 👯 TWIN SCAN — sub-item duplicate code, in one of two scopes.
    ///
    /// ⚠ This is the SECOND door. The scan normally happens on its own: own-code
    /// as a lens fold on every generation change, deps as a jera job when the
    /// resolved dependency closure moves. This verb exists so a human can force
    /// a run, and it calls exactly the same `nornir::twinscan` functions — there
    /// is one writer, not one per door.
    ///
    /// `own`  — the constellation's own repos. CPU (gatling), ~0.2 s.
    /// `deps` — all of `~/.cargo/registry/src`. **Requires a real discrete GPU
    ///          and REFUSES without one**, before reading a single file: 5 702 262
    ///          rows on CPU is hours. The refusal names every adapter it turned
    ///          down, so a `llvmpipe`-only box reads as *refused a software
    ///          adapter*, never as *no adapter*.
    Twinscan {
        /// `own` or `deps`. Anything else is refused, never defaulted.
        scope: String,
        /// Repo roots to scan (`own` only; defaults to the current directory).
        #[arg(long = "root")]
        roots: Vec<std::path::PathBuf>,
        /// Emit the findings as JSON instead of the operator table.
        #[arg(long)]
        json: bool,
    },
    /// Warehouse (Parquet + SQLite catalog) for bench history.
    Warehouse {
        #[command(subcommand)]
        op: WarehouseOp,
    },
    /// Full-text index (Tantivy/BM25) over docs/code/bench/changelog/config.
    Index {
        #[command(subcommand)]
        op: IndexOp,
    },
    /// Semantic / vector search: embed code+docs into the warehouse and query
    /// by meaning. `search --mode` does lexical (BM25), semantic (vector), or
    /// hybrid (both, RRF-merged). Indexing needs an embedder feature
    /// (`embed-tract` CPU or `embed-ort` GPU).
    /// UI-robot tester: drive a real browser (WebDriver) through declarative
    /// TOML scenarios — a plan-tests-DAG with `needs = []` edges. Feature `robot`.
    #[cfg(feature = "robot")]
    Robot {
        #[command(subcommand)]
        op: RobotOp,
    },
    #[cfg(feature = "vector")]
    Vector {
        #[command(subcommand)]
        op: VectorOp,
    },
    /// Knowledge map: the git activity scan (gix) and the queries over the code
    /// room (symbols, calls and feature gates are written by its consumer).
    Knowledge {
        #[command(subcommand)]
        op: KnowledgeOp,
    },
    /// Map an appointed workspace member end-to-end: persisted knowledge scan +
    /// build the workspace code index (scoped to members) + snapshot it keyed to
    /// this member's HEAD. One verb for the whole index pipeline.
    Map(RepoArg),
    /// Architecture-wiring generator (EPIC ARCH): coarsen the knowledge facts
    /// (the code room's nodes + CALLS + TOUCHES_TABLE edges) into ONE
    /// component/gRPC/core/table wiring graph, render it skade circuit-board
    /// style, and historize it in the warehouse keyed by git sha.
    Arch {
        #[command(subcommand)]
        op: ArchOp,
    },
    /// Skíðblaðnir airgap: pack a lake into a signed bundle, verify-on-receipt,
    /// unfold a target (Redfish/Terraform/KVM), install + start in dep order —
    /// zero network on the target. (EPIC AIRGAP; crate `skidbladnir`.)
    Airgap {
        #[command(subcommand)]
        op: AirgapOp,
    },
    /// Idea→plan funnel (the planning DAG) — human CLI mirror of the MCP tools.
    /// QUERY verbs (show/next/history/cards/board) stay here; the funnel ACTION
    /// (decompose/plan-drive/prompt/run) moved to `dwarves funnel-action`
    /// (doctrine 2026-07-15) and now warns on use.
    Funnel {
        #[command(subcommand)]
        op: FunnelOp,
    },
    /// List configured repos.
    Repos,
    /// **WHICH REF IS EACH CHECKOUT AT, and is anything unpushed?** — one row per
    /// checkout under `ROOT`, then a verdict line naming the ratio.
    ///
    /// The question asked at the top of most sessions ("är allt commitat och
    /// pushat till main?") had no verb: it was answered by
    /// `workspace_nordisk/.nornir/fleet-refs.sh`, a shell script no Rust code
    /// dispatches to, in a constellation whose own law is no shelling. This is
    /// that answer as code, over `nornir_git::fleet` — pure gix, no `git`
    /// subprocess.
    ///
    /// Four axes, and the fourth is NOT the third: `dirty` (uncommitted work),
    /// `behind` (the default ref moved on), `ahead` (this checkout has commits
    /// the default ref lacks) and **AT-RISK** (the ahead commits that no remote
    /// ref anywhere holds). Work pushed to a side branch is ahead and perfectly
    /// safe; only AT-RISK is the shape that loses work when a disk dies.
    ///
    /// A checkout is FIT when it sits on its remote default ref, is level with
    /// it, is clean, and holds nothing at risk. **Exits non-zero when any row is
    /// not FIT**, so it is a guard and not only a report.
    ///
    /// It does NOT fetch — every verdict is against the remote-tracking refs as
    /// they stand on disk, and each row names the ref it was measured against.
    Fleet {
        /// Directory holding the checkouts. Every entry with a `.git` — FILE or
        /// directory, so linked worktrees count — is surveyed.
        #[arg(default_value = ".")]
        root: String,
        /// Emit the rows as JSON instead of the table.
        #[arg(long)]
        json: bool,
    },
    /// The unified **job ledger** — what long-running nornir op is running / did
    /// it finish / how long did it take (release, bench, test matrix, docs,
    /// coverage, deepscan, container runs, …). Reads the SAME data the viz 🧬
    /// nornir-tab job list and the `Viz.Jobs` RPC return: the server's `jobs.redb`
    /// when `NORNIR_SERVER` is set, else the local warehouse's `jobs.redb`.
    Jobs(JobsArgs),
    /// Run a **container** (`podman`/`docker run …`) AS a tracked nornir job:
    /// spawn → `running`, capture stdout/stderr + exit, then `done` (exit 0) or
    /// `failed`. Appears in `nornir jobs` / the viz like any other job. The
    /// process's exit code is propagated (a non-zero container exit ⇒ non-zero
    /// `nornir`). Example: `nornir container --image docker.io/library/alpine:3 -- echo hi`.
    Container(ContainerArgs),
    /// Print the resolved nornir data root (`<home>/.nornir`) for the running
    /// user — "where does nornir keep its data?". ONE rule for everyone, derived
    /// from `$HOME`; no path env-vars. The server (user `nornir`) prints
    /// `/home/nornir/.nornir`.
    Root,
    /// Launch the gRPC server (thin wrapper over the `nornir-server` binary).
    Serve(ServeArgs),
    /// Launch the time-travel visualizer (thin wrapper over `nornir-ui`).
    /// Reads `NORNIR_SERVER`/`NORNIR_SERVER_TOKEN`/`NORNIR_WORKSPACE` like the CLI;
    /// with `NORNIR_SERVER` set it views a running server live, else opens a local
    /// warehouse.
    Viz(VizArgs),
    /// Manage nornir-server as a systemd service on THIS host: add | update |
    /// remove. (For other lake components from airgap bundles, see
    /// `nornir airgap component …`.)
    Install {
        #[command(subcommand)]
        op: InstallOp,
    },
    /// SSH identity used for git poll/push in server-monitored mode. The keypair
    /// lives in the `nornir` user's `.ssh` dir (created by `install add`).
    Key {
        #[command(subcommand)]
        op: KeyOp,
    },
    /// The server's workspace registry — the index of every workspace it tracks
    /// (server-monitored / pushed / external). Stored in `<root>/registry.redb`.
    Workspace {
        #[command(subcommand)]
        op: WorkspaceOp,
    },
    /// SBOM + vulnerability + license scan (CycloneDX 1.5; `cargo metadata` + OSV).
    Security {
        #[command(subcommand)]
        cmd: SecurityCmd,
    },
    /// Cross-repo dependency-graph queries (the `Mimir` surface). Mirrors the
    /// server's `Mimir` RPCs + the MCP tools: deps-of / dependents-of / affected
    /// / build-order / dep-path / external-users / svg / overview / changed.
    /// Routes to the server over gRPC when `NORNIR_SERVER` is set (warehouse-
    /// cached), else builds the graph locally (drives `cargo metadata`).
    Mimir {
        #[command(subcommand)]
        op: MimirOp,
    },
    /// Render the viz `Timeline` model as headless diagrams (static SVG /
    /// Markdown tables) — the data-only `src/viz/diagram.rs` renderers the egui app draws,
    /// reachable from the CLI. Reads a local warehouse, or the server's
    /// `Viz.Timeline` when `NORNIR_SERVER` is set.
    Diagram {
        #[command(subcommand)]
        op: DiagramOp,
    },
    /// Agent × model **bake-off MATRIX** (H5): run one task across a
    /// cross-product of **agents** (claude / kilo / a local-LLM driver) ×
    /// **models**, recording one `agent_model_runs` row per `(task, agent,
    /// model)` cell (score, wall_ms, tokens/s, cost, mcp_tool_calls) to the
    /// warehouse, then read the leaderboard / matrix back. The viz 🏆 Leaderboard
    /// pane shows the same agent×model grid with the winner highlighted.
    Bakeoff {
        #[command(subcommand)]
        op: BakeoffOp,
    },
    /// Repo-level git plumbing: pure-Rust (gix) history tools. `scan-blobs`
    /// reports the largest blobs across ALL history (what a clean would reclaim);
    /// `clean-history` deep-cleans a path out of EVERY commit across EVERY ref —
    /// the pure-Rust analogue of `git filter-repo --invert-paths --path <p>`.
    Repo {
        #[command(subcommand)]
        op: RepoOp,
    },
    /// Declarative **custom builds** (task #19): drive `cargo build` from a
    /// repo's declared build variants. A repo like holger ships several
    /// distinct binaries (`holger-server`, `holger-agent`, `holger-mannequin`)
    /// plus a fat GUI (`holger-ui`) — each a `package × bin × feature-set ×
    /// profile × target` point declared as data (a `[[build.variant]]` table in
    /// `nornir.toml` or a standalone `.nornir/build-variants.toml`). This verb
    /// materializes the exact cargo invocation, refusing combinations the repo
    /// never offered. `--list-variants` shows the space; `--dry-run` prints the
    /// argv without building.
    Build(BuildArgs),
    /// **UI-movement saga** viewer + replay (crate `nornir-saga`). View live UI
    /// movements from a saga (an Elm `Msg` stream) and REPLAY them
    /// deterministically — in the terminal (state_json frames), in a browser
    /// (`--web`, self-contained axum+SSE page that actually moves), or driven at
    /// a real page (`--drive`, via the `WebReplayDriver` seam). Demoable with a
    /// built-in synthetic saga (`--demo`) — no live Njord required. See
    /// `.nornir/nornir-saga.md`.
    Saga {
        #[command(subcommand)]
        op: SagaOp,
    },
    /// **WORLD-CODEROOM** (design `.nornir/world-coderoom-design.md`) — generalize
    /// Mímir to the crates.io world at symbol granularity. `ingest` politely fetches
    /// (disk-cached, rate-limited, UA'd) `.crate` tarballs and syn-scans them into the
    /// warehouse partitioned by `(crate, version)`; `compat` persists the `compatible?`
    /// edge (the real producer of the `semver_diffs` slot); `what-breaks` answers
    /// "who across the world calls a symbol version Z removed" (agent-callable JSON).
    /// Mímir v2: `ingest`, `compat` and `what-breaks` wait for the world ring and
    /// answer `not_wired` (the tarball scan is gone); `snapshot-diff` answers from
    /// the workspace's code room.
    Coderoom {
        #[command(subcommand)]
        op: CoderoomOp,
    },
    /// Interactive OIDC single-sign-on (RFC 8628 device-authorization grant) — the
    /// CLIENT side of holger-style auth. Prints a verification URL + user code, waits
    /// for approval, and caches the token in `~/.nornir/auth-token.json`; nornir's
    /// client calls then PREFER it over the static `~/.nornir/token`. Needs
    /// `--features login` (the lean build prints a rebuild hint).
    Login,
    /// Print the current signed-in identity (silently refreshing an expired token when a
    /// refresh token is held). Exit 1 when signed out. Needs `--features login`.
    Whoami,
    /// Remove the cached OIDC login token (`~/.nornir/auth-token.json`). Idempotent —
    /// leaves the static `~/.nornir/token` untouched. Needs `--features login`.
    Logout,
}

#[derive(Subcommand)]
enum CoderoomOp {
    /// Fetch + ingest crates.io crates into Mímir. Each SPEC is either `name`
    /// (→ its latest `--versions-per` versions from the sparse index) or
    /// `name@version` (exact). Between the two newest ingested versions of a crate,
    /// the `compatible?` edge is persisted automatically. Polite by default
    /// (disk cache + rate-limit), so re-runs never re-fetch.
    Ingest {
        /// Crate specs: `name` or `name@version` (repeatable; commas also split).
        /// Optional when `--top N` supplies the ranked list instead.
        specs: Vec<String>,
        /// Ingest the TOP N crates by reverse-dependency count (crates.io db-dump)
        /// instead of / in addition to `specs` — the scale-phase ranked input.
        #[arg(long = "top")]
        top: Option<usize>,
        /// Permit the LARGE one-time crates.io db-dump download that `--top` needs
        /// (cached after). Without it, `--top` errors if the dump isn't already cached.
        #[arg(long = "download", default_value_t = false)]
        download: bool,
        /// Latest versions to ingest for a bare `name` / `--top` crate (default 2).
        #[arg(long = "versions-per", default_value_t = 2)]
        versions_per: usize,
        /// Override the on-disk cache dir (default `$NORNIR_CODEROOM_CACHE` /
        /// `~/.cache/nornir/coderoom`).
        #[arg(long = "cache-dir")]
        cache_dir: Option<PathBuf>,
        /// Override the DEDICATED coderoom warehouse (default
        /// `$NORNIR_CODEROOM_WAREHOUSE` / `<cache>/warehouse` — NEVER the workspace Mímir).
        #[arg(long = "warehouse")]
        warehouse: Option<PathBuf>,
        /// Milliseconds to pause after each NETWORK fetch (politeness; default 1000).
        #[arg(long = "rate-ms", default_value_t = 1000)]
        rate_ms: u64,
        /// Force re-scan of versions ALREADY ingested (default: skip them — a published
        /// version is immutable, so re-ingest is a no-op, making a `--top N` re-run
        /// incremental). Pass to re-append (e.g. after a scanner change).
        #[arg(long = "reingest", default_value_t = false)]
        reingest: bool,
    },
    /// Print the TOP N crate names by reverse-dependency count (crates.io db-dump,
    /// cached). The ranked ingest input for `ingest --top N`.
    TopN {
        n: usize,
        /// Permit the large one-time db-dump download (cached after).
        #[arg(long = "download", default_value_t = false)]
        download: bool,
        #[arg(long = "cache-dir")]
        cache_dir: Option<PathBuf>,
    },
    /// Persist the `compatible?` edge for KRATE between FROM→TO (both must already be
    /// ingested): the T1 public-API drop, written to `semver_diffs`.
    Compat {
        krate: String,
        from: String,
        to: String,
        #[arg(long = "warehouse")]
        warehouse: Option<PathBuf>,
    },
    /// "What breaks if KRATE bumps to VERSION" — the world callers referencing a symbol
    /// VERSION removed. `--from PREV` restricts to symbols actually dropped in PREV→VERSION.
    /// `--transitive` walks the full impact closure (callers-of-callers …), each impacted
    /// crate tagged with its hop distance (direct callers = 1).
    WhatBreaks {
        krate: String,
        version: String,
        #[arg(long)]
        from: Option<String>,
        /// Walk the TRANSITIVE impact closure outward over the corpus call-graph
        /// (default: direct callers only).
        #[arg(long)]
        transitive: bool,
        /// Max call-graph hops for `--transitive` (0 = full closure; 1 = direct-only).
        #[arg(long, default_value_t = 0)]
        radius: usize,
        /// ALSO anti-join the `call_edges` of this warehouse — in practice the real
        /// Mímir, whose `repo = "deps"` partition is the deepscan corpus and whose
        /// other partitions are our own repos. Without it a breaking change that
        /// breaks OUR code reports zero callers, because the coderoom warehouse is
        /// deliberately a separate root. Read-only; repeatable.
        #[arg(long = "mimir")]
        mimir: Vec<PathBuf>,
        /// A `crate@version` the caller asked about but could NOT ingest (a failed
        /// fetch). Named in the answer's evidence note as a MISSING FEED, so an
        /// incomplete corpus can never read as a clean zero. Repeatable.
        #[arg(long = "unavailable")]
        unavailable: Vec<String>,
        #[arg(long = "warehouse")]
        warehouse: Option<PathBuf>,
    },
    /// CORPUS snapshot-diff — what changed across the WHOLE ingested world of the
    /// coderoom warehouse between two of its snapshots: crates added / removed, and
    /// per surviving crate, symbols added / removed / changed (JSON, agent-callable).
    /// SNAP_A/SNAP_B are `snapshot_id`s; both default to the two most-recent snapshots.
    SnapshotDiff {
        /// The "before" snapshot id (default: second-most-recent snapshot).
        snap_a: Option<String>,
        /// The "after" snapshot id (default: most-recent snapshot).
        snap_b: Option<String>,
        #[arg(long = "warehouse")]
        warehouse: Option<PathBuf>,
    },
}

#[derive(Subcommand)]
enum SagaOp {
    /// Fold a saga's movement stream through the ELM viewer and print the
    /// resulting `state_json` (the whole observable model). `--frames` prints one
    /// `state_json` per movement (deterministic sequence). Use `--demo` for the
    /// built-in synthetic saga instead of a file.
    View(SagaViewArgs),
    /// Replay a saga. Default: print each frame's `state_json` as JSONL
    /// (deterministic). `--web` serves a self-contained browser page that moves
    /// (needs `--features saga-web`). `--drive <url>` dispatches the recorded
    /// movements at a real page through the `WebReplayDriver` seam (mock by
    /// default — prints the gestures; real WebDriver with `--features saga-drive`
    /// + `--webdriver <endpoint>`).
    Replay(SagaReplayArgs),
    /// **Nav-path / funnel** for a recorded session: read its `saga_events` back
    /// from the warehouse and print the ordered route funnel (route →
    /// sessions-reached, per-step drop-off, next-route transitions). `--json` emits
    /// the funnel as JSON. An unknown session prints a clear "no events" message.
    Path(SagaPathArgs),
    /// **EGUI replay** (the egui sibling of the web `replay`) — read a recorded
    /// session's `saga_events` back into a `ReplayPlan`, convert it to the SHARED
    /// `nornir_robotui::Scenario`, and replay it THROUGH the live nornir viz:
    /// HEADLESS (egui_kittest matrix drive, default) or `--visible` (a real,
    /// paced eframe window you WATCH). `--demo` uses a built-in synthetic plan
    /// (no warehouse needed). Needs nornir built with `--features saga-egui`. The
    /// answer to "saga replays on web — now it replays on egui too".
    EguiReplay(SagaEguiArgs),
}

#[derive(Args)]
struct SagaEguiArgs {
    /// The recorded session id to replay (its `saga_events` are read back into a
    /// `ReplayPlan`). Omit with `--demo`.
    session: Option<String>,
    /// Use the built-in synthetic replay plan (a short tab tour) instead of a
    /// recorded session — demoable with no `saga_events` in the warehouse.
    #[arg(long)]
    demo: bool,
    /// Open a REAL, visible eframe window and replay the steps PACED so a human
    /// watches the viz move (`RunMode::Watch`). Without it the replay runs
    /// headless (egui_kittest → test-matrix rows). CLI parity for the
    /// `NORNIR_ROBOT_MODE=visible` env toggle; the flag wins when both are set.
    #[arg(long)]
    visible: bool,
    /// Force headless even if `NORNIR_ROBOT_MODE`/`ROBOTUI_WATCH` ask for visible
    /// (mutually exclusive with `--visible`).
    #[arg(long, conflicts_with = "visible")]
    headless: bool,
    /// Warehouse root to read the session's `saga_events` from (defaults to the
    /// configured `[storage].local_path`, else `<home>/.nornir/warehouse`).
    #[arg(long)]
    warehouse: Option<PathBuf>,
}

#[derive(Args)]
struct SagaPathArgs {
    /// The recorded session id to aggregate (as stored in `saga_events`).
    session: String,
    /// Emit the funnel as JSON instead of a human-readable table.
    #[arg(long)]
    json: bool,
    /// Warehouse root to read from (defaults to the configured
    /// `[storage].local_path`, else `<home>/.nornir/warehouse`).
    #[arg(long)]
    warehouse: Option<PathBuf>,
}

#[derive(Args)]
struct SagaViewArgs {
    /// Path to a saga JSON file (omit with `--demo`).
    saga: Option<String>,
    /// Use the built-in synthetic demo saga instead of a file.
    #[arg(long)]
    demo: bool,
    /// Print one `state_json` per movement (the full deterministic sequence)
    /// instead of just the final model.
    #[arg(long)]
    frames: bool,
}

#[derive(Args)]
struct SagaReplayArgs {
    /// Path to a saga JSON file (omit with `--demo`).
    saga: Option<String>,
    /// Use the built-in synthetic demo saga instead of a file.
    #[arg(long)]
    demo: bool,
    /// Serve the replay as a self-contained browser page (axum + SSE) that
    /// actually moves. Requires nornir built with `--features saga-web`.
    #[arg(long)]
    web: bool,
    /// Address to bind the `--web` server to.
    #[arg(long, default_value = "127.0.0.1:8971")]
    addr: String,
    /// Dispatch the recorded movements at a REAL page at this base URL through
    /// the `WebReplayDriver`. Without `--features saga-drive` this uses the mock
    /// driver and prints the gestures that WOULD be dispatched.
    #[arg(long)]
    drive: Option<String>,
    /// WebDriver endpoint (e.g. `http://localhost:4444`) for `--drive` with a
    /// real browser (needs `--features saga-drive`).
    #[arg(long)]
    webdriver: Option<String>,
    /// Playback speed multiplier (1.0 = real time). Scales inter-frame timing.
    #[arg(long, default_value_t = 1.0)]
    speed: f64,
}

#[derive(Subcommand)]
enum RepoOp {
    /// Read-only: walk all refs + ancestry, enumerate blobs and sizes, and report
    /// the N largest (size, example path, #commits) + the total — "what would a
    /// clean reclaim". `--path <glob>` narrows to matching paths.
    ScanBlobs(RepoScanArgs),
    /// Deep-clean: remove `--path <p>` from EVERY commit across EVERY ref. DRY-RUN
    /// by default (reports commits/blobs/bytes); pass `--apply` to rewrite history
    /// (writes a `refs/original/*` + file backup first, repoints refs, and leaves
    /// the worktree consistent with the new HEAD). Idempotent. On-disk shrink of
    /// `.git` needs a follow-up `git gc --prune=now` (history is rewritten
    /// regardless).
    CleanHistory(RepoCleanArgs),
}

#[derive(Args)]
struct RepoScanArgs {
    /// Repo to scan: a configured member name OR a path to a git working dir.
    repo: String,
    /// Only report paths matching this glob (`*`/`?`, `*` spans `/`), e.g. `*.pdf`.
    #[arg(long)]
    path: Option<String>,
    /// How many of the largest blobs to list.
    #[arg(long, default_value_t = 20)]
    top: usize,
}

#[derive(Args)]
struct RepoCleanArgs {
    /// Repo to clean: a configured member name OR a path to a git working dir.
    repo: String,
    /// Exact repo path(s) to strip from ALL history, e.g. `docs/book.pdf`.
    /// Repeatable. Nested paths supported; empty subtrees are dropped.
    #[arg(long, required = true)]
    path: Vec<String>,
    /// Perform the rewrite. Without this it is a DRY-RUN (report only).
    #[arg(long)]
    apply: bool,
}

#[derive(Subcommand)]
enum BakeoffOp {
    /// Run `--task` across the `--agents × --models` cross-product and persist one
    /// row per `(task, agent, model)` cell. With no `--agents`, a single
    /// local-LLM agent is used (a model-only bake-off, as before); with no
    /// `--models`, the embedding-model registry's ids. By default the models are
    /// called via ollama (`$OLLAMA_HOST` or http://localhost:11434); pass `--mock`
    /// to record canned offline rows (no LLM needed).
    Run(BakeoffRunArgs),
    /// Show a bake-off's leaderboard — `(agent, model)` cells ranked by score then
    /// tokens/s. REF is matched first as a `run_id`; if no rows carry it, as a
    /// model name (per-model history). With no REF, the most recent run.
    /// `--json` prints the raw rows; `--matrix` prints the agent × model grid.
    Leaderboard(BakeoffLeaderboardArgs),
    /// **Demo:** write a realistic mock agent × model matrix to the warehouse
    /// (3 agents × 3 models over one task, with one failed cell) so a fresh viz
    /// shows a populated 🏆 Leaderboard. Mirrors `funnel demo`; no LLM needed.
    /// Idempotent-ish: each invocation writes a fresh `run_id`.
    Demo,
}

#[derive(Args)]
struct BakeoffRunArgs {
    /// The task/prompt to pit the agent×model cells against.
    #[arg(long, visible_alias = "prompt")]
    task: String,
    /// Stable handle for the task (so two runs of the same task compare).
    #[arg(long, default_value = "default", visible_alias = "prompt-id")]
    task_id: String,
    /// Comma-separated agent ids (claude,kilo,local-llm,…). Empty ⇒ a single
    /// local-LLM agent (a model-only bake-off).
    #[arg(long, value_delimiter = ',')]
    agents: Vec<String>,
    /// Comma-separated model ids/tags. Empty ⇒ the embedding-model registry's ids.
    #[arg(long, value_delimiter = ',')]
    models: Vec<String>,
    /// ollama host base URL (else `$OLLAMA_HOST`, else http://localhost:11434).
    #[arg(long)]
    host: Option<String>,
    /// Record canned offline rows instead of calling ollama (no live LLM needed).
    #[arg(long)]
    mock: bool,
    /// Generative-LLM backend spec for the model call (EPIC #39): one of
    /// `candle:<m>` | `mistralrs:<m>` | `onnx:<m>` | `ollama:<m>` | `mock`. Each
    /// real backend is feature-gated (`gen-candle`/`gen-mistralrs`/`gen-onnx`,
    /// `gen-ollama`); an absent backend gives a clear "rebuild with --features"
    /// error. Falls back to `$NORNIR_GEN_BACKEND`, then to `--mock`/ollama. The
    /// `generator(spec)` factory drives the same model across every cell.
    #[arg(long, value_name = "SPEC")]
    r#gen: Option<String>,
    /// Emit the persisted rows as JSON after the run.
    #[arg(long)]
    json: bool,
    /// Print the agent × model grid (not just the flat leaderboard) after the run.
    #[arg(long)]
    matrix: bool,
    /// **Codegen judge:** path to the target crate each cell's answer is scored
    /// against (compile-and-test). Set it to score answers by build+test outcome
    /// instead of leaving the quality score at 0.0. The answer is dropped into a
    /// scratch copy of this crate, `cargo build` + the accept command run, and
    /// the row's `score` becomes the verdict (0.0 = won't compile, fraction of
    /// acceptance tests passing, 1.0 = build+all-tests green).
    #[arg(long, value_name = "PATH")]
    judge_crate: Option<std::path::PathBuf>,
    /// Where the judged answer goes inside `--judge-crate` (relative path), e.g.
    /// `src/lib.rs` — the answer is a full file body that overwrites it. Mutually
    /// exclusive with `--judge-patch`. Required with `--judge-crate` unless
    /// `--judge-patch` is set.
    #[arg(long, value_name = "REL_PATH")]
    judge_file: Option<String>,
    /// Treat each answer as a unified diff and `git apply` it to `--judge-crate`
    /// instead of overwriting a file. Mutually exclusive with `--judge-file`.
    #[arg(long)]
    judge_patch: bool,
    /// The acceptance command the judge gates on (default `cargo test`). Quote it,
    /// e.g. `--judge-cmd "cargo test --test integration"`.
    #[arg(long, value_name = "CMD")]
    judge_cmd: Option<String>,
}

#[derive(Args)]
struct BakeoffLeaderboardArgs {
    /// run_id (or model) to read. Empty ⇒ the most recent run.
    #[arg(default_value = "")]
    reference: String,
    /// Emit the raw `agent_model_runs` rows as JSON instead of the leaderboard.
    #[arg(long)]
    json: bool,
    /// Print the agent × model grid instead of the flat ranked leaderboard.
    #[arg(long)]
    matrix: bool,
}

#[derive(Subcommand)]
enum MimirOp {
    /// Repos that REPO depends on (`--transitive` for the full forward closure).
    DepsOf {
        repo: String,
        #[arg(long)]
        transitive: bool,
    },
    /// Repos that depend ON REPO — the blast radius (`--transitive` for closure).
    DependentsOf {
        repo: String,
        #[arg(long)]
        transitive: bool,
    },
    /// Changed repos + everything that transitively depends on them, in build order.
    Affected {
        /// One or more changed repos.
        repos: Vec<String>,
    },
    /// Full workspace build order (dependencies before dependents).
    BuildOrder,
    /// Shortest dependency path FROM→TO, annotated with the justifying crates.
    DepPath { from: String, to: String },
    /// Workspace repos that consume the external crate KRATE.
    ExternalUsers { krate: String },
    /// Render the cross-repo dependency graph as a static SVG flowchart.
    Svg,
    /// One-shot orientation for REPO: internal deps + dependents, build-order
    /// index, and a knowledge digest (symbol/call counts) from the warehouse.
    Overview { repo: String },
    /// Repos changed since their last recorded release (server-only; needs the
    /// warehouse's release lineage). Requires `NORNIR_SERVER`.
    Changed,
    /// Warehouse-native code analyses over the persisted knowledge map.
    Analyze {
        #[command(subcommand)]
        op: AnalyzeOp,
    },
}

#[derive(Subcommand)]
enum AnalyzeOp {
    /// Copied-not-moved detection: symbols whose embedding cosine AND structural
    /// signals (signature + callee neighbourhood) BOTH clear the thresholds are
    /// clustered across crates/repos. Reads one vector per function node (written
    /// by the `embeddings` feed / `workspace embed`) and the code room's nodes and
    /// CALLS edges; says `vectors_missing` / `no_embedder` when there is none.
    Duplicates {
        /// Max clusters in the ranked tail (default 10).
        #[arg(long)]
        limit: Option<usize>,
        /// Report only clusters touching this repo (detection stays workspace-wide).
        #[arg(long)]
        repo: Option<String>,
        /// Minimum vector cosine of a pair (default: the lens's own).
        #[arg(long)]
        cosine: Option<f64>,
        /// Minimum structural agreement of a pair, signature + callee set (default: the lens's own).
        #[arg(long)]
        structural: Option<f64>,
        /// Also report pairs inside ONE crate (default: only pairs in different crates).
        #[arg(long)]
        same_crate: bool,
        /// List the trait-impl / constructor / accessor families instead of withholding them.
        #[arg(long)]
        keep_trait_impls: bool,
        /// Add the threshold instrument to the answer: the distribution of every
        /// pairwise cosine, what each threshold keeps, and the pairs around the bar
        /// (`--limit` of each) for a person to judge.
        #[arg(long)]
        calibration: bool,
        /// With `--calibration`: the bar the "just above" / "just below" lists hug
        /// (default: the cosine in force).
        #[arg(long)]
        around: Option<f64>,
        /// With `--calibration`: `a.rs,b.rs` — every function of the first file
        /// with its nearest function in the second.
        #[arg(long)]
        files: Option<String>,
    },
    /// Semantic clones: the model over the NORMALISED body (names → roles),
    /// cosine ≥ 0.90 AND agreement of the normalised-body signal (exact /
    /// near) or the structural one. Names never decide a pair.
    SemanticClones {
        /// Report only clusters touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max clusters in the ranked tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Exact clones: the same NORMALISED body (names → roles, literals → kinds, layout and comments gone).
    ExactClones {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Near clones: MinHash/LSH over the normalised bodies' role shingles (Jaccard estimate ≥ 0.80).
    NearClones {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Structural clones: IDF-weighted Jaccard of param/ret/used types, fields read, callees — names play no part.
    StructuralClones {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Nearest by role: FastRP role vectors over the code room (types, fields, callees, callers).
    RoleNeighbours {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Semantic twins: independent re-implementations (role × normalised meaning × body
    /// different) and copies (normalised body + structure); the false-hit families left out.
    SemanticTwins {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Re-implemented libraries: a crate that depends on crate B and holds a copy or
    /// twin of a B function — call the library's instead (the twins × DEPENDS_ON).
    ReimplementsShared {
        /// Report only functions of this repo to replace (default: all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Same intent — NAME-DRIVEN: the raw-text model, names included on purpose.
    SameIntent {
        /// Report only findings touching this repo (default: report all).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Undocumented public API in churny files (idea #16): `pub fn` with no doc
    /// comment (`doc_lines=0`) in a file many commits/authors keep changing — an
    /// undocumented public contract under active change. The code room's nodes ×
    /// `git_heat_facts` on (repo, file), ranked by recent churn.
    UndocApi {
        /// Restrict to one repo (default: all repos).
        #[arg(long)]
        repo: Option<String>,
        /// Churn floor — only files with `commits_90d >= N` (default 2).
        #[arg(long)]
        min_commits_90d: Option<i64>,
        /// Max findings in the ranked tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// CPG graph-soundness self-audit (idea #34): leaf fn names defined in more
    /// than one place, which the string-name call-graph matcher conflates into one
    /// node. Reports the fraction of CALLS edges pointing at an ambiguous callee;
    /// cross-repo colliders rank worst.
    NameCollision {
        /// Restrict the definitions considered to one repo (default: all repos).
        #[arg(long)]
        repo: Option<String>,
        /// Max collisions in the ranked tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Provenance-trust semiring (Gemini 2A): walk the code room's CALLS FORWARD from the
    /// entry surface under the bottleneck `(⊕=max over paths, ⊗=min weakest-hop)`
    /// semiring, dock local trust by shell-out impurity × sensitive-data touch ×
    /// test coverage, and map each function's inherited trust to act/hold/refuse —
    /// naming the weakest hop that caps the verdict.
    Trust {
        /// Restrict to one repo (default: all repos in the warehouse).
        #[arg(long)]
        repo: Option<String>,
        /// ACT floor — inherited trust ≥ this ⇒ act (default 0.66).
        #[arg(long)]
        act_at: Option<f64>,
        /// HOLD floor — inherited trust ≥ this ⇒ hold, else refuse (default 0.33).
        #[arg(long)]
        hold_at: Option<f64>,
        /// Max verdicts in the ranked (refuse/hold) tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
        /// Emit the skade-style red-marked CPG SVG (refuse/hold set) instead of JSON.
        #[arg(long)]
        svg: bool,
    },
    /// b1 reachable-vuln (baddie-detection flagship): `vuln_findings` ×
    /// `sbom_components` × the code room's CALLS. A CVE present in a repo's resolved
    /// components is escalated to refuse 🔴 ONLY if the vulnerable crate is actually
    /// CALLED (a call edge targets it); present-but-unreachable is hold 🟠. The
    /// reachability gate is the 80–90% false-positive killer. Empty until modgunn's
    /// `warehouse scan --persist` feeds `vuln_findings`.
    ReachableVuln {
        /// Restrict to one repo (default: all repos in the warehouse).
        #[arg(long)]
        repo: Option<String>,
        /// Max findings in the ranked (refuse-before-hold) tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
        /// Emit the skade-style red-marked CPG SVG (refuse/hold set) instead of JSON.
        #[arg(long)]
        svg: bool,
    },
    /// SUBSTRATE invariant gate: is the code room's node id (the SCIP moniker) actually an identity
    /// (one definition per symbol per snapshot)? Every duplicate claim the
    /// similarity tier makes rests on it. Separates OUR defect — a bare
    /// `local <id>`, SCIP's document-scoped form written by a binary predating
    /// `scope_local_symbol`, cured by re-ingesting the named repos — from UPSTREAM
    /// ambiguity (rust-analyzer defining one global moniker twice), which no
    /// re-ingest fixes. An unfed table reads `unknown`, never `sound`.
    SymbolIdentity {
        /// Restrict the audit to one repo (default: all repos in the warehouse).
        #[arg(long)]
        repo: Option<String>,
        /// Max ambiguous symbols in the ranked (ours-before-upstream) tail (default 50).
        #[arg(long)]
        limit: Option<usize>,
    },
}

#[derive(Subcommand)]
enum DiagramOp {
    /// Static SVG timeline of every release node per lane.
    Timeline(DiagramArgs),
    /// Markdown table: one row per lane (repo) with its release count + latest sha.
    LaneSummary(DiagramArgs),
    /// Static SVG flowchart of the latest snapshot's cross-repo dep edges.
    Depgraph(DiagramArgs),
    /// Plain edge list (`from -> to`) of the latest snapshot.
    SnapshotEdges(DiagramArgs),
    /// Markdown gate-status matrix (release × repo).
    GateMatrix(DiagramArgs),
    /// Markdown table of published crate versions per release.
    ReleaseVersions(DiagramArgs),
    /// Markdown bench-history table (`--limit N` caps rows; newest first).
    BenchHistory {
        #[command(flatten)]
        args: DiagramArgs,
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Markdown bench first-vs-last comparison table per metric.
    BenchCompare(DiagramArgs),
}

#[derive(Args)]
struct DiagramArgs {
    // Workspace comes from the global `--workspace` flag / `$NORNIR_WORKSPACE`
    // (see `env_workspace_or`); no per-op flag needed.
}

#[derive(Subcommand)]
enum SecurityCmd {
    /// Deep dependency scan: vulnerabilities (OSV.dev) + licenses; optionally
    /// write a CycloneDX 1.5 SBOM. Exit code = vulnerable-crate count (CI gate).
    Scan {
        /// Repo directory (default: current dir).
        #[arg(default_value = ".")]
        repo: std::path::PathBuf,
        /// Write the CycloneDX SBOM to this path.
        #[arg(long)]
        out: Option<std::path::PathBuf>,
        /// Emit the uniform `CommandOutcome` as JSON (the CLI twin of the Security
        /// tab's `state_json`) instead of the human table.
        #[arg(long)]
        json: bool,
    },
    /// Clone/update the local rustsec advisory-db mirror for **offline (airgap)**
    /// vuln matching. `NORNIR_ADVISORY_DB` = path (default /opt/nornir/advisory-db),
    /// `NORNIR_ADVISORY_DB_URL` = source (e.g. holger; default github rustsec).
    UpdateDb,
}

#[derive(Subcommand)]
enum WorkspaceOp {
    /// List registered workspaces (name · mode · members · last-synced) — the CLI
    /// twin of the Nornir-tab roster. `--json` emits a uniform `CommandOutcome`.
    #[command(visible_alias = "list")]
    Ls {
        #[arg(long)]
        json: bool,
        /// PO1: roll up each workspace's `clone_events` into a POPULATE verdict
        /// column (green = all members' latest ok · red = ≥1 latest error · stale
        /// = never populated), naming the first failing member + its error. The
        /// CLI twin of the Nornir-tab roster's red/green badge.
        #[arg(long)]
        status: bool,
    },
    /// Show one workspace's full record.
    Show { name: String },
    /// Register a workspace — **EAGER by default**: record it AND immediately
    /// fetch (clone members) + build the warehouse inline, so members + the
    /// snapshot are populated NOW (the same pipeline the poll loop runs on a
    /// change). The poll loop then handles UPDATES. Pass `--lazy` for the old
    /// record-only behaviour (defer population to the poll loop — scripted/daemon
    /// flows). (`add` is the explicit eager alias, kept for habit.)
    Register {
        name: String,
        /// Local path or git URL to the workspace's `nornir-workspace.toml`.
        /// OPTIONAL — when omitted, nornir discovers `nornir-workspace.toml` by
        /// walking up from the current directory (stand in the workspace dir and
        /// just run `nornir workspace register <name>`). In thin/server mode a
        /// discovered LOCAL descriptor's content is shipped to the server.
        #[arg(long)]
        descriptor: Option<String>,
        /// Server polls members and republishes (server-user `nornir`).
        #[arg(long)]
        monitored: bool,
        /// Sources live outside; only `builds/` is server-owned (`git/` empty).
        #[arg(long)]
        external: bool,
        /// Poll interval for monitored workspaces, e.g. `60s`.
        #[arg(long)]
        poll: Option<String>,
        /// Record-only: skip the eager fetch+build, leaving population to the
        /// poll loop. The opt-out from the default eager behaviour.
        #[arg(long)]
        lazy: bool,
    },
    /// **Eager add** — register + fetch (clone members) + build the warehouse, all
    /// inline, so the workspace is **populated now** (no waiting for a poll tick).
    /// The SAME thing the server's poll loop does on a change — so a fat user and
    /// the server do the same thing; `add` is the manual button, the poll loop is
    /// the automated one. Identical flags to `register`.
    Add {
        name: String,
        /// Local path or git URL to the workspace's `nornir-workspace.toml`.
        /// OPTIONAL — when omitted, nornir discovers `nornir-workspace.toml` by
        /// walking up from the current directory (stand in the workspace dir and
        /// just run `nornir workspace add <name>`). In thin/server mode a
        /// discovered LOCAL descriptor's content is shipped to the server.
        #[arg(long)]
        descriptor: Option<String>,
        /// Server polls members + republishes (server-user `nornir`).
        #[arg(long)]
        monitored: bool,
        /// Sources live outside; only `builds/` is server-owned (`git/` empty).
        #[arg(long)]
        external: bool,
        /// Poll interval for monitored workspaces, e.g. `60s`.
        #[arg(long)]
        poll: Option<String>,
    },
    /// Remove a workspace from the registry.
    Rm {
        name: String,
        /// Also delete the workspace's on-disk build data — its `builds/` dir
        /// (warehouse + catalog.redb + jobs.redb) — not just the registry row.
        /// Default (without this flag) is registry-only (back-compat). Alias `--data`.
        #[arg(long, visible_alias = "data")]
        purge: bool,
    },
    /// Clone/fetch every git member into `<root>/<name>/git/` and update each
    /// member's sync state, then consume what is behind into the code room.
    Fetch {
        name: String,
        /// The explicit refresh: also run again the feeds (git heat, sbom, code
        /// index, embeddings) that did not run green for a member's commit. Maps
        /// to the `force` field of the Fetch RPC.
        #[arg(long)]
        force: bool,
    },
    /// **Build the whole workspace's code room NOW and wait for it** (builder 1,
    /// Runa `BuildWorkspace`): every member behind is read, the members whose
    /// next commit is their head in ONE super build (all repos compiled at once
    /// over one target dir, each shared unit once). Inline it runs here; over
    /// `--server` it asks the server to read now and waits until nothing is
    /// queued or in flight. Fails when a member could not be read.
    Build {
        name: String,
        /// Only this member (inline only).
        #[arg(long)]
        member: Option<String>,
        /// Only what a change reaches (Runa `only: Affected`): the members
        /// behind are read, the members that depend on them (Mímir's
        /// affected_by_change) compiled to see that they still build, the
        /// rest not touched. Fails when a dependent does not build (inline).
        #[arg(long)]
        affected: bool,
    },
    /// **Populate now**: clone the members, then consume them into the workspace's
    /// code room (Mímir v2) — every commit behind a member's offset becomes one
    /// atomic release. A member that was never consumed starts at its TIP.
    ///
    /// Over `--server` this returns right after the clone and the server's
    /// consumer loop follows in the background; without a server it runs inline
    /// (this process then holds the lake: stop the server first).
    Populate {
        name: String,
        /// Backfill: where a member's history starts — a commit (≥ 7 hex digits)
        /// or a date (`YYYY-MM-DD`: the first commit at or after it). For a member
        /// that was already consumed the commits from there are REPLAYED (its
        /// ordinals keep counting; nothing is deleted). Over `--server` this
        /// QUEUES the walk on the server and returns: one commit per turn, in the
        /// background (`workspace repopulate` says how far it is). Without a
        /// server it runs inline.
        #[arg(long)]
        from: Option<String>,
        /// Only this member.
        #[arg(long)]
        member: Option<String>,
        /// With `--from` over a server: first FORGET the members' dumps that
        /// carry no MIR facts, so the walk dumps them again with them —
        /// `--from head --mir` re-dumps each member's current commit only.
        #[arg(long)]
        mir: bool,
    },
    /// **Make the code room current, now** — report which state is served, which
    /// members are behind their offset (and start consuming them), what is in
    /// flight, and why nothing runs when nothing can (the driver is not installed).
    ///
    /// Goes through the server's OPEN handles (`Workspaces.Repopulate`); without a
    /// server it runs the consume inline, like `populate`.
    Repopulate {
        name: String,
        /// Also drop the orphaned v1 tables (the syn / rust-analyzer era's
        /// `symbol_facts`, `call_edges`, `lens_*` …) from the warehouse. Once: a
        /// second purge drops nothing. The code room's lake is never purged.
        #[arg(long)]
        purge: bool,
    },
    /// **Embed now** — run ONLY the semantic-vector embed step over the workspace's
    /// CURRENT member checkouts, and nothing else: no fetch, no knowledge scan, no
    /// resolved (SCIP) ingest, no deep-scan, no republish.
    ///
    /// This is the escape hatch for a registered workspace whose `embeddings` table
    /// is EMPTY (measured 2026-08-14 on `nordisk`: 0 parquet files for `embeddings`
    /// against 74 for `symbol_facts`). The embed only ever ran inside a full
    /// populate/republish pass, and on an UNCHANGED tree the monitor short-circuits
    /// before reaching it — so the vectors could not be filled at all short of a
    /// purge + re-register, which consumes the whole workspace again
    /// first, for nothing.
    ///
    /// IDEMPOTENT and RESUMABLE: content already stored for the active model
    /// profile is never re-embedded, and each member's manifest + snapshot commits
    /// on its own boundary, so a run killed mid-flight keeps every member it
    /// finished and the next run continues from there.
    ///
    /// ⚠ EMBEDDED/FAT PATH ONLY — there is no `Workspaces.Embed` RPC, so this does
    /// NOT work against `--server`/`NORNIR_SERVER`. Run it on the server host as
    /// the server user. The warehouse catalog is single-writer, so `nornir-server`
    /// must be stopped for the duration.
    ///
    /// Two kinds of vectors are written. FUNCTIONS first: one vector per
    /// function node of every consumed member (what `mimir analyze duplicates`
    /// / `semantic-clones` read), cut by the code room's spans — a member whose
    /// checkout is not at the commit the room holds is named and left alone.
    /// Then the LINE WINDOWS free-text search reads, as before.
    Embed {
        name: String,
        /// Also embed the DEPENDENCY RING's functions (every crate@version the
        /// driver dumped that has no vectors yet). On a server this runs by
        /// itself as a background job when the lockfile moves; this flag runs
        /// it here and now. A ring is 10^5–10^6 function bodies: a GPU job. On
        /// a CPU box the embed budget refuses it with the measured ETA
        /// (`NORNIR_EMBED_BUDGET=off` accepts the cost).
        #[arg(long)]
        ring: bool,
    },
    /// **Populate status** (PLAN #6): print the recent clone/fetch/republish
    /// outcomes for a workspace — one line per member attempt (ok / failed + the
    /// error chain + when), so you can see *why* a member didn't populate (auth,
    /// vanished repo, network) instead of only missing data. Reads the `clone_events`
    /// warehouse table; `--server` reads it over `Viz.CloneEvents`. `--json` for
    /// machine output.
    Events {
        name: String,
        /// Emit the raw event rows as JSON instead of the human view.
        #[arg(long)]
        json: bool,
    },
}

#[derive(Subcommand)]
enum KeyOp {
    /// Print the public key — paste it as a deploy key on each monitored repo.
    Show,
    /// Print the path to the public key.
    Path,
    /// Create the ed25519 keypair if it doesn't exist yet (idempotent).
    Generate,
}

#[derive(Args)]
struct ServeArgs {
    /// `nornir.toml` to serve (else the server uses NORNIR_CONFIG / cwd discovery).
    #[arg(long)]
    config: Option<PathBuf>,
    /// Bind address (default `127.0.0.1:7878`).
    #[arg(long)]
    addr: Option<String>,
    /// `nornir-server` binary (default: next to this executable).
    #[arg(long)]
    server_bin: Option<PathBuf>,
}

#[derive(Args)]
struct VizArgs {
    // Workspace + thin-client target come from the global `--server`/`--token`/
    // `--workspace` flags (placed in the env by `main`); the execed `nornir-ui`
    // inherits that env.
    /// Local warehouse dir (embedded mode; ignored when `--server`/`NORNIR_SERVER` is set).
    #[arg(long)]
    warehouse: Option<PathBuf>,
    /// `nornir-ui` binary (default: next to this executable / on PATH).
    #[arg(long)]
    viz_bin: Option<PathBuf>,
    /// **LAW4b** — take a self-screenshot once the viz starts and write it to
    /// this path as a PNG.  The viz also appends the screenshot to the warehouse
    /// `robot_test_results` blob store so it is durable and queryable.
    /// CLI parity with the Ctrl+Shift+S keyboard shortcut and `VizCommand::Screenshot`.
    #[arg(long)]
    screenshot: Option<String>,
    /// 🎬 **Demo mode** — launch a hands-free guided tour that ROBOT-DRIVES the
    /// viz through every tab (Arch/Metro, Knowledge, Funnel, Test, Warehouse Deck,
    /// Release, Security) via the existing robot-UI control channel, with pauses
    /// so the motion (graph pan/zoom, 3D spin, palette glow) plays. Loops until
    /// closed. `$NORNIR_VIZ_DEMO_BEAT_MS` tunes the pause between beats.
    #[arg(long)]
    demo: bool,
}

/// `nornir airgap …` — the Skíðblaðnir pipeline verbs (EPIC AIRGAP / AIRGAP9).
/// CLI parity with the `skidbladnir` crate API + the MCP tools.
#[derive(Subcommand)]
enum AirgapOp {
    /// AIRGAP1/2 — pack a staging tree into a signed `tar.zst` bundle + a sidecar
    /// manifest (per-entry SHA-256), and a `VERSION` file beside it.
    Pack(AirgapPackArgs),
    /// AIRGAP3 — verify a received bundle against its manifest (zero network).
    Fetch(AirgapBundleArgs),
    /// AIRGAP7 — show the unfold backend's provisioning plan for a target
    /// (kvm qemu argv | terraform HCL | redfish reset body).
    Unfold(AirgapUnfoldArgs),
    /// AIRGAP6 — install one component from a bundle (root-only on Linux).
    Install(AirgapInstallArgs),
    /// Install/manage a lake component (holger | tunnr | nornir-server) BY NAME
    /// from the built-in registry — the adoption layer over the airgap installer
    /// (add | upgrade | remove | status). Renders `<name>.service`.
    Component {
        #[command(subcommand)]
        op: ComponentOp,
    },
    /// AIRGAP8 — dependency-ordered start waves for a lake spec; emits
    /// `airgap_events` to the warehouse.
    Start(AirgapStartArgs),
    /// AIRGAP-TEARDOWN — tear a lake down in **reverse** dependency order
    /// (dependents removed before their deps). Removes each component's systemd
    /// unit (keeps data). Idempotent.
    Destroy(AirgapStartArgs),
    /// Read back the `airgap_events` DAG (from the warehouse JSONL sink).
    Events(AirgapEventsArgs),
}

#[derive(Args)]
struct AirgapPackArgs {
    /// Staging tree to pack (artifacts laid out as they appear in the bundle).
    staging: PathBuf,
    /// Output bundle path (`*.tar.zst`).
    #[arg(long)]
    out: PathBuf,
    /// Airgap generation stamp (default: today, YYYYMMDD).
    #[arg(long)]
    version: Option<String>,
}

#[derive(Args)]
struct AirgapBundleArgs {
    /// The received bundle (its sidecar `<bundle>.manifest.json` must be present).
    bundle: PathBuf,
    /// Optional out-of-band whole-file SHA-256 to also confirm.
    #[arg(long)]
    sha256: Option<String>,
    /// Emit the uniform `CommandOutcome` as JSON (machine) instead of the verdict.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct AirgapUnfoldArgs {
    /// Backend: `kvm` | `terraform` | `redfish`.
    backend: String,
    /// Target name / workspace / BMC url (backend-specific).
    target: String,
    /// KVM / libvirt: disk image to boot.
    #[arg(long)]
    disk: Option<String>,
    /// libvirt only: libvirtd `host:port` (TCP, default port 16509).
    #[arg(long)]
    host: Option<String>,
    /// Actually drive the backend (libvirt: define+start over the pure-Rust RPC),
    /// not just print the plan.
    #[arg(long)]
    run: bool,
    /// Emit the uniform `CommandOutcome` as JSON (machine) instead of the plan.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct AirgapInstallArgs {
    /// The verified bundle to install from.
    bundle: PathBuf,
    /// Component manifest (`manifests/<name>.json`) path INSIDE the bundle, or a
    /// local `ComponentSpec` JSON file.
    #[arg(long)]
    spec: PathBuf,
    /// Software root (version-dated dirs created under here).
    #[arg(long, default_value = "/opt/lake")]
    software_dir: PathBuf,
    /// Fast-moving data root.
    #[arg(long, default_value = "/var/lib")]
    data_dir: PathBuf,
    /// Log root (per-product log dirs created under here).
    #[arg(long, default_value = "/var/log")]
    log_dir: PathBuf,
}

#[derive(Args)]
struct AirgapStartArgs {
    /// Lake spec JSON (`LakeSpec`: version + products + pki).
    spec: PathBuf,
    /// Override the warehouse root the `airgap_events.jsonl` sink writes under
    /// (default: the resolved workspace warehouse root).
    #[arg(long)]
    warehouse: Option<PathBuf>,
}

#[derive(Args)]
struct AirgapEventsArgs {
    /// Warehouse root holding `airgap_events.jsonl` (default: resolved root).
    #[arg(long)]
    warehouse: Option<PathBuf>,
    /// Emit the uniform `CommandOutcome` as JSON (machine) instead of the table.
    #[arg(long)]
    json: bool,
}

/// `nornir install …` — manage nornir's own server as a systemd service on THIS
/// host. The everyday lifecycle is three verbs: `add` (install), `update` (swap
/// in a freshly-built binary + restart), `remove` (tear down).
///
/// Two **scopes** (auto-picked by privilege, overridable with `--scope`):
/// - **user** (default when NOT root): a `systemctl --user` service that runs as
///   YOU, execs your own `~/.cargo/bin/nornir-server` in place, and keeps its
///   state in your own `~/.nornir`. No root, no system user, no `/etc`.
/// - **system** (default when root): a system service running as the dedicated
///   `nornir` user, binaries copied to `/usr/local/bin`, state under
///   `/home/nornir/.nornir`. This is the "always-on box" install.
///
/// Either way the bearer token lives in a 0600 `<home>/.nornir/token` (read by
/// the server AND the CLI client — no `/etc/nornir`, no env file); the bind addr
/// and monitor settings are inline `Environment=` lines in the unit.
///
/// To install OTHER lake components (holger, tunnr) from airgap bundles, see
/// `nornir airgap component …` — that is the generic, registry-driven installer;
/// this is the bespoke fast path for nornir-server, and both render the SAME
/// `nornir-server.service` unit.
#[derive(Subcommand)]
enum InstallOp {
    /// Install nornir-server as a systemd service. Default scope follows privilege
    /// (`--scope user` when non-root → runs as you from `~/.cargo/bin` with state
    /// in `~/.nornir`; `--scope system` when root → creates the `nornir` user,
    /// copies binaries to `/usr/local/bin`, state under `/home/nornir`). Writes
    /// the SSH identity + the `<home>/.nornir/token`. Idempotent (safe to re-run);
    /// to only refresh the binary after a new release, prefer `update`.
    Add(SystemdArgs),
    /// Swap in freshly-built binaries and restart the service — PRESERVING the
    /// unit, token, and SSH key. System scope copies the `nornir`/`nornir-server`
    /// binaries you ran into `/usr/local/bin`; user scope just restarts (your
    /// `cargo install` already replaced `~/.cargo/bin`). The "new nornir crate
    /// shipped → update the box" path. NEVER fetches from a registry.
    Update(UpdateArgs),
    /// UNINSTALL: stop + remove the systemd service AND erase the data home —
    /// warehouses, registry, caches, funnel, jobs — while ALWAYS keeping the `ssh/`
    /// deploy key + the `token`. No flag needed and no flag can turn the data erase
    /// off: THE SUITE UNINSTALL LAW is that an uninstall always kills the data and
    /// always keeps the keys. Destroying the identity is the one explicit opt-in,
    /// `--wipe-identity`.
    Remove(UninstallArgs),
}

/// Service scope for `nornir install`. Defaults to `User` when not root and
/// `System` when root.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum Scope {
    /// `systemctl --user` unit that runs as the invoking user, from that user's
    /// own binaries, with state in that user's `~/.nornir`. No root.
    User,
    /// System unit (root) running as the dedicated `nornir` system user, binaries
    /// in `/usr/local/bin`, state under `/home/nornir`.
    System,
}

/// `nornir airgap component …` — the generic, registry-driven component
/// installer (adopting `skidbladnir`). Drives
/// `skidbladnir::product::{Installer, SystemdInstaller, ComponentSpec}` against
/// the built-in registry (holger, tunnr, nornir-server). ONE install system with
/// the bespoke `nornir install` above — both render `<name>.service`.
#[derive(Subcommand)]
enum ComponentOp {
    /// Install a lake component from an airgap bundle by name (holger | tunnr |
    /// nornir-server). Resolves the canonical `ComponentSpec` from the registry,
    /// renders the systemd unit, unpacks the binary, opens ports. Root-only on
    /// Linux. With `--dry-run`, prints the unit it WOULD install (no privilege).
    Add(InstallComponentArgs),
    /// Re-install (upgrade) a component from a (newer) bundle — same atomic code
    /// path as `add` (layout version swap). `--delta` requests a binary-delta
    /// apply via the znippy seam; until that engine is wired it falls back to a
    /// full re-install with a clear note.
    Upgrade(InstallUpgradeArgs),
    /// Stop + remove a component's systemd unit by name (idempotent). Root-only.
    Remove(InstallComponentRefArgs),
    /// Show whether a component's unit is active (or list the whole registry).
    Status(InstallStatusArgs),
}

/// Shared: identify a registered component + the airgap layout roots.
#[derive(Args)]
struct InstallComponentArgs {
    /// Component name (holger | tunnr | nornir-server). See `airgap component status`.
    component: String,
    /// The verified airgap bundle to install the component binary from.
    #[arg(long)]
    bundle: PathBuf,
    /// Software root (version-dated dirs created under here).
    #[arg(long, default_value = "/opt/lake")]
    software_dir: PathBuf,
    /// Fast-moving data root.
    #[arg(long, default_value = "/var/lib")]
    data_dir: PathBuf,
    /// Log root (per-product log dirs created under here).
    #[arg(long, default_value = "/var/log")]
    log_dir: PathBuf,
    /// Render the systemd unit the component WOULD install, then stop — no root,
    /// no side effects (the "render unit → would-install" demo).
    #[arg(long)]
    dry_run: bool,
}

#[derive(Args)]
struct InstallUpgradeArgs {
    #[command(flatten)]
    inner: InstallComponentArgs,
    /// Request a binary-delta upgrade via the znippy zero-copy patch seam
    /// (INSTALL-5). Falls back to a full re-install when the delta engine is not
    /// built in (honest fallback).
    #[arg(long)]
    delta: bool,
    /// The previously-installed bundle to diff against (the delta base). With
    /// `--delta`, the new bundle in `--bundle` is staged from this base + only the
    /// changed blobs — the airgap-kubernetes speed story.
    #[arg(long)]
    from: Option<PathBuf>,
}

#[derive(Args)]
struct InstallComponentRefArgs {
    /// Component name (holger | tunnr | nornir-server).
    component: String,
}

#[derive(Args)]
struct InstallStatusArgs {
    /// Component name; omit to list the whole registry with active state.
    component: Option<String>,
}

#[derive(Args)]
struct UninstallArgs {
    /// DEPRECATED — no effect, erasing the data IS the uninstall. `install remove`
    /// always wipes the data home (warehouses, registry, caches, funnel, jobs) and
    /// always keeps the `ssh/` deploy key + the `token`. Still accepted so existing
    /// scripts and pipelines keep working.
    #[arg(long)]
    purge: bool,
    /// THE ONE OPT-IN: also destroy the install identity — delete the `ssh/` deploy
    /// key and the server `token`. THE DEPLOY KEY REGISTERED ON THE GIT REMOTES IS
    /// DESTROYED: a reinstall mints a brand-new key that you must register again on
    /// every repo (Codeberg/GitHub deploy keys) before the server can clone or push
    /// anything. Never implied, never a side effect.
    #[arg(long)]
    wipe_identity: bool,
    /// DEPRECATED — no effect; keeping the `ssh/` key + `token` is unconditional
    /// (see `--wipe-identity` to opt out of it). Still accepted so existing scripts
    /// and pipelines keep working.
    #[arg(long)]
    keep_identity: bool,
    /// Service user (system scope only; default `nornir`).
    #[arg(long, default_value = "nornir")]
    user: String,
    /// Scope to tear down (`user` | `system`). Defaults to `system` when root,
    /// `user` otherwise — i.e. the scope this same invocation would have installed.
    #[arg(long, value_enum)]
    scope: Option<Scope>,
}

/// The service account's home directory, read from a passwd-format file — the
/// SYSTEM-scope half of the scope-aware state-root rule.
///
/// INCIDENT 2026-07-27: dwarves and korp erased a hardcoded `/home/<app>`
/// regardless of scope. On a per-user install that path does not exist, so the
/// uninstall wiped NOTHING, left the real multi-GB state root intact — and printed
/// a success line. A resolver and an eraser that disagree about where state lives
/// means one of them is lying. So the eraser resolves, it never assumes.
///
/// `None` when the account has no passwd row (never installed / already removed).
#[cfg(unix)]
fn service_home_in(user: &str, passwd_path: &Path) -> Option<PathBuf> {
    let text = std::fs::read_to_string(passwd_path).ok()?;
    text.lines().find_map(|line| {
        let mut f = line.split(':');
        (f.next()? == user).then_some(())?;
        // name:passwd:uid:gid:gecos:home:shell — `next` took `name`, so the home
        // field is 4 further along.
        let home = f.nth(4)?;
        (!home.is_empty()).then(|| PathBuf::from(home))
    })
}

/// [`service_home_in`] against the real `/etc/passwd`, falling back to the
/// conventional [`NORNIR_HOME`] only when the account has no row at all.
#[cfg(unix)]
fn service_home(user: &str) -> PathBuf {
    service_home_in(user, Path::new("/etc/passwd")).unwrap_or_else(|| PathBuf::from(NORNIR_HOME))
}

/// The state root an uninstall erases, RESOLVED BY SCOPE — never a hardcoded
/// absolute constant (see [`service_home_in`] for the incident that rule comes
/// from). User scope is the invoking user's own `$HOME/.nornir`; system scope is
/// the service account's `<passwd home>/.nornir`. Same rule
/// (`config::nornir_home_from`) the SERVER itself uses to decide where to write,
/// so the eraser and the resolver can never disagree.
#[cfg(unix)]
fn uninstall_state_root(scope: Scope, user: &str, invoking_home: &Path) -> PathBuf {
    match scope {
        Scope::User => nornir::config::nornir_home_from(invoking_home),
        Scope::System => nornir::config::nornir_home_from(&service_home(user)),
    }
}

/// The install-identity entries an uninstall NEVER destroys — the `ssh/`
/// deploy key registered on the git remotes and the server bearer `token`. `.ssh`
/// is in the list too because the system-scope key lives at `<service home>/.ssh`
/// (see [`nornir_ssh_dir`]), which is the very name skidbladnir's
/// `purge_service_data_keep_keys` spares.
///
/// THE SUITE UNINSTALL LAW (`workspace_nordisk/.nornir/suite-uninstall-law.md`):
/// uninstalling ALWAYS kills the data and ALWAYS keeps the ssh keys — not behind a
/// flag, not opt-in. Deleting the identity is the one explicit opt-in
/// (`--wipe-identity`), never a side effect.
#[cfg(unix)]
const IDENTITY_KEEP: &[&str] = &["ssh", ".ssh", "token"];

/// Wipe every entry directly under `root` EXCEPT the names in `keep`, returning
/// `(wiped, kept)`.
///
/// Each individual delete goes through skidbladnir's post-incident guarded
/// primitive `skidbladnir::systemd::guarded_remove_dir_all` — the SAME authority
/// behind `skidbladnir::systemd::purge_service_data_keep_keys`, which refuses any
/// path with a `.ssh` component, a real user's home, a system directory, or a
/// symlink pointing out of the tree, and pins the delete to `root`. The delete
/// decision lives in skidbladnir once (L5); nornir only supplies the keep-list,
/// because nornir's identity sits at `<data root>/ssh` + `<data root>/token`
/// rather than the `<service home>/.ssh` that skidbladnir's own keep-list covers.
///
/// A missing `root` is a no-op that reports `(0, 0)` — the caller must NOT print a
/// success line for a purge that removed nothing.
#[cfg(unix)]
fn purge_except(root: &Path, keep: &[&str], owner_uid: u32) -> (usize, usize) {
    let (mut wiped, mut kept) = (0usize, 0usize);
    let Ok(entries) = std::fs::read_dir(root) else {
        return (0, 0);
    };
    for entry in entries.flatten() {
        let name = entry.file_name();
        if keep.iter().any(|k| name == std::ffi::OsStr::new(k)) {
            kept += 1; // KEEP THE KEY — never touch the registered identity
            continue;
        }
        let path = entry.path();
        match skidbladnir::systemd::guarded_remove_dir_all(&path, owner_uid, Some(root)) {
            Ok(()) => wiped += 1,
            Err(e) => eprintln!("nornir: uninstall left {} in place: {e:#}", path.display()),
        }
    }
    (wiped, kept)
}

/// The identity DECISION for `install remove`, in one place: which names survive
/// the erase. `--wipe-identity` (and only that flag) returns an empty keep-list;
/// `--purge`/`--keep-identity` are inert because erasing the data while keeping
/// the key is the unconditional behaviour.
///
/// Reverting the law here is what turns
/// `purge_law_tests::uninstall_with_no_flags_kills_data_and_keeps_the_key` RED.
#[cfg(unix)]
fn purge_keep_list(a: &UninstallArgs) -> &'static [&'static str] {
    if a.wipe_identity { &[] } else { IDENTITY_KEEP }
}

/// The skidbladnir guard REFUSES any path with a `.ssh` component outright — key
/// material is never data. `--wipe-identity` is the operator explicitly overriding
/// exactly that, so the `.ssh` child of the tree being purged is removed HERE,
/// outside the guard, and nowhere else in nornir. Returns how many entries went.
#[cfg(unix)]
fn force_remove_key_material(root: &Path) -> usize {
    let dot_ssh = root.join(".ssh");
    usize::from(dot_ssh.exists() && std::fs::remove_dir_all(&dot_ssh).is_ok())
}

/// Purge a data home per the law: everything under `data` goes, the identity stays
/// unless `--wipe-identity` was passed. Returns `(wiped, kept)` so the caller can
/// print HONESTLY (and stay quiet when nothing was there). With `--wipe-identity`
/// the key material and the now-empty root are removed too.
#[cfg(unix)]
fn purge_data_home(data: &Path, a: &UninstallArgs, owner_uid: u32) -> (usize, usize) {
    let (mut wiped, kept) = purge_except(data, purge_keep_list(a), owner_uid);
    if a.wipe_identity {
        wiped += force_remove_key_material(data);
        // Nothing was spared, so the root itself is now an empty shell — drop it.
        let _ = std::fs::remove_dir(data);
    }
    (wiped, kept)
}

#[derive(Args)]
struct UpdateArgs {
    /// `nornir-server` binary to install (default: next to this executable —
    /// e.g. a fresh `cargo build`/`cargo install` tree).
    #[arg(long)]
    server_bin: Option<PathBuf>,
    /// Scope to update (`user` | `system`). Defaults to `system` when root,
    /// `user` otherwise.
    #[arg(long, value_enum)]
    scope: Option<Scope>,
}

#[derive(Args)]
struct SystemdArgs {
    /// OPTIONAL release-recipe `nornir.toml` for a *served* workspace
    /// (gates/bench/publish_order). Omit for a plain empty server — workspaces
    /// are added at runtime via `nornir workspace add` / the viz UI.
    #[arg(long)]
    config: Option<PathBuf>,
    /// Service scope. `user` = a `systemctl --user` unit running as you (no root);
    /// `system` = a root unit running as the `nornir` user. Default: `user` when
    /// not root, `system` when root.
    #[arg(long, value_enum)]
    scope: Option<Scope>,
    /// Bind address baked into the unit as `Environment=NORNIR_SERVER_ADDR`
    /// (default `127.0.0.1:7878`). If the port is already taken the install fails
    /// — pick another with `--addr` (no silent port-bumping).
    #[arg(long, default_value = "127.0.0.1:7878")]
    addr: String,
    /// System user to create + run as (system scope only; default `nornir`).
    #[arg(long, default_value = "nornir")]
    user: String,
    /// `nornir-server` binary path (default: next to this executable).
    #[arg(long)]
    server_bin: Option<PathBuf>,
    /// DEPRECATED no-op: the registry root is home-derived
    /// (`<home>/.nornir/workspaces`); set the service user's `$HOME` to relocate.
    #[arg(long)]
    root: Option<PathBuf>,
}

#[derive(Subcommand)]
enum FunnelOp {
    /// Submit a krav / prompt / idea into the intake funnel. Prints the new
    /// idea id. The text comes from the positional arg, `--file <path>`, or
    /// stdin (pipe) — so long requirements ingest naturally:
    ///   `nornir funnel submit "<krav>"` · `--file krav.md` · `cat p | nornir funnel submit`
    Submit {
        /// Inline idea text. Omit when using `--file` or piping via stdin.
        text: Option<String>,
        /// Read the idea text from a file (e.g. a krav/prompt markdown).
        #[arg(long)]
        file: Option<std::path::PathBuf>,
        #[arg(long)]
        source: Option<String>,
    },
    /// FI1 — paste an **idea** (feature/krav/prompt) into the funnel. Alias of
    /// `submit` with `item_kind=idea`. Text from the arg, `--file`, or stdin.
    ///   `nornir funnel idea "<idea>"` · `--file krav.md` · `cat p | nornir funnel idea`
    Idea {
        /// Inline idea text. Omit when using `--file` or piping via stdin.
        text: Option<String>,
        #[arg(long)]
        file: Option<std::path::PathBuf>,
        #[arg(long)]
        source: Option<String>,
    },
    /// FI1 — paste an **error report** into the funnel (a first-class funnel
    /// item, kind `error`, triageable to a plan like any idea). Text from the
    /// arg, `--file`, or stdin (the natural way to ingest a captured report):
    ///   `nornir funnel error - < report.txt` · `nornir funnel error "<msg>"`
    Error {
        /// Inline error text, or `-` to read the report from stdin. Omit when
        /// using `--file` or piping (stdin is the default with no arg).
        text: Option<String>,
        #[arg(long)]
        file: Option<std::path::PathBuf>,
        #[arg(long)]
        source: Option<String>,
    },
    /// FI-extension — paste a **test-idea** (a desired/missing test) into the
    /// funnel (a first-class funnel item, kind `test`, planned into `test:write`
    /// node(s) on the affected component(s) like any idea). Text from the arg,
    /// `--file`, or stdin:
    ///   `nornir funnel test "cover the retry path in util"` · `--file t.md`
    Test {
        /// Inline test-idea text, or `-` to read from stdin. Omit when using
        /// `--file` or piping.
        text: Option<String>,
        #[arg(long)]
        file: Option<std::path::PathBuf>,
        #[arg(long)]
        source: Option<String>,
    },
    /// **Plan without big AI** — auto-derive a plan for IDEA_ID from the
    /// component dependency graph. Classifies the item text → component(s) (local
    /// LLM → embedder → pick-list), computes the topological affected set (blast
    /// radius in build order), and creates a plan with one node per affected
    /// component. Prints the proposal + affected set + new plan id.
    ///   `nornir funnel plan-from i-003` · `--component util` (skip classify)
    #[command(name = "plan-from")]
    PlanFrom {
        /// The funnel item id (idea / error / test) to plan.
        idea_id: String,
        /// Force the touched component(s), skipping classification (repeatable).
        /// Use this to act on the pick-list when no LLM/embedder is confident.
        #[arg(long = "component")]
        components: Vec<String>,
        /// Only classify + print the proposal & affected set; do NOT create the
        /// plan (a dry-run preview).
        #[arg(long)]
        dry_run: bool,
    },
    /// FI2 — browse submitted errors/ideas newest-first (the intake history),
    /// optionally filtered by kind/status. JSON unless `--table`.
    ///   `nornir funnel history` · `--kind error` · `--status untriaged --table`
    History {
        /// Filter by kind: `idea` | `error` | `test` (default: all).
        #[arg(long)]
        kind: Option<String>,
        /// Filter by derived status: untriaged | accepted | dropped | planned.
        #[arg(long)]
        status: Option<String>,
        /// Max items (newest-first). 0 = all.
        #[arg(long, default_value_t = 0)]
        limit: usize,
        /// Render a compact human table instead of JSON.
        #[arg(long)]
        table: bool,
    },
    /// Create a plan refining IDEA_ID (auto-activated). Prints the plan id.
    Plan { idea_id: String, summary: String },
    /// Add a node to PLAN_ID. KIND is a free verb (e.g. `code:write`).
    Node {
        plan_id: String,
        kind: String,
        #[arg(long)]
        title: Option<String>,
        /// Target artifact (repeatable).
        #[arg(long = "target")]
        targets: Vec<String>,
        /// Node id in the same plan this node depends on (repeatable).
        #[arg(long = "needs")]
        needs: Vec<String>,
        #[arg(long)]
        prompt: Option<String>,
    },
    /// Add a dependency edge: TO becomes ready only after FROM is done.
    Link {
        plan_id: String,
        from: String,
        to: String,
    },
    /// Print the ready PlanNodes across active plans (JSON), topo-ordered.
    Next {
        /// Emit the machine-readable `CommandOutcome` JSON (the autopilot shape)
        /// instead of the human listing. `next` is JSON-native, so this is on by
        /// default; pass `--no-json`-style by omitting it for the human render.
        #[arg(long)]
        json: bool,
    },
    /// Flip a node's status: ready | active | blocked | done | failed.
    Status {
        plan_id: String,
        node_id: String,
        status: String,
        #[arg(long)]
        why: Option<String>,
    },
    /// Dump the whole funnel (ideas, plans, nodes, edges).
    Show {
        /// Emit the machine-readable `CommandOutcome` JSON (the autopilot shape)
        /// instead of the human tree.
        #[arg(long)]
        json: bool,
    },
    /// **Decompose (EPIC #45):** break a task into ≤N atomic, testable subtasks
    /// (RAG-anchored to real files/fns via the code index), then run each through
    /// a local-LLM generate → CodegenJudge oracle → retry loop and print the
    /// subtask DAG + per-subtask verdicts. 100% local — `--gen mock` (default)
    /// needs no model and no network; the compiler is the accept/reject oracle.
    ///
    ///   `nornir funnel decompose "make add() correct" --fixture`
    ///   `nornir funnel decompose i-003 --gen candle:qwen2-0.5b --store`
    Decompose(DecomposeArgs),
    /// **funnel-prompt-planner keystone:** prompt-as-mother. Persists TEXT as
    /// the root `Idea` (the "mother" node), grounds + decomposes it into an
    /// anchored, dep-linked child task DAG (100% offline/model-free — the
    /// codegen generate→judge loop is orthogonal and never runs here), and
    /// routes the children per mode: `--auto` commits straight into `Planned`;
    /// `--manual` (default) stages them in `Proposed` for `funnel accept`.
    /// `--assign auto` allocates by role/capacity from `.nornir/team.toml`.
    ///
    ///   `nornir funnel prompt "add retry to the http client" --auto`
    ///   `nornir funnel prompt "fix the flaky test" --manual --assign auto`
    Prompt(PromptArgs),
    /// **Executor dry-run** — print the topological waves the executor would run
    /// for a plan-DAG (the ready-frontier order), without executing. Live drain
    /// (drive each node through the generate→judge oracle) lands with a
    /// Claude-backed generator — a role separate from the planner.
    ///   `nornir funnel run p-001`
    Run(RunArgs),
    /// (Re)assign a card — a mother idea (`i-NNN`) or a decomposed task node
    /// (`n-NNN`) — to a real team member (spec §3).
    ///   `nornir funnel assign n-004 ada --role coder`
    Assign {
        /// The card id (`i-NNN` or `n-NNN`).
        node: String,
        member: String,
        #[arg(long)]
        role: Option<String>,
        #[arg(long)]
        why: Option<String>,
    },
    /// Promote a `Proposed` (Manual-mode draft) card to `Planned`, or with
    /// `--reject`, drop it to `Dropped` — the Manual-mode review gate (spec
    /// §2). Errors if the card isn't currently `Proposed`.
    ///   `nornir funnel accept n-004` · `nornir funnel accept n-005 --reject`
    Accept {
        /// The card id (`i-NNN` or `n-NNN`), normally a task node.
        node: String,
        #[arg(long)]
        reject: bool,
        #[arg(long)]
        why: Option<String>,
    },
    /// YouTrack-style smart query over every card (ideas/errors/tests + every
    /// decomposed task node) — `type:`/`lane:`/`is:open`/`reporter:`/
    /// `assignee:`/`for:me`/`label:`/`has:plan`/`sort:`, free text otherwise.
    ///   `nornir funnel cards "assignee:ada lane:planned"` · `--json`
    Cards {
        /// The smart-query string (empty = everything).
        query: Option<String>,
        #[arg(long)]
        json: bool,
    },
    /// The kanban board: every card grouped into its 5 fixed lanes
    /// (Inbox/Accepted/Proposed/Planned/Dropped).
    Board {
        #[arg(long)]
        json: bool,
    },
    /// **Demo:** materialise `--size N` fake git repos near the git root (each a
    /// `Cargo.toml` + `src/lib.rs` wired into a layered path-dep graph) AND
    /// inject the matching funnel DAG into the warehouse. Idempotent — a rerun
    /// detects the existing `funnel:demo` idea and skips the warehouse half.
    /// A manifest `git/.nornir-funnel-demo.json` records exactly what was made
    /// so `nuke` deletes only demo paths.
    Demo {
        /// Number of fake repos to create.
        #[arg(long, default_value_t = 1)]
        size: usize,
    },
    /// **Nuke (disk-only):** delete the fake repos the demo created on disk,
    /// reading the manifest so only demo paths are removed (never a heuristic
    /// `rm`). The warehouse funnel data is PRESERVED — the plan still shows in
    /// `funnel show` / the viz Funnel tab. Refuses without `--yes`.
    Nuke {
        /// Confirm the disk deletion (required). Without it, prints what it
        /// would delete and exits.
        #[arg(long)]
        yes: bool,
    },
}

#[derive(Args)]
struct DecomposeArgs {
    /// The task: either a funnel item id (`i-003`/`p-…` — its text is used) or a
    /// raw task string. With `--store`, an id is required (the subtasks hang
    /// under it); a raw string is fine for a stateless `--fixture` dry-run.
    task: String,
    /// Run the OFFLINE simulation against a self-contained tiny fixture crate
    /// (created in a temp dir, no repo touched): the code index IS the fixture,
    /// the judge builds+tests the fixture. The fully model-free, network-free
    /// demo. Mutually exclusive with `--repo`.
    #[arg(long)]
    fixture: bool,
    /// Judge against this on-disk crate dir (RAG over its scanned symbols, the
    /// CodegenJudge builds+tests a scratch copy). Defaults to the fixture when
    /// neither this nor `--fixture` is given on a dry run.
    #[arg(long)]
    repo: Option<String>,
    /// Generator spec (`mock` | `candle:<m>` | `mistralrs:<m>` | `onnx:<m>` |
    /// `ollama:<m>`). Default `mock` — offline, no model. Falls back to
    /// `$NORNIR_GEN_BACKEND`.
    #[arg(long)]
    r#gen: Option<String>,
    /// Max atomic subtasks (hard cap 20).
    #[arg(long, default_value_t = 8)]
    max_subtasks: usize,
    /// Bounded generate→judge retry rounds per subtask.
    #[arg(long, default_value_t = 3)]
    rounds: usize,
    /// Top-k RAG anchors retrieved for the task.
    #[arg(long, default_value_t = 5)]
    top_k: usize,
    /// Persist the subtasks as child funnel DAG nodes under the task item +
    /// write the per-subtask verdicts back (requires `task` to be an item id).
    #[arg(long)]
    store: bool,
    /// Emit the whole report as JSON (`state_json`) instead of the DAG view.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct PromptArgs {
    /// The free-text idea/prompt. Persisted VERBATIM as the mother `Idea`'s
    /// body (its first line becomes the card title).
    text: String,
    /// Auto (Populate): child tasks land straight in `Planned`. Mutually
    /// exclusive with `--manual`.
    #[arg(long, conflicts_with = "manual")]
    auto: bool,
    /// Manual (Suggest): child tasks land in `Proposed` for `funnel accept`
    /// to promote/reject. DEFAULT when neither `--auto` nor `--manual` is
    /// given (front-loaded human review — see spec §2 "Safety").
    #[arg(long, conflicts_with = "auto")]
    manual: bool,
    /// `auto` (allocate by role/capacity from `.nornir/team.toml`) |
    /// `<member>` (blanket-assign) | `none` (default — unassigned/pull model).
    #[arg(long, default_value = "none")]
    assign: String,
    /// Repo name to ground the decomposition in — matches a prior `nornir
    /// knowledge scan <repo>`. Best-effort: with no prior scan (or no
    /// warehouse yet) the decomposition still runs, via `decompose`'s
    /// whole-task fallback subtask. Defaults to the workspace name.
    #[arg(long)]
    repo: Option<String>,
    #[arg(long)]
    source: Option<String>,
    /// Max atomic child subtasks (hard cap 20).
    #[arg(long, default_value_t = 8)]
    max_subtasks: usize,
    /// Top-k RAG anchors retrieved for the prompt.
    #[arg(long, default_value_t = 5)]
    top_k: usize,
    /// Emit the sub-DAG as `state_json` (LAW 6) instead of the human view.
    #[arg(long)]
    json: bool,
    /// Decompose the prompt with an LLM (Claude) instead of the model-free
    /// RAG/code-index leg. Needs `ANTHROPIC_API_KEY` (+ the `claude` build
    /// feature). On any failure it prints a notice and falls back to the
    /// model-free decompose — never a silent no-op.
    #[arg(long)]
    llm: bool,
}

#[derive(Args)]
struct RunArgs {
    /// The plan id to execute, e.g. `p-001`.
    plan_id: String,
    /// Generator spec (`claude`, `mock`, `candle:<m>`, …). LIVE-generates a
    /// proposal per ready node with that backend (real model calls, proposals
    /// only — no state change). Omit for a dry-run frontier walk.
    #[arg(long = "gen")]
    generator: Option<String>,
}

#[derive(Subcommand)]
enum KnowledgeOp {
    /// Scan <repo>: walk HEAD's commits with gix (per-file heat) and persist
    /// `git_heat_facts`; pass `--no-save` to only print the summary. Symbols,
    /// calls and feature gates are NOT scanned here: the code room's consumer
    /// writes them per commit (`nornir workspace populate`).
    Scan {
        repo: String,
        /// Don't write to the warehouse (default: persist the repo's git heat —
        /// nothing computed is discarded).
        #[arg(long = "no-save")]
        no_save: bool,
    },
    /// Query the code room's graph of <repo> — asked of the SERVER's
    /// `CodeRoom.Answer` (`--server <url>` / `$NORNIR_SERVER`: the Dwarves forge
    /// or a nornir-server); the CLI never opens the lake itself, and without a
    /// server the query is refused. `kind` is one of:
    /// `callers` (who calls ARG), `callees` (what ARG calls),
    /// `defined-in` (symbols in files ending with ARG), `lookup`
    /// (symbols whose name contains ARG), `path` (shortest call chain from
    /// ARG to `--to`, BFS over call edges), `feature-gates` (the `#[cfg(...)]`
    /// items of the latest scan, ARG filters by cfg-expression substring).
    Query {
        repo: String,
        /// callers | callees | defined-in | lookup | path | feature-gates
        kind: String,
        /// function name / file suffix / name substring / path source (per `kind`)
        arg: String,
        /// Target function for `kind = path` (matched by last path segment).
        #[arg(long)]
        to: Option<String>,
        #[arg(long, default_value_t = 50)]
        limit: usize,
    },
    /// Graph-RAG **context slices** for <repo>: embed QUERY, vector-match the
    /// repo's `embeddings`, JOIN the `embedding_manifest` span + the code room's nodes
    /// (kind/name/visibility), and return ranked, LLM-injectable code slices —
    /// no disk re-search. Optionally trim to a CALLS change-radius
    /// (`--seed` symbols expanded `--hops` hops) and hydrate the exact span text
    /// from `--hydrate <repo-root>`. Prints JSON. Needs an embedder feature.
    Context {
        repo: String,
        /// Natural-language / code query — embedded with the repo's model.
        query: String,
        /// Point-in-time git sha (default: latest indexed snapshot).
        #[arg(long)]
        sha: Option<String>,
        /// How many ranked slices to return.
        #[arg(long, default_value_t = 8)]
        limit: usize,
        /// Change-radius seed symbol(s): keep only slices within `--hops` call-
        /// graph hops of a seed. Repeatable. Empty = no radius trim.
        #[arg(long = "seed")]
        seed: Vec<String>,
        /// Call-graph hop radius from the seeds (only meaningful with `--seed`).
        #[arg(long, default_value_t = 1)]
        hops: usize,
        /// Also follow reverse edges (callers) when expanding the radius.
        #[arg(long = "include-callers")]
        include_callers: bool,
        /// Read the exact span text back from this repo root into each slice.
        #[arg(long)]
        hydrate: Option<String>,
    },
}

#[derive(Subcommand)]
enum GuardOp {
    /// Report writable state of each forbidden path. `--json` prints the
    /// CommandOutcome (machine); else a human table.
    Status {
        #[arg(long)]
        json: bool,
    },
    /// chmod -w every forbidden path, then record the tamper-evidence
    /// manifest (sha256 + mode) and export guard-policy.json.
    Apply,
    /// Verify forbidden paths against the recorded manifest (drift report).
    Verify,
    /// chmod +w every forbidden path (intentional human unlock). Requires
    /// the NORNIR_GUARD_UNLOCK_TOKEN env var to be set — never agent-facing.
    Release,
}

#[derive(Subcommand)]
enum BenchOp {
    /// Show <repo>'s bench history — scalar metrics + test pass/fail per run
    /// (newest first). `--json` prints the raw JSONL instead; `--limit N` caps rows.
    HistoryShow(HistoryShowArgs),
    /// **MOVED — use `dwarves bench run <repo>`.** Kept as a working alias.
    ///
    /// Run <repo>'s examples/nornir-bench.rs (or xtask/examples/nornir-bench.rs),
    /// parse the BenchRun it prints, and append it to the iceberg
    /// `bench_runs` table.
    ///
    /// Running a build-and-measure over a product is forge work, so the verb's
    /// home is the forge; the warehouse keeps the READ (`bench history-show`).
    /// Both spawn the identical measurement — one `nornir_bench::runner`, no
    /// second copy — so a row written here and a row written there are the same
    /// row, and this alias resolves <repo> by NAME against the workspace root
    /// exactly as it always has. `dwarves bench run` additionally accepts a PATH,
    /// which is what makes it usable from a worktree.
    Run(BenchRunArgs),
}

/// Args for `nornir bench run <repo> [--features …]`. A dedicated struct (rather
/// than the shared `RepoArg`) so the `--features` passthrough lands ONLY on the
/// bench-run command and doesn't leak onto the ~30 other `RepoArg`-backed verbs.
#[derive(Args)]
struct BenchRunArgs {
    repo: String,
    /// Extra cargo features to build the bench example with (comma/space-separated,
    /// e.g. `s3` to enable the RustFS/Nessie/Polaris S3 container arms). Overrides
    /// the `NORNIR_BENCH_FEATURES` env var. Local-run path only for now.
    #[arg(long, value_name = "FEATURES")]
    features: Option<String>,
}

#[derive(Subcommand)]
enum TestOp {
    /// Run <repo>'s **multi-aspect** matrix (EPIC L) and append every row to
    /// `test_results` under one run id. Default aspects: build,unit,doctest,
    /// clippy,fmt,audit,funnel,funnel-demo,functional,gated-tests (slow/optional
    /// ones — bench-smoke,coverage,feature-powerset,msrv,examples,target-compile — only with
    /// `--aspects`). Guarded against drift by
    /// `tests/aspect_help_matches_the_engine.rs`. The unit aspect wraps the
    /// repo's native `cargo nextest run` / `cargo test`; a missing tool
    /// (clippy/audit/…) is a neutral `skip`, never a hard fail. The stall
    /// watchdog (`NORNIR_TEST_STALL_SECS`, default 120) records a red `stalled`
    /// row if the test subprocess goes silent.
    Run(TestRunArgs),
    /// Run the FULL aspect matrix across EVERY repo in nornir.toml, persist every
    /// row, print a per-repo×aspect grid, and exit red if any red.
    /// `--aspects build,unit,clippy,…` overrides the default set.
    All(TestAllArgs),
    /// List the BUILD-FREE test matrix for <repo>: every discovered `#[test]`
    /// (from the code room's test nodes — no test is run for it) with its tri-state verdict
    /// `ok | fail | X` (X = discovered but not run) and a `heavy` tag for
    /// `#[ignore]`'d tests. This is the CLI twin of the viz 🧪 Test pane — the
    /// matrix is the `test_inventory` ⟕ `test_results` join. `--json` prints the
    /// raw rows; `--heavy-only` shows just the heavy tests; `--not-run` shows just
    /// the X (not-run) tests.
    List(TestListArgs),
    /// **Which tests a commit can break** (incremental tests): the tests of
    /// <repo> whose reach in the code room holds something that changed between
    /// `--from` (default: the newest commit before `--to` whose Runa test run was
    /// green) and `--to` (default: the newest consumed commit) — "these N of M
    /// tests, because …", the skip list a runner takes (`--skip <name> --exact`),
    /// the changed items, and the changed fns no test reaches. When the graph
    /// cannot be sure (a manifest, the lock, a build script, a cfg, a macro, a
    /// file a test may read, a commit the room has not read) it says `all` and
    /// why. The CLI twin of the `Test.Affected` surface a Runa
    /// `Nextest(only: Affected)` step asks.
    Affected(TestAffectedArgs),
    /// Show <repo>'s recorded test history — per-run green/red counts (newest
    /// first) with red cases listed. `--json` prints the raw rows; `--limit N`
    /// caps the number of runs shown (0 = all).
    History(TestHistoryArgs),
    /// autonom COMPLETENESS GATE (n-005): discover the testable surface
    /// (viz tabs × {thin,fat}, CLI subcommands, MCP tools, and the unreached-fn
    /// set = the code room's functions − those a test reaches over CALLS), difference it
    /// against the covered set + the checked-in `.nornir/autonom-allow.toml`,
    /// persist the verdict to `surface_coverage`, and FAIL (exit 1) if any
    /// surface is uncovered+un-allowlisted OR an allowlist entry is stale.
    /// Green ⟺ every surface is wired. `--seed-allowlist` rewrites the allowlist
    /// with every currently-uncovered surface (green now, burn down by deleting).
    Coverage(TestCoverageArgs),
}

#[derive(Args)]
struct TestCoverageArgs {
    /// Repo whose code room drives the function
    /// surface. Defaults to the workspace's primary repo (the workspace name).
    #[arg(long)]
    repo: Option<String>,
    /// Seed `.nornir/autonom-allow.toml` with every currently-uncovered surface
    /// node (green now; burn down by deleting entries as tests land). Does NOT
    /// fail the gate — it WRITES the allowlist.
    #[arg(long)]
    seed_allowlist: bool,
    /// Emit the persisted per-node coverage rows as JSON instead of the summary.
    #[arg(long)]
    json: bool,
    /// Emit the autonom **metrics SVG** (the gate's visual twin: surface ×
    /// {ran, utfallsrum, reachable} per workspace) to stdout instead of the
    /// summary. Redirect to a `.svg` to embed in docs. Self-contained, no JS.
    #[arg(long)]
    svg: bool,
    /// Emit the FIELD-level **value-gap worklist** instead of the surface gate:
    /// every on-screen `state_json` value pointer some UI emits but no robot
    /// `Assert::State` pins — RAGNARÖK / failure-path first. Drives the embedded
    /// facett components headless (behind `viz`), enumerates their emitted value
    /// pointers, scores each against the robot scenarios' asserted pointers, lands
    /// `ui_value` rows in `surface_coverage`, then prints the ranked worklist
    /// (`WHERE utfallsrum_covered = 0 AND kind IN (ui_value, viz_tab,
    /// facett_component)`). The machine-readable twin of the hand gap doc. `--json`
    /// dumps the rows.
    #[arg(long = "ui-values")]
    ui_values: bool,
}

#[derive(Args)]
struct ContainerArgs {
    /// The image to run, e.g. `docker.io/library/alpine:3`.
    #[arg(long)]
    image: String,
    /// Container engine binary (default `podman`; pass `docker` to use docker).
    #[arg(long, default_value = "podman")]
    engine: String,
    /// Extra flags inserted between `run` and the image (e.g. `-e FOO=bar`).
    /// Repeatable; `--rm` is always added so nothing leaks.
    #[arg(long = "flag")]
    flags: Vec<String>,
    /// Human label for the job row (defaults to the image).
    #[arg(long)]
    target: Option<String>,
    /// Command + args to run inside the container, after `--`. Empty = the
    /// image's default entrypoint.
    #[arg(last = true)]
    cmd: Vec<String>,
}

#[derive(Args)]
struct JobsArgs {
    /// Filter to one job kind (e.g. `bench_run`, `release_run`, `test_matrix`,
    /// `container`, `coverage`). Empty = every kind.
    #[arg(long)]
    kind: Option<String>,
    /// Only show running (non-terminal) jobs.
    #[arg(long)]
    running: bool,
    /// Max rows to show (newest-first). 0 = no cap.
    #[arg(long, default_value_t = 50)]
    limit: usize,
    /// Emit the rows as a JSON array of `jobs::JobRecord` instead of the table
    /// (CLI ⟺ viz parity: the SAME records the 🧬 nornir tab renders).
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct CoverageArgs {
    /// Repo to measure (must be in nornir.toml). Defaults to the workspace's
    /// primary repo (the workspace name).
    repo: Option<String>,
    /// Skip running `cargo-llvm-cov` and just READ + print the latest persisted
    /// coverage run from the warehouse (the viz-pane / docs source of truth).
    #[arg(long)]
    show: bool,
    /// Show ONLY the ui / gRPC / emitter boundary-coverage view (task #4): every
    /// layer-boundary function + whether it was exercised, with a per-boundary
    /// green/red tally. The CLI twin of the viz pane's boundary view.
    #[arg(long)]
    boundary: bool,
    /// Emit the report as JSON (the maven-style report) instead of the table.
    #[arg(long)]
    json: bool,
    /// Extra args passed verbatim to `cargo llvm-cov` (e.g. `--all-features`).
    #[arg(last = true)]
    extra: Vec<String>,
}

#[derive(Args)]
struct TestRunArgs {
    /// Repo to run the matrix for (must be in nornir.toml).
    repo: String,
    /// Comma-separated aspect list (e.g. `build,unit,clippy,coverage`). Empty =
    /// the default set (build,unit,doctest,clippy,fmt,audit,funnel,funnel-demo,
    /// functional,gated-tests). Aspects: build · unit · doctest · clippy · fmt ·
    /// audit · bench-smoke · coverage · feature-powerset · msrv · examples ·
    /// target-compile · funnel · funnel-demo · functional · gated-tests.
    #[arg(long)]
    aspects: Option<String>,
}

#[derive(Args)]
struct TestAllArgs {
    /// Comma-separated aspect list applied to every repo. Empty = the default set.
    #[arg(long)]
    aspects: Option<String>,
}

#[derive(Args)]
struct TestAffectedArgs {
    /// The workspace member whose tests are asked about.
    repo: String,
    /// The commit under test (a sha or a prefix of ≥ 7); default: the newest
    /// commit the room has read.
    #[arg(long)]
    to: Option<String>,
    /// The commit to compare with; default: the newest commit before `--to`
    /// whose Runa test run was green.
    #[arg(long)]
    from: Option<String>,
}

#[derive(Args)]
struct TestListArgs {
    /// Repo whose discovered tests to list (must be in nornir.toml). Omit to list
    /// every repo configured in nornir.toml.
    repo: Option<String>,
    /// Emit the raw matrix rows as JSON instead of the human table.
    #[arg(long)]
    json: bool,
    /// Show only the heavy (`#[ignore]`'d) tests.
    #[arg(long)]
    heavy_only: bool,
    /// Show only the X (not-run) tests.
    #[arg(long)]
    not_run: bool,
}

#[derive(Args)]
struct TestHistoryArgs {
    /// Repo (or run id) to read. Matched first as a repo name; if no rows carry
    /// it, treated as an exact run_id.
    repo: String,
    /// Emit the raw `test_results` rows as JSON instead of the matrix view.
    #[arg(long)]
    json: bool,
    /// Show only the N most recent runs (0 = all).
    #[arg(long, default_value_t = 0)]
    limit: usize,
}

#[derive(Subcommand)]
enum CacheOp {
    /// Bring up the holger `/cache` + `/sparring` proxy (foreground; Ctrl+C stops).
    /// Registers a `holger-proxy` job so it shows in the nornir job/task list + viz.
    Up {
        /// holger-server binary.
        #[arg(long, default_value = "holger-server")]
        holger_bin: PathBuf,
        /// gRPC control-plane bind addr.
        #[arg(long, default_value = "127.0.0.1:18443")]
        grpc: String,
        /// HTTP sparse bind addr cargo talks to.
        #[arg(long, default_value = "127.0.0.1:18464")]
        http: String,
        /// Persistent data root for the /cache + /sparring znippy archives.
        #[arg(long)]
        data: Option<PathBuf>,
    },
}

#[derive(Subcommand)]
enum ReleaseOp {
    /// Regression time-bisect: for <repo>, find the last green release and the
    /// first red one (the gate's last-good → first-bad boundary) from recorded
    /// release history, and bound the suspect commit range. No rebuild — pure
    /// scan over the warehouse (see `release::regression`).
    Trace {
        repo: String,
        /// Emit JSON instead of the human-readable trace.
        /// (Restrict to a workspace with the global `--workspace`; default = all.)
        #[arg(long)]
        json: bool,
    },
    /// Print the recorded release-op DAG (the `release_events` table) for a
    /// repo or a specific run id. <selector> is matched first as an exact
    /// run_id; if no events carry it, it's treated as a repo name. `--json`
    /// emits the raw rows; otherwise a human topological view (per-run
    /// timeline + `depends_on` edges) is printed. Pure warehouse scan, no
    /// rebuild — lock-tolerant so it coexists with a live server.
    Events {
        /// A run_id (groups one release run) or a repo name (all its runs).
        selector: String,
        /// Emit the raw event rows as JSON instead of the topo view.
        #[arg(long)]
        json: bool,
    },
    /// Compute the impacted-crate set for <repo> by comparing HEAD
    /// against <base> and walking the latest dep-graph snapshot's
    /// reverse edges. Prints each crate on its own line, ready to
    /// feed to `cargo test -p ...` (feature 11).
    ImpactedCrates {
        repo: String,
        #[arg(long, default_value = "main")]
        base: String,
        /// Emit the changed + impacted sets as the uniform CommandOutcome JSON.
        #[arg(long)]
        json: bool,
    },
}

#[derive(Subcommand)]
enum ArchOp {
    /// Print the wiring board of a repo. (There is no `generate`: the board is
    /// a view of the code graph, not a table somebody has to fill first.)
    Svg(ArchGenerateArgs),
    /// Trace one entrypoint (UI pane / gRPC verb / module ident) to the focused
    /// sub-board of everything it reaches downstream (the visual twin of
    /// `knowledge_call_path`). Renders the SVG to stdout or `--out`.
    Trace(ArchTraceArgs),
    /// List historized wiring snapshots for a repo (newest first). `--json`
    /// emits the uniform `CommandOutcome`.
    History(ArchHistoryArgs),
    /// Resolve a symbol / arch-node label to its source `file:line` (the
    /// click-to-code bridge the 🏛 Architecture viz tab uses on a node click),
    /// printing the location. With `--open` it also launches `code --goto`.
    Open(ArchOpenArgs),
}

#[derive(Args)]
struct ArchOpenArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// The symbol / arch-node label to resolve (e.g. `ArchTabState`,
    /// `nornir::arch::trace_from`, or a `Service.verb` gRPC label). Matched
    /// against the code room's nodes by name (exact → leaf segment → substring).
    symbol: String,
    /// Also open the resolved `file:line` in the editor (`code --goto`).
    #[arg(long)]
    open: bool,
    /// Print every candidate, not just the best match.
    #[arg(long)]
    all: bool,
    /// Emit the uniform `CommandOutcome` JSON (the resolved `file:line` as data).
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct ArchHistoryArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Emit the uniform `CommandOutcome` JSON (the snapshot listing as data).
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct ArchTraceArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// The entrypoint to trace from — matched against node labels/ids by
    /// case-insensitive substring (`TestTab`, `Viz.Timeline`, a module bucket).
    entrypoint: String,
    /// Write the rendered SVG to this path instead of stdout.
    #[arg(long)]
    out: Option<PathBuf>,
    /// Emit the uniform `CommandOutcome` JSON (the reachable node/edge set as
    /// data) instead of the rendered SVG.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct ArchGenerateArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Also write the rendered SVG to this path.
    #[arg(long)]
    out: Option<PathBuf>,
    /// Build the graph but skip historizing it into the warehouse.
    #[arg(long)]
    skip_snapshot: bool,
}

#[derive(Subcommand)]
enum DocsOp {
    /// Scaffold `.nornir/` and migrate existing README.md / CHANGELOG.md
    /// into `.nornir/` as source-of-truth.
    Init(RepoArg),
    /// Render every source in `.nornir/` to the repo-root artifact
    /// (atomic write, preserves read-only bit).
    Render(DocsRenderArgs),
    /// CI gate: re-render in memory and fail on any drift vs disk.
    Check(DocsCheckArgs),
    /// Install a tracked pre-commit hook (`.githooks/pre-commit` +
    /// `core.hooksPath`) that re-renders managed markdown on every commit. The
    /// hook is markdown-only and never blocks a commit on failure. Idempotent.
    InstallHooks(RepoArg),
    /// Render the assembled README to PDF / HTML / MD bytes (runs render first).
    #[cfg(feature = "docs-export")]
    Export(DocsExportArgs),
    /// Render the *whole* doc set (every `.nornir/*.md` + non-generated
    /// `<repo>/*.md`) into one PDF / HTML / MD book (runs render first).
    #[cfg(feature = "docs-export")]
    Book(DocsBookArgs),
    /// Assemble a single self-contained markdown "AI context pack": the repo's
    /// architecture + EVERY exported item's signature & doc-comment + the prose
    /// docs, in one file to feed an AI agent as prompt context. Accepts a repo
    /// NAME (from nornir.toml) or a PATH to any repo root.
    #[cfg(feature = "docs-export")]
    AiContext(DocsAiContextArgs),
    /// Publish the rendered `docs/book.pdf` to the repo's Codeberg Pages
    /// `pages` branch (orphan: `.nojekyll` + `index.html` viewer + `book.pdf`),
    /// served at `https://nordisk.codeberg.page/<repo>/`. Renders the book first
    /// if it's missing. The branch is staged locally (pure-gix, orphan commit,
    /// HEAD untouched); the force-push line is printed for the operator/CI to run
    /// (nornir has no in-process push — same as `release`).
    #[cfg(feature = "docs-export")]
    PublishPages(DocsPublishPagesArgs),
    /// List document exports historized in the Iceberg `doc_exports` table.
    History(DocsHistoryArgs),
    /// Build the dedicated docs Tantivy index (separate from the code index)
    /// and historize it into the `docs_index_*` Iceberg tables.
    Index(DocsIndexArgs),
    /// BM25 search over the docs index (restores from iceberg on a cold cache).
    Search(DocsSearchArgs),
}

#[derive(Args)]
struct DocsIndexArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Build the index but skip historizing it into iceberg.
    #[arg(long)]
    skip_snapshot: bool,
}

#[derive(Args)]
struct DocsSearchArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Query string (Tantivy syntax).
    query: String,
    /// Pin a historical git SHA (time-travel). Defaults to the latest snapshot.
    #[arg(long)]
    sha: Option<String>,
    /// Max hits to return.
    #[arg(long, default_value_t = 10)]
    limit: usize,
}

#[derive(Args)]
struct DocsHistoryArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Restrict to one document (e.g. README).
    #[arg(long)]
    doc: Option<String>,
    /// Restrict to one version.
    #[arg(long)]
    version: Option<String>,
    /// Restrict to one format (pdf | html | md).
    #[arg(long)]
    format: Option<String>,
    /// Max rows to show (newest first).
    #[arg(long, default_value_t = 20)]
    limit: usize,
    /// Emit the uniform CommandOutcome as JSON instead of the human table.
    #[arg(long)]
    json: bool,
}

#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsExportArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Output format: pdf | html | md.
    #[arg(long, default_value = "pdf")]
    format: String,
    /// Extra output file path. Always writes `<repo>/docs/README.<ext>`; this
    /// copies the bytes to an additional path too.
    #[arg(long)]
    out: Option<PathBuf>,
}

#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsBookArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Output format: pdf | html | md. (md + pdf are the primary paths;
    /// html is experimental via typst's HTML target.)
    #[arg(long, default_value = "pdf")]
    format: String,
    /// Additional output file path. Always writes `<repo>/docs/book.<ext>`;
    /// `--out` copies the same bytes to an extra path too.
    #[arg(long)]
    out: Option<PathBuf>,
    /// Assemble the FULL doc set (include the non-manual back-matter: reasoning,
    /// history, unsorted, idiot/dummy guides, Tankegångar). Default is the thin
    /// **manual** (README + design + guide + benchmarks only) so the shipped
    /// `docs/book.pdf` stays small — the doctrine + Codeberg-space rule.
    #[arg(long)]
    full: bool,
}

#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsAiContextArgs {
    /// Repo NAME (from nornir.toml) or a PATH to a repo root.
    repo: String,
    /// Additional output path. Always writes `<repo>/docs/ai-context.md` too.
    #[arg(long)]
    out: Option<PathBuf>,
    /// Include private items, not just the exported (`pub`/`pub(crate)`) surface.
    #[arg(long)]
    private: bool,
    /// Skip the appended hand-written prose in the full pack.
    #[arg(long)]
    no_prose: bool,
    /// Generation mode: `development` (default), `ci`, or `release`. CI/release
    /// refuse a dirty working tree unless `--allow-dirty` is passed.
    #[arg(long, default_value = "development")]
    mode: String,
    /// Permit a dirty-tree generation in ci/release mode (records the override).
    #[arg(long)]
    allow_dirty: bool,
}

#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsPublishPagesArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Git remote to force-push the `pages` branch to.
    #[arg(long, default_value = "origin")]
    remote: String,
    /// Re-render the book even if `docs/book.pdf` already exists.
    #[arg(long)]
    render: bool,
}

#[derive(Args)]
struct DocsFileArgs {
    #[command(flatten)]
    repo: RepoArg,
}

#[allow(dead_code)] // kept for the docs_fresh gate to reference if needed
type _DocsFileArgsRef = DocsFileArgs;

#[derive(Subcommand)]
enum GraphOp {
    /// Extract the binary graph from one or more ELF artefacts (executables /
    /// `.so` shared objects), then print the reconstructed symbol + call-edge
    /// facts. Calls `graph::extract::binary::extract_graph`, projects to
    /// `SymbolRow`/`CallEdgeRow`, and reports the counts. `--json` emits the
    /// uniform `CommandOutcome` (the rows as data, tagged `binary_graph`).
    Binary(GraphBinaryArgs),
}

#[derive(Args)]
struct GraphBinaryArgs {
    /// One or more ELF artefacts to parse (executable / `.so`). Passing several
    /// lets the extractor resolve cross-object `DT_NEEDED` + symbol edges.
    #[arg(required = true, num_args = 1..)]
    paths: Vec<PathBuf>,
    /// Emit the uniform `CommandOutcome` JSON (symbol/call-edge rows as data)
    /// instead of the human summary.
    #[arg(long)]
    json: bool,
}

#[derive(Subcommand)]
enum BinOp {
    /// Analyse a compiled artifact end-to-end (size + hardening) and PERSIST the
    /// `binary_facts`/`section_sizes`/`symbol_sizes` rows into Mimir, keyed by the
    /// artifact's `content_sha256`. `--source-root` enables DWARF crate + file/line
    /// attribution. `--json` emits the uniform `CommandOutcome`.
    Analyze(BinAnalyzeArgs),
    /// Per-crate / per-symbol / per-section size report (cargo-bloat / twiggy /
    /// Bloaty view). Reads the artifact directly (no persist).
    Bloat(BinBloatArgs),
    /// Hardening badges (checksec: RELRO/PIE/NX/stack-canary/stripped/debug-info/
    /// FORTIFY). Exit code = count of missing protections (CI gate).
    Checksec(BinChecksecArgs),
    /// Diff two compiled artifacts (base → new): size waterfall (total +
    /// per-section + per-crate deltas), symbol churn (added/removed/grown/shrunk),
    /// and the coarse ABI-surface delta (exported symbols added/removed). Persists
    /// the `artifact_diff` rows into Mimir keyed on the `(base, new)` content-id
    /// pair. The binary twin of the source-side `semver_diffs`.
    Diff(BinDiffArgs),
    /// The **release binary-GATE** (task #65 slice 3): a pass/fail verdict over the
    /// base→new diff. FAILS on a total-size regression beyond a threshold, an ABI
    /// removal (a dropped export), or a known CVE in the shipped bytes. `base` is the
    /// prior *released* artifact (`release_lineage`), `new` the candidate. Emits
    /// structured findings (JSON, agent-callable) and exits nonzero on fail.
    Gate(BinGateArgs),
    /// The **binary-derived SBOM** (task #65 slice 4a): the components actually
    /// present in the shipped bytes (per-crate attribution), CVE-matched via the
    /// OSV/RUSTSEC engine. Persists `sbom_components` rows with `derived_from=binary`
    /// — the twin of the source-side cargo-metadata SBOM.
    Sbom(BinSbomArgs),
}

#[derive(Args)]
struct BinAnalyzeArgs {
    /// The compiled artifact to analyse (ELF executable / `.so` / `.a`).
    artifact: PathBuf,
    /// Workspace root enabling DWARF crate + source file/line attribution
    /// (symbols whose source lives inside it are attributed to their crate).
    #[arg(long)]
    source_root: Option<PathBuf>,
    /// Repo name recorded on the header row (build context; default empty).
    #[arg(long, default_value = "")]
    repo: String,
    /// Git SHA recorded on the header row (build context; default empty).
    #[arg(long, default_value = "")]
    git_sha: String,
    /// Analyse only — do NOT persist into the warehouse.
    #[arg(long)]
    no_save: bool,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct BinBloatArgs {
    /// The compiled artifact to analyse.
    artifact: PathBuf,
    /// Workspace root for DWARF crate + source attribution.
    #[arg(long)]
    source_root: Option<PathBuf>,
    /// Roll-up axis: `crate` (default), `symbol`, or `section`.
    #[arg(long, default_value = "crate")]
    by: String,
    /// Show the top N rows (default 20).
    #[arg(long, default_value_t = 20)]
    top: usize,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct BinChecksecArgs {
    /// The compiled artifact to analyse.
    artifact: PathBuf,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct BinDiffArgs {
    /// The BASE (prior / reference) artifact.
    base: PathBuf,
    /// The NEW (candidate) artifact.
    new: PathBuf,
    /// Workspace root enabling DWARF crate + source attribution on both sides.
    #[arg(long)]
    source_root: Option<PathBuf>,
    /// Repo name recorded on the persisted diff rows (build context; default empty).
    #[arg(long, default_value = "")]
    repo: String,
    /// Release-lineage anchor recorded on the diff rows (default empty).
    #[arg(long, default_value = "")]
    release_id: String,
    /// Show the top N symbol/section/crate rows in each bucket (default 20).
    #[arg(long, default_value_t = 20)]
    top: usize,
    /// Diff only — do NOT persist the `artifact_diff` rows into the warehouse.
    #[arg(long)]
    no_save: bool,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct BinGateArgs {
    /// The BASE (prior *released*) artifact — the `release_lineage` anchor.
    base: PathBuf,
    /// The NEW (candidate) artifact under gate.
    new: PathBuf,
    /// Workspace root enabling DWARF crate + source attribution on both sides.
    #[arg(long)]
    source_root: Option<PathBuf>,
    /// Release-lineage id this verdict anchors to (recorded on the output).
    #[arg(long, default_value = "")]
    release_id: String,
    /// Fail when the total size grows more than this many bytes (absolute budget).
    #[arg(long)]
    max_growth_bytes: Option<i64>,
    /// Fail when the total size grows more than this percent of the base (e.g. 5.0
    /// = 5%). Defaults to 5% when neither size flag is given.
    #[arg(long)]
    max_growth_pct: Option<f64>,
    /// Do NOT fail on an ABI-surface removal (a dropped exported symbol).
    #[arg(long)]
    allow_abi_removed: bool,
    /// Skip the CVE match against the binary-derived SBOM.
    #[arg(long)]
    no_cve: bool,
    /// Do NOT fail on a hardening regression (PIE/RELRO/NX/canary/FORTIFY lost
    /// vs the base release).
    #[arg(long)]
    allow_hardening_regression: bool,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct BinSbomArgs {
    /// The compiled artifact to derive the SBOM from.
    artifact: PathBuf,
    /// Workspace root for DWARF crate attribution.
    #[arg(long)]
    source_root: Option<PathBuf>,
    /// Repo name recorded on the persisted rows (default = artifact file stem).
    #[arg(long, default_value = "")]
    repo: String,
    /// CVE-match the derived components against the OSV/RUSTSEC advisory DB.
    #[arg(long)]
    cve: bool,
    /// Derive only — do NOT persist the `sbom_components` rows into the warehouse.
    #[arg(long)]
    no_save: bool,
    /// Emit the uniform `CommandOutcome` JSON.
    #[arg(long)]
    json: bool,
}

#[derive(Subcommand)]
enum IntrospectOp {
    /// Print the workspace dependency graph (static SVG).
    Depgraph(RepoArg),
    /// Extract every function symbol from a built binary (DWARF) as JSON, and
    /// snapshot the symbols + callgraph into the warehouse (`dwarf_*` tables,
    /// git-SHA keyed) so they're queryable without the binary. `--no-save` to skip.
    Symbols(DwarfSymbolsArgs),
    /// Search extracted symbols by name substring.
    SymbolLookup(SymbolLookupArgs),
    /// List symbols defined in a source file (suffix match).
    DefinedIn(DefinedInArgs),
    /// Extract every inline-call edge from a built binary (DWARF) as JSON.
    Callgraph(SymbolsArgs),
    /// List functions that call <name> (inline edges only).
    Callers(CallQueryArgs),
    /// List functions called by <name> (inline edges only).
    Callees(CallQueryArgs),
    /// Shortest call chain from <from> to <to>, if any.
    PathBetween(PathArgs),
}

#[derive(Args)]
struct CallQueryArgs {
    binary: PathBuf,
    name: String,
}

#[derive(Args)]
struct PathArgs {
    binary: PathBuf,
    from: String,
    to: String,
}

#[derive(Args)]
struct HistoryShowArgs {
    repo: String,
    /// Print the raw JSONL (one BenchRun per line) instead of the formatted view.
    #[arg(long)]
    json: bool,
    /// Show only the N most recent runs (0 = all).
    #[arg(long, default_value_t = 0)]
    limit: usize,
}

#[derive(Args)]
struct SymbolsArgs {
    /// Path to a debug-info-bearing binary (e.g. `target/debug/nornir`).
    binary: PathBuf,
}

#[derive(Args)]
struct DwarfSymbolsArgs {
    /// Path to a debug-info-bearing binary (e.g. `target/debug/nornir`).
    binary: PathBuf,
    /// Don't snapshot the extracted symbols + callgraph into the warehouse
    /// `dwarf_*` tables (default: persist, keyed by git SHA, so nothing computed
    /// is discarded and they're queryable later without the binary).
    #[arg(long = "no-save")]
    no_save: bool,
    /// Member repo to key the snapshot to (its HEAD SHA + label). Defaults to
    /// the whole workspace (`_workspace`). Only meaningful when saving (default).
    #[arg(long)]
    repo: Option<String>,
}

#[derive(Args)]
struct SymbolLookupArgs {
    binary: PathBuf,
    pattern: String,
    #[arg(long, default_value_t = 20)]
    limit: usize,
}

#[derive(Args)]
struct DefinedInArgs {
    binary: PathBuf,
    /// Path suffix to match, e.g. `bench/mod.rs`.
    file: String,
    #[arg(long, default_value_t = 50)]
    limit: usize,
}

#[derive(Subcommand)]
enum WarehouseOp {
    /// Import existing bench_history.jsonl for <repo> into the warehouse.
    ImportJsonl(RepoArg),
    /// Run **SQL** over the warehouse tables (DataFusion over the Iceberg
    /// warehouse, via native `iceberg-datafusion` `TableProvider`s → projection
    /// + filter pushdown to Parquet). Table names = warehouse table names; the
    /// full `SELECT … WHERE / JOIN / GROUP BY / ORDER BY / LIMIT` surface works.
    /// Read-only: tolerates a live `nornir-server` holding the catalog lock by
    /// reading a copied-aside snapshot. Example:
    /// `nornir warehouse query "SELECT repo, COUNT(*) FROM git_heat_facts GROUP BY repo"`.
    Query(WarehouseSqlArgs),
    /// **Discover** what `warehouse query` can SELECT from: with no argument,
    /// list every queryable warehouse table; with a `<table>` argument, describe
    /// that table's columns (name · Arrow type · nullable). Cheap — reads only
    /// the catalog metadata, never a Parquet file, and (like `query`) tolerates
    /// a live `nornir-server` holding the catalog lock. `--json` emits a machine
    /// shape (array of table names, or array of `{name,data_type,nullable}`).
    /// Example: `nornir warehouse tables` then
    /// `nornir warehouse tables git_heat_facts`.
    Tables(WarehouseTablesArgs),
    /// Query bench runs for <repo> (Parquet → JSON, last N if --last). The
    /// convenience typed reader; for arbitrary SQL use `warehouse query`.
    #[command(name = "bench-query")]
    BenchQuery(WarehouseQueryArgs),
    /// **COMPACT** small data files into a few large SORTED ones (per table +
    /// partition), committing a replace snapshot. Far fewer/larger/sorted files
    /// → tighter manifest bounds + fewer Parquet footers per scan. Operate on a
    /// FIXTURE warehouse; never the live server's locked warehouse.
    Compact(WarehouseCompactArgs),
    /// **EXPIRE** old snapshots (keep the last N / last D days) and delete their
    /// now-unreferenced data + manifest files from disk. Reclaims disk. A file is
    /// removed only when no retained snapshot references it.
    Expire(WarehouseExpireArgs),
    /// **RESET** — empty the warehouse: remove BOTH the catalog (`catalog.redb`)
    /// and the data dir (`warehouse/`), so the next open starts from a clean,
    /// zero-row warehouse. This is the "empty → measure → repopulate" step of the
    /// ingest loop, which until now meant hand-removing two paths and getting it
    /// wrong: deleting only `warehouse/` leaves a catalog whose metadata points at
    /// files that no longer exist, and `open()` then fails. Refuses while another
    /// process holds the catalog lock (a live `nornir-server`), and refuses
    /// without `--yes`. Unlike `compact`/`expire` this is NOT reclaim — it is
    /// total data loss, on purpose.
    Reset(WarehouseResetArgs),
}

#[derive(Args)]
struct WarehouseResetArgs {
    /// Reset THIS warehouse root instead of the configured one — e.g. a
    /// workspace's `~/.nornir/workspaces/<name>/builds`.
    #[arg(long)]
    root: Option<PathBuf>,
    /// Actually delete. Without it the command only REPORTS what it would remove
    /// (a dry run) and changes nothing.
    #[arg(long)]
    yes: bool,
}

#[derive(Args)]
struct WarehouseQueryArgs {
    repo: String,
    #[arg(long)]
    machine: Option<String>,
    #[arg(long)]
    last: Option<usize>,
}

#[derive(Args)]
struct WarehouseSqlArgs {
    /// The SQL to run, e.g. `SELECT * FROM bench_runs WHERE repo = 'nornir' LIMIT 5`.
    sql: String,
    /// Output format: `table` (bordered text, default) or `json` (array of row
    /// objects).
    #[arg(long, value_enum, default_value_t = SqlFormat::Table)]
    format: SqlFormat,
}

#[derive(Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
enum SqlFormat {
    Table,
    Json,
}

#[derive(Args)]
struct WarehouseTablesArgs {
    /// A table to describe (its columns + types). Omit to list every table.
    table: Option<String>,
    /// Emit JSON: an array of table names (list mode), or an array of
    /// `{name,data_type,nullable}` column objects (describe mode).
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct WarehouseCompactArgs {
    /// Restrict to one table (default: every sorted/point-lookup table).
    #[arg(long)]
    table: Option<String>,
    /// Restrict to one `repo` partition (default: every partition).
    #[arg(long)]
    repo: Option<String>,
    /// Emit the per-table CompactReport array as JSON instead of a human summary.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct WarehouseExpireArgs {
    /// Restrict to one table (default: every table).
    #[arg(long)]
    table: Option<String>,
    /// Retain the most-recent N snapshots (default 10). The current head is
    /// always retained regardless.
    #[arg(long = "keep-last")]
    keep_last: Option<usize>,
    /// Also retain every snapshot newer than D days.
    #[arg(long = "keep-days")]
    keep_days: Option<u64>,
    /// Emit the per-table ExpireReport array as JSON instead of a human summary.
    #[arg(long)]
    json: bool,
}

#[derive(Subcommand)]
enum IndexOp {
    /// Walk the workspace and bring the index up to date.
    Build(IndexBuildArgs),
    /// BM25 search over the index (optionally filter by --corpus/--repo).
    Search(IndexSearchArgs),
    /// Print per-corpus document counts. `--json` prints the CommandOutcome
    /// (machine); else a human summary.
    Stats {
        #[arg(long)]
        json: bool,
    },
    /// Snapshot the current tantivy index byte-for-byte into iceberg,
    /// keyed by HEAD git SHA. Idempotent per `(repo, sha)`.
    Snapshot(IndexSnapshotArgs),
    /// Restore a tantivy index from iceberg blobs back to disk.
    /// Pass `--sha` to pin a specific release, or omit to pull the
    /// most recent snapshot for `--repo`.
    Restore(IndexRestoreArgs),
    /// Push the locally-built tantivy index *up* to a running nornir-server,
    /// which writes it into its (locked) warehouse — the live-refresh path
    /// for a server's index (it can't open the warehouse a client built).
    /// Requires `NORNIR_SERVER`; embedded mode has no server to push to (use
    /// `index snapshot`). Idempotent server-side per `(repo, sha)`.
    Upload(IndexSnapshotArgs),
}

#[derive(Args)]
struct IndexBuildArgs {
    /// Wipe the index dir first and rebuild from scratch. Use after changing
    /// `[repo.*]` scope or `[storage].local_path` so out-of-scope documents
    /// indexed under the previous config are evicted (incremental builds only
    /// add/update in-scope docs; they never remove stale cross-repo hits).
    #[arg(long)]
    clean: bool,
    /// Don't historize the built index into the warehouse afterwards (default:
    /// snapshot it, so the index reaches the warehouse, not just the local cache).
    #[arg(long = "no-snapshot")]
    no_snapshot: bool,
}

#[derive(Args)]
struct IndexSnapshotArgs {
    /// Repo whose .nornir/cache/index/ to capture. Defaults to the
    /// workspace-wide index at <workspace>/.nornir/cache/index/.
    #[arg(long)]
    repo: Option<String>,
    // Workspace name recorded in the iceberg row comes from the global
    // `--workspace` / `$NORNIR_WORKSPACE` (default `workspace_holger`).
}

#[derive(Args)]
struct IndexRestoreArgs {
    /// Repo whose snapshot to restore.
    #[arg(long)]
    repo: Option<String>,
    /// Specific HEAD sha to restore. Defaults to the latest snapshot
    /// for `--repo`.
    #[arg(long)]
    sha: Option<String>,
    /// Destination directory. Defaults to the same `.nornir/cache/index/`
    /// path the index normally lives at.
    #[arg(long)]
    into: Option<PathBuf>,
}

#[derive(Args)]
struct IndexSearchArgs {
    query: String,
    #[arg(long)]
    corpus: Option<String>,
    #[arg(long)]
    repo: Option<String>,
    #[arg(long, default_value_t = 10)]
    limit: usize,
}

#[cfg(feature = "robot")]
#[derive(Subcommand)]
enum RobotOp {
    /// Run a scenario file against a live app via a WebDriver endpoint.
    Run(RobotRunArgs),
}

#[cfg(feature = "robot")]
#[derive(Args)]
struct RobotRunArgs {
    /// Repo under test (for the report's identity; resolved for its git sha).
    repo: String,
    /// Scenario file (plan-tests-DAG TOML).
    #[arg(long, default_value = "robot/scenarios.toml")]
    scenarios: PathBuf,
    /// App base URL (env BASE_URL also honored).
    #[arg(long, default_value = "http://127.0.0.1:3000")]
    base_url: String,
    /// WebDriver endpoint (geckodriver :4444 / chromedriver :9515).
    #[arg(long, default_value = "http://127.0.0.1:9515")]
    webdriver: String,
    /// Parse + print the DAG execution plan without driving a browser.
    #[arg(long)]
    dry_run: bool,
}

#[cfg(feature = "vector")]
#[derive(Subcommand)]
enum VectorOp {
    /// Embed a repo's Rust sources at HEAD into the warehouse (idempotent;
    /// only changed/new chunks are re-embedded). Needs an embedder feature.
    Index(VectorIndexArgs),
    /// Search by meaning. `--mode lexical|semantic|hybrid`.
    Search(VectorSearchArgs),
    /// Count materialized embeddings + index snapshots in the warehouse.
    Stats,
    /// Preflight the embedder + GPU: report NVIDIA driver / CUDA / cuDNN status,
    /// run a test embed, and advise what to fix if the GPU isn't engaged.
    Doctor,
    /// Benchmark the CPU cosine kernels (G2): scalar vs runtime SIMD vs int8
    /// VNNI on a synthetic corpus. Prints per-kernel timing, throughput, the
    /// SIMD/int8 speedup over scalar, and the int8 quantization error.
    Bench(VectorBenchArgs),
    /// Pin a complete CUDA runtime set into /opt/nornir/cuda by copying the
    /// libs from wherever they exist on this box (pip nvidia wheels, ollama,
    /// a toolkit) — after this the GPU "just works" with no env. Usually sudo.
    SetupCuda(SetupCudaArgs),
    /// Pin a complete AMD ROCm runtime set (+ ROCm-enabled libonnxruntime.so)
    /// into /opt/nornir/rocm by copying from wherever they exist on this box
    /// (/opt/rocm*, $ROCM_PATH, onnxruntime-rocm) — after this the AMD GPU
    /// "just works" with no env. Usually sudo. AMD/Strix-Halo sibling of
    /// setup-cuda.
    SetupRocm(SetupRocmArgs),
    /// Lend this box's GPU embedder to servers without one: serve
    /// `Embedder.Embed` (texts → vectors in this build's model space) on
    /// `--listen`. A server names it with `$NORNIR_EMBED_REMOTE=<host:port>`
    /// and its `embeddings` feed embeds here. Refuses to serve the CPU
    /// unless `--allow-cpu`.
    #[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
    ServeEmbed(ServeEmbedArgs),
    /// Mímir SemanticClones STAGE 2: embed every function's k-hop call-chain
    /// SLICE (forward callee closure, order-independent structural
    /// serialisation) under the synthetic namespace `<repo>::slices` — so
    /// re-implementations SPLIT across N helper functions become findable.
    /// Opt-in; NOT wired into populate (recorded follow-up, stinky mimir-9).
    SlicesIndex(SlicesIndexArgs),
    /// Search the `<repo>::slices` namespace BY EXAMPLE: build <QUERY_FN>'s
    /// own slice, embed its serialisation, k-NN it against every indexed
    /// slice. Hits point at each matching slice's ROOT function definition.
    SlicesSearch(SlicesSearchArgs),
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct SlicesIndexArgs {
    /// Repo whose knowledge view (the code room's nodes + CALLS) gets sliced.
    repo: String,
    /// Forward hop count of each slice (part of the namespace identity — a
    /// different k forces a re-index).
    #[arg(long, default_value_t = nornir::mimir_slices::DEFAULT_K)]
    k: usize,
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct SlicesSearchArgs {
    /// Repo whose slice namespace to search.
    repo: String,
    /// The example: a function ident in <REPO> (fqn, suffix, or bare name —
    /// resolved by longest matching suffix).
    query_fn: String,
    /// Hop count for the QUERY slice (should match the indexed k).
    #[arg(long, default_value_t = nornir::mimir_slices::DEFAULT_K)]
    k: usize,
    #[arg(long, default_value_t = 10)]
    limit: usize,
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct SetupCudaArgs {
    /// Target dir (a built-in CUDA search dir).
    #[arg(long, default_value = "/opt/nornir/cuda")]
    target: PathBuf,
}

#[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
#[derive(Args)]
struct ServeEmbedArgs {
    /// Address to listen on.
    #[arg(long, default_value = "0.0.0.0:7932")]
    listen: String,
    /// The bearer callers must send (first line of the file). Without it the
    /// worker is open to anyone who can reach the port.
    #[arg(long)]
    token_file: Option<PathBuf>,
    /// Serve the CPU when no GPU backend is here (a worker is for a GPU).
    #[arg(long)]
    allow_cpu: bool,
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct SetupRocmArgs {
    /// Target dir (a built-in ROCm search dir).
    #[arg(long, default_value = "/opt/nornir/rocm")]
    target: PathBuf,
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorBenchArgs {
    /// Number of vectors in the synthetic corpus.
    #[arg(long, default_value_t = 100_000)]
    n: usize,
    /// Vector dimensionality (default = jina-v2 768).
    #[arg(long, default_value_t = 768)]
    dim: usize,
    /// Timed iterations to average over.
    #[arg(long, default_value_t = 20)]
    iters: usize,
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorIndexArgs {
    /// Repo to vectorize (must be declared in nornir.toml). Omit with `--all`.
    repo: Option<String>,
    /// Embed EVERY workspace member in ONE flattened, GPU-batched pass — the
    /// monitor's populate embed path (`index_repos_batched`): a single model load +
    /// CPU chunking flattened across all members in one gatling pool + chunks
    /// de-duplicated across repos into full GPU batches. The fast way to embed a
    /// whole workspace, vs N serial `vector index <repo>` invocations (N model
    /// loads, one thin GPU batch per repo, 31/32 cores idle between members).
    #[arg(long, conflicts_with = "repo")]
    all: bool,
    /// Memory ceiling for the embed, e.g. `2G`, `1536M`, `768MiB`; a bare
    /// number is MiB. Env twin: `NORNIR_EMBED_MAX_MEMORY_MB`.
    ///
    /// `vector index` was OOM-killed at 7.5 GB RSS on a loaded box
    /// (INVENTORY-semantic-twins-2026-09-10 §2): the CPU embedder runs one
    /// batch per core CONCURRENTLY and every in-flight forward owns its own
    /// `[B, L]` activations, so peak RSS is `baseline + workers × batch tokens ×
    /// ~40 KiB`, and `taskset` was the only lever. This fits the fan-out under
    /// the ceiling (workers first, then batch width) and prints the plan with
    /// its estimated peak on stderr. The estimate is MEASURED from two points on
    /// t14s, not derived — compare it with `ps` and correct `vector::memory` if
    /// they disagree. Without it the plan is unbounded (the old behaviour).
    #[arg(long = "max-memory", value_parser = parse_max_memory)]
    max_memory: Option<u64>,
}

/// `--max-memory` value parser — clap wants `Result<T, String>`.
#[cfg(feature = "vector")]
fn parse_max_memory(s: &str) -> Result<u64, String> {
    nornir::vector::memory::parse_memory_size(s).and_then(|b| {
        if b == 0 {
            Err("a zero ceiling would refuse every batch".to_string())
        } else {
            Ok(b)
        }
    })
}

#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorSearchArgs {
    query: String,
    /// Repo to search. Required for semantic/hybrid (embeddings are per-repo);
    /// optional for lexical (defaults to the workspace index).
    #[arg(long)]
    repo: Option<String>,
    /// Pin a historical git SHA (time-travel). Defaults to the latest snapshot.
    #[arg(long)]
    sha: Option<String>,
    /// lexical (BM25) | semantic (vector) | hybrid (both, RRF-merged).
    #[arg(long, default_value = "semantic")]
    mode: String,
    #[arg(long, default_value_t = 10)]
    limit: usize,
}

#[derive(Args)]
struct RepoArg {
    repo: String,
}

/// `nornir build` — drive a declared build variant (task #19).
#[derive(Args)]
struct BuildArgs {
    /// The variant to build (`server`, `agent`, `gui`, …). Required unless
    /// `--list-variants` / `--json` / `--image` / `--vm` is given.
    #[arg(long)]
    variant: Option<String>,
    /// Build a declared **container image** target (`[[build.container]]`) by name
    /// and seal provenance over the produced image digest. Needs `--features
    /// build-image` for the real bollard build; otherwise `--dry-run` previews it.
    #[arg(long, conflicts_with = "variant")]
    image: Option<String>,
    /// Build/seal a declared **VM appliance** target (`[[build.vm]]`) by name —
    /// seals provenance over the produced kernel+rootfs/disk hashes (the artifacts
    /// must already be on disk; the heavy VM build is the app's own).
    #[arg(long, conflicts_with = "variant")]
    vm: Option<String>,
    /// Master a declared **disk-installer ISO** target (`[[build.installer]]`) by
    /// name — drives the installer cell's `installer_iso()` through the pure-Rust
    /// ISO-9660/El-Torito masterer (zero-shell) and seals provenance over the
    /// produced `.iso`. `--dry-run` resolves + previews the ISO plan without
    /// mastering. NB a live-bootable installer needs a namable PID-1 payload and a
    /// kernel provided out-of-band (e.g. korp's `$KORP_KERNEL`); without them this
    /// stages the ISO but cannot boot it.
    #[arg(long, conflicts_with = "variant")]
    installer: Option<String>,
    /// List the repo's declared variants (name → materialized cargo argv) and exit.
    #[arg(long)]
    list_variants: bool,
    /// Emit the data-driven build-thing SURFACE (option axes, per-variant
    /// validity, materialized command, Build action) as `state_json` and exit —
    /// the model a viz tab / the MCP tool renders.
    #[arg(long)]
    json: bool,
    /// Print the cargo invocation without running it (still validates the variant).
    #[arg(long)]
    dry_run: bool,
    /// After a successful build, capture a build-provenance attestation (variant,
    /// inputs, toolchain, artifact hashes) and write it as JSON.
    #[arg(long)]
    attest: bool,
    /// znippy-SEAL the attestation with a dev signer (implies `--attest`). Needs
    /// the `build-provenance` feature; otherwise the attestation stays unsealed.
    #[arg(long)]
    seal: bool,
    /// Where to write the attestation JSON (default:
    /// `<repo>/.nornir/build-provenance/<variant>-<timestamp>.json`).
    #[arg(long)]
    out: Option<std::path::PathBuf>,
    /// Appointed repo whose variants to use; defaults to the sole configured
    /// repo (or the workspace root's own `.nornir/build-variants.toml`).
    #[arg(long)]
    repo: Option<String>,
    /// **Build the WHOLE suite** — walk every `[repo.<name>]` in the loaded
    /// workspace roster (`workspace_nordisk/release/nornir.toml`), discover each
    /// repo's `.nornir/build-variants.toml`, and build every declared CARGO
    /// variant (Exe / Lib / Wasm / Service) with its exact feature set (thin vs
    /// fat, prod-strips-`demo`, …), landing each at its canonical `dist/<name>`
    /// path. Container / VM / ISO targets are SKIPPED (they need an engine — build
    /// those per-repo with `--image`/`--vm`/`--installer`). Combine with
    /// `--dry-run` to enumerate the whole-suite cargo plan without compiling, or
    /// `--role <server|ui|cli|…>` to build just one role across the suite.
    #[arg(long, conflicts_with_all = ["variant", "image", "vm", "installer", "repo"])]
    all: bool,
    /// With `--all` (or a single `--repo`): build ONLY the variants whose declared
    /// `role` matches (`server`, `client`, `library`, `tool`). Omit to build every
    /// role.
    #[arg(long)]
    role: Option<String>,
    /// Skip landing a named copy of each built binary into `<repo>/dist/<name>`
    /// (the canonical-artifact scheme). By default a successful Exe/Service build
    /// is hardlinked (else copied) into `dist/` under its variant `name`.
    #[arg(long)]
    no_dist: bool,
    /// The **delivery (utgåva)** the variant belongs to — `--suite ORIGINAL` in
    /// every nordisk member, `--suite MVP` in holger. Required only once a repo
    /// declares more than one; an unknown name is a hard error that lists the real
    /// ones (`nornir_build_thing::suite_cli::resolve`, the SAME resolver
    /// `cargo xtask build` uses — one reader of the declaration, two front doors).
    #[arg(long)]
    suite: Option<String>,
}

#[derive(Args)]
struct DocsCheckArgs {
    #[command(flatten)]
    repo: RepoArg,
    /// Emit the uniform CommandOutcome as JSON instead of the human summary.
    #[arg(long)]
    json: bool,
}

#[derive(Args)]
struct DocsRenderArgs {
    repo: String,
    /// Render the managed **markdown only** (README.md / CHANGELOG.md): never
    /// (re)generate the non-deterministic depgraph SVG or the book PDF. The
    /// committed assets are reused for image links. This is what the pre-commit
    /// hook uses so an ordinary commit stays cheap and deterministic.
    /// Alias: `--no-assets`.
    #[arg(long, alias = "no-assets")]
    markdown_only: bool,

    /// Render the bench sections from a `BenchRun` **JSON file on disk** instead
    /// of the warehouse.
    ///
    /// This is the seam for a run that never reached the warehouse. `nornir bench
    /// run --server` executes the bench server-side, where `NORNIR_MACHINE` /
    /// `NORNIR_BENCH_ONLY` / `…SKIP` / `…PREVIEW` do not exist, so its rows stamp
    /// as the literal machine `"server"` — useless for a host-stamped table. The
    /// working alternative is to run each repo's `nornir-bench` example directly
    /// and keep the emitted `BenchRun` envelope as a file; this flag renders the
    /// documentation from that file with the SAME renderers, so the tables stay
    /// generated rather than transcribed by hand.
    ///
    /// The file is one `BenchRun` JSON object (what the example prints). Warehouse
    /// history is still used for `bench_history` sections; only the *latest run*
    /// is replaced.
    #[arg(long, value_name = "PATH")]
    from_json: Option<PathBuf>,
}

/// Every LEAF subcommand path in nornir's clap tree, space-joined — e.g.
/// `"bench history"`, `"workspace ls"`, `"mimir deps-of"`. RECURSIVE, unlike the
/// old `get_subcommands().map(get_name)` which saw only the ~25 top-level roots
/// and left every nested subcommand invisible to the autonom completeness gate.
/// A command with no subcommands of its own IS a leaf (the actually-runnable
/// verb). clap's auto-injected `help` subcommand is skipped. This is the CLI twin
/// of `viz::surfaces::viz_surfaces()` — mechanical "find them all", never a hand
/// list — so the gate's `Gap = Surface − Covered` covers EVERY command.
fn clap_command_paths() -> Vec<String> {
    fn walk(cmd: &clap::Command, prefix: &str, out: &mut Vec<String>) {
        let children: Vec<&clap::Command> = cmd
            .get_subcommands()
            .filter(|c| c.get_name() != "help")
            .collect();
        if children.is_empty() {
            if !prefix.is_empty() {
                out.push(prefix.to_string());
            }
            return;
        }
        for sub in children {
            let path = if prefix.is_empty() {
                sub.get_name().to_string()
            } else {
                format!("{prefix} {}", sub.get_name())
            };
            walk(sub, &path, out);
        }
    }
    let mut out = Vec::new();
    walk(&Cli::command(), "", &mut out);
    out.sort();
    out.dedup();
    out
}

/// `nornir commands` as a [`CommandOutcome`] — the reference AUT9 migration. `data`
/// is the recursive leaf-command list (the autopilot's surface source / CLI twin of
/// MCP `tools/list`); `human` is the newline list. Sane by construction (a non-empty
/// command list), so the autopilot's sane-data check passes on it.
/// `nornir fleet [ROOT]` — the fleet sweep.
///
/// ONE reader: the rows, the table and the exit code all come from
/// [`nornir_git::fleet`], so a human's table and a script's exit code can never
/// disagree about what "clean" means. Non-zero exit when any checkout is not
/// FIT — the verb is a guard, not a report.
fn run_fleet(root: &str, json: bool) -> anyhow::Result<()> {
    let rows = nornir_git::fleet::survey_root(std::path::Path::new(root))?;
    if json {
        println!("{}", serde_json::to_string_pretty(&rows)?);
    } else {
        print!("{}", nornir_git::fleet::report(&rows));
    }
    if rows.iter().any(|r| !r.fit()) {
        std::process::exit(1);
    }
    Ok(())
}

fn commands_outcome() -> nornir::cli_outcome::CommandOutcome {
    let paths = clap_command_paths();
    let human = paths.join("\n");
    nornir::cli_outcome::CommandOutcome::ok("commands", serde_json::json!(paths), human)
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    // Heavy is OFF by default everywhere; the global `--heavy` opts IN by setting
    // the env `test_matrix::heavy_enabled()` reads. `--skip-heavy` is a no-op kept
    // for back-compat (off is already the default). SAFETY: single-threaded here,
    // before any worker/child spawns.
    if cli.heavy {
        unsafe { std::env::set_var("NORNIR_RUN_HEAVY", "1") };
    }
    if cli.skip_heavy {
        unsafe { std::env::set_var("NORNIR_SKIP_HEAVY", "1") };
    }
    // Global thin-client flags → env, so every `server_target()` / `client_workspace()`
    // path lights up uniformly for ALL subcommands (not just viz/serve). A bare
    // `host:port` gets `http://`; the token falls back to a cached OIDC token (from
    // `nornir login`), then the user-readable `<home>/.nornir/token`. SAFETY:
    // single-threaded, before any worker spawns.
    if let Some(s) = &cli.server {
        let url = if s.starts_with("http://") || s.starts_with("https://") {
            s.clone()
        } else {
            format!("http://{s}")
        };
        unsafe { std::env::set_var("NORNIR_SERVER", &url) };
    }
    // ★ THE LADDER IS WALKED FOR THE **RESOLVED** TARGET, NOT FOR THE FLAG.
    //
    // This used to read `else if cli.server.is_some() && …`, so the OIDC and token-file
    // rungs were walked for `nornir jobs --server <url>` and NOT for
    // `NORNIR_SERVER=<url> nornir jobs` — even though `server_target()` honours both and
    // the block right above has already normalised the flag INTO `$NORNIR_SERVER`.
    // Measured live 2026-08-27: the env form answered `Unauthenticated`, the flag form
    // returned rows. Two ways to name the same server, one of them unauthenticated, and
    // the client reported the server's refusal instead of its own missing walk.
    //
    // Reading `$NORNIR_SERVER` here is what makes the two forms ONE fact: whichever way
    // the target arrived, it is in that variable by now.
    //
    // The walk itself is `nornir::client_bearer` — the SHARED
    // `nornir_service::resolve_token_whence` ladder plus nornir's OIDC rung inserted
    // between the env var and the static file (LAW 5). What was here was a hand-rolled
    // `read_to_string` + `trim` + `is_empty` copy of that helper's bottom rung, which is
    // how the gate above could drift away from the resolver in the first place.
    // SAFETY: single-threaded, before any worker spawns.
    let resolved_target = std::env::var("NORNIR_SERVER").ok();
    if let Some((tok, _whence)) = nornir::client_bearer(
        cli.token.as_deref(),
        resolved_target.as_deref(),
        Some(&nornir::config::nornir_home().join("token")),
        nornir::cached_oidc_bearer().as_deref(),
    ) {
        unsafe { std::env::set_var("NORNIR_SERVER_TOKEN", tok) };
    }
    if let Some(w) = &cli.workspace {
        unsafe { std::env::set_var("NORNIR_WORKSPACE", w) };
    }
    // Launch/install verbs don't need a discovered workspace config.
    match &cli.cmd {
        Cmd::Serve(a) => return run_serve(a),
        Cmd::Viz(a) => return run_viz(a, cli.config.as_deref()),
        Cmd::Install { op } => return run_install(op),
        Cmd::Key { op } => return run_key(op),
        Cmd::Workspace { op } => return run_workspace(op),
        Cmd::Security { cmd } => return run_security(cmd),
        // A filesystem sweep of checkouts — no workspace config, no server, no
        // network. Sits with the other pre-config verbs precisely because the
        // question it answers is often asked BEFORE anything is configured.
        Cmd::Fleet { root, json } => return run_fleet(root, *json),
        // Pure environment query — no config discovery needed.
        Cmd::Root => {
            println!("{}", nornir::config::nornir_home().display());
            return Ok(());
        }
        // CLI self-description — no config needed. The reference `CommandOutcome`
        // migration (AUT9): the autopilot's surface source, dogfooding the contract.
        Cmd::Commands { json } => {
            commands_outcome().print(*json);
            return Ok(());
        }
        // Binary graph extraction reads ELF files off the local filesystem —
        // no workspace config, no server round-trip (like `introspect depgraph`
        // / `callgraph-llvm`, this parses artefacts directly).
        Cmd::Graph { op } => return run_graph(op),
        // Binary analysis parses a compiled artifact off the local filesystem, so
        // unlike its workspace-aware neighbours it must still run when NO config
        // is discoverable (`nornir bin analyze /bin/true` from anywhere). Hence a
        // BEST-EFFORT load here rather than the mandatory `load_config` below —
        // it only ever feeds [`bin_warehouse_root`], which picks the warehouse
        // from the global `--workspace` first, a discovered `workspace_<name>`
        // config second, and the machine default otherwise.
        Cmd::Bin { op } => return run_bin(op, load_config(cli.config.as_deref()).ok().as_ref()),
        // The twin scan reads source off the local filesystem and, for `deps`,
        // refuses on the device before it reads anything at all. Neither half
        // needs a workspace config — and the deps REFUSAL in particular must be
        // reachable on a box where no config is discoverable, because "no GPU"
        // is the answer whatever the workspace says.
        #[cfg(feature = "vector")]
        Cmd::Twinscan { scope, roots, json } => return run_twinscan(scope, roots, *json),
        // The embed worker holds no warehouse: no workspace config either.
        #[cfg(all(feature = "server", feature = "embed-tract", feature = "embed-ort"))]
        Cmd::Vector { op: VectorOp::ServeEmbed(a) } => return run_vector_serve_embed(a),
        // UI-movement saga viewer/replay reads a saga JSON off the local
        // filesystem (or the built-in synthetic saga) — no workspace config.
        Cmd::Saga { op } => return run_saga(op),
        // OIDC device-code SSO — self-contained (talks to the IdP, not a nornir
        // server / workspace). No config discovery needed.
        Cmd::Login => std::process::exit(run_login_cli()),
        Cmd::Whoami => std::process::exit(run_whoami_cli()),
        Cmd::Logout => std::process::exit(run_logout_cli()),
        _ => {}
    }
    // Thin-client commands target a workspace. If none is resolvable (no
    // --workspace, no $NORNIR_WORKSPACE, not in a workspace_<name>/ dir), pick one
    // interactively (TTY) or error with the served list — never send a bogus name.
    #[cfg(feature = "wire-client")]
    {
        if let Some(server) = server_target() {
            let have_ws = std::env::var("NORNIR_WORKSPACE")
                .ok()
                .filter(|s| !s.is_empty())
                .is_some()
                || std::env::current_dir()
                    .ok()
                    .and_then(|d| cwd_workspace_name(&d))
                    .is_some();
            if !have_ws {
                let chosen = pick_workspace_or_explain(&server)?;
                // SAFETY: single-threaded, before any worker spawns / first client call.
                unsafe { std::env::set_var("NORNIR_WORKSPACE", &chosen) };
            }
        }
    }
    let loaded = load_config(cli.config.as_deref())?;
    match cli.cmd {
        Cmd::Serve(_)
        | Cmd::Viz(_)
        | Cmd::Install { .. }
        | Cmd::Key { .. }
        | Cmd::Workspace { .. }
        | Cmd::Security { .. }
        | Cmd::Graph { .. }
        | Cmd::Bin { .. }
        | Cmd::Saga { .. }
        | Cmd::Fleet { .. }
        | Cmd::Root => {
            unreachable!("handled before load_config")
        }
        // A separate arm rather than another `|` in the chain above: the variant
        // itself is `#[cfg]`-gated, and a cfg cannot be attached to one leg of an
        // or-pattern.
        #[cfg(feature = "vector")]
        Cmd::Twinscan { .. } => unreachable!("handled before load_config"),
        Cmd::Repos => {
            // Client mode lists the *server's* repos; embedded lists local config.
            if let Some(server) = server_target() {
                for name in list_repos_remote(&server)? {
                    println!("{name}");
                }
            } else {
                for name in loaded.nornir.repo.keys() {
                    println!("{name}");
                }
            }
        }
        Cmd::Guard { op } => run_guard(op, &loaded)?,
        Cmd::Bench { op } => run_bench(op, &loaded)?,
        Cmd::Release { op } => run_release(op, &loaded)?,
        Cmd::Cache { op } => run_cache(op, &loaded)?,
        Cmd::Test { op } => run_test(op, &loaded)?,
        Cmd::Coverage(a) => run_coverage(a, &loaded)?,
        Cmd::Docs { op } => run_docs(op, &loaded)?,
        Cmd::Introspect { op } => run_introspect(op, &loaded)?,
        Cmd::Warehouse { op } => run_warehouse(op, &loaded)?,
        Cmd::Index { op } => run_index(op, &loaded)?,
        #[cfg(feature = "vector")]
        Cmd::Vector { op } => run_vector(op, &loaded)?,
        #[cfg(feature = "robot")]
        Cmd::Robot { op } => run_robot(op, &loaded)?,
        Cmd::Knowledge { op } => run_knowledge(op, &loaded)?,
        Cmd::Map(a) => run_map(&a.repo, &loaded)?,
        Cmd::Arch { op } => run_arch(op, &loaded)?,
        Cmd::Airgap { op } => run_airgap(op, &loaded)?,
        Cmd::Funnel { op } => run_funnel(op, &loaded)?,
        Cmd::Mimir { op } => run_mimir(op, &loaded)?,
        Cmd::Diagram { op } => run_diagram(op, &loaded)?,
        Cmd::Bakeoff { op } => run_bakeoff_cmd(op, &loaded)?,
        Cmd::Repo { op } => run_repo(op, &loaded)?,
        Cmd::Coderoom { op } => run_coderoom(op, &loaded)?,
        Cmd::Build(a) => run_build(a, &loaded)?,
        Cmd::Jobs(a) => run_jobs(a, &loaded)?,
        Cmd::Container(a) => run_container_cmd(a, &loaded)?,
        // Handled in the early, config-free dispatch above (returns before here).
        Cmd::Commands { .. } => {
            unreachable!("`commands` is dispatched before workspace resolution")
        }
        Cmd::Login | Cmd::Whoami | Cmd::Logout => {
            unreachable!(
                "login/whoami/logout are dispatched (and exit) before workspace resolution"
            )
        }
    }
    Ok(())
}

/// `nornir bakeoff …` (H5) — run a prompt across a set of local models and
/// persist one `agent_model_runs` row per model, then read the leaderboard
/// back. The model-call layer is mockable (`--mock`) so this never needs a
/// running LLM; the viz Leaderboard pane ranks the same rows.
fn run_bakeoff_cmd(op: BakeoffOp, loaded: &Loaded) -> Result<()> {
    use nornir::warehouse::agent_model_runs::{
        BakeoffSelector, MockCaller, ModelAnswer, ModelCaller, OllamaCaller, default_agents,
        default_models, demo_matrix_rows, new_run_id, query_agent_model_runs, render_leaderboard,
        render_matrix, rows_to_json, run_bakeoff_matrix, score_rows_with_judge, short_run,
    };
    use nornir::warehouse::codegen_judge::{CodegenJudge, CodegenSpec};
    use nornir::warehouse::iceberg::IcebergWarehouse;

    match op {
        BakeoffOp::Run(a) => {
            let agents = if a.agents.is_empty() {
                default_agents()
            } else {
                a.agents.clone()
            };
            let models = if a.models.is_empty() {
                default_models()
            } else {
                a.models.clone()
            };
            if models.is_empty() {
                bail!("no models to run — pass --models a,b,c or configure the registry");
            }
            let job = start_job(
                loaded,
                nornir::jobs::kind::BAKEOFF,
                workspace_name(loaded).as_str(),
                serde_json::json!({ "agents": agents.len(), "models": models.len() }),
            );
            let run_id = new_run_id();
            let ts_micros = chrono::Utc::now().timestamp_micros();

            // The bake-off backend, chosen in priority order:
            //   1. `--gen <spec>` / `$NORNIR_GEN_BACKEND` → the EPIC #39
            //      generative-LLM factory (candle / mistralrs / onnx / ollama /
            //      mock), driving the SAME selected model across every cell.
            //   2. `--mock` → a canned offline matrix (deterministic economics).
            //   3. otherwise → the prior-art ollama HTTP caller.
            let gen_spec = a
                .r#gen
                .clone()
                .or_else(nornir::warehouse::generator::spec_from_env);
            let caller: Box<dyn ModelCaller> = if let Some(spec) = gen_spec {
                use nornir::warehouse::generator::{BoxGeneratorCaller, generator};
                let g = generator(&spec)?;
                println!(
                    "▶ generator backend `{}` (available: {})",
                    g.id(),
                    g.available()
                );
                Box::new(BoxGeneratorCaller::new(g))
            } else if a.mock {
                let mut mock = MockCaller::new();
                for (ai, agent) in agents.iter().enumerate() {
                    let hosted = agent != "local-llm" && agent != "-" && agent != "local";
                    for (mi, m) in models.iter().enumerate() {
                        let tout = 16 + (mi as i64) + (ai as i64) * 2;
                        let lat = 100.0 + (mi as f64) * 50.0 + (ai as f64) * 30.0;
                        mock = mock.with_cell(
                            agent.clone(),
                            m.clone(),
                            ModelAnswer {
                                output: format!("[mock answer from {agent}/{m}]"),
                                latency_ms: lat,
                                tokens_in: 12,
                                tokens_out: tout,
                                tokens_per_s: tout as f64 / (lat / 1000.0),
                                score: (1.0 - (mi as f64) * 0.1 + if hosted { 0.05 } else { 0.0 })
                                    .min(1.0),
                                cost_usd: if hosted {
                                    0.01 * (mi as f64 + 1.0)
                                } else {
                                    0.0
                                },
                                mcp_tool_calls: if hosted { 3 + mi as i64 } else { 0 },
                            },
                        );
                    }
                }
                Box::new(mock)
            } else {
                Box::new(OllamaCaller::new(a.host.clone()))
            };

            println!(
                "▶ bake-off `{}` matrix: {} agent(s) [{}] × {} model(s) [{}] = {} cells",
                a.task_id,
                agents.len(),
                agents.join(", "),
                models.len(),
                models.join(", "),
                agents.len() * models.len(),
            );
            let mut rows = run_bakeoff_matrix(
                caller.as_ref(),
                &run_id,
                ts_micros,
                &a.task_id,
                &a.task,
                &agents,
                &models,
            );

            // Optional compile-and-test scoring: if a codegen spec was given,
            // score each cell's answer by building+testing it, replacing the
            // (otherwise 0.0) quality score with the verdict.
            if let Some(crate_dir) = a.judge_crate.clone() {
                if a.judge_patch && a.judge_file.is_some() {
                    bail!("--judge-patch and --judge-file are mutually exclusive");
                }
                let spec = if a.judge_patch {
                    CodegenSpec::apply_patch(crate_dir)
                } else {
                    let rel = a.judge_file.clone().ok_or_else(|| {
                        anyhow::anyhow!(
                            "--judge-crate needs --judge-file <rel-path> (or --judge-patch)"
                        )
                    })?;
                    CodegenSpec::write_file(crate_dir, rel)
                };
                let spec = match a.judge_cmd.clone() {
                    Some(cmd) => spec
                        .with_accept_cmd(cmd.split_whitespace().map(|s| s.to_string()).collect()),
                    None => spec,
                };
                println!(
                    "⚖ judging {} cell(s) by compile-and-test against `{}` ({})",
                    rows.iter().filter(|r| r.ok).count(),
                    spec.crate_dir.display(),
                    spec.accept_cmd.join(" "),
                );
                let judge = CodegenJudge::new(spec);
                let reports = score_rows_with_judge(&mut rows, &judge);
                for rep in &reports {
                    let detail = match (&rep.verdict, &rep.error) {
                        (Some(v), _) => v.message.clone(),
                        (None, Some(e)) => format!("judge error: {e}"),
                        (None, None) => String::new(),
                    };
                    println!(
                        "  {}/{}: score {:.3} — {}",
                        rep.agent, rep.model, rep.score, detail
                    );
                }
            }

            persist_bakeoff(&loaded, &rows)?;
            job.finish(
                serde_json::json!({ "rows": rows.len() }),
                &format!("agent_model_runs:{run_id}"),
            );

            println!(
                "✓ recorded run {} ({} rows)\n",
                short_run(&run_id),
                rows.len()
            );
            if a.json {
                println!("{}", rows_to_json(&rows));
            } else if a.matrix {
                print!("{}", render_matrix(&rows));
            } else {
                print!("{}", render_leaderboard(&rows));
            }
        }
        BakeoffOp::Demo => {
            let run_id = new_run_id();
            let ts_micros = chrono::Utc::now().timestamp_micros();
            let rows = demo_matrix_rows(&run_id, ts_micros);
            persist_bakeoff(&loaded, &rows)?;
            println!(
                "✓ seeded demo bake-off matrix: run {} ({} cells, 3 agents × 3 models)\n",
                short_run(&run_id),
                rows.len()
            );
            print!("{}", render_matrix(&rows));
            println!(
                "\nopen the 🏆 Leaderboard tab in `nornir viz`, or: nornir bakeoff leaderboard --matrix"
            );
        }
        BakeoffOp::Leaderboard(a) => {
            // Source of truth is the warehouse `agent_model_runs` table. Server
            // mode: pull every row over Viz.BakeoffResults (the server holds the
            // redb lock — a thin client can't open it) and apply the SAME pure
            // selection in-memory, so reading matches where `bakeoff demo`/`run`
            // submitted. Embedded mode: open the local warehouse read-only.
            // (Fixes: writes went to oden but `leaderboard` read a stale LOCAL
            // warehouse → the old m1/m2 run.)
            #[cfg(feature = "wire-client")]
            let all_rows: Option<
                Vec<nornir::warehouse::agent_model_runs::AgentModelRunRow>,
            > = match server_target() {
                Some(server) => Some(bakeoff_results_remote(&server)?),
                None => None,
            };
            #[cfg(not(feature = "wire-client"))]
            let all_rows: Option<
                Vec<nornir::warehouse::agent_model_runs::AgentModelRunRow>,
            > = None;

            let rows = if let Some(all) = all_rows {
                // Server mode: select over the fetched rows (pure, identical to the
                // embedded `BakeoffSelector` scoping below).
                select_bakeoff_rows(all, &a.reference)
            } else {
                // Embedded: read-only + lock-tolerant (coexist with a live server),
                // like `release events` / `test history`.
                let warehouse_root = loaded.warehouse_root();
                let wh = IcebergWarehouse::open_read_only(&warehouse_root).with_context(|| {
                    format!("open iceberg warehouse at {}", warehouse_root.display())
                })?;
                if a.reference.is_empty() {
                    let all = wh.block_on(query_agent_model_runs(&wh, &BakeoffSelector::All))?;
                    match all
                        .iter()
                        .max_by_key(|r| r.ts_micros)
                        .map(|r| r.run_id.clone())
                    {
                        Some(run_id) => all.into_iter().filter(|r| r.run_id == run_id).collect(),
                        None => Vec::new(),
                    }
                } else {
                    let by_run = wh.block_on(query_agent_model_runs(
                        &wh,
                        &BakeoffSelector::Run(a.reference.clone()),
                    ))?;
                    if by_run.is_empty() {
                        wh.block_on(query_agent_model_runs(
                            &wh,
                            &BakeoffSelector::Model(a.reference.clone()),
                        ))?
                    } else {
                        by_run
                    }
                }
            };

            // AUT9: shape the already-fetched rows through the shared
            // `leaderboard_outcome` builder so the fat path and the thin
            // (Viz.BakeoffResults) path emit the SAME {command, ok, data, human}.
            nornir::warehouse::agent_model_runs::leaderboard_outcome(&a.reference, &rows, a.matrix)
                .print(a.json);
        }
    }
    Ok(())
}

/// Pure leaderboard selection over a fetched row set (server mode), mirroring the
/// embedded `BakeoffSelector` precedence: empty `reference` → only the newest run
/// (by `ts_micros`); else try `reference` as a `run_id`, and if no row matches,

The file goes on; the console shows all of it.