diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ebf8a7..d3c58e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ breaking changes may land in a minor release. ### Added +- **Dev-choosable multiplexer backend selection (#87).** `get_multiplexer()` now resolves by + precedence — `BMAD_LOOP_MUX_BACKEND` env var → the new machine-scoped `[mux] backend` key in + policy.toml → the platform default (win32: `psmux`, elsewhere: `tmux`) when installed → the first + registered backend that matches the platform _and_ is `available()` — so two same-platform + backends (psmux / tmux-windows) no longer collide by registration order. Forced names are trusted + (no availability gate) and fail loudly when unregistered, naming the policy file. New + `bmad-loop mux` lists registered backends (platform / available / version / selected + why); + `bmad-loop mux set ` persists the choice (`--clear` reverts to auto, `--force` allows a + name that only registers on the target machine); no interactive prompts anywhere. `validate`'s + preflight lists all detected backends when more than one is registered and notes an env/policy + forced selection. A tmux-less POSIX host still selects `TmuxMultiplexer` and reports it + unavailable, exactly as before. + - **Stories mode — a second planning pipeline that drives the loop off a typed `stories.yaml` (folder+id dispatch) instead of `sprint-status.yaml`.** Opt in with `[stories] source = "stories"` + `spec_folder`, or per run with `bmad-loop run --spec ` (overrides policy); `--story` then filters by story id. Each entry dispatches by folder + id — the dev skill creates-or-resumes the story spec at `/stories/-.md` and the orchestrator reads that id-keyed path back deterministically (no shared board to line-edit, no result-artifact mtime-scan). Strictly linear schedule (list order, no `depends_on`); `bmad-loop run --dry-run --spec ` and `bmad-loop status` print the board (id · live disk state · checkpoint markers · title). Sprint mode is unchanged and remains the default. Requires a `bmad-dev-auto` new enough for folder+id dispatch — the run preflight checks and remediates. - **Per-story human checkpoints (stories mode).** Independent `spec_checkpoint` (pause before code to review the plan — dev halts at `ready-for-dev`; approve to implement, or request a replan that resets the spec to `draft`) and `done_checkpoint` (pause after the story commits, skipped when it is the last story); both additive to `gates.mode`. A blocked story escalates + resolves as in sprint mode, with a pre-planning-halt sentinel auto-deleted (a copy preserved under the run dir) on re-arm. - **TUI human-in-the-loop surface for stories mode.** The sprint tree is replaced by a stories board (id · live disk state · spec/done checkpoint markers · title) when a stories-mode run is selected; paused runs carry a per-run pause-kind badge and the run list shows a global _⚑ N need attention_ count; `p` opens the stage-appropriate viewer — plan-checkpoint spec review (Approve & resume / Request replan), story-checkpoint summary card (Continue / Stop), escalation with story context (Resolve / Re-arm & resume), and a gate spec viewer that the existing spec-approval/epic pauses reuse. The start-run modal gains a source select + spec-folder field with a live schedule preview. Every TUI action calls the same code paths as the CLI. @@ -31,6 +44,13 @@ breaking changes may land in a minor release. ### Changed +- **`bmad-loop init` now gitignores `.bmad-loop/policy.toml`.** Policy is per-machine-per-repo — + it carries the machine-specific `[mux] backend` choice (and the TUI settings editor rewrites + it), so it must not travel to teammates on other machines or OSes. A `.gitignore` entry does not + untrack an already-committed file: existing repos run `git rm --cached .bmad-loop/policy.toml` + once (the local copy is kept; `init` prints this hint when it detects a tracked policy). + bmad-loop's own worktree-clean preflight already exempted policy.toml — this additionally stops + inner dev sessions and plain `git status` from reading a policy edit as a dirty tree. - **The patch-restore seam is now one validator, one path normalizer, and one exclusion site.** `runs.validate_restore_latch` holds every latch precondition (sentinel wedge, spec-less escalation, worktree isolation) — the worktree check lived only in the CLI, so `rearm_escalation` called diff --git a/README.md b/README.md index 4a3eac39..6595c3f0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad- ## Requirements - **Python 3.11+**, **tmux**, and a supported coding CLI — `claude` by default; `codex` and `gemini` via [profiles](#other-coding-clis). -- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the one terminal-multiplexer backend today, but it now sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter), so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. +- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the one bundled terminal-multiplexer backend today, but it sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter) with availability-aware selection — env var → persisted `[mux] backend` choice (`bmad-loop mux set `) → platform default → first available platform match — so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. - A **BMAD v6 project** (`_bmad/bmm/config.yaml`, a `sprint-status.yaml` from `bmad-sprint-planning`) with the upstream `bmad-dev-auto` skill (and the three review-hunter skills its step-04 invokes inline: `bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, `bmad-review-verification-gap`) and the bmad-loop skill module from this repo installed (`bmad-loop-resolve`, `bmad-loop-sweep` — see [Installing the skill module](#installing-the-skill-module)). Standard BMAD skills stay untouched. ## Quick start @@ -61,8 +61,9 @@ bmad-loop tui # …or drive everything from the dashboard | Command | What it does | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bmad-loop init` | Install the bundled `bmad-loop-*` skills, the hook relay, `.bmad-loop/policy.toml`, and a runs-dir gitignore. `--cli ` (repeatable) targets specific agents; `--no-skills` / `--force-skills` control skill copying. | -| `bmad-loop validate` | Preflight every prerequisite: BMAD config, sprint-status, git, CLI binary, hook registration, and a platform check that reports the selected multiplexer's readiness and process host. | +| `bmad-loop init` | Install the bundled `bmad-loop-*` skills, the hook relay, `.bmad-loop/policy.toml`, and a gitignore for the runs dir, plugin caches, and policy.toml itself (policy is per-machine — see the CHANGELOG migration note for repos initialized before this). `--cli ` (repeatable) targets specific agents; `--no-skills` / `--force-skills` control skill copying. | +| `bmad-loop validate` | Preflight every prerequisite: BMAD config, sprint-status, git, CLI binary, hook registration, and a platform check that reports the selected multiplexer's readiness and process host (listing every registered backend when more than one is detected). | +| `bmad-loop mux` | List registered terminal-multiplexer backends — platform match, availability, version, and which one is selected (and why). `mux set ` persists a machine-scoped choice into `.bmad-loop/policy.toml` (`--clear` reverts to auto-select, `--force` allows a name that only registers on the target machine); the `BMAD_LOOP_MUX_BACKEND` env var outranks it. | | `bmad-loop run` | Drive the dev → review → verify → commit loop. `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run`. `--spec ` forces **stories mode** (folder+id dispatch off `/stories.yaml`), overriding `[stories].source`; `--story` then filters by story id. | | `bmad-loop sweep` | Triage + execute open `deferred-work.md` entries. `--no-prompt`, `--decisions-only`, `--max-bundles N`, `--repeat`, `--max-cycles N`, `--dry-run`. | | `bmad-loop resume ` | Continue a run paused at a gate, escalation, or interruption. | @@ -288,7 +289,7 @@ uv tool upgrade bmad-loop --reinstall # follows main or you bmad-loop init --project /path/to/project --force-skills ``` -Your `.bmad-loop/policy.toml` is left untouched on upgrade — new keys are optional and fall back to their defaults, so configs survive. Check the [CHANGELOG / releases](https://github.com/bmad-code-org/bmad-loop/releases) for what changed between tags. +Your `.bmad-loop/policy.toml` is left untouched on upgrade — new keys are optional and fall back to their defaults, so configs survive. `init` now also gitignores policy.toml (it is per-machine: it carries the `[mux] backend` choice); if yours is already committed, run `git rm --cached .bmad-loop/policy.toml` once to stop sharing it (your local copy is kept). Check the [CHANGELOG / releases](https://github.com/bmad-code-org/bmad-loop/releases) for what changed between tags. To remove bmad-loop from a project, see [Uninstalling](docs/setup-guide.md#uninstalling) — it reverses what `init` laid down (state, skills, hooks, gitignore) and uninstalls the tool. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index de94b214..76cb9756 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -126,7 +126,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Multi-CLI / multi-agent support - Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — tmux is the only backend today, behind a pluggable seam so a native-Windows backend can be added without touching the engine (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). -- The OS is abstracted by a **registry of seams**, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (`register_multiplexer`), the process-lifecycle `ProcessHost` (`register_process_host` — `terminate`/`force_kill`/`is_alive`/`identity`), and the hook interpreter (`ProcessHost.hook_interpreter()`); `bmad-loop validate` runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see [Porting bmad-loop to a new OS](porting-to-a-new-os.md). +- The OS is abstracted by a **registry of seams**, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (`register_multiplexer`, with availability-aware selection: env var → persisted `[mux] backend` via `bmad-loop mux set` → platform default → first available platform match), the process-lifecycle `ProcessHost` (`register_process_host` — `terminate`/`force_kill`/`is_alive`/`identity`), and the hook interpreter (`ProcessHost.hook_interpreter()`); `bmad-loop validate` runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see [Porting bmad-loop to a new OS](porting-to-a-new-os.md). - Supported, E2E-verified: `claude` (reference), `codex` (≥ 0.139), `gemini` (≥ 0.46), `copilot` (GitHub Copilot CLI ≥ 2026-02 — the `copilot` binary, not the VS Code extension; `agentStop` turn-end, `-i` interactive launch, `--allow-all-tools`; pin a capable model — the free GPT-5 mini default is unreliable for multi-step skills). - Experimental, probe-required: `antigravity` (Google's `agy` ≥ 1.0.16) — `-i` interactive launch, `Stop` turn-end hook (flat handler in `.agents/hooks.json`, no SessionStart/SessionEnd), `--dangerously-skip-permissions` for unattended runs; `usage_parser = "none"` until a transcript parser is written. Verify against your `agy` build with `probe-adapter antigravity`. - Per-stage CLI/model overrides: run dev on one CLI/model, review on another (`[adapter.dev]`, `[adapter.review]`, `[adapter.triage]`). @@ -165,7 +165,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Setup & install -- `bmad-loop init` installs the five `bmad-loop-*` skills (`.claude/skills/` and/or `.agents/skills/`), the hook relay, `.bmad-loop/policy.toml`, and a runs-dir gitignore. Flags: `--cli` (repeatable), `--no-skills`, `--force-skills`. +- `bmad-loop init` installs the five `bmad-loop-*` skills (`.claude/skills/` and/or `.agents/skills/`), the hook relay, `.bmad-loop/policy.toml`, and a gitignore covering the runs dir, plugin caches, and policy.toml itself (per-machine config). Flags: `--cli` (repeatable), `--no-skills`, `--force-skills`. - `bmad-loop validate` preflights every prerequisite: BMAD config, sprint-status, git, tmux, CLI binary, hook registration. - Non-invasive: drives the upstream `bmad-dev-auto` skill unmodified — there is no fork to keep in sync — and review is just a re-invocation of it on the `done` spec. Your standard BMAD install is never modified. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af81abb6..7eaef2f0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,8 +17,11 @@ obtained from `get_multiplexer()`; the tmux backend — argv + spawn primitive i `BaseTmuxBackend` (`adapters/tmux_base.py`), POSIX leaf `TmuxMultiplexer` (`adapters/tmux_backend.py`) — is the **only** place allowed to shell out to `tmux`, and it quarantines the POSIX `sh -c` parked-window trailer. Backends now self-**register** -(`register_multiplexer`, selected by platform with a `BMAD_LOOP_MUX_BACKEND` override) rather -than being hardcoded into `get_multiplexer()`. The process-lifecycle POSIX-isms moved behind a +(`register_multiplexer`) rather than being hardcoded into `get_multiplexer()`, and selection +(#87) is availability-aware with a dev-choosable persisted pick: `BMAD_LOOP_MUX_BACKEND` env +var → the machine-scoped `[mux] backend` policy key (`bmad-loop mux set `; policy.toml +is gitignored) → the platform default (win32: `psmux`, elsewhere: `tmux`) when installed → +the first registered platform match that is `available()`. The process-lifecycle POSIX-isms moved behind a matching seam, `ProcessHost` (`src/bmad_loop/process_host.py`): `terminate` / `force_kill` / `is_alive` / `identity` (a PID-reuse guard) plus `hook_interpreter()` (so hook registration never branches on platform), registered the same way (`register_process_host`, @@ -31,15 +34,21 @@ held by a CI portability guard (`tests/test_portability_guard.py`). **WSL alread — it _is_ Linux, so it takes every fast path unchanged; this is purely about a future _native_ Windows host. -The remaining work is a real non-tmux backend (a "psmux"-style multiplexer) that implements -the `TerminalMultiplexer` contract on native Windows and registers itself for `win32`. The -seams are designed so this slots in as **new files + one registration line each, with no -change to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`, or -`cli.py`'s `validate`** (`WindowsProcessHost` and its hook interpreter are already in place -and registered). The end-to-end port path — both build options, the test-override env vars, -and exactly what a native-Windows port costs — is documented in -[Porting bmad-loop to a new OS](porting-to-a-new-os.md); the deep transport contract is in -the [adapter authoring guide](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). +The remaining work is the native-Windows backend itself. Two candidates are in flight, and +they are **sibling tmux-family backends**, not stages of one plan: `psmux` (drives psmux's +tmux-compatible shim via pwsh) and `tmux-windows` (#85; drives the tmux-windows port) — both +subclass `BaseTmuxBackend`, both register for `win32`, and both invoke a binary literally +named `tmux`, which is exactly why selection is availability-aware with discriminating +`available()` probes (psmux → `which("psmux") and which("tmux") and which("pwsh")`; +tmux-windows → `which("tmux") and not which("psmux")`) and an explicit +`bmad-loop mux set ` tie-break. The seams are designed so each slots in as **new files +plus one registration line, with no change to the adapters, `runs.py`, `tui/launch.py`, +`probe.py`, `tui/data.py`, or `cli.py`'s `validate`** (`WindowsProcessHost` and its hook +interpreter are already in place and registered). The end-to-end port path — both build +options, the test-override env vars, and exactly what a native-Windows port costs — is +documented in [Porting bmad-loop to a new OS](porting-to-a-new-os.md); the deep transport +contract is in the +[adapter authoring guide](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). **Open questions:** what hosts the windows on native Windows (Windows Terminal panes, a ConPTY-based manager, a headless process supervisor?); how attach/detach and the parked diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index ba17db2a..92ccc63f 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -50,10 +50,15 @@ only `shutil.which("tmux")` presence checks, never an invocation. To add a backend, build a `TerminalMultiplexer` (`adapters/multiplexer.py`) and **register** it — `register_multiplexer(name, matches, factory)`, where -`matches(sys.platform)` decides automatic selection and `name` is the key the -`BMAD_LOOP_MUX_BACKEND` env var forces (for tests / overrides). `get_multiplexer()` -returns the first backend whose `matches` is true, with tmux as the default -fallback. There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`) +`matches(sys.platform)` decides automatic selection and `name` is the key both +the `BMAD_LOOP_MUX_BACKEND` env var and the persisted `[mux] backend` policy key +force. `get_multiplexer()` resolves by precedence: env var → `[mux] backend` +(set with `bmad-loop mux set `, machine-scoped — policy.toml is +gitignored) → the platform default when registered and `available()` → the +first available platform match → the historical tmux fallback. `bmad-loop mux` +lists every registered backend and the selection; same-platform backends need +discriminating `available()` probes (see the +[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`) for a tmux-family backend — overriding only its single spawn primitive `_run()` plus the shell-dialect hooks (`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 4a332716..93c69308 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -11,12 +11,12 @@ The OS-specific work is quarantined behind four seams. Porting to a new OS is core `.py` modules or their call sites. Each seam selects its implementation by platform from a registry, with an env-var override for tests. -| # | Seam | Contract / registry | Override env var | -| --- | -------------------- | ------------------------------------------------------- | ------------------------ | -| 1 | Terminal multiplexer | `TerminalMultiplexer` / `register_multiplexer` | `BMAD_LOOP_MUX_BACKEND` | -| 2 | Process lifecycle | `ProcessHost` / `register_process_host` | `BMAD_LOOP_PROCESS_HOST` | -| 3 | Hook interpreter | `ProcessHost.hook_interpreter()` | (rides on seam 2) | -| 4 | Validate preflight | `_platform_preflight()` (no new code — reads seams 1–2) | — | +| # | Seam | Contract / registry | Override env var | +| --- | -------------------- | ------------------------------------------------------- | ------------------------------------------------------- | +| 1 | Terminal multiplexer | `TerminalMultiplexer` / `register_multiplexer` | `BMAD_LOOP_MUX_BACKEND` (or `bmad-loop mux set `) | +| 2 | Process lifecycle | `ProcessHost` / `register_process_host` | `BMAD_LOOP_PROCESS_HOST` | +| 3 | Hook interpreter | `ProcessHost.hook_interpreter()` | (rides on seam 2) | +| 4 | Validate preflight | `_platform_preflight()` (no new code — reads seams 1–2) | — | The **one** bundled caveat: a backend you ship _in this repo_ needs its import added to the relevant `_load_builtin_*` loader so it self-registers (one line). An @@ -51,9 +51,24 @@ register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultipl - `matches(sys.platform) -> bool` — decides automatic selection. - `factory() -> TerminalMultiplexer` — builds the backend. -`get_multiplexer()` returns the first backend whose `matches(sys.platform)` is -true, unless `BMAD_LOOP_MUX_BACKEND` forces one by name; tmux is the default -fallback, so POSIX behavior is unchanged. (The result is cached — see +`get_multiplexer()` resolves by precedence: + +1. `BMAD_LOOP_MUX_BACKEND` — forces a backend by name (per-invocation). +2. `[mux] backend` in `.bmad-loop/policy.toml` — the persisted, machine-scoped + choice (`bmad-loop init` gitignores policy.toml, so it never reaches + teammates). Set it with `bmad-loop mux set `. +3. The **platform default** — win32: `psmux`, everywhere else: `tmux` — when + that backend is registered _and_ `available()`. +4. The first registered backend whose `matches(sys.platform)` is true _and_ + whose `available()` reports usable (registration order breaks ties). +5. The historical fallback: first platform match regardless of availability, + bottoming out at tmux — so a POSIX host without tmux still selects + `TmuxMultiplexer` and `validate` reports it unavailable. + +A forced name (1–2) bypasses both the platform predicate and `available()` — an +explicit choice is trusted — and fails loudly when it matches no registered +backend. `bmad-loop mux` lists every registered backend with its availability, +version, and the current selection. (The result is cached — see [Testing a port](#testing-a-port).) ### Two build paths @@ -78,6 +93,36 @@ fallback, so POSIX behavior is unchanged. (The result is cached — see binary is on PATH); the optional `version()` feeds the diagnostic dump and the validate preflight (seam 4). +### Availability discriminators (same-platform backends) + +Selection consults `available()`, so it must be a **cheap, side-effect-free +probe** (PATH lookups — never a spawn), and `factory()` must be a plain +constructor: `detect_multiplexers()` instantiates every registered backend just +to list it. When two backends claim the same platform, their `available()` +probes should be **pairwise discriminating** — otherwise both report usable and +only the platform default / registration order separates them in listings and +selection. The contract for the two native-Windows tmux-family backends in +flight (both drive a binary literally named `tmux`, and `tmux -V` cannot tell +them apart): + +```python +# psmux ships a distinctly-named psmux.exe alongside its tmux shim: +class PsmuxMultiplexer(BaseTmuxBackend): + def available(self) -> bool: + return all(shutil.which(b) for b in ("psmux", "tmux", "pwsh")) + +# tmux-windows has no marker binary of its own — it is "tmux without psmux": +class WindowsTmuxMultiplexer(BaseTmuxBackend): + def available(self) -> bool: + return shutil.which("tmux") is not None and shutil.which("psmux") is None +``` + +Do **not** inherit `BaseTmuxBackend.available()` (`which("tmux")` alone) for a +same-platform sibling: selection would still break the tie via the platform +default, but `bmad-loop mux` and the validate preflight would list both as +available when only one actually drives the installed binary. A host with an +ambiguous install resolves it explicitly: `bmad-loop mux set `. + **Deep contract →** [adapter authoring guide: the transport contract for a backend author](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). @@ -208,7 +253,11 @@ keys off `sys.platform`. To exercise a not-yet-default backend on your dev box: | a process host | `BMAD_LOOP_PROCESS_HOST=windows` | `get_process_host.cache_clear()` | The env var picks the registered backend by `name`; `cache_clear()` is required -because the first call memoizes the selection for the process. +because the first call memoizes the selection for the process. To make a +multiplexer choice stick across invocations instead, persist it with +`bmad-loop mux set ` (writes `[mux] backend` into the machine-local +policy.toml; the env var still outranks it, and `configure_multiplexer` / +`register_multiplexer` clear the cache themselves). --- diff --git a/docs/setup-guide.md b/docs/setup-guide.md index b9b23e5f..ba4bde81 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -58,7 +58,7 @@ claude "/bmad-loop-setup accept all defaults" `uv tool upgrade bmad-loop --reinstall`. 3. Asks **which coding CLI(s)** the orchestrator should drive, then runs `bmad-loop init` to install the `bmad-loop-*` skills + register hooks + write the `.bmad-loop/policy.toml` - template + add gitignore entries (see [Choosing which CLIs to drive](#choosing-which-clis-to-drive) + template + add gitignore entries (including policy.toml itself — policy is per-machine; repos initialized before this run `git rm --cached .bmad-loop/policy.toml` once if theirs is already committed) (see [Choosing which CLIs to drive](#choosing-which-clis-to-drive) and [Initializing CLIs other than claude](#initializing-clis-other-than-claude)). On an upgrade it passes `--force-skills` so the per-project skill copies are refreshed. 4. Runs `bmad-loop validate` as a preflight (see [Verify](#verify)). diff --git a/docs/tui-guide.md b/docs/tui-guide.md index f39345d9..d6ac85cf 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -503,6 +503,7 @@ behavior. | `plugins.unity.ready_timeout_sec` | int ≥ 1 | 600 | readiness-gate budget for the Editor + MCP to come up | | `plugins.unity.ready_grace_sec` | int ≥ -1 | -1 | pre-probe delay; `-1` = auto (120s cold `per_worktree`, 0s warm `shared`) | | `tui.low_frame_rate` | switch | off | cap to 15fps + disable animations (slow/SSH links); applies next launch | +| `mux.backend` | text | (auto-select) | force a registered terminal-multiplexer backend by name (see `bmad-loop mux`); machine-specific — policy.toml is gitignored; applies next invocation | (`scm.max_parallel` is intentionally **not** exposed in the editor — it stays inert, clamped to 1, until parallel fan-out is built.) diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index dc05c985..cfed86fc 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -11,8 +11,15 @@ names mirror today's call sites verbatim so the migration is mechanical. Backends register themselves through :func:`register_multiplexer` (bundled ones from :func:`_load_builtin_backends`, out-of-tree ones at import time); the process-wide -backend is selected by registry — by platform, or forced by name through the -``BMAD_LOOP_MUX_BACKEND`` env var — and returned by :func:`get_multiplexer`. +backend is selected by registry and returned by :func:`get_multiplexer`. + +Selection precedence (issue #87): the ``BMAD_LOOP_MUX_BACKEND`` env var, then the +policy ``[mux] backend`` choice (installed once per CLI invocation via +:func:`configure_multiplexer`), then the platform default when registered and +available, then the first registered backend that matches the platform and is +available, then the historical fallback (first platform match regardless of +availability, bottoming out at tmux). :func:`detect_multiplexers` enumerates the +registry for ``bmad-loop mux`` and the ``validate`` preflight. """ from __future__ import annotations @@ -22,6 +29,7 @@ import sys from abc import ABC, abstractmethod from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path @@ -204,6 +212,16 @@ def version(self) -> str | None: # (name, matches(platform) -> bool, factory() -> TerminalMultiplexer) _BACKENDS: list[tuple[str, Callable[[str], bool], Callable[[], TerminalMultiplexer]]] = [] _BUILTINS_LOADED = False +# The policy [mux] backend choice — (name, origin policy path) — installed once +# per CLI invocation by cli._configure_mux via configure_multiplexer. None = auto. +_CONFIGURED: tuple[str, Path | None] | None = None + +# Per-platform default backend name, consulted only when that backend is both +# registered AND available on this host. Naming a backend that never registers +# is deliberate and harmless: psmux is an out-of-tree backend today, so on a +# win32 host without it the default simply doesn't apply. +_PLATFORM_DEFAULTS: dict[str, str] = {"win32": "psmux"} +_DEFAULT_BACKEND = "tmux" # every platform not listed above def register_multiplexer( @@ -236,26 +254,182 @@ def _load_builtin_backends() -> None: _BUILTINS_LOADED = True # set only after a successful import so a transient failure retries +def configure_multiplexer(name: str | None, *, origin: Path | None = None) -> None: + """Install the policy ``[mux] backend`` choice (``None``/``""`` = auto). + + Called once per CLI invocation (``cli.main``, after parsing ``--project``) + before any :func:`get_multiplexer` consumer runs, so probe/diagnose/attach — + which never load policy themselves — select under the persisted choice too. + Idempotent: the selection cache is cleared only when the effective value + changes, so the process-wide singleton identity survives repeated + same-value configuration.""" + global _CONFIGURED + new = (name, origin) if name else None + if new == _CONFIGURED: + return + _CONFIGURED = new + get_multiplexer.cache_clear() + + +def _known() -> str: + return ", ".join(name for name, _, _ in _BACKENDS) or "(none registered)" + + +def _factory_by_name(name: str) -> Callable[[], TerminalMultiplexer] | None: + for reg_name, _, factory in _BACKENDS: + if reg_name == name: # duplicate registrations: first wins, as in the loop below + return factory + return None + + +def _usable(backend: TerminalMultiplexer) -> bool: + """``available()`` read through a guard: selection must never crash on a + backend's host probe, so a missing or raising probe reads as unavailable.""" + try: + return bool(backend.available()) + except Exception: + return False + + +def _select() -> tuple[TerminalMultiplexer, str, str]: + """Resolve the backend by precedence; returns ``(instance, name, reason)``. + + 1. ``env`` — ``BMAD_LOOP_MUX_BACKEND`` forces a backend by name + 2. ``policy`` — the ``[mux] backend`` choice installed by + :func:`configure_multiplexer`, same forced-by-name semantics + 3. ``platform-default`` — this platform's default, iff registered + + platform match + available + 4. ``first-match`` — first registered backend matching the platform that is + available (registration order breaks ties among available backends) + 5. ``fallback`` — the historical behavior, preserved so a POSIX host without + tmux still returns TmuxMultiplexer and ``validate`` reports it + unavailable: first platform match regardless of availability, then tmux + + A forced name (1-2) bypasses both the platform predicate and ``available()`` + — an explicit choice is trusted, and the backend itself fails loudly if it + can't run. A forced name matching nothing is a misconfiguration; never + silently fall back to tmux (wrong/unsafe on a non-POSIX host).""" + _load_builtin_backends() + forced = os.environ.get("BMAD_LOOP_MUX_BACKEND") + if forced: + factory = _factory_by_name(forced) + if factory is None: + raise MultiplexerError( + f"BMAD_LOOP_MUX_BACKEND={forced!r} matches no registered backend; " + f"known: {_known()}" + ) + return factory(), forced, "env" + if _CONFIGURED is not None: + name, origin = _CONFIGURED + factory = _factory_by_name(name) + if factory is None: + where = f"[mux] backend = {name!r}" + (f" in {origin}" if origin else "") + raise MultiplexerError(f"{where} matches no registered backend; known: {_known()}") + return factory(), name, "policy" + + # Construct each candidate at most once across the remaining steps. + instances: dict[str, TerminalMultiplexer] = {} + + def _instance(name: str, factory: Callable[[], TerminalMultiplexer]) -> TerminalMultiplexer: + if name not in instances: + instances[name] = factory() + return instances[name] + + default = _PLATFORM_DEFAULTS.get(sys.platform, _DEFAULT_BACKEND) + for name, matches, factory in _BACKENDS: + if name != default: + continue + # first registration with the default name wins, as everywhere else; + # it must also claim this platform — a name-colliding backend for + # another platform doesn't get defaulted onto this one. + if matches(sys.platform): + backend = _instance(name, factory) + if _usable(backend): + return backend, name, "platform-default" + break + for name, matches, factory in _BACKENDS: + if matches(sys.platform) and _usable(_instance(name, factory)): + return instances[name], name, "first-match" + for name, matches, factory in _BACKENDS: + if matches(sys.platform): + return _instance(name, factory), name, "fallback" + from .tmux_backend import TmuxMultiplexer # bottom fallback, as before + + return TmuxMultiplexer(), "tmux", "fallback" + + @functools.lru_cache(maxsize=1) def get_multiplexer() -> TerminalMultiplexer: """Return the process-wide terminal multiplexer, selected by registry. - ``BMAD_LOOP_MUX_BACKEND`` forces a backend by name (test / override hook); - otherwise the first backend whose ``matches(sys.platform)`` is true wins. tmux - is the default fallback, so POSIX behavior is unchanged. Cached — tests that - flip the env var must call ``get_multiplexer.cache_clear()``.""" - forced = os.environ.get("BMAD_LOOP_MUX_BACKEND") + Selection precedence lives in :func:`_select` (env var, policy choice, + platform default, first available match, historical fallback). Cached — + tests that flip the env var must call ``get_multiplexer.cache_clear()``; + :func:`register_multiplexer` and :func:`configure_multiplexer` clear it + themselves.""" + return _select()[0] + + +@dataclass(frozen=True) +class MuxBackendInfo: + """One registered backend's detection row, for ``bmad-loop mux`` and the + ``validate`` preflight.""" + + name: str + matches_platform: bool + available: bool + version: str | None + selected: bool + reason: str # "" unless selected: env | policy | platform-default | first-match | fallback + + +def detect_multiplexers() -> list[MuxBackendInfo]: + """Probe every registered backend: availability, version, platform match, + and which one :func:`_select` would pick (with its reason). + + Never raises — this feeds diagnostics, which must work on a misconfigured + host: a forced unknown name yields rows with no selected mark, and a + backend whose factory or probes blow up reads as unavailable. Constructs + every registered backend, so factories must stay cheap, side-effect-free + constructors (true of the tmux family).""" _load_builtin_backends() + try: + _, selected_name, reason = _select() + except MultiplexerError: + selected_name, reason = None, "" + rows: list[MuxBackendInfo] = [] + seen: set[str] = set() for name, matches, factory in _BACKENDS: - if name == forced or (not forced and matches(sys.platform)): - return factory() - if forced: - # An explicit override that matches nothing is a misconfiguration; never - # silently fall back to tmux (wrong/unsafe on a non-POSIX host). - known = ", ".join(name for name, _, _ in _BACKENDS) or "(none registered)" - raise MultiplexerError( - f"BMAD_LOOP_MUX_BACKEND={forced!r} matches no registered backend; known: {known}" + if name in seen: # duplicate registrations: only the selectable (first) one is shown + continue + seen.add(name) + try: + matches_platform = bool(matches(sys.platform)) + except Exception: + matches_platform = False + version: str | None = None + try: + backend = factory() + available = _usable(backend) + except Exception: + available = False + else: + # version() is cosmetic: its failure must not overwrite the + # already-computed availability (a selected backend would + # otherwise show a contradictory available=False row). + try: + version = backend.version() + except Exception: + version = None + selected = name == selected_name + rows.append( + MuxBackendInfo( + name=name, + matches_platform=matches_platform, + available=available, + version=version, + selected=selected, + reason=reason if selected else "", + ) ) - from .tmux_backend import TmuxMultiplexer # default fallback - - return TmuxMultiplexer() + return rows diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 6cb36ef4..15a800b9 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -49,6 +49,24 @@ def _policy_path(project: Path) -> Path: return project / POLICY_FILE +def _configure_mux(project: Path) -> None: + """Install the policy ``[mux] backend`` choice into the multiplexer seam. + + The single configuration point, called from ``main()`` before dispatch so + every mux consumer — including probe/diagnose/attach/stop, which never load + policy themselves — selects under the persisted choice. Tolerant of a broken + policy file: diagnostics must keep working on a misconfigured host, and the + commands that need policy re-load it loudly themselves.""" + from .adapters.multiplexer import configure_multiplexer + + path = _policy_path(project) + try: + name = policy_mod.load(path).mux.backend or None + except (policy_mod.PolicyError, OSError): + name = None + configure_multiplexer(name, origin=path) + + def _reject_bad_run_id(run_id: str | None) -> int | None: """Guard the hidden ``--run-id`` flag before the id becomes a directory name, a multiplexer session name and a git ref component. Rejects rather than sanitizes @@ -138,7 +156,7 @@ def _platform_preflight() -> tuple[list[str], list[str]]: ``sys.platform`` branch to validate. The process host is named so a misselection (e.g. the Windows host picked on Linux) is visible at a glance. """ - from .adapters.multiplexer import get_multiplexer + from .adapters.multiplexer import detect_multiplexers, get_multiplexer from .process_host import get_process_host notes: list[str] = [] @@ -158,6 +176,26 @@ def _platform_preflight() -> tuple[list[str], list[str]]: except Exception as e: # noqa: BLE001 — selection or readiness must not abort validate problems.append(f"multiplexer preflight failed: {e}") + try: + infos = detect_multiplexers() + except Exception: # noqa: BLE001 — detection is advisory; never break validate + infos = [] + if len(infos) > 1: # a lone tmux needs no listing; keep single-backend output stable + listed = ", ".join( + i.name + + ("*" if i.selected else "") + + ( + " (available" + (f", {i.version}" if i.version else "") + ")" + if i.available + else " (unavailable)" + ) + for i in infos + ) + notes.append(f"mux backends: {listed} — `bmad-loop mux` for details") + chosen = next((i for i in infos if i.selected), None) + if chosen and chosen.reason in ("env", "policy"): + notes.append(f"multiplexer selection {_mux_reason_label(chosen.reason)}") + try: notes.append(f"process host: {type(get_process_host()).__name__}") except Exception as e: # noqa: BLE001 — a bad BMAD_LOOP_PROCESS_HOST must report, not crash @@ -270,6 +308,110 @@ def cmd_validate(args: argparse.Namespace) -> int: return 1 if problems else 0 +def _mux_reason_label(reason: str) -> str: + """Human wording for a MuxBackendInfo.reason, shared by `mux` and validate.""" + return { + "env": "forced by BMAD_LOOP_MUX_BACKEND", + "policy": f"set by [mux] backend in {POLICY_FILE}", + "platform-default": f"platform default for {sys.platform}", + "first-match": "first available platform match", + "fallback": "fallback (no registered backend is available)", + }.get(reason, reason) + + +def cmd_mux(args: argparse.Namespace) -> int: + """List registered terminal-multiplexer backends and the selection, or + persist a machine-scoped choice (`mux set ` / `mux set --clear`) into + .bmad-loop/policy.toml. Never prompts — runs are unattended.""" + from .adapters.multiplexer import MultiplexerError, detect_multiplexers, get_multiplexer + + project = _project(args) + if args.action == "set": + return _mux_set(project, args) + if args.clear or args.force: + print("error: --clear/--force apply to `bmad-loop mux set`", file=sys.stderr) + return 1 + + rows = detect_multiplexers() + header = ("NAME", "PLATFORM", "AVAILABLE", "VERSION", "SELECTED") + table = [ + ( + r.name, + "yes" if r.matches_platform else "no", + "yes" if r.available else "no", + r.version or "-", + f"* {_mux_reason_label(r.reason)}" if r.selected else "", + ) + for r in rows + ] + widths = [max(len(h), *(len(row[i]) for row in table), 0) for i, h in enumerate(header)] + for row in (header, *table): + print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)).rstrip()) + print( + "override: BMAD_LOOP_MUX_BACKEND env var, or `bmad-loop mux set ` " + f"(persists to {POLICY_FILE})" + ) + try: + backend = get_multiplexer() + except MultiplexerError as e: + # A forced unknown name (env or policy): the listing above still helps. + print(f"FAIL: {e}", file=sys.stderr) + return 1 + chosen = next((r for r in rows if r.selected), None) + reason = _mux_reason_label(chosen.reason) if chosen else "fallback" + name = chosen.name if chosen else "tmux" + print(f"selection: {name} ({type(backend).__name__}) — {reason}") + return 0 + + +def _mux_set(project: Path, args: argparse.Namespace) -> int: + from .adapters.multiplexer import detect_multiplexers + + path = _policy_path(project) + if args.clear: + if args.name: + print("error: `mux set --clear` takes no backend name", file=sys.stderr) + return 1 + policy_mod.write_mux_backend(path, None) + print(f"mux backend cleared (auto-select) in {path}") + return 0 + if not args.name: + print( + "error: `mux set` requires a backend name (run `bmad-loop mux` to list), " + "or `mux set --clear` to return to auto-select", + file=sys.stderr, + ) + return 1 + rows = {r.name: r for r in detect_multiplexers()} + row = rows.get(args.name) + if row is None and not args.force: + known = ", ".join(sorted(rows)) or "(none registered)" + print( + f"error: {args.name!r} is not a registered backend; known: {known}. " + "A plugin backend that only registers on the target machine can be " + "persisted with --force.", + file=sys.stderr, + ) + return 1 + if row is not None and not row.available: + # Deliberate choice = trusted (same doctrine as the env override), but + # say so: the run will fail loudly if the binary never appears. + print( + f"warning: backend {args.name!r} is not available on this host (its " + "transport binary is not on PATH); persisted anyway — `bmad-loop validate` " + "will report it", + file=sys.stderr, + ) + if os.environ.get("BMAD_LOOP_MUX_BACKEND"): + print( + "note: BMAD_LOOP_MUX_BACKEND is set in this shell and outranks the " "persisted choice", + file=sys.stderr, + ) + policy_mod.write_mux_backend(path, args.name) # a junk name raises PolicyError → main() + print(f'mux backend set to "{args.name}" in {path}') + return 0 + + def _require_base_skills(project: Path, pol, *, require_stories: bool = False) -> bool: """Preflight the upstream skills the orchestrator drives (bmad-dev-auto + the three review hunters it invokes inline). @@ -1608,6 +1750,30 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: "(overrides [stories].source; skips the sprint-status gate)", ) + mux_p = add( + "mux", + cmd_mux, + "list terminal-multiplexer backends + selection; `mux set ` persists a choice", + ) + mux_p.add_argument( + "action", + nargs="?", + choices=("set",), + help="set: persist a backend choice into .bmad-loop/policy.toml", + ) + mux_p.add_argument("name", nargs="?", help="backend name to persist (see the listing)") + mux_p.add_argument( + "--clear", + action="store_true", + help="with set: remove the persisted choice (back to auto-select)", + ) + mux_p.add_argument( + "--force", + action="store_true", + help="with set: persist a name not registered in this process (e.g. a plugin " + "backend that only registers on the target machine)", + ) + probe_p = add( "probe-adapter", cmd_probe, @@ -1822,6 +1988,10 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: args = parser.parse_args(argv) try: + # Install the policy [mux] backend choice before dispatch: several + # handlers (probe/diagnose/attach/stop/cleanup/tui) reach the mux + # without ever loading policy, so this is the one reliable seam. + _configure_mux(_project(args)) return args.func(args) except ( bmadconfig.BmadConfigError, diff --git a/src/bmad_loop/data/settings/core.toml b/src/bmad_loop/data/settings/core.toml index f776259d..2702db1c 100644 --- a/src/bmad_loop/data/settings/core.toml +++ b/src/bmad_loop/data/settings/core.toml @@ -361,3 +361,14 @@ kind = "switch" default_ref = "TuiPolicy.low_frame_rate" label = "low frame rate" description = "cap to 15fps + disable animations — fixes repaint tearing/garbage over slow or SSH links · takes effect next time the TUI launches" + +[[section]] +name = "mux" +description = "terminal-multiplexer backend (machine-specific — policy.toml is gitignored)" +[[section.field]] +key = "backend" +kind = "str" +default_ref = "MuxPolicy.backend" +placeholder = "auto: env var > this key > platform default > first available match" +label = "backend" +description = "force a registered transport backend by name (see `bmad-loop mux`) — BMAD_LOOP_MUX_BACKEND outranks it · takes effect on the next bmad-loop invocation" diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 5c64ea4c..a0a20e06 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -542,6 +542,32 @@ def provision_worktree( _worktree_local_exclude(worktree, sorted(patterns)) +def _warn_if_policy_tracked(project: Path) -> None: + """One-time migration hint: a .gitignore entry does not untrack an + already-committed policy.toml, so repos initialized before the file was + gitignored keep sharing it (and this machine's [mux] backend choice) until + the dev runs `git rm --cached` once. Best-effort — not a repo, or no git, + means nothing to warn about.""" + try: + tracked = ( + subprocess.run( # noqa: S603, S607 — fixed argv, no shell + ["git", "ls-files", "--error-unmatch", ".bmad-loop/policy.toml"], + cwd=project, + capture_output=True, + timeout=10, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return + if tracked: + print( + " note: .bmad-loop/policy.toml is tracked by git; run " + "`git rm --cached .bmad-loop/policy.toml` once to stop sharing it " + "(your local copy is kept)" + ) + + def install_into( project: Path, clis: Sequence[str] = ("claude",), @@ -599,13 +625,19 @@ def install_into( policy_path.write_text(POLICY_TEMPLATE, encoding="utf-8") print(f" policy written: {policy_path}") - # 5. gitignore generated dirs: per-run state (.bmad-loop/runs/) and the - # game-engine plugins' rebuildable caches, e.g. the per-worktree Unity Library - # (.bmad-loop/cache/). Both are large/ephemeral and must never be committed. + # 5. gitignore generated/machine-local state: per-run state (.bmad-loop/runs/), + # the game-engine plugins' rebuildable caches, e.g. the per-worktree Unity + # Library (.bmad-loop/cache/), and the policy file itself — policy.toml is + # per-machine-per-repo (it carries this machine's [mux] backend choice, and + # the TUI settings editor rewrites it), so it must never travel to teammates. gitignore = project / ".gitignore" existing = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else "" have = set(existing.splitlines()) - to_add = [line for line in (".bmad-loop/runs/", ".bmad-loop/cache/") if line not in have] + to_add = [ + line + for line in (".bmad-loop/runs/", ".bmad-loop/cache/", ".bmad-loop/policy.toml") + if line not in have + ] if to_add: with gitignore.open("a", encoding="utf-8") as f: if existing and not existing.endswith("\n"): @@ -613,6 +645,7 @@ def install_into( f.write("\n".join(to_add) + "\n") for line in to_add: print(f" gitignored: {line}") + _warn_if_policy_tracked(project) if skills_skipped: print(" some skills already present; re-run with --force-skills to overwrite") diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index e4a397e9..b2ea4f38 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -2,12 +2,15 @@ from __future__ import annotations +import re import tomllib import warnings from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any +from .platform_util import atomic_replace + POLICY_FILE = Path(".bmad-loop") / "policy.toml" GATE_MODES = {"none", "per-epic", "per-story-spec-approval"} @@ -24,6 +27,15 @@ MERGE_STRATEGIES = {"ff", "merge", "squash"} DEV_SKILLS = {"bmad-dev-auto"} +# Backend names are registry keys (adapters/multiplexer.py), never paths or +# shell input; the alphabet mirrors what built-in and plugin backends use. +_MUX_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +# write_mux_backend's line targets: a [section] header, and the (possibly +# commented) `backend =` anchor line inside [mux]. Template prose comments must +# never start with `backend =` or the anchor match would hit them first. +_TOML_SECTION_RE = re.compile(r"^\s*\[(?P[^\]]+)\]\s*(?:#.*)?$") +_MUX_KEY_RE = re.compile(r"^\s*#?\s*backend\s*=") + # Deprecated [engine] keys, folded into [plugins.unity] at load time. The # game-engine layer is now a plugin; [engine] is a one-release compatibility # alias (see _fold_deprecated_engine). @@ -149,6 +161,17 @@ class TuiPolicy: low_frame_rate: bool = False +@dataclass(frozen=True) +class MuxPolicy: + # Terminal-multiplexer backend for THIS machine (the transport axis — which + # tmux-like program hosts sessions; independent of [adapter], the coding-CLI + # axis). "" = auto-select. Whether the name is actually registered is checked + # at selection time (adapters/multiplexer.py), not here — policy stays + # data-only, and a plugin backend may not be importable in every context that + # parses policy. Machine-specific: `bmad-loop init` gitignores policy.toml. + backend: str = "" + + @dataclass(frozen=True) class SweepPolicy: auto: str = "never" # never | per-epic | run-end @@ -352,6 +375,7 @@ class Policy: cleanup: CleanupPolicy = field(default_factory=CleanupPolicy) plugins: PluginsPolicy = field(default_factory=PluginsPolicy) tui: TuiPolicy = field(default_factory=TuiPolicy) + mux: MuxPolicy = field(default_factory=MuxPolicy) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -469,6 +493,7 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: engine_d = _section(doc, "engine") # deprecated; folded into [plugins] below plugins_d = _section(doc, "plugins") tui_d = _section(doc, "tui") + mux_d = _section(doc, "mux") gates = GatesPolicy( mode=str(gates_d.get("mode", GatesPolicy.mode)), @@ -675,6 +700,11 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: _validate_plugin_settings(name, raw_settings, plugin_schemas.get(name)) plugins = PluginsPolicy(enabled=tuple(enabled), settings=plugin_settings) tui = TuiPolicy(low_frame_rate=bool(tui_d.get("low_frame_rate", TuiPolicy.low_frame_rate))) + mux = MuxPolicy(backend=str(mux_d.get("backend", MuxPolicy.backend)).strip()) + if mux.backend and not _MUX_NAME_RE.match(mux.backend): + raise PolicyError( + f"mux.backend must be a backend name (letters, digits, . _ -): got {mux.backend!r}" + ) return Policy( gates=gates, limits=limits, @@ -689,6 +719,7 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy: cleanup=cleanup, plugins=plugins, tui=tui, + mux=mux, ) @@ -870,4 +901,85 @@ def _fold_deprecated_engine( # over slow/high-latency links (SSH, Tailscale). Equivalent to launching with # `bmad-loop tui --low-frame-rate`. Takes effect the next time the TUI starts. low_frame_rate = false + +[mux] +# Terminal-multiplexer backend for this machine (the transport axis — which +# tmux-like program hosts sessions; independent of [adapter], the coding-CLI +# axis). Machine-specific: `bmad-loop init` gitignores policy.toml, so this +# choice never travels to teammates on other machines or OSes. +# Unset = auto-select: the BMAD_LOOP_MUX_BACKEND env var wins, then this key, +# then the platform default (win32: psmux, elsewhere: tmux) when installed, +# then the first registered backend that matches this platform and is +# available. Naming a backend that is not registered fails loudly at launch. +# `bmad-loop mux` lists backends and shows the selection; `bmad-loop mux set +# ` writes this key. Takes effect on the next bmad-loop invocation. +# backend = "tmux" """ + + +def write_mux_backend(path: Path, name: str | None) -> None: + """Persist (``name``) or clear (``None``) the ``[mux] backend`` key in the + policy file at ``path``, preserving every other byte — devs hand-edit + policy.toml, and the core install has no comment-preserving TOML writer + (tomlkit ships only with the [tui] extra). A missing file is created from + :data:`POLICY_TEMPLATE` so the written file keeps the full documentation. + + The template anchors the key as a single ``# backend = "tmux"`` line under + ``[mux]`` with all prose comments *above* it, so the rewrite is a targeted + line replace: the first (possibly commented) ``backend =`` line inside + ``[mux]`` is swapped for the new value, or re-commented on clear. A file + predating the ``[mux]`` table gets the table appended at EOF (TOML tables + are order-free, so appending is always safe).""" + if name is not None and not _MUX_NAME_RE.match(name): + raise PolicyError( + f"mux.backend must be a backend name (letters, digits, . _ -): got {name!r}" + ) + # bytes in / bytes out: text mode would translate a CRLF file's endings. + text = path.read_bytes().decode("utf-8") if path.is_file() else POLICY_TEMPLATE + new_line = f'backend = "{name}"' if name is not None else '# backend = "tmux"' + + section = "" + replaced = False + mux_header_at: int | None = None + out: list[str] = [] + for line in text.splitlines(keepends=True): + header = _TOML_SECTION_RE.match(line) + if header: + section = header.group("name").strip() + out.append(line) + if section == "mux" and mux_header_at is None: + mux_header_at = len(out) - 1 + continue + if not replaced and section == "mux" and _MUX_KEY_RE.match(line): + stripped = line.rstrip("\r\n") + ending = line[len(stripped) :] or "\n" + # backend names never contain '#' (_MUX_NAME_RE), so any '#' after + # '=' on this line is a hand-added trailing comment worth keeping. + hash_idx = stripped.find("#", stripped.index("=")) + trailing = (" " + stripped[hash_idx:]) if hash_idx != -1 else "" + out.append(new_line + trailing + ending) + replaced = True + continue + out.append(line) + if not replaced: + if mux_header_at is not None: # [mux] table present but the key line was deleted + out.insert(mux_header_at + 1, new_line + "\n") + else: # policy file predating the [mux] table + if out and not out[-1].endswith("\n"): + out.append("\n") + out.append(f"\n[mux]\n{new_line}\n") + result = "".join(out) + + # Round-trip guard: never write a file this module can't read back to the + # intended value (catches an anchor/regex drift before it corrupts config). + parsed = loads(result) + if parsed.mux.backend != (name or ""): + raise PolicyError( + f"internal error: rewriting {path} would read back " + f"mux.backend = {parsed.mux.backend!r}, expected {(name or '')!r}" + ) + + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".toml.tmp") + tmp.write_bytes(result.encode("utf-8")) + atomic_replace(tmp, path) diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index 25e4654e..ed6584a6 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -3,12 +3,16 @@ The multiplexer seam selects its transport backend through a registry (:func:`~bmad_loop.adapters.multiplexer.register_multiplexer`) rather than a hardcoded constructor, so a new OS/backend is a registration — not a core edit. -These tests pin selection: by platform match, by the ``BMAD_LOOP_MUX_BACKEND`` -override, the safe tmux fallback, and the lru_cache gotcha. Backends register a -sentinel ``object()`` factory so a test need not implement the whole ABC. +These tests pin the selection precedence (env var > policy [mux] backend > +platform default > first available platform match > the historical fallback), +the safe tmux fallback, detect_multiplexers, and the lru_cache gotcha. Backends +register a sentinel ``object()`` factory where availability doesn't matter (a +missing ``available()`` reads as unavailable); availability-sensitive tests use +the tiny :class:`_Stub` instead. """ import sys +from pathlib import Path import pytest @@ -17,20 +21,49 @@ from bmad_loop.adapters.tmux_backend import TmuxMultiplexer +class _Stub: + """Minimal backend double for selection tests: fixed availability/version. + Selection only touches available()/version(), so the full ABC is overkill.""" + + def __init__(self, avail=True, version=None): + self._avail = avail + self._version = version + + def available(self): + if isinstance(self._avail, Exception): + raise self._avail + return self._avail + + def version(self): + if isinstance(self._version, Exception): + raise self._version + return self._version + + +def _platform_default_name(): + """This host's platform-default backend name (win32 differs), so the tests + stay deterministic on both CI legs.""" + return m._PLATFORM_DEFAULTS.get(sys.platform, m._DEFAULT_BACKEND) + + @pytest.fixture def fresh_registry(monkeypatch): - """Isolate the global registry + lru_cache: snapshot, clear, restore. The env - override is removed so a test opts in explicitly. Teardown restores the real - tmux registry so unrelated tests see normal selection.""" + """Isolate the global registry + lru_cache + configured choice: snapshot, + clear, restore. The env override is removed so a test opts in explicitly. + Teardown restores the real tmux registry so unrelated tests see normal + selection.""" monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) saved_backends = list(m._BACKENDS) saved_loaded = m._BUILTINS_LOADED + saved_configured = m._CONFIGURED m._BACKENDS.clear() m._BUILTINS_LOADED = False + m._CONFIGURED = None m.get_multiplexer.cache_clear() yield m m._BACKENDS[:] = saved_backends m._BUILTINS_LOADED = saved_loaded + m._CONFIGURED = saved_configured m.get_multiplexer.cache_clear() @@ -67,13 +100,17 @@ def test_unknown_forced_name_raises(fresh_registry, monkeypatch): def test_match_based_selection_wins_by_order(fresh_registry): - """A backend registered before the builtins whose ``matches`` accepts the - current platform is selected by auto-match (no override), proving platform - selection and registration-order precedence over tmux.""" - sentinel = object() - fresh_registry.register_multiplexer("fake", lambda p: p == sys.platform, lambda: sentinel) - fresh_registry.get_multiplexer.cache_clear() - assert fresh_registry.get_multiplexer() is sentinel + """Registration order breaks ties among *available* platform-matching + backends that aren't the platform default: the first registered wins. + Builtins are suppressed so no real binary probe can skew the outcome.""" + fresh_registry._BUILTINS_LOADED = True + first = _Stub(avail=True) + second = _Stub(avail=True) + fresh_registry.register_multiplexer("first", lambda p: p == sys.platform, lambda: first) + fresh_registry.register_multiplexer("second", lambda p: p == sys.platform, lambda: second) + backend, name, reason = fresh_registry._select() + assert backend is first + assert (name, reason) == ("first", "first-match") def test_get_multiplexer_is_cached(fresh_registry): @@ -91,3 +128,201 @@ def test_register_invalidates_cached_selection(fresh_registry): fresh_registry.register_multiplexer("fake", lambda p: False, lambda: object()) # no manual cache_clear() here — registration is responsible for invalidating it assert fresh_registry.get_multiplexer.cache_info().currsize == 0 + + +# --------------------------------------------------------------------------- +# Selection precedence (issue #87): env > policy > platform default > +# first available match > historical fallback + + +def test_policy_choice_selects_by_name_bypassing_match_and_availability(fresh_registry): + """configure_multiplexer installs the [mux] backend choice: exact-name + selection that, like the env override, ignores the platform predicate and + available() — an explicit choice is trusted.""" + sentinel = object() # no available() at all: forced selection must not probe it + fresh_registry.register_multiplexer("fake", lambda p: False, lambda: sentinel) + fresh_registry.configure_multiplexer("fake") + assert fresh_registry.get_multiplexer() is sentinel + + +def test_env_override_beats_policy_choice(fresh_registry, monkeypatch): + """A per-invocation env override outranks the persisted policy choice.""" + by_policy, by_env = object(), object() + fresh_registry.register_multiplexer("pol", lambda p: False, lambda: by_policy) + fresh_registry.register_multiplexer("env", lambda p: False, lambda: by_env) + fresh_registry.configure_multiplexer("pol") + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "env") + fresh_registry.get_multiplexer.cache_clear() + assert fresh_registry.get_multiplexer() is by_env + + +def test_unknown_policy_name_raises_naming_the_policy_file(fresh_registry): + """A persisted choice that matches no registered backend is a + misconfiguration and must fail loudly, pointing at the file to edit.""" + fresh_registry.configure_multiplexer("ghost", origin=Path("/repo/.bmad-loop/policy.toml")) + with pytest.raises(MultiplexerError, match=r"ghost.*policy\.toml"): + fresh_registry.get_multiplexer() + + +def test_platform_default_outranks_registration_order(fresh_registry): + """When the platform's default backend is registered and available it wins, + even against an available backend registered earlier.""" + fresh_registry._BUILTINS_LOADED = True + early = _Stub(avail=True) + default = _Stub(avail=True) + fresh_registry.register_multiplexer("early", lambda p: p == sys.platform, lambda: early) + fresh_registry.register_multiplexer( + _platform_default_name(), lambda p: p == sys.platform, lambda: default + ) + backend, name, reason = fresh_registry._select() + assert backend is default + assert (name, reason) == (_platform_default_name(), "platform-default") + + +def test_unavailable_platform_default_falls_through_to_first_available(fresh_registry): + """A registered-but-unavailable default doesn't block selection: the first + available platform match is chosen instead.""" + fresh_registry._BUILTINS_LOADED = True + other = _Stub(avail=True) + fresh_registry.register_multiplexer( + _platform_default_name(), lambda p: p == sys.platform, lambda: _Stub(avail=False) + ) + fresh_registry.register_multiplexer("other", lambda p: p == sys.platform, lambda: other) + backend, name, reason = fresh_registry._select() + assert backend is other + assert (name, reason) == ("other", "first-match") + + +def test_platform_default_requires_platform_match(fresh_registry): + """A backend name-colliding with this platform's default but claiming a + *different* platform must not be defaulted onto this one: the default step + enforces matches() like every other step, and selection falls through to + the first genuine platform match.""" + fresh_registry._BUILTINS_LOADED = True + other = _Stub(avail=True) + fresh_registry.register_multiplexer( + _platform_default_name(), lambda p: False, lambda: _Stub(avail=True) + ) + fresh_registry.register_multiplexer("other", lambda p: p == sys.platform, lambda: other) + backend, name, reason = fresh_registry._select() + assert backend is other + assert (name, reason) == ("other", "first-match") + + +def test_all_unavailable_pins_historical_first_match_fallback(fresh_registry): + """Nothing available → today's behavior is preserved: the first platform + match is returned anyway (validate reports it unavailable later).""" + fresh_registry._BUILTINS_LOADED = True + first = _Stub(avail=False) + fresh_registry.register_multiplexer("first", lambda p: p == sys.platform, lambda: first) + fresh_registry.register_multiplexer( + "second", lambda p: p == sys.platform, lambda: _Stub(avail=False) + ) + backend, name, reason = fresh_registry._select() + assert backend is first + assert (name, reason) == ("first", "fallback") + + +def test_empty_registry_bottoms_out_at_tmux(fresh_registry): + """No registered backend at all → the historical TmuxMultiplexer fallback.""" + fresh_registry._BUILTINS_LOADED = True # suppress builtins; registry stays empty + backend, name, reason = fresh_registry._select() + assert isinstance(backend, TmuxMultiplexer) + assert (name, reason) == ("tmux", "fallback") + + +def test_raising_available_probe_reads_as_unavailable(fresh_registry): + """A backend whose available() blows up must not crash selection — it is + skipped exactly like an unavailable one.""" + fresh_registry._BUILTINS_LOADED = True + ok = _Stub(avail=True) + fresh_registry.register_multiplexer( + "broken", lambda p: p == sys.platform, lambda: _Stub(avail=RuntimeError("boom")) + ) + fresh_registry.register_multiplexer("ok", lambda p: p == sys.platform, lambda: ok) + backend, name, _ = fresh_registry._select() + assert backend is ok and name == "ok" + + +def test_configure_multiplexer_clears_cache_only_on_change(fresh_registry): + """Re-configuring the same value must keep the cached singleton identity; + an actual change must invalidate it (mirrors register_multiplexer).""" + sentinel = object() + fresh_registry.register_multiplexer("fake", lambda p: False, lambda: sentinel) + before = fresh_registry.get_multiplexer() # auto-selected, cache populated + fresh_registry.configure_multiplexer(None) # same effective value (auto) + assert fresh_registry.get_multiplexer() is before # cache survived + fresh_registry.configure_multiplexer("fake") # real change + assert fresh_registry.get_multiplexer.cache_info().currsize == 0 + assert fresh_registry.get_multiplexer() is sentinel + fresh_registry.configure_multiplexer("fake") # same value again + assert fresh_registry.get_multiplexer.cache_info().currsize == 1 + + +def test_empty_string_configuration_means_auto(fresh_registry): + """configure_multiplexer("") — an unset policy key — must behave exactly + like None, not force an empty backend name.""" + fresh_registry.configure_multiplexer("") + assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) + + +# --------------------------------------------------------------------------- +# detect_multiplexers — the registry enumeration behind `bmad-loop mux` and +# the validate preflight + + +def test_detect_multiplexers_rows_and_selection_mark(fresh_registry): + fresh_registry._BUILTINS_LOADED = True + fresh_registry.register_multiplexer( + "off-platform", lambda p: False, lambda: _Stub(avail=True, version="v9") + ) + fresh_registry.register_multiplexer( + "chosen", lambda p: p == sys.platform, lambda: _Stub(avail=True, version="chosen 1.0") + ) + rows = {r.name: r for r in fresh_registry.detect_multiplexers()} + assert set(rows) == {"off-platform", "chosen"} + assert rows["off-platform"].matches_platform is False + assert rows["off-platform"].available is True + assert rows["off-platform"].selected is False and rows["off-platform"].reason == "" + assert rows["chosen"].selected is True + assert rows["chosen"].reason == "first-match" + assert rows["chosen"].version == "chosen 1.0" + + +def test_detect_multiplexers_survives_forced_unknown_name(fresh_registry, monkeypatch): + """Diagnostics must work on a misconfigured host: a forced unknown backend + yields rows with no selected mark instead of raising.""" + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "ghost") + rows = fresh_registry.detect_multiplexers() + assert rows # the tmux builtin row is still listed + assert not any(r.selected for r in rows) + + +def test_detect_multiplexers_guards_broken_probes(fresh_registry): + """A sentinel with no available()/version() and a probe that raises both + read as unavailable rows, never an exception.""" + fresh_registry._BUILTINS_LOADED = True + fresh_registry.register_multiplexer("bare", lambda p: False, lambda: object()) + fresh_registry.register_multiplexer( + "raiser", lambda p: p == sys.platform, lambda: _Stub(avail=RuntimeError("boom")) + ) + rows = {r.name: r for r in fresh_registry.detect_multiplexers()} + assert rows["bare"].available is False and rows["bare"].version is None + assert rows["raiser"].available is False + + +def test_detect_multiplexers_version_crash_keeps_availability(fresh_registry): + """version() is cosmetic: a backend whose availability probes True but + whose version() raises must still read available (and selected, since + _select never calls version()) — never a contradictory + selected=True/available=False row.""" + fresh_registry._BUILTINS_LOADED = True + fresh_registry.register_multiplexer( + "verless", + lambda p: p == sys.platform, + lambda: _Stub(avail=True, version=RuntimeError("boom")), + ) + rows = {r.name: r for r in fresh_registry.detect_multiplexers()} + assert rows["verless"].available is True + assert rows["verless"].version is None + assert rows["verless"].selected is True and rows["verless"].reason == "first-match" diff --git a/tests/test_cli.py b/tests/test_cli.py index 93c5b56b..6bb271e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1814,3 +1814,157 @@ def test_dry_run_stories_relativizes_absolute_folder(project, capsys): out = capsys.readouterr().out assert "Spec folder: _bmad-output/epic-1. Story id: 1." in out assert f"Spec folder: {abs_folder}" not in out # not the raw absolute path + + +# --------------- `bmad-loop mux`: backend listing + persisted choice (issue #87) ---- + + +class _MuxStub: + """Selection-surface double (available/version only — `mux` needs no more).""" + + def __init__(self, avail=True, version=None): + self._avail, self._version = avail, version + + def available(self): + return self._avail + + def version(self): + return self._version + + +@pytest.fixture +def mux_registry(monkeypatch): + """Isolated multiplexer registry with builtins suppressed, so `mux` tests are + deterministic regardless of whether the host has tmux installed.""" + from bmad_loop.adapters import multiplexer as m + + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) + saved_backends = list(m._BACKENDS) + saved_loaded = m._BUILTINS_LOADED + saved_configured = m._CONFIGURED + m._BACKENDS.clear() + m._BUILTINS_LOADED = True # suppress the real tmux builtin + m._CONFIGURED = None + m.get_multiplexer.cache_clear() + yield m + m._BACKENDS[:] = saved_backends + m._BUILTINS_LOADED = saved_loaded + m._CONFIGURED = saved_configured + m.get_multiplexer.cache_clear() + + +def test_mux_lists_backends_and_selection(mux_registry, tmp_path, capsys): + import sys as _sys + + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(avail=True, version="alpha 1.2") + ) + mux_registry.register_multiplexer("beta", lambda p: False, lambda: _MuxStub(avail=False)) + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "alpha 1.2" in out + assert "beta" in out + assert "selection: alpha (_MuxStub) — first available platform match" in out + assert "bmad-loop mux set " in out # the no-prompt override hint + + +def test_mux_exits_1_on_forced_unknown_name(mux_registry, tmp_path, capsys, monkeypatch): + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "ghost") + mux_registry.get_multiplexer.cache_clear() + assert cli.main(["mux", "--project", str(tmp_path)]) == 1 + captured = capsys.readouterr() + assert "NAME" in captured.out # the listing still prints for diagnosis + assert "ghost" in captured.err and "FAIL" in captured.err + + +def test_mux_set_persists_choice(mux_registry, tmp_path, capsys): + import sys as _sys + + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(avail=True) + ) + assert cli.main(["mux", "set", "alpha", "--project", str(tmp_path)]) == 0 + assert policy_mod.load(tmp_path / ".bmad-loop" / "policy.toml").mux.backend == "alpha" + assert 'mux backend set to "alpha"' in capsys.readouterr().out + + +def test_mux_set_unavailable_backend_warns_but_persists(mux_registry, tmp_path, capsys): + mux_registry.register_multiplexer("alpha", lambda p: False, lambda: _MuxStub(avail=False)) + assert cli.main(["mux", "set", "alpha", "--project", str(tmp_path)]) == 0 + captured = capsys.readouterr() + assert "not available on this host" in captured.err + assert policy_mod.load(tmp_path / ".bmad-loop" / "policy.toml").mux.backend == "alpha" + + +def test_mux_set_unregistered_errors_without_force(mux_registry, tmp_path, capsys): + assert cli.main(["mux", "set", "ghost", "--project", str(tmp_path)]) == 1 + assert "not a registered backend" in capsys.readouterr().err + assert not (tmp_path / ".bmad-loop" / "policy.toml").exists() + + +def test_mux_set_force_persists_unregistered_name(mux_registry, tmp_path): + assert cli.main(["mux", "set", "ghost", "--force", "--project", str(tmp_path)]) == 0 + assert policy_mod.load(tmp_path / ".bmad-loop" / "policy.toml").mux.backend == "ghost" + + +def test_mux_set_requires_a_name(mux_registry, tmp_path, capsys): + assert cli.main(["mux", "set", "--project", str(tmp_path)]) == 1 + assert "requires a backend name" in capsys.readouterr().err + + +def test_mux_set_clear_returns_to_auto(mux_registry, tmp_path): + mux_registry.register_multiplexer("alpha", lambda p: False, lambda: _MuxStub()) + assert cli.main(["mux", "set", "alpha", "--project", str(tmp_path)]) == 0 + assert cli.main(["mux", "set", "--clear", "--project", str(tmp_path)]) == 0 + assert policy_mod.load(tmp_path / ".bmad-loop" / "policy.toml").mux.backend == "" + + +def test_mux_set_notes_env_override_shadowing(mux_registry, tmp_path, capsys, monkeypatch): + mux_registry.register_multiplexer("alpha", lambda p: False, lambda: _MuxStub()) + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "alpha") + assert cli.main(["mux", "set", "alpha", "--project", str(tmp_path)]) == 0 + assert "outranks the persisted choice" in capsys.readouterr().err + + +def test_policy_mux_choice_reaches_selection_through_main(mux_registry, tmp_path, capsys): + """End-to-end chokepoint proof: a [mux] backend persisted in policy.toml is + honored by a fresh CLI invocation (main() installs it before dispatch).""" + mux_registry.register_multiplexer("fake", lambda p: False, lambda: _MuxStub(avail=True)) + _write_policy(tmp_path, '[mux]\nbackend = "fake"\n') + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "selection: fake (_MuxStub) — set by [mux] backend" in out + + +def test_policy_mux_unknown_name_fails_loud_through_main(mux_registry, tmp_path, capsys): + """A stale persisted choice (backend uninstalled later) must fail loudly and + name the policy file to edit.""" + _write_policy(tmp_path, '[mux]\nbackend = "ghost"\n') + assert cli.main(["mux", "--project", str(tmp_path)]) == 1 + err = capsys.readouterr().err + assert "ghost" in err and "policy.toml" in err + + +def test_platform_preflight_lists_multiple_backends(mux_registry, monkeypatch): + import sys as _sys + + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(avail=True, version="alpha 1.2") + ) + mux_registry.register_multiplexer("beta", lambda p: False, lambda: _MuxStub(avail=False)) + notes, problems = cli._platform_preflight() + assert any("mux backends:" in n and "alpha*" in n and "beta (unavailable)" in n for n in notes) + + +def test_platform_preflight_single_backend_gets_no_listing(mux_registry): + mux_registry.register_multiplexer("alpha", lambda p: True, lambda: _MuxStub(avail=True)) + notes, problems = cli._platform_preflight() + assert not any("mux backends:" in n for n in notes) + + +def test_platform_preflight_notes_forced_selection_provenance(mux_registry, monkeypatch): + mux_registry.register_multiplexer("alpha", lambda p: False, lambda: _MuxStub(avail=True)) + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "alpha") + mux_registry.get_multiplexer.cache_clear() + notes, problems = cli._platform_preflight() + assert any("forced by BMAD_LOOP_MUX_BACKEND" in n for n in notes) diff --git a/tests/test_install.py b/tests/test_install.py index 3bea9139..bae8fc8d 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -369,6 +369,7 @@ def test_install_into_full(tmp_path): gitignore = (tmp_path / ".gitignore").read_text() assert ".bmad-loop/runs/" in gitignore assert ".bmad-loop/cache/" in gitignore # engine plugins' rebuildable caches + assert ".bmad-loop/policy.toml" in gitignore # per-machine config ([mux] backend) # all bundled skills land in claude's tree, with nested files intact skills_dir = tmp_path / ".claude" / "skills" @@ -383,6 +384,26 @@ def test_install_into_full(tmp_path): final_gitignore = (tmp_path / ".gitignore").read_text() assert final_gitignore.count(".bmad-loop/runs/") == 1 assert final_gitignore.count(".bmad-loop/cache/") == 1 + assert final_gitignore.count(".bmad-loop/policy.toml") == 1 + + +def test_install_into_warns_when_policy_is_tracked(tmp_path, capsys): + """A .gitignore entry doesn't untrack an already-committed policy.toml: + upgrading repos get the one-time `git rm --cached` hint.""" + import subprocess + + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + policy_file = tmp_path / ".bmad-loop" / "policy.toml" + policy_file.parent.mkdir(parents=True) + policy_file.write_text("[gates]\n", encoding="utf-8") + subprocess.run(["git", "add", ".bmad-loop/policy.toml"], cwd=tmp_path, check=True) + assert install_into(tmp_path) == 0 + assert "git rm --cached .bmad-loop/policy.toml" in capsys.readouterr().out + + +def test_install_into_no_tracking_warning_outside_a_repo(tmp_path, capsys): + assert install_into(tmp_path) == 0 + assert "git rm --cached" not in capsys.readouterr().out def test_hook_command_uses_selected_process_host(tmp_path, monkeypatch): diff --git a/tests/test_policy.py b/tests/test_policy.py index 88916fd5..0c551c23 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -522,3 +522,135 @@ def test_to_dict_roundtrips_for_snapshot(): pol = policy.load(None) snapshot = pol.to_dict() assert snapshot["limits"]["max_review_cycles"] == 3 + + +# --------------------------------------------------------------------------- +# [mux] — machine-scoped terminal-multiplexer backend choice (issue #87) + + +def test_mux_defaults_to_auto(): + pol = policy.loads("") + assert pol.mux.backend == "" + + +def test_mux_backend_parses_and_strips(): + pol = policy.loads('[mux]\nbackend = " psmux "\n') + assert pol.mux.backend == "psmux" + + +def test_mux_backend_rejects_junk(): + with pytest.raises(policy.PolicyError, match="mux.backend"): + policy.loads('[mux]\nbackend = "not a name!"\n') + + +def test_mux_scalar_section_rejected(): + with pytest.raises(policy.PolicyError, match=r"\[mux\] must be a table"): + policy.loads('mux = "tmux"\n') + + +def test_template_mux_block_parses_to_defaults(): + pol = policy.loads(policy.POLICY_TEMPLATE) + assert pol.mux.backend == "" # the anchor line ships commented out + + +def test_write_mux_backend_uncomments_template_anchor(tmp_path): + p = tmp_path / "policy.toml" + policy.write_mux_backend(p, "psmux") + text = p.read_text(encoding="utf-8") + assert 'backend = "psmux"' in text + assert policy.load(p).mux.backend == "psmux" + # created from the template: full documentation retained + assert "[gates]" in text and "[scm]" in text + + +def test_write_mux_backend_replaces_existing_value(tmp_path): + p = tmp_path / "policy.toml" + policy.write_mux_backend(p, "psmux") + before = p.read_text(encoding="utf-8") + policy.write_mux_backend(p, "tmux") + after = p.read_text(encoding="utf-8") + assert policy.load(p).mux.backend == "tmux" + # a targeted line replace: everything but the anchor line is byte-identical + diff = [(a, b) for a, b in zip(before.splitlines(), after.splitlines(), strict=True) if a != b] + assert diff == [('backend = "psmux"', 'backend = "tmux"')] + + +def test_write_mux_backend_clear_recomments(tmp_path): + p = tmp_path / "policy.toml" + policy.write_mux_backend(p, "psmux") + policy.write_mux_backend(p, None) + assert policy.load(p).mux.backend == "" + assert '# backend = "tmux"' in p.read_text(encoding="utf-8") + + +def test_write_mux_backend_appends_table_to_legacy_file(tmp_path): + p = tmp_path / "policy.toml" + legacy = '# my notes\n[gates]\nmode = "none"\n' + p.write_text(legacy, encoding="utf-8") + policy.write_mux_backend(p, "herdr") + text = p.read_text(encoding="utf-8") + assert text.startswith(legacy) # untouched prefix, table appended at EOF + pol = policy.load(p) + assert pol.mux.backend == "herdr" + assert pol.gates.mode == "none" + + +def test_write_mux_backend_reinserts_deleted_key_line(tmp_path): + p = tmp_path / "policy.toml" + p.write_text("[mux]\n# hand-trimmed file: no key line\n", encoding="utf-8") + policy.write_mux_backend(p, "tmux") + assert policy.load(p).mux.backend == "tmux" + + +def test_write_mux_backend_preserves_hand_edits(tmp_path): + p = tmp_path / "policy.toml" + hand = '[limits]\nmax_dev_attempts = 7 # keep my comment\n\n[mux]\nbackend = "old"\n' + p.write_text(hand, encoding="utf-8") + policy.write_mux_backend(p, "new") + pol = policy.load(p) + assert pol.mux.backend == "new" + assert pol.limits.max_dev_attempts == 7 + assert "# keep my comment" in p.read_text(encoding="utf-8") + + +def test_write_mux_backend_preserves_trailing_comment_on_anchor_line(tmp_path): + """A hand-added comment on the backend line itself survives a replace — + 'preserving every other byte' includes the anchor line's own comment.""" + p = tmp_path / "policy.toml" + p.write_text('[mux]\nbackend = "old" # pinned per teammate X\n', encoding="utf-8") + policy.write_mux_backend(p, "new") + text = p.read_text(encoding="utf-8") + assert 'backend = "new" # pinned per teammate X\n' in text + assert policy.load(p).mux.backend == "new" + + +def test_write_mux_backend_clear_preserves_trailing_comment(tmp_path): + """Clearing re-comments the line but keeps the hand-added trailing comment.""" + p = tmp_path / "policy.toml" + p.write_text('[mux]\nbackend = "old" # pinned per teammate X\n', encoding="utf-8") + policy.write_mux_backend(p, None) + text = p.read_text(encoding="utf-8") + assert '# backend = "tmux" # pinned per teammate X\n' in text + assert policy.load(p).mux.backend == "" + + +def test_write_mux_backend_preserves_crlf_line_ending(tmp_path): + p = tmp_path / "policy.toml" + p.write_bytes(b'[mux]\r\nbackend = "old"\r\n') + policy.write_mux_backend(p, "new") + assert b'backend = "new"\r\n' in p.read_bytes() + + +def test_write_mux_backend_rejects_bad_name(tmp_path): + p = tmp_path / "policy.toml" + with pytest.raises(policy.PolicyError, match="mux.backend"): + policy.write_mux_backend(p, "bad name!") + assert not p.exists() # rejected before any write + + +def test_write_mux_backend_refuses_broken_file(tmp_path): + p = tmp_path / "policy.toml" + p.write_text("[gates\nmode = ", encoding="utf-8") + with pytest.raises(policy.PolicyError): + policy.write_mux_backend(p, "tmux") + assert p.read_text(encoding="utf-8") == "[gates\nmode = " # never half-writes diff --git a/tests/test_settings_schema.py b/tests/test_settings_schema.py index cc52b1f3..25c303c4 100644 --- a/tests/test_settings_schema.py +++ b/tests/test_settings_schema.py @@ -28,6 +28,7 @@ CleanupPolicy, GatesPolicy, LimitsPolicy, + MuxPolicy, NotifyPolicy, Policy, ReviewPolicy, @@ -58,6 +59,7 @@ "scm": ScmPolicy, "cleanup": CleanupPolicy, "tui": TuiPolicy, + "mux": MuxPolicy, } # select fields whose options are an enum set.