#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/libexec/ai-tools/ai-tools-admin
# Host administration for the ai-tools sandbox. A root helper (run via sudo), not an ai-tools CLI verb: it edits host
# config (the OPERATORS list, the ai-ops group, the sandbox account's linger; the loaded optional SELinux policy groups)
# while the ai-tools CLI is unprivileged and refuses to run as root.
#
#   sudo ai-tools-admin operators                          # list (the zero-argument default)
#   sudo ai-tools-admin operators add [user]               # default: $SUDO_USER
#   sudo ai-tools-admin operators remove <user>
#   ```bash
#   sudo ai-tools-admin selinux groups                     # show core + optional group state
#   sudo ai-tools-admin selinux groups enable <name>...    # load prebuilt (stable) groups
#   sudo ai-tools-admin selinux groups disable <name>      # unload one
#   sudo ai-tools-admin system bootstrap                   # provision the sandbox account's toolchain
#   sudo ai-tools-admin system bootstrap --scope full      # ... and every enabled integration
#   sudo ai-tools-admin system bootstrap --agents agent-codex # ... enabling the named agent, unattended
#   sudo ai-tools-admin system entrypoints relabel         # verify + relabel the agent entrypoints
#   sudo ai-tools-admin system post-upgrade                # reconcile the .rpmnew files upgrades leave
#   sudo ai-tools-admin system post-upgrade --check        # findings as a record stream; silent, exit 0 when clean
#   sudo ai-tools-admin status                             # the host's health, read as root
#   sudo ai-tools-admin dotnet bootstrap                   # a domain a provider package contributes
#   ```
#
# The spelling is the project's command grammar (cli-grammar.rule.md): a bare-word command, a plural collection,
# the verb after the noun, `list` as the zero-argument default, and a singular domain (`selinux`, `system`) where one is
# needed. `--` introduces an option and never a command, which here is `--help`/`-h` and `--version`.
#
# An operator is a login user (a human or a rootless service account) that drives the sandbox through the shared
# ai-tools account. `add` is accumulating and idempotent: it seeds the user's config in `~/.config/ai-tools` (asking
# before it creates a missing `.config`), appends the name to OPERATORS in /etc/ai-tools/operator.conf, adds the user
# to the ai-ops group (the sudoers grant and the launch wrapper gate on membership), ensures the sandbox account's
# linger, and offers to wire the PATH ordering line. The seed comes first so that an account enrolled here holds
# the `allowed-projects` every later command resolves it through; a first enrolment that cannot write one is refused
# with the host unchanged. `remove` reverses the host-side membership (drops the name from OPERATORS and ai-ops),
# leaving the user's own allowlist and config. `list` prints the current operators.
#
# `selinux groups` toggles the optional policy groups, all off by default. It loads the COMPILED ai_tools_<group>.pp
# that ai-tools-selinux (or a checkout's install-selinux.sh build) staged under AI_TOOLS_SELINUX_GROUPS__PACKAGE_DIR
# via semodule -- no source tree or selinux-policy-devel needed on the host. The group set, descriptions, and per-group
# stability are single-sourced from selinux-groups.lib.sh, shared with selinux/install-selinux.sh (the source-tree
# authoring tool that instead COMPILES a group; this operator helper only loads a shipped one). Only STABLE groups are
# on the shipped set; `groups enable` of an EXPERIMENTAL (unaudited) group is refused with a pointer to the source
# compile-and-verify workflow (install-selinux.sh + the avc bring-up loop), since this tool will not load an unaudited
# module. `groups disable` works for any loaded group, stable or not.
#
# `system bootstrap` provisions the sandbox account and the Node toolchain the enabled agents run on, through the root
# helper ai-tools-bootstrap. It is the first command an administrator runs on a new host, and the one that installs
# software over the network, which is why it is a command rather than an RPM scriptlet: a scriptlet must succeed offline
# and inside a build chroot. Idempotent -- an existing account, nvm install or Node version is reused -- so it is also
# the re-run after enabling an agent in operator.conf. No agent is enabled until an operator names one:
# with AI_TOOLS_AGENTS absent the helper asks which ONE installed agent to enable and writes that line, and `--agents`
# is the unattended form of the same choice, passed through to the helper, which checks the names against the installed
# manifests before writing. The bare form does the minimal provision; `--scope full` then runs each ENABLED
# integration's own `bootstrap` through the admin-commands seam, so a host is provisioned end to end in one command
# without base naming an integration.
#
# Beyond those, the command set is EXTENSIBLE rather than enumerated: this tool ships in ai-tools-base, which is
# installed before anyone knows which provider packages a host will add, so a provider contributes a domain of its own
# as an executable fragment at /usr/local/lib/ai-tools/admin-commands.d/<name> and this tool discovers it. The basename
# is the domain token -- the same name the provider takes in agents.d/integrations.d and in operator.conf. Dispatch is
# an exec, not a source, so a fragment keeps its own `set -euo pipefail`, root guard and logging. A fragment is honored
# only while it passes the same trust predicate as every other provider input (root-owned, not group/other-writable,
# and so is its directory), and one claiming a name base owns is refused rather than merged; both refusals are reported.
# INSTALLATION, not enablement, decides whether a command exists -- what a command reports still names the enablement
# state, and `system bootstrap --scope full` reads the enabled set instead.
#
# `system entrypoints relabel` reconciles each enabled agent's entrypoint after a toolchain change, through the root
# helper ai-tools-relabel-agent: it verifies the binary against the checksum its vendor signed and records the result
# in that agent's pin, then restores ai_tools_exec_t so the domain transition fires and ai-tools-run stops fail-closing
# on the launch. It clears AI_TOOLS_ENTRYPOINT_PIN_REUSE before the exec, so this route always re-fetches the vendor's
# signed manifest: the unattended callers (ai-tools-relabel.service, the agent package's %post) may answer
# from an unchanged pin, and an administrator asking for a reconcile is asking for the fetch.
#
# `status` reports this host's health as root: the same resource `ai-tools status` reports to an operator, completed
# with the three readings that vantage point prints as `?` -- the sandbox account's own `systemd --user units`,
# whose bus root reaches over the machine transport; the entrypoint pin, in a state directory a non-operator has no
# traverse bit on; and the SELinux type each agent path carries right now, inside a 0750 toolchain. Every verdict comes
# from the same services.lib.sh registry both reports and the launch wrapper's pre-launch warning read, so the two
# commands differ in what each is allowed to see and agree on what they both see.
#
# `system post-upgrade` reconciles the `<file>.rpmnew` copies an upgrade leaves beside the %config(noreplace) files this
# stack owns. rpm keeps what the host edited and parks the new version alongside it; choosing between the two is
# a judgement about the operator's own configuration, so it happens here, when the operator asks, and never
# in a scriptlet. Each file gets the treatment its content deserves -- merge, report, or show only, per the post-upgrade
# registry -- and every treatment shows what it would change, confirms, backs the file up before writing, and names each
# path it touched. The copy itself is never removed: each block names the one to delete, once the operator has merged
# what they want from it. The from-source installer reaches the same end through its own keep-or-reset prompts and dated
# .bak/.shipped sidecars; this is the RPM-side equivalent.
#
# Its domain rules are cli.rule.md (what each command does) and cli-grammar.rule.md (how it is spelled).

set -euo pipefail

readonly SANDBOX_USER="ai-tools"
readonly SANDBOX_GROUP="ai-tools"
readonly OPERATORS_GROUP="ai-ops"
readonly OPERATOR_CONF="/etc/ai-tools/operator.conf"
readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh"
readonly SELINUX_GROUPS_LIB="/usr/local/lib/ai-tools/selinux-groups.lib.sh"
readonly CONF_LIB="/usr/local/lib/ai-tools/conf.lib.sh"
readonly SETTINGS_MERGE_LIB="/usr/local/lib/ai-tools/settings-merge.lib.sh"
readonly PROVIDERS_LIB="/usr/local/lib/ai-tools/providers.lib.sh"
readonly PATH_ORDER_LIB="/usr/local/lib/ai-tools/path-order.lib.sh"
readonly RECORDS_TSV_LIB="/usr/local/lib/ai-tools/records-tsv.lib.sh"
readonly ASSETS_LIB="/usr/local/lib/ai-tools/assets.lib.sh"
# Where a provider package drops the command fragment carrying its own domain. The environment override is a test hook
# of the same standing as AI_TOOLS_POSTUPGRADE_ROOT: sudo strips the name, so tests/unit/admin-commands.sh drives
# the dispatch against a fixture tree. `--help` lists the domains through it ahead of the root check, so a non-root
# caller can point the LISTING at another root-owned directory; the dispatch still needs root and each fragment still
# needs root ownership, so the reach does not add a command. Unset in production.
readonly ADMIN_COMMANDS_DIR="${AI_TOOLS_ADMIN_COMMANDS_DIR:-/usr/local/lib/ai-tools/admin-commands.d}"
# The names base owns. A contributed fragment claiming one is refused, so no installed package can shadow a command
# an administrator relies on. `status` is reserved before it is implemented: a name a provider could take first is not
# a name base can take back.
readonly -a BASE_COMMANDS=(operators selinux system status assets)
# What an administrator does about a contributed command that is not root's alone. Stated once and shared by every
# message that reports one, so the dispatch, the full-scope bootstrap and the help give one answer. It is deliberately
# NOT "chmod it": a packaged command installs root-owned and unwritable by anyone else, so a file in that state is
# either one installed by hand or a change somebody made to this host -- and a mode fixed in place would re-bless
# content that whoever could write the file may already have rewritten.
readonly ADMIN_COMMANDS_TAMPER_REMEDY="a packaged command installs root-owned and unwritable by anyone else, so reinstall the package owning it -- 'rpm -qf <path>' names it -- and look into how the file came to be writable; a command installed by hand is yours to correct"
# Root-only helpers, 750 root:root, reached directly rather than through sudo: this tool already refuses a non-root
# caller. Both ship in ai-tools-integration-nodejs, which the metapackage pulls in weakly, so each command checks
# for its helper and names that package when it is absent. The ai-tools-relabel.path watcher, ai-tools-bootstrap
# and the agent package's %post run the relabel helper as root themselves.
readonly RELABEL_ENTRYPOINT_BIN="/usr/local/libexec/ai-tools/ai-tools-relabel-agent"
readonly BOOTSTRAP_BIN="/usr/local/libexec/ai-tools/ai-tools-bootstrap"

# Substituted at deploy time (install.sh install_subst from packaging/VERSION; the RPM from %{version}), and left
# as the literal token in the checkout -- which `--version` reports as `dev`, the same value and the same fallback
# the CLI uses.
AI_TOOLS_VERSION="0.24.1-1.el9"
[[ "${AI_TOOLS_VERSION}" == @*@ ]] && AI_TOOLS_VERSION="dev"
readonly AI_TOOLS_VERSION

# A leading message code (msg.lib.sh states the form) is printed on its own line ahead of the message, the shape
# tests/lib/harness.sh's assert_msg reads. Matched inline, since these helpers report before the library is loaded.
die() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-admin: error: %s\n' "$*" >&2; exit 1
}
log()  { printf 'ai-tools-admin: %s\n' "$*"; }
# warn: a refusal that narrows what this tool will do -- a contributed command skipped, an integration that would not
# provision. stderr, so the domain list on stdout stays data-only.
warn() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-admin: warning: %s\n' "$*" >&2
}
# notice: a consequence of what this tool wrote, at a lower severity than warn -- the run is complete and the host is
# as asked for. stderr, like warn, so a verb's record stream on stdout stays data-only.
notice() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-admin: notice: %s\n' "$*" >&2
}

# reject <message>: the command line was rejected. Exit 2 separates a command nobody can type correctly
# from an operation that ran and failed (`die`, exit 1), which is the split ai-tools-admin(8) documents and the one
# ai-tools(1) already uses.
reject() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-admin: %s\n' "$*" >&2
    printf "try 'ai-tools-admin --help'\n" >&2
    exit 2
}

# reject_with_usage <message>: the refusal the LAST dispatch arm takes, at reject's own exit 2. It prints the command
# surface rather than a pointer at `--help`, because a name that matched no base command and no contributed domain is
# answered by the domain list this host actually has, which only usage() knows. Defined here so that surface
# and the code naming the situation stay together.
reject_with_usage() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-admin: %s\n\n' "$*" >&2
    usage >&2
    exit 2
}

# die_unsourced <lib>: one situation for every library this tool requires, the library named as its argument -- called
# after the `source` line so each keeps its own shellcheck source directive.
die_unsourced() { die MSG-V6F4 "cannot source ${1}"; }

# coded_refusal <code> <text>: a refusal carried as a VALUE -- the code on the first line, the text on the second.
# admin_command_check's branches define their codes through it, so each of its situations keeps a code of its own while
# the two sites that emit the value need not know which branch produced it.
coded_refusal() { printf '%s\n' "$1" "$2"; }
# emit_coded <emitter> <value> [prefix]: hand a coded_refusal value to warn or die as the two arguments they read,
# with <prefix> naming who the refusal is about ahead of the text.
emit_coded() { "$1" "${2%%$'\n'*}" "${3-}${2#*$'\n'}"; }

# usage: the command surface, grouped by domain. Orientation rather than reference -- every option, exit code
# and example is in ai-tools-admin(8), and tests/unit/man.sh holds the two in agreement on the command set.
#
# Two heredocs with the contributed domains between them, by design: the FIRST one is the base command set, and it is
# exactly what man.sh reads (it stops at the first EOF). The block between them is whatever this host installed,
# so the page documents the seam rather than any one domain -- a domain a package contributes cannot be documented
# by the package that does not ship it.
usage() {
    cat <<EOF
ai-tools-admin -- administer the ai-tools host: operators, SELinux groups, the toolchain, upgrades

  Operators
    operators                        the enrolled operators
    operators add [user]             enrol an operator (default: \$SUDO_USER)
    operators remove <user>          withdraw an operator's enrolment
  SELinux
    selinux groups                   the core module and the optional groups
    selinux groups enable <name>...  load prebuilt optional groups
    selinux groups disable <name>    unload a loaded group
  System
    system bootstrap [--scope full]  provision the sandbox account and its toolchain
      --agents NAME[,NAME...]        enable the named agents instead of asking which one
    system entrypoints relabel       verify and relabel the agent entrypoints
    system post-upgrade [--check]    reconcile the .rpmnew files an upgrade leaves; --check only reports
      --all                          with --check: list the findings that need no action too
      --format tsv                   with --check: the line format (the default)
  Assets
    assets enable <id>...            enable assets, each <set>/<kind>/<name>, and reconcile
      --set <set>                    instead: enable each valid asset a set holds now
    assets disable <id>...           disable assets and remove their links
    assets reconcile                 bring the asset view and each agent's links up to date
  Health
    status                           this host's services, assets, entrypoints and live labels
EOF
    usage_domains
    cat <<'EOT'
    --version                        the installed version
    --help                           this summary

  Run every command through sudo: each one administers the host and refuses a
  non-root caller. The project lifecycle is the unprivileged ai-tools CLI, which
  you run as yourself.

  Every command, exit code and example:  man ai-tools-admin
EOT
}

# usage_domains: the domain line each installed provider contributes, so the help an administrator reads
# and the commands that dispatch cannot disagree -- both read admin_domains. The summary is the provider manifest's
# admin_summary key (data, parsed not sourced), rather than each fragment being executed to ask it what it is;
# a provider that declares none still gets its line.
usage_domains() {
    admin_domains
    printf '\n'
    [[ "${#ADMIN_DOMAINS[@]}" -gt 0 ]] || return 0
    printf '  Providers\n'
    local domain summary
    for domain in "${ADMIN_DOMAINS[@]}"; do
        summary=""
        declare -F ai_tools_providers__read_provider_manifest_field >/dev/null 2>&1 \
            && summary="$(ai_tools_providers__read_provider_manifest_field "${domain}" admin_summary || true)"
        printf '    %-32s %s\n' "${domain} <command>" "${summary}"
    done
    # What the help shows and what runs stay one answer: while the set-wide gate holds these back, the listing says
    # so rather than offering a command that refuses.
    admin_commands_trusted \
        || printf "    (refused: a file in that directory is not root's alone -- see the warning above)\n"
    printf '\n'
}

# ── contributed command domains ──────────────────────────────────────────────────────────────
# The seam that lets a provider package add a domain to this tool. Base cannot enumerate the integrations it ships
# without, so the domain list is DISCOVERED -- and every way that discovery can fail leaves the command surface smaller,
# never wider.

# is_base_command <name>: succeed when base owns <name>.
is_base_command() {
    local candidate="$1" reserved
    for reserved in "${BASE_COMMANDS[@]}"; do
        [[ "${candidate}" == "${reserved}" ]] && return 0
    done
    return 1
}

# admin_domains: resolve the contributed domains this host has into ADMIN_DOMAINS, in filename order, and record
# in ADMIN_COMMANDS_TAMPERED whether any entry failed the trust predicate. Both results are globals rather than stdout
# because the caller must see the second one, and a $(...) capture would leave it behind in a subshell. Every refusal
# goes to stderr.
#
# A fragment is honored only while ai_tools_conf__is_trusted holds for it AND for the directory holding it --
# a group-writable directory lets a non-root writer unlink a root-owned file and put its own in that name, which here
# would be a command root then executes. A name base owns is refused rather than merged, and a basename that is not
# a bare lower-case word is skipped before it is ever joined to a path, so a separator or a traversal cannot address
# a file outside the directory. The dispatch and `--help` both read this one function, so what an administrator is told
# and what runs cannot disagree.
#
# The two rejections are different findings and are counted apart. A name this seam does not recognize (a README,
# a backup, a base name) is a file that is not a command, and the set around it is unaffected. A file that group
# or other may write is a broken assumption about the directory itself -- that only root decides what is run from it --
# and it is what ADMIN_COMMANDS_TAMPERED carries to the conformance gate.
ADMIN_DOMAINS=()
ADMIN_COMMANDS_TAMPERED=0
admin_domains() {
    ADMIN_DOMAINS=()
    ADMIN_COMMANDS_TAMPERED=0
    [[ -d "${ADMIN_COMMANDS_DIR}" ]] || return 0
    if ! ai_tools_conf__is_trusted "${ADMIN_COMMANDS_DIR}"; then
        warn MSG-V5S5 "ignoring every contributed command: ${ADMIN_COMMANDS_DIR} is a symlink, is not root-owned, or is writable by group/other"
        ADMIN_COMMANDS_TAMPERED=1
        return 0
    fi
    local fragment domain
    for fragment in "${ADMIN_COMMANDS_DIR}"/*; do
        [[ -f "${fragment}" ]] || continue
        domain="${fragment##*/}"
        if [[ ! "${domain}" =~ ^[a-z][a-z0-9-]*$ ]]; then
            warn MSG-H6J9 "skipping $(printf '%q' "${fragment}"): a contributed command is named for its provider, in bare lower-case"
            continue
        fi
        if is_base_command "${domain}"; then
            warn MSG-U6P9 "refusing ${fragment}: '${domain}' is a command ai-tools-admin owns and no package may replace it"
            continue
        fi
        if ! ai_tools_conf__is_trusted "${fragment}"; then
            warn MSG-D3P6 "refusing ${fragment}: it is a symlink, is not root-owned, or is writable by group/other, so what it runs is not root's decision alone"
            ADMIN_COMMANDS_TAMPERED=1
            continue
        fi
        ADMIN_DOMAINS+=( "${domain}" )
    done
}

# admin_commands_trusted: the SET-WIDE gate, applied before any contributed command is run.
#
# Per-file skipping already keeps a mis-permissioned fragment from being executed, and this refuses its neighbours
# as well. The reason is what the failure means rather than what it reaches: only root may write this directory,
# so a fragment inside it that group or other may write is a file the sandbox account can rewrite between one run
# and the next, sitting where root looks for commands. Running the entries that still pass would leave that file
# in place and the administrator with no reason to act. Base's own commands are untouched by this, so the host stays
# administrable while the finding is dealt with (ADMIN_COMMANDS_TAMPER_REMEDY).
admin_commands_trusted() {
    (( ADMIN_COMMANDS_TAMPERED == 0 ))
}

# admin_command_check <domain>: succeed when the fragment carrying <domain> conforms to the interface this tool
# dispatches, publishing its declared verbs in ADMIN_COMMAND_VERBS; otherwise set _admin_command_reason to what is
# wrong, as a coded_refusal value. A reason rather than a message, so the two callers can act differently on it --
# an administrator's own command stops, while `system bootstrap --scope full` names it and carries on to the next
# integration. Each branch defines its own message code, since neither emit site knows which of them it holds.
#
# Where the trust checks decide WHO wrote the file, this decides whether the file is a command of this seam at all. It
# is a conformance contract, not a security boundary: what stops a file the agent wrote is the trust predicate,
# and what this stops is a file that was never meant to be run this way. It is declarative and static -- a fragment is
# read, never executed, to find out what it is, so a report is built by reading alone, without forking or running
# the fragment.
#
# A conforming fragment is a script (`#!`) carrying three declarations in its first 20 lines. The whole block is
# the interface: a third-party integration writes it once, and every check here reads it rather than running anything.
#
#   # ai-tools-admin-command: <domain>              the domain it is installed as
#   # ai-tools-admin-api-min-version: <maj>.<min>   the least this tool must implement for it to run
#   # ai-tools-admin-verbs: <verb> ...              the top-level verbs it answers (this project's
#                                                   list grammar: commas and whitespace separate)
#
# Each earns its place:
#   * the COMMAND line makes the fragment self-identifying, so a root-owned executable that merely
#     ends up in this directory -- a stray tool, an editor's backup, one provider's command copied
#     under another provider's name -- does not claim to be a command here and is not run as one.
#   * the API-MIN-VERSION line is version skew made visible, and it is a FLOOR rather than a
#     stamp of what the fragment was written on. A fragment ships in a package that upgrades
#     independently of base, and a third party does not re-declare it for every release of this
#     project, so what it states is what it NEEDS: `1.0` means "any ai-tools-admin implementing 1.0
#     or later can run me", and it keeps being true as this tool moves forward.
#   * the VERBS line is a capability list base READS rather than probes. `system bootstrap --scope
#     full` asks whether a provider has a `bootstrap` before running anything, so an integration
#     that contributes other commands is reported as having no provisioning to do rather than as
#     having failed. It is NOT argument validation -- what a verb accepts is the fragment's own
#     dispatch to answer, and a base that second-guessed it would drift out of agreement with it.
#
# There is no date and no "written against" version in the block: the package that installs the fragment carries both,
# and a hand-maintained copy of either would drift from it.
#
# ADMIN_COMMAND_API is what this tool implements, and it is the only version base holds. A fragment runs when its
# declared floor is one this tool satisfies -- the same comparison Apache httpd makes between a module's Module Magic
# Number and its own (AP_MODULE_MAGIC_AT_LEAST), and the same shape as every plugin host that takes a floor from its
# plugins (Chrome's minimum_chrome_version, Jenkins' jenkins.version baseline, a VS Code extension's engines.vscode):
#
#   the MAJOR must match      a different major is a different contract, and neither side can guess
#                             at the other's, so the refusal names both versions
#   the MINOR must be at      a minor revision only ADDS, so a fragment that needs 1.0 runs on 1.7;
#   least the declared one    one that needs 1.7 does not run here until base catches up
#
# That leaves one number to maintain and puts retirement in the major digit, where a reader already expects it, rather
# than in a second constant saying the same thing. It is also what lets a third party declare a floor once and leave it
# alone: everything valid at 1.0 keeps dispatching however far the minor advances.
#
# How the version moves, since that is the question every later change to this seam asks:
#   * base learns to CALL something new -- a `reset` or `update` verb invoked the way `system
#     bootstrap --scope full` invokes `bootstrap` -- is ADDITIVE. A fragment that does not declare
#     the verb is skipped, exactly as one without `bootstrap` is, so this takes a MINOR bump.
#   * base changes what it REQUIRES -- a new mandatory declaration, a different invocation -- is
#     still a MINOR bump while base keeps honouring the old shape (the new key optional, the old
#     invocation still answered). It is a MAJOR bump only when base drops that compatibility path,
#     which is the single change that refuses a working third-party command.
#   * a provider adding a verb TO ITSELF moves neither digit. `dotnet reset` is typed directly and
#     asks base for no behaviour, so the interface grows only when base starts invoking something.
readonly ADMIN_COMMAND_API="1.0"
_admin_command_reason=""
ADMIN_COMMAND_VERBS=()
admin_command_check() {
    local domain="$1"
    local fragment="${ADMIN_COMMANDS_DIR}/${domain}"
    local head_bytes header declared_floor declared_major declared_minor declared_verbs verb
    local base_major="${ADMIN_COMMAND_API%%.*}" base_minor="${ADMIN_COMMAND_API##*.}"
    _admin_command_reason=""
    ADMIN_COMMAND_VERBS=()
    if [[ ! -x "${fragment}" ]]; then
        _admin_command_reason="$(coded_refusal MSG-F4Z5 "not executable: ${fragment} -- reinstall the package that ships it")"
        return 1
    fi
    head_bytes="$(head -c 2 "${fragment}" 2>/dev/null || true)"
    if [[ "${head_bytes}" != '#!' ]]; then
        _admin_command_reason="$(coded_refusal MSG-D9F7 "not a script: ${fragment} -- a contributed command is an interpreted file")"
        return 1
    fi
    # Captured before matching, never piped into `grep`/`head`: an early-exiting reader leaves the writer to die
    # of SIGPIPE, which pipefail reports as a failed probe (see ai_tools_selinux_groups__is_loaded). Every read works
    # on this one string.
    header="$(head -n 20 "${fragment}" 2>/dev/null || true)"
    if ! grep -qxF -- "# ai-tools-admin-command: ${domain}" <<<"${header}"; then
        _admin_command_reason="$(coded_refusal MSG-H5F8 "no domain declaration: ${fragment} does not declare '# ai-tools-admin-command: ${domain}' in its first 20 lines -- reinstall the package that ships it")"
        return 1
    fi

    declared_floor="$(_admin_command_field "${header}" api-min-version)"
    if [[ -z "${declared_floor}" ]]; then
        _admin_command_reason="$(coded_refusal MSG-U6U6 "no interface floor: ${fragment} declares no '# ai-tools-admin-api-min-version: <major>.<minor>' -- reinstall the package that ships it")"
        return 1
    fi
    if [[ ! "${declared_floor}" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
        _admin_command_reason="$(coded_refusal MSG-B5K9 "a malformed interface floor: ${fragment} declares $(printf '%q' "${declared_floor}"), which is not <major>.<minor>")"
        return 1
    fi
    declared_major="${BASH_REMATCH[1]}"; declared_minor="${BASH_REMATCH[2]}"
    if (( declared_major != base_major )); then
        _admin_command_reason="$(coded_refusal MSG-W3T9 "an incompatible contract: ${fragment} needs contributed-command interface ${declared_floor}, and this ai-tools-admin implements ${ADMIN_COMMAND_API} -- a different major is a different contract, so upgrade whichever of the two is behind")"
        return 1
    fi
    if (( declared_minor > base_minor )); then
        _admin_command_reason="$(coded_refusal MSG-M3D7 "an interface newer than this one: ${fragment} needs contributed-command interface ${declared_floor}, and this ai-tools-admin implements ${ADMIN_COMMAND_API} -- upgrade ai-tools-base")"
        return 1
    fi

    declared_verbs="$(_admin_command_field "${header}" verbs)"
    # One list grammar across this project: commas and whitespace both separate (conf.lib.sh).
    ai_tools_conf__split ADMIN_COMMAND_VERBS "${declared_verbs}"
    if [[ "${#ADMIN_COMMAND_VERBS[@]}" -eq 0 ]]; then
        _admin_command_reason="$(coded_refusal MSG-V5Q3 "no verb list: ${fragment} declares no '# ai-tools-admin-verbs: <verb> ...' -- a command that answers nothing is not one")"
        return 1
    fi
    for verb in "${ADMIN_COMMAND_VERBS[@]}"; do
        [[ "${verb}" =~ ^[a-z][a-z0-9-]*$ ]] && continue
        _admin_command_reason="$(coded_refusal MSG-E3G7 "a verb outside the command charset: ${fragment} declares $(printf '%q' "${verb}") among its verbs, and a verb is a bare lower-case word")"
        return 1
    done
    return 0
}

# _admin_command_field <header> <key>: the value of one `# ai-tools-admin-<key>: ` line, empty when absent. First
# occurrence wins, so a later line cannot quietly override the declaration a reader saw first. Works on the captured
# header string, so no read reaches the file again.
_admin_command_field() {
    local values
    values="$(sed -n "s/^# ai-tools-admin-$2:[[:space:]]*\\(.*[^[:space:]]\\)[[:space:]]*\$/\\1/p" <<<"$1")"
    printf '%s' "${values%%$'\n'*}"
}

# admin_command_has_verb <verb>: succeed when the verb list published by the last admin_command_check holds <verb>. Read
# after that call, never instead of it.
admin_command_has_verb() {
    local wanted="$1" verb
    for verb in "${ADMIN_COMMAND_VERBS[@]}"; do
        [[ "${verb}" == "${wanted}" ]] && return 0
    done
    return 1
}

# contributed_dispatch <name> [args...]: exec the fragment carrying <name>, with the remaining arguments. An exec rather
# than a source: the fragment keeps its own `set -euo pipefail`, its own root guard and its own logging, and cannot
# collide with this tool's function names.
#
# Membership of ADMIN_DOMAINS is the gate, so <name> is never interpolated into a path before it has matched
# a discovered domain. Reached only from the top-level default arm, which is what makes a base name unreachable here
# twice over: its own arm matched first, and admin_domains refuses a fragment claiming one.
contributed_dispatch() {
    local domain="$1"; shift
    admin_domains
    local known found=no
    for known in "${ADMIN_DOMAINS[@]}"; do
        [[ "${known}" == "${domain}" ]] && { found=yes; break; }
    done
    if [[ "${found}" != yes ]]; then
        reject_with_usage MSG-N2A5 "unknown command: ${domain}"
    fi
    admin_commands_trusted \
        || die MSG-A3P2 "refusing every contributed command while ${ADMIN_COMMANDS_DIR} holds a file that is not root's alone (named above) -- ${ADMIN_COMMANDS_TAMPER_REMEDY}"
    admin_command_check "${domain}" || emit_coded die "${_admin_command_reason}"
    exec "${ADMIN_COMMANDS_DIR}/${domain}" "$@"
}

# The shared config grammar and sidecar handling that `system post-upgrade` drives, and the trust predicate every
# contributed command is vetted with. Required, not optional: a reconcile that silently skipped its merge would leave
# a shipped hook uninvoked while reporting success, and a dispatch that could not tell a trusted fragment from a planted
# one would exec whatever it found. Loaded BEFORE the other libraries, unlike them, because `--help` lists this host's
# contributed domains and that list is drawn through this predicate.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/conf.lib.sh
. "${CONF_LIB}" || die_unsourced "${CONF_LIB}"
# The hook-declaration merge `system post-upgrade` applies to a kept settings.json. Required for the same reason.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/settings-merge.lib.sh
. "${SETTINGS_MERGE_LIB}" || die_unsourced "${SETTINGS_MERGE_LIB}"
# The record stream `system post-upgrade --check` writes and the exit status every report here ends with. Required:
# a check that could not write its stream would exit 0 over a host it did not read. Sources records-base.lib.sh itself.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/records-tsv.lib.sh
. "${RECORDS_TSV_LIB}" || die_unsourced "${RECORDS_TSV_LIB}"

# Provider resolver: the manifest key behind each domain's summary line, and the enabled-integration list
# `system bootstrap --scope full` iterates. Optional at load and gated at each use -- without it every installed
# fragment still dispatches (its own presence is what decides that), a domain is listed without its summary,
# and full-scope bootstrap REFUSES rather than guessing which integrations this host runs.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/providers.lib.sh
source "${PROVIDERS_LIB}" 2>/dev/null || true

# Where an operator's shell finds an agent launcher, and the guard line that settles it. Required, like the operator
# lib: enrolment asks about that line and then writes it, so a missing library is a broken install rather than
# a question this tool can ask a different way. It reads the enabled agents through providers.lib.sh, so a host
# that could not load that resolver gets the `unknown` verdict and is asked with the stake unnamed.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/path-order.lib.sh
. "${PATH_ORDER_LIB}" || die_unsourced "${PATH_ORDER_LIB}"

# Executed, this administers a host and needs root. Sourced -- by tests/unit/admin-operator-add.sh, which drives one
# function with sudo stubbed -- it does not assert anything about the host and only defines, stopping at the matching
# guard that precedes the dispatch. Everything between the two is definitions, so the executed path still refuses
# a non-root caller before any action.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    # `--help` and `--version` read no host state and leave the host as it is, so they answer any caller and are handled
    # here, ahead of the root check: an operator meeting the tool gets the command surface rather than a refusal naming
    # sudo without saying what to run under it. Both ignore any further argument.
    case "${1:-}" in
        --help|-h) usage; exit 0 ;;
        --version) printf 'ai-tools-admin %s\n' "${AI_TOOLS_VERSION}"; exit 0 ;;
    esac
    [[ "${EUID}" -eq 0 ]] || die MSG-D7D4 "run as root (sudo)"
fi

# shellcheck source=SCRIPTDIR/../../lib/ai-tools/operator.lib.sh
. "${OPERATOR_LIB}" || die_unsourced "${OPERATOR_LIB}"

# Optional SELinux policy-group registry + predicates, shared with install-selinux.sh.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/selinux-groups.lib.sh
. "${SELINUX_GROUPS_LIB}" || die_unsourced "${SELINUX_GROUPS_LIB}"

# Shared yes/no prompt (ai_tools_msg__confirm; see msg.lib.sh). REQUIRED like the operator lib: a valid install ships
# it, so there is no fallback. Include-guarded, so a re-source is a no-op.
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/msg.lib.sh
source /usr/local/lib/ai-tools/msg.lib.sh || die_unsourced /usr/local/lib/ai-tools/msg.lib.sh
# Fixed 80-column frame for any box this tool renders, aligned with the CLI's.
export AI_TOOLS_MSG_FULLWIDTH=1

# write_operators <name>...: set the OPERATORS list in operator.conf (root:root 644). Edits ONLY the OPERATORS line
# in an existing file, through the shared writer (ai_tools_conf__set_list, conf.lib.sh -- the grammar's owner,
# so the line replaced is the one every reader of the file matches), preserving every other setting the operator
# maintains there; seeds a minimal file when absent. 644: world-readable (the agent hooks and the root helpers both read
# it; it is free of secrets) and root-write-only, so the agent cannot rewrite the identity root hands files back to.
write_operators() {
    install -d -o root -g root -m 755 /etc/ai-tools
    if [[ ! -f "${OPERATOR_CONF}" ]]; then
        local tmp; tmp="$(mktemp)"
        printf '%s\n' "# ai-tools host configuration -- full reference: man 5 ai-tools-operator.conf" > "${tmp}"
        install -o root -g root -m 644 "${tmp}" "${OPERATOR_CONF}"
        rm -f "${tmp}"
    fi
    ai_tools_conf__set_list "${OPERATOR_CONF}" OPERATORS "$@" \
        || die MSG-N4H9 "could not write OPERATORS into ${OPERATOR_CONF} -- the enrolment is incomplete; check the file and re-run"
}

# in_list <name>: succeed when <name> is already in AI_TOOLS_OPERATOR__OPERATORS.
in_list() {
    local n; for n in "${AI_TOOLS_OPERATOR__OPERATORS[@]:-}"; do [[ "${n}" == "$1" ]] && return 0; done
    return 1
}

# seed_config_file <user> <group> <path> <seed-function>: place <path> from what <seed-function> prints, owned
# by the operator at 600. An existing file holds that operator's own edits and is left as it stands, so enrolment is
# idempotent. Returns non-zero when the file is not there afterwards, so a failed write reaches the caller
# as the refusal it makes rather than as a step that logged `seeding` and stopped.
seed_config_file() {
    local user="$1" group="$2" file="$3" seed="$4" tmp status=0
    [[ -f "${file}" ]] && return 0
    log "seeding ${file}"
    tmp="$(mktemp)"
    "${seed}" > "${tmp}" && install -o "${user}" -g "${group}" -m 600 "${tmp}" "${file}" || status=$?
    rm -f "${tmp}"
    (( status == 0 )) || warn MSG-P5C5 "could not seed ${file}"
    return "${status}"
}

# ensure_config_home <user> <group> <home>: make sure `<home>/.config` exists, since `~/.config/ai-tools` is seeded
# inside it and an account that has never logged in has no config home yet.
#
# An existing one is left as it stands: other applications keep their config there, and its mode is not this command's
# to decide. A missing one is created with `mkdir`, so the HOST's umask decides the mode -- the compliance answer
# for a directory this command only has to traverse, and a permissive umask leaves the access boundary where it already
# is, on the `700` `~/.config/ai-tools` inside it. Creating it asks first, default YES: the write lands in an account's
# home, and a run with no terminal takes the yes, which is what seeds an operator during an unattended install.
# `restorecon` gives it `config_home_t`, which root creating it inside the home does not.
#
# Returns non-zero when `.config` is absent afterwards, so the caller refuses the enrolment rather than reporting a seed
# that did not happen.
ensure_config_home() {
    local user="$1" group="$2" cfg_home="$3/.config"
    [[ -d "${cfg_home}" ]] && return 0
    if [[ -e "${cfg_home}" ]]; then
        warn MSG-Z6V4 "not a directory: ${cfg_home} -- ${user}'s ai-tools config cannot be seeded inside it"
        return 1
    fi
    log "${user} has no ${cfg_home}, which is where its ai-tools config directory goes"
    if ! ai_tools_msg__confirm "Create ${cfg_home}, owned by ${user}?" y; then
        warn MSG-B6P3 "declined: ${cfg_home} was not created, so ${user}'s ai-tools config cannot be seeded"
        return 1
    fi
    if ! mkdir "${cfg_home}"; then
        warn MSG-S9G3 "could not create ${cfg_home} as ${user}:${group}"
        return 1
    fi
    # A directory root owns is one the operator cannot write, so an ownership this could not set takes the empty
    # directory with it and the enrolment refuses on a home it did not change.
    if ! chown "${user}:${group}" "${cfg_home}"; then
        rmdir "${cfg_home}" 2>/dev/null || true
        warn MSG-U8E9 "could not give ${cfg_home} to ${user}:${group}; removed the directory again"
        return 1
    fi
    if command -v restorecon >/dev/null 2>&1; then restorecon "${cfg_home}" 2>/dev/null || true; fi
    log "created ${cfg_home} $(stat -c '%a %U:%G' "${cfg_home}" 2>/dev/null)"
}

# seed_operator_config <user>: create the config an operator keeps in ~/.config/ai-tools -- allowed-projects (the launch
# gate) and secret-patterns (which basenames are credentials) -- at 600 inside a 700 directory, so the sandbox account,
# neither owner nor group member and unable to enter the directory, reads neither while the root helpers read both
# on the operator's behalf. This is the one place either file is created for an operator, and both headers come
# from conf.lib.sh, so an account enrolled on a packaged host gets what a from-source install writes.
#
# Each way it can come up short is reported where it happens and returns non-zero, so `op_add` decides what an enrolment
# missing its `allowed-projects` becomes.
seed_operator_config() {
    local user="$1" home group cfg status=0
    home="$(getent passwd "${user}" | cut -d: -f6)"
    group="$(id -gn "${user}")"
    if [[ -z "${home}" || ! -d "${home}" ]]; then
        warn MSG-R7U6 "no home directory on this host for ${user}; skipping the config seed"
        warn "    create the home, then re-run: sudo ai-tools-admin operators add ${user}"
        return 1
    fi
    ensure_config_home "${user}" "${group}" "${home}" || return 1
    cfg="${home}/.config/ai-tools"
    if [[ ! -d "${cfg}" ]] && ! install -d -o "${user}" -g "${group}" -m 700 "${cfg}"; then
        warn MSG-N2J8 "could not create ${cfg} at 700 ${user}:${group}"
        return 1
    fi
    seed_config_file "${user}" "${group}" "${cfg}/allowed-projects"  ai_tools_conf__get_allowlist_seed       || status=1
    seed_config_file "${user}" "${group}" "${cfg}/secret-patterns"   ai_tools_conf__get_secret_patterns_seed || status=1
    return "${status}"
}

# label_operator_config <user>: give that operator's ~/.config/ai-tools the ai_tools_conf_t SELinux type,
# through relabel.lib.sh. There is one rule per operator and enrolment is where this host learns an account is one,
# so the rule is registered here; what the type buys is in .claude/rules/confinement.rule.md. A re-run re-asserts it,
# which repairs an account whose rule a concurrent semanage transaction refused.
#
# BEST-EFFORT, and the two ways it declines are different facts. On a host with no SELinux layer there is no such type
# to apply -- the intended DAC-only deployment -- so the run stays silent. A host that has one and could not apply it is
# WARNED with the command that repairs it, and the enrolment still stands: the two facts that make an operator are
# already written, so a refusal here would leave an account in OPERATORS and in the group with no way to finish.
label_operator_config() {
    local user="$1" home cfg status=0
    home="$(getent passwd "${user}" 2>/dev/null | cut -d: -f6)"
    cfg="${home}/.config/ai-tools"
    [[ -n "${home}" && -d "${cfg}" ]] || return 0
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/relabel.lib.sh
    if ! source "${RELABEL_LIB}" 2>/dev/null \
            || ! declare -F ai_tools_relabel__label_operator_conf >/dev/null 2>&1; then
        warn MSG-H5N2 "cannot label ${cfg}: ${RELABEL_LIB} did not load -- reinstall ai-tools-base"
        return 0
    fi
    # Taken in this shell, not a subshell: the lock is an open descriptor (see relabel.lib.sh). It serializes this write
    # against the other helpers that write the same policy store.
    ai_tools_relabel__lock
    if [[ -n "${AI_TOOLS_RELABEL__LOCK_NOTE}" ]]; then
        warn MSG-G2C6 "proceeding without the relabel lock: ${AI_TOOLS_RELABEL__LOCK_NOTE}"
    fi
    ai_tools_relabel__label_operator_conf "${cfg}" || status=$?
    case "${status}" in
        0) log "labelled ${cfg} ${AI_TOOLS_RELABEL__OPERATOR_CONF_TYPE}" ;;
        2) : ;;   # no SELinux layer on this host -- nothing to label, and not a fault
        *) warn MSG-N3T7 "could not label ${cfg} ${AI_TOOLS_RELABEL__OPERATOR_CONF_TYPE}${AI_TOOLS_RELABEL__FCONTEXT_ERROR:+ -- ${AI_TOOLS_RELABEL__FCONTEXT_ERROR}}"
           warn "    until it carries that type the ownership handback no-ops for ${user}'s projects"
           warn "    repair: sudo ai-tools-admin operators add ${user}" ;;
    esac
    return 0
}

# wire_init_file <file> <user> <group> [login-chain] : add the guard line to one bash init file, creating it owned
# by the account when it is absent. Idempotent -- a file already naming the fragment is left as it is. `login-chain`
# seeds a created file with the `. ~/.bashrc` block EL's skel carries, which the caller passes for ~/.bash_profile
# alone: bash reads that file by itself at login, so one holding only the guard line would leave a login shell without
# the account's own .bashrc, its nvm init among it. Top-level so tests/unit/admin-operator-add.sh drives it against its
# own fixture files, apart from the prompt in wire_dedup.
wire_init_file() {
    local f="$1" user="$2" group="$3" seed="${4-}"
    if [[ ! -e "${f}" ]]; then
        install -o "${user}" -g "${group}" -m 644 /dev/null "${f}" || return 1
        [[ "${seed}" == login-chain ]] && printf '%s\n' \
            "# Created by ai-tools-admin: read this account's .bashrc at login." \
            'if [ -f ~/.bashrc ]; then' '    . ~/.bashrc' 'fi' >> "${f}"
    fi
    if [[ "$(ai_tools_path_order__read_guard_state "${f}")" == yes ]]; then
        log "PATH ordering already wired in ${f}"; return 0
    fi
    grep -qF 'NVM_DIR' "${f}" \
        || log "note: NVM_DIR not found in ${f} -- the line still works, but it is meant to follow your nvm init"
    printf '\n# Added by ai-tools-admin: rank /usr/local/bin (the ai-tools wrappers) first on PATH.\n# Keep this AFTER anything that prepends to PATH, the nvm init included.\n%s\n' \
        "${AI_TOOLS_PATH_ORDER__GUARD}" >> "${f}"
    log "wired PATH ordering into ${f}"
}

# path_order_explain <user> <state> <launcher> <winner> The framed block that precedes the question, written
# for the state the account is in. Its whole job is to put the stake in front of the decision: what the answer settles
# is whether typing an agent's name reaches the sandbox at all, so the block names the binary that wins today
# and what running it would mean. A question that reads as shell-config housekeeping collects a no from the operator it
# protects. The renderer is ai_tools_msg__block, so the paths inside it stay copy-pasteable.
path_order_explain() {
    local user="$1" state="$2" launcher="$3" winner="$4"
    case "${state}" in
    shadowed)
        ai_tools_msg__block "PATH ordering: /usr/local/bin has to win" \
            "Typing ${launcher} in ${user}'s shell starts:" \
            "" \
            "    ${winner}" \
            "" \
            "That is an UNCONFINED agent: it runs as ${user}, with ${user}'s credentials and home, outside the project allowlist, the SELinux confinement and the ownership handback. The sandbox is reached only through the wrapper in ${AI_TOOLS_PATH_ORDER__WRAPPER_DIR}, so that directory has to come first on the PATH." \
            "" \
            "ai-tools ships one line that deduplicates the PATH and ranks the root-owned directories ahead of the nvm shims. It is appended after the nvm init, changes nothing else about the shell, and is undone by deleting it." ;;
    clear)
        ai_tools_msg__block "PATH ordering" \
            "Typing ${launcher} in ${user}'s shell reaches ${AI_TOOLS_PATH_ORDER__WRAPPER_DIR}/${launcher} -- the sandbox wrapper -- so the ordering is right as it stands." \
            "" \
            "The line below is what keeps it right. Without it, an agent installed under ${user}'s own nvm, an nvm init, or anything else that prepends to PATH puts an unconfined agent ahead of the wrapper, and nothing says so: the agent simply starts outside the sandbox." ;;
    *)
        ai_tools_msg__block "PATH ordering" \
            "This host cannot read where ${user}'s shell finds an agent launcher, so it cannot say whether typing one reaches the sandbox wrapper in ${AI_TOOLS_PATH_ORDER__WRAPPER_DIR} or an agent installed somewhere else on that PATH -- which would run unconfined, as ${user}." \
            "" \
            "The line below settles it either way: it deduplicates the PATH and ranks the root-owned directories first." ;;
    esac
}

# path_order_decline <user> <state> <launcher> <winner> <bashrc> <bashprof> What is said when the line is not written --
# declined, or a run with no terminal. On a SHADOWED account this is the last place the consequence can be stated, so it
# is a warning naming the binary that wins rather than a line reporting the step as skipped; on an account that already
# reaches the wrapper it is a note, since that account's sessions are sandboxed today. Either way it prints the line,
# to be added by hand.
path_order_decline() {
    local user="$1" state="$2" launcher="$3" winner="$4" bashrc="$5" bashprof="$6"
    if [[ "${state}" == shadowed ]]; then
        warn MSG-W7J6 "typing ${launcher} in ${user}'s shell runs ${winner}, not ${AI_TOOLS_PATH_ORDER__WRAPPER_DIR}/${launcher} -- that agent runs unconfined, as ${user}"
        warn "    add this line after the nvm init in ${bashrc} and ${bashprof}, or re-run: sudo ai-tools-admin operators add ${user}"
        warn "    ${AI_TOOLS_PATH_ORDER__GUARD}"
        return 0
    fi
    log "PATH ordering not wired; add this line after the nvm init in ${bashrc} and ${bashprof}:"
    log "  ${AI_TOOLS_PATH_ORDER__GUARD}"
}

# wire_dedup <user> Settle where ${user}'s shell finds an agent launcher. It reads the account's ordering first
# (path-order.lib.sh), states what that reading means, and only then offers to source the PATH fragment from ~/.bashrc
# and ~/.bash_profile after their nvm init. That order carries the meaning: the question decides whether typing
# an agent's name reaches the sandbox wrapper or an agent of the same name elsewhere on the PATH, which runs unconfined
# as the operator -- so it is asked with the account's own state named, and declining it on a shadowed account warns
# instead of passing in silence.
#
# This wiring is the fragment's only delivery: it lives in the ai-tools lib dir rather than /etc/profile.d,
# so an unwired account keeps its stock PATH. The two files it names are what bash reads; an account that logs
# in through another shell is told where its own ordering stands. It edits the operator's home, so it asks first
# and never rewrites one in a run with no terminal, the `[[ -t 0 && -e /dev/tty ]]` branch deciding which.
# ai_tools_path_order__repoint is the one exception, and it follows a file this package moved rather than making
# a choice of its own.
wire_dedup() {
    local user="$1" home group login_shell bashrc bashprof
    local state launcher winner pair
    home="$(getent passwd "${user}" | cut -d: -f6)"
    login_shell="$(getent passwd "${user}" | cut -d: -f7)"
    group="$(id -gn "${user}")"
    [[ -n "${home}" && -d "${home}" ]] || return 0
    bashrc="${home}/.bashrc"; bashprof="${home}/.bash_profile"

    # A host upgraded from a release that shipped the fragment under its former name carries a guard line naming a file
    # this package moved, and that line succeeds without applying the ordering. Repoint before reading, so the state
    # this command reports and asks about is the repaired one.
    local repointed
    while IFS= read -r repointed; do
        [[ -n "${repointed}" ]] && log "repointed the PATH ordering line in ${repointed} to ${AI_TOOLS_PATH_ORDER__FRAGMENT}"
    done < <(ai_tools_path_order__repoint_user "${user}")

    ai_tools_path_order__read_user "${user}" || true
    state="${AI_TOOLS_PATH_ORDER__STATE:-unknown}"
    winner="${AI_TOOLS_PATH_ORDER__SHADOW:-}"
    # The launcher a message names is the shadowed one where there is one, and otherwise the first this host enables --
    # so the message is about a command the operator types.
    launcher=""
    for pair in "${AI_TOOLS_PATH_ORDER__WINNERS[@]+"${AI_TOOLS_PATH_ORDER__WINNERS[@]}"}"; do
        [[ -z "${launcher}" ]] && launcher="${pair%%=*}"
        [[ -n "${winner}" && "${pair#*=}" == "${winner}" ]] && { launcher="${pair%%=*}"; break; }
    done
    launcher="${launcher:-the agent}"

    # The two files it names govern bash. Another login shell reads its own, so the operator hears which ordering their
    # sessions actually get, at the moment the wiring is offered.
    case "${login_shell}" in
        */bash|'') ;;
        *) log "note: ${user}'s login shell is ${login_shell}, which reads its own init files rather than ${bashrc} or ${bashprof}"
           log "      rank ${AI_TOOLS_PATH_ORDER__WRAPPER_DIR} ahead of the nvm shims there too, so that typing ${launcher} reaches the ai-tools wrapper in that shell" ;;
    esac

    # Already wired and still shadowed: appending the line again edits the file and leaves the ordering as it was,
    # because what stands between this account and the wrapper is a later line that prepends to PATH. Report
    # that diagnosis instead of asking a question whose yes does not change the ordering.
    if [[ "${state}" == shadowed && "${AI_TOOLS_PATH_ORDER__WIRED:-no}" == yes ]]; then
        warn MSG-M2N9 "the PATH ordering line is already in ${user}'s shell init, and typing ${launcher} still runs ${winner}"
        warn "    something after that line prepends to PATH -- move it to the END of ${bashrc}, after the nvm init and anything else that touches PATH"
        return 0
    fi
    if [[ "${state}" == wired ]]; then
        log "PATH ordering: ${bashrc} already sources ${AI_TOOLS_PATH_ORDER__FRAGMENT}, and ${launcher} reaches the wrapper"
        return 0
    fi

    path_order_explain "${user}" "${state}" "${launcher}" "${winner}"
    if [[ -t 0 && -e /dev/tty ]]; then
        if ai_tools_msg__confirm \
            "Add the PATH ordering line to ${bashrc} and ${bashprof}?" y; then
            wire_init_file "${bashrc}"   "${user}" "${group}"
            wire_init_file "${bashprof}" "${user}" "${group}" login-chain
        else
            path_order_decline "${user}" "${state}" "${launcher}" "${winner}" "${bashrc}" "${bashprof}"
        fi
    else
        log "non-interactive: not editing shell init"
        path_order_decline "${user}" "${state}" "${launcher}" "${winner}" "${bashrc}" "${bashprof}"
    fi
}

# report_operator_role <user> -- say which of the two operator shapes this enrolment produced, where the decision is
# being made rather than where it first fails.
#
# This command writes both facts that make an operator (ai-ops membership, a name in OPERATORS) and CANNOT write
# the third thing a claim needs: a general sudo grant, which the host's own sudoers decides. Group membership cannot
# imply a sudoers rule, so the only way to know is to ask sudo -- and running as root, it can ask on the enrolled
# account's behalf without a password. The probe names ai-tools-lockdown because the secret gate is a claim's first
# sudo, so it answers the question the administrator actually has: can this account claim a project?
#
# An account without the grant is a supported shape, not a misconfiguration, so this reports and never refuses: it names
# the `--for` command that claims on the account's behalf.
#
# A non-zero answer is a refusal only while sudo is answering at all -- for a command no rule matches, `sudo -l` exits
# non-zero with EMPTY output, so there is no message separating that from a sudo which failed for its own reasons (an
# unreachable sudoers backend, a host that refuses `-l`). It is separated by a second probe, the same way the CLI's
# sudo_grant_missing does it: listing the account's whole rule set, which succeeds for anyone this command has just
# enrolled, since the %ai-ops rules apply to the membership written moments earlier (sudo reads the group database, not
# a cached credential). Only a first probe refused while the second answers is read as "no grant"; anything else is
# reported as undetermined, because a wrong verdict here is acted on immediately.
report_operator_role() {
    local user="$1"
    local claim_helper="/usr/local/libexec/ai-tools/ai-tools-lockdown"
    if ! command -v sudo >/dev/null 2>&1; then
        log "${user}: no sudo on this host, so no project can be claimed by anyone -- ${user} can still launch agent sessions"
        return 0
    fi
    if LC_ALL=C sudo -l -U "${user}" "${claim_helper}" >/dev/null 2>&1; then
        log "${user} holds a general sudo grant: it can claim projects as well as launch sessions"
        return 0
    fi
    if ! LC_ALL=C sudo -l -U "${user}" >/dev/null 2>&1; then
        log "${user}: sudo did not answer, so whether ${user} holds a general sudo grant is undetermined -- check with: sudo -l -U ${user}"
        return 0
    fi
    log "${user} holds no general sudo grant: it can launch agent sessions and read the reports, and cannot claim a project"
    log "${user}: claim on its behalf from an operator that holds one -- ai-tools projects claim --for ${user} <path>"
}

op_add() {
    local user="${1:-${SUDO_USER:-}}"
    [[ -n "${user}" ]] || reject MSG-H2P9 "operators add: name a user, or run it through sudo so SUDO_USER is set"
    [[ "${user}" != "${SANDBOX_USER}" ]] || die MSG-C9C4 "an operator must not be the sandbox account ${SANDBOX_USER}"
    [[ "${user}" != "root" ]]            || die MSG-H3M6 "an operator must be a normal login user, not root"
    id "${user}" &>/dev/null || die MSG-U8T8 "no such user: ${user}"

    ai_tools_operator__load_operators || true   # tolerate an unenrolled host (empty list)
    local enrolled=0
    if in_list "${user}"; then
        enrolled=1
        log "${user} is already an operator; reconciling group, config, and sandbox linger"
    fi

    # The config is seeded BEFORE either fact that makes an operator, so an enrolment either completes or leaves
    # the host as it found it. An account in `OPERATORS` and in `ai-ops` whose `allowed-projects` does not exist is
    # refused at every launch, and does not resolve an owner in any root helper, while the enrolment reports success --
    # so the refusal belongs here, ahead of the first write, where a re-run then enrols the account in full.
    #
    # A RECONCILING re-run carries on instead: the record already stands, this run did not write it, and withdrawing
    # an operator is `operators remove`'s decision. It repairs what it can, reports what it could not, and exits
    # non-zero at the close.
    local seeded=0
    seed_operator_config "${user}" || seeded=1
    if (( seeded && ! enrolled )); then
        die MSG-T4X9 "not enrolled: ${user}'s config in ~/.config/ai-tools could not be seeded, and without allowed-projects it cannot start a session or claim a project -- clear the reason above and re-run"
    fi

    if (( ! enrolled )); then
        local newlist=()
        [[ "${#AI_TOOLS_OPERATOR__OPERATORS[@]}" -gt 0 ]] && newlist=( "${AI_TOOLS_OPERATOR__OPERATORS[@]}" )
        newlist+=( "${user}" )
        write_operators "${newlist[@]}"
        log "added ${user} to OPERATORS"
    fi

    # ai-ops membership: the sudoers grant and the launch wrapper gate on it. The sandbox account is never a member (it
    # must not be able to drive itself as an operator). Add -- and log -- only when the user is not already a member,
    # so a reconciling re-run does not report a change it did not make.
    if id -nG "${user}" 2>/dev/null | tr ' ' '\n' | grep -qx "${OPERATORS_GROUP}"; then
        log "${user} is already in group ${OPERATORS_GROUP}"
    else
        usermod -aG "${OPERATORS_GROUP}" "${user}" || die MSG-C4A8 "failed to add ${user} to ${OPERATORS_GROUP}"
        log "added ${user} to group ${OPERATORS_GROUP}"
    fi

    label_operator_config "${user}"

    # The sandbox account needs a `systemd --user instance` without an interactive login: its nvm-update timer and each
    # ai-tools-run session unit run there, and it has no login shell, so only linger keeps that instance alive.
    # An operator runs claude from its own active login, so it does not need linger here; enabling operator linger
    # for other reasons is host policy.
    log "enabling linger for ${SANDBOX_USER}"
    loginctl enable-linger "${SANDBOX_USER}"  2>/dev/null \
        || warn MSG-Q4K4 "could not enable linger for ${SANDBOX_USER}"

    wire_dedup "${user}"
    log "operator ${user} added"
    report_operator_role "${user}"
    # ai-ops membership applies to NEW login sessions; an already-open shell keeps the credential set it had at login,
    # and the launch wrapper gates on that live set. Name the activation step so the operator's first claude launch does
    # not hit the stale-session refusal.
    log "${user}: start a new login session (or run 'newgrp ${OPERATORS_GROUP}') before launching claude -- ${OPERATORS_GROUP} membership does not apply to already-open shells"
    # Only a reconciling re-run reaches this: a first enrolment already refused. Said last because the reason was
    # reported several steps and one prompt back, and non-zero so an unattended caller reads it without parsing
    # the output.
    if (( seeded )); then
        warn MSG-P9K2 "config not seeded for ${user}, which stays enrolled -- without allowed-projects in ~/.config/ai-tools it cannot start a session or claim a project"
        warn "    re-run once the reason above is cleared: sudo ai-tools-admin operators add ${user}"
        exit 1
    fi
}

op_remove() {
    local user="${1:-}"
    [[ -n "${user}" ]] || reject MSG-S3E9 "operators remove: name the user to withdraw"
    ai_tools_operator__load_operators || true
    if ! in_list "${user}"; then
        log "${user} is not an operator; nothing to remove"
        return 0
    fi
    local kept=() n
    for n in "${AI_TOOLS_OPERATOR__OPERATORS[@]}"; do [[ "${n}" == "${user}" ]] || kept+=("${n}"); done
    write_operators "${kept[@]}"
    log "removed ${user} from OPERATORS"
    # Drop ai-ops membership; leave the account's own allowlist and secret patterns (their data).
    gpasswd -d "${user}" "${OPERATORS_GROUP}" >/dev/null 2>&1 \
        || warn MSG-J9F9 "could not remove ${user} from ${OPERATORS_GROUP}"
    log "removed ${user} from group ${OPERATORS_GROUP}"
}

op_list() {
    if ai_tools_operator__load_operators; then
        printf '%s\n' "${AI_TOOLS_OPERATOR__OPERATORS[@]}"
    else
        log "no operators configured"
    fi
}

# ── selinux groups: optional policy-group management ─────────────────────────────────
# These load/unload the COMPILED ai_tools_<group>.pp staged in the package directory; the group set and text come
# from selinux-groups.lib.sh. Distinct from selinux/install-selinux.sh, which compiles a group from source in a repo
# checkout -- this runs on any installed host.

# require_selinux: guard shared by every selinux command. Returns 1 (caller exits 0 -- no policy to manage) when SELinux
# is disabled; dies when semodule is absent (a real gap).
require_selinux() {
    if [[ "$(getenforce 2>/dev/null)" == "Disabled" ]]; then
        log "SELinux is disabled on this host -- no policy groups to manage"
        return 1
    fi
    command -v semodule >/dev/null 2>&1 || die MSG-A8Z7 "semodule not found -- install policycoreutils"
    return 0
}

# _sel_require_known_group <name>: refuse a name the registry does not carry, showing what this host does know. One
# situation for both verbs -- a name that is not a group is the same refusal whether it was to be loaded or unloaded,
# so the two arms share it rather than each writing it.
_sel_require_known_group() {
    ai_tools_selinux_groups__is_valid "$1" && return 0
    log "unknown group '$1'. Available groups:"; _selinux_usage_groups
    die MSG-D7Y7 "no such policy group: $1"
}

# _selinux_usage_groups: list the known groups (name + description) to stderr.
_selinux_usage_groups() {
    local entry
    for entry in "${AI_TOOLS_SELINUX_GROUPS__REGISTRY[@]}"; do
        printf '    %-10s %s\n' \
            "$(ai_tools_selinux_groups__get_name "${entry}")" \
            "$(ai_tools_selinux_groups__get_description "${entry}")" >&2
    done
}

# sel_enable <name>...: load each named group in turn. Several names are accepted because a toolchain declares the set
# it needs (selinux_groups in its manifest, ai-tools-providers(5)) and the status nudge prints that set as one command;
# each name is validated before any is loaded, so a typo refuses the whole command rather than loading half of it.
sel_enable() {
    [[ $# -ge 1 ]] || reject MSG-H7J4 "selinux groups enable: name the group(s) to load"
    require_selinux || return 0
    local name
    for name in "$@"; do
        [[ -n "${name}" && "${name}" != -* ]] || reject MSG-Q3Z5 "selinux groups enable: '${name}' is not a group name"
        _sel_require_known_group "${name}"
    done
    for name in "$@"; do _sel_enable_one "${name}"; done
}

_sel_enable_one() {
    local name="$1"
    if ai_tools_selinux_groups__is_loaded "${name}"; then
        log "group '${name}' is already loaded -- nothing to do"
        return 0
    fi
    # Experimental groups are unaudited drafts and are NOT on the shipped set. This tool loads only shipped, stable
    # modules; an experimental group must be compiled and verified against a real workload from a source checkout first
    # (install-selinux.sh does both), because it widens the sandbox domain's access beyond the repo-only core. Point
    # the operator there rather than loading an unaudited module.
    if ai_tools_selinux_groups__is_experimental "${name}"; then
        ai_tools_msg__warn MSG-C4F5 \
            "The '${name}' SELinux policy group is an EXPERIMENTAL, unaudited draft. It is not shipped prebuilt and cannot be enabled from here -- it widens the sandbox domain's access beyond the repo-only core and must be compiled and verified against a real workload from a source checkout first."
        log "compile, audit under permissive, and load it from a repo checkout:"
        log "    sudo selinux/install-selinux.sh enable-group ${name}"
        log "    (then re-run the bring-up loop in selinux/avc/ before relying on it)"
        log "docs: selinux/README.md, \"Optional policy groups\""
        die "'${name}' is experimental -- verify and enable it from source (see above)"
    fi
    local pp="${AI_TOOLS_SELINUX_GROUPS__PACKAGE_DIR}/ai_tools_${name}.pp"
    [[ -f "${pp}" ]] || die MSG-S5P4 "compiled module ${pp} not found -- reinstall ai-tools-selinux, or from a checkout: sudo selinux/install-selinux.sh build"
    # A former module still loaded from before this group was renamed or split out of it goes in the same transaction,
    # together with every OTHER current group that former module's rules became, so the host never holds both rule sets,
    # never loses a capability the old module carried, and a failed load leaves the old module in place.
    local former sibling
    local -a swap_args=( -i "${pp}" )
    if former="$(ai_tools_selinux_groups__get_former_module "${name}" 2>/dev/null)" \
            && ai_tools_selinux_groups__is_module_loaded "${former}"; then
        while IFS= read -r sibling; do
            [[ -n "${sibling}" && "${sibling}" != "${name}" ]] || continue
            [[ -f "${AI_TOOLS_SELINUX_GROUPS__PACKAGE_DIR}/ai_tools_${sibling}.pp" ]] || continue
            swap_args+=( -i "${AI_TOOLS_SELINUX_GROUPS__PACKAGE_DIR}/ai_tools_${sibling}.pp" )
        done < <(ai_tools_selinux_groups__list_groups_from_former_module "${former}")
        log "replacing the loaded '${former}' module with the group(s) its rules became"
        semodule -r "${former}" "${swap_args[@]}" || die MSG-M8X2 "semodule failed to replace ${former}"
    else
        log "loading group: ai_tools_${name}"
        semodule "${swap_args[@]}" || die MSG-W7T4 "semodule failed to load ${pp}"
    fi
    _restore_group_static_labels
    log "group '${name}' enabled"
    log "re-run the SELinux bring-up loop (selinux/avc/) to catch any new denials from the"
    log "expanded surface before relying on it under enforcing"
}

sel_disable() {
    local name="${1:-}"
    [[ $# -le 1 ]] || reject MSG-W8A7 "selinux groups disable: one group name at a time"
    [[ -n "${name}" && "${name}" != -* ]] || reject MSG-P2T5 "selinux groups disable: name the group to unload"
    require_selinux || return 0
    _sel_require_known_group "${name}"
    if ai_tools_selinux_groups__is_loaded "${name}"; then
        semodule -r "ai_tools_${name}" || die MSG-Q6F4 "semodule failed to remove ai_tools_${name}"
        _restore_group_static_labels
        log "group '${name}' disabled"
    else
        log "group '${name}' is not loaded -- nothing to do"
    fi
}

# _restore_group_static_labels: after a group is loaded or unloaded, restore the labels its static file contexts decide.
# The one tree a group's .fc names is the sandbox-clone area (the dotnet group maps a clone's build output
# to ai_tools_project_build_t), so that is what is restored; a claimed project's build rule is a per-project local rule
# written at claim time and is not the group's.
_restore_group_static_labels() {
    local clones=/var/opt/ai-tools/sandbox-projects
    [[ -d "${clones}" ]] && command -v restorecon >/dev/null 2>&1 || return 0
    restorecon -FR "${clones}" >/dev/null 2>&1 \
        || warn MSG-C9W9 "could not restore labels under ${clones}; run: sudo restorecon -FR ${clones}"
    return 0
}

# sel_list is a read-only REPORT, not operational output, so it renders as a plain section (like the CLI's
# `providers`/`projects list`) instead of `log`'s per-line `ai-tools-admin:` prefix. The core module uses the same
# bracketed [LOADED]/[disabled] state column as the group rows for one legend.
sel_list() {
    require_selinux || return 0
    local core_state='[disabled]' modules
    # Captured, not piped into `grep -q`: an early-exiting reader makes semodule die of SIGPIPE and pipefail then
    # reports the probe failed -- see ai_tools_selinux_groups__is_loaded.
    modules="$(semodule -l 2>/dev/null || true)"
    grep -qx 'ai_tools' <<<"${modules}" && core_state='[LOADED]  '
    printf '\nSELinux policy groups\n\n'
    printf '  %s core module (ai_tools) -- the confinement domain; DAC-only when disabled\n\n' "${core_state}"
    printf '  optional groups (all default: disabled)\n'
    local entry name desc stability state
    for entry in "${AI_TOOLS_SELINUX_GROUPS__REGISTRY[@]}"; do
        name="$(ai_tools_selinux_groups__get_name "${entry}")"
        desc="$(ai_tools_selinux_groups__get_description "${entry}")"
        stability="$(ai_tools_selinux_groups__get_stability "${entry}")"
        if ai_tools_selinux_groups__is_loaded "${name}"; then state='[LOADED]  '; else state='[disabled]'; fi
        printf '    %s %-9s %-15s %s\n' "${state}" "${name}" "(${stability})" "${desc}"
    done
    printf '\n  toggle       : sudo ai-tools-admin selinux groups enable <name> | disable <name>\n'
    printf '  experimental : not shipped prebuilt -- enable from a source checkout with\n'
    printf '                 sudo selinux/install-selinux.sh enable-group <name>\n'
}

# ── system bootstrap: provision the sandbox account and its toolchain ────────────────────────
# Execs the provisioning helper, which creates the ai-tools account and its /opt/ai-tools home if they are absent,
# installs nvm and Node as that account, then installs each enabled agent's npm package and points its launcher
# at the result. The helper keeps its own name, path and output; what this command changes is how an administrator
# reaches it.
#
# The provisioning logic stays a separate file rather than moving in here: it is long, runs unattended
# from the container selftest, and reaches the network, none of which this dispatcher
# does.
#
# Scope defaults to the MINIMUM that works, and a bare run takes that default: the toolchain and the enabled agents,
# which is what a first host needs. `--scope full` also reaches every enabled integration, through each one's own
# contributed `bootstrap` -- which is why full scope needed the admin-commands seam before it could exist. It is spelled
# as a switch rather than a positional word because every other verb here takes a resource identifier in that slot.
system_bootstrap() {
    local scope=minimal
    local -a helper_args=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --scope)
                [[ $# -ge 2 ]] || reject MSG-V5J3 "system bootstrap: --scope takes a value (minimal|full)"
                scope="$2"; shift 2 ;;
            # The unattended form of the agent choice the helper otherwise asks for; the helper checks each name
            # against the installed manifests before it writes anything, so the value passes through as typed.
            --agents)
                [[ $# -ge 2 ]] || reject MSG-X8K6 "system bootstrap: --agents takes a value (NAME[,NAME...])"
                helper_args+=(--agents "$2"); shift 2 ;;
            *)  reject MSG-H5Z4 "system bootstrap: unknown argument '$1' (--scope minimal|full, --agents NAME[,NAME...])" ;;
        esac
    done
    case "${scope}" in
        minimal|full) ;;
        *) reject MSG-A8G5 "system bootstrap: unknown scope '${scope}' (minimal|full)" ;;
    esac
    [[ -x "${BOOTSTRAP_BIN}" ]] \
        || die MSG-D9W6 "the provisioning helper is not installed: ${BOOTSTRAP_BIN} -- install ai-tools-integration-nodejs"
    # Minimal scope has no step after the helper, so it hands the process over rather than wrapping it: the helper's
    # exit status is this command's, unmediated.
    [[ "${scope}" == full ]] || exec "${BOOTSTRAP_BIN}" "${helper_args[@]+"${helper_args[@]}"}"
    "${BOOTSTRAP_BIN}" "${helper_args[@]+"${helper_args[@]}"}" || die MSG-Y6F3 "the toolchain bootstrap failed -- no integration was reached"
    bootstrap_integrations
}

# bootstrap_integrations: run the `bootstrap` of each ENABLED integration that contributes one. Enablement, not
# installation, is what "everything this host runs" means, so this reads the enabled set where the dispatch reads
# the installed one.
#
# Every integration is attempted and each failure named before the command exits non-zero: an administrator provisioning
# a host wants every outcome, not the first one that went wrong. An enabled integration that does not contribute
# a command fragment is reported and skipped -- base cannot assume any particular package is installed.
bootstrap_integrations() {
    declare -F ai_tools_providers__list_enabled_integrations >/dev/null 2>&1 \
        || die MSG-Z4D6 "the provider resolver is unavailable, so the enabled integrations cannot be resolved -- the toolchain itself is provisioned; re-run without --scope to confirm"
    local -a enabled=()
    mapfile -t enabled < <(ai_tools_providers__list_enabled_integrations)
    if [[ "${#enabled[@]}" -eq 0 ]]; then
        log "no integration is enabled in ${OPERATOR_CONF} -- nothing further to provision"
        return 0
    fi
    admin_domains
    admin_commands_trusted \
        || die MSG-D9U7 "refusing every contributed command while ${ADMIN_COMMANDS_DIR} holds a file that is not root's alone (named above); the toolchain itself is provisioned -- ${ADMIN_COMMANDS_TAMPER_REMEDY}"
    local integration known known_domain failed=0
    for integration in "${enabled[@]}"; do
        known=no
        for known_domain in "${ADMIN_DOMAINS[@]}"; do
            [[ "${known_domain}" == "${integration}" ]] && { known=yes; break; }
        done
        if [[ "${known}" != yes ]]; then
            log "${integration}: contributes no command -- nothing to provision"
            continue
        fi
        # The same gates an administrator's own `ai-tools-admin <domain> bootstrap` passes, so a fragment reached
        # from here is vetted exactly as one that is typed.
        if ! admin_command_check "${integration}"; then
            emit_coded warn "${_admin_command_reason}" "${integration}: "
            failed=1
            continue
        fi
        # Read from the declaration rather than learned by running it: an integration whose commands are something other
        # than provisioning has no work here, and running its fragment to find that out would report a rejected command
        # line as a failed provision.
        if ! admin_command_has_verb bootstrap; then
            log "${integration}: declares no bootstrap verb -- nothing to provision"
            continue
        fi
        log "provisioning the ${integration} integration"
        if ! "${ADMIN_COMMANDS_DIR}/${integration}" bootstrap; then
            warn MSG-J2Y8 "provisioning failed -- ${integration}: its bootstrap failed, with the cause above and in its own log"
            failed=1
        fi
    done
    (( failed == 0 )) || die MSG-V8V7 "one or more integrations did not provision"
}

# ── system entrypoints relabel: reconcile each enabled agent's entrypoint ─────────────────────
# Two steps in the helper, in this order: VERIFY the binary against the checksum its vendor signed and record it
# in that agent's pin, which ai-tools-run compares the binary against at launch; then RELABEL it to ai_tools_exec_t,
# which an nvm-update leaves as bin_t so the domain transition stops firing. Takes no path -- the helper resolves
# the entrypoints from the agent manifests.
entrypoints_relabel() {
    [[ $# -eq 0 ]] || reject MSG-A4J6 "system entrypoints relabel: takes no arguments"
    [[ -x "${RELABEL_ENTRYPOINT_BIN}" ]] \
        || die MSG-F5R8 "the entrypoint relabel helper is not installed: ${RELABEL_ENTRYPOINT_BIN} -- install ai-tools-integration-nodejs"
    log "reconciling the agent entrypoints (verify, then relabel)"
    # Cleared, not merely left unset: the guarantee that this command re-fetches the vendor's signed manifest holds
    # however it was invoked, rather than resting on sudo scrubbing the environment (updater.rule.md). The unattended
    # callers set it themselves.
    unset AI_TOOLS_ENTRYPOINT_PIN_REUSE
    exec "${RELABEL_ENTRYPOINT_BIN}"
}

# ── system post-upgrade: reconcile the .rpmnew files an upgrade leaves ─────────────────────── rpm keeps
# an operator-modified %config(noreplace) file and parks the package's copy beside it as <file>.rpmnew. Choosing
# between the two is a judgement call about the operator's own configuration, so no scriptlet makes it: this is
# the explicit, interactive command that does, and it is what the install output points at. Every treatment confirms
# first, backs the file up before writing, and names each path it touched. A .rpmnew survives the run, whichever
# treatment it got: each one leaves part of the reconciliation to the operator, and the copy is the baseline that edit
# is made from, so each block closes by naming the file to remove once the merge is done.
#
# The treatment follows the file's CONTENT, rather than one generic merge covering all three:
#   json    hook DECLARATIONS merge additively -- they are control plane, and a declaration the
#           file lacks means a shipped hook installs but no event invokes it. The permission arrays
#           are the host's and stay exactly as written (claude-settings.rule.md).
#   keyval  reported, never rewritten. An absent key already means its default, so a stale file
#           costs knowledge rather than behaviour, and its layout is the operator's own prose. Its
#           merge opens <copy>-merge, the package copy with the file's own values carried in.
#   review  shown only. A tool does not merge the sudo grant.
#   show    a file the registry does not name: named with the command that compares it, never printed, since
#           a kept config of another package may hold a credential (endpoints/typesafe.conf holds an API key).
readonly -a POSTUPGRADE_FILES=(
    "/opt/ai-tools/.claude/settings.json|json|Claude Code settings"
    "/etc/ai-tools/operator.conf|keyval|host options"
    "/etc/sudoers.d/ai-tools|review|sudoers grant"
)
# The directories the packages of this stack ship a kept config file into. POSTUPGRADE_FILES is base's and cannot name
# what an agent or integration package ships, so a .rpmnew under one of these that the registry does not name is found
# here and reported: a KEY=value `*.conf` with the keyval treatment, anything else with `show`.
readonly -a POSTUPGRADE_DIRS=(/etc/ai-tools /etc/codex /opt/ai-tools/.claude /opt/ai-tools/.codex)

# AI_TOOLS_POSTUPGRADE_ROOT prefixes every path in that registry, so the test suite drives this command against fixtures
# in its own /tmp testdir instead of the live host's control plane. It is a ROOT-ONLY test hook of the same shape
# and standing as AI_TOOLS_ALLOWLIST: sudo strips the environment (env_reset, and this name is not in env_keep)
# and the helper is reachable only as root, so neither an operator nor the agent can set it, and a caller who could is
# one that may already edit these files outright. Unset in production, where the registry paths are absolute.

# _pu_diff <deployed> <rpmnew> [<label> <label>]: show what the package would change, indented, each side named by its
# label where the files compared are temporary copies. Colourized through colordiff when the host has it AND stdout is
# a terminal: colordiff is an EPEL package on RHEL, so it is used where present and never depended on, and the terminal
# test keeps escape sequences out of a redirected run, the way every other message this project prints degrades
# when piped. diff(1) is optional too -- without it the report continues and only the difference itself is missing.
_pu_diff() {
    local differ=diff
    local -a labels=()
    command -v diff >/dev/null 2>&1 \
        || { warn MSG-Q4S8 "diff is not installed, so the difference is not shown here -- install diffutils"; return 0; }
    [[ -t 1 ]] && command -v colordiff >/dev/null 2>&1 && differ=colordiff
    [[ -n "${3-}" ]] && labels=(--label "$3" --label "$4")
    "${differ}" -u "${labels[@]}" "$1" "$2" 2>/dev/null | sed 's/^/    /' || true
}

# _pu_say <level> <text>: one report line about the file being reconciled, prefixed by that file's name (_PU_NAME)
# so a line names what it is about. The level says whether the operator has anything to do: `ok` is a green check,
# `info` plain, `act` yellow -- something to carry over or decide -- and `err` red. Colour only on a terminal, like
# the CLI's own report, so a captured run stays plain text.
if [[ -t 1 ]]; then
    readonly _PU_GRN=$'\033[32m' _PU_YEL=$'\033[33m' _PU_RED=$'\033[31m' _PU_DIM=$'\033[2m' _PU_RST=$'\033[0m'
else
    readonly _PU_GRN='' _PU_YEL='' _PU_RED='' _PU_DIM='' _PU_RST=''
fi
_PU_NAME=""
# The lines that ask the operator to act, counted so the closing line can say whether anything needs review,
# and the errors among them, which colour that line red rather than yellow.
_PU_ATTENTION=0
_PU_ERRORS=0
_pu_say() {
    local level="$1" text="$2"
    [[ "${level}" == act || "${level}" == err ]] && _PU_ATTENTION=$(( _PU_ATTENTION + 1 ))
    [[ "${level}" == err ]] && _PU_ERRORS=$(( _PU_ERRORS + 1 ))
    case "${level}" in
        ok)  printf '  %s: %s✓%s %s\n' "${_PU_NAME}" "${_PU_GRN}" "${_PU_RST}" "${text}" ;;
        act) printf '  %s:   %s%s%s\n' "${_PU_NAME}" "${_PU_YEL}" "${text}" "${_PU_RST}" ;;
        err) printf '  %s:   %s%s%s\n' "${_PU_NAME}" "${_PU_RED}" "${text}" "${_PU_RST}" ;;
        *)   printf '  %s:   %s\n' "${_PU_NAME}" "${text}" ;;
    esac
}

# _pu_leave <rpmnew> [merged]: close a file's block by naming what is left to do with the copy. This function prints,
# and is the only thing any treatment does about the .rpmnew, so the copy survives every run. Each treatment leaves part
# of the reconciliation to the operator -- the permission rules here, the whole edit for a KEY=value file, the adoption
# of a sudo grant -- and the copy is the only record of what the package shipped, so deleting it would take away
# the baseline that edit is made from. It is the operator's file to remove, once the merge they wanted is in place.
# `merged` says nothing is left to carry over, so the removal is all that remains and its command is printed.
_pu_leave() {
    local rpmnew="$1"
    if [[ "${2:-}" == merged ]]; then
        _pu_say ok "nothing is left to carry over -- remove the copy when you are ready:"
        _pu_say info "  sudo rm ${rpmnew}"
    else
        if [[ -f "${rpmnew}-merge" ]]; then
            _pu_say act "carry over what you want by hand, then remove ${rpmnew} and ${rpmnew}-merge"
        else
            _pu_say act "carry over what you want by hand, then remove ${rpmnew}"
        fi
    fi
}

# _pu_installed_day: the day this command's own file was installed, as YYYYMMDD. A copy dated before it is
# from an earlier installation. Compared by day because rpm gives every file of one package its build time, and files
# built in one run can differ by seconds.
_pu_installed_day() { date -r "${BASH_SOURCE[0]}" +%Y%m%d 2>/dev/null; }

# _pu_provenance <copy>: the one line under a file's headline, naming the copy, which route left it (the package's
# .rpmnew, or the installer's .shipped from a from-source install) and its date. A copy dated before this installation
# -- a from-source install after an RPM one, or a copy left from an earlier upgrade -- is an earlier version's template,
# which is said beside it.
_pu_provenance() {
    local copy="$1" dated day installed route="package copy"
    [[ "${copy}" == *.shipped ]] && route="installer copy"
    dated="$(date -r "${copy}" +%Y-%m-%d 2>/dev/null)" || dated="an unknown date"
    day="${dated//-/}"
    installed="$(_pu_installed_day)" || installed=""
    if [[ -n "${installed}" && "${day}" =~ ^[0-9]{8}$ && "${day}" < "${installed}" ]]; then
        printf '%s: %s, dated %s -- older than this installation, so an earlier version'"'"'s template' \
            "${route}" "${copy}" "${dated}"
    else
        printf '%s: %s, dated %s' "${route}" "${copy}" "${dated}"
    fi
}

# _pu_json <deployed> <rpmnew>: merge the hook declarations the deployed file does not carry. The addition list comes
# from running the merge on a THROWAWAY COPY first, so what the operator confirms is the exact set the real merge adds
# rather than a promise of one, and a merge that would fail says so before the real file is touched.
_pu_json() {
    local deployed="$1" rpmnew="$2" scratch status=0
    ai_tools_settings_merge__require_jq \
        || { warn MSG-A5Z7 "jq is missing, so this file's JSON cannot be read -- merge it by hand"
             _PU_ATTENTION=$(( _PU_ATTENTION + 1 )); return 0; }

    scratch="$(mktemp -d)" || return 0
    cp -p "${deployed}" "${scratch}/probe" 2>/dev/null || { rm -rf "${scratch}"; return 0; }
    ai_tools_settings_merge__merge_hook_declarations "${scratch}/probe" "${rpmnew}" || status=$?
    rm -rf "${scratch}"

    case "${status}" in
    1)  _pu_say ok "hook declarations are already current"
        _pu_settings_rest "${deployed}" "${rpmnew}"
        return 0 ;;
    2)  warn MSG-Q4F6 "cannot merge the hook declarations: ${ai_tools_settings_merge__reason}"
        _pu_say err "unchanged -- copy the \"hooks\" block from ${rpmnew} by hand"
        return 0 ;;
    esac

    local line
    if (( ${#ai_tools_settings_merge__added[@]} > 0 )); then
        _pu_say act "hook declarations this version adds:"
        for line in "${ai_tools_settings_merge__added[@]}"; do _pu_say act "  + ${line}"; done
    fi
    if (( ${#ai_tools_settings_merge__removed[@]} > 0 )); then
        _pu_say act "hook declarations declared twice, of which the merge keeps the first:"
        for line in "${ai_tools_settings_merge__removed[@]}"; do _pu_say act "  - ${line}"; done
    fi
    _pu_say info "the permission rules stay as written"
    ai_tools_msg__confirm "  Merge these into ${deployed}?" y || { _pu_say act "skipped -- ${deployed} unchanged"; return 0; }

    status=0
    ai_tools_settings_merge__merge_hook_declarations "${deployed}" "${rpmnew}" || status=$?
    if (( status >= 2 )); then
        warn MSG-X9F8 "the merge failed: ${ai_tools_settings_merge__reason} -- ${deployed} is unchanged"
        _PU_ATTENTION=$(( _PU_ATTENTION + 1 ))
        _PU_ERRORS=$(( _PU_ERRORS + 1 ))
        return 0
    fi
    _pu_say ok "merged -- the previous file is saved as ${ai_tools_settings_merge__backup}"

    # Close against what is left. Once the permission rules match too, the .rpmnew has no difference left to report,
    # so the operator is told the removal is all that remains.
    if command -v diff >/dev/null 2>&1 && diff -q "${deployed}" "${rpmnew}" >/dev/null 2>&1; then
        _pu_say ok "now matches the shipped file exactly"
        _pu_leave "${rpmnew}" merged
    else
        _pu_settings_rest "${deployed}" "${rpmnew}"
    fi
}

# _pu_settings_rest <deployed> <rpmnew>: report what a settings file differs in once its hook declarations are current.
# The permission rule lists are compared as sets (ai_tools_settings_merge__find_permission_gaps) and every other setting
# with its keys sorted (ai_tools_settings_merge__read_settings_rest), so a list's order, a key's place in the object,
# and how hooks are grouped are not reported. A rule the copy carries and the file does not is named for the operator
# to add; a rule only the file carries is the host's and is listed without being counted; with neither left, the copy
# does not carry anything the file lacks.
_pu_settings_rest() {
    local deployed="$1" rpmnew="$2" gaps kind list rule scratch pending=0
    local -a missing=() extra=()
    if ! gaps="$(ai_tools_settings_merge__find_permission_gaps "${deployed}" "${rpmnew}")"; then
        _pu_say act "the permission rules were not compared (jq is missing or a file is not valid JSON):"
        _pu_diff "${deployed}" "${rpmnew}"
        _pu_leave "${rpmnew}"
        return 0
    fi
    while IFS=$'\t' read -r kind list rule; do
        case "${kind}" in
            missing) missing+=("${list}: ${rule}") ;;
            extra)   extra+=("${list}: ${rule}") ;;
        esac
    done <<< "${gaps}"
    if (( ${#missing[@]} > 0 )); then
        _pu_say act "rules this version ships that the file does not carry -- add them unless you removed them on purpose:"
        for rule in "${missing[@]}"; do _pu_say act "  ${rule}"; done
        pending=1
    fi
    if (( ${#extra[@]} > 0 )); then
        _pu_say info "rules the file carries that the package copy does not, kept as yours:"
        for rule in "${extra[@]}"; do _pu_say info "  ${rule}"; done
    fi
    scratch="$(mktemp -d)" || scratch=""
    if [[ -n "${scratch}" ]] && ai_tools_settings_merge__read_settings_rest "${deployed}" > "${scratch}/file" \
            && ai_tools_settings_merge__read_settings_rest "${rpmnew}" > "${scratch}/copy" \
            && ! cmp -s "${scratch}/file" "${scratch}/copy"; then
        _pu_say act "other settings differ (key order ignored):"
        _pu_diff "${scratch}/file" "${scratch}/copy" "${deployed}" "${rpmnew}"
        pending=1
    fi
    [[ -n "${scratch}" ]] && rm -rf "${scratch}"
    if (( pending )); then
        _pu_say info "merge into the left (live file); new changes are on the right:"
        printf '      %s\n' "$(_pu_merge_command "${deployed}" "${rpmnew}")"
        _pu_leave "${rpmnew}"
    else
        _pu_say ok "the rest matches the package copy, apart from order and layout"
        _pu_leave "${rpmnew}" merged
    fi
}

# _pu_prose <file>: the file's comment prose as one word stream -- comment lines with their `#` removed, the commented
# defaults (`#KEY=value`, `# KEY=value`) left out as settings, and whitespace collapsed -- so a comment that was only
# re-wrapped reads as unchanged and a reworded one does not.
_pu_prose() {
    sed -n '/^[[:space:]]*#/{/^[[:space:]]*#[[:space:]]\{0,1\}[A-Za-z_][A-Za-z0-9_]*=/d;s/^[[:space:]]*#//;p;}' "$1" \
        2>/dev/null | tr -s '[:space:]' ' '
}

# _pu_set_keys <array-name> <file>: each key <file> assigns, once, in file order. A commented default is not a key
# the file sets.
_pu_set_keys() {
    local -n _pu_set_out="$1"
    local line key
    _pu_set_out=()
    while IFS= read -r line || [[ -n "${line}" ]]; do
        line="${line#"${line%%[![:space:]]*}"}"
        [[ -z "${line}" || "${line}" == '#'* || "${line}" != *=* ]] && continue
        key="${line%%=*}"; key="${key%"${key##*[![:space:]]}"}"
        [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ && " ${_pu_set_out[*]} " != *" ${key} "* ]] && _pu_set_out+=("${key}")
    done < "$2"
}

# _pu_own_keys <array-name> <deployed> <rpmnew>: the keys the deployed file sets whose value the copy does not set
# the same way -- the operator's own settings, which is what most of such a difference is. Names only: a kept config may
# hold a credential, so no value is printed or kept beyond the comparison.
_pu_own_keys() {
    local -n _pu_own_out="$1"
    local deployed="$2" rpmnew="$3" key live shipped
    local -a set_keys=()
    _pu_own_out=()
    _pu_set_keys set_keys "${deployed}"
    for key in "${set_keys[@]}"; do
        live="$(ai_tools_conf__print_value "${deployed}" "${key}")" || true
        if shipped="$(ai_tools_conf__print_value "${rpmnew}" "${key}")" && [[ "${shipped}" == "${live}" ]]; then
            continue
        fi
        _pu_own_out+=("${key}")
    done
}

# _pu_merge_copy <out-var> <deployed> <rpmnew>: write <rpmnew>-merge, the package copy with this host's settings carried
# in, and set <out-var> to its path. Each key the live file sets replaces the copy's first line for that key, set
# or commented, with the live file's own line, and a later assignment of the key in the copy is dropped. The merge then
# shows the options and prose the version changed, and not each value the host chose, which differs from the template
# by design and would otherwise be a difference to review and leave. A key the copy does not mention is not carried:
# the live line then stands alone on the left, which is a difference worth seeing. The copy sits beside the package copy
# it is made from -- an .rpmnew, or the .shipped a from-source install leaves -- and exists only while a merge is
# pending: each run removes the ones it did not rewrite (_pu_prune_merge_copies). It is 0600 root whatever the live
# file's mode, since it carries the live values and a kept KEY=value file may hold a credential, and its name does not
# end in .rpmnew or .shipped, so no scan here reads it as a package copy. The comparison with the live file ignores
# trailing newlines, so a file without a final one is not offered a merge for it. Returns 0 with the copy written
# and recorded in _PU_MERGE_WRITTEN, 2 with no copy when it would equal the live file -- the package copy then differs
# only in the host's values -- and 1 with <out-var> empty when it could not be written.
_pu_merge_copy() {
    local -n _pu_merge_out="$1"
    local deployed="$2" rpmnew="$3" line key trimmed scratch
    local -a set_keys=()
    local -A live_line=() carried_keys=()
    _pu_merge_out=""
    _pu_set_keys set_keys "${deployed}"
    while IFS= read -r line || [[ -n "${line}" ]]; do
        trimmed="${line#"${line%%[![:space:]]*}"}"
        [[ -z "${trimmed}" || "${trimmed}" == '#'* || "${trimmed}" != *=* ]] && continue
        key="${trimmed%%=*}"; key="${key%"${key##*[![:space:]]}"}"
        [[ " ${set_keys[*]} " == *" ${key} "* ]] && live_line["${key}"]="${line}"
    done < "${deployed}"
    # mktemp creates the file 0600 and owned by this root process, before a line of the live file is written to it.
    scratch="$(mktemp "${rpmnew%/*}/.${rpmnew##*/}-merge.XXXXXX" 2>/dev/null)" || return 1
    while IFS= read -r line || [[ -n "${line}" ]]; do
        if [[ "${line}" =~ ^[[:space:]]*#[[:space:]]?([A-Za-z_][A-Za-z0-9_]*)= ]]; then
            key="${BASH_REMATCH[1]}"
            if [[ -n "${live_line[${key}]+set}" && -z "${carried_keys[${key}]+set}" ]]; then
                printf '%s\n' "${live_line[${key}]}"; carried_keys["${key}"]=1; continue
            fi
        elif [[ "${line}" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*= ]]; then
            key="${BASH_REMATCH[1]}"
            if [[ -n "${live_line[${key}]+set}" ]]; then
                [[ -n "${carried_keys[${key}]+set}" ]] && continue
                printf '%s\n' "${live_line[${key}]}"; carried_keys["${key}"]=1; continue
            fi
        fi
        printf '%s\n' "${line}"
    done < "${rpmnew}" > "${scratch}" || { rm -f "${scratch}"; return 1; }
    if [[ "$(< "${deployed}")" == "$(< "${scratch}")" ]]; then
        rm -f "${scratch}"
        return 2
    fi
    mv -f "${scratch}" "${rpmnew}-merge" 2>/dev/null || { rm -f "${scratch}"; return 1; }
    _PU_MERGE_WRITTEN+=("${rpmnew}-merge")
    _pu_merge_out="${rpmnew}-merge"
}

# The merge copies this run wrote, so the closing prune keeps them.
_PU_MERGE_WRITTEN=()

# _pu_prune_merge_copies <root>: remove each root-owned <copy>-merge under the scanned locations that this run did not
# write, naming it. A merge copy carries the live file's values, so it exists only while the report asks for a merge:
# once the operator has merged, removed the package copy, or a .shipped copy stays behind as recovery material, the next
# run removes it. A same-named file of another owner -- one the sandbox account placed in its own config directory -- is
# not this command's to remove and is left as it is. Removal unlinks the name and does not follow it.
_pu_prune_merge_copies() {
    local root="$1" dir copy written
    local -a copies=()
    for dir in "${POSTUPGRADE_DIRS[@]}"; do
        [[ -d "${root}${dir}" ]] || continue
        mapfile -t -O "${#copies[@]}" copies < <(find "${root}${dir}" -maxdepth 3 -type f -uid 0 \
            \( -name '*.rpmnew-merge' -o -name '*.shipped-merge' \) 2>/dev/null | sort -u)
    done
    for copy in "${copies[@]+"${copies[@]}"}"; do
        for written in "${_PU_MERGE_WRITTEN[@]+"${_PU_MERGE_WRITTEN[@]}"}"; do
            [[ "${copy}" == "${written}" ]] && continue 2
        done
        rm -f "${copy}" && printf '%s• removed %s -- no merge is pending for it%s\n' "${_PU_DIM}" "${copy}" "${_PU_RST}"
    done
}

# _pu_keyval <deployed> <rpmnew>: report and never write. A KEY=value config is mostly prose -- commented option blocks
# whose layout is the operator's -- and merging prose would need a convention an operator has to learn before they can
# predict it. Name the options the new version documents that this file does not mention, the operator's own settings,
# and whether the comments changed, and leave the edit to them. The difference is named as a command and not printed:
# a kept KEY=value file may hold a credential. The removal is offered only when every option is mentioned and the prose
# is the same, since otherwise the copy still holds something the live file lacks.
_pu_keyval() {
    local deployed="$1" rpmnew="$2" key page
    local -a new_keys=() own_keys=()
    local carried=0
    if ai_tools_conf__find_new_keys new_keys "${deployed}" "${rpmnew}"; then
        carried=1
        _pu_say act "options this version documents that the file does not mention:"
        for key in "${new_keys[@]}"; do _pu_say act "  ${key}"; done
        _pu_say info "each is optional, and an unmentioned key keeps its default -- copy the blocks you want"
    else
        _pu_say ok "every option this version documents is mentioned"
    fi
    _pu_own_keys own_keys "${deployed}" "${rpmnew}"
    (( ${#own_keys[@]} == 0 )) || _pu_say ok "set on this host, and kept as set: ${own_keys[*]}"

    page="ai-tools-${deployed##*/}"
    if [[ "$(_pu_prose "${deployed}")" != "$(_pu_prose "${rpmnew}")" ]]; then
        carried=1
        if [[ -r "/usr/local/share/man/man5/${page}.5" ]]; then
            _pu_say act "the comments differ from this version's -- the current wording is in ${page}(5)"
        else
            _pu_say act "the comments differ from this version's"
        fi
    fi
    local merge_copy merge_status=0
    _pu_merge_copy merge_copy "${deployed}" "${rpmnew}" || merge_status=$?
    case "${merge_status}" in
        0)  _pu_say info "merge into the left (live file); the right is this version's copy with the values set here carried in, so each difference is one the version made:"
            printf '      %s\n' "$(_pu_merge_command "${deployed}" "${merge_copy}")" ;;
        2)  _pu_say ok "the copy differs from the file only in the values set here" ;;
        *)  _pu_say info "merge into the left (live file); the right is this version's copy as shipped, so keep the left side of each key set here:"
            printf '      %s\n' "$(_pu_merge_command "${deployed}" "${rpmnew}")" ;;
    esac
    if (( carried )); then _pu_leave "${rpmnew}"; else _pu_leave "${rpmnew}" merged; fi
}

# _pu_review <deployed> <rpmnew>: show and stop. This file is the sudo grant itself.
_pu_review() {
    local deployed="$1" rpmnew="$2"
    _PU_ATTENTION=$(( _PU_ATTENTION + 1 ))
    ai_tools_msg__warn MSG-H8A2 \
        "This file defines the sudo grant that lets an operator launch the sandbox. It is shown, never merged: check any change yourself with visudo -c before adopting it."
    _pu_diff "${deployed}" "${rpmnew}"
    _pu_say info "adopt the packaged version with:  sudo visudo -c -f ${rpmnew} && sudo cp ${rpmnew} ${deployed}"
    _pu_leave "${rpmnew}"
}

# _pu_show <deployed> <rpmnew>: a file this command has no treatment for. Named, never printed or merged.
_pu_show() {
    local deployed="$1" rpmnew="$2"
    _pu_say act "this command does not merge this file -- merge into the left (live file); new changes are on the right:"
    printf '      %s\n' "$(_pu_merge_command "${deployed}" "${rpmnew}")"
    _pu_leave "${rpmnew}"
}

# _pu_clock_lines <array-name> <entry>...: fill the named array with "<when>  <path>" for every file or copy
# among the entries dated after the system clock (ai_tools_conf__find_paths_ahead_of_clock), and fail when there is one.
# The copy a file is compared with is the newest by date, so under a clock that is behind the comparison picks the wrong
# copy and the report would send the operator to merge from it: both runs ask this first and, with a finding, name
# the clock as the first thing to correct and make no comparison.
_pu_clock_lines() {
    local -n _pu_clock_out="$1"
    shift
    local entry file kind label copy when path
    local -a paths=()
    _pu_clock_out=()
    for entry in "$@"; do
        IFS='|' read -r file kind label copy <<< "${entry}"
        paths+=("${file}" "${copy}")
    done
    (( ${#paths[@]} > 0 )) || return 0
    while IFS=$'\t' read -r when path; do
        [[ -n "${path}" ]] && _pu_clock_out+=("${when}  ${path}")
    done < <(ai_tools_conf__find_paths_ahead_of_clock "${paths[@]}" || true)
    (( ${#_pu_clock_out[@]} == 0 ))
}

# _pu_entries <root>: one "<file>|<kind>|<label>|<copy>" line per file with a baseline copy waiting -- the registry
# first, then each one found under POSTUPGRADE_DIRS that the registry does not name. The copy is the newest beside
# the file of the package's .rpmnew and the installer's .shipped (ai_tools_conf__find_latest_copy), so a host
# whose install routes alternated is compared with the baseline that reached it last, whichever route brought it.
_pu_entries() {
    local root="$1" entry file kind label dir copy fd pid
    local -a named=()
    for entry in "${POSTUPGRADE_FILES[@]}"; do
        IFS='|' read -r file kind label <<< "${entry}"
        named+=("${root}${file}")
        [[ -f "${root}${file}" ]] && copy="$(ai_tools_conf__find_latest_copy "${root}${file}")" \
            && printf '%s|%s|%s|%s\n' "${root}${file}" "${kind}" "${label}" "${copy}"
    done
    for dir in "${POSTUPGRADE_DIRS[@]}"; do
        [[ -d "${root}${dir}" ]] || continue
        exec {fd}< <(find "${root}${dir}" -maxdepth 3 -type f \( -name '*.rpmnew' -o -name '*.shipped' \) 2>/dev/null | sort); pid=$!
        while IFS= read -r -u "${fd}" copy; do
            case "${copy}" in
                *.rpmnew)  file="${copy%.rpmnew}" ;;
                *.shipped) file="$(sed -E 's/\.[0-9]{8}(-[0-9]+)?\.shipped$//' <<< "${copy}")"
                           [[ "${file}" != "${copy}" ]] || continue ;;
            esac
            [[ -f "${file}" && " ${named[*]} " != *" ${file} "* ]] || continue
            named+=("${file}")
            copy="$(ai_tools_conf__find_latest_copy "${file}")" || continue
            if [[ "${file}" == *.conf ]]; then kind=keyval; else kind=show; fi
            printf '%s|%s|%s|%s\n' "${file}" "${kind}" "${file##*/}" "${copy}"
        done
        exec {fd}<&-
        # A directory the walk could not read is a copy this collector may have missed, so the collector fails rather
        # than reporting the files it did find as the whole set.
        wait "${pid}" || return 1
    done
    return 0
}

# _pu_ask_gaps <root>: name each ask entry the kept settings.json lacks for an installed command, with the line to add.
# Checked whether or not a .rpmnew is waiting: rpm parks a copy only on the upgrade that changed the shipped file,
# and the operator may have removed it since, while the entry stays missing. Reported and not written, since
# the permission rules are the host's.
_pu_ask_gaps() {
    local settings="$1/opt/ai-tools/.claude/settings.json" gaps line
    local -a entries=() fix=()
    [[ -f "${settings}" ]] || return 0
    if ! gaps="$(ai_tools_settings_merge__find_ask_gaps "${settings}" "$1")"; then
        warn MSG-B2E6 "the ask entries in ${settings} were not checked -- jq is missing or the file is not valid JSON"
        _PU_ATTENTION=$(( _PU_ATTENTION + 1 ))
        return 0
    fi
    [[ -n "${gaps}" ]] || return 0
    mapfile -t entries <<< "${gaps}"
    mapfile -t fix < <(ai_tools_settings_merge__format_ask_fix "${settings}" "${entries[@]}")
    _PU_NAME="${settings##*/}"
    ai_tools_msg__headline "${_PU_NAME} -- commands that run without asking" 1 "${settings}"
    _pu_say act "each of these sends data off the host, and the file does not ask before it runs:"
    for line in "${entries[@]}"; do _pu_say act "  ${line}"; done
    if (( ${#fix[@]} > 0 )); then
        _pu_say info "to have it ask, ${fix[0]}"
        # Printed bare, not through _pu_say, so the snippet copies out of the terminal without a prefix on each line.
        # Green as a diff's added lines are: every line of it is text to add.
        printf "      ${_PU_GRN}%s${_PU_RST}\n" "${fix[@]:1}"
    fi
    _pu_say info "this command does not edit the file, since the permission rules are yours -- re-run it to confirm"
}

# _pu_managed_pairs <root>: "<live>\t<reference>" per managed file an installed agent's manifest declares
# (ai_tools_providers__list_agent_managed_files), both paths under <root>. The manifests are read under <root> as well,
# so the unit suite drives the check against fixture manifests. Returns 1, printing nothing, when the provider library
# did not load.
_pu_managed_pairs() {
    local root="$1" agent live reference
    declare -F ai_tools_providers__list_agent_managed_files >/dev/null 2>&1 || return 1
    # shellcheck disable=SC2034  # read by the provider functions this one calls, which see it through dynamic scope
    local AI_TOOLS_AGENTS_DIR="${root}${AI_TOOLS_AGENTS_DIR}"
    while IFS=$'\t' read -r agent _ _; do
        [[ -n "${agent}" ]] || continue
        while IFS=$'\t' read -r live reference; do
            printf '%s\t%s\n' "${root}${live}" "${root}${reference}"
        done < <(ai_tools_providers__list_agent_managed_files "${agent}" 2>/dev/null)
    done < <(ai_tools_providers__list_installed_agents 2>/dev/null)
}

# _pu_merge_command <file> <copy>: the command that merges a file with its package copy side by side, the file
# on the left. sudoedit reads each as root into a temporary copy the invoking user owns, runs the merge tool
# as that user on both, and writes a copy back only when it changed, so the tool never runs as root; an edit made
# to the right-hand pane lands in the package copy, which is disposable. meld where it is installed, vimdiff otherwise.
_pu_merge_command() {
    local tool=vimdiff
    command -v meld >/dev/null 2>&1 && tool=meld
    printf 'SUDO_EDITOR=%s sudoedit %s %s' "${tool}" "$1" "$2"
}

# _pu_key_gaps <root>: name each key an agent's managed file does not set that its shipped copy does, with the command
# that merges the file with that copy. Checked whether or not a .rpmnew is waiting, for the reason _pu_ask_gaps states,
# and not written: the file is the host's, and a key arrives with the comment that explains it, which a key-by-key edit
# would not carry over.
_pu_key_gaps() {
    local root="$1" live reference keys key
    local -a missing=()
    while IFS=$'\t' read -r live reference; do
        [[ -f "${live}" && -f "${reference}" ]] || continue
        cmp -s "${live}" "${reference}" && continue
        keys="$(ai_tools_providers__find_managed_file_missing_keys "${live}" "${reference}")" || continue
        [[ -n "${keys}" ]] || continue
        mapfile -t missing <<< "${keys}"
        _PU_NAME="${live##*/}"
        ai_tools_msg__headline "${_PU_NAME} -- keys this release ships that the file does not set" 1 "${live}"
        for key in "${missing[@]}"; do _pu_say act "  ${key}"; done
        # sudoedit writes back an edited right-hand pane too, so the merge runs against a .rpmnew -- the one rpm left,
        # or one recreated here -- and the pristine copy the status reports compare with stays untouched.
        _pu_say info "merge into the left (live file); new changes are on the right:"
        # Printed bare, so each command copies out of the terminal whole.
        [[ -f "${live}.rpmnew" ]] || printf '      sudo cp %s %s\n' "${reference}" "${live}.rpmnew"
        printf '      %s\n' "$(_pu_merge_command "${live}" "${live}.rpmnew")"
        _pu_say info "then remove ${live}.rpmnew -- this command does not edit the file, since its contents are yours"
    done < <(_pu_managed_pairs "${root}")
}

# _pu_orphan_report <root>: a block per package copy whose file is gone, naming the two ways to settle it.
_pu_orphan_report() {
    local path
    while IFS= read -r path; do
        _PU_NAME="${path##*/}"
        ai_tools_msg__headline "${_PU_NAME} -- a package copy without its file" 1 "${path}"
        _pu_say act "the file this copy belongs to is gone -- restore it from the copy, or remove the copy:"
        printf '      sudo cp -p %s %s\n' "${path}" "${path%.rpmnew}"
        printf '      sudo rm %s\n' "${path}"
    done < <(_pu_orphans "$1")
}

# _pu_asset_report <root>: one block for the shared skills, subagents and orientation text whose live copy or agent link
# is not the one provisioning places. A missing asset or link needs the operator; an outdated or overridden one is named
# as their choice.
_pu_asset_report() {
    local state path detail
    local -a lines=()
    mapfile -t lines < <(_pu_assets "$1")
    (( ${#lines[@]} > 0 )) || return 0
    _PU_NAME="assets"
    ai_tools_msg__headline "shared skills, subagents and orientation" 1 "$1/opt/ai-tools"
    for path in "${lines[@]}"; do
        IFS=$'\t' read -r state _ path detail <<< "${path}"
        case "${state}" in
            asset-missing|asset-unlinked|error) _pu_say act "${path} -- ${detail}" ;;
            *)                                  _pu_say info "${path} -- ${detail}" ;;
        esac
    done
    if printf '%s\n' "${lines[@]}" | grep -qE '^(asset-missing|asset-unlinked)'; then
        _pu_say info "sudo ai-tools-admin system bootstrap seeds and links them again"
    fi
}

# _pu_sort_copies: read sidecar paths on stdin and print them grouped by file and in the order they were made --
# by date, then by the day's number, an unnumbered copy (the name an earlier release gave a day's first) counting as 1.
# A name that carries a date but not in that shape (a copy made by hand) is kept, after the day's numbered copies.
_pu_sort_copies() {
    awk 'BEGIN { OFS = "\t" }
        match($0, /\.[0-9]{8}(-[0-9]+)?\.(bak|shipped|retired)$/) {
            tail = substr($0, RSTART + 1); base = substr($0, 1, RSTART - 1)
            day = substr(tail, 1, 8); n = 1
            if (substr(tail, 9, 1) == "-") { n = substr(tail, 10); sub(/\..*/, "", n) }
            print base, day, n, $0; next
        }
        match($0, /\.[0-9]{8}/) { print substr($0, 1, RSTART - 1), substr($0, RSTART + 1, 8), 9999, $0; next }
        { print $0, "99999999", 9999, $0 }' \
        | sort -t $'\t' -k1,1 -k2,2 -k3,3n | cut -f4
}

# _pu_sidecars <root> [<reference>...]: list the dated copies _pu_copies names, so an operator learns they exist,
# leaving out each <reference> -- a .shipped _pu_report compared a file with, which has a block of its own. Listed,
# never removed: a .bak is the only copy that restores host tuning a merge got wrong. One dated before this command's
# own file is from an earlier installation, which is said beside it.
_pu_sidecars() {
    local root="$1" path stamp installed
    shift
    local -a copies=() references=("$@")
    installed="$(_pu_installed_day)" || installed=""
    mapfile -t copies < <(_pu_copies "${root}")
    (( ${#copies[@]} > 0 )) || return 0
    ai_tools_msg__headline "earlier copies kept beside the config files" 1 \
        "a .bak is what a file held before a merge replaced it, a .shipped a baseline an earlier from-source install left," \
        "a .retired a managed file or a withdrawn skill set aside"
    for path in "${copies[@]}"; do
        [[ " ${references[*]+"${references[*]}"} " != *" ${path} "* ]] || continue
        stamp="$(sed -nE 's/.*\.([0-9]{8})(-[0-9]+)?\.(bak|shipped|retired)$/\1/p' <<< "${path}")"
        if [[ -n "${installed}" && -n "${stamp}" && "${stamp}" < "${installed}" ]]; then
            printf '  %s  %s(before this installation)%s\n' "${path}" "${_PU_DIM}" "${_PU_RST}"
        else
            printf '  %s\n' "${path}"
        fi
    done
    printf '  each is yours to keep or remove -- this command reads only the newest copy beside a file, above\n'
}

# postupgrade [--check [--all] [--format tsv]]: reconcile the copies, or with `--check` write what needs attention
# as a record stream without asking or writing. Returns 0 when no file is left to act
# on, AI_TOOLS_RECORDS_BASE__EXIT_FINDINGS while one is, and 1 when a merge the run was asked to make did not happen
# (the two `err` lines _pu_json counts), so a caller reads the outcome from the exit status alone; under `--check`
# the status is the report state's, which adds AI_TOOLS_RECORDS_BASE__EXIT_UNREADABLE for a source the check could not
# read (ai-tools-records(5), EXIT STATUS).
postupgrade() {
    local check=0 all=0 format=tsv format_given=0 refusal=""
    while [[ $# -gt 0 && -z "${refusal}" ]]; do
        case "$1" in
            --check)  check=1 ;;
            --all)    all=1 ;;
            --format) if [[ $# -ge 2 ]]; then format="$2"; format_given=1; shift
                      else refusal="--format takes a value (tsv)"; fi ;;
            *)        refusal="unknown argument '$1' (--check [--all] [--format tsv])" ;;
        esac
        shift
    done
    if [[ -z "${refusal}" ]] && (( ! check && (all || format_given) )); then
        refusal="--all and --format apply to --check alone"
    elif [[ -z "${refusal}" && "${format}" != tsv ]]; then
        refusal="unknown --format '${format}' (tsv)"
    fi
    [[ -z "${refusal}" ]] || reject MSG-S9M6 "system post-upgrade: ${refusal}"
    if (( check )); then
        _PU_ALL="${all}"
        _pu_run_check "${AI_TOOLS_POSTUPGRADE_ROOT:-}"
        ai_tools_records_base__get_exit_status || return $?
        return 0
    fi
    _pu_report
    (( _PU_ERRORS == 0 )) || return 1
    (( _PU_ATTENTION == 0 )) || return "${AI_TOOLS_RECORDS_BASE__EXIT_FINDINGS}"
    return 0
}

# ── `--check`: the findings as a record stream ───────────────────────────────────────────────
# One record per finding, in the stream ai-tools-records(5) states -- no colour, no heading, and no output at all
# on a host with no finding -- so a cron job mails only a host that needs attention and a monitor reads the header
# and the rows. Each finding is one situation, so it carries one message code, and _pu_write_finding is the one table
# tying a finding to its code and its severity: `attention` makes the run exit AI_TOOLS_RECORDS_BASE__EXIT_FINDINGS,
# `unreadable` (the `error` finding, a check that could not run) AI_TOOLS_RECORDS_BASE__EXIT_UNREADABLE, and `info` is
# written under `--all` alone and leaves the exit as it is. The caller names the subject-type, since an asset reported
# missing has no path to stat, and the item is the components the collector's own granularity gives -- the event
# and the command of a hook, the list and the rule of a permission rule, a key, and for an `error` one token naming
# the check -- so two findings on one file keep two ids. No row carries an operator: every subject is under /etc
# or /opt/ai-tools, host-wide.
#
# The collectors feed the check through `< <(...)`, and _pu_run_check saves each one's PID on the statement that opens
# it and waits for it once its output is read (records.rule.md), so a collector that exits non-zero is an `error` row
# on the root it walked rather than a shorter set read as complete. They keep the line and `|` framing the interactive
# report reads them with, which holds for a path the packages install and does not for a name the sandbox account plants
# under an agent's config directory with a line feed in it: such a name splits into two rows and removes none, so it
# adds findings to a run and does not make one read as clean.
_PU_ALL=0
_PU_CODE=""
_PU_SEVERITY=""

# _pu_attention / _pu_unreadable / _pu_info <code> <finding>: the code and severity a finding token carries,
# into _PU_CODE and _PU_SEVERITY -- one word per severity, and the code first, so the message index reads each table
# line as the definition it is and the messages page derives the severity from the word.
_pu_attention()  { _PU_CODE="$1"; _PU_SEVERITY=attention; }
_pu_unreadable() { _PU_CODE="$1"; _PU_SEVERITY=unreadable; }
_pu_info()       { _PU_CODE="$1"; _PU_SEVERITY=info; }

# _pu_write_finding <finding> <subject-type> <subject> <detail> [<component>...]: write one record, or none
# for an `info` finding without `--all`. ai-tools-admin(8) lists what each finding means. A token this table does not
# carry, and an empty item component, are defects of this file: each is written as an `error` row, so the run reads
# AI_TOOLS_RECORDS_BASE__EXIT_UNREADABLE rather than silently short.
_pu_write_finding() {
    local finding="$1" subject_type="$2" subject="$3" detail="$4" item
    shift 4
    case "${finding}" in
        hook-missing)       _pu_attention MSG-F2G7 "hook-missing" ;;
        hook-repeated)      _pu_attention MSG-E8S8 "hook-repeated" ;;
        ask-missing)        _pu_attention MSG-E9V5 "ask-missing" ;;
        rule-missing)       _pu_attention MSG-Z8U4 "rule-missing" ;;
        key-missing)        _pu_attention MSG-K5H2 "key-missing" ;;
        option-unmentioned) _pu_attention MSG-N3U8 "option-unmentioned" ;;
        rpmnew-differs)     _pu_attention MSG-P4Q4 "rpmnew-differs" ;;
        rpmnew-review)      _pu_attention MSG-Y3P3 "rpmnew-review" ;;
        rpmnew-orphan)      _pu_attention MSG-K8D2 "rpmnew-orphan" ;;
        asset-missing)      _pu_attention MSG-X6H5 "asset-missing" ;;
        asset-unlinked)     _pu_attention MSG-N9S4 "asset-unlinked" ;;
        list-unmigrated)    _pu_attention MSG-P5K4 "list-unmigrated" ;;
        list-unmigratable)  _pu_attention MSG-S3D8 "list-unmigratable" ;;
        error)              _pu_unreadable MSG-Y3J5 "error" ;;
        rpmnew-residual)    _pu_info MSG-J3X7 "rpmnew-residual" ;;
        copy-kept)          _pu_info MSG-W8F8 "copy-kept" ;;
        asset-outdated)     _pu_info MSG-R6B2 "asset-outdated" ;;
        asset-overridden)   _pu_info MSG-W3M8 "asset-overridden" ;;
        *)  _pu_write_finding error "${subject_type}" "${subject}" "no code for the finding '${finding}'" finding-table
            return 0 ;;
    esac
    if ! ai_tools_records_tsv__frame_item_components item "$@"; then
        _pu_write_finding error "${subject_type}" "${subject}" "an empty item component for ${finding}" finding-table
        return 0
    fi
    [[ "${_PU_SEVERITY}" != info ]] || (( _PU_ALL )) || return 0
    ai_tools_records_tsv__write_record "" "${_PU_CODE}" "${_PU_SEVERITY}" "${finding}" "${subject_type}" "" "${item}" \
        "${subject}" "${detail}"
}

# _pu_hook_finding <finding> <file> <line>: a hook declaration the merge names as "<event>: <command>", written
# with the event and the command as its item components. A line without that shape is an `error` row, not a guess.
_pu_hook_finding() {
    if [[ "$3" != *": "* ]]; then
        _pu_write_finding error file "$2" "a hook declaration the merge named could not be read: $3" hook-merge
        return 0
    fi
    _pu_write_finding "$1" file "$2" "$3" "${3%%: *}" "${3#*: }"
}

# _pu_wait_collector <pid> <subject-type> <subject> <source>: wait for a collector opened with `< <(...)`, and write
# an `error` row naming it when it exited non-zero. Called in the report's own shell, after the collector's output is
# read, with the PID saved on the statement that opened it.
_pu_wait_collector() {
    local status=0
    wait "$1" || status=$?
    (( status == 0 )) || _pu_write_finding error "$2" "$3" "the ${4} collector exited ${status}" "$4"
}

# _pu_run_check <root>: every finding, through _pu_write_finding, without asking or writing. Each reads the same
# predicate the report does. The caller reads the outcome off the report state, ai_tools_records_base__get_exit_status.
_pu_run_check() {
    local root="$1" file kind label scratch status key line state stype path detail live reference fd pid
    local -a new_keys=() entries=() files_with_baseline=()
    ai_tools_records_base__begin_report
    _pu_kind_findings "${root}"
    exec {fd}< <(_pu_entries "${root}"); pid=$!
    mapfile -t -u "${fd}" files_with_baseline
    exec {fd}<&-
    _pu_wait_collector "${pid}" directory "${root:-/}" entries
    # A file or copy dated after the clock: the comparison would pick the wrong copy, so each is an `error` row --
    # a check that could not run -- and the run does not compare a file.
    local -a clock_lines=()
    if ! _pu_clock_lines clock_lines "${files_with_baseline[@]+"${files_with_baseline[@]}"}"; then
        for line in "${clock_lines[@]}"; do
            _pu_write_finding error file "${line#*  }" "dated ${line%%  *}, after the system clock -- set the clock before acting on this report" clock
        done
        files_with_baseline=()
    fi
    local entry
    for entry in "${files_with_baseline[@]+"${files_with_baseline[@]}"}"; do
        IFS='|' read -r file kind label copy <<< "${entry}"
        if cmp -s "${file}" "${copy}"; then _pu_write_finding rpmnew-residual file "${copy}" ""; continue; fi
        case "${kind}" in
        json)
            if ! ai_tools_settings_merge__require_jq 2>/dev/null; then
                _pu_write_finding error file "${file}" "jq is not installed" jq
                continue
            fi
            scratch="$(mktemp -d)" || { _pu_write_finding error file "${file}" "no temporary directory" mktemp; continue; }
            status=0
            cp -p "${file}" "${scratch}/probe" && ai_tools_settings_merge__merge_hook_declarations "${scratch}/probe" \
                "${copy}" >/dev/null 2>&1 || status=$?
            rm -rf "${scratch}"
            case "${status}" in
            0)  for line in "${ai_tools_settings_merge__added[@]}"; do _pu_hook_finding hook-missing "${file}" "${line}"; done
                for line in "${ai_tools_settings_merge__removed[@]}"; do
                    _pu_hook_finding hook-repeated "${file}" "${line}"
                done
                _pu_settings_findings "${file}" "${copy}" ;;
            1)  _pu_settings_findings "${file}" "${copy}" ;;
            *)  _pu_write_finding error file "${file}" "${ai_tools_settings_merge__reason:-merge probe failed}" hook-merge ;;
            esac ;;
        keyval)
            if ai_tools_conf__find_new_keys new_keys "${file}" "${copy}"; then
                for key in "${new_keys[@]}"; do _pu_write_finding option-unmentioned file "${file}" "${key}" "${key}"; done
            fi
            [[ "$(_pu_prose "${file}")" == "$(_pu_prose "${copy}")" ]] \
                || _pu_write_finding rpmnew-differs file "${file}" "comments" ;;
        review)
            _pu_write_finding rpmnew-review file "${file}" "sudoers grant" ;;
        *)
            _pu_write_finding rpmnew-differs file "${file}" "" ;;
        esac
    done

    exec {fd}< <(_pu_orphans "${root}"); pid=$!
    while IFS= read -r -u "${fd}" path; do
        _pu_write_finding rpmnew-orphan file "${path}" "the file it belongs to is gone"
    done
    exec {fd}<&-
    _pu_wait_collector "${pid}" directory "${root:-/}" orphans

    file="${root}/opt/ai-tools/.claude/settings.json"
    if [[ -f "${file}" ]]; then
        if line="$(ai_tools_settings_merge__find_ask_gaps "${file}" "${root}" 2>/dev/null)"; then
            [[ -n "${line}" ]] && mapfile -t entries <<< "${line}"
            for line in "${entries[@]}"; do _pu_write_finding ask-missing file "${file}" "${line}" "${line}"; done
        else
            _pu_write_finding error file "${file}" "ask entries not checked: jq is missing or the file is not valid JSON" \
                ask-entries
        fi
    fi

    if declare -F ai_tools_providers__find_managed_file_missing_keys >/dev/null 2>&1; then
        exec {fd}< <(_pu_managed_pairs "${root}"); pid=$!
        while IFS=$'\t' read -r -u "${fd}" live reference; do
            [[ -f "${live}" && -f "${reference}" ]] || continue
            cmp -s "${live}" "${reference}" && continue
            if line="$(ai_tools_providers__find_managed_file_missing_keys "${live}" "${reference}")"; then
                entries=()
                [[ -n "${line}" ]] && mapfile -t entries <<< "${line}"
                for line in "${entries[@]}"; do _pu_write_finding key-missing file "${live}" "${line}" "${line}"; done
            else
                _pu_write_finding error file "${live}" "keys not checked: the file or its shipped copy is not readable" \
                    managed-keys
            fi
        done
        exec {fd}<&-
        _pu_wait_collector "${pid}" directory "${root}/etc" managed-pairs
    else
        _pu_write_finding error directory "${root}/etc" "managed-file keys not checked: ${PROVIDERS_LIB} did not load" \
            managed-keys
    fi

    exec {fd}< <(_pu_assets "${root}"); pid=$!
    while IFS=$'\t' read -r -u "${fd}" state stype path detail; do
        if [[ "${state}" == error ]]; then
            _pu_write_finding error "${stype}" "${path}" "${detail}" shared-assets
        else
            _pu_write_finding "${state}" "${stype}" "${path}" "${detail}"
        fi
    done
    exec {fd}<&-
    _pu_wait_collector "${pid}" directory "${root}/opt/ai-tools" assets

    exec {fd}< <(_pu_copies "${root}"); pid=$!
    while IFS= read -r -u "${fd}" path; do _pu_write_finding copy-kept file "${path}" ""; done
    exec {fd}<&-
    _pu_wait_collector "${pid}" directory "${root:-/}" copies
}

# _pu_settings_findings <file> <copy>: the findings _pu_settings_rest reports for a settings file and its baseline copy
# -- a rule the copy carries that the file does not, and any other setting that differs -- through the same two readers.
_pu_settings_findings() {
    local file="$1" copy="$2" gaps kind list rule scratch
    if ! gaps="$(ai_tools_settings_merge__find_permission_gaps "${file}" "${copy}")"; then
        _pu_write_finding error file "${file}" "permission rules not compared: jq is missing or a file is not valid JSON" \
            permission-rules
        return 0
    fi
    while IFS=$'\t' read -r kind list rule; do
        [[ "${kind}" == missing ]] && _pu_write_finding rule-missing file "${file}" "${list}: ${rule}" "${list}" "${rule}"
    done <<< "${gaps}"
    scratch="$(mktemp -d)" || return 0
    if ai_tools_settings_merge__read_settings_rest "${file}" > "${scratch}/file" \
            && ai_tools_settings_merge__read_settings_rest "${copy}" > "${scratch}/copy" \
            && ! cmp -s "${scratch}/file" "${scratch}/copy"; then
        _pu_write_finding rpmnew-differs file "${file}" "settings"
    fi
    rm -rf "${scratch}"
}

# _pu_kind_findings <root>: the provider list items operator.conf holds in an earlier release's bare form, read
# through the plan the rewrite follows (ai_tools_providers__plan_kind_migration, providers.lib.sh): list-unmigrated
# for a key the run would rewrite, list-unmigratable for an item no run can map, one row per item. A detection with no
# plan to read -- providers.lib.sh did not load -- is a check that could not run.
_pu_kind_findings() {
    local file="$1/etc/ai-tools/operator.conf" verdict key old new fd pid
    [[ -f "${file}" ]] || return 0
    if ! declare -F ai_tools_providers__plan_kind_migration >/dev/null 2>&1; then
        declare -F ai_tools_conf__find_unmigrated_items >/dev/null 2>&1 && [[ -n "$(ai_tools_conf__find_unmigrated_items "${file}")" ]] \
            && _pu_write_finding error file "${file}" "provider names not checked: ${PROVIDERS_LIB} did not load" \
                provider-names
        return 0
    fi
    exec {fd}< <(ai_tools_providers__plan_kind_migration "${file}"); pid=$!
    while IFS=$'\t' read -r -u "${fd}" verdict key old new; do
        case "${verdict}" in
            migrate) _pu_write_finding list-unmigrated file "${file}" "${key}: [${old// /, }] -> [${new// /, }]" "${key}" ;;
            blocked) _pu_write_finding list-unmigratable file "${file}" "${key}: ${old}" "${key}" "${old}" ;;
        esac
    done
    exec {fd}<&-
    _pu_wait_collector "${pid}" file "${file}" kind-plan
}

# _pu_orphans <root>: each package copy whose file is gone, one path per line. rpm parks a copy only beside a file it
# kept, so one of these is a file removed afterwards, and neither the report nor a merge otherwise reaches it. Exits
# non-zero when a directory walk failed, since a copy it did not see is then not known to be absent.
_pu_orphans() {
    local root="$1" entry file kind label dir fd pid
    {
        for entry in "${POSTUPGRADE_FILES[@]}"; do
            IFS='|' read -r file kind label <<< "${entry}"
            [[ -f "${root}${file}.rpmnew" && ! -e "${root}${file}" ]] && printf '%s\n' "${root}${file}.rpmnew"
        done
        for dir in "${POSTUPGRADE_DIRS[@]}"; do
            [[ -d "${root}${dir}" ]] || continue
            exec {fd}< <(find "${root}${dir}" -maxdepth 3 -type f -name '*.rpmnew' 2>/dev/null); pid=$!
            while IFS= read -r -u "${fd}" file; do
                [[ -e "${file%.rpmnew}" ]] || printf '%s\n' "${file}"
            done
            exec {fd}<&-
            wait "${pid}" || exit 1
        done
        exit 0
    } | sort -u
}

# _pu_copies <root>: the dated copies this stack keeps as recovery material, in the order they were made -- a .bak
# beside a config file a merge replaced, a .shipped baseline left when a merge could not run, a .retired managed file
# an agent package replaced, and a withdrawn skill or subagent under /opt/ai-tools/retired. None is rpm's, and none is
# read by any command. Exits non-zero when a directory walk failed, for the reason _pu_orphans states.
_pu_copies() {
    local root="$1" dir
    {
        for dir in "${POSTUPGRADE_DIRS[@]}" /etc/sudoers.d; do
            [[ -d "${root}${dir}" ]] || continue
            find "${root}${dir}" -maxdepth 3 -type f \
                \( -name '*.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.bak' \
                   -o -name '*.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.shipped' \
                   -o -name '*.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.retired' \) 2>/dev/null || exit 1
        done
        if [[ -d "${root}/opt/ai-tools/retired" ]]; then
            find "${root}/opt/ai-tools/retired" -mindepth 1 -maxdepth 1 -name '*.retired' 2>/dev/null || exit 1
        fi
        exit 0
    } | _pu_sort_copies
}

# _pu_assets <root>: `<finding> TAB <subject-type> TAB <path> TAB <detail>` for each shipped skill, subagent
# and orientation text whose live copy or agent link is not the one provisioning places -- the seeding
# that `system bootstrap` and base's scriptlet run. The subject-type follows the asset kind, `directory` for a skill
# and `file` otherwise, since a missing asset has no path to stat and a link stands for the asset it points at. A live
# asset that is absent or an empty directory is missing: a session is not offered it. A real file where an agent's link
# belongs, or a live asset without the managed marker, is the operator's override and is left to them. A symlink
# at the live name is the assets resolver's view link and is not compared. Checked only where the pristine assets are
# installed, under <root>.
_pu_assets() {
    local root="$1" src_root="$1/usr/share/ai-tools" live_root="$1/opt/ai-tools"
    local kind glob src name marker dst dst_marker cur new field agent dir link target stype
    [[ -d "${src_root}" ]] || return 0
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/control-plane.lib.sh
    source /usr/local/lib/ai-tools/control-plane.lib.sh 2>/dev/null || true
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/managed-assets.lib.sh
    source /usr/local/lib/ai-tools/managed-assets.lib.sh 2>/dev/null || true
    # The sanitizer is required: a link's target is a string the sandbox account can write, since an agent's config
    # directory is group-writable, and this report prints it on root's terminal.
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/log.lib.sh
    source /usr/local/lib/ai-tools/log.lib.sh 2>/dev/null || true
    if ! declare -F ai_tools_managed_assets__is_managed >/dev/null 2>&1 \
            || ! declare -F ai_tools_control_plane__list_agent_asset_dirs >/dev/null 2>&1 \
            || ! declare -F ai_tools_log__sanitize >/dev/null 2>&1; then
        printf 'error\tdirectory\t%s\t%s\n' "${live_root}" "shared assets not checked: the asset libraries did not load"
        return 0
    fi
    for kind in "${AI_TOOLS_MANAGED_ASSETS__KINDS[@]}"; do
        case "${kind}" in
            skills)      glob="${src_root}/skills/ai-tools-*/";      field=skills_dir;    stype="directory" ;;
            subagents)   glob="${src_root}/subagents/ai-tools-*.md"; field=subagents_dir; stype="file" ;;
            orientation) glob="${src_root}/orientation/AGENTS.md";  field="";            stype="file" ;;
            *)           continue ;;
        esac
        for src in ${glob}; do
            [[ -e "${src}" ]] || continue
            name="$(basename "${src}")"
            ai_tools_managed_assets__is_retired "${kind}" "${name}" && continue
            if [[ -d "${src}" ]]; then marker="${src%/}/SKILL.md"; else marker="${src}"; fi
            ai_tools_managed_assets__is_managed "${marker}" || continue
            dst="${live_root}/${kind}/${name}"
            # A symlink at the live name is the assets resolver's view link, not a seeded copy: its marker and version
            # are not compared, and its per-agent links are reconcile's.
            [[ -L "${dst}" ]] && continue
            if [[ -d "${src}" ]]; then dst_marker="${dst}/SKILL.md"; else dst_marker="${dst}"; fi
            if [[ ! -e "${dst}" ]] || { [[ -d "${dst}" ]] && [[ -z "$(find "${dst}" -mindepth 1 -print -quit)" ]]; }; then
                printf 'asset-missing\t%s\t%s\t%s\n' "${stype}" "${dst}" "not seeded -- sessions are not offered it"
                continue
            fi
            if ! ai_tools_managed_assets__is_managed "${dst_marker}"; then
                printf 'asset-overridden\t%s\t%s\t%s\n' "${stype}" "${dst}" "not ai-tools-managed, so provisioning leaves it"
                continue
            fi
            new="$(ai_tools_managed_assets__read_asset_version "${marker}")"
            cur="$(ai_tools_managed_assets__read_asset_version "${dst_marker}")"
            [[ -n "${new}" && -n "${cur}" && "${new}" -gt "${cur}" ]] \
                && printf 'asset-outdated\t%s\t%s\t%s\n' "${stype}" "${dst}" "v${cur} live, v${new} shipped"
            while IFS=$'\t' read -r agent dir; do
                [[ -n "${agent}" ]] || continue
                if [[ -n "${field}" ]]; then link="${root}${dir}/${name}"; else link="${root}${dir}"; fi
                [[ -d "${link%/*}" ]] || continue
                if [[ -L "${link}" ]]; then
                    target="$(readlink "${link}")"
                    [[ "${target}" == "${dst}" || "${target}" == "${CP_HOME}/${kind}/${name}" ]] \
                        || printf 'asset-unlinked\t%s\t%s\t%s\n' "${stype}" "${link}" \
                               "${agent}: points at $(ai_tools_log__sanitize "${target}")"
                elif [[ -e "${link}" ]]; then
                    printf 'asset-overridden\t%s\t%s\t%s\n' "${stype}" "${link}" \
                        "${agent}: a file of its own in place of the link"
                else
                    printf 'asset-unlinked\t%s\t%s\t%s\n' "${stype}" "${link}" "${agent}: no link"
                fi
            done < <(if [[ -n "${field}" ]]; then ai_tools_control_plane__list_agent_asset_dirs "${field}"
                     else ai_tools_control_plane__list_agent_memory_targets; fi)
        done
    done
    return 0
}

# _pu_kind_noun <KEY>: what an item of a kind-prefixed list key names, for a report line.
_pu_kind_noun() {
    case "$1" in
        AI_TOOLS_AGENTS)       printf 'agent' ;;
        AI_TOOLS_INTEGRATIONS) printf 'integration' ;;
        *)                     printf 'filter set' ;;
    esac
}

# _pu_kind_migrate <root>: rewrite the provider list items operator.conf holds in an earlier release's bare form
# (ai_tools_providers__migrate_kinds, providers.lib.sh), whether or not a .rpmnew waits, and report each key in a block
# of its own. It runs on every run, the unattended one included: the rewrite changes spelling alone, and until it lands
# every session start is refused. A key holding a name no installed manifest or rule set matches is left as written
# and named, since only the operator knows what it meant.
#
# A rewritten AI_TOOLS_AGENTS enables agents that the unmigrated line left enabled nowhere, so no relabel covered them
# while it stood: an install run in that window restorecon'd the toolchain with no entrypoint rule to apply last,
# which leaves an entrypoint hardlinked to its platform package on that package's type. The reconciliation
# `system entrypoints relabel` runs (ai-tools-relabel-agent) therefore follows the rewrite, answering from an unchanged
# pin as the other unattended callers do. It is skipped under AI_TOOLS_POSTUPGRADE_ROOT, since the suite does not change
# the host's SELinux policy to test a helper.
_pu_kind_migrate() {
    local file="$1/etc/ai-tools/operator.conf" line verdict key old new opened=0 agents_rewritten=0
    [[ -f "${file}" ]] || return 0
    if ! declare -F ai_tools_providers__migrate_kinds >/dev/null 2>&1; then
        declare -F ai_tools_conf__find_unmigrated_items >/dev/null 2>&1 && [[ -n "$(ai_tools_conf__find_unmigrated_items "${file}")" ]] \
            || return 0
        _PU_NAME="${file##*/}"
        ai_tools_msg__headline "${_PU_NAME} -- provider names" 1 "${file}"
        _pu_say err "provider names were not rewritten: ${PROVIDERS_LIB} did not load -- reinstall ai-tools-base"
        return 0
    fi
    while IFS= read -r line; do
        IFS=$'\t' read -r verdict key old new <<< "${line}"
        if (( ! opened )); then
            opened=1
            _PU_NAME="${file##*/}"
            ai_tools_msg__headline "${_PU_NAME} -- provider names" 1 "${file}" \
                "each provider list item names its kind: agent-<name>, integration-<name>, filter-<name>"
        fi
        case "${verdict}" in
            backup)    _pu_say info "the file as it was is saved as ${key}" ;;
            rewritten) _pu_say ok "${key}: [${old// /, }] -> [${new// /, }]"
                       [[ "${key}" == AI_TOOLS_AGENTS ]] && agents_rewritten=1 ;;
            blocked)   _pu_say act "${key} holds ${old}, which names no installed $(_pu_kind_noun "${key}") -- the line is left as written and enables nothing; edit it by hand" ;;
            failed)    _pu_say err "${key} was not rewritten: ${new} -- the line is left as written" ;;
        esac
    done < <(ai_tools_providers__migrate_kinds "${file}")
    (( agents_rewritten )) && [[ -z "${AI_TOOLS_POSTUPGRADE_ROOT:-}" ]] || return 0
    if [[ ! -x "${RELABEL_ENTRYPOINT_BIN}" ]]; then
        _pu_say info "the entrypoints were not reconciled: ${RELABEL_ENTRYPOINT_BIN} is not installed"
    elif AI_TOOLS_ENTRYPOINT_PIN_REUSE=1 "${RELABEL_ENTRYPOINT_BIN}"; then
        _pu_say ok "the enabled agents' entrypoints are reconciled (verified and labelled)"
    else
        _pu_say err "the entrypoint reconciliation reported a problem above -- re-run it: sudo ai-tools-admin system entrypoints relabel"
    fi
}

# _pu_report: the reconciliation itself, one block per file with a copy waiting, then the closing lines.
_pu_report() {
    local file kind label title found=0 attention_before copy
    local root="${AI_TOOLS_POSTUPGRADE_ROOT:-}"
    # Files whose copy still differs after their treatment, which is when a side-by-side comparison has something
    # to show, and copies byte-identical to their file, which have nothing to show and are only named for removal.
    local -a to_compare=() identical=()

    _pu_kind_migrate "${root}"
    local -a references=() entries=() clock_lines=()
    local entry line
    mapfile -t entries < <(_pu_entries "${root}")
    # A file or copy dated after the clock says the clock is behind, and the copy this run would compare a file with is
    # the newest by date: the block names the clock as the first thing to correct, and this run does not compare a file.
    if ! _pu_clock_lines clock_lines "${entries[@]+"${entries[@]}"}"; then
        _PU_NAME="clock"
        ai_tools_msg__headline "the system clock is behind a file this command orders by date" 1 "${clock_lines[@]}"
        warn MSG-S5S2 "the system clock reads $(date '+%Y-%m-%d %H:%M:%S'), earlier than the file(s) above -- the copy a file is compared with is the newest by date, so set the clock first (timedatectl set-time, or chronyc makestep once a time source is reachable), then re-run this command; no file was compared"
        _PU_ATTENTION=$(( _PU_ATTENTION + 1 ))
        found=1
        entries=()
    fi
    for entry in "${entries[@]+"${entries[@]}"}"; do
        IFS='|' read -r file kind label copy <<< "${entry}"
        found=1
        references+=("${copy}")
        # A copy byte-identical to the file does not add an option or a line of prose to it, whatever its format, so it
        # does not get a block of its own: it is listed for removal under the closing line.
        if cmp -s "${file}" "${copy}"; then
            identical+=("${copy}")
            continue
        fi
        _PU_NAME="${file##*/}"
        if [[ "${label}" == "${_PU_NAME}" ]]; then title="${_PU_NAME}"; else title="${_PU_NAME} -- ${label}"; fi
        ai_tools_msg__headline "${title}" 1 "${file}" "$(_pu_provenance "${copy}")"
        attention_before="${_PU_ATTENTION}"
        case "${kind}" in
            json)   _pu_json   "${file}" "${copy}" ;;
            keyval) _pu_keyval "${file}" "${copy}" ;;
            review) _pu_review "${file}" "${copy}" ;;
            show)   _pu_show   "${file}" "${copy}" ;;
        esac
        (( _PU_ATTENTION > attention_before )) && ! cmp -s "${file}" "${copy}" && to_compare+=("${file}")
    done

    _pu_orphan_report "${root}"
    _pu_ask_gaps "${root}"
    _pu_key_gaps "${root}"
    _pu_asset_report "${root}"
    _pu_sidecars "${root}" "${references[@]+"${references[@]}"}"
    _pu_prune_merge_copies "${root}"
    printf '\n'
    # The closing line takes the colour of the worst line above it: red for an error, yellow for anything else to act
    # on. A copy identical to its file is not something to act on, so it does not colour the line; it is named
    # as reconciled rather than as "nothing to do", since the copies are listed for removal under it.
    if (( _PU_ERRORS > 0 )); then
        printf '%sPost-upgrade done -- review the warnings and errors above%s\n' "${_PU_RED}" "${_PU_RST}"
    elif (( _PU_ATTENTION > 0 )); then
        printf '%sPost-upgrade done -- review the warnings above%s\n' "${_PU_YEL}" "${_PU_RST}"
    elif (( found == 0 )); then
        printf 'Post-upgrade done -- no package or installer copy is waiting, so every config file this stack owns is reconciled\n'
    elif (( ${#identical[@]} > 0 )); then
        printf 'Post-upgrade done -- every config file is reconciled, and its baseline copy is identical to it\n'
    else
        printf 'Post-upgrade done -- nothing needs your attention\n'
    fi
    # The comparison command is printed on a line of its own, indented, so it copies whole. It is offered only while
    # a file still differs from its copy in a way the report asked the operator to act on.
    if (( ${#to_compare[@]} > 0 )); then
        printf '\n%sCompare a file with its baseline copy side by side, and carry over what you want:%s\n\n' \
            "${_PU_DIM}" "${_PU_RST}"
        printf '  %s%s%s\n\n' "${_PU_DIM}" "$(_pu_merge_command '<file>' '<copy>')" "${_PU_RST}"
        command -v meld >/dev/null 2>&1 \
            || printf '%s• meld is not installed; for a side-by-side view in a desktop session: sudo dnf install meld%s\n' \
                "${_PU_DIM}" "${_PU_RST}"
    fi
    if (( ${#identical[@]} > 0 )); then
        printf '%s• baseline copies identical to their files, to remove when you are ready:%s\n' "${_PU_DIM}" "${_PU_RST}"
        for copy in "${identical[@]}"; do printf '    %ssudo rm %s%s\n' "${_PU_DIM}" "${copy}" "${_PU_RST}"; done
    fi
    printf '%s• system post-upgrade is idempotent -- re-run it at any time%s\n' "${_PU_DIM}" "${_PU_RST}"
    if (( _PU_ATTENTION > 0 )); then
        printf '%s• Happy merging!%s\n' "${_PU_DIM}" "${_PU_RST}"
    else
        printf "%s• All settings merged. You're good to go!%s\n" "${_PU_DIM}" "${_PU_RST}"
    fi
}

# ── status ───────────────────────────────────────────────────────────────────────────────────
# `ai-tools status` read from root's vantage -- one resource reported twice, not a second report. Which readings root
# adds and why the two agree is .claude/rules/cli.rule.md; what this file contributes is the rendering, in the plain
# bracket-token idiom the rest of this tool uses. The verdicts come from services.lib.sh and relabel.lib.sh, which emit
# data and leave every consumer to format it.
readonly SERVICES_LIB="/usr/local/lib/ai-tools/services.lib.sh"
readonly RELABEL_LIB="/usr/local/lib/ai-tools/relabel.lib.sh"
readonly ENTRYPOINT_VERIFY_LIB="/usr/local/lib/ai-tools/entrypoint-verify.lib.sh"
readonly TOOLCHAIN_LIB="/usr/local/lib/ai-tools/toolchain.lib.sh"
# The cache cleanup state and its per-group verdicts, read by status_caches alone, which loads it.
readonly CACHE_CLEANUP_LIB="/usr/local/lib/ai-tools/cache-cleanup.lib.sh"
readonly CONFINEMENT_LIB="/usr/local/lib/ai-tools/confinement.lib.sh"
# AI_TOOLS_LAUNCHER_DIR is the hook the CLI and relabel.lib.sh read for the same directory,
# so tests/unit/admin-status.sh drives this report against fixture links. It moves a report: this tool is reachable only
# as root, sudo strips the name, and no access decision here reads it.
readonly LAUNCHER_LINK_DIR="${AI_TOOLS_LAUNCHER_DIR:-/opt/ai-tools/bin}"

# st <state> <text>: one report line, state in a bracket token so a scan down the left column finds what needs
# attention. The vocabulary is the CLI's -- OK, DOWN, FAILED, STALE, SKIPPED, n/a, ? -- because an administrator reads
# both reports about one host and a second set of words for the same states would read as a second set of facts.
st()      { printf '    %-13s %s\n' "[$1]" "$2"; }
detail()  { printf '                  %s\n' "$*"; }
heading() { printf '\n  %s\n\n' "$*"; }

# The two counts status() folds into its exit (ai-tools-records(5)): STATUS_PROBLEMS, a fault a section read,
# and STATUS_UNREADABLE, a reading a section could not make because a library base ships did not load. A `?` line is
# neither: it is a probe this vantage may fail without the host being broken, and does not count.
STATUS_PROBLEMS=0
STATUS_UNREADABLE=0

# status_services: every unit in the shared registry, with its consequence and remedy where one needs attention. Renders
# to stdout and counts the units needing attention in STATUS_PROBLEMS.
status_services() {
    heading "Services"
    local rec unit scope stamp mode state age when exit_code reason remedy uid
    while IFS= read -r rec; do
        unit="$(ai_tools_services__get_field "${rec}" 1)"
        scope="$(ai_tools_services__get_field "${rec}" 2)"
        stamp="$(ai_tools_services__get_field "${rec}" 7)"
        mode="$(ai_tools_services__get_field "${rec}" 8)"
        state="$(ai_tools_services__read_record_state "${rec}")"
        age=""; when=""
        if [[ -n "${stamp}" ]]; then
            age="$(ai_tools_services__format_age "$(ai_tools_services__read_stamp_age "${stamp}")")"
            [[ -n "${age}" ]] && when="last run ${age}"
        fi
        case "${state}" in
            active)  st OK "${unit}${when:+  ${when}}" ;;
            down)    st DOWN "${unit}" ;;
            skipped) reason="$(ai_tools_services__read_stamp_field "${stamp}" REASON)"
                     st SKIPPED "${unit}  ${when:-last run at an unknown time}${reason:+, ${reason}} -- nothing was changed" ;;
            failed)  if [[ -n "${stamp}" ]]; then
                         exit_code="$(ai_tools_services__read_stamp_field "${stamp}" EXIT_CODE)"
                         st FAILED "${unit}  ${when:-last run at an unknown time}, exit ${exit_code:-?}"
                     else
                         exit_code="$(ai_tools_services__read_unit_property "${unit}" ExecMainStatus "${scope}")"
                         st FAILED "${unit}  its last run exited ${exit_code:-non-zero}"
                     fi ;;
            stale)   st STALE "${unit}  ${when:-last run long ago}" ;;
            absent)  st "n/a" "${unit}  not installed" ;;
            # Not a fault report -- it says only that even this vantage point could not tell, and is not counted,
            # the same rule the operator view follows. The two scopes fail for different reasons and say so: a system
            # unit is unreadable only where there is no systemctl at all, while a sandbox-user one means root reached
            # neither that account's manager (no machine transport, no timeout(1), or no answer inside the probe's
            # window) nor a last-run stamp -- or, separable and said as such, a stamp still empty as the package seeded
            # it: the unit has never run, which is where a freshly provisioned host stands until its first window.
            *)       if [[ "${scope}" == system ]]; then
                         st "?" "${unit}  systemctl is unavailable here"
                     elif declare -F ai_tools_services__is_stamp_unwritten >/dev/null 2>&1 \
                             && ai_tools_services__is_stamp_unwritten "${stamp}"; then
                         st "?" "${unit}  no run recorded yet -- its first scheduled run has not happened"
                     else
                         st "?" "${unit}  neither its manager nor a last-run stamp could be read"
                     fi ;;
        esac
        ai_tools_services__is_attention_needed "${state}" || continue
        STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        detail "$(ai_tools_services__get_field "${rec}" 5)"
        # A sandbox-user unit's commands name the sandbox ACCOUNT, and services.lib.sh is deployed with no
        # ai-tools substitution, so they are composed here -- the same split the CLI makes, and the reason
        # the registry's remedy field is empty for those units.
        if [[ "${scope}" != system ]]; then
            detail "sudo systemctl --user -M ${SANDBOX_USER}@.host status ${unit}"
            uid="$(id -u "${SANDBOX_USER}" 2>/dev/null || true)"
            [[ -n "${uid}" ]] \
                && detail "sudo journalctl _SYSTEMD_USER_UNIT=${unit} _UID=${uid} -n 50 --no-pager"
            detail "sudo systemctl --user -M ${SANDBOX_USER}@.host restart ${unit}"
        fi
        remedy="$(ai_tools_services__get_field "${rec}" 6)"
        [[ -n "${remedy}" ]] && detail "${remedy}"
    done < <(ai_tools_services__list_records)
    return 0
}

# status_provisioning: per enabled agent, whether its stable launcher link exists -- bootstrap's last artifact
# for that agent, and the link the CLI's bootstrap gate reads, so the two reports answer alike. The link is tested
# per launcher, since the launcher directory also holds the base package's own files and is never empty on an installed
# host. Counted as the CLI counts it (cli.rule.md): an unprovisioned agent and an empty enabled set are reported and not
# counted, since an unfinished install is what the section exists to say; a resolver that did not load is a reading this
# section could not make, so the report exits 5.
status_provisioning() {
    heading "Provisioning"
    if ! declare -F ai_tools_providers__list_enabled_agents >/dev/null 2>&1; then
        st UNREADABLE "cannot read the enabled agents -- ${PROVIDERS_LIB} did not load; reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local agent launcher reason
    local -a agents=()
    mapfile -t agents < <(ai_tools_providers__list_enabled_agents 2>/dev/null)
    if (( ${#agents[@]} == 0 )); then
        reason=""
        declare -F ai_tools_providers__evaluate_empty_agents >/dev/null 2>&1 \
            && IFS=$'\t' read -r _ reason <<< "$(ai_tools_providers__evaluate_empty_agents)"
        st "n/a" "no agent enabled${reason:+ -- ${reason}}"
        return 0
    fi
    for agent in "${agents[@]}"; do
        IFS=$'\t' read -r agent _ launcher <<< "${agent}"
        if [[ -n "${launcher}" && -L "${LAUNCHER_LINK_DIR}/${launcher}" ]]; then
            st OK "${agent} provisioned (${launcher})"
        else
            st MISSING "${agent} has no launcher in ${LAUNCHER_LINK_DIR} -- it is not provisioned"
            detail "sudo ai-tools-admin system bootstrap"
        fi
    done
    return 0
}

# status_managed_files: per enabled agent, each managed file its manifest names (managed_files, ai-tools-providers(5))
# whose live copy is not the shipped one -- the same reading `ai-tools status` makes, rendered in this tool's table.
# The package never overwrites such a file, so this line is where an operator learns it differs. An edited file is
# a supported state and is not counted; a missing one is, since the package is then broken. Prints no line for a file
# matching the shipped copy. Without the resolver status_provisioning has already said so; a resolver that loaded
# without its managed-files reader is a reading this section could not make.
status_managed_files() {
    declare -F ai_tools_providers__list_enabled_agents >/dev/null 2>&1 || return 0
    if ! declare -F ai_tools_providers__list_agent_managed_files >/dev/null 2>&1; then
        st UNREADABLE "managed files: ${PROVIDERS_LIB} did not load its managed-files reader; reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local agent live reference state
    while IFS=$'\t' read -r agent _ _; do
        [[ -n "${agent}" ]] || continue
        while IFS=$'\t' read -r live reference; do
            [[ -n "${live}" ]] || continue
            state="$(ai_tools_providers__evaluate_managed_file "${live}" "${reference}")"
            case "${state}" in
                shipped) ;;
                edited)  st edited "${agent}  ${live} differs from the shipped copy"
                         detail "${agent} reads the live file alone: a key this release adds is not in it, and what it declares is the host's"
                         detail "shipped copy: ${reference}" ;;
                missing) st MISSING "${agent}  ${live} is missing -- reinstall the ${agent} package"
                         STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
                *)       st "?" "${agent}  ${live} cannot be compared with the shipped copy ${reference}" ;;
            esac
        done < <(ai_tools_providers__list_agent_managed_files "${agent}")
    done < <(ai_tools_providers__list_enabled_agents 2>/dev/null)
    return 0
}

# status_entrypoints: per enabled agent, the two halves of the entrypoint reconciliation -- the pin
# ai_tools_entrypoint_verify__write_pin leaves, and the type its paths carry now. Reported together because they fail
# independently: verification can succeed while labelling does not, leaving a green pin written by the very run
# whose labelling failed.
#
# Only two states here count toward the exit status, and both stop the next launch: an unpinned entrypoint
# where AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set, and a mislabelled path. Unpinned without that setting is a supported
# state -- an air-gapped host, a release published without a manifest -- so it is reported and left uncounted.
status_entrypoints() {
    heading "Entrypoints"
    # Four libraries answer this section between them, and a partial load must report that rather than reach
    # an undefined function -- which under `set -e` would abort the whole report over the section it could not give.
    # Each ships with base, so a missing one is a reading this section could not make, not a `?`.
    if ! declare -F ai_tools_providers__list_enabled_agents      >/dev/null 2>&1 \
            || ! declare -F ai_tools_providers__read_agent_manifest_field  >/dev/null 2>&1 \
            || ! declare -F ai_tools_entrypoint_verify__get_pin_path   >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__read_stamp_field   >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__read_stamp_age     >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__format_age       >/dev/null 2>&1; then
        st UNREADABLE "the provider, entrypoint or service libraries did not load -- no agent could be resolved; reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi

    local agent pin version age live strict=no seen=0
    declare -F ai_tools_entrypoint_verify__is_required >/dev/null 2>&1 \
        && ai_tools_entrypoint_verify__is_required && strict=yes
    while IFS=$'\t' read -r agent _ _; do
        [[ -n "${agent}" ]] || continue
        seen=1
        # Verification compares the binary against the checksum in the vendor's signed release manifest,
        # which an agent's package names in release_manifest_url. An agent whose package omits that key is reported
        # as such rather than as perpetually unverified.
        pin="$(ai_tools_entrypoint_verify__get_pin_path "${agent}" 2>/dev/null || true)"
        if [[ -z "$(ai_tools_providers__read_agent_manifest_field "${agent}" release_manifest_url 2>/dev/null || true)" \
              && ! -e "${pin}" ]]; then
            st "n/a" "${agent}  its package declares no signed release manifest, and root has recorded no pin yet"
            continue
        fi
        version="$(ai_tools_services__read_stamp_field "${pin}" VERSION)"
        # The reading this vantage adds: root can traverse the toolchain, so it hashes the entrypoint and compares it
        # against the pin instead of reporting what the last reconciliation recorded. A binary that changed since is
        # therefore named here even where no reconciliation has run over it yet -- the pin's own fields cannot say
        # so, and the operator's report can only read them.
        live="$(entrypoint_live_verdict "${agent}")"
        if [[ "${live}" == mismatch ]]; then
            st MISMATCH "${agent}  the installed entrypoint does not match its pin${version:+ (${version})} -- its sessions refuse to start"
            detail "sudo ai-tools-admin system entrypoints relabel   (re-reads the entrypoint and prints how to replace it)"
            STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        elif entrypoint_stale_mark "${agent}"; then
            STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        elif [[ -n "${version}" && "$(ai_tools_entrypoint_verify__read_pin_kind "${agent}" 2>/dev/null || true)" == observed ]]; then
            # Recorded as installed: a change to the binary refuses at every setting, and no vendor signature stands
            # behind the value. UNCHANGED is what the comparison proves; it does not say the binary was sound when root
            # first recorded it. It is a pin, so it satisfies the strictness switch and is not a problem.
            age="$(ai_tools_services__format_age "$(ai_tools_services__read_stamp_age "${pin}" VERIFIED)")"
            st UNCHANGED "${agent}  ${version}${age:+, ${age}}, as installed -- its vendor publishes no signed manifest"
        elif [[ -n "${version}" ]]; then
            age="$(ai_tools_services__format_age "$(ai_tools_services__read_stamp_age "${pin}" VERIFIED)")"
            st VERIFIED "${agent}  ${version}${age:+, ${age}}"
        elif [[ -e "${pin}" ]]; then
            st unverified "${agent}  its pin is present and carries no version this reader accepts"
            detail "sudo ai-tools-admin system entrypoints relabel   (rewrites the pin)"
        elif [[ "${strict}" == yes ]]; then
            st UNVERIFIED "${agent}  this host requires verification, so its sessions will not launch"
            detail "sudo ai-tools-admin system entrypoints relabel   (fetches the vendor's signed manifest)"
            STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        else
            st unverified "${agent}  no pin -- launches are not blocked"
        fi
    done < <(ai_tools_providers__list_enabled_agents 2>/dev/null)
    [[ "${seen}" -eq 1 ]] || st "n/a" "no agent is enabled in ${OPERATOR_CONF}"
    status_labels
}

# status_agent_versions: one Version line per enabled agent, the version its installed package declares, read as data --
# ai_tools_entrypoint_verify__read_installed_version over the package.json around the entrypoint the stable launcher
# resolves to. Root traverses the toolchain the operator cannot, and reads it rather than running the agent's
# `--version`, which would execute a file the sandbox account can write (updater.rule.md, "Root runs none
# of the toolchain"). An entrypoint outside its package reads as unknown, with the path, which is itself the finding.
# Best-effort: a missing reader does not print a line, and Entrypoints reports the libraries.
status_agent_versions() {
    declare -F ai_tools_providers__list_enabled_agents                >/dev/null 2>&1 || return 0
    declare -F ai_tools_relabel__resolve_agent_entrypoint_path         >/dev/null 2>&1 || return 0
    declare -F ai_tools_entrypoint_verify__read_installed_version  >/dev/null 2>&1 || return 0
    local agent entrypoint version
    while IFS=$'\t' read -r agent _ _; do
        [[ -n "${agent}" ]] || continue
        entrypoint="$(ai_tools_relabel__resolve_agent_entrypoint_path "${agent}" 2>/dev/null || true)"
        if [[ -z "${entrypoint}" ]]; then
            printf '    %-13s %s\n' "${agent}" "not provisioned (no launcher link resolves)"
            continue
        fi
        version="$(ai_tools_entrypoint_verify__read_installed_version "${entrypoint}")"
        printf '    %-13s %s\n' "${agent}" \
            "${version:-unknown (the launcher resolves to ${entrypoint}, outside its package)}"
    done < <(ai_tools_providers__list_enabled_agents 2>/dev/null)
    return 0
}

# entrypoint_live_verdict <agent>: print what the agent's installed entrypoint hashes to against its pin RIGHT NOW --
# `ok`, `mismatch`, `unpinned`, `unreadable` -- or an empty string where the reading cannot be made. The third root-only
# reading of this report, beside the live SELinux type: the toolchain is 0750 and sandbox-owned, so no operator-side
# command can hash that file, and a pin records only what the last reconciliation found. Read-only, and it does not
# reach the network: it is the comparison the launch shim makes on every launch.
entrypoint_live_verdict() {
    local agent="$1" entrypoint
    declare -F ai_tools_entrypoint_verify__check      >/dev/null 2>&1 || return 0
    declare -F ai_tools_relabel__resolve_agent_entrypoint_path >/dev/null 2>&1 || return 0
    entrypoint="$(ai_tools_relabel__resolve_agent_entrypoint_path "${agent}" 2>/dev/null || true)"
    [[ -n "${entrypoint}" ]] || return 0
    ai_tools_entrypoint_verify__check "${agent}" "${entrypoint}" 2>/dev/null || true
}

# entrypoint_stale_mark <agent>: report, and return 0, when the last reconciliation REFUSED to re-record this agent's
# pin. The refusal leaves the pin standing -- that staleness is what makes the next launch refuse -- so without this
# the pin's own fields render as a verification that succeeded. Returns non-zero when there is no mark, which is
# the ordinary state.
entrypoint_stale_mark() {
    local agent="$1" record reason version age
    declare -F ai_tools_entrypoint_verify__get_stale_path >/dev/null 2>&1 || return 1
    record="$(ai_tools_entrypoint_verify__get_stale_path "${agent}" 2>/dev/null || true)"
    [[ -n "${record}" && -e "${record}" ]] || return 1
    [[ "$(ai_tools_services__read_stamp_field "${record}" STATE)" == stale ]] || return 1
    reason="$(ai_tools_services__read_stamp_field "${record}" REASON)"
    version="$(ai_tools_services__read_stamp_field "${record}" VERSION)"
    age="$(ai_tools_services__format_age "$(ai_tools_services__read_stamp_age "${record}" DETECTED)")"
    st "PIN STALE" "${agent}  a reconciliation refused to re-record this pin (${reason:-refused}${version:+, ${version}})${age:+, ${age}} -- its sessions refuse to start"
    detail "sudo ai-tools-admin system entrypoints relabel   (re-reads the entrypoint and prints how to replace it)"
    return 0
}

# status_labels: render ai_tools_relabel__list_agent_labels -- the live SELinux type of each enabled agent's own paths,
# which that function's header specifies. It closes status_entrypoints rather than opening a section of its own, so each
# agent's pin and its labels read together.
status_labels() {
    # Base ships this library beside this tool, so a missing report means a broken or half-upgraded install rather than
    # an optional piece -- a reading that could not be made, counted as such, since that is what it is from the reader's
    # side.
    if ! declare -F ai_tools_relabel__list_agent_labels >/dev/null 2>&1; then
        st UNREADABLE "live labels: ${RELABEL_LIB} did not load its report -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local report verdict agent what path actual wanted rc=0
    report="$(ai_tools_relabel__list_agent_labels)" || rc=$?
    if [[ "${rc}" -eq 2 ]]; then
        st "n/a" "SELinux confinement is inactive here -- there is no agent label to carry"
        return 0
    fi
    [[ -n "${report}" ]] || return 0
    while read -r verdict agent what path actual wanted; do
        case "${verdict}" in
            ok)   st labelled "${agent}  its ${what} is ${actual} -- ${path}" ;;
            bad)  st "NOT LABELLED" "${agent}  its ${what} is '${actual}', NOT ${wanted} -- ${path}"
                  detail "its next session refuses to launch rather than run unconfined"
                  detail "sudo ai-tools-admin system entrypoints relabel"
                  STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
            none) st "n/a" "${agent}  its ${what} is not installed -- nothing to label" ;;
        esac
    done <<< "${report}"
    return 0
}

# status_unit_search_path: the sandbox account's systemd unit search path -- the chain's drift
# (ai_tools_control_plane__find_unit_search_path_drift), any unexpected entry on it
# (ai_tools_control_plane__find_unexpected_unit_search_path_entries), and the `Persistent=` timer stamp. A root-vantage
# reading: the chain has no world bits. It reads and does not write. <home> is a parameter so a unit test drives it
# over a fixture chain.
status_unit_search_path() {
    local home="${1:-${CP_HOME:-/opt/ai-tools}}"
    heading "Sandbox unit search path"
    if ! declare -F ai_tools_control_plane__find_unit_search_path_drift >/dev/null 2>&1 \
            || ! declare -F ai_tools_control_plane__find_unexpected_unit_search_path_entries >/dev/null 2>&1; then
        st UNREADABLE "the control-plane library did not load its unit search path readers -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local line path got wanted drift_out drift_rc=0 open=0
    drift_out="$(ai_tools_control_plane__find_unit_search_path_drift "${home}" "${SANDBOX_GROUP}")" || drift_rc=$?
    case "${drift_rc}" in
        0)  while IFS= read -r line; do
                [[ -n "${line}" ]] || continue
                path="${line%% *}"; got="${line#* }"; wanted="${got#* root:}"; got="${got%% root:*}"
                # An absent directory is a host the provisioning run has not reached, which Provisioning reports; one
                # that exists at another owner or mode is the state this layout removes, so it counts.
                if [[ "${got}" == absent ]]; then
                    st "n/a" "${path}  not created yet -- the provisioning run makes it root:${wanted}"
                    continue
                fi
                open=1
                st DRIFTED "${path}  is ${got}, not root:${wanted}"
                STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
            done <<< "${drift_out}"
            if (( open )); then
                detail "${SANDBOX_USER}'s --user manager runs unconfined, so a unit left on this path would run with none of the properties a session unit sets"
                detail "sudo ai-tools-admin system bootstrap"
            fi ;;
        1)  st OK "root owns every directory down to ${home}/${CP_UNIT_SEARCH_PATH_CHAIN[-1]}" ;;
        *)  st UNREADABLE "the unit search path under ${home} could not be read -- reinstall ai-tools-base"
            STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 )) ;;
    esac
    while IFS= read -r line; do
        [[ -n "${line}" ]] || continue
        st UNEXPECTED "${line}  is on the unit search path and nothing ai-tools ships writes it -- inspect it, then remove it"
        STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
    done < <(ai_tools_control_plane__find_unexpected_unit_search_path_entries "${home}")
    status_update_timer_stamp "${home}"
    return 0
}

# status_caches [home] [state-file]: per cache group, what its last cleanup did and when, read from the state file
# through cache-cleanup.lib.sh, whose verdicts and remedies ai-tools status renders too, and beside it the size
# the cache holds now, which only root reads in the 0750 tree (`du -sxP`, as data, bounded to ten seconds). A group
# that failed, or that holds links its producer does not write, is counted in STATUS_PROBLEMS; an overdue one is shown
# and not counted, and an empty file reads `?` and is not counted. <home> and <state-file> are parameters so a unit test
# drives the section over fixtures; the report passes AI_TOOLS_CACHE_CLEANUP_STATE, a root-only override.
status_caches() {
    local home="${1:-${CP_HOME:-/opt/ai-tools}}"
    local state="${2:-${AI_TOOLS_CACHE_CLEANUP_STATE:-/var/opt/ai-tools/state/cache-cleanup.status}}"
    local name relative verdict result bytes links reason checked_age cleaned_age token text size size_text
    heading "Caches"
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/cache-cleanup.lib.sh
    source "${CACHE_CLEANUP_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_cache_cleanup__list_group_statuses >/dev/null 2>&1; then
        st UNREADABLE "the cache cleanup library (${CACHE_CLEANUP_LIB}) did not load -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    if ai_tools_services__is_stamp_unwritten "${state}"; then
        st "?" "no cleanup recorded yet -- the updater's first run and the first session launch write ${state}"
        return 0
    fi
    while IFS=$'\t' read -r name relative verdict result bytes links reason checked_age cleaned_age; do
        [[ -n "${name}" ]] || continue
        [[ "${checked_age}" == - ]] && checked_age=""
        [[ "${cleaned_age}" == - ]] && cleaned_age=""
        text="${relative}  "
        [[ "${verdict}" == links ]] && text+="${links} symbolic link(s) its producer does not write, "
        text+="$(ai_tools_cache_cleanup__format_result "${result}" "${bytes}" "${reason}")"
        [[ -n "${checked_age}" ]] && text+=", $(ai_tools_services__format_age "${checked_age}")"
        if [[ "${result}" != removed && "${result}" != absent && -n "${cleaned_age}" ]]; then
            text+=" -- last cleaned $(ai_tools_services__format_age "${cleaned_age}")"
        fi
        size="$(timeout 10 du -sxP --bytes -- "${home}/${relative}" 2>/dev/null | cut -f1 || true)"
        size_text=""
        if [[ "${size}" =~ ^[0-9]+$ ]]; then
            size_text="; holds $(ai_tools_cache_cleanup__format_bytes "${size}") now"
        elif [[ ! -e "${home}/${relative}" ]]; then
            size_text="; absent now"
        fi
        text+="${size_text}"
        case "${verdict}" in
            unknown) st "?" "${relative}  no cleanup recorded yet${size_text}"; continue ;;
            failed)  token=FAILED ;;
            links)   token=LINKS ;;
            overdue) token=OVERDUE ;;
            *)       case "${result}" in
                         removed|absent) token=OK ;;
                         # The token is the result here, so the text keeps its reason alone.
                         *)              token="${result^^}"; text="${text/"  ${result} ("/"  ("}" ;;
                     esac ;;
        esac
        st "${token}" "${text}"
        case "${verdict}" in
            failed|links) detail "$(ai_tools_cache_cleanup__get_status_remedy "${verdict}" "${reason}")"
                          STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
            overdue)      detail "$(ai_tools_cache_cleanup__get_status_remedy "${verdict}" "${reason}")" ;;
        esac
    done < <(ai_tools_cache_cleanup__list_group_statuses "${state}")
    return 0
}

# status_update_timer_stamp: the `Persistent=` stamp nvm-update.timer keeps (ai_tools_services__evaluate_timer_stamp).
# Whether the timer is overdue is the Services section's reading.
status_update_timer_stamp() {
    local home="${1:-${CP_HOME:-/opt/ai-tools}}"
    if ! declare -F ai_tools_services__evaluate_timer_stamp >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__parse_timespan_seconds >/dev/null 2>&1; then
        st UNREADABLE "the service library did not load its timer-stamp readers -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local stamp="${home}/${CP_TIMER_STAMP_DIR}/${CP_UPDATE_TIMER_STAMP}"
    local state=ok mtime now skew="" accuracy delay allowance verdict
    if [[ -L "${stamp}" || ( -e "${stamp}" && ! -f "${stamp}" ) ]]; then
        state=unreadable
    elif [[ ! -e "${stamp}" ]]; then
        state=absent
    elif ! mtime="$(stat -c %Y -- "${stamp}" 2>/dev/null)" || [[ ! "${mtime}" =~ ^[0-9]+$ ]]; then
        state=unreadable
    else
        now="$(date -u +%s 2>/dev/null || true)"
        if [[ "${now}" =~ ^[0-9]+$ ]]; then
            skew=0
            [[ "${mtime}" -gt "${now}" ]] && skew=$(( mtime - now ))
        fi
    fi
    # An unreadable property leaves its share of the allowance at 0, which only makes the future test stricter.
    accuracy="$(ai_tools_services__parse_timespan_seconds "$(ai_tools_services__read_unit_property nvm-update.timer AccuracyUSec sandbox-user)")"
    delay="$(ai_tools_services__parse_timespan_seconds "$(ai_tools_services__read_unit_property nvm-update.timer RandomizedDelayUSec sandbox-user)")"
    allowance=$(( ${accuracy:-0} + ${delay:-0} ))
    verdict="$(ai_tools_services__evaluate_timer_stamp "${state}" "${skew}" "${allowance}")"
    case "${verdict}" in
        ok)     st OK "the update timer's Persistent= stamp" ;;
        absent) st "n/a" "no Persistent= stamp yet -- the next start of that manager runs a catch-up update" ;;
        future) st FUTURE "its Persistent= stamp is dated $(( ${skew:-0} / 60 )) min ahead of now, past the timer's own ${allowance}s tolerance"
                detail "systemd reads that stamp at timer start, so a missed window gets no catch-up run while it stands"
                detail "check this host's clock, then: sudo systemctl --user -M ${SANDBOX_USER}@.host restart nvm-update.timer"
                STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
        *)      st "?" "its Persistent= stamp could not be read at ${stamp}" ;;
    esac
    return 0
}

# status_selinux_attestation: render the rows ai_tools_confinement__list_attestation_report prints (confinement.lib.sh):
# the per-domain mode of ai_tools_t and the Booleans that widen it, read and classified by the launch shim's own
# functions, so this report, ai-tools status and the launch cannot disagree. A finding counts only where the report's
# verdict row says AI_TOOLS_REQUIRE_SELINUX is set, since that is when it refuses a launch, and on a host without
# SELinux confinement by its own configuration (ai_tools_confinement__evaluate_dac_only_state) only where that key makes
# every launch warn; elsewhere it is reported as the posture it is. <operator-conf> is a parameter so a unit test drives
# the counting rule over a fixture.
status_selinux_attestation() {
    local operator_conf="${1:-${OPERATOR_CONF}}"
    heading "SELinux status"
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/confinement.lib.sh
    source "${CONFINEMENT_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_confinement__list_attestation_report >/dev/null 2>&1 \
            || ! declare -F ai_tools_confinement__evaluate_dac_only_state >/dev/null 2>&1; then
        st UNREADABLE "${CONFINEMENT_LIB} did not load its attestation readers -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    local selinux_mode module_present policy_shipped dac_only_state=""
    selinux_mode="$(getenforce 2>/dev/null || true)"
    if [[ -z "${selinux_mode}" ]]; then
        st "n/a" "SELinux is not readable here (getenforce) -- there is no domain to attest"
        return 0
    fi
    module_present="$(ai_tools_confinement__read_module_present "${AI_TOOLS_CONFINEMENT__MODULE_PROBE_PATH}" 2>/dev/null || true)"
    policy_shipped="$(ai_tools_confinement__read_policy_shipped "${AI_TOOLS_CONFINEMENT__CORE_MODULE_FILE}")"
    dac_only_state="$(ai_tools_confinement__evaluate_dac_only_state "${selinux_mode}" "${module_present}" "${policy_shipped}")" \
        || true
    if [[ -n "${dac_only_state}" ]]; then
        case "${dac_only_state}" in
            disabled) st "n/a" "SELinux is disabled on this host -- there is no domain to attest" ;;
            *)        st "n/a" "the ai_tools policy is not installed (SELinux ${selinux_mode}) -- there is no domain to attest" ;;
        esac
        if ai_tools_confinement__is_selinux_required "${operator_conf}"; then
            detail "AI_TOOLS_REQUIRE_SELINUX is set, so every launch runs DAC-only and warns that the requirement is not met"
            detail "declare this host DAC-only:  set AI_TOOLS_REQUIRE_SELINUX=no in ${operator_conf}"
            detail "or install the policy:       sudo dnf install ai-tools-selinux"
            STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        else
            detail "AI_TOOLS_REQUIRE_SELINUX is not set, so a launch runs DAC-only without a warning"
        fi
        return 0
    fi
    local -a row
    local origin_note effect_note enforcement_note="not enforced"
    ai_tools_confinement__is_selinux_required "${operator_conf}" && enforcement_note="launch blocked"
    while IFS=$'\t' read -r -a row; do
        case "${row[0]}" in
            domain)   # <yes|no|unread> <remedy|->
                case "${row[1]}" in
                    no)  st enforcing "ai_tools_t is enforced as a domain" ;;
                    yes) st PERMISSIVE "ai_tools_t is a permissive domain -- its denials are logged and not enforced"
                         detail "${row[2]}" ;;
                    *)   st "?" "whether ai_tools_t is a permissive domain could not be read" ;;
                esac ;;
            boolean)  # <name> <classification> <state> <required> <origin> <opening> <grants> <remedy|->
                origin_note=""
                [[ "${row[5]}" == operator.conf ]] && origin_note=", operator.conf"
                [[ "${row[5]}" == built-in ]] && origin_note=", built in"
                effect_note=""
                [[ "${row[6]}" == on || "${row[6]}" == off ]] && effect_note="when ${row[6]}, allows ${row[7]}"
                case "${row[2]}" in
                    matches) st "${row[3]}" "${row[1]}  required: ${row[4]}${origin_note}" ;;
                    differs) st "${row[3]^^}" "${row[1]}  required: ${row[4]}${origin_note}, ${enforcement_note}${effect_note:+ -- ${effect_note}}"
                             detail "${row[8]}" ;;
                    open)    st "${row[3]}" "${row[1]}  ${effect_note}" ;;
                    closed)  st "${row[3]}" "${row[1]}${effect_note:+  ${effect_note/, allows/, would allow}}" ;;
                    malformed) st MALFORMED "${row[1]} in ${operator_conf} has ${row[7]} -- every launch refuses until it is fixed" ;;
                    *)       st "?" "${row[1]}  could not be read" ;;
                esac ;;
            verdict)  # <ok|permissive|boolean|unknown> <required: yes|no>
                [[ "${row[1]}" == ok ]] && continue
                if [[ "${row[2]}" == yes ]]; then
                    detail "AI_TOOLS_REQUIRE_SELINUX is set, so every launch refuses while this stands"
                    STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
                else
                    detail "AI_TOOLS_REQUIRE_SELINUX is no, so launches are not refused for this"
                fi ;;
        esac
    done < <(ai_tools_confinement__list_attestation_report "${operator_conf}")
    return 0
}

# status_assets: the Assets section, read from the plan alone (assets.lib.sh), without the lock and without a write: one
# [ATTENTION] line per enabled asset that is not linked, per untrusted or foreign entry the plan met, and per agent
# whose own entry stands at an enabled asset's name, each counted in STATUS_PROBLEMS; one [UNREADABLE] line per input
# the plan could not read, counted in STATUS_UNREADABLE; the withdrawn-successor notice (status_assets_successors);
# an OK line per linked asset that declares a capability; and an OK summary of how many entries are enabled and linked.
# A library that did not load is a reading this section could not make. Every string from a set or an agent's directory
# passes the sanitizer. The section closes with status_assets_summary over the counts it added.
status_assets() {
    local problems_before="${STATUS_PROBLEMS}" unreadable_before="${STATUS_UNREADABLE}"
    heading "Assets"
    status_assets_rows
    status_assets_summary $(( STATUS_PROBLEMS - problems_before )) $(( STATUS_UNREADABLE - unreadable_before ))
    return 0
}

# status_assets_rows: the Assets section's lines, as status_assets states them.
status_assets_rows() {
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/assets.lib.sh
    source "${ASSETS_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_assets__plan >/dev/null 2>&1; then
        st UNREADABLE "the assets library (${ASSETS_LIB}) did not load -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    ai_tools_assets__plan 2>/dev/null
    local entry index linked=0 reason
    case "${AI_TOOLS_ASSETS__LIST_STATE}" in
        untrusted) st ATTENTION "enable-list-untrusted  ${AI_TOOLS_OPERATOR_CONF} -- $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__LIST_DETAIL}")"
                   detail "no asset is linked while it stands; restore the file to root:root 0644"
                   STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
        invalid)   st ATTENTION "id-malformed  AI_TOOLS_ASSETS -- $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__LIST_DETAIL}")"
                   STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
        unread)    st UNREADABLE "error  AI_TOOLS_ASSETS -- $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__LIST_DETAIL}")"
                   STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 )) ;;
        unset)     status_assets_successors ;;
    esac
    for entry in "${AI_TOOLS_ASSETS__ENTRIES[@]}"; do
        if [[ "${AI_TOOLS_ASSETS__STATE[${entry}]}" == linked ]]; then
            linked=$(( linked + 1 ))
            [[ -z "${AI_TOOLS_ASSETS__CAPS[${entry}]:-}" ]] \
                || st OK "$(ai_tools_log__sanitize "${entry}")  linked, requires ${AI_TOOLS_ASSETS__CAPS[${entry}]}"
            continue
        fi
        reason="${AI_TOOLS_ASSETS__DETAIL[${entry}]:-}"
        if [[ "${AI_TOOLS_ASSETS__STATE[${entry}]}" == error ]]; then
            st UNREADABLE "$(ai_tools_log__sanitize "${entry}")  ${AI_TOOLS_ASSETS__STATE[${entry}]}"
            STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        else
            st ATTENTION "$(ai_tools_log__sanitize "${entry}")  ${AI_TOOLS_ASSETS__STATE[${entry}]}"
            STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
        fi
        detail "$(ai_tools_log__sanitize "${reason:0:200}")"
    done
    for (( index = 0; index < ${#AI_TOOLS_ASSETS__ROW_SEVERITY[@]}; index++ )); do
        case "${AI_TOOLS_ASSETS__ROW_SEVERITY[index]}" in
            attention)  st ATTENTION "${AI_TOOLS_ASSETS__ROW_FINDING[index]}  $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__ROW_SUBJECT[index]}")"
                        STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 )) ;;
            unreadable) st UNREADABLE "${AI_TOOLS_ASSETS__ROW_FINDING[index]}  $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__ROW_SUBJECT[index]}")"
                        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 )) ;;
            *)          continue ;;
        esac
        reason="${AI_TOOLS_ASSETS__ROW_DETAIL[index]}"
        detail "$(ai_tools_log__sanitize "${reason:0:200}")"
    done
    st OK "${#AI_TOOLS_ASSETS__ENTRIES[@]} asset(s) enabled in AI_TOOLS_ASSETS, ${linked} linked"
    return 0
}

# status_assets_successors: the notice for a host whose upgrade withdrew assets a set succeeds while AI_TOOLS_ASSETS is
# absent from operator.conf, the one input that records the operator's decision. One [ATTENTION] successor-unenabled
# line per successor set, naming the withdrawn entries (ai_tools_managed_assets__list_withdrawn_successors)
# and the commands that end it: the set's package install where the set is not on the host
# (ai_tools_assets__is_set_installed), its enable, and AI_TOOLS_ASSETS=[] to keep it off. A retired directory that could
# not be listed, and a seeder library that did not load, are each one [UNREADABLE] line. A host without a retired copy
# of a withdrawn entry with a successor, a fresh install among them, does not print a line. Runs as root without
# the lock.
status_assets_successors() {
    local listing entry successor successor_set install
    local -a sets=()
    local -A names=()
    if ! declare -F ai_tools_managed_assets__list_withdrawn_successors >/dev/null 2>&1; then
        st UNREADABLE "error  the seeder library (/usr/local/lib/ai-tools/managed-assets.lib.sh) did not load -- reinstall ai-tools-base; a withdrawn asset's successor is not checked"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    if ! listing="$(ai_tools_managed_assets__list_withdrawn_successors "${AI_TOOLS_ASSETS_HOME}" 2>/dev/null)"; then
        st UNREADABLE "error  ${AI_TOOLS_ASSETS_HOME}/retired -- the directory could not be listed, so a withdrawn asset's successor is not checked"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        return 0
    fi
    while IFS=$'\t' read -r entry successor; do
        [[ -n "${entry}" ]] || continue
        successor_set="${successor%%/*}"
        [[ -n "${names[${successor_set}]+x}" ]] || sets+=( "${successor_set}" )
        names["${successor_set}"]+="${names[${successor_set}]:+, }${entry#*/}"
    done <<< "${listing}"
    for successor_set in "${sets[@]}"; do
        install=""
        ai_tools_assets__is_set_installed "${successor_set}" || install="sudo dnf install ai-tools-assets-${successor_set} && "
        st ATTENTION "successor-unenabled  ${successor_set} -- ${names[${successor_set}]} withdrawn from ai-tools-base; their successors in the set ${successor_set} are not enabled and AI_TOOLS_ASSETS is not set; copies are under ${AI_TOOLS_ASSETS_HOME}/retired"
        detail "run: ${install}sudo ai-tools-admin assets enable --set ${successor_set}, or set AI_TOOLS_ASSETS=[] in ${AI_TOOLS_OPERATOR_CONF} to keep them off"
        STATUS_PROBLEMS=$(( STATUS_PROBLEMS + 1 ))
    done
}

# status_assets_summary <finding-count> <unreadable-count> : the Assets section's closing line, printed when either
# count is one or more: the non-zero counts and the exit status the section sets, 5 where a reading is unavailable and 4
# otherwise. The counts are the ones the section added to STATUS_PROBLEMS and STATUS_UNREADABLE, so the line
# and the exit agree. Prose for a person, not a record: a monitor reads the exit status.
status_assets_summary() {
    local finding_count="$1" unreadable_count="$2" findings="" readings=""
    (( finding_count > 0 || unreadable_count > 0 )) || return 0
    if (( finding_count == 1 )); then findings="1 finding needs attention"
    elif (( finding_count > 1 )); then findings="${finding_count} findings need attention"
    fi
    if (( unreadable_count == 1 )); then readings="1 reading is unavailable"
    elif (( unreadable_count > 1 )); then readings="${unreadable_count} readings are unavailable"
    fi
    printf '    Summary: %s%s%s in this section (exit %s)\n' "${findings}" "${findings:+${readings:+ and }}" "${readings}" \
        "$( (( unreadable_count > 0 )) && printf 5 || printf 4 )"
}

# status_node_version: the Version section's Node line, from the same verdict the CLI renders
# (ai_tools_toolchain__evaluate_node_versions, toolchain.lib.sh): the active version read off the enabled agents' stable
# launcher links, and the version the updater's last run recorded shown beside it only where the two differ. Root could
# read the toolchain itself; the link is read instead so the two reports have one source and one answer. Best-effort:
# a host with neither a link nor a stamp gets no Node line, and Provisioning says why.
status_node_version() {
    local rec stamp_node="" verdict kind version stamp_seen
    if declare -F ai_tools_services__read_stamp_field >/dev/null 2>&1; then
        while IFS= read -r rec; do
            stamp_node="$(ai_tools_services__read_stamp_field "$(ai_tools_services__get_field "${rec}" 7)" NODE)"
            [[ -n "${stamp_node}" && "${stamp_node}" != unknown ]] && break
            stamp_node=""
        done < <(ai_tools_services__list_records)
    fi
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/toolchain.lib.sh
    source "${TOOLCHAIN_LIB}" 2>/dev/null || true
    if declare -F ai_tools_toolchain__list_agent_link_node_versions >/dev/null 2>&1 \
            && declare -F ai_tools_toolchain__evaluate_node_versions >/dev/null 2>&1; then
        verdict="$(ai_tools_toolchain__list_agent_link_node_versions "${LAUNCHER_LINK_DIR}" 2>/dev/null \
                       | ai_tools_toolchain__evaluate_node_versions "${stamp_node}")"
    elif [[ -n "${stamp_node}" ]]; then
        verdict=$'stamp\t'"${stamp_node}"     # no link reader: the stamp is the only reading left
    else
        verdict=none
    fi
    IFS=$'\t' read -r kind version stamp_seen <<<"${verdict}"
    case "${kind}" in
        active) printf '    %-13s %s%s\n' "node" "${version}" \
                    "${stamp_seen:+ (active; the last update run saw ${stamp_seen})}" ;;
        split)  printf '    %-13s %s (the enabled agents'"'"' launchers name different Node versions -- an update may be in progress)\n' \
                    "node" "${version}" ;;
        stamp)  printf '    %-13s %s (as of the last toolchain update -- no launcher link names one)\n' "node" "${version}" ;;
    esac
    return 0
}

# status: the host report. The exit is the report state's (ai-tools-records(5)): 4 when a section read a fault, 5
# when a section could not make a reading it promises, so it is usable from a monitor or a cron check without parsing
# this output -- the same contract `ai-tools status` offers, and the reason `?` and `n/a` are never counted: a reading
# this vantage point cannot make must not make a healthy host alarm every night.
status() {
    [[ $# -eq 0 ]] || reject MSG-T6S6 "status: takes no arguments"
    STATUS_PROBLEMS=0
    STATUS_UNREADABLE=0
    ai_tools_records_base__begin_report
    # Ahead of the library load, so a report that cannot be given still says what was asked for: the refusal then reads
    # as this command failing rather than as an unattributed error.
    printf '\nai-tools host status\n'

    # Loaded here rather than beside the other libraries: each is read by one command, and relabel.lib.sh pulls
    # in the provider and control-plane libraries behind it. `operators add` loads that one the same way, inside
    # the command that needs it. Each is best-effort and its section reports what it could not read. The service
    # registry is the one whose absence is reported here, since the Services section cannot open without it: a reading
    # that could not be made, so the run exits 5 and the other sections still print what they read.
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/services.lib.sh
    source "${SERVICES_LIB}" 2>/dev/null || true
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/relabel.lib.sh
    source "${RELABEL_LIB}" 2>/dev/null || true
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/entrypoint-verify.lib.sh
    source "${ENTRYPOINT_VERIFY_LIB}" 2>/dev/null || true
    local services_readable=yes
    if ! declare -F ai_tools_services__list_records   >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__read_record_state >/dev/null 2>&1 \
            || ! declare -F ai_tools_services__format_age  >/dev/null 2>&1; then
        warn MSG-V6N9 "the service registry (${SERVICES_LIB}) is unavailable -- reinstall ai-tools-base"
        STATUS_UNREADABLE=$(( STATUS_UNREADABLE + 1 ))
        services_readable=no
    fi
    # Root reaching the sandbox account's own manager is what this report adds over the operator's.
    declare -F ai_tools_services__set_sandbox_account >/dev/null 2>&1 && ai_tools_services__set_sandbox_account "${SANDBOX_USER}"

    heading "Version"
    printf '    %-13s %s\n' "ai-tools" "${AI_TOOLS_VERSION}"
    status_node_version
    status_agent_versions

    status_provisioning
    status_managed_files
    status_assets

    if [[ "${services_readable}" == yes ]]; then
        status_services
    else
        heading "Services"
        st UNREADABLE "the service registry did not load, so no unit could be read"
    fi
    status_caches "${CP_HOME:-/opt/ai-tools}" \
        "${AI_TOOLS_CACHE_CLEANUP_STATE:-/var/opt/ai-tools/state/cache-cleanup.status}"
    status_entrypoints
    status_unit_search_path "${CP_HOME:-/opt/ai-tools}"
    status_selinux_attestation "${OPERATOR_CONF}"

    # Pointers, not duplication: the reports that own the detail this one deliberately does not, then the commands
    # that read and drive the two managed units this report judges by their last run. The updater's unit lives
    # in the sandbox account's own manager, which root reaches over the machine transport and whose journal is selected
    # by unit and uid, since `journalctl --user-unit` as root reads root's own units (cli.rule.md).
    heading "More"
    printf '    %s\n' \
        "ai-tools status          the same host, read from an operator's vantage" \
        "ai-tools providers       installed agents and integrations, and which are enabled" \
        "sudo ai-tools-admin selinux groups   the core module and the optional groups" \
        "sudo ai-tools-admin --help           every command this host has"
    local sandbox_uid
    sandbox_uid="$(id -u "${SANDBOX_USER}" 2>/dev/null || printf '<uid of %s>' "${SANDBOX_USER}")"
    printf '\n    %s\n' "The relabel watcher and the toolchain updater, read and run as root:"
    printf '      %-78s %s\n' \
        "sudo systemctl status ai-tools-relabel.path ai-tools-relabel.service" "the watcher, and the result of its last run" \
        "sudo journalctl -u ai-tools-relabel.service -n 50" "what that run printed" \
        "sudo systemctl start ai-tools-relabel.service" "run the relabel now; a FAILED above clears with a run that succeeds" \
        "sudo systemctl --user -M ${SANDBOX_USER}@.host status nvm-update.timer nvm-update.service" "the updater's schedule and its last run" \
        "sudo journalctl _SYSTEMD_USER_UNIT=nvm-update.service _UID=${sandbox_uid} -n 50" "what the updater printed" \
        "sudo systemctl --user -M ${SANDBOX_USER}@.host start nvm-update.service" "run the update now" \
        "sudo systemctl --user -M ${SANDBOX_USER}@.host daemon-reload" "reload that manager after a unit file changed on disk"
    printf '\n'

    (( STATUS_PROBLEMS == 0 )) || ai_tools_records_base__accumulate_severity attention
    (( STATUS_UNREADABLE == 0 )) || ai_tools_records_base__accumulate_severity unreadable
    ai_tools_records_base__get_exit_status || return $?
    return 0
}

# ── assets ───────────────────────────────────────────────────────────────────────────────────────────────────────────
# The `assets` domain: AI_TOOLS_ASSETS in operator.conf names the assets every enabled agent loads, and assets.lib.sh
# resolves each to `linked` or a reason token and keeps the view and the agents' links current (shipped-assets.rule.md).
# Every verb takes the assets lock (ai_tools_managed_assets__lock) before its first read of operator.conf and holds it
# to its exit, so two verbs at once each read the list the other wrote: lock, read, back up, write, plan, apply, report.
# Every verb prints a record stream (ai-tools-records(5)): the rows of its own, then the rows the reconcile it ends
# with writes. A verb exits 1 for a write refused or failed, else 5 when the library did not load, else 4
# for an attention row, else 0. Each attention row is logged under its code to journald and to the root-only
# assets.log.

# assets_load: source assets.lib.sh, or exit 5 under its code: a verb whose resolver did not load does not read or write
# any input.
assets_load() {
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/assets.lib.sh
    source "${ASSETS_LIB}" 2>/dev/null || true
    if ! declare -F ai_tools_assets__reconcile >/dev/null 2>&1 || ! declare -F ai_tools_assets__build_enable_snapshot >/dev/null 2>&1; then
        warn MSG-P8M5 "the assets library (${ASSETS_LIB}) did not load, so no asset was read or changed -- reinstall ai-tools-base"
        exit 5
    fi
    AI_TOOLS_LOG_TAG=ai-tools-assets
    AI_TOOLS_LOG_FILE=assets.log
}

# _assets_attention_row <code> <situation> <finding> <subject> <identifier> <detail> and _assets_info_row (the same
# at info): write one record of a verb's own, subject-type `file`, its item the identifier; an attention row is logged
# under its code too. The situation is the code's own wording, for the index; the row carries the finding
# and the detail.
_assets_attention_row() {
    local code="$1" finding="$3" subject="$4" identifier="$5" detail="$6" item=""
    ai_tools_records_tsv__frame_item_components item "${identifier}" || item=""
    ai_tools_records_tsv__write_record "" "${code}" attention "${finding}" file "" "${item}" "${subject}" "${detail}" || true
    ai_tools_log__coded warning "${code}" "${finding} ${identifier} ${subject}: ${detail}"
}
_assets_info_row() {
    local code="$1" finding="$3" subject="$4" identifier="$5" detail="$6" item=""
    ai_tools_records_tsv__frame_item_components item "${identifier}" || item=""
    ai_tools_records_tsv__write_record "" "${code}" info "${finding}" file "" "${item}" "${subject}" "${detail}" || true
}

# assets_lock_or_exit: take the assets lock for the rest of the verb, or exit 1 with no input read and no link changed;
# ai_tools_managed_assets__lock has written the refusal under MSG-M8T9.
assets_lock_or_exit() {
    ai_tools_managed_assets__lock || exit 1
}

# assets_read_list <array-name>: the entries of AI_TOOLS_ASSETS in the file the verbs write, after the checks the write
# makes: the file, where it exists, passes ai_tools_conf__is_trusted -- a file the predicate refuses is not one this
# tool repairs by writing to it -- and the key, where present, reads as a list, since an invalid one would be lost
# on the rewrite. Either refusal exits 1 before any write.
assets_read_list() {
    local -n _assets_read_list_out="$1"
    local conf="${AI_TOOLS_OPERATOR_CONF}"
    _assets_read_list_out=()
    [[ -e "${conf}" || -L "${conf}" ]] || return 0
    ai_tools_conf__is_trusted "${conf}" \
        || die MSG-K6K3 "assets: ${conf} is not root's alone ($(ai_tools_conf__read_untrusted_reason "${conf}")), so it is not written -- restore it to root:root 0644 and run the command again"
    ai_tools_conf__read_list _assets_read_list_out "${conf}" AI_TOOLS_ASSETS 2>/dev/null || return 0
    [[ "${ai_tools_conf__list_invalid:-0}" == 0 ]] \
        || die MSG-B4Z5 "assets: AI_TOOLS_ASSETS in ${conf} is not a valid list, so it is not rewritten -- write it as [<set>/<kind>/<name>, ...] and run the command again"
}

# assets_write_list <entry>...: write AI_TOOLS_ASSETS as exactly these entries through ai_tools_conf__set_list,
# after a dated .bak of the file where it exists. Exits 1, the file as it was, when the backup or the write fails.
# A file carrying neither a live line nor the key's commented default -- one written before the key -- takes the line
# at its end, and the write says so, since the slot under its header reaches that file through `system post-upgrade`.
assets_write_list() {
    local conf="${AI_TOOLS_OPERATOR_CONF}" backup="" rc=0
    if [[ -e "${conf}" ]]; then
        backup="$(ai_tools_conf__write_backup "${conf}")" \
            || die MSG-V3M3 "assets: ${conf} could not be backed up, so AI_TOOLS_ASSETS was not written"
    fi
    ai_tools_conf__set_list "${conf}" AI_TOOLS_ASSETS "$@" || rc=$?
    (( rc == 0 )) || die MSG-Q8E5 "assets: AI_TOOLS_ASSETS could not be written to ${conf} (writer status ${rc})${backup:+; the previous file is ${backup}}"
    ai_tools_log__info "assets: AI_TOOLS_ASSETS in ${conf} lists ${#} entries${backup:+; backup ${backup}}"
    [[ "${AI_TOOLS_CONF__WRITE_PLACEMENT:-}" != appended ]] \
        || notice MSG-T3X4 "assets: AI_TOOLS_ASSETS was added at the end of ${conf}, which predates the key -- sudo ai-tools-admin system post-upgrade carries it under its header from the shipped template"
}

# assets_finish <reconcile-status>: exit as the records page states for a command that changes the host: 1 when a write
# did not take, else the record stream's own fold.
assets_finish() {
    (( $1 == 0 )) || exit 1
    ai_tools_records_base__get_exit_status || exit $?
    exit 0
}

# assets_reconcile_now: run the view transaction for the sandbox group, and return its status.
assets_reconcile_now() {
    local status=0
    ai_tools_assets__reconcile "${SANDBOX_GROUP}" || status=$?
    return "${status}"
}

# assets_enable <identifier>... | --set <set>: check every identifier before writing -- the grammar and a registry kind,
# then a binding for its set, shipped or the operator's -- refusing the whole call on the first that fails; then add
# each new one to AI_TOOLS_ASSETS once, after the existing entries in their order, and reconcile. An identifier
# whose set is not installed is accepted where the set is bound, and the reconcile reports it set-absent.
assets_enable() {
    local identifier status=0 entry
    local -a entries=() added=()
    (( $# > 0 )) || reject MSG-N5U6 "assets enable takes one or more <set>/<kind>/<name>, or --set <set>"
    case "$1" in
        --set) shift; assets_enable_set "$@"; return ;;
    esac
    assets_load
    ai_tools_records_base__begin_report
    assets_lock_or_exit
    for identifier in "$@"; do
        status=0
        ai_tools_assets__parse_id "${identifier}" || status=$?
        (( status == 0 )) \
            || die MSG-E9D6 "assets enable: '$(ai_tools_log__sanitize "${identifier}")' is not an asset identifier -- ${AI_TOOLS_ASSETS__ID_DETAIL}; AI_TOOLS_ASSETS is unchanged"
        ai_tools_assets__is_binding_present "${AI_TOOLS_ASSETS__ID_SET}" \
            || die MSG-W6D7 "assets enable: no binding names the set ${AI_TOOLS_ASSETS__ID_SET} under ${AI_TOOLS_ASSETS_BINDINGS_DIR} or ${AI_TOOLS_ASSETS_OPERATOR_BINDINGS_DIR}, so ${identifier} is accepted only once a binding pins its signer; AI_TOOLS_ASSETS is unchanged"
    done
    assets_read_list entries
    for identifier in "$@"; do
        for entry in "${entries[@]}" "${added[@]}"; do [[ "${entry}" == "${identifier}" ]] && continue 2; done
        added+=( "${identifier}" )
    done
    (( ${#added[@]} == 0 )) || assets_write_list "${entries[@]}" "${added[@]}"
    status=0
    assets_reconcile_now || status=$?
    assets_finish "${status}"
}

# assets_enable_set <set>: snapshot the set as it stands (ai_tools_assets__build_enable_snapshot, whose doc states
# which set states refuse); a refused set exits with that state's token. Each asset that passes the asset-scope rules is
# written as its own identifier, and each that does not is reported under MSG-J4E9 and left out. A later release
# of the set does not enable a new asset until that asset is enabled by name.
assets_enable_set() {
    local set="${1:-}" identifier index entry status=0
    local -a entries=() added=()
    (( $# == 1 )) || reject MSG-Z7D4 "assets enable --set takes exactly one set name"
    assets_load
    ai_tools_records_base__begin_report
    assets_lock_or_exit
    ai_tools_assets_verify__is_valid_set_name "${set}" \
        || die MSG-R3M6 "assets enable --set: '$(ai_tools_log__sanitize "${set}")' is not a set name -- 1-64 characters of a-z, 0-9 and single hyphens; AI_TOOLS_ASSETS is unchanged"
    ai_tools_assets__build_enable_snapshot "${set}"
    [[ "${AI_TOOLS_ASSETS__SNAPSHOT_STATE}" == ok ]] \
        || die MSG-P9Z3 "assets enable --set: the set ${set} is ${AI_TOOLS_ASSETS__SNAPSHOT_STATE} -- $(ai_tools_log__sanitize "${AI_TOOLS_ASSETS__SNAPSHOT_DETAIL:0:300}"); AI_TOOLS_ASSETS is unchanged"
    assets_read_list entries
    for (( index = 0; index < ${#AI_TOOLS_ASSETS__SNAPSHOT_IDS[@]}; index++ )); do
        identifier="${AI_TOOLS_ASSETS__SNAPSHOT_IDS[index]}"
        case "${AI_TOOLS_ASSETS__SNAPSHOT_TOKENS[index]}" in
            ok|capability-unsupported|integration-off) ;;
            *)  _assets_attention_row MSG-J4E9 "assets enable --set: an asset of the set that fails the asset-scope rules, left out of AI_TOOLS_ASSETS" \
                    "${AI_TOOLS_ASSETS__SNAPSHOT_TOKENS[index]}" "${AI_TOOLS_OPERATOR_CONF}" "${identifier}" \
                    "not written to AI_TOOLS_ASSETS: the asset fails a rule base enforces"
                continue ;;
        esac
        for entry in "${entries[@]}" "${added[@]}"; do [[ "${entry}" == "${identifier}" ]] && continue 2; done
        added+=( "${identifier}" )
    done
    (( ${#added[@]} == 0 )) || assets_write_list "${entries[@]}" "${added[@]}"
    assets_reconcile_now || status=$?
    assets_finish "${status}"
}

# assets_disable <identifier>...: remove each named entry from AI_TOOLS_ASSETS -- one the list does not hold is reported
# under MSG-Q5T6 and does not refuse the others -- write the list with its backup, and reconcile, which removes
# the links.
assets_disable() {
    local identifier entry status=0 found
    local -a entries=() kept=() removed=()
    (( $# > 0 )) || reject MSG-H7A8 "assets disable takes one or more <set>/<kind>/<name>"
    assets_load
    ai_tools_records_base__begin_report
    assets_lock_or_exit
    assets_read_list entries
    for identifier in "$@"; do
        found=0
        for entry in "${entries[@]}"; do [[ "${entry}" == "${identifier}" ]] && found=1; done
        if (( found )); then
            removed+=( "${identifier}" )
        else
            _assets_attention_row MSG-Q5T6 "assets disable: an identifier AI_TOOLS_ASSETS does not hold" not-enabled \
                "${AI_TOOLS_OPERATOR_CONF}" "${identifier}" "AI_TOOLS_ASSETS does not list it, so it was not disabled"
        fi
    done
    for entry in "${entries[@]}"; do
        for identifier in "${removed[@]}"; do [[ "${entry}" == "${identifier}" ]] && continue 2; done
        kept+=( "${entry}" )
    done
    if (( ${#removed[@]} > 0 )); then
        assets_write_list "${kept[@]}"
        for identifier in "${removed[@]}"; do
            _assets_info_row MSG-Q2C6 "assets disable: an identifier removed from AI_TOOLS_ASSETS" unlinked \
                "${AI_TOOLS_OPERATOR_CONF}" "${identifier}" "removed from AI_TOOLS_ASSETS; a session already running keeps what it loaded"
        done
    fi
    assets_reconcile_now || status=$?
    assets_finish "${status}"
}

# assets_reconcile_verb: `assets reconcile`, the view transaction alone.
assets_reconcile_verb() {
    local status=0
    (( $# == 0 )) || reject MSG-M7S5 "assets reconcile takes no arguments"
    assets_load
    ai_tools_records_base__begin_report
    assets_lock_or_exit
    assets_reconcile_now || status=$?
    assets_finish "${status}"
}

# assets_dispatch: the domain's verbs. A bare `assets` prints them, since each one changes the host and no `list` exists
# yet.
assets_dispatch() {
    [[ $# -ge 1 ]] || reject MSG-E5Z8 "assets takes a verb: 'assets enable <id>...', 'assets enable --set <set>', 'assets disable <id>...', 'assets reconcile'"
    local verb="$1"; shift
    case "${verb}" in
        enable)    assets_enable "$@" ;;
        disable)   assets_disable "$@" ;;
        reconcile) assets_reconcile_verb "$@" ;;
        *)         reject MSG-D4S7 "unknown command 'assets ${verb}' (enable|disable|reconcile)" ;;
    esac
}

# ── dispatch ─────────────────────────────────────────────────────────────────────────────────
# One arm per name in the grammar's two shapes: `<collection> [verb]`, where the absent verb is `list`,
# and `<domain> <collection|verb>`. A bare collection lists; a bare domain prints its own commands, since a domain has
# no reading that a default could safely take and every verb under one of these mutates the host.

operators_dispatch() {
    local verb="${1:-list}"; [[ $# -eq 0 ]] || shift
    case "${verb}" in
        list)   op_list   "$@" ;;
        add)    op_add    "$@" ;;
        remove) op_remove "$@" ;;
        *)      reject MSG-H6J5 "unknown command 'operators ${verb}' (list|add|remove)" ;;
    esac
}

selinux_groups_dispatch() {
    local verb="${1:-list}"; [[ $# -eq 0 ]] || shift
    case "${verb}" in
        list)    sel_list ;;
        enable)  sel_enable  "$@" ;;
        disable) sel_disable "$@" ;;
        *)       reject MSG-H3F8 "unknown command 'selinux groups ${verb}' (list|enable|disable)" ;;
    esac
}

selinux_dispatch() {
    [[ $# -ge 1 ]] || reject MSG-D6K9 "selinux owns one collection: 'selinux groups [list|enable|disable]'"
    local resource="$1"; shift
    case "${resource}" in
        groups) selinux_groups_dispatch "$@" ;;
        *)      reject MSG-V9Z4 "unknown command 'selinux ${resource}' (groups)" ;;
    esac
}

system_entrypoints_dispatch() {
    # No `list` yet, so a bare `system entrypoints` names its verb rather than running one: relabel re-fetches a signed
    # manifest and rewrites a pin, which is not a reading a default may take.
    [[ $# -ge 1 ]] || reject MSG-U8G3 "system entrypoints takes a verb: 'system entrypoints relabel'"
    local verb="$1"; shift
    case "${verb}" in
        relabel) entrypoints_relabel "$@" ;;
        *)       reject MSG-T7N9 "unknown command 'system entrypoints ${verb}' (relabel)" ;;
    esac
}

system_dispatch() {
    [[ $# -ge 1 ]] || reject MSG-C7S7 "system takes a resource or a verb: 'system bootstrap', 'system entrypoints relabel', 'system post-upgrade'"
    local name="$1"; shift
    case "${name}" in
        bootstrap)    system_bootstrap "$@" ;;
        entrypoints)  system_entrypoints_dispatch "$@" ;;
        post-upgrade) postupgrade "$@" ;;
        *)            reject MSG-Q2X5 "unknown command 'system ${name}' (bootstrap|entrypoints|post-upgrade)" ;;
    esac
}

# Sourced rather than executed (see the note at the root check): stop here with every function defined and no command
# dispatched, so the caller's arguments are not read as a command.
[[ "${BASH_SOURCE[0]}" == "${0}" ]] || return 0

# `--help`/`-h` and `--version` are answered ahead of the root check.
[[ $# -ge 1 ]] || { usage >&2; exit 2; }
case "$1" in
    operators) shift; operators_dispatch "$@" ;;
    selinux)   shift; selinux_dispatch   "$@" ;;
    system)    shift; system_dispatch    "$@" ;;
    status)    shift; status             "$@" ;;
    assets)    shift; assets_dispatch    "$@" ;;
    # Anything else is either a domain a provider package contributed or an unknown command, and only the discovered set
    # tells the two apart. Base names are matched first, so a fragment cannot shadow one however it is named.
    *) contributed_dispatch "$@" ;;
esac
