diff --git a/CHANGELOG.md b/CHANGELOG.md index b999ba5fb..042a8b1f9 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, 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 d0fbfb4f9..707240554 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. @@ -459,7 +459,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") effort = "" # reasoning effort, free-form (e.g. "high", "max"); empty = provider default. # Sent by opencode-http as the per-prompt `variant`; the tmux CLIs have no @@ -678,6 +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` | 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. | @@ -687,7 +688,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 6e8dd4569..07874c6cf 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 | -| 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 | -| 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 | +| 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 | --- @@ -696,6 +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). +- 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/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index becfee66d..9b66286c5 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 71c515e76..1add6e398 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`), `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 @@ -309,8 +322,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. @@ -409,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 diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index 98ad754a1..94bb2660d 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -57,6 +57,7 @@ "gemini-settings-json", "copilot-settings-json", "antigravity-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", diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index a324630de..1c3cf51c3 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -5339,7 +5339,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( @@ -5400,7 +5400,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 new file mode 100644 index 000000000..f76c162b1 --- /dev/null +++ b/src/bmad_loop/data/profiles/cursor.toml @@ -0,0 +1,81 @@ +# Cursor CLI (`cursor-agent`), driven interactively in a mux pane. Verified +# 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): +# 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", 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" +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" +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" +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 68a86ca12..cb27370d5 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -1100,6 +1100,12 @@ 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'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 # agy's Stop event value is a flat list of handler objects — the handler @@ -1164,9 +1170,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 = [ @@ -1253,8 +1259,12 @@ 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"): + # 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, []) @@ -1272,8 +1282,8 @@ def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tu else: del hooks[native_event] 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)) diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index 319af2467..e6a0112e6 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -1413,7 +1413,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") effort = "" # reasoning effort, free-form (e.g. "high", "max"); empty = provider default. # Sent by opencode-http as the per-prompt `variant`; the tmux CLIs ignore it diff --git a/tests/test_install.py b/tests/test_install.py index b9ba4ff2c..4a46f6bab 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -58,6 +58,7 @@ provision_worktree, registered_relay_paths, relay_executable, + relay_registered, renderer_stub_resolved, resolve_dev_primitive, resolve_review_layers, @@ -156,6 +157,77 @@ 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. + + 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") + config, changed = merge_hooks({}, _registrations(profile, _CURSOR_RELAY), profile.hooks.dialect) + assert changed is True + assert config["version"] == 1 + assert config["hooks"] == { + "sessionStart": [{"command": "/opt/bmad/bin/bmad-loop relay SessionStart"}], + "stop": [{"command": "/opt/bmad/bin/bmad-loop relay 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". + + 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, _CURSOR_RELAY), 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, _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 + + +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") + 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": "/opt/bmad/bin/bmad-loop relay Stop"}] + + def test_merge_hooks_idempotent(): profile = get_profile("claude") settings, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) @@ -344,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 @@ -605,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() @@ -635,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 @@ -647,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 diff --git a/tests/test_profile.py b/tests/test_profile.py index c1b9b7380..df8dda0fa 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,11 +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 "--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 == {