From 45f2622b6be24a04f14e5b9d8c98ea7d8ed58e2a Mon Sep 17 00:00:00 2001 From: Phil Date: Fri, 7 Aug 2026 21:25:23 -0400 Subject: [PATCH 1/8] feat(adapters): port Cursor CLI profile to current adapter seams --- src/bmad_loop/adapters/profile.py | 7 ++++ src/bmad_loop/data/profiles/cursor.toml | 18 +++++++++ src/bmad_loop/install.py | 51 ++++++++++++++++++++++++- src/bmad_loop/worktree_flow.py | 5 +++ tests/test_install.py | 20 ++++++++++ tests/test_profile.py | 8 +++- 6 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/bmad_loop/data/profiles/cursor.toml diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index 6de2af768..1ae3bc568 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -51,6 +51,8 @@ "gemini-settings-json", "copilot-settings-json", "antigravity-hooks-json", + "cursor-hooks-json", + "cursor-hooks-json", # hookless: the adapter observes completion itself (HTTP/SSE transport) — # no hook config is ever written, so config_path/events must stay empty. "none", @@ -183,6 +185,10 @@ class CLIProfile: # Provenance is that boundary; it answers "who wrote this", which is the # question actually being asked. packaged: bool = False + # cursor-agent blocks interactive launches in an untrusted workspace. The + # profile opts into seeding Cursor's workspace marker for the project and + # isolated worktrees before the session is spawned. + seed_workspace_trust: bool = False @property def hookless(self) -> bool: @@ -445,6 +451,7 @@ def str_list(key: str) -> tuple[str, ...]: first_run_note=str(doc.get("first_run_note", "")), seed_files=str_list("seed_files"), env_fault_patterns=str_list("env_fault_patterns"), + seed_workspace_trust=bool(doc.get("seed_workspace_trust", False)), ) _validate_profile(profile, source) return profile diff --git a/src/bmad_loop/data/profiles/cursor.toml b/src/bmad_loop/data/profiles/cursor.toml new file mode 100644 index 000000000..7f1a77d44 --- /dev/null +++ b/src/bmad_loop/data/profiles/cursor.toml @@ -0,0 +1,18 @@ +# Cursor CLI (cursor-agent), driven interactively in a tmux pane. Cursor's +# project hook file is versioned and emits the lower-cased events below; it does +# not provide a reliable session-end event, so window-death remains the crash +# fallback. Its transcript does not expose token totals on disk. +name = "cursor" +binary = "cursor-agent" +skill_tree = ".cursor/skills" +prompt_template = "{prompt}" +bypass_args = ["--force"] +model_flag = "--model" +usage_parser = "none" +seed_workspace_trust = true +first_run_note = "run `cursor-agent login` once (or set CURSOR_API_KEY); workspace trust is seeded automatically by init and worktree provisioning" + +[hooks] +dialect = "cursor-hooks-json" +config_path = ".cursor/hooks.json" +events = { sessionStart = "SessionStart", stop = "Stop" } diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 089b767e1..4c22baa73 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -22,6 +22,7 @@ import re import shutil import tomllib +from datetime import datetime, timezone from collections.abc import Iterable, Iterator, Sequence from contextlib import ExitStack from importlib import resources @@ -1072,6 +1073,10 @@ def _hook_entry(dialect: str, command: str) -> dict: if dialect == "copilot-settings-json": handler["timeoutSec"] = COPILOT_HOOK_TIMEOUT_SEC # Copilot timeouts are seconds return handler # Copilot stores the handler directly in the event list + if dialect == "cursor-hooks-json": + # Cursor uses the same versioned top-level shape as Copilot, but its + # event entries are bare command objects (no type/matcher wrapper). + return {"command": command} if dialect == "antigravity-hooks-json": handler["timeout"] = ANTIGRAVITY_HOOK_TIMEOUT_SEC # agy timeouts are seconds # agy's Stop event value is a flat list of handler objects — the handler @@ -1190,8 +1195,8 @@ def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tu handlers.append(_hook_entry(dialect, command)) changed = True return config, changed - if dialect == "copilot-settings-json": - config.setdefault("version", 1) # Copilot hook configs are versioned + if dialect in ("copilot-settings-json", "cursor-hooks-json"): + config.setdefault("version", 1) # Copilot and Cursor configs are versioned hooks = config.setdefault("hooks", {}) for native_event, command in registrations.items(): matchers = hooks.setdefault(native_event, []) @@ -2671,6 +2676,42 @@ def _warn_if_policy_tracked(project: Path) -> None: ) +CURSOR_TRUST_METHOD = "bmad-loop-seeded" + + +def _cursor_trust_slug(real_path: str) -> str: + """Cursor's per-workspace directory name for an absolute workspace path.""" + return real_path.lstrip("/").replace("/", "-") + + +def seed_workspace_trust(target: Path, home: Path | None = None) -> Path | None: + """Create Cursor's trust marker for *target* when it is not already present. + + Cursor resolves the working directory before looking up the marker, hence the + real path rather than the user-supplied spelling. We deliberately leave an + existing marker untouched: it belongs to Cursor/the operator, not bmad-loop. + """ + home = home or Path(os.path.expanduser("~")) + real = os.path.realpath(str(target)) + marker = home / ".cursor" / "projects" / _cursor_trust_slug(real) / ".workspace-trusted" + if marker.is_file(): + return None + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text( + json.dumps( + { + "trustedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), + "workspacePath": real, + "trustMethod": CURSOR_TRUST_METHOD, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return marker + + def install_into( project: Path, clis: Sequence[str] = ("claude",), @@ -2707,6 +2748,12 @@ def install_into( if _register_hooks(project, profile) != 0: return 1 + for profile in profiles: + if profile.seed_workspace_trust: + marker = seed_workspace_trust(project) + if marker is not None: + print(f" workspace trust seeded ({profile.name}): {marker}") + # 3. bundled skills into each CLI's skill tree (deduped: codex+gemini share # .agents/skills) skills_skipped = False diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index f4e5d503b..5ea7cd491 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -58,6 +58,7 @@ renderer_stub_resolved, resolve_review_layers, strip_relay_hooks, + seed_workspace_trust, ) from .model import Phase from .platform_util import atomic_write_text @@ -991,6 +992,10 @@ def provision_worktree( if pin_degrade is not None and on_degraded is not None: on_degraded(pin_degrade) + for profile in profiles: + if profile.seed_workspace_trust: + seed_workspace_trust(worktree) + # Shield exactly the paths we wrote (skill trees + hook configs + seeded # configs) from the unit's `git add -A`, in case a project doesn't gitignore # its tool dirs. Scoped to this worktree and expiring with it — these are diff --git a/tests/test_install.py b/tests/test_install.py index 1b4ed61da..3e109e779 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -42,6 +42,7 @@ RENDERER_ENTRY_REL, RENDERER_SCRIPT_REL, SNAPSHOT_TOKEN_RE, + _cursor_trust_slug, _absent_renderer_sources, _copy_traversable, _is_dev_primitive_shim, @@ -59,6 +60,7 @@ resolve_dev_primitive, resolve_review_layers, strip_relay_hooks, + seed_workspace_trust, ) from bmad_loop.worktree_flow import ( _bmad_scripts_seed_incomplete, @@ -146,6 +148,24 @@ def test_merge_hooks_adds_all_events(): assert set(profile.hooks.events) <= set(settings["hooks"]) +def test_merge_hooks_cursor_uses_bare_versioned_entries(): + profile = get_profile("cursor") + settings, changed = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + assert changed is True + assert settings["version"] == 1 + assert settings["hooks"]["stop"] == [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}] + + +def test_cursor_workspace_trust_is_copy_when_absent(tmp_path): + home, target = tmp_path / "home", tmp_path / "project" + target.mkdir() + marker = seed_workspace_trust(target, home=home) + real = os.path.realpath(str(target)) + assert marker == home / ".cursor" / "projects" / _cursor_trust_slug(real) / ".workspace-trusted" + assert json.loads(marker.read_text(encoding="utf-8"))["workspacePath"] == real + assert seed_workspace_trust(target, home=home) is None + + def test_merge_hooks_idempotent(): profile = get_profile("claude") settings, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) diff --git a/tests/test_profile.py b/tests/test_profile.py index ee104fd7c..0d227930d 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -36,7 +36,7 @@ def test_builtin_profiles_load(): profiles = load_profiles() - assert {"claude", "codex", "gemini", "opencode-http"} <= set(profiles) + assert {"claude", "codex", "gemini", "cursor", "opencode-http"} <= set(profiles) assert profiles["claude"].usage_parser == "claude-jsonl" assert profiles["codex"].hooks.dialect == "codex-hooks-json" assert "SessionEnd" not in profiles["codex"].hooks.events # codex has no such hook @@ -46,6 +46,12 @@ def test_builtin_profiles_load(): assert profiles["claude"].skill_tree == ".claude/skills" assert profiles["codex"].skill_tree == ".agents/skills" assert profiles["gemini"].skill_tree == ".agents/skills" + cursor = profiles["cursor"] + assert cursor.binary == "cursor-agent" + assert cursor.skill_tree == ".cursor/skills" + assert cursor.hooks.dialect == "cursor-hooks-json" + assert cursor.hooks.events == {"sessionStart": "SessionStart", "stop": "Stop"} + assert cursor.seed_workspace_trust is True # each profile carries the gitignored configs a worktree checkout omits assert ".mcp.json" in profiles["claude"].seed_files assert ".claude/settings.json" in profiles["claude"].seed_files From b0f345c92b6ce7fa35d6dddd21034b724f747f91 Mon Sep 17 00:00:00 2001 From: Phil Date: Sun, 23 Aug 2026 21:23:51 -0400 Subject: [PATCH 2/8] fix(cursor): register hook dialect once --- src/bmad_loop/adapters/profile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index 1ae3bc568..cdefef979 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -52,7 +52,6 @@ "copilot-settings-json", "antigravity-hooks-json", "cursor-hooks-json", - "cursor-hooks-json", # hookless: the adapter observes completion itself (HTTP/SSE transport) — # no hook config is ever written, so config_path/events must stay empty. "none", From 99c72f4d4be098418925c64ca9acba1b377bde39 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Thu, 3 Sep 2026 15:51:39 -0400 Subject: [PATCH 3/8] feat(adapters): add Cursor CLI (cursor-agent) profile bmad-loop could not drive Cursor. This adds a packaged `cursor` profile and a `cursor-hooks-json` hook dialect, so the generic adapter runs it with no Python. Skills load from .cursor/skills/. The relay registers `sessionStart` and `stop` in a project .cursor/hooks.json. That file is versioned and its entries are bare {"command": ...} objects, so merge_hooks always writes a top-level `version`: Cursor 3.x loads none of a project file's hooks without one, which reads as a session timeout rather than as an error anyone can see. Launch flags are `--force --trust`. Measured against cursor-agent 2026.08.04, each in a fresh untrusted git dir launched interactively under tmux: bare -> blocks on the workspace-trust dialog --force -> still blocks --force --trust -> runs seeded trust marker -> still blocks That last row is why this drops the earlier approach of writing Cursor's ~/.cursor/projects//.workspace-trusted marker. The marker is real, but replaying a byte-correct copy does not satisfy the gate, so that code did not do what it claimed. Dropping it also removes the `seed_workspace_trust` profile field, the install-time writer, and the only code that wrote outside the project. Because `--trust` applies per launch, isolation = "worktree" works here, unlike antigravity. The prompt is handed over as an argv positional, so a leading "/" never reaches Cursor's slash menu. The template names the SKILL.md outright, as codex and copilot do. usage_parser stays "none". The Stop payload names a transcript, but its token schema is unread, so nothing is claimed about it. Marked experimental: no full dev/review loop has been run end to end. Finalize with `probe-adapter cursor`. Co-authored-by: Cursor --- CHANGELOG.md | 13 +++++ README.md | 11 ++-- docs/FEATURES.md | 3 +- docs/adapter-authoring-guide.md | 5 +- docs/setup-guide.md | 19 ++++++- src/bmad_loop/adapters/profile.py | 5 -- src/bmad_loop/cli.py | 4 +- src/bmad_loop/data/profiles/cursor.toml | 67 +++++++++++++++++++--- src/bmad_loop/install.py | 65 +++++----------------- src/bmad_loop/policy.py | 2 +- src/bmad_loop/worktree_flow.py | 5 -- tests/test_install.py | 74 ++++++++++++++++++++----- tests/test_profile.py | 14 ++++- 13 files changed, 189 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2f66f07..637435258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ breaking changes may land in a minor release. ### Added +- **Cursor CLI (`cursor-agent`) profile** — a packaged `cursor` profile plus a new + `cursor-hooks-json` hook dialect, so the generic adapter drives Cursor with no Python. + Skills live in `.cursor/skills/`; the relay registers `sessionStart` and `stop` in a project + `.cursor/hooks.json`. That file is versioned and its entries are bare `{"command": …}` + objects — Cursor 3.x loads no hooks from a project file lacking a numeric top-level + `version`, so `merge_hooks` always writes one. Launches with `--force --trust`: an + interactive launch in an untrusted directory blocks on a workspace-trust dialog no + unattended session can answer, and `--force` alone does not clear it. Setting + `[adapter] extra_args` replaces the bypass flags, so it must keep `--trust`. Trust is + granted per launch, so `isolation = "worktree"` works. `usage_parser = "none"` pending a + transcript-schema probe. Experimental — verified against cursor-agent 2026.08.04, not yet + run through a full loop; finalize with `probe-adapter cursor`. + - **Review-gate verify commands are journalled** (#656, partial). The three review gates (`verify_review`, `verify_review_stories`, `verify_review_bundle`) now emit one `verify-command-result` per command, `verification_stage: "review"`, sharing the story's diff --git a/README.md b/README.md index 05cacb278..78773657e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Plain Python drives the loop — **pick story → implement → adversarially re ![Status: early open beta](https://img.shields.io/badge/status-early%20open%20beta-orange) [![CI](https://github.com/bmad-code-org/bmad-loop/actions/workflows/ci.yml/badge.svg)](https://github.com/bmad-code-org/bmad-loop/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/python-3.11%E2%80%933.14-blue) -![CLIs](https://img.shields.io/badge/agents-claude%20%C2%B7%20codex%20%C2%B7%20gemini%20%C2%B7%20copilot%20%C2%B7%20antigravity%20%C2%B7%20opencode-8a2be2) +![CLIs](https://img.shields.io/badge/agents-claude%20%C2%B7%20codex%20%C2%B7%20gemini%20%C2%B7%20copilot%20%C2%B7%20cursor%20%C2%B7%20antigravity%20%C2%B7%20opencode-8a2be2) ![No LLM in the loop](https://img.shields.io/badge/control%20loop-deterministic-success) ![License: MIT](https://img.shields.io/badge/license-MIT-green) @@ -45,12 +45,12 @@ Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad- - 🔍 **Trust nothing, verify everything.** After each session the orchestrator checks artifacts on disk: spec frontmatter status, baseline-commit validity, non-empty diff, sprint-status sync, and _your_ test/lint commands before any commit. An exact recorded baseline passes; so does a uniquely resolved immutable descendant that is reachable from `HEAD`, but its proof is measured after that commit and must be tracked, staged, or committed — untracked-only residue does not count. Deferred-work bundles retain their older-ancestor exception. In the default shared checkout, later tracked changes prove work exists but cannot identify which session made it; `[scm] isolation = "worktree"` preserves that provenance. - 📒 **One source of truth.** `sprint-status.yaml` is the workflow ledger: the loop's dev skill flips only its story spec's status, the orchestrator mirrors that onto the board through a single idempotent, never-regress writer, and verification re-checks the stage after every session. - 🪟 **Fresh context per step.** Dev and review are separate sessions — review never inherits the implementer's context, so there's no anchoring bias. -- ♻️ **Resumable & multi-agent.** Every run is a resumable state machine on disk, and a generic tmux adapter drives `claude`, `codex`, `gemini`, `copilot`, or `antigravity` (mix per stage). +- ♻️ **Resumable & multi-agent.** Every run is a resumable state machine on disk, and a generic tmux adapter drives `claude`, `codex`, `gemini`, `copilot`, `cursor`, or `antigravity` (mix per stage). - 🌿 **Optional worktree isolation.** Opt in (`[scm] isolation = "worktree"`) and each story runs in its own git worktree/branch and merges back locally — your main checkout stays free while a run is in flight. ## Requirements -- **Python 3.11+**, a **terminal multiplexer** (tmux is the bundled default; 3.2 is the supported minimum, not enforced at selection), **git 2.34 or newer** (the supported minimum, and this one _is_ enforced — `run`, `sweep` and `resume` refuse to start below it and `validate` reports it as a problem), and a supported coding CLI — `claude` by default; `codex`, `gemini`, `copilot`, and `antigravity` (`agy`) via [profiles](#other-coding-clis). +- **Python 3.11+**, a **terminal multiplexer** (tmux is the bundled default; 3.2 is the supported minimum, not enforced at selection), **git 2.34 or newer** (the supported minimum, and this one _is_ enforced — `run`, `sweep` and `resume` refuse to start below it and `validate` reports it as a problem), and a supported coding CLI — `claude` by default; `codex`, `gemini`, `copilot`, `cursor` (`cursor-agent`), and `antigravity` (`agy`) via [profiles](#other-coding-clis). - **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the bundled terminal-multiplexer backend (externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) co-install as packages and self-register — see [Terminal multiplexer backends](docs/multiplexer-backends.md)), and all of 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 (`psmux` on Windows, `tmux` elsewhere) → 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`) on **BMAD-METHOD ≥ 6.10.0**, with three skill sets installed (standard BMAD skills stay untouched): - the upstream dev primitive — `bmad-build-auto`, or a complete `bmad-dev-auto` on pre-rename releases. bmad-loop drives whichever is on disk under that name, so either era works with no config edit; the bare forwarding shim the rename leaves behind is refused as incomplete — it has neither `step-04-review.md` nor `customize.toml` — because a session dispatched into it stalls on an interactive migration gate. @@ -436,7 +436,7 @@ skill = "bmad-dev-auto" # the only supported value — the generic upstream d # No settings-schema entry: edit it here, not in the TUI editor. [adapter] -name = "claude" # CLI profile: claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | custom +name = "claude" # CLI profile: claude | codex | gemini | copilot | cursor | antigravity | opencode-http (alias: opencode) | custom model = "" # empty = CLI default (opencode-http wants "provider/model") cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection) # extra_args replaces the profile's default bypass flags when set: @@ -644,6 +644,7 @@ One generic driver (`adapters/generic.py`) runs any coding CLI that fits the inj | `codex` | supported, E2E-verified | Codex ≥ 0.139. No slash expansion in the initial prompt — the profile renders `$skill-name` mentions (plus a "use subagents as needed" nudge) instead. No SessionEnd hook; window-death fallback covers crashes. | | `gemini` | supported, E2E-verified | Gemini CLI ≥ 0.46 (hooks on by default since then). Launches with `-i` to stay interactive; `AfterAgent` maps to canonical Stop. Usage parser validated against real chat logs. | | `copilot` | supported, E2E-verified | GitHub Copilot **CLI** (the `copilot` binary, GA ≥ 2026-02) — _not_ the VS Code extension. Launches with `-i` to stay interactive; turn-end is `agentStop` (per response turn); `--allow-all-tools` for unattended runs. `copilot-events` usage parser reads token totals from the trailing `session.shutdown` line, so the profile waits a short grace (`usage_grace_s = 8`) before tallying. **Pin a capable model** (see below). | +| `cursor` | experimental | **Cursor CLI** (`cursor-agent`), verified against 2026.08.04. Skills live in `.cursor/skills/`; hooks in a project `.cursor/hooks.json`, whose top-level `version` is required — without it Cursor 3.x loads no hooks at all. `stop` is the turn-end event, `sessionStart` marks liveness. **`--trust` is mandatory for unattended runs**: an interactive launch in an untrusted directory blocks on a workspace-trust dialog and `--force` alone does _not_ clear it, so the profile ships `--force --trust` in its bypass flags — if you set `[adapter] extra_args` (which _replaces_ them) you must keep `--trust`. Because the flag is per-launch, `isolation = "worktree"` works, unlike antigravity. `usage_parser = "none"` for now: the Stop payload names a transcript, but its token schema is unread. Finalize with `probe-adapter cursor`. | | `antigravity` | experimental — `isolation = "none"` only | Google **Antigravity CLI** (`agy` ≥ 1.1.3). Launches with `-i` to stay interactive; `Stop` is the turn-end event (agy has no SessionStart/SessionEnd hook). Skills and hooks live in `.agents/` (flat `Stop` handler in `.agents/hooks.json`, keyed by hook-group name). Hook payloads are protojson/camelCase. **Trust is exact-path**: `agy` blocks on a "trust this folder" dialog for any workspace not listed verbatim in `settings.json` `trustedWorkspaces`, and `--dangerously-skip-permissions` does not bypass it — so `isolation = "worktree"` hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). `usage_parser = "none"` is permanent, not pending: agy's transcript carries no usage data (tokens live only in an internal SQLite/protobuf store), so runs work but token columns stay empty. Verify against your build with `probe-adapter antigravity`. | | `opencode` | supported, E2E-verified | **OpenCode** ≥ 1.18 (profile `opencode-http`), driven over HTTP/SSE — one headless `opencode serve` per session, **no tmux window**. Needs the extra: `pip install 'bmad-loop[opencode]'`. Auth once globally with `opencode auth login`; skills live in `.claude/skills/`; set `model` as `provider/model` (e.g. `anthropic/claude-haiku-4-5`). Watch sessions via `run_dir/logs/.log` or the TUI Log tab; `resolve` is `--no-interactive` only; the Unity plugin's window guards are unsupported here. | @@ -653,7 +654,7 @@ One generic driver (`adapters/generic.py`) runs any coding CLI that fits the inj **Shared prerequisites:** the `bmad-loop-*` skills must be present in `.agents/skills/` (codex and gemini read it; Claude Code reads `.claude/skills/`), and each CLI must have been run once interactively in the project for auth/trust — `bmad-loop init --cli codex --cli gemini` installs the skills into `.agents/skills/`, registers the hook relay, and prints the per-CLI first-run steps. -**Adding a CLI without touching Python:** drop a TOML file in `/.bmad-loop/profiles/.toml` with at minimum a binary, `prompt_template`, bypass flags, and a `[hooks]` block picking one of the config dialects (`claude-settings-json` / `codex-hooks-json` / `gemini-settings-json` / `copilot-settings-json` / `antigravity-hooks-json`) plus a native→canonical event map. Every `CLIProfile` / `HookSpec` field and its default lives in the **[Profile field reference](docs/adapter-authoring-guide.md#profile-field-reference)**. The hook relay and orchestrator are CLI-agnostic — each registration passes the canonical event name as the script argument — so a CLI cloning an existing dialect needs nothing else; a genuinely different transport gets its own adapter class ([how](docs/adapter-authoring-guide.md#writing-a-new-adapter-class); worked example: `adapters/opencode_http.py`). +**Adding a CLI without touching Python:** drop a TOML file in `/.bmad-loop/profiles/.toml` with at minimum a binary, `prompt_template`, bypass flags, and a `[hooks]` block picking one of the config dialects (`claude-settings-json` / `codex-hooks-json` / `gemini-settings-json` / `copilot-settings-json` / `cursor-hooks-json` / `antigravity-hooks-json`) plus a native→canonical event map. Every `CLIProfile` / `HookSpec` field and its default lives in the **[Profile field reference](docs/adapter-authoring-guide.md#profile-field-reference)**. The hook relay and orchestrator are CLI-agnostic — each registration passes the canonical event name as the script argument — so a CLI cloning an existing dialect needs nothing else; a genuinely different transport gets its own adapter class ([how](docs/adapter-authoring-guide.md#writing-a-new-adapter-class); worked example: `adapters/opencode_http.py`). **Finalizing a profile:** the facts a profile needs that live in no doc — the CLI's exact hook payload shape, its transcript location/format, and the token schema a `usage_parser` reads — are collected and sanitized by `bmad-loop probe-adapter ` (a zero-launch scan by default, or `--probe` for a live capture). The [adapter authoring guide](docs/adapter-authoring-guide.md) walks through using it end to end. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 50bc2cb3f..2eb975b1b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -21,7 +21,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se | Human-gated stories (`awaiting-operator`) | A story owing external actions only a human can take commits its agent-doable work, records what is owed, and parks; `bmad-loop confirm` completes it later | Work needing a domain purchase or a DNS record had no honest terminal state — `done` hid it behind a green board, `blocked` halted the run | | Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | | Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | -| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/antigravity/opencode; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux + experimental native-Windows psmux bundled; external backends via entry points) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | +| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/cursor/antigravity/opencode; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux + experimental native-Windows psmux bundled; external backends via entry points) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | | Cost-weighted token budgets | Mid-session per-session guard (`warn`/`enforce` with a wrap-up grace, sampled every ~30s) plus the advisory per-story cap; both count cache reads at ~0.1x; every display leads with the weighted total and names both units | A runaway-but-busy session was bounded only by the wall clock; naive token caps misjudge real cost (cache reads dominate) | | Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; your BMAD install is never modified, and `sprint-status.yaml` is the one board it writes while a run is in flight — the sessions it dispatches never do | Modifying a user's standard BMAD install | | Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | @@ -222,6 +222,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - 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). - 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/.log` (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in `.server.out`, and a structured SSE trace in `.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: `cursor` (Cursor CLI `cursor-agent`, verified against 2026.08.04) — interactive launch, `stop` turn-end hook and `sessionStart` in a project `.cursor/hooks.json` (versioned file, bare `{"command": …}` entries; Cursor 3.x loads no hooks from a project file with no top-level `version`), skills in `.cursor/skills/`, snake_case hook payloads. Launches with `--force --trust`: the trust flag is required, because an interactive launch in an untrusted directory blocks on a workspace-trust dialog that `--force` alone does not clear and that seeding Cursor's own `.workspace-trusted` marker does not satisfy either. Since trust is granted per launch rather than per stored path, `isolation = "worktree"` works (unlike antigravity). `[adapter] extra_args` replaces the bypass flags, so it must keep `--trust`. `usage_parser = "none"` pending a transcript-schema probe. Verify with `probe-adapter cursor`. - 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]`). - Add a CLI without touching Python: drop a TOML profile in `.bmad-loop/profiles/.toml` (binary, prompt template, bypass flags, hook dialect, native→canonical event map). A CLI that needs its own adapter _class_ still needs Python — but not a core edit: the profile's `adapter` field names a kind resolved against the registry, which a co-installed package extends. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 883f585f6..88a82107d 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -360,7 +360,8 @@ Drop a TOML file in `/.bmad-loop/profiles/.toml` with the fields from the [Profile field reference](#profile-field-reference) below. The minimum is a `binary`, a `prompt_template`, bypass flags, a `[hooks]` block picking one of the config dialects (`claude-settings-json` / `codex-hooks-json` / -`gemini-settings-json` / `copilot-settings-json` / `antigravity-hooks-json`) and +`gemini-settings-json` / `copilot-settings-json` / `cursor-hooks-json` / +`antigravity-hooks-json`) and a native→canonical event map, and a `usage_parser` (start with `"none"` until you've written one). @@ -505,7 +506,7 @@ resolves to `claude`. | Field | Required | Meaning | | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dialect` | ✅ | The CLI's hook-config format — one of `claude-settings-json`, `codex-hooks-json`, `gemini-settings-json`, `copilot-settings-json`, `antigravity-hooks-json`. | +| `dialect` | ✅ | The CLI's hook-config format — one of `claude-settings-json`, `codex-hooks-json`, `gemini-settings-json`, `copilot-settings-json`, `cursor-hooks-json`, `antigravity-hooks-json`. | | `config_path` | ✅ | Project-relative path the hook config is written to (e.g. `.claude/settings.json`). Absolute paths are rejected. | | `events` | ✅ | Map of **native** event name → **canonical** event name. The canonical side must be one of `SessionStart`, `Stop`, `SessionEnd`, `PreCompact`; the native side is whatever the CLI emits (e.g. `agentStop = "Stop"`). At least one entry. | diff --git a/docs/setup-guide.md b/docs/setup-guide.md index f30138f1f..0b92bcbf4 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -180,6 +180,7 @@ would not carry. ## Choosing which CLIs to drive The supported adapters are `claude` (the default), `codex`, `gemini`, `copilot`, +`cursor` (Cursor's `cursor-agent`, experimental), `antigravity` (Google's `agy`, experimental — `isolation = "none"` only), and `opencode` (OpenCode ≥ 1.18 over HTTP/SSE, profile `opencode-http` — no tmux window; needs the `bmad-loop[opencode]` extra and `model` set as `provider/model`). You can pick more @@ -260,7 +261,7 @@ bmad-loop init --project --cli claude --cli codex --cli gemini Run with no `--cli` and `init` registers hooks for every CLI the `policy.toml` references, so a dual-client setup that's already configured in policy needs no extra flags. Names must -be exactly `claude`, `codex`, `gemini`, `copilot`, `antigravity`, or `opencode-http` (alias +be exactly `claude`, `codex`, `gemini`, `copilot`, `cursor`, `antigravity`, or `opencode-http` (alias `opencode`) — `init` errors on an unknown profile and lists the valid ones. A hookless profile like `opencode-http` installs its skills but registers no hooks (it signals over HTTP/SSE). @@ -282,6 +283,18 @@ them to whoever owns the machine: subscription). Requires the Copilot **CLI** GA (≥ 2026-02) — _not_ the VS Code extension. **Pin a capable model**: the free default (GPT-5 mini) silently skips steps in the multi-step dev/review skills; set `[adapter] model = "claude-sonnet-4-6"` (→ `--model`). +- **cursor** — authenticate once with `cursor-agent login` (or set `CURSOR_API_KEY`). + Verified against Cursor CLI 2026.08.04. Two things to know: + - **`--trust` is what makes unattended runs work.** An interactive `cursor-agent` in a + directory it has not been told to trust blocks on a "Workspace Trust Required" dialog, + and `-f`/`--force` alone does **not** clear it — measured, along with the fact that + hand-seeding Cursor's own `~/.cursor/projects//.workspace-trusted` marker does not + either. The profile therefore launches with `--force --trust`. If you set + `[adapter] extra_args`, it **replaces** the profile's bypass flags, so keep `--trust` in + it or every session will hang until `session_timeout_min`. Because the flag applies per + launch, `isolation = "worktree"` works here — unlike antigravity. + - **Token usage is not recorded yet** (`usage_parser = "none"`). The Stop payload carries a + `transcript_path`, but its token schema has not been read, so the columns stay empty. - **antigravity** — run `agy` once in the project, authenticate, and answer **"Yes, I trust this folder"** before `bmad-loop run`; spawned sessions can't answer that dialog, and a pending one reads as a session timeout. Requires Antigravity CLI @@ -307,8 +320,8 @@ them to whoever owns the machine: ### Skill location -`claude` reads skills from `.claude/skills/`; `codex`, `gemini`, `copilot`, and `antigravity` -read from `.agents/skills/`. `init` installs the bundled `bmad-loop-*` skills into the right tree +`claude` reads skills from `.claude/skills/`; `cursor` reads from `.cursor/skills/`; `codex`, +`gemini`, `copilot`, and `antigravity` read from `.agents/skills/`. `init` installs the bundled `bmad-loop-*` skills into the right tree for each CLI you pass via `--cli`, so selecting any of the `.agents/skills/` CLIs populates it automatically. It skips skill dirs that already exist — pass `--force-skills` to overwrite a stale copy, or `--no-skills` to manage them yourself. diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index 495979288..1790b5aa4 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -189,10 +189,6 @@ class CLIProfile: # Provenance is that boundary; it answers "who wrote this", which is the # question actually being asked. packaged: bool = False - # cursor-agent blocks interactive launches in an untrusted workspace. The - # profile opts into seeding Cursor's workspace marker for the project and - # isolated worktrees before the session is spawned. - seed_workspace_trust: bool = False @property def hookless(self) -> bool: @@ -470,7 +466,6 @@ def str_list(key: str) -> tuple[str, ...]: first_run_note=str(doc.get("first_run_note", "")), seed_files=str_list("seed_files"), env_fault_patterns=str_list("env_fault_patterns"), - seed_workspace_trust=bool(doc.get("seed_workspace_trust", False)), ) _validate_profile(profile, source) return profile diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ef81a5a19..af65794d8 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4647,7 +4647,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: action="append", metavar="PROFILE", help="CLI profile(s) to register hooks for (claude | codex | gemini | copilot | " - "antigravity | opencode-http (alias: opencode) | custom; " + "cursor | antigravity | opencode-http (alias: opencode) | custom; " "repeatable; default: profiles referenced by .bmad-loop/policy.toml, or claude)", ) init_p.add_argument( @@ -4708,7 +4708,7 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: ) probe_p.add_argument( "cli", - help="CLI profile name (claude | codex | gemini | copilot | antigravity | custom; " + help="CLI profile name (claude | codex | gemini | copilot | cursor | antigravity | custom; " "opencode-http is HTTP-driven — nothing to probe)", ) probe_p.add_argument( diff --git a/src/bmad_loop/data/profiles/cursor.toml b/src/bmad_loop/data/profiles/cursor.toml index 7f1a77d44..0905562b9 100644 --- a/src/bmad_loop/data/profiles/cursor.toml +++ b/src/bmad_loop/data/profiles/cursor.toml @@ -1,16 +1,67 @@ -# Cursor CLI (cursor-agent), driven interactively in a tmux pane. Cursor's -# project hook file is versioned and emits the lower-cased events below; it does -# not provide a reliable session-end event, so window-death remains the crash -# fallback. Its transcript does not expose token totals on disk. +# Cursor CLI (`cursor-agent`), driven interactively in a mux pane. Verified +# against cursor-agent 2026.08.04 on macOS. EXPERIMENTAL: the hook dialect, +# trust gate and skill tree below were each checked against a live binary, but no +# full dev/review loop has been run end to end — finalize with +# `bmad-loop probe-adapter cursor --probe` before trusting it unattended. +# +# TRUST (measured, 4-cell matrix, each in a fresh untrusted git dir launched +# interactively under tmux — the shape bmad-loop actually spawns): +# bare -> blocks on a "Workspace Trust Required" dialog +# --force -> STILL blocks (the flag covers tool permissions only) +# --force --trust -> passes straight through to the session +# seeded trust marker -> STILL blocks +# So `--trust` is the only thing that clears the gate for an unattended launch, +# and it is in bypass_args for that reason, not merely for tidiness. The marker +# row is the one worth writing down: `~/.cursor/projects// +# .workspace-trusted` is real and is what Cursor writes when a human answers the +# dialog, but replaying a byte-correct copy of one does NOT satisfy the gate, so +# seeding it is not a substitute. NOTE: `extra_args` in policy.toml REPLACES +# bypass_args — set it and you drop `--trust`, and every session hangs on the +# dialog until session_timeout_min. Unlike antigravity's exact-path trust (#169), +# a per-launch flag costs nothing per worktree, so `isolation = "worktree"` works. +# +# HOOKS: project-level .cursor/hooks.json. The top-level `version` is REQUIRED — +# Cursor 3.x refuses a project hook file without a numeric one and loads none of +# its hooks, which would read as a session timeout rather than an error (see +# merge_hooks). Event entries are bare {"command": ...} objects: no matcher +# wrapper, no nested "hooks" list, no "type" key, and no timeout field. +# `stop` fires when the agent loop ends and is the turn-end signal; `sessionStart` +# marks liveness. Cursor also documents `sessionEnd` and `preCompact`, and they +# are deliberately NOT registered: nothing derives completion from them, and +# whether cursor-agent (as opposed to the IDE) fires them is unverified here. +# Payload keys are snake_case (conversation_id, transcript_path), which the +# shared relay already reads. Cursor sends no `cwd` — it sends `workspace_roots`, +# a list the relay does not unpack, so the event's cwd is empty. Nothing consumes +# that field, so this is recorded, not deferred work. +# +# PROMPT: the prompt is handed over as an argv positional, never typed into the +# pane, so a leading "/" never reaches Cursor's slash menu and must not be relied +# on to expand. The explicit LOAD form is used for the same reason codex and +# copilot use it — it names the file and cannot depend on command expansion. +# +# usage_parser is "none": the Stop payload carries a `transcript_path`, but its +# token schema has not been read, so nothing is claimed about it. Runs work; the +# token columns stay empty until a probe supplies the schema. name = "cursor" binary = "cursor-agent" skill_tree = ".cursor/skills" -prompt_template = "{prompt}" -bypass_args = ["--force"] +prompt_template = "LOAD the FULL .cursor/skills/{skill}/SKILL.md, read its entire contents and follow its directions exactly, using subagents as needed: {args}" +bypass_args = ["--force", "--trust"] model_flag = "--model" usage_parser = "none" -seed_workspace_trust = true -first_run_note = "run `cursor-agent login` once (or set CURSOR_API_KEY); workspace trust is seeded automatically by init and worktree provisioning" +first_run_note = "run `cursor-agent login` once (or set CURSOR_API_KEY). Requires Cursor CLI with a `--trust` flag (verified on 2026.08.04). Do not set [adapter] extra_args unless it also carries `--trust`: it replaces bypass_args, and without that flag every unattended session hangs on the workspace-trust dialog." +# Gitignored configs a worktree checkout omits: project MCP servers +# (.cursor/mcp.json) and the hook file itself. .cursor/hooks.json is also the +# hook config_path — it is seeded first, then the relay is merged into it, so a +# worktree keeps any hooks the project already had (see provision_worktree). +seed_files = [ + ".cursor/mcp.json", + ".cursor/hooks.json", +] + +# No env_fault_patterns: no cursor-agent transport-failure line has been +# captured, and on a pane capture that vocabulary is what a story implementing +# error handling prints. Seed one only from a captured line, citing its run. [hooks] dialect = "cursor-hooks-json" diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 79227d829..83c11f73d 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -22,7 +22,6 @@ import re import shutil import tomllib -from datetime import datetime, timezone from collections.abc import Iterable, Iterator, Sequence from contextlib import ExitStack from importlib import resources @@ -1074,8 +1073,10 @@ def _hook_entry(dialect: str, command: str) -> dict: handler["timeoutSec"] = COPILOT_HOOK_TIMEOUT_SEC # Copilot timeouts are seconds return handler # Copilot stores the handler directly in the event list if dialect == "cursor-hooks-json": - # Cursor uses the same versioned top-level shape as Copilot, but its - # event entries are bare command objects (no type/matcher wrapper). + # Cursor's event entries are BARE command objects: no matcher wrapper, no + # nested "hooks" list, and no "type" key either — the shape read off a + # live ~/.cursor/hooks.json and matching Cursor's published example. It + # takes no timeout key, so the handler built above is discarded whole. return {"command": command} if dialect == "antigravity-hooks-json": handler["timeout"] = ANTIGRAVITY_HOOK_TIMEOUT_SEC # agy timeouts are seconds @@ -1144,9 +1145,9 @@ def strip_relay_hooks(config: dict, dialect: str) -> bool: continue # claude/codex/gemini wrap commands in a nested "hooks" list, and a # user may have added their own command beside the relay inside ONE - # matcher entry — strip inside the list so theirs survives. copilot - # and agy store the command dict flat in the event list, so a marker - # match means the entry IS the relay and it drops whole. + # matcher entry — strip inside the list so theirs survives. copilot, + # cursor and agy store the command dict flat in the event list, so a + # marker match means the entry IS the relay and it drops whole. nested = handler.get("hooks") if isinstance(handler, dict) else None if isinstance(nested, list): surviving = [c for c in nested if RELAY_MARKER not in json.dumps(c)] @@ -1196,12 +1197,16 @@ def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tu changed = True return config, changed if dialect in ("copilot-settings-json", "cursor-hooks-json"): - config.setdefault("version", 1) # Copilot and Cursor configs are versioned + # Both are versioned. For cursor this is REQUIRED, not cosmetic: Cursor 3.x + # refuses a project-level .cursor/hooks.json with no numeric top-level + # `version` and loads NONE of its hooks, so omitting it would register a + # relay that never fires and read as a session timeout. + config.setdefault("version", 1) hooks = config.setdefault("hooks", {}) for native_event, command in registrations.items(): matchers = hooks.setdefault(native_event, []) - # claude/codex/gemini nest handlers under "hooks"; copilot stores the - # handler dict directly in the event list — the serialized scan covers + # claude/codex/gemini nest handlers under "hooks"; copilot and cursor store + # the handler dict directly in the event list — the serialized scan covers # both shapes so a re-run stays idempotent for every dialect. if not _managed_hook_in_handlers(matchers): matchers.append(_hook_entry(dialect, command)) @@ -2832,42 +2837,6 @@ def _warn_if_policy_tracked(project: Path) -> None: ) -CURSOR_TRUST_METHOD = "bmad-loop-seeded" - - -def _cursor_trust_slug(real_path: str) -> str: - """Cursor's per-workspace directory name for an absolute workspace path.""" - return real_path.lstrip("/").replace("/", "-") - - -def seed_workspace_trust(target: Path, home: Path | None = None) -> Path | None: - """Create Cursor's trust marker for *target* when it is not already present. - - Cursor resolves the working directory before looking up the marker, hence the - real path rather than the user-supplied spelling. We deliberately leave an - existing marker untouched: it belongs to Cursor/the operator, not bmad-loop. - """ - home = home or Path(os.path.expanduser("~")) - real = os.path.realpath(str(target)) - marker = home / ".cursor" / "projects" / _cursor_trust_slug(real) / ".workspace-trusted" - if marker.is_file(): - return None - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text( - json.dumps( - { - "trustedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), - "workspacePath": real, - "trustMethod": CURSOR_TRUST_METHOD, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - return marker - - def install_into( project: Path, clis: Sequence[str] = ("claude",), @@ -2904,12 +2873,6 @@ def install_into( if _register_hooks(project, profile) != 0: return 1 - for profile in profiles: - if profile.seed_workspace_trust: - marker = seed_workspace_trust(project) - if marker is not None: - print(f" workspace trust seeded ({profile.name}): {marker}") - # 3. bundled skills into each CLI's skill tree (deduped: codex+gemini share # .agents/skills) skills_skipped = False diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index 63c10f99f..5f77e84ae 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -1347,7 +1347,7 @@ def _fold_deprecated_engine( spec_folder = "" [adapter] -name = "claude" # claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | +name = "claude" # claude | codex | gemini | copilot | cursor | antigravity | opencode-http (alias: opencode) | model = "" # empty = CLI default model (opencode-http wants "provider/model") cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection) # extra_args replaces the profile's default permission-bypass flags when set: diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index b5e787db9..ba2fa42f2 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -58,7 +58,6 @@ renderer_stub_resolved, resolve_review_layers, strip_relay_hooks, - seed_workspace_trust, ) from .model import Phase from .platform_util import atomic_write_text @@ -1187,10 +1186,6 @@ def provision_worktree( if pin_degrade is not None and on_degraded is not None: on_degraded(pin_degrade) - for profile in profiles: - if profile.seed_workspace_trust: - seed_workspace_trust(worktree) - # Shield exactly the paths we wrote (skill trees + hook configs + seeded # configs) from the unit's `git add -A`, in case a project doesn't gitignore # its tool dirs. Scoped to this worktree and expiring with it — these are diff --git a/tests/test_install.py b/tests/test_install.py index f1dd3d52c..3b5641185 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -42,7 +42,6 @@ RENDERER_ENTRY_REL, RENDERER_SCRIPT_REL, SNAPSHOT_TOKEN_RE, - _cursor_trust_slug, _absent_renderer_sources, _copy_traversable, _is_dev_primitive_shim, @@ -56,11 +55,11 @@ missing_base_skills, missing_stories_support, provision_worktree, + relay_registered, renderer_stub_resolved, resolve_dev_primitive, resolve_review_layers, strip_relay_hooks, - seed_workspace_trust, ) from bmad_loop.worktree_flow import ( _bmad_scripts_seed_incomplete, @@ -149,22 +148,69 @@ def test_merge_hooks_adds_all_events(): assert set(profile.hooks.events) <= set(settings["hooks"]) -def test_merge_hooks_cursor_uses_bare_versioned_entries(): +def test_merge_hooks_cursor_writes_versioned_bare_entries(): + """Cursor's project hook file is versioned and its entries are bare commands. + + The `version` key is the load-bearing half: Cursor 3.x refuses a project-level + .cursor/hooks.json carrying no numeric top-level `version` and then loads NONE + of its hooks, so omitting it registers a relay that never fires and reads as a + session timeout rather than as an error anyone can see. + + The equality is exact, not a subset, because the claim IS the absence: Cursor's + entries carry no matcher wrapper, no nested "hooks" list, no "type" and no + timeout field, all of which the shared handler in `_hook_entry` would add. + + Ablation (both run): dropping "cursor-hooks-json" from the version arm of + `merge_hooks` fails the version assertion, and returning `_hook_entry`'s shared + `handler` instead of the bare dict fails the equality on the extra "type" key. + """ profile = get_profile("cursor") - settings, changed = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + config, changed = merge_hooks({}, _registrations(profile), profile.hooks.dialect) assert changed is True - assert settings["version"] == 1 - assert settings["hooks"]["stop"] == [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}] + assert config["version"] == 1 + assert config["hooks"] == { + "sessionStart": [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py SessionStart"}], + "stop": [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}], + } + + +def test_cursor_relay_strips_whole_and_spares_a_project_hook(): + """`provision_worktree` seeds the main repo's hook file into the worktree and + then re-registers, so the relay has to survive a strip/re-add round trip. + Cursor stores the command dict FLAT in the event list, like copilot and agy and + unlike claude/codex/gemini, so `strip_relay_hooks` must drop a matching entry + WHOLE — there is no nested "hooks" list to reach into. The project's own `stop` + hook sitting beside the relay is the control: it must still be there afterwards, + which is what separates "removed the relay" from "cleared the event". -def test_cursor_workspace_trust_is_copy_when_absent(tmp_path): - home, target = tmp_path / "home", tmp_path / "project" - target.mkdir() - marker = seed_workspace_trust(target, home=home) - real = os.path.realpath(str(target)) - assert marker == home / ".cursor" / "projects" / _cursor_trust_slug(real) / ".workspace-trusted" - assert json.loads(marker.read_text(encoding="utf-8"))["workspacePath"] == real - assert seed_workspace_trust(target, home=home) is None + Ablation: make the strip loop keep any entry lacking a nested "hooks" list and + the relay survives, failing the `relay_registered` check below. + """ + profile = get_profile("cursor") + mine = {"command": "./hooks/my-own-audit.sh"} + config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + config["hooks"]["stop"].append(mine) + + assert strip_relay_hooks(config, profile.hooks.dialect) is True + assert relay_registered(config, profile.hooks.dialect, profile.hooks.events) is False + assert config["hooks"]["stop"] == [mine] # the project's own hook is untouched + assert "sessionStart" not in config["hooks"] # emptied events are dropped + + # and re-registering restores the relay without duplicating the project's hook + config, changed = merge_hooks(config, _registrations(profile), profile.hooks.dialect) + assert changed is True + assert relay_registered(config, profile.hooks.dialect, profile.hooks.events) is True + assert config["hooks"]["stop"].count(mine) == 1 + + +def test_merge_hooks_cursor_is_idempotent(): + """A second `init` must not stack a duplicate relay in the flat event list.""" + profile = get_profile("cursor") + config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + again, changed = merge_hooks(config, _registrations(profile), profile.hooks.dialect) + assert changed is False + assert again["hooks"]["stop"] == [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}] def test_merge_hooks_idempotent(): diff --git a/tests/test_profile.py b/tests/test_profile.py index 3b8238b0e..df8dda0fa 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -46,17 +46,29 @@ def test_builtin_profiles_load(): assert profiles["claude"].skill_tree == ".claude/skills" assert profiles["codex"].skill_tree == ".agents/skills" assert profiles["gemini"].skill_tree == ".agents/skills" + # cursor: lower-cased event names, its own project hook file, and `--trust` in + # the bypass flags. That flag is not decoration — measured against cursor-agent + # 2026.08.04, an interactive launch without it blocks on the workspace-trust + # dialog (`--force` alone does NOT clear it), which an unattended session can + # never answer and which therefore reads as a session timeout. cursor = profiles["cursor"] assert cursor.binary == "cursor-agent" assert cursor.skill_tree == ".cursor/skills" assert cursor.hooks.dialect == "cursor-hooks-json" + assert cursor.hooks.config_path == ".cursor/hooks.json" assert cursor.hooks.events == {"sessionStart": "SessionStart", "stop": "Stop"} - assert cursor.seed_workspace_trust is True + assert "--trust" in cursor.bypass_args + # the prompt is an argv positional, so a leading "/" never reaches Cursor's + # slash menu — the template names the SKILL.md outright, as codex/copilot do + assert cursor.prompt_template.startswith("LOAD the FULL .cursor/skills/{skill}/SKILL.md") # each profile carries the gitignored configs a worktree checkout omits assert ".mcp.json" in profiles["claude"].seed_files assert ".claude/settings.json" in profiles["claude"].seed_files assert profiles["codex"].seed_files == (".codex/config.toml",) assert profiles["gemini"].seed_files == (".gemini/settings.json",) + # cursor seeds its MCP config and its hook file, the latter because + # provision_worktree merges the relay into whatever the project already had + assert profiles["cursor"].seed_files == (".cursor/mcp.json", ".cursor/hooks.json") # copilot: turn-end is agentStop (Copilot 1.0.63 never fires PascalCase Stop), # no PreCompact equivalent, and its events.jsonl parser is wired up assert profiles["copilot"].hooks.events == { From c900451d55904c189e17cb039dbcb1f3369a5c58 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Fri, 4 Sep 2026 11:40:13 -0400 Subject: [PATCH 4/8] docs(cursor): record the probe run and why token columns stay empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile told the next reader to finalize it with `probe-adapter cursor --probe` before trusting it unattended. That probe has now run — 0 warnings, both hook events captured live — and a one-story dev loop then completed end to end with a real commit, so the EXPERIMENTAL warning is stale and goes. The probe also settled the open token question, and the answer is that `usage_parser` can never fix it. Cursor puts the counts on the Stop hook payload (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) and puts no token fields in the transcript at all, so a parser handed a transcript path has nothing to read whatever it is set to. The counts do reach this machine and are then dropped: the shared relay narrows every payload to ts/event/task_id/session_id/transcript_path/cwd. Wiring them up therefore means widening a contract every provider shares, not editing this profile. Recording that here stops the next reader retrying it as a cursor-local fix. Also note what has NOT been measured. Completion here comes from hooks rather than parsed stdout, so this profile does not depend on the terminal result frame that a dropped agent stream takes away from the print/stream-json transports. But the dev loop above ran on a healthy network path, so how these hooks behave when the stream drops mid-turn is untested, and the note says so rather than implying immunity. --- src/bmad_loop/data/profiles/cursor.toml | 29 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/bmad_loop/data/profiles/cursor.toml b/src/bmad_loop/data/profiles/cursor.toml index 0905562b9..38dcb265d 100644 --- a/src/bmad_loop/data/profiles/cursor.toml +++ b/src/bmad_loop/data/profiles/cursor.toml @@ -1,8 +1,16 @@ # Cursor CLI (`cursor-agent`), driven interactively in a mux pane. Verified -# against cursor-agent 2026.08.04 on macOS. EXPERIMENTAL: the hook dialect, -# trust gate and skill tree below were each checked against a live binary, but no -# full dev/review loop has been run end to end — finalize with -# `bmad-loop probe-adapter cursor --probe` before trusting it unattended. +# against cursor-agent 2026.08.04 and re-verified on 2026.09.02-c22c1a3, macOS. +# No longer experimental: `probe-adapter cursor --probe` was run (0 warnings, +# both events captured live) and a one-story dev loop then completed end to end +# — `1 done, 0 deferred, 0 escalated`, with a real commit — so the hook dialect, +# trust gate and skill tree below are all measured through a full run, not just +# against a live binary. +# Completion here comes from the hooks, not from parsed stdout, so this profile +# does not depend on the terminal `result` frame that a dropped agent stream +# takes away from the print/stream-json transports (see the cursor-cli-headless +# profile for that measurement). Untested claim, stated as one: the dev loop +# above ran on a healthy network path, so how these hooks behave when the stream +# drops mid-turn has NOT been measured. # # TRUST (measured, 4-cell matrix, each in a fresh untrusted git dir launched # interactively under tmux — the shape bmad-loop actually spawns): @@ -39,9 +47,16 @@ # on to expand. The explicit LOAD form is used for the same reason codex and # copilot use it — it names the file and cannot depend on command expansion. # -# usage_parser is "none": the Stop payload carries a `transcript_path`, but its -# token schema has not been read, so nothing is claimed about it. Runs work; the -# token columns stay empty until a probe supplies the schema. +# usage_parser is "none", and the probe has now settled why it must stay that +# way. Cursor puts the token counts on the Stop hook payload itself — +# `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, +# plus `loop_count` and `status` — and puts NO token fields in the transcript at +# all, so a `usage_parser` (which is handed a transcript path) has nothing to +# read no matter what it is set to. The counts do reach this machine and are then +# dropped: the shared relay narrows every payload to ts/event/task_id/session_id/ +# transcript_path/cwd. Wiring them up therefore means widening the relay contract +# that all providers share, not editing this profile, so the token columns stay +# empty for cursor by design and `session_budget_mode` is inert here. name = "cursor" binary = "cursor-agent" skill_tree = ".cursor/skills" From 7f8dc61e79e8a5e5745274415f704629af76aaf7 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Tue, 22 Sep 2026 15:42:55 -0400 Subject: [PATCH 5/8] test(cursor): check the cursor dialect against the installed relay command Upstream #825 replaced the copied bmad_loop_hook.py with an absolute `bmad-loop relay ` command. The cursor tests still built the old command, so they never checked that Cursor's bare entries carry the new form or that the stale-relay strip recognizes it. - Cursor merge/strip/idempotency tests now use the installed relay form. - Add test_install_into_cursor: real `init --cli cursor` writes a versioned .cursor/hooks.json whose entries hold only "command". - Add cursor to the per-dialect tests for fresh init, legacy flat-hook migration, and probe hook registration. Co-authored-by: Cursor --- tests/test_install.py | 55 ++++++++++++++++++++++++++++++++++--------- tests/test_probe.py | 4 +++- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index 0dfb0ea43..4a46f6bab 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -157,6 +157,9 @@ def test_merge_hooks_adds_all_events(): assert set(profile.hooks.events) <= set(settings["hooks"]) +_CURSOR_RELAY = "/opt/bmad/bin/bmad-loop relay {event}" + + def test_merge_hooks_cursor_writes_versioned_bare_entries(): """Cursor's project hook file is versioned and its entries are bare commands. @@ -174,12 +177,12 @@ def test_merge_hooks_cursor_writes_versioned_bare_entries(): `handler` instead of the bare dict fails the equality on the extra "type" key. """ profile = get_profile("cursor") - config, changed = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + config, changed = merge_hooks({}, _registrations(profile, _CURSOR_RELAY), profile.hooks.dialect) assert changed is True assert config["version"] == 1 assert config["hooks"] == { - "sessionStart": [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py SessionStart"}], - "stop": [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}], + "sessionStart": [{"command": "/opt/bmad/bin/bmad-loop relay SessionStart"}], + "stop": [{"command": "/opt/bmad/bin/bmad-loop relay Stop"}], } @@ -198,7 +201,7 @@ def test_cursor_relay_strips_whole_and_spares_a_project_hook(): """ profile = get_profile("cursor") mine = {"command": "./hooks/my-own-audit.sh"} - config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + config, _ = merge_hooks({}, _registrations(profile, _CURSOR_RELAY), profile.hooks.dialect) config["hooks"]["stop"].append(mine) assert strip_relay_hooks(config, profile.hooks.dialect) is True @@ -207,7 +210,9 @@ def test_cursor_relay_strips_whole_and_spares_a_project_hook(): assert "sessionStart" not in config["hooks"] # emptied events are dropped # and re-registering restores the relay without duplicating the project's hook - config, changed = merge_hooks(config, _registrations(profile), profile.hooks.dialect) + config, changed = merge_hooks( + config, _registrations(profile, _CURSOR_RELAY), profile.hooks.dialect + ) assert changed is True assert relay_registered(config, profile.hooks.dialect, profile.hooks.events) is True assert config["hooks"]["stop"].count(mine) == 1 @@ -216,10 +221,11 @@ def test_cursor_relay_strips_whole_and_spares_a_project_hook(): def test_merge_hooks_cursor_is_idempotent(): """A second `init` must not stack a duplicate relay in the flat event list.""" profile = get_profile("cursor") - config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) - again, changed = merge_hooks(config, _registrations(profile), profile.hooks.dialect) + registrations = _registrations(profile, _CURSOR_RELAY) + config, _ = merge_hooks({}, registrations, profile.hooks.dialect) + again, changed = merge_hooks(config, registrations, profile.hooks.dialect) assert changed is False - assert again["hooks"]["stop"] == [{"command": "python3 /x/.bmad-loop/bmad_loop_hook.py Stop"}] + assert again["hooks"]["stop"] == [{"command": "/opt/bmad/bin/bmad-loop relay Stop"}] def test_merge_hooks_idempotent(): @@ -410,7 +416,9 @@ def test_init_preserves_different_script_with_same_basename(tmp_path): assert any(command.endswith(_installed_relay_suffix("Stop")) for command in commands) -@pytest.mark.parametrize("name,container", [("copilot", "hooks"), ("antigravity", "bmad-loop")]) +@pytest.mark.parametrize( + "name,container", [("copilot", "hooks"), ("cursor", "hooks"), ("antigravity", "bmad-loop")] +) def test_init_migrates_flat_legacy_hook_and_preserves_user(tmp_path, name, container): profile = get_profile(name) config = tmp_path / profile.hooks.config_path @@ -671,6 +679,30 @@ def test_install_into_copilot(tmp_path): assert len(settings["hooks"]["agentStop"]) == 1 +def test_install_into_cursor(tmp_path): + """`init --cli cursor` writes the installed relay as bare, versioned entries. + + The merge-level tests pin the shape with a synthetic command; this pins it with + the command `_hook_command` really builds, so the bare-entry arm and the + installed-relay form are checked together. + """ + assert install_into(tmp_path, clis=("cursor",)) == 0 + config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text()) + assert config["version"] == 1 + assert set(config["hooks"]) == {"sessionStart", "stop"} + for native, canonical in (("sessionStart", "SessionStart"), ("stop", "Stop")): + [entry] = config["hooks"][native] + assert set(entry) == {"command"} + assert entry["command"].endswith(_installed_relay_suffix(canonical)) + assert relay_executable(entry["command"]) is not None + for skill in MODULE_SKILLS: + assert (tmp_path / ".cursor" / "skills" / skill / "SKILL.md").is_file() + + assert install_into(tmp_path, clis=("cursor",)) == 0 + config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text()) + assert len(config["hooks"]["stop"]) == 1 + + def test_install_into_full(tmp_path): assert install_into(tmp_path) == 0 assert not (tmp_path / ".bmad-loop" / "bmad_loop_hook.py").exists() @@ -701,7 +733,7 @@ def test_install_into_full(tmp_path): assert final_gitignore.count(f"{RENDER_DIR_REL}/") == 1 -@pytest.mark.parametrize("name", ["claude", "codex", "gemini", "copilot", "antigravity"]) +@pytest.mark.parametrize("name", ["claude", "codex", "gemini", "copilot", "cursor", "antigravity"]) def test_fresh_init_registers_installed_command_for_each_dialect(tmp_path, name): profile = get_profile(name) assert install_into(tmp_path, clis=(name,), skills=False) == 0 @@ -713,7 +745,8 @@ def test_fresh_init_registers_installed_command_for_each_dialect(tmp_path, name) handlers = container[native] command = ( handlers[0]["command"] - if profile.hooks.dialect in {"copilot-settings-json", "antigravity-hooks-json"} + if profile.hooks.dialect + in {"copilot-settings-json", "cursor-hooks-json", "antigravity-hooks-json"} else handlers[0]["hooks"][0]["command"] ) assert command.endswith(_installed_relay_suffix(canonical)) diff --git a/tests/test_probe.py b/tests/test_probe.py index 89abb39e7..a9663d022 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -207,7 +207,9 @@ def test_discover_location_redacts_username(tmp_path, monkeypatch): # ----------------------------------------------------------- registration -@pytest.mark.parametrize("dialect_cli", ["claude", "codex", "gemini", "copilot", "antigravity"]) +@pytest.mark.parametrize( + "dialect_cli", ["claude", "codex", "gemini", "copilot", "cursor", "antigravity"] +) def test_probe_hook_registers_under_native_events(dialect_cli): from bmad_loop.install import ANTIGRAVITY_HOOK_GROUP, merge_hooks From fbd46b79bc6863cc241c071e4e0a0c8dfd7add00 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Tue, 22 Sep 2026 15:42:55 -0400 Subject: [PATCH 6/8] docs(cursor): list .cursor/hooks.json in the uninstall steps Upstream now lists each CLI's hook config to edit on uninstall. Cursor was missing, so a cursor user had no pointer to the file holding the relay. Co-authored-by: Cursor --- docs/setup-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/setup-guide.md b/docs/setup-guide.md index c9c7dc276..084477999 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -422,6 +422,7 @@ that hook event): - **codex** — `.codex/hooks.json` - **gemini** — `.gemini/settings.json` - **copilot** — `.github/copilot/settings.json` +- **cursor** — `.cursor/hooks.json` - **antigravity** — `.agents/hooks.json` (the `bmad-loop` hook group) Edit only the registered CLIs. Match the full relay command and event; leave every From 830e587a505423e6018992a2c25b0ad87bc4a078 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Tue, 22 Sep 2026 15:42:55 -0400 Subject: [PATCH 7/8] style(cursor): apply trunk fmt to the cursor docs and profile The cursor rows widened three markdown tables without realigning them, and taplo collapses the short seed_files array. Whitespace only. Co-authored-by: Cursor --- README.md | 2 +- docs/FEATURES.md | 34 ++++++++++++------------- docs/adapter-authoring-guide.md | 2 +- src/bmad_loop/data/profiles/cursor.toml | 5 +--- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 166c69ecc..ef94f7478 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,7 @@ One generic driver (`adapters/generic.py`) runs any coding CLI that fits the inj | `codex` | supported, E2E-verified | Codex ≥ 0.139. No slash expansion in the initial prompt — the profile renders `$skill-name` mentions (plus a "use subagents as needed" nudge) instead. No SessionEnd hook; window-death fallback covers crashes. | | `gemini` | supported, E2E-verified | Gemini CLI ≥ 0.46 (hooks on by default since then). Launches with `-i` to stay interactive; `AfterAgent` maps to canonical Stop. Usage parser validated against real chat logs. | | `copilot` | supported, E2E-verified | GitHub Copilot **CLI** (the `copilot` binary, GA ≥ 2026-02) — _not_ the VS Code extension. Launches with `-i` to stay interactive; turn-end is `agentStop` (per response turn); `--allow-all-tools` for unattended runs. `copilot-events` usage parser reads token totals from the trailing `session.shutdown` line, so the profile waits a short grace (`usage_grace_s = 8`) before tallying. **Pin a capable model** (see below). | -| `cursor` | experimental | **Cursor CLI** (`cursor-agent`), verified against 2026.08.04. Skills live in `.cursor/skills/`; hooks in a project `.cursor/hooks.json`, whose top-level `version` is required — without it Cursor 3.x loads no hooks at all. `stop` is the turn-end event, `sessionStart` marks liveness. **`--trust` is mandatory for unattended runs**: an interactive launch in an untrusted directory blocks on a workspace-trust dialog and `--force` alone does _not_ clear it, so the profile ships `--force --trust` in its bypass flags — if you set `[adapter] extra_args` (which _replaces_ them) you must keep `--trust`. Because the flag is per-launch, `isolation = "worktree"` works, unlike antigravity. `usage_parser = "none"` for now: the Stop payload names a transcript, but its token schema is unread. Finalize with `probe-adapter cursor`. | +| `cursor` | experimental | **Cursor CLI** (`cursor-agent`), verified against 2026.08.04. Skills live in `.cursor/skills/`; hooks in a project `.cursor/hooks.json`, whose top-level `version` is required — without it Cursor 3.x loads no hooks at all. `stop` is the turn-end event, `sessionStart` marks liveness. **`--trust` is mandatory for unattended runs**: an interactive launch in an untrusted directory blocks on a workspace-trust dialog and `--force` alone does _not_ clear it, so the profile ships `--force --trust` in its bypass flags — if you set `[adapter] extra_args` (which _replaces_ them) you must keep `--trust`. Because the flag is per-launch, `isolation = "worktree"` works, unlike antigravity. `usage_parser = "none"` for now: the Stop payload names a transcript, but its token schema is unread. Finalize with `probe-adapter cursor`. | | `antigravity` | experimental — `isolation = "none"` only | Google **Antigravity CLI** (`agy` ≥ 1.1.3). Launches with `-i` to stay interactive; `Stop` is the turn-end event (agy has no SessionStart/SessionEnd hook). Skills and hooks live in `.agents/` (flat `Stop` handler in `.agents/hooks.json`, keyed by hook-group name). Hook payloads are protojson/camelCase. **Trust is exact-path**: `agy` blocks on a "trust this folder" dialog for any workspace not listed verbatim in `settings.json` `trustedWorkspaces`, and `--dangerously-skip-permissions` does not bypass it — so `isolation = "worktree"` hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). `usage_parser = "none"` is permanent, not pending: agy's transcript carries no usage data (tokens live only in an internal SQLite/protobuf store), so runs work but token columns stay empty. Verify against your build with `probe-adapter antigravity`. | | `opencode` | supported, E2E-verified | **OpenCode** ≥ 1.18 (profile `opencode-http`), driven over HTTP/SSE — one headless `opencode serve` per session, **no tmux window**. Needs the extra: `pip install 'bmad-loop[opencode]'`. Auth once globally with `opencode auth login`; skills live in `.claude/skills/`; set `model` as `provider/model` (e.g. `anthropic/claude-haiku-4-5`). Watch sessions via `run_dir/logs/.log` or the TUI Log tab; `resolve` is `--no-interactive` only; the Unity plugin's window guards are unsupported here. | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 85a7504cc..baf29dbfc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -8,24 +8,24 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ## Capability matrix (feature → problem addressed) -| Capability | What it does | Problem it addresses | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | -| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | -| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | -| Trust-nothing verification | Checks on-disk artifacts (spec status, canonical baseline validity, proof after the accepted baseline, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | -| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | -| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | -| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | -| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | -| Human-gated stories (`awaiting-operator`) | A story owing external actions only a human can take commits its agent-doable work, records what is owed, and parks; `bmad-loop confirm` completes it later | Work needing a domain purchase or a DNS record had no honest terminal state — `done` hid it behind a green board, `blocked` halted the run | -| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | -| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | +| Capability | What it does | Problem it addresses | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | +| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | +| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | +| Trust-nothing verification | Checks on-disk artifacts (spec status, canonical baseline validity, proof after the accepted baseline, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | +| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | +| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | +| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | +| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | +| Human-gated stories (`awaiting-operator`) | A story owing external actions only a human can take commits its agent-doable work, records what is owed, and parks; `bmad-loop confirm` completes it later | Work needing a domain purchase or a DNS record had no honest terminal state — `done` hid it behind a green board, `blocked` halted the run | +| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | +| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | | Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/cursor/antigravity/opencode; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux + experimental native-Windows psmux bundled; external backends via entry points) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | -| Cost-weighted token budgets | Mid-session per-session guard (`warn`/`enforce` with a wrap-up grace, sampled every ~30s) plus the advisory per-story cap; both count cache reads at ~0.1x; every display leads with the weighted total and names both units | A runaway-but-busy session was bounded only by the wall clock; naive token caps misjudge real cost (cache reads dominate) | -| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; your BMAD install is never modified, and `sprint-status.yaml` is the one board it writes while a run is in flight — the sessions it dispatches never do | Modifying a user's standard BMAD install | -| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | -| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | +| Cost-weighted token budgets | Mid-session per-session guard (`warn`/`enforce` with a wrap-up grace, sampled every ~30s) plus the advisory per-story cap; both count cache reads at ~0.1x; every display leads with the weighted total and names both units | A runaway-but-busy session was bounded only by the wall clock; naive token caps misjudge real cost (cache reads dominate) | +| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; your BMAD install is never modified, and `sprint-status.yaml` is the one board it writes while a run is in flight — the sessions it dispatches never do | Modifying a user's standard BMAD install | +| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | +| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | --- diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 974e35811..9b66286c5 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -506,7 +506,7 @@ resolves to `claude`. | Field | Required | Meaning | | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dialect` | ✅ | The CLI's hook-config format — one of `claude-settings-json`, `codex-hooks-json`, `gemini-settings-json`, `copilot-settings-json`, `cursor-hooks-json`, `antigravity-hooks-json`. | +| `dialect` | ✅ | The CLI's hook-config format — one of `claude-settings-json`, `codex-hooks-json`, `gemini-settings-json`, `copilot-settings-json`, `cursor-hooks-json`, `antigravity-hooks-json`. | | `config_path` | ✅ | Project-relative path the hook config is written to (e.g. `.claude/settings.json`). Absolute paths are rejected. | | `events` | ✅ | Map of **native** event name → **canonical** event name. The canonical side must be one of `SessionStart`, `Stop`, `SessionEnd`, `PreCompact`; the native side is whatever the CLI emits (e.g. `agentStop = "Stop"`). At least one entry. | diff --git a/src/bmad_loop/data/profiles/cursor.toml b/src/bmad_loop/data/profiles/cursor.toml index 38dcb265d..f76c162b1 100644 --- a/src/bmad_loop/data/profiles/cursor.toml +++ b/src/bmad_loop/data/profiles/cursor.toml @@ -69,10 +69,7 @@ first_run_note = "run `cursor-agent login` once (or set CURSOR_API_KEY). Require # (.cursor/mcp.json) and the hook file itself. .cursor/hooks.json is also the # hook config_path — it is seeded first, then the relay is merged into it, so a # worktree keeps any hooks the project already had (see provision_worktree). -seed_files = [ - ".cursor/mcp.json", - ".cursor/hooks.json", -] +seed_files = [".cursor/mcp.json", ".cursor/hooks.json"] # No env_fault_patterns: no cursor-agent transport-failure line has been # captured, and on a pane capture that vocabulary is what a story implementing From b39ddc52ccd2be772862283b51afd43e92a57269 Mon Sep 17 00:00:00 2001 From: Phil Mahncke Date: Tue, 22 Sep 2026 16:48:16 -0400 Subject: [PATCH 8/8] docs(cursor): mark the cursor profile supported and E2E-verified The profile header already recorded the graduation: probe-adapter cursor --probe passed with 0 warnings and a one-story dev loop completed with a real commit on cursor-agent 2026.09.02. README, FEATURES, setup guide and CHANGELOG still called it experimental. They now say supported, E2E-verified, and note that no token usage is recorded yet. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++-- README.md | 2 +- docs/FEATURES.md | 2 +- docs/setup-guide.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa42d7db8..042a8b1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,9 @@ breaking changes may land in a minor release. unattended session can answer, and `--force` alone does not clear it. Setting `[adapter] extra_args` replaces the bypass flags, so it must keep `--trust`. Trust is granted per launch, so `isolation = "worktree"` works. `usage_parser = "none"` pending a - transcript-schema probe. Experimental — verified against cursor-agent 2026.08.04, not yet - run through a full loop; finalize with `probe-adapter cursor`. + transcript-schema probe, so no token usage is recorded. Supported and E2E-verified on + cursor-agent 2026.09.02: `probe-adapter cursor --probe` captured both events with 0 + warnings, and a one-story dev loop completed with a real commit. - Add a free-form `effort` key to `[adapter]` and every `[adapter.]` table, inherited like `model`; `opencode-http` sends it as the per-prompt `variant` on every turn, and `validate` warns (`policy.effort-unsupported`) when a tmux stage diff --git a/README.md b/README.md index ef94f7478..707240554 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,7 @@ One generic driver (`adapters/generic.py`) runs any coding CLI that fits the inj | `codex` | supported, E2E-verified | Codex ≥ 0.139. No slash expansion in the initial prompt — the profile renders `$skill-name` mentions (plus a "use subagents as needed" nudge) instead. No SessionEnd hook; window-death fallback covers crashes. | | `gemini` | supported, E2E-verified | Gemini CLI ≥ 0.46 (hooks on by default since then). Launches with `-i` to stay interactive; `AfterAgent` maps to canonical Stop. Usage parser validated against real chat logs. | | `copilot` | supported, E2E-verified | GitHub Copilot **CLI** (the `copilot` binary, GA ≥ 2026-02) — _not_ the VS Code extension. Launches with `-i` to stay interactive; turn-end is `agentStop` (per response turn); `--allow-all-tools` for unattended runs. `copilot-events` usage parser reads token totals from the trailing `session.shutdown` line, so the profile waits a short grace (`usage_grace_s = 8`) before tallying. **Pin a capable model** (see below). | -| `cursor` | experimental | **Cursor CLI** (`cursor-agent`), verified against 2026.08.04. Skills live in `.cursor/skills/`; hooks in a project `.cursor/hooks.json`, whose top-level `version` is required — without it Cursor 3.x loads no hooks at all. `stop` is the turn-end event, `sessionStart` marks liveness. **`--trust` is mandatory for unattended runs**: an interactive launch in an untrusted directory blocks on a workspace-trust dialog and `--force` alone does _not_ clear it, so the profile ships `--force --trust` in its bypass flags — if you set `[adapter] extra_args` (which _replaces_ them) you must keep `--trust`. Because the flag is per-launch, `isolation = "worktree"` works, unlike antigravity. `usage_parser = "none"` for now: the Stop payload names a transcript, but its token schema is unread. Finalize with `probe-adapter cursor`. | +| `cursor` | supported, E2E-verified | **Cursor CLI** (`cursor-agent`), verified against 2026.08.04 and 2026.09.02. Skills live in `.cursor/skills/`; hooks in a project `.cursor/hooks.json`, whose top-level `version` is required — without it Cursor 3.x loads no hooks at all. `stop` is the turn-end event, `sessionStart` marks liveness. **`--trust` is mandatory for unattended runs**: an interactive launch in an untrusted directory blocks on a workspace-trust dialog and `--force` alone does _not_ clear it, so the profile ships `--force --trust` in its bypass flags — if you set `[adapter] extra_args` (which _replaces_ them) you must keep `--trust`. Because the flag is per-launch, `isolation = "worktree"` works, unlike antigravity. `usage_parser = "none"` for now: the Stop payload names a transcript, but its token schema is unread, so no token usage is recorded yet. | | `antigravity` | experimental — `isolation = "none"` only | Google **Antigravity CLI** (`agy` ≥ 1.1.3). Launches with `-i` to stay interactive; `Stop` is the turn-end event (agy has no SessionStart/SessionEnd hook). Skills and hooks live in `.agents/` (flat `Stop` handler in `.agents/hooks.json`, keyed by hook-group name). Hook payloads are protojson/camelCase. **Trust is exact-path**: `agy` blocks on a "trust this folder" dialog for any workspace not listed verbatim in `settings.json` `trustedWorkspaces`, and `--dangerously-skip-permissions` does not bypass it — so `isolation = "worktree"` hangs ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169)). `usage_parser = "none"` is permanent, not pending: agy's transcript carries no usage data (tokens live only in an internal SQLite/protobuf store), so runs work but token columns stay empty. Verify against your build with `probe-adapter antigravity`. | | `opencode` | supported, E2E-verified | **OpenCode** ≥ 1.18 (profile `opencode-http`), driven over HTTP/SSE — one headless `opencode serve` per session, **no tmux window**. Needs the extra: `pip install 'bmad-loop[opencode]'`. Auth once globally with `opencode auth login`; skills live in `.claude/skills/`; set `model` as `provider/model` (e.g. `anthropic/claude-haiku-4-5`). Watch sessions via `run_dir/logs/.log` or the TUI Log tab; `resolve` is `--no-interactive` only; the Unity plugin's window guards are unsupported here. | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index baf29dbfc..07874c6cf 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -696,7 +696,7 @@ persisted artifacts. - 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). - 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/.log` (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in `.server.out`, and a structured SSE trace in `.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: `cursor` (Cursor CLI `cursor-agent`, verified against 2026.08.04) — interactive launch, `stop` turn-end hook and `sessionStart` in a project `.cursor/hooks.json` (versioned file, bare `{"command": …}` entries; Cursor 3.x loads no hooks from a project file with no top-level `version`), skills in `.cursor/skills/`, snake_case hook payloads. Launches with `--force --trust`: the trust flag is required, because an interactive launch in an untrusted directory blocks on a workspace-trust dialog that `--force` alone does not clear and that seeding Cursor's own `.workspace-trusted` marker does not satisfy either. Since trust is granted per launch rather than per stored path, `isolation = "worktree"` works (unlike antigravity). `[adapter] extra_args` replaces the bypass flags, so it must keep `--trust`. `usage_parser = "none"` pending a transcript-schema probe. Verify with `probe-adapter cursor`. +- Supported, E2E-verified (no token usage yet): `cursor` (Cursor CLI `cursor-agent`, verified against 2026.08.04 and 2026.09.02) — interactive launch, `stop` turn-end hook and `sessionStart` in a project `.cursor/hooks.json` (versioned file, bare `{"command": …}` entries; Cursor 3.x loads no hooks from a project file with no top-level `version`), skills in `.cursor/skills/`, snake_case hook payloads. Launches with `--force --trust`: the trust flag is required, because an interactive launch in an untrusted directory blocks on a workspace-trust dialog that `--force` alone does not clear and that seeding Cursor's own `.workspace-trusted` marker does not satisfy either. Since trust is granted per launch rather than per stored path, `isolation = "worktree"` works (unlike antigravity). `[adapter] extra_args` replaces the bypass flags, so it must keep `--trust`. `usage_parser = "none"` pending a transcript-schema probe. Verify with `probe-adapter cursor`. - 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]`). - Reasoning effort: a free-form `effort` string on `[adapter]` and every `[adapter.]` table (values are provider/model-specific — `high`, `max`, … — so nothing is validated against a catalog), inherited exactly like `model` (a stage that switches client falls back to `""` = provider default). Only the `opencode-http` adapter carries it: it is sent as the per-call `variant` on every `prompt_async` body the session issues (initial prompt and every nudge) and omitted entirely when empty, never through `OPENCODE_CONFIG_CONTENT` (whose only effort key, `agent..variant`, is inert unless that agent also pins a model). An unrecognized name is not rejected — OpenCode accepts the prompt and the session silently runs at the provider default — so spell it exactly as the model's variant list names it. The tmux CLIs have no channel for it and ignore it; `bmad-loop validate` reports `policy.effort-unsupported` (a warning, exit code unchanged) when a stage on that family sets it. The value never reaches argv, so `config_digest` is unaffected; `run --dry-run` shows it as `effort=` beside the model on the hookless launch line. diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 084477999..1add6e398 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -180,7 +180,7 @@ would not carry. ## Choosing which CLIs to drive The supported adapters are `claude` (the default), `codex`, `gemini`, `copilot`, -`cursor` (Cursor's `cursor-agent`, experimental), +`cursor` (Cursor's `cursor-agent`), `antigravity` (Google's `agy`, experimental — `isolation = "none"` only), and `opencode` (OpenCode ≥ 1.18 over HTTP/SSE, profile `opencode-http` — no tmux window; needs the `bmad-loop[opencode]` extra and `model` set as `provider/model`). You can pick more