Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/ISSUE_TEMPLATE/bug-report.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,14 @@ body:
id: platform
attributes:
label: Operating System
description: On WSL, run `bmad-loop mux` and read the `selection:` line — `platform default for linux`
is the supported setup, `platform default for win32` means you are on the Windows build
and should say so below. A forced `BMAD_LOOP_MUX_BACKEND`/`[mux] backend` choice replaces
that line with the forced reason; `bmad-loop diagnose` reports `sys.platform` either way.
options:
- macOS
- Windows
- Windows (WSL)
- Linux
- Other
validations:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,27 @@ whose seams had diverged enough that several ports needed a different fix, and t

### Fixed

- **A native-Windows install driven from a WSL shell now says so (#332).** WSL appends the Windows
`PATH` to its own, so a bash prompt can reach a Windows-installed `bmad-loop`: that interpreter
reports `win32`, takes the psmux platform default, and never sees the distro's tmux — while
`validate` printed a green `multiplexer PsmuxMultiplexer available` and nothing named the platform.
`validate` now reports the multiplexer selection reason for **every** host (it was emitted only for
a forced `BMAD_LOOP_MUX_BACKEND`/`[mux] backend` choice), so `platform default for win32` is on
screen wherever the mismatch happens; the same un-gating makes a `fallback` selection — no
available backend matches this platform — a warning rather than a green line. A `win32` interpreter
working on a `\\wsl.localhost\...` project additionally raises a `host.win32-on-wsl-path` **warning**
naming the fix (install with the WSL/Linux Python) and the backend it actually chose. That warning
covers the project-on-the-distro shape only; a project under `/mnt/c` gets a genuine Windows path
and no warning, and is covered by the selection line instead. Nothing changes which backend is
selected — psmux is correct for a `win32` interpreter — nor validate's exit code. `diagnose` gains
`sys.platform` and `win32 on WSL distro path` (`yes`/`no`) in its Environment block.

- **A multiplexer-detection failure is reported instead of swallowed.** `validate` caught and
discarded any exception from backend detection, so `mux.selection` and the backend inventory
vanished with nothing said — while `mux.backend` above them, which comes from an independent
selection call, still printed a healthy backend. It now reports under `mux.backends-detected` at
**warning** carrying the error.

- **Provider quota refusals are environment faults on `opencode-http` too (#323).** #194's classifier
lived on `GenericAdapter`, so its hookless HTTP sibling silently omitted it: a five-hour provider
usage limit read as three stalled stories and burned their retry budgets. The classifier now lives
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

- `bmad-loop init` installs the three `bmad-loop-*` skills (`bmad-loop-setup`, `bmad-loop-resolve`, `bmad-loop-sweep`, into `.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, the selected terminal-multiplexer backend (listing all detected when more than one is registered), CLI binary, hook registration, and the review skills the installed dev primitive actually invokes (reporting which name it resolved) — derived from its `customize.toml` review layers (or from `step-04-review.md` on releases that name reviewers inline), so both the merged `bmad-review` topology and the standalone-hunter one validate, and configured layers naming an uninstalled skill are caught — plus its `customize.toml`.
- The preflight also **names the multiplexer selection reason wherever selection resolves** (`mux.selection`, e.g. `platform default for win32`), not only when a `BMAD_LOOP_MUX_BACKEND`/`[mux] backend` choice forced it. A `fallback` selection is reported as a warning (its own label says no available backend matches this platform); a selection that outright failed is carried by `mux.preflight`, and a detection that failed by `mux.backends-detected` at warning — so a missing `mux.selection` line is normally explained by another finding (the historical unregistered-tmux fallback is the one silent exception; see the `--json` contract note in `documents.py`). On top of that, `host.win32-on-wsl-path` warns when a **native-Windows interpreter is working on a `\\wsl.localhost\...` project** ([#332](https://github.com/bmad-code-org/bmad-loop/issues/332) — see [multiplexer-backends.md](multiplexer-backends.md) for why WSL can hand a bash prompt the Windows build). Both are diagnostics only: neither changes which backend is selected (psmux _is_ correct for a `win32` interpreter) and neither flips validate's exit code. `bmad-loop diagnose` carries the same two facts in its Environment block as `sys.platform` and `win32 on WSL distro path` (`yes`/`no`).
- Non-invasive: drives the upstream dev primitive 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.

### Command reference
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ matching seam, `ProcessHost` (`src/bmad_loop/process_host.py`): `terminate` / `f
`is_alive` / `identity` (a PID-reuse guard) plus `hook_interpreter()` (so hook registration
never branches on platform), registered the same way (`register_process_host`,
`BMAD_LOOP_PROCESS_HOST`); `WindowsProcessHost` already ships. `bmad-loop validate` runs a
`_platform_preflight()` that reports the selected backend's readiness and names the process
`_platform_preflight(project)` that reports the selected backend's readiness and names the process
host — so a new OS surfaces in preflight by registering, not by a `validate` edit. The Unity
plugin's `/proc`/`/tmp`/`cp -a`/symlink primitives degrade off Linux (with `psutil` from the
optional `non-linux` extra) and its pid lifecycle now delegates to `ProcessHost`; everything is
Expand Down
7 changes: 6 additions & 1 deletion docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ names carry over. It is selected automatically when available; `available()` req
releases can force-kill a recycled PID during teardown, so they report unavailable and
selection falls through). Native Windows is still experimental — see the
[roadmap](ROADMAP.md#native-windows-multiplexer-backend) for the remaining work. WSL is
unaffected: it _is_ Linux and uses tmux.
unaffected: it _is_ Linux and uses tmux — provided bmad-loop was installed with the
distro's own Python. WSL appends the Windows `PATH` to its own, so a Windows-installed
bmad-loop is reachable from the bash prompt; that process reports `win32` and takes the
psmux default no matter how Linux the shell looks. `bmad-loop validate` names the
selection reason on every host, and warns (`host.win32-on-wsl-path`) when a `win32` interpreter
is working on a `\\wsl.localhost\...` project (#332).

Two model differences matter if you port a backend or read psmux argv. psmux runs one server
per session, so window ids are minted per server and the backend session-qualifies every id it
Expand Down
31 changes: 19 additions & 12 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (or `bmad-loop mux set <name>`) |
| 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 <name>`) |
| 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(project)` (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
Expand Down Expand Up @@ -260,15 +260,22 @@ A new OS overrides this on its `ProcessHost`; nothing else changes.

## Seam 4 — validate preflight

`_platform_preflight()` (`src/bmad_loop/cli.py`, called from `cmd_validate`) asks
the selected multiplexer for its `available()` / `version()` and names the selected
process host. A new OS therefore surfaces its readiness in `bmad-loop validate`
**by registering** (seams 1–2) — not by adding a `win32` block to `validate`. The
process host is named in the output so a misselection (e.g. the Windows host picked
on Linux) is visible at a glance.
`_platform_preflight(project)` (`src/bmad_loop/cli.py`, called from `cmd_validate`)
asks the selected multiplexer for its `available()` / `version()` and names the
selected process host. A new OS therefore surfaces its readiness in `bmad-loop
validate` **by registering** (seams 1–2) — not by adding a `win32` block to
`validate`. The process host is named in the output so a misselection (e.g. the
Windows host picked on Linux) is visible at a glance.

There is no new code to write for this seam — it reads seams 1 and 2.

The one `sys.platform` branch that does live here is not a port seam and is not a
precedent for one: the `host.win32-on-wsl-path` check (#332) reports that the _interpreter
itself_ is the wrong build for the shell that launched it — a native-Windows
`bmad-loop` reached from a WSL prompt. No registration can express that, because
every seam is correctly selected for the interpreter that is running; what is wrong
is which interpreter the operator got. Readiness questions still register.

---

## Helper scripts (plugins)
Expand Down
9 changes: 8 additions & 1 deletion docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ of the README.
not yet at the Linux/macOS/WSL support tier — the remaining native-Windows work (window
hosting, attach/detach, Unity cache paths) is tracked in
[the roadmap](ROADMAP.md#native-windows-multiplexer-backend); the port path is in
[Porting bmad-loop to a new OS](porting-to-a-new-os.md).
[Porting bmad-loop to a new OS](porting-to-a-new-os.md). Inside WSL, install with the
**Linux** interpreter — a Windows-installed bmad-loop is reachable from the bash prompt
and silently behaves as Windows
([why](multiplexer-backends.md#psmux-native-windows-experimental)). To check:
`bmad-loop mux` should read `platform default for linux`; if it names `win32` you are
running the Windows build, and `bmad-loop validate` says so too. (A forced choice —
`BMAD_LOOP_MUX_BACKEND` or `[mux] backend` — is reported as the reason _instead_ of the
platform, so clear it before reading that line as a platform check.)

## Installed via the BMAD-method installer? (recommended)

Expand Down
1 change: 1 addition & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"mux.selection",
"mux.external-backend",
"host.process",
"host.win32-on-wsl-path",
"notify.desktop-unavailable",
"skills.base",
"skills.base-missing",
Expand Down
6 changes: 4 additions & 2 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ def cmd_validate(args: argparse.Namespace) -> int:
except verify.GitError:
pass

report.extend(_platform_preflight())
report.extend(_platform_preflight(project))

# #231: notify.desktop defaults to true but only fires when a platform notifier
# exists (osascript/PowerShell/notify-send). When none does, the setting is
Expand Down Expand Up @@ -2992,7 +2992,9 @@ def cmd_diagnose(args: argparse.Namespace) -> int:
return 1

pseudo = sanitize.Pseudonymizer()
diag = diagnostics.collect(run_dirs, pseudo=pseudo, cap=args.max_journal_entries)
diag = diagnostics.collect(
run_dirs, pseudo=pseudo, cap=args.max_journal_entries, project=project
)
repairs: list[tuple[str, int]] = []
fail_rules: list[str] | None = None
report = ""
Expand Down
36 changes: 34 additions & 2 deletions src/bmad_loop/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import json
import platform
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
Expand Down Expand Up @@ -151,6 +152,17 @@ class EnvInfo:
package_version: str
multiplexer: str
tmux_version: str | None
# `platform.system()` above says "Windows" for both a native shell and a WSL
# interop launch. `sys_platform` carries the raw token instead, so a dump can be
# matched character-for-character against validate's `platform default for {token}`
# line; `win32_on_wsl_path` is the #332 condition itself. Named for exactly what it
# observes — a win32 interpreter working a distro path — and NOT "wsl interop":
# `cd \\wsl.localhost\...` from native PowerShell reaches the same state, so this
# must not claim where the operator is standing (the matching `host.win32-on-wsl-path`
# finding is worded to the same limit). Additive fields — no SCHEMA_VERSION bump;
# `--json` evolution is additive-only, see the contract note in machine.py.
sys_platform: str
win32_on_wsl_path: bool


@dataclass
Expand Down Expand Up @@ -240,8 +252,23 @@ class Diagnostics:
# ----------------------------------------------------------------- collectors


def collect_env() -> EnvInfo:
def collect_env(project: Path) -> EnvInfo:
"""Host facts for the dump's Environment block.

``project`` feeds only the #332 verdict. It is required — here *and* on
``collect`` — so a new caller must consciously supply the project it actually
diagnosed; the type cannot force that choice to be *right* (any ``Path``
yields a confident verdict), and the sole production caller, ``cmd_diagnose``,
passes the resolved project.

The project *path* itself is never emitted: it is a redaction hazard — the
redactor leaves the Linux username in a ``\\\\wsl.localhost\\...\\home\\<user>\\...``
path standing (it compares against the *Windows* account), and ``collect_env``
has no pseudonymizer to alias it against — so the boolean is what ships. Same
reason ``sys.executable`` is absent despite naming the exact mismatch: the venv
path carries the project name past the redactor."""
from .adapters.multiplexer import fold_version, get_multiplexer
from .platform_util import is_wsl_unc_path

mux = "none"
tmux_v = None
Expand All @@ -265,6 +292,8 @@ def collect_env() -> EnvInfo:
package_version=__version__,
multiplexer=mux,
tmux_version=tmux_v,
sys_platform=sys.platform,
win32_on_wsl_path=sys.platform == "win32" and is_wsl_unc_path(project),
)


Expand Down Expand Up @@ -522,6 +551,7 @@ def collect(
pseudo: sanitize.Pseudonymizer,
cap: int = DEFAULT_JOURNAL_CAP,
generated_at: str | None = None,
project: Path,
) -> Diagnostics:
runs: list[RunDiag] = []
for run_dir in run_dirs:
Expand All @@ -533,7 +563,7 @@ def collect(
schema_version=SCHEMA_VERSION,
generated_at=generated_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
tool_version=__version__,
env=collect_env(),
env=collect_env(project),
runs=runs,
)

Expand Down Expand Up @@ -627,6 +657,8 @@ def render_markdown(
out.append(_fmt_kv("bmad-loop version", e.package_version))
out.append(_fmt_kv("python", e.python_version))
out.append(_fmt_kv("os", f"{e.os} {e.os_release}"))
out.append(_fmt_kv("sys.platform", e.sys_platform))
out.append(_fmt_kv("win32 on WSL distro path", "yes" if e.win32_on_wsl_path else "no"))
out.append(_fmt_kv("multiplexer", e.multiplexer))
out.append(_fmt_kv("tmux", e.tmux_version or "—"))
out.append(_fmt_kv("schema / generated", f"v{d.schema_version} @ {d.generated_at}"))
Expand Down
15 changes: 13 additions & 2 deletions src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def validate_document(
failure to produce one (see machine.py on parsing non-empty stdout whatever
the exit code).

Three things a consumer has to know:
Four things a consumer has to know:

- **``message`` is not contracted.** Several problems are a bare ``str(e)``
from the config, policy, profile and sprint-status exceptions, so their
Expand All @@ -77,7 +77,18 @@ def validate_document(
no finding at all. A check id missing from ``findings`` means "did not run",
never "passed" — check ``ok`` for the verdict, not the absence of an id.
- **``mux.backends-detected`` is gated on more than one registered backend**,
so a lone-tmux host carries no backend inventory. Same rule as above.
so a lone-tmux host carries no backend inventory. Same rule as above. The one
exception to its ``ok`` severity is a detection failure, which reports under
the same id at ``warning`` and carries no ``detail`` at all — read the
severity, never index ``detail["backends"]`` without a null check.
- **``mux.selection`` is not gated on the reason.** It used to appear only for a
forced ``env``/``policy`` choice and now names the reason wherever selection
resolves. It is absent whenever no backend row is *selected*, which has three
causes: a forced name matching no registered backend (``mux.preflight``
carries that failure), a detection failure (the ``warning`` above carries
it), and ``_select`` bottoming out at its historical tmux fallback with tmux
unregistered (nothing else reports that — the inventory simply holds no
selected row). Same rule as above.

``findings`` stays flat and in emission order rather than grouped by severity:
grouping would destroy the cross-severity ordering (the order the gates ran)
Expand Down
Loading