Skip to content
Draft
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ breaking changes may land in a minor release.

### Added

- **`run`/`sweep` take `--cli` and `--cli-binary` (#395).** Pick the coding CLI or the executable
for one run without editing `policy.toml` — `bmad-loop run --cli-binary cc` bills a run to a
second account, `--cli codex` moves it to another client. A flag is a whole-run choice, so it
beats the per-stage `[adapter.<stage>]` tables as well as the base; a stage the flag moves to a
different client drops the model and flags it had chosen for the old one, and keeps its timing
knobs. The override is folded into the policy before the run is stamped, so `resume`,
`status --json` and `--dry-run` all report what actually ran. An unknown `--cli` fails before
anything spawns. `--cli-binary` stops at a stage pinned to another client — an executable
belongs to one CLI — and prints a note naming that stage rather than applying partly in
silence.

- **`[adapter] binary` spawns a CLI under a custom name (#395).** Set `binary = "cc"` (base or
per-stage) to drive a second subscription or a work/personal account that lives beside the
default install, without forking the whole profile to change one field — a copy freezes its
`bypass_args`, `[env]`, `seed_files` and `env_fault_patterns` against upstream. Empty (the
default) keeps the profile's own binary, so nothing changes for existing projects. The override
is for an alias or wrapper of the _same_ CLI: the profile still supplies the hook dialect, config
path and transcript layout. Client-specific like `model`/`extra_args` — a stage that also switches
`name` falls back to that profile's binary. `validate` probes the effective executable (a missing
`cc` reports as `cc`, not as a missing `claude`) and `--dry-run` prints it.

- **A park travels with its story's commit, so `bmad-loop confirm` works from any clone (#356).**
Each parked story now writes one committed JSON record to `.bmad-loop/operator/<key>.json`, inside
the story's own commit window, so the record rides the park's commit — through the worktree
Expand Down
2 changes: 2 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Supported, E2E-verified over HTTP/SSE (no tmux window): `opencode` (OpenCode ≥ 1.18, profile `opencode-http`, alias `opencode`) — one headless `opencode serve` per session, SSE `session.idle` completion with an HTTP poll fallback, per-session server password, token usage read back over the API. Hookless (`[hooks] dialect = "none"`, no hook registration). With no pane to replay, the run logs split three ways: a curated readable transcript in `logs/<task-id>.log` (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in `<task-id>.server.out`, and a structured SSE trace in `<task-id>.sse.jsonl`. Install the extra (`pip install 'bmad-loop[opencode]'`), auth once globally (`opencode auth login`), and set `model` as `provider/model`; the Unity plugin's window guards don't apply (there is no window).
- Experimental, `isolation = "none"` only: `antigravity` (Google's `agy` ≥ 1.1.3) — `-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"` permanently — agy's transcript exposes no usage data (tokens live only in an internal SQLite/protobuf store). `agy` gates each workspace on an exact-path `trustedWorkspaces` entry and blocks on an interactive trust dialog, which `--dangerously-skip-permissions` does not bypass — so worktree isolation hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). 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]`).
- Custom executable per project (`[adapter] binary`, per-stage too): spawn the same CLI under another name — a second subscription or a work/personal account reachable as `cc` beside `claude`, or two aliases used to split usage limits — without forking the profile (a copied profile freezes its `bypass_args`, `[env]`, `seed_files` and `env_fault_patterns` against upstream). It must be an alias or wrapper of the **same** CLI: the profile still supplies the hook dialect, config path and transcript layout. Empty = the profile's own binary. `validate` probes the overridden name, and `--dry-run` prints it. `binary` is client-specific, so a stage that also switches `name` falls back to that profile's binary instead of inheriting.
- Add a CLI without touching Python: drop a TOML profile in `.bmad-loop/profiles/<name>.toml` (binary, prompt template, bypass flags, hook dialect, native→canonical event map).
- `bmad-loop probe-adapter` collects + sanitizes the data needed to finalize/add a profile (hook payload shape, transcript location/format, token schema): a zero-launch scan by default, opt-in `--probe` for live capture. See the [adapter authoring guide](adapter-authoring-guide.md).

Expand Down Expand Up @@ -191,6 +192,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set <name>` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md).
- `bmad-loop run` — drive the dev → review → verify → commit loop.
- `bmad-loop sweep` — triage + execute open deferred-work entries.
- `run` and `sweep` both take `--cli <profile>` and `--cli-binary <executable>` — the per-run form of `[adapter] name` / `[adapter] binary`, for picking a client or an account without editing project config (`bmad-loop run --cli-binary cc`). A flag is a whole-run choice, so it beats the per-stage tables too; it is folded into the policy before the run is stamped, so `resume`, `status --json` and `--dry-run` all report what actually ran. `--cli-binary` stops at a stage pinned to another client — an executable belongs to one CLI, and handing a `cc` alias of claude to a codex-pinned stage would give claude codex's flags and kill the session at startup — and says so on stderr, naming the stage and pointing at `--cli` to force one client for the whole run.
- `bmad-loop resume <run-id>` — continue a paused/interrupted run.
- `bmad-loop resolve <run-id>` — resolve a CRITICAL escalation, then re-arm + resume (`--story`, `--no-interactive`, `--restore-patch <path>` for intent-gap patch-restore, `--resume`/`--no-resume`).
- `bmad-loop decisions` — answer deferred-work decisions past sweeps left unanswered (`--list` to just show them). `--json` instead emits a stable machine-readable document (schema-versioned; per decision the id, question, context, recommendation and every option's key/label/effect/intent/resolution/bundle-name plus a derived `recommended` flag) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing pending yields a valid empty document.
Expand Down
152 changes: 145 additions & 7 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ def cmd_validate(args: argparse.Namespace) -> int:

profiles = []
profile_by_name: dict[str, CLIProfile] = {}
# Effective executable -> was it an [adapter] binary override. Keyed by the
# binary actually spawned, not by profile: a `binary` override means the
# profile's own name is NOT what runs, and probing it would report "claude
# found" for a run that will spawn `cc`.
binaries: dict[str, bool] = {}
pol = None
try:
pol = policy_mod.load(_policy_path(project))
Expand All @@ -227,6 +232,12 @@ def cmd_validate(args: argparse.Namespace) -> int:
profile_by_name[name] = profile
except ProfileError as e:
report.fail("adapter.profile", str(e), {"profile": name})
for role in ROLES:
cfg = pol.adapter.resolved(role)
profile = profile_by_name.get(cfg.name)
if profile is not None:
tool = cfg.binary or profile.binary
binaries[tool] = binaries.get(tool, False) or bool(cfg.binary)
except policy_mod.PolicyError as e:
report.fail("policy", str(e))

Expand Down Expand Up @@ -291,11 +302,15 @@ def cmd_validate(args: argparse.Namespace) -> int:
{"platform": sys.platform},
)

for tool in dict.fromkeys(p.binary for p in profiles):
for tool, overridden in binaries.items():
# Name the override in the message: a missing `cc` is a typo'd alias, not
# a missing Claude Code install, and the fix differs.
note = " (binary override)" if overridden else ""
detail = {"binary": tool, "override": overridden}
if shutil.which(tool):
report.ok("adapter.binary", f"{tool} found", {"binary": tool})
report.ok("adapter.binary", f"{tool} found{note}", detail)
else:
report.fail("adapter.binary", f"{tool} not found on PATH", {"binary": tool})
report.fail("adapter.binary", f"{tool} not found on PATH{note}", detail)

for profile in profiles:
if profile.hookless:
Expand Down Expand Up @@ -870,12 +885,130 @@ def _warn_unknown_keys(ss: sprintstatus.SprintStatus) -> None:
)


def _add_cli_override_flags(parser: argparse.ArgumentParser) -> None:
"""Per-run coding-CLI selection, shared by `run` and `sweep`. Named after
`probe-adapter`'s existing vocabulary: `cli` is the profile, `binary` the
executable, so one word means one thing across the CLI surface."""
parser.add_argument(
"--cli", metavar="PROFILE", help="override [adapter] name for this run (e.g. codex)"
)
parser.add_argument(
"--cli-binary",
metavar="EXECUTABLE",
help="override [adapter] binary for this run: spawn the same CLI under "
"another name, e.g. a second account reachable as `cc`",
)


def _apply_cli_overrides(pol, args, project: Path):
"""Fold ``--cli`` / ``--cli-binary`` into the policy, returning a new Policy.

Applied to the *policy object* before the run composes, not threaded into the
engine, so the RunState snapshot records what actually ran: `resume`, the
``--json`` adapter projection, per-task adapter stamping and the dry-run
render all follow with no further wiring.

A flag is a whole-run choice, so it wins over the per-stage tables rather
than only the base: forcing a client or a binary and then silently keeping a
stage pinned to another one would defeat the point of passing it. Clearing is
surgical, and mirrors ``AdapterPolicy.resolved``'s own client-switch rule — a
stage whose effective client the flag changes drops its client-specific
fields (model, extra_args, binary) and keeps its timing knobs; a stage that
already ran the forced client keeps everything but its now-redundant name.
"""
from dataclasses import replace

from .adapters.profile import ProfileError, get_profile

name, binary = getattr(args, "cli", None), getattr(args, "cli_binary", None)
# The same normalization policy.toml gets: trim, and refuse a present-but-
# blank value rather than letting it read as unset (or, worse, become a
# whitespace argv[0] that fails at spawn with an unrecognizable error).
for flag, raw in (("--cli", name), ("--cli-binary", binary)):
if raw is not None and not raw.strip():
raise SystemExit(f"error: {flag} must not be blank")
name, binary = (name or "").strip(), (binary or "").strip()
if not name and not binary:
return pol
if name:
# Fail here rather than at session spawn: the dry-run render resolves the
# profile too, and an unknown name should not surface as a traceback.
try:
get_profile(name, project)
except ProfileError as e:
raise SystemExit(f"error: {e}") from e
adapter = pol.adapter
# A forced client resets the base's client-specific fields to that CLI's own
# defaults, the same reset the stage loop below performs — binary included: a
# `cc` alias of claude left standing under `--cli codex` would spawn claude
# with codex's prompt template and bypass flags. An explicit --cli-binary
# still wins over the reset.
switched = bool(name) and name != adapter.name
stages = {}
for role in ROLES:
stage = getattr(adapter, role)
if name and (stage.name or adapter.name) != name:
# the client changed under this stage: its model/extra_args/binary
# were chosen for the old one and must not be carried over
stages[role] = policy_mod.StageAdapterPolicy(
usage_grace_s=stage.usage_grace_s,
stop_without_result_nudges=stage.stop_without_result_nudges,
)
continue
stages[role] = replace(
stage,
name=None if name else stage.name,
binary=None if binary else stage.binary,
)
out = replace(
pol,
adapter=replace(
adapter,
name=name or adapter.name,
binary=binary or ("" if switched else adapter.binary),
model="" if switched else adapter.model,
extra_args=None if switched else adapter.extra_args,
**stages,
),
)
_warn_unapplied_binary(out, binary)
return out


def _warn_unapplied_binary(pol, binary: str) -> None:
"""Name every stage a ``--cli-binary`` did not reach, and why.

A binary only means something relative to one client — the profile decides
the prompt dialect, hook contract and bypass flags, so spawning a `cc` alias
of claude for a codex-pinned stage would hand claude codex's flags and kill
the session at startup. The flag therefore stops at a stage running another
client. That is defensible; doing it *silently* is not, since the operator
passed a whole-run flag and would otherwise have to read a `--dry-run` to
notice it was partly ignored."""
if not binary:
return
skipped = [
(role, resolved.name)
for role in ROLES
if (resolved := pol.adapter.resolved(role)).binary != binary
]
if not skipped:
return
where = ", ".join(f"{role} (runs {client})" for role, client in skipped)
print(
f"note: --cli-binary {binary} not applied to {where} — a binary belongs to one "
f"client, and {pol.adapter.name} is not what these stages run; "
f"pass --cli to force one client for the whole run",
file=sys.stderr,
)


def cmd_run(args: argparse.Namespace) -> int:
if (rc := _reject_bad_run_id(args.run_id)) is not None:
return rc
project = _project(args)
paths = bmadconfig.load_paths(project)
pol = policy_mod.load(_policy_path(project))
pol = _apply_cli_overrides(policy_mod.load(_policy_path(project)), args, project)
stories_on, spec_folder = _stories_mode(args, pol)

if stories_on and args.epic is not None:
Expand Down Expand Up @@ -944,19 +1077,22 @@ def _render_invocation(pol, project: Path, role: str, prompt: str) -> str:

cfg = pol.adapter.resolved(role)
profile = get_profile(cfg.name, project)
# The dry-run plan must name the executable the run would really spawn, so a
# binary override is visible before any session starts.
binary = cfg.binary or profile.binary
if profile.hookless:
# HTTP/SSE transport — there is no shell invocation to print. Render
# the real sequence (per-session server spawn + API prompt) instead of
# a fake argv that run would never execute.
model = f" model={cfg.model}" if cfg.model else ""
return (
f"{profile.binary} serve --hostname 127.0.0.1 --port <auto> "
f"{binary} serve --hostname 127.0.0.1 --port <auto> "
f'(cwd=<worktree>) → POST /session → prompt_async "{profile.render_prompt(prompt)}"'
f"{model}"
)
extra = cfg.extra_args if cfg.extra_args is not None else profile.bypass_args
argv = [
profile.binary,
binary,
*profile.launch_args,
f'"{profile.render_prompt(prompt)}"',
*extra,
Expand Down Expand Up @@ -1132,7 +1268,7 @@ def cmd_sweep(args: argparse.Namespace) -> int:
return rc
project = _project(args)
paths = bmadconfig.load_paths(project)
pol = policy_mod.load(_policy_path(project))
pol = _apply_cli_overrides(policy_mod.load(_policy_path(project)), args, project)

if args.dry_run:
return _sweep_dry_run(paths, pol)
Expand Down Expand Up @@ -2752,6 +2888,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser:
"or full key (sprint mode); a story id (stories mode)",
)
run_p.add_argument("--max-stories", type=int, help="stop after N stories")
_add_cli_override_flags(run_p)
run_p.add_argument("--dry-run", action="store_true", help="print the plan, spawn nothing")
run_p.add_argument("--run-id", help=argparse.SUPPRESS) # pre-assigned id (used by the TUI)

Expand All @@ -2767,6 +2904,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser:
help="triage + answer decisions + record them; run no bundles",
)
sweep_p.add_argument("--max-bundles", type=int, help="override [sweep] max_bundles")
_add_cli_override_flags(sweep_p)
sweep_p.add_argument(
"--repeat",
action=argparse.BooleanOptionalAction,
Expand Down
10 changes: 10 additions & 0 deletions src/bmad_loop/data/settings/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ key = "model"
kind = "str"
placeholder = "CLI default model"
[[section.field]]
key = "binary"
kind = "str"
placeholder = "the profile's own binary"
description = "executable to spawn — an alias or wrapper of the same CLI (e.g. `cc` for a second Claude account); the profile still supplies the hook dialect and config path"
[[section.field]]
key = "extra_args"
kind = "args"
[[section.field]]
Expand Down Expand Up @@ -249,6 +254,11 @@ key = "model"
kind = "str"
placeholder = "inherit / client default"
[[section.field]]
key = "binary"
kind = "str"
placeholder = "inherit / profile binary"
description = "executable for this stage only — inherits from [adapter] when the stage runs the same client"
[[section.field]]
key = "extra_args"
kind = "args"
[[section.field]]
Expand Down
Loading