# Buzz — development task runner

set dotenv-load := true

desktop_dir := "desktop"
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
web_dir := "web"

# Opt-in mesh-llm. Off by default so `just dev`/`just staging`/`just production`
# skip ~420 extra crates + the llama.cpp native runtime build and stay fast to
# iterate on. Turn on to test mesh compute features: `just mesh=1 dev` /
# `just mesh=1 staging` / `just mesh=1 production`.
mesh := ""

# Reset only the current standalone desktop instance before launch.
# Usage: `just fresh=1 desktop-standalone`.
fresh := ""

# List all available tasks
default:
    @just --list

# ─── Dev Environment ─────────────────────────────────────────────────────────

# Install required dev tools via Hermit and create .env (safe to re-run)
bootstrap:
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    # Hermit's bin/ symlinks auto-download pinned tool versions on first use.
    # Running each tool once triggers the download if not already cached.
    echo "Ensuring toolchain via Hermit..."
    cargo --version &
    node --version &
    pnpm --version &
    wait
    if ! command -v docker &>/dev/null; then
        echo "Error: Docker is required but not installed."
        echo "Install it from https://docs.docker.com/get-docker/"
        exit 1
    fi
    if [[ ! -f .env ]]; then
        cp .env.example .env
        echo "Created .env from .env.example — review it before running just dev."
    fi
    ./scripts/ensure-local-relay-key.sh .env

# Start Docker services, run migrations, install desktop deps
setup: bootstrap
    ./scripts/dev-setup.sh

# Install git hooks via lefthook (dispatches from the shared .git/hooks dir so all
# linked worktrees inherit the same hooks without a worktree-relative .hooks path)
hooks:
    #!/usr/bin/env bash
    set -euo pipefail
    # Use the Hermit-pinned lefthook (bin/lefthook self-downloads on first use):
    # works with no pre-installed lefthook and guarantees the pinned version
    # rather than whatever happens to be on PATH.
    export PATH="{{justfile_directory()}}/bin:$PATH"
    # --path-format=absolute guarantees an absolute path from every invocation context:
    # without it, --git-common-dir returns ".git" from the main checkout and a
    # relative hooksPath would break linked-worktree dispatch just like .hooks did.
    HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir)/hooks"
    git config --local core.hooksPath "$HOOKS_DIR"
    lefthook install --force

# Wipe development state and recreate a clean environment. Installed Buzz is preserved.
[confirm("This will DELETE all development data and preserve installed Buzz. Continue? (y/N)")]
reset:
    ./scripts/dev-reset.sh --yes

# Stop all dev services (keep data)
down:
    docker compose down

# Show dev service status
ps:
    docker compose ps

# Tail all service logs
logs *ARGS:
    docker compose logs -f {{ARGS}}

# ─── Build & Check ───────────────────────────────────────────────────────────

# Build the Rust workspace
build:
    cargo build --workspace

# Build the Rust workspace in release mode
build-release:
    cargo build --workspace --release

# Run repo lint, formatting, and repository policy checks
check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check security-review-check file-size-check

# Validate the trusted security-review workflow support and renderer contract.
security-review-check:
    node --check .github/scripts/codex-security-review.js
    node --test .github/scripts/codex-security-review.test.js
    actionlint .github/workflows/codex-security-review.yml

# Run the repository-wide differential file-size ratchet and its policy tests.
# The ratchet inspects only files changed from the merge base, so this stays
# cheap enough to run unconditionally without duplicating path filters.
file-size-check:
    node --test scripts/check-file-sizes-core.test.mjs
    node desktop/scripts/check-file-sizes.mjs
    node web/scripts/check-file-sizes.mjs
    node mobile/scripts/check-file-sizes.mjs

# Format all Rust code
fmt:
    cargo fmt --all

# Check formatting without modifying files
fmt-check:
    cargo fmt --all -- --check

# Run clippy with warnings as errors
clippy:
    cargo clippy --workspace --all-targets -- -D warnings

# Install JS dependencies (pnpm workspace — installs all packages from root)
desktop-install:
    pnpm install

# Install JS dependencies reproducibly for CI (pnpm workspace)
desktop-install-ci:
    pnpm install --frozen-lockfile

# Run desktop lint and format checks
desktop-check:
    cd {{desktop_dir}} && pnpm check

# Fix desktop lint and format issues
desktop-fix:
    cd {{desktop_dir}} && pnpm exec biome check --write .

# Run desktop TS helper unit tests
desktop-test:
    cd {{desktop_dir}} && pnpm test

# Run desktop TypeScript checks
desktop-typecheck:
    cd {{desktop_dir}} && pnpm typecheck

# Build desktop frontend assets
desktop-build:
    cd {{desktop_dir}} && pnpm build

# Format desktop Tauri Rust code
desktop-tauri-fmt:
    cargo fmt --manifest-path {{desktop_tauri_manifest}} --all

# Check desktop Tauri Rust formatting
desktop-tauri-fmt-check:
    cargo fmt --manifest-path {{desktop_tauri_manifest}} --all -- --check

# Format all code (Rust + Tauri Rust + Dart)
fmt-all: fmt desktop-tauri-fmt mobile-fmt

# Fix all formatting and lint issues
fix-all: fmt desktop-tauri-fmt desktop-fix web-fix mobile-fix

# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
# Sidecar binary list must stay in sync with desktop-release-build below.
_ensure-sidecar-stubs:
    #!/usr/bin/env bash
    set -euo pipefail
    TARGET=$(rustc -vV | sed -n 's|host: ||p')
    mkdir -p desktop/src-tauri/binaries
    SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz)
    if [[ "$TARGET" != *windows* ]]; then
        SIDECARS+=(buzz-backend-kubernetes)
    fi
    # Create missing stubs only: tauri-build reruns build.rs (and recompiles
    # buzz-desktop) whenever an externalBin's mtime changes, so touching
    # existing files would invalidate every cached desktop test build.
    for bin in "${SIDECARS[@]}"; do
        stub="desktop/src-tauri/binaries/${bin}-${TARGET}"
        [[ -e "$stub" ]] || : > "$stub"
    done

# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
_ensure-services:
    #!/usr/bin/env bash
    set -euo pipefail
    pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
    redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
    if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
        echo "Services already healthy"
        exit 0
    fi
    echo "Starting services..."
    docker compose up -d || true
    echo -n "Waiting for services"
    for i in $(seq 1 40); do
        pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
        redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
        if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
            echo " ready"
            exit 0
        fi
        echo -n "."
        sleep 3
    done
    echo " timed out"
    exit 1

# Apply database migrations and seed the local dev community if the dev database is running
_ensure-migrations: _ensure-services
    cargo run -p buzz-admin -- migrate
    ./scripts/seed-local-community.sh

# Run clippy on the desktop Tauri Rust crate
# Features are additive, so a single invocation lints only one cfg graph.
# Both graphs ship (release-windows builds without mesh-llm), so lint both:
# the default graph covers the `#[cfg(not(feature = "mesh-llm"))]` arms and
# the feature-enabled graph covers the mesh code.
desktop-tauri-clippy: _ensure-sidecar-stubs
    cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings
    cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets --features mesh-llm -- -D warnings

# Check the desktop Tauri Rust crate compiles
desktop-tauri-check: _ensure-sidecar-stubs
    cargo check --manifest-path {{desktop_tauri_manifest}}

# Run desktop Tauri Rust unit tests
desktop-tauri-test: _ensure-sidecar-stubs
    cd desktop/src-tauri && cargo test --workspace

# Run the desktop Tauri lib tests with the mesh-llm feature graph
desktop-tauri-test-mesh: _ensure-sidecar-stubs
    cargo test --manifest-path {{desktop_tauri_manifest}} --features mesh-llm --lib

# Run the native terminal latency gate explicitly on a known-idle host.
# This is intentionally excluded from shared CI: scheduler contention makes a
# wall-clock assertion flaky, and the release profile is the shipped shape.
desktop-terminal-performance-test:
    cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture

# Verify compiled-flag behavior in each compile state.
# The clean (OSS) state's full suite already runs in desktop-tauri-test, so
# only its flag assertions run here. The internal state sets both release
# capabilities together, as internal release packaging does, and reruns the
# buzz_lib suite. An auto-connect-only state asserts the two capabilities stay
# independent (a swapped build.rs variable fails it). The demo state sets only
# the demo slug, asserts neither capability leaks on, and reruns the suite.
# Every invocation uses --lib so each state compiles a single test harness;
# build.rs rerun-if-env-changed triggers the recompile between states. The
# workspace member crates and integration tests do not read these build
# variables, so they are not rerun.
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
    #!/usr/bin/env bash
    set -euo pipefail
    cd desktop/src-tauri
    unset BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY BUZZ_BUILD_DEMO_SLUG
    unset BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY BUZZ_TEST_EXPECTED_DEMO_SLUG
    # Run ignored assertions by name; libtest passes a filter that matches
    # nothing, so require exactly one passing test per filter.
    run_ignored() {
      local out
      out="$(cargo test --lib -- --ignored --nocapture "$@" 2>&1 | tee >(cat >&2))"
      if ! grep -qE "^test result: ok\. $# passed;" <<<"$out"; then
        echo "expected exactly $# ignored compiled-flag assertions to pass: $*" >&2
        exit 1
      fi
    }
    flag_tests=(compiled_flag_matches_expected compiled_policy_matches_expected)
    echo "=== Clean build (no flag) → expect false ==="
    (
      export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false
      export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
      run_ignored "${flag_tests[@]}"
    )
    echo "=== Internal build (flags set) → expect true ==="
    (
      export BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1
      export BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1
      export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true
      export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true
      run_ignored "${flag_tests[@]}"
      cargo test --lib
    )
    echo "=== Auto-connect only → capabilities stay independent ==="
    (
      export BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1
      export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true
      export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
      run_ignored "${flag_tests[@]}"
    )
    echo "=== Maximum accepted demo name reaches Rust build validation ==="
    DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)"
    DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")"
    (
      export BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG"
      export BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG"
      export BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false
      export BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false
      run_ignored compiled_demo_slug_matches_expected "${flag_tests[@]}"
      cargo test --lib
    )
    if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then
      echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2
      exit 1
    fi
    echo "All compiled states and the accepted/rejected demo-name boundary verified."

# Build the full desktop Tauri app locally (unsigned, for testing)
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
# pnpm install is unconditional here: release builds must start from a clean dep tree.
desktop-release-build target="aarch64-apple-darwin":
    #!/usr/bin/env bash
    set -euo pipefail
    TARGET={{target}}
    mkdir -p desktop/src-tauri/binaries
    touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
    touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
    if [[ "$TARGET" != *windows* ]]; then
        touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET"
    fi
    touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
    touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
    touch "desktop/src-tauri/binaries/buzz-$TARGET"
    pnpm install
    cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}

# Build an unsigned named macOS demo DMG with isolated app and runtime identities.
desktop-demo-build demo_name target="aarch64-apple-darwin":
    #!/usr/bin/env bash
    set -euo pipefail
    TARGET={{target}}
    [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; }
    CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX")"
    trap 'rm -f "$CONFIG_PATH"' EXIT
    DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')"
    DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")"
    read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; }
    PRODUCT_NAME="$(read_config productName)"
    DMG_VOLUME_NAME="$(read_config dmgVolumeName)"
    DMG_FILE_STEM="$(read_config dmgFileStem)"
    DEMO_SLUG="$(read_config slug)"
    cargo build --release --target "$TARGET" \
      -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp \
      -p git-credential-nostr -p buzz-cli
    ./scripts/bundle-sidecars.sh "$TARGET"
    pnpm install
    cd {{desktop_dir}}
    BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH"
    cd ..
    VERSION="$(node -p "require('./desktop/package.json').version")"
    DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64
    APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app"
    PLIST="$APP_PATH/Contents/Info.plist"
    /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $PRODUCT_NAME" "$PLIST"
    /usr/libexec/PlistBuddy -c "Set :CFBundleName $PRODUCT_NAME" "$PLIST"
    codesign --force --deep --sign - "$APP_PATH"
    VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg"

# Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test

# Seed deterministic channel data for desktop Playwright tests
desktop-e2e-seed: _ensure-migrations
    ./scripts/setup-desktop-test-data.sh

# Run desktop browser smoke tests
desktop-e2e-smoke:
    cd {{desktop_dir}} && pnpm test:e2e:smoke

# Run desktop relay-backed e2e tests
desktop-e2e-integration: _ensure-migrations
    cd {{desktop_dir}} && pnpm test:e2e:integration

# Run the deterministic desktop correctness smoke against an isolated local relay
desktop-release-smoke:
    ./scripts/run-desktop-release-smoke.sh

# Run only the e2e specs changed vs origin/main (both projects) before pushing
desktop-e2e-pre-push: _ensure-migrations
    git fetch origin main
    cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main

# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test

# ─── Test ─────────────────────────────────────────────────────────────────────

# Run all tests (unit + integration)
test:
    ./scripts/run-tests.sh all

# Run unit tests only (no infra needed)
test-unit:
    #!/usr/bin/env bash
    set -euo pipefail
    ./scripts/test-ensure-local-relay-key.sh
    if command -v cargo-nextest &>/dev/null; then
        cargo nextest run -p buzz-core -p buzz-auth --lib
        cargo nextest run -p buzz-audit --lib
        # Cross-pod disconnect payload tests (NIP-FI and community archive
        # fence); all infra-free.
        cargo nextest run -p buzz-pubsub --lib -E 'test(/^conn_control::tests::/)'
        # buzz-auth NIP-FI verifier doctests. The sealed-authority
        # `compile_fail` doctests prove the default-feature public API alone
        # cannot forge the issuer→JWKS authority; nextest does not run
        # doctests, hence this separate step. The verifier's regression suite
        # lives in the in-crate `#[cfg(test)] mod tests`, so `--lib` above
        # already runs it.
        cargo test -p buzz-auth --doc
        cargo nextest run -p buzz-voice --lib
        cargo nextest run -p buzz-cli
        # buzz-sdk builder/validation unit tests: pure event-builder and input
        # validation (e.g. the canvas writer-discipline/skew guard and the
        # canvas_write_survived predicate), no infra. `--lib` runs all unit
        # tests without the rustdoc dependency-resolution flake the full-package
        # invocation hits. Enumerated explicitly because nothing in CI runs
        # `cargo test --workspace` — membership buys clippy/check, not tests.
        cargo nextest run -p buzz-sdk --lib
        # buzz-acp owns the relay-to-agent trust boundary. Run its tests here so
        # forged relay events cannot regain a path into agent routing unnoticed.
        cargo nextest run -p buzz-acp
        # Exercise the real MCP server for modern and legacy clients.
        cargo nextest run -p buzz-dev-mcp
        # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
        # They guard the embedded-migrator invariant (the complete checked-in
        # additive migration set; legacy cutover/backfill remains an operator
        # script, not startup state) and the tenant-scoping lints. The
        # Postgres-backed buzz-db tests are
        # #[ignore]d, so --lib runs only the infra-free set. Without this gate a
        # stray file in migrations/ or a broken lint ships green.
        cargo nextest run -p buzz-db --lib
        # buzz-db source-policy tests: infra-free scans of the crate sources
        # that enforce the event-write admission chokepoint, metric/provenance
        # contracts, and their fixtures. They live in an integration-test
        # binary, so `--lib` above does not run them.
        cargo nextest run -p buzz-db --test observability_source
        # buzz-db `AdmittedTx` doctests. The `compile_fail` cases prove code
        # outside the crate can neither construct an admitted transaction nor
        # pass a raw `sqlx::Transaction` to an event-write helper; nextest
        # does not run doctests, hence this separate step.
        cargo test -p buzz-db --doc
        # Storage accounting crosses three crates whose focused regression
        # suites are otherwise absent from the infra-free unit lane.
        cargo nextest run -p buzz-media --lib \
            -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)'
        # buzz-admin: storage-snapshot worker plus the community archive
        # command parser, validation, and propagation-evidence tests.
        cargo nextest run -p buzz-admin \
            -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/)'
        # Multi-tenant conformance gate (buzz-conformance): the independent
        # replay checker + golden fixtures. No infra — pure in-process trace
        # replay — so it belongs in the unit job. Run all targets (lib + the
        # tests/replay_fixtures.rs integration test), not just --lib.
        cargo nextest run -p buzz-conformance
        # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
        # contract/race tests run in the dedicated CI job below.
        cargo nextest run -p buzz-push-gateway
        cargo nextest run -p buzz-push-gateway --features personal-dev-app-attest
        # Kubernetes backend provider: the decision layers (state machine, GC
        # planner, env precedence, naming, wire) are pure functions with a fake
        # substrate, so they belong in the unit job. Enumerated explicitly
        # because nothing in CI runs `cargo test --workspace` — workspace
        # membership alone buys clippy/check, not a single executed test.
        cargo nextest run -p buzz-backend-kubernetes
        # Feature-flag crate coverage: run once with LaunchDarkly enabled.
        # This includes all default tests plus the cfg(feature="launchdarkly")
        # tests, avoiding duplicate default-only execution in the unit lane.
        cargo nextest run -p buzz-feature-flags --features launchdarkly
        # buzz-agent: two infra-free concerns run together by executing the
        # whole crate (lib + integration tests), because nothing in CI runs
        # `cargo test --workspace`, so without this stanza neither the crate's
        # library tests nor its integration tests execute remotely.
        #   * model-capabilities corpus (lib): the Rust half of the
        #     cross-language drift guard. `model_capabilities.rs` embeds
        #     scripts/model-capabilities.json + scripts/normative-corpus.json via
        #     include_str! and replays the full locked corpus as pure in-process
        #     tests; without it a manifest edit that diverges Rust from the
        #     corpus ships green.
        #   * OAuth auth coordinator (lib concurrency matrix + databricks
        #     integration tests): lock single-flight, cooldown, cross-process
        #     crash recovery — infra-free via a stub OIDC provider and an
        #     injected browser opener, no network or Postgres.
        cargo nextest run -p buzz-agent
        # Admin API auth-boundary tests (api::admin in buzz-relay): the NIP-98
        # duplicate-tag rejections, the Host/Origin replay-ordering causal pair,
        # the admin.localhost origin/advertisement/canonical-URL pins, and the
        # host-oracle/credential-first checks. These are the regression guard for
        # the /api/admin/v1 moderation auth surface. Enumerated explicitly because
        # nothing in CI runs `cargo test --workspace`, `just test-unit` did not
        # enumerate `buzz-relay --lib`, and Backend Integration selects only the
        # #[ignore]d Postgres suites — so these non-ignored tests ran in no lane
        # and a red one could ship green (exactly how a broken admin test slipped
        # past every gate once). Scoped to api::admin, not the whole buzz-relay
        # --lib, because api::media has non-ignored tests that require Postgres.
        # DB-backed api::admin tests are #[ignore]d and run in the PostgreSQL
        # lane; the non-ignored ones reject before touching the database. Any
        # new non-ignored test here must stay DB-free: without a database, a
        # DB fallthrough only "passes" by waiting out the ~30s sqlx acquire
        # timeout.
        # The second clause adds the relay's pure authorization-decision tests:
        # the NIP-29 channel membership grid (handlers::channel_authz), the
        # moderation capability grid (handlers::moderation_authz), and the pure
        # helpers in handlers::side_effects. They ran in NO lane before —
        # `test(/^api::admin::/)` never matched them, and the PostgreSQL lane
        # pairs `--run-ignored ignored-only` with a `postgres_tests::`
        # default-filter — so a red one shipped green, exactly the gap the
        # api::admin clause above was added to close.
        # Deliberately scoped to these three modules instead of all of
        # `handlers::`: the wider set is mostly Postgres-backed, and five of its
        # non-postgres_tests cases only "pass" without a database by waiting out
        # the ~30s sqlx acquire timeout, so they do not belong in the infra-free
        # unit job either. The REQ subscription-lifecycle tests are picked by
        # exact name for the same reason: the rest of handlers::req needs a DB.
        # The third clause adds the NIP-FI HTTP ingress and its router/config
        # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests,
        # and the Git transport off_mode_precedence_tests. All are infra-free
        # and finish in well under a second with no DATABASE_URL, so none wait
        # out the sqlx acquire timeout. `--bin buzz-relay` adds main.rs's
        # `tests::` module (JWKS refresh cadence) and `composition_tests::`
        # (JWKS source + refresh-loop composition), which `--lib` cannot reach
        # because they live in the binary target; the nested
        # `tests::postgres_tests::` stays in the PostgreSQL lane.
        # The fourth clause adds the startup step timers (startup_steps) and the
        # env- and log-capture tests in telemetry and config. All three modules
        # are infra-free; config parses env into a struct and never connects.
        # `^config::` is anchored so it does not also select nip_fi_config.
        cargo nextest run -p buzz-relay --lib --bin buzz-relay \
            -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
        # Note on audio::join::tests scope: the full suite is infra-free (no DB,
        # no Redis). The infra-free audio/FI regression witnesses — bootstrap
        # ordering barrier, CommitConfirmed arm, pending-close invisibility,
        # abnormal-stream-close fanout, and never-ready-sink writer witnesses —
        # are all selected by audio::join::tests and audio::handler::tests.
        # DB-backed audio join tests use #[ignore] and run in the postgres lane.
        # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_upgrade and
        # api::nip_fi modules,
        # the auth metrics contract module, plus the exact NIP-FI tests added,
        # or whose assertions changed, in mixed modules (audio::room,
        # connection, handlers::*, state). nip_fi_config and router are
        # selected whole by the command above. Mixed modules are listed by exact
        # name so main's unselected tests (several wait out the ~30s sqlx
        # acquire timeout) stay out. NIP-FI tests that need Postgres live in
        # postgres_tests and
        # run in the PostgreSQL lane. Keep scripts/run-tests.sh in step.
        cargo nextest run -p buzz-relay --lib -E '
                test(/^nip_fi_upgrade::/)
                + test(/^metrics::contract_tests::/)
                + test(=audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative)
                + test(=connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge)
                + test(=audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta)
                + test(=audio::room::tests::b2_commit_peer_emits_exactly_one_joined_delta_and_marks_visible)
                + test(=audio::room::tests::b3_commit_peer_revision_is_monotone_between_concurrent_events)
                + test(=audio::room::tests::f7a_pending_peer_excluded_from_snapshot_until_committed)
                + test(=connection::tests::b2_cancelled_connection_event_frame_not_dispatched)
                + test(=connection::tests::b3_expiry_denial_precedes_close_through_send_loop)
                + test(=connection::tests::b3_root_pairing_denial_precedes_close_through_send_loop)
                + test(=connection::tests::cancellation_during_select_with_fi_denial_routes_through_bounded_path)
                + test(=connection::tests::cancelled_never_ready_sink_with_queued_fi_denial_exits_within_timeout)
                + test(=connection::tests::deadline_exp_is_earliest_selects_exp)
                + test(=connection::tests::deadline_max_connection_lifetime_is_earliest_selects_partition)
                + test(=connection::tests::deadline_no_lifetime_returns_upstream_only)
                + test(=connection::tests::expiry_notice_queued_on_ctrl_before_cancel)
                + test(=connection::tests::f3_root_outer_wrapper_delivers_denial_on_bootstrap_cancellation)
                + test(=connection::tests::f3_root_pre_built_expired_gate_terminates_connection)
                + test(=handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated)
                + test(=handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable)
                + test(=handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames)
                + test(=handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected)
                + test(=handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path)
                + test(=handlers::auth::tests::shadow_root_auth_records_pairing_denial)
                + test(=handlers::auth::tests::shadow_root_invalid_nip42_retires_without_record)
                + test(=handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence)
                + test(=handlers::event::tests::fanout_access::owner_only_kinds_keep_only_the_owner)
                + test(=handlers::event::tests::pubsub_fanout::pubsub_owner_only_kinds_reach_only_the_owner)
                + test(=handlers::event::tests::pubsub_fanout::dispatch_owner_only_kinds_reach_only_the_owner)
                + test(=handlers::req::tests::p1a_huddle_liveness_req_barrier_expiry_blocks_query_and_emission)
                + test(=state::tests::f3_cancellation_during_check_terminates_socket_without_waiting_for_check)
                + test(=state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit)
                + test(=state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection)
                + test(=state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped)
                + test(=state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason)
                + test(=state::tests::nip_fi_disconnect_audio_is_issuer_scoped)
                + test(=state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason)
                + test(=state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer)
                + test(=state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket)
                + test(=state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket)
                + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason)
                + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame)
                + test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels)
                + test(/^api::nip_fi::/)'
        # boot_lifecycle spawns the real relay binary and asserts its startup
        # lifecycle, including that buzz_startup_phase_* reaches /metrics. Its
        # non-ignored tests need no Postgres or Redis; the Postgres cases are
        # #[ignore]d and stay in the PostgreSQL lane. Keep
        # scripts/run-tests.sh in step.
        cargo nextest run -p buzz-relay --test boot_lifecycle
        # ACP author-gate and queue tests protect the trust boundary between
        # relay events and agent prompts. They are infra-free; ignored lifecycle
        # tests remain excluded and run in their dedicated integration lanes.
        cargo nextest run -p buzz-acp --lib
    else
        ./scripts/run-tests.sh unit
    fi

# Run integration tests only (starts services if needed)
test-integration:
    ./scripts/run-tests.sh integration

# Regenerate the model-capability normative corpus from the production Rust
# resolver. The corpus is a golden snapshot, never hand-edited: this runs the
# `#[ignore]`d writer test in buzz-agent, which serializes `resolve()` over the
# inputs-only question table to scripts/normative-corpus.json. Run this after
# any model-capabilities.json edit, then commit the regenerated file. The
# `corpus_matches_generated_snapshot` gate fails CI if the committed file drifts.
regen-model-corpus:
    cargo test -p buzz-agent --lib model_capabilities::tests::regen_corpus_file -- --ignored --exact

# Buzz shared compute e2e: current desktop discovery/admission logic and
# Playwright UI coverage.
mesh-e2e:
    cargo test --manifest-path {{desktop_dir}}/src-tauri/Cargo.toml --features mesh-llm mesh_llm --lib
    cd {{desktop_dir}} && pnpm test:e2e:smoke -- mesh-compute.spec.ts

# Reset only development state, seed deterministic local channels, and launch
# the mesh-enabled desktop with the repository's public Tyler test identity.
# This is for local verification only; never point this identity at staging/prod.
[confirm("This will reset development data, preserve installed Buzz, then launch a seeded mesh dev app. Continue? (y/N)")]
mesh-dev-fresh:
    #!/usr/bin/env bash
    set -euo pipefail
    ./scripts/dev-reset.sh --yes
    ./scripts/setup-desktop-test-data.sh
    export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
    export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
    export BUZZ_ALLOW_NIP_OA_AUTH=true
    export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
    export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
    export BUZZ_RECONCILE_CHANNELS=true
    export BUZZ_RESET_WEBVIEW_STATE=1
    exec just mesh=1 dev

# Real serve->client->inference on this machine (not CI).
mesh-e2e-hardware:
    #!/usr/bin/env bash
    set -euo pipefail
    cargo run -p buzz-mesh-smoke --example mesh_serve_client_smoke

# Three isolated node processes: trusted member joins and infers; stranger is rejected.
# Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain.
mesh-e2e-admission:
    #!/usr/bin/env bash
    set -euo pipefail
    cargo run -p buzz-mesh-smoke --example mesh_admission_smoke

# Full hardware confidence suite: routing, owner admission, and real agent inference.
mesh-e2e-confidence:
    #!/usr/bin/env bash
    set -euo pipefail
    cargo build --release -p buzz-agent -p buzz-dev-mcp
    cargo run -p buzz-mesh-smoke --example mesh_serve_client_smoke
    cargo run -p buzz-mesh-smoke --example mesh_admission_smoke
    cargo run -p buzz-mesh-smoke --example mesh_agent_e2e

# Take desktop screenshots using the mock bridge
desktop-screenshot *ARGS:
    #!/usr/bin/env bash
    set -euo pipefail
    pnpm -C {{desktop_dir}} build:e2e
    cd {{desktop_dir}}
    if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
        python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
        trap "kill $! 2>/dev/null || true" EXIT
        for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
    fi
    node tests/helpers/screenshot.mjs {{ARGS}}

# ─── Run ──────────────────────────────────────────────────────────────────────

# Start the relay server (auto-starts Docker services if needed)
relay: bootstrap _ensure-migrations
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    set -o allexport
    source .env
    set +o allexport
    cargo run -p buzz-relay

# Post one root + N replies (and a few reactions) to the local dev relay, for eyeballing long threads
seed-long-thread replies="187":
    ./scripts/seed-long-thread.sh {{replies}}

# Start the relay with the built web UI served from it
relay-web: bootstrap _ensure-migrations
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    set -o allexport
    source .env
    set +o allexport
    [[ -d node_modules ]] || pnpm install
    pnpm -C web build
    BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay

# Build and run the private admin dashboard
admin: bootstrap _ensure-migrations
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    set -o allexport
    source .env
    set +o allexport
    [[ -d node_modules ]] || pnpm install
    pnpm -C admin-web build
    export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
    export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
    # Default to disabled auth locally: a NIP-07 signer extension can't be
    # assumed in dev. Disabled mode serves every moderation read (reports,
    # feedback, restrictions, community directory, member profiles, any stored
    # message by ID) to anyone who can reach the relay port and refuses writes
    # and staffing, so it binds the relay to 127.0.0.1 (port from .env)
    # unless the mode is exactly nip98: a padded or misspelled value stays
    # local. Override per run with BUZZ_ADMIN_AUTH=nip98 (plus
    # RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) to exercise the
    # authenticated path; that keeps BUZZ_BIND_ADDR as configured.
    export BUZZ_ADMIN_AUTH="${BUZZ_ADMIN_AUTH:-disabled}"
    if [[ "$BUZZ_ADMIN_AUTH" != nip98 ]]; then
        bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
        relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
        export BUZZ_BIND_ADDR="127.0.0.1:${relay_port}"
    fi
    echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
    echo "Auth mode: ${BUZZ_ADMIN_AUTH} (set BUZZ_ADMIN_AUTH=nip98 to require a signed operator)"
    echo "Relay bind: ${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
    cargo run -p buzz-relay

# Seed deterministic reports and product feedback for local admin dashboard review
admin-seed: _ensure-migrations
    ./scripts/seed-admin-dashboard.sh

# Run focused relay and browser checks for the admin dashboard
admin-check: fmt-check
    cargo check -p buzz-relay --all-targets
    cargo test -p buzz-relay api::admin
    cargo test -p buzz-relay router::tests
    pnpm -C admin-web check
    pnpm -C admin-web test:e2e

# Start the relay server in release mode
relay-release: bootstrap _ensure-migrations
    #!/usr/bin/env bash
    set -euo pipefail
    set -o allexport
    source .env
    set +o allexport
    cargo run -p buzz-relay --release


# Run the desktop Tauri app in dev mode with a local relay (ports and identity derived from worktree)
dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    set -o allexport
    source .env
    set +o allexport
    bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
    relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
    health_port="${BUZZ_HEALTH_PORT:-8080}"
    metrics_port="${BUZZ_METRICS_PORT:-9102}"
    if command -v lsof >/dev/null 2>&1; then
        for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do
            name="${spec%%:*}"; port="${spec##*:}"
            if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
                echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2
                lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
                echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2
                exit 1
            fi
        done
    fi
    cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
    # Docker Desktop's forwarded MinIO port can stall under the deployment
    # probe's 32 concurrent writers. Keep the gate enabled in local dev, using
    # the bounded profile already used by the relay test launcher.
    export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}"
    export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}"
    ./target/debug/buzz-relay &
    RELAY_PID=$!
    cleanup() {
        [[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true
        kill "$RELAY_PID" 2>/dev/null || true
    }
    trap cleanup EXIT
    relay_ready=false
    for _ in $(seq 1 120); do
        if ! kill -0 "$RELAY_PID" 2>/dev/null; then
            echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2
            wait "$RELAY_PID" || true
            exit 1
        fi
        if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then
            relay_ready=true
            break
        fi
        sleep 0.5
    done
    if [[ "$relay_ready" != true ]]; then
        echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2
        exit 1
    fi
    cd {{desktop_dir}}
    [[ -d node_modules ]] || pnpm install
    source ../scripts/instance-env.sh
    INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
    echo "Starting on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
    FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
    pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}

# Run only the desktop app. No relay, database, Docker, migrations, or .env are needed.
# The app opens normally and asks for a community before making a relay connection.
desktop-standalone *ARGS: _ensure-sidecar-stubs
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
    TARGET=$(rustc -vV | sed -n 's|host: ||p')
    TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
    for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do
        cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
        chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
    done
    cd {{desktop_dir}}
    [[ -d node_modules ]] || pnpm install
    unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY
    if [[ -n "{{fresh}}" ]]; then
        export BUZZ_RESET_WEBVIEW_STATE=1
    fi
    source ../scripts/instance-env.sh
    INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
    export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-dev.${BUZZ_INSTANCE_SLUG:-main}"
    if [[ -n "{{fresh}}" ]]; then
        ../scripts/reset-desktop-standalone-state.sh "$INSTANCE_ID" "$BUZZ_DEV_KEYRING_SERVICE"
    fi
    trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
    echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started"
    FEATURES=()
    if [[ -n "{{mesh}}" ]]; then
        FEATURES=(--features mesh-llm)
    fi
    pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}

# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
staging *ARGS: bootstrap _ensure-sidecar-stubs
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    pnpm install  # unconditional: staging must always start with a clean dep tree
    cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
    FEATURES=()
    if [[ -n "{{mesh}}" ]]; then
        FEATURES=(--features mesh-llm)
    fi
    # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
    # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
    # exe dir for executable buzz-backend-* files, so the non-executable stub that
    # tauri dev copies next to the exe would hide the provider from "Run on".
    TARGET=$(rustc -vV | sed -n 's|host: ||p')
    TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
    STAGING_SIDECARS=(buzz)
    if [[ "$TARGET" != *windows* ]]; then
        STAGING_SIDECARS+=(buzz-backend-kubernetes)
    fi
    for bin in "${STAGING_SIDECARS[@]}"; do
        cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
        chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
    done
    cd {{desktop_dir}}
    export BUZZ_RELAY_URL="wss://buzz.test.blockstaging.build"
    source ../scripts/instance-env.sh
    # Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
    # agent workers. Reap this instance's agents on exit as a backstop.
    INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
    trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
    echo "Starting staging on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
    pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}

# Run the desktop app against the production relay (installs deps + builds agent tools automatically)
production *ARGS: bootstrap _ensure-sidecar-stubs
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    pnpm install  # unconditional: production must always start with a clean dep tree
    cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
    FEATURES=()
    if [[ -n "{{mesh}}" ]]; then
        FEATURES=(--features mesh-llm)
    fi
    # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
    # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
    # exe dir for executable buzz-backend-* files, so the non-executable stub that
    # tauri dev copies next to the exe would hide the provider from "Run on".
    TARGET=$(rustc -vV | sed -n 's|host: ||p')
    TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
    PRODUCTION_SIDECARS=(buzz)
    if [[ "$TARGET" != *windows* ]]; then
        PRODUCTION_SIDECARS+=(buzz-backend-kubernetes)
    fi
    for bin in "${PRODUCTION_SIDECARS[@]}"; do
        cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
        chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
    done
    cd {{desktop_dir}}
    export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz"
    source ../scripts/instance-env.sh
    # Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
    # agent workers. Reap this instance's agents on exit as a backstop.
    INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
    trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
    echo "Starting production on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
    pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}

# Run the desktop frontend dev server (port derived from worktree)
desktop-dev:
    #!/usr/bin/env bash
    set -euo pipefail
    cd {{desktop_dir}}
    [[ -d node_modules ]] || pnpm install
    source ../scripts/instance-env.sh
    echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
    pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort

# ─── Web ─────────────────────────────────────────────────────────────────────

# Run the web frontend dev server (port derived from worktree to avoid collisions)
web:
    #!/usr/bin/env bash
    set -euo pipefail
    [[ -d node_modules ]] || pnpm install
    source scripts/instance-env.sh
    export VITE_PORT=$((BUZZ_VITE_PORT + 100))
    export VITE_RELAY_URL="${BUZZ_RELAY_URL}"
    echo "Starting web dev server on port ${VITE_PORT}, relay ${BUZZ_RELAY_URL}"
    cd {{web_dir}}
    pnpm exec vite --port "${VITE_PORT}" --strictPort

# Run web lint and format checks
web-check:
    cd {{web_dir}} && pnpm check

# Fix web lint and format issues
web-fix:
    cd {{web_dir}} && pnpm exec biome check --write .

# Run web TypeScript checks
web-typecheck:
    cd {{web_dir}} && pnpm typecheck

# Build web frontend assets
web-build:
    cd {{web_dir}} && pnpm build

# Run web browser smoke tests
web-e2e-smoke:
    cd {{web_dir}} && pnpm test:e2e:smoke

# ─── Mobile ──────────────────────────────────────────────────────────────────

mobile_dir := "mobile"

# Install mobile Flutter dependencies
mobile-install:
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter pub get

# Format all Dart code
mobile-fmt:
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format .

# Fix mobile formatting and run analysis
mobile-fix:
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format . && flutter analyze

# Run mobile lint and format checks
mobile-check:
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze

# Run mobile tests
mobile-test:
    /bin/bash ./scripts/test-mobile-gateway-recipes.sh
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
    unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test test/shared/push/push_unconfigured_build_test.dart

# Regenerate the emoji dataset asset from desktop's emoji-mart install.
# Output is committed — rerun after bumping @emoji-mart/data.
mobile-emoji-data:
    node {{mobile_dir}}/scripts/generate-emoji-data.mjs

# Compile an unsigned Android debug APK (worktree-aware debug identity)
mobile-build-android:
    #!/usr/bin/env bash
    set -euo pipefail
    ./scripts/mobile-worktree-overrides.sh
    set -- build apk --debug --no-pub
    if [[ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]]; then
        set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}"
    fi
    unset GIT_DIR GIT_WORK_TREE
    cd {{mobile_dir}}
    flutter "$@"

# Run the mobile app on iOS simulator (worktree-aware debug identity)
mobile-dev:
    #!/usr/bin/env bash
    set -euo pipefail
    if ! pgrep -x Simulator &>/dev/null; then
        open -a Simulator
        sleep 3
    fi
    ./scripts/mobile-worktree-overrides.sh
    gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}"
    overrides_file="{{mobile_dir}}/ios/Flutter/AppOverrides.xcconfig"
    if [[ -z "$gateway_url" && -f "$overrides_file" ]]; then
        gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$overrides_file" | tail -n 1 | sed 's/\$()//g')"
    fi
    set -- run
    if [[ -n "$gateway_url" ]]; then
        set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${gateway_url}"
    fi
    cd {{mobile_dir}}
    unset GIT_DIR GIT_WORK_TREE
    flutter "$@"

# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept)
mobile-clean:
    ./scripts/mobile-worktree-clean.sh

# ─── Database ─────────────────────────────────────────────────────────────────

# Apply database migrations
migrate: _ensure-migrations

# ─── Utilities ────────────────────────────────────────────────────────────────

# Remove build artifacts
clean:
    cargo clean
    cargo clean --manifest-path desktop/src-tauri/Cargo.toml

# Check the Rust workspace compiles without producing binaries
check-compile:
    cargo check --workspace --all-targets

# ─── Release ─────────────────────────────────────────────────────────────────

# Read the current desktop version from package.json
get-current-version:
    @node -p "require('./desktop/package.json').version"

# Read the current relay version from its crate manifest
get-current-relay-version:
    @grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/'

# Compute next minor version (e.g., 0.3.0 → 0.4.0)
get-next-minor-version:
    @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')"

# Compute next patch version (e.g., 0.3.0 → 0.3.1)
get-next-patch-version:
    @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"

# Compute next relay patch version (e.g., 0.3.0 → 0.3.1)
get-next-relay-patch-version:
    @python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"

# Update version in desktop package manifests and regenerate lockfiles
bump-desktop-version version:
    #!/usr/bin/env bash
    set -euo pipefail
    # desktop/package.json
    cd desktop && npm pkg set "version={{ version }}" && cd ..
    # desktop/src-tauri/tauri.conf.json
    node -e "
        const fs = require('fs');
        const p = 'desktop/src-tauri/tauri.conf.json';
        const c = JSON.parse(fs.readFileSync(p, 'utf8'));
        c.version = '{{ version }}';
        fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
    "
    # JSON.stringify expands arrays/objects in a way biome rejects; reformat to match.
    (cd desktop && pnpm exec biome format --write src-tauri/tauri.conf.json)
    # desktop/src-tauri/Cargo.toml — only first version line (under [package])
    node -e "
        const fs = require('fs');
        const p = 'desktop/src-tauri/Cargo.toml';
        let t = fs.readFileSync(p, 'utf8');
        t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"');
        fs.writeFileSync(p, t);
    "
    # Regenerate lockfiles
    pnpm install --lockfile-only
    cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml
    echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles"

# Bump the relay crate version and regenerate the lockfile
bump-relay-version version:
    #!/usr/bin/env bash
    set -euo pipefail
    # buzz-relay carries its own `version =` (not version.workspace), so the
    # replace targets the package version line only.
    perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml
    cargo update -p buzz-relay
    echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"

# Open or update the desktop release PR from an immutable origin/main snapshot
release-desktop *ARGS:
    #!/usr/bin/env bash
    set -euo pipefail
    ARG="{{ ARGS }}"
    if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
        VERSION=$(just get-next-patch-version)
    else
        VERSION="$ARG"
    fi
    scripts/prepare-desktop-release.sh "$VERSION"

# Open or update the relay release PR (ghcr.io/block/buzz image)
release-relay *ARGS:
    #!/usr/bin/env bash
    set -euo pipefail
    ARG="{{ ARGS }}"
    if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
        VERSION=$(just get-next-relay-patch-version)
    else
        VERSION="$ARG"
    fi
    just _release-pr relay "$VERSION"

# Shared release-PR engine for desktop and relay. Mobile publishes immutable
# candidate tags directly from remote main instead of using metadata-only PRs.
_release-pr lane version:
    #!/usr/bin/env bash
    set -euo pipefail
    VERSION="{{ version }}"
    if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
        echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)"
        exit 1
    fi
    # Lane-specific identifiers. The bump command runs after the branch switch.
    case "{{ lane }}" in
        desktop)
            BRANCH_PREFIX="version-bump"
            TAG_FETCH='v*'
            TAG_MATCH='v[0-9]*'
            TAG_EXCLUDE='*-*'
            TAG_PREFIX="v"
            CHANGELOG="CHANGELOG.md"
            ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md)
            LOG_PATHS=(desktop/ crates/buzz-core/ crates/buzz-persona/ crates/buzz-sdk/ crates/buzz-agent/)
            ARTIFACT="Buzz Desktop" ;;
        relay)
            BRANCH_PREFIX="relay-release"
            TAG_FETCH='relay-v*'
            TAG_MATCH='relay-v[0-9]*'
            TAG_EXCLUDE='relay-v*-*'
            TAG_PREFIX="relay-v"
            CHANGELOG="crates/buzz-relay/CHANGELOG.md"
            ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
            LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
            ARTIFACT="Buzz Relay" ;;
        *)
            echo "Error: unknown release lane '{{ lane }}'"
            exit 1 ;;
    esac
    echo "Preparing ${ARTIFACT} release v${VERSION}..."
    # Must run on main with a clean, up-to-date tree.
    CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
    if [[ "$CURRENT_BRANCH" != "main" ]]; then
        echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
        exit 1
    fi
    git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags
    # Release tags are remote-owned state; sync only this lane's tags so stale
    # local tags from older histories do not make release preflight fail.
    git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}"
    if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
        echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
        exit 1
    fi
    if ! git diff --quiet || ! git diff --cached --quiet; then
        echo "Error: working tree is dirty. Commit or stash changes first."
        exit 1
    fi
    # Switch to the release branch (create, or reset to main if it exists).
    BRANCH="${BRANCH_PREFIX}/${VERSION}"
    if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then
        echo "Branch '$BRANCH' already exists — resetting to origin/main..."
        git switch "$BRANCH"
        git reset --hard origin/main
    elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
        echo "Branch '$BRANCH' exists on remote — checking out and resetting to origin/main..."
        git switch -c "$BRANCH" --track "origin/$BRANCH"
        git reset --hard origin/main
    else
        git switch -c "$BRANCH"
    fi
    # Lane-specific bump (the one diverging step).
    case "{{ lane }}" in
        desktop) just bump-desktop-version "$VERSION" ;;
        relay)   just bump-relay-version "$VERSION" ;;
    esac
    # Generate the changelog from commits since this lane's last release tag.
    LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")
    REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||')
    format_log() {
        local range="$1"
        git log "$range" --format="%h %H %s" --no-merges -- "${LOG_PATHS[@]}" | while IFS=' ' read -r short full rest; do
            local pr subject
            pr=$(printf '%s' "$rest" | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' || true)
            if [[ -n "$pr" ]]; then
                subject=$(printf '%s' "$rest" | sed -E 's/ \(#[0-9]+\)$//')
                printf -- '- %s ([#%s](https://github.com/%s/pull/%s)) ([`%s`](https://github.com/%s/commit/%s))\n' \
                    "$subject" "$pr" "$REPO" "$pr" "$short" "$REPO" "$full"
            else
                printf -- '- %s ([`%s`](https://github.com/%s/commit/%s))\n' \
                    "$rest" "$short" "$REPO" "$full"
            fi
        done
    }
    TMPFILE=$(mktemp)
    {
        echo "# Changelog"
        echo ""
        echo "## ${TAG_PREFIX}${VERSION}"
        echo ""
        if [[ -n "$LAST_TAG" ]]; then
            format_log "${LAST_TAG}..HEAD"
        else
            echo "- Initial release"
        fi
        echo ""
        if [[ -f "$CHANGELOG" ]]; then
            tail -n +2 "$CHANGELOG"
        fi
    } > "$TMPFILE"
    mkdir -p "$(dirname "$CHANGELOG")"
    mv "$TMPFILE" "$CHANGELOG"
    # Commit.
    git add "${ADD_FILES[@]}"
    RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}"
    if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then
        git commit --amend --no-edit
    else
        git commit -m "$RELEASE_MSG"
    fi
    # Push and open/update the PR.
    git push --force-with-lease -u origin "$BRANCH"
    PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n'
    if [[ -n "$LAST_TAG" ]]; then
        PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n'
        CHANGELOG_BODY=$(format_log "${LAST_TAG}..HEAD~1")
        MAX_LOG=62000
        if (( ${#CHANGELOG_BODY} > MAX_LOG )); then
            TRUNCATED=$(printf '%s' "$CHANGELOG_BODY" | awk -v max="$MAX_LOG" \
                'BEGIN{n=0} {line_len=length($0)+1; if(n+line_len>max) exit; n+=line_len; print}')
            SHOWN=$(printf '%s\n' "$TRUNCATED" | grep -c '^-' || true)
            TOTAL=$(printf '%s\n' "$CHANGELOG_BODY" | grep -c '^-' || true)
            SKIPPED=$(( TOTAL - SHOWN ))
            CHANGELOG_BODY="${TRUNCATED}"$'\n'"_… and ${SKIPPED} more commits — [compare ${LAST_TAG}…${TAG_PREFIX}${VERSION}](https://github.com/${REPO}/compare/${LAST_TAG}...${TAG_PREFIX}${VERSION})_"
        fi
        PR_BODY+="${CHANGELOG_BODY}"$'\n\n'
    else
        PR_BODY+="Initial release."$'\n\n'
    fi
    PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically."
    PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}"
    EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true)
    if [[ -n "$EXISTING_PR" ]]; then
        gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY"
        PR_URL="$EXISTING_PR"
        echo ""
        echo "Updated existing release PR: ${PR_URL}"
    else
        PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY")
        echo ""
        echo "Release PR opened: ${PR_URL}"
    fi
    echo "Merge it to trigger the release build."

# ─── Agent Harness ────────────────────────────────────────────────────────────

# Run a goose agent connected to a Buzz relay (foreground)
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
    exec env "${env_args[@]}" ./target/release/buzz-acp

# Run a goose agent in the background (screen session named 'goose-agent-N')
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
    screen -dmS goose-agent-{{agents}} bash -c "$(printf '%q ' env "${env_args[@]}") ./target/release/buzz-acp"
    echo "Agent running in screen session 'goose-agent-{{agents}}'. Attach with: screen -r goose-agent-{{agents}}"

# ─── Benchmarking ─────────────────────────────────────────────────────────────

# Run the Buzz orchestra benchmark — TB defaults to leaderboard-eligible k=5; Buzz task layers default to regression k=1 and workflow k=3. Stands up its own Docker stack; --gui opens a live spectator desktop app; other flags pass to benchmark.py (--dataset/--path, --layer, --include-task, --attempts, --manifest, --dry-run, ...)
benchmark *ARGS:
    #!/usr/bin/env bash
    set -euo pipefail
    export PATH="{{justfile_directory()}}/bin:$PATH"
    uv run --project benchmarks/harbor-buzz-orchestra/testbed \
        benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}}

# Run the benchmark adapter + testbed gate exactly as CI does (pytest + ruff, pinned ruff from pyproject)
benchmark-check:
    #!/usr/bin/env bash
    set -euo pipefail
    cd "{{justfile_directory()}}/benchmarks/harbor-buzz-orchestra"
    # CI installs the dev extra with pip, so pyproject — not uv.lock — decides
    # which ruff lints. Read the pin from there so this recipe cannot drift
    # from the workflow (a floating specifier once meant CI failed on RUF100
    # while the locked local ruff passed).
    ruff_pin="$(grep -oE 'ruff==[0-9.]+' pyproject.toml | head -1 | cut -d= -f3)"
    for project in . testbed; do
        (
            cd "$project"
            echo "── harbor-buzz-orchestra/$project (ruff $ruff_pin)"
            uv run --frozen pytest -q
            uvx "ruff@$ruff_pin" check .
            uvx "ruff@$ruff_pin" format --check .
        )
    done
    # The task verifiers live in the sibling benchmarks/buzz-dataset, so they
    # need the harness config passed explicitly to stay linted.
    echo "── buzz-dataset (ruff $ruff_pin)"
    uvx "ruff@$ruff_pin" check --config pyproject.toml ../buzz-dataset
    uvx "ruff@$ruff_pin" format --check --config pyproject.toml ../buzz-dataset

# Stop the benchmark Docker stack (state and channels are kept)
benchmark-down:
    docker compose --project-name buzz-benchmark down
