diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad1e5fe..7bb2ddd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ breaking changes may land in a minor release. ### Added +- **Mistral Vibe adapter profile (`mistral-vibe`, alias `vibe`), experimental.** Drives + Mistral's `vibe` CLI (≥ 2.23) through the generic tmux + hook-signal transport. Adds the + first non-JSON hook dialect, `vibe-hooks-toml`: `.vibe/hooks.toml` is a flat + `hooks = [...]` array of tables whose entries name their own event in a `type` field + rather than being keyed by it. Hook-config read/write now goes through a dialect-aware + `load_hook_config` / `dump_hook_config` seam in `install.py`; every JSON dialect emits + byte-identical output as before. `post_agent` is vibe's only turn-end event (no + SessionStart/SessionEnd/PreCompact) and fires per response turn, so the profile ships + `stop_without_result_nudges = 5`. Launches with `--trust`, which is mandatory — vibe + reads project hooks only in a trusted directory — and, being per-invocation rather than + an exact-path store, leaves `isolation = "worktree"` working. vibe exposes no `--model` + flag: leave `[adapter] model` empty and select the model via `VIBE_ACTIVE_MODEL`. + `usage_parser = "none"` pending a probe of `~/.vibe/logs/session/*/messages.jsonl`. + - **A park travels with its story's commit, so `bmad-loop confirm` works from any clone (#356).** Each parked story now writes one committed JSON record to `.bmad-loop/operator/.json`, inside the story's own commit window, so the record rides the park's commit — through the worktree diff --git a/README.md b/README.md index 3b5dedb3..48b93179 100644 --- a/README.md +++ b/README.md @@ -584,14 +584,15 @@ One caveat repeated from above: a `--json` command reporting a **verdict** exits One generic driver (`adapters/generic.py`) runs any coding CLI that fits the injection + hook-signal transport; everything CLI-specific lives in a declarative **profile** (`adapters/profile.py`), and the terminal transport itself sits behind a pluggable `TerminalMultiplexer` seam (tmux bundled; external backends like the herdr adapter install as packages — see [Terminal multiplexer backends](docs/multiplexer-backends.md)). Built-in profiles ship as TOML in `bmad_loop/data/profiles/`: -| Profile | Status | Notes | -| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `claude` | supported | reference implementation | -| `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). | -| `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. | +| Profile | Status | Notes | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `claude` | supported | reference implementation | +| `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). | +| `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`. | +| `mistral-vibe` | experimental (alias `vibe`) | **Mistral Vibe** (`vibe` ≥ 2.23). Hooks and skills live in `.vibe/` and `.agents/skills/`; `post_agent` is the only turn-end event (no SessionStart/SessionEnd/PreCompact) and it fires per response turn, so the profile ships `stop_without_result_nudges = 5`. The **only non-JSON hook dialect**: `.vibe/hooks.toml` is a flat `hooks = [...]` array (`vibe-hooks-toml`). **Trust is per-invocation**: vibe reads project hooks only in a trusted directory, so the profile launches with `--trust` — which, unlike `antigravity`, means `isolation = "worktree"` works. vibe has **no `--model` flag**: leave `[adapter] model` empty and select the model via `VIBE_ACTIVE_MODEL` or vibe's own config. `usage_parser = "none"` pending a probe — runs work, token columns stay empty. Verify against your build with `probe-adapter mistral-vibe`. | +| `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. | **Copilot — pin a capable model:** Copilot's free default (GPT-5 mini) is unreliable for the multi-step dev/review skills — it silently skips steps mid-workflow and fails the story. Set a capable model in policy, e.g. `[adapter] model = "claude-sonnet-4-6"` (passed through as `--model`), for end-to-end reliability. Because Copilot fires `agentStop` per response turn, a thorough multi-turn review needs more than one nudge to finish; the profile ships `stop_without_result_nudges = 5`, and you can tune it per stage (e.g. `[adapter.review] stop_without_result_nudges = …`). Both knobs are editable in the settings TUI under `[adapter]`. @@ -599,7 +600,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. The full profile schema — every `CLIProfile` / `HookSpec` field and its default — lives in the **[Profile field reference](docs/adapter-authoring-guide.md#profile-field-reference)** of the adapter authoring guide, the single canonical home for it. The hook relay script and orchestrator are CLI-agnostic — each registration passes the canonical event name as the script argument. A CLI whose hook config clones one of the existing dialects (the ecosystem trend) needs nothing else; a genuinely different transport gets its own adapter class instead (see [Writing a new adapter class](docs/adapter-authoring-guide.md#writing-a-new-adapter-class) — the shipped opencode HTTP+SSE adapter in `adapters/opencode_http.py` is its worked example). +**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` / `vibe-hooks-toml`) plus a native→canonical event map. The full profile schema — every `CLIProfile` / `HookSpec` field and its default — lives in the **[Profile field reference](docs/adapter-authoring-guide.md#profile-field-reference)** of the adapter authoring guide, the single canonical home for it. The hook relay script and orchestrator are CLI-agnostic — each registration passes the canonical event name as the script argument. A CLI whose hook config clones one of the existing dialects (the ecosystem trend) needs nothing else; a genuinely different transport gets its own adapter class instead (see [Writing a new adapter class](docs/adapter-authoring-guide.md#writing-a-new-adapter-class) — the shipped opencode HTTP+SSE adapter in `adapters/opencode_http.py` is its worked example). **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 2c4407d8..fa77a094 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -141,6 +141,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - 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, `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`. +- Experimental: `mistral-vibe` (Mistral **Vibe** ≥ 2.23, alias `vibe`) — `post_agent` is the sole turn-end hook (no SessionStart/SessionEnd/PreCompact) and fires per response turn, so the profile ships `stop_without_result_nudges = 5`; skills read from `.agents/skills/`. Its `.vibe/hooks.toml` is the only **non-JSON** hook dialect (`vibe-hooks-toml`): a flat `hooks = [...]` array whose entries name their own event in a `type` field. vibe loads project hooks only in a **trusted** directory, so the profile launches with `--trust` — trust is per-invocation rather than an exact-path store, so `isolation = "worktree"` works (unlike `antigravity`). vibe exposes no `--model` flag: leave `[adapter] model` empty and pick the model via `VIBE_ACTIVE_MODEL` or vibe's config. `usage_parser = "none"` pending a probe of `~/.vibe/logs/session/*/messages.jsonl`. Verify against your build with `probe-adapter mistral-vibe`. - 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). - `bmad-loop probe-adapter` collects + sanitizes the data needed to finalize/add a profile (hook payload shape, transcript location/format, token schema): a zero-launch scan by default, opt-in `--probe` for live capture. See the [adapter authoring guide](adapter-authoring-guide.md). diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 36fe4f3b..255e5542 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -281,7 +281,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` / `antigravity-hooks-json` / +`vibe-hooks-toml`) and a native→canonical event map, and a `usage_parser` (start with `"none"` until you've written one). @@ -423,11 +424,11 @@ resolves to `claude`. ### `HookSpec` (the `[hooks]` table) -| 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`. | -| `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. | +| 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`, `vibe-hooks-toml`. Every dialect is JSON except `vibe-hooks-toml`, whose `.vibe/hooks.toml` is a flat `hooks = [...]` array of tables — entries name their own event in a `type` field rather than being keyed by it. | +| `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. | ### Worked TOML — copilot diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 265d3e9a..6d6cfbc3 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -154,7 +154,8 @@ would not carry. ## Choosing which CLIs to drive The supported adapters are `claude` (the default), `codex`, `gemini`, `copilot`, -`antigravity` (Google's `agy`, experimental — `isolation = "none"` only), and `opencode` +`antigravity` (Google's `agy`, experimental — `isolation = "none"` only), +`mistral-vibe` (Mistral's `vibe`, experimental), 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 than one — register every CLI you intend to use for dev, review, or sweep triage. @@ -234,7 +235,8 @@ 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`, `antigravity`, `mistral-vibe` (alias +`vibe`), 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). @@ -269,6 +271,22 @@ them to whoever owns the machine: - **Token usage is not recorded** (`usage_parser = "none"`) — and this is permanent, not a gap: agy's transcript carries no usage data at all (it counts tokens only in an internal SQLite/protobuf store). Runs work; the token columns stay empty. +- **mistral-vibe** — run `vibe --setup` once and authenticate (Mistral API key) before + `bmad-loop run`; spawned sessions can't answer the setup prompt, and a pending one + reads as a session timeout. Requires Mistral Vibe ≥ 2.23. Three things to know, + verified against 2.23.2: + - **Trust gates the hooks.** vibe reads `/.vibe/hooks.toml` only when the + working directory is trusted, so the profile launches with `--trust` (trusted for + that invocation only, never persisted). Without it no `Stop` ever arrives and every + session reads as a timeout. Because trust is per-invocation rather than an + exact-path store, `isolation = "worktree"` works fine here. + - **No `--model` flag.** Leave `[adapter] model` empty and select the model with the + `VIBE_ACTIVE_MODEL` env var or vibe's own `active_model` config; setting it in + policy passes an argument vibe's parser rejects. + - **Token usage is not yet recorded** (`usage_parser = "none"`) — unlike antigravity + this is pending rather than permanent: vibe writes a `messages.jsonl` per session + under `~/.vibe/logs/session/`, which a parser may be able to read. Runs work; the + token columns stay empty. Confirm with `bmad-loop probe-adapter mistral-vibe`. - **opencode** — install the HTTP client extra (`pip install 'bmad-loop[opencode]'`) and authenticate once, **globally**, with `opencode auth login` (not per-project — there is no workspace-trust dialog to answer). Requires OpenCode ≥ 1.18. Set the model as @@ -281,7 +299,7 @@ them to whoever owns the machine: ### Skill location -`claude` reads skills from `.claude/skills/`; `codex`, `gemini`, `copilot`, and `antigravity` +`claude` reads skills from `.claude/skills/`; `codex`, `gemini`, `copilot`, `antigravity`, and `mistral-vibe` 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 diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index ec9b1a73..ccfc5790 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -29,6 +29,9 @@ "gemini-settings-json", "copilot-settings-json", "antigravity-hooks-json", + # the one non-JSON dialect: vibe's .vibe/hooks.toml is a flat `hooks = [...]` + # array of tables whose entries name their own event in a `type` field. + "vibe-hooks-toml", # hookless: the adapter observes completion itself (HTTP/SSE transport) — # no hook config is ever written, so config_path/events must stay empty. "none", @@ -37,7 +40,7 @@ USER_PROFILES_REL = Path(".bmad-loop") / "profiles" # legacy adapter names from older policy.toml files, plus friendly short names -ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http"} +ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http", "vibe": "mistral-vibe"} class ProfileError(Exception): diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 1afd313f..5446035a 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -185,7 +185,7 @@ def _reconcile_stale(project: Path, paths: bmadconfig.ProjectPaths, pol) -> None def cmd_validate(args: argparse.Namespace) -> int: - from .install import relay_registered + from .install import load_hook_config, relay_registered project = _project(args) report = ValidationReport() @@ -324,14 +324,14 @@ def cmd_validate(args: argparse.Namespace) -> int: hooks_ok = False if hook_config.is_file(): try: - parsed = json.loads(hook_config.read_text(encoding="utf-8")) - hooks_ok = isinstance(parsed, dict) and relay_registered( - parsed, profile.hooks.dialect, profile.hooks.events + parsed = load_hook_config( + hook_config.read_text(encoding="utf-8"), profile.hooks.dialect ) - except json.JSONDecodeError: + hooks_ok = relay_registered(parsed, profile.hooks.dialect, profile.hooks.events) + except ProfileError as exc: report.fail( "hooks.config-parse", - f"{hook_config} is not valid JSON", + f"{hook_config} is {exc}", {"profile": profile.name, "config_path": str(hook_config)}, ) if hooks_ok: @@ -2656,7 +2656,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; " + "antigravity | mistral-vibe (alias: vibe) | opencode-http (alias: opencode) | custom; " "repeatable; default: profiles referenced by .bmad-loop/policy.toml, or claude)", ) init_p.add_argument( @@ -2711,7 +2711,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 | antigravity | mistral-vibe | custom; " "opencode-http is HTTP-driven — nothing to probe)", ) probe_p.add_argument( diff --git a/src/bmad_loop/data/profiles/mistral-vibe.toml b/src/bmad_loop/data/profiles/mistral-vibe.toml new file mode 100644 index 00000000..94266d6a --- /dev/null +++ b/src/bmad_loop/data/profiles/mistral-vibe.toml @@ -0,0 +1,46 @@ +# Mistral Vibe CLI (`vibe`), verified against 2.23.2. +# +# TRUST IS LOAD-BEARING. vibe reads /.vibe/hooks.toml only when the +# working directory is trusted; an untrusted cwd silently yields zero hooks, so +# no Stop ever arrives and every session reads as a timeout. `--trust` trusts the +# directory for THIS invocation only (not persisted to trusted_folders.toml), +# which is exactly what an unattended spawn wants — and, unlike antigravity's +# exact-path trust store, it means isolation = "worktree" works: each worktree is +# trusted by the same flag on launch. +# +# ONE TURN-END EVENT. vibe's hook types are post_agent / pre_tool / post_tool — +# there is no SessionStart, SessionEnd or PreCompact analogue. post_agent fires +# once per TURN (after the agent finishes responding with no pending tool calls), +# not once per session, so stop_without_result_nudges is raised the way copilot's +# per-turn agentStop required. Payload keys are snake_case (session_id, +# transcript_path, cwd, parent_session_id), which the shared relay already reads. +# +# NO --model FLAG. vibe takes its model from config (`active_model`) or the +# VIBE_ACTIVE_MODEL env var. Leave `[adapter] model` EMPTY in policy.toml — +# model_flag below is inert only while it stays empty, and setting it would pass +# an argument vibe's parser rejects. +# +# usage_parser = "none" pending a probe of ~/.vibe/logs/session/*/messages.jsonl. +name = "mistral-vibe" +binary = "vibe" +prompt_template = "{prompt}" +launch_args = ["--trust"] +bypass_args = ["--auto-approve"] +model_flag = "--model" +usage_parser = "none" +# post_agent fires PER response turn, not once per session, so a multi-turn +# review ends several turns; the global default of 1 nudge would declare it +# stalled on the 2nd Stop. 5 matches copilot's agentStop, for the same reason. +stop_without_result_nudges = 5 +first_run_note = "run `vibe --setup` once and authenticate (Mistral API key) before `bmad-loop run` — spawned sessions can't answer the setup prompt, and a pending one reads as a session timeout. Leave `[adapter] model` empty: vibe has no --model flag, so set the model via VIBE_ACTIVE_MODEL or vibe's own config. Requires Mistral Vibe >= 2.23." +skill_tree = ".agents/skills" +# .vibe/config.toml holds project MCP servers and the active-model setting; it is +# gitignored-by-convention and a `git worktree add` checkout omits it, so seed it +# into isolated dev/review worktrees. (.vibe/hooks.toml is the config_path — the +# worktree flow merges the Stop hook into it separately.) +seed_files = [".vibe/config.toml"] + +[hooks] +dialect = "vibe-hooks-toml" +config_path = ".vibe/hooks.toml" +events = { post_agent = "Stop" } diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md index c4192f44..f936257e 100644 --- a/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md @@ -92,9 +92,9 @@ Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), 3. **Bootstrap the project** — install the coding-CLI hooks, the bundled `bmad-loop-*` skills, the `.bmad-loop/policy.toml` template, and the gitignore entry (idempotent). - First decide **which coding CLI(s)** the orchestrator should drive. The supported adapters are `claude` (default), `codex`, `gemini`, `copilot`, and `antigravity` (Google's `agy`). Hooks are registered per CLI, so the choice matters — register every CLI you intend to use for dev/review/triage. Ask the user (unless they already specified it in their setup args, e.g. `cli: claude, codex`, or accepted defaults — then default to `claude` only): + First decide **which coding CLI(s)** the orchestrator should drive. The supported adapters are `claude` (default), `codex`, `gemini`, `copilot`, `antigravity` (Google's `agy`), and `mistral-vibe` (Mistral's `vibe`). Hooks are registered per CLI, so the choice matters — register every CLI you intend to use for dev/review/triage. Ask the user (unless they already specified it in their setup args, e.g. `cli: claude, codex`, or accepted defaults — then default to `claude` only): - > "Which coding CLI(s) should the orchestrator drive — `claude`, `codex`, `gemini`, `copilot`, and/or `antigravity`? You can pick more than one. [claude]" + > "Which coding CLI(s) should the orchestrator drive — `claude`, `codex`, `gemini`, `copilot`, `antigravity`, and/or `mistral-vibe`? You can pick more than one. [claude]" Build the command with one `--cli ` per selected CLI (the flag is repeatable). **On an upgrade, append `--force-skills`** so the per-project skill copies are actually refreshed — without it `init` skips every existing skill dir and the project keeps stale skills against the upgraded tool. On a fresh install, omit it. @@ -109,9 +109,9 @@ Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), bmad-loop init --project "{project-root}" --cli claude --force-skills ``` - Names must be exactly `claude`, `codex`, `gemini`, `copilot`, or `antigravity` — `init` errors on an unknown profile and lists the valid ones. `init` prints any one-time first-run notes per CLI (e.g. start `claude` once in the project and accept the workspace-trust + hooks-approval dialogs before `bmad-loop run` — spawned sessions can't answer first-run dialogs). Relay those notes to the user. + Names must be exactly `claude`, `codex`, `gemini`, `copilot`, `antigravity`, or `mistral-vibe` — `init` errors on an unknown profile and lists the valid ones. `init` prints any one-time first-run notes per CLI (e.g. start `claude` once in the project and accept the workspace-trust + hooks-approval dialogs before `bmad-loop run` — spawned sessions can't answer first-run dialogs). Relay those notes to the user. - **Skills are installed automatically:** `init` lays the bundled `bmad-loop-*` skills into the right tree for each selected CLI — `.claude/skills/` for `claude`, `.agents/skills/` for `codex`/`gemini`/`copilot`/`antigravity`. On a fresh install, existing skill dirs are left untouched; on an upgrade, `--force-skills` overwrites them with the bundled copies from the upgraded tool (use `--no-skills` to skip the step and manage skills yourself). + **Skills are installed automatically:** `init` lays the bundled `bmad-loop-*` skills into the right tree for each selected CLI — `.claude/skills/` for `claude`, `.agents/skills/` for `codex`/`gemini`/`copilot`/`antigravity`/`mistral-vibe`. On a fresh install, existing skill dirs are left untouched; on an upgrade, `--force-skills` overwrites them with the bundled copies from the upgraded tool (use `--no-skills` to skip the step and manage skills yourself). > **Note:** `--force-skills` also overwrites `bmad-loop-setup` itself (it ships in the same bundle). That's expected and safe — the freshly laid-down setup skill takes effect on the **next** invocation, and your `_bmad/custom/*.toml` overrides (keyed by skill directory name) are untouched. diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 0a548fe7..ae7d0aa1 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -53,6 +53,10 @@ # agy's .agents/hooks.json keys by hook NAME at the top level (not a "hooks" # wrapper); bmad-loop registers all its handlers under this single group. ANTIGRAVITY_HOOK_GROUP = "bmad-loop" +VIBE_HOOK_TIMEOUT_SEC = 60 # vibe hook timeouts are seconds, float (default 60) +# vibe's .vibe/hooks.toml is a flat `hooks = [...]` array whose entries carry a +# unique `name`; ours is registered under this one. +VIBE_HOOK_NAME = "bmad-loop-stop" # The bmad-loop-* skills bundled in the wheel (bmad_loop/data/skills/) that # `bmad-loop init` lays down. The inner dev primitive `bmad-dev-auto` is upstream @@ -564,6 +568,86 @@ def _review_findings(project: Path, tree: str) -> list[Finding]: return findings +_TOML_ESCAPES = { + "\\": "\\\\", + '"': '\\"', + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +} + + +def _toml_scalar(key: str, value: object) -> str: + """Render one scalar as TOML. Anything else is a boundary failure.""" + if isinstance(value, bool): # before int: bool IS an int in Python + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, str): + body = "".join( + _TOML_ESCAPES.get(ch) or (ch if ch >= " " else f"\\u{ord(ch):04X}") for ch in value + ) + return f'"{body}"' + raise ProfileError( + f"hook field {key!r} holds a {type(value).__name__}, which this TOML writer cannot emit" + ) + + +def _dump_vibe_hooks(config: dict) -> str: + """Serialize a vibe hook config as `[[hooks]]` array-of-tables TOML. + + Hand-rolled rather than tomlkit/tomli-w because neither is a core dependency + (tomlkit ships only with the [tui] extra) and vibe's HookConfig schema is + closed and entirely scalar — see the profile header for the field list. + + KNOWN LOSS: this re-emits the whole file, so comments and formatting in a + user's .vibe/hooks.toml do not survive. Callers only write when merge_hooks + reports `changed`, so a steady-state `init` re-run rewrites nothing. A `None` + value drops its key: TOML has no null. + """ + entries = config.get("hooks", []) + if not isinstance(entries, list): + raise ProfileError("'hooks' in the vibe hooks file is not an array of tables") + out: list[str] = [] + for entry in entries: + if not isinstance(entry, dict): + raise ProfileError("every entry in the vibe 'hooks' array must be a table") + out.append("[[hooks]]") + out += [f"{k} = {_toml_scalar(k, v)}" for k, v in entry.items() if v is not None] + out.append("") + return "\n".join(out) + + +def load_hook_config(text: str, dialect: str) -> dict: + """Parse a hook config's bytes into the dict shape merge_hooks operates on. + + The dialect owns its own on-disk format: every shipped dialect is JSON except + vibe's, which is TOML. Raises ProfileError on a syntax error so the caller can + report the path — a hook config we cannot read must never be silently replaced. + """ + if dialect == "vibe-hooks-toml": + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise ProfileError(f"not valid TOML: {exc}") from exc + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise ProfileError(f"not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise ProfileError("hook config must be a JSON object") + return parsed + + +def dump_hook_config(config: dict, dialect: str) -> str: + """Serialize a merged hook config back to its dialect's on-disk format.""" + if dialect == "vibe-hooks-toml": + return _dump_vibe_hooks(config) + return json.dumps(config, indent=2) + "\n" + + def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> str: host = get_process_host() interp = host.hook_interpreter() @@ -574,7 +658,17 @@ def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> s return f"{interp} {host.shell_quote(str(project / HOOK_SCRIPT_REL))} {canonical_event}" -def _hook_entry(dialect: str, command: str) -> dict: +def _hook_entry(dialect: str, command: str, native_event: str = "") -> dict: + if dialect == "vibe-hooks-toml": + # vibe's hooks.toml is a flat array whose entries name their own event in + # a `type` field, so the native event travels IN the entry rather than as + # the key it is filed under. Every other dialect ignores native_event. + return { + "name": VIBE_HOOK_NAME, + "type": native_event, + "command": command, + "timeout": VIBE_HOOK_TIMEOUT_SEC, + } handler: dict = {"type": "command", "command": command} if dialect == "gemini-settings-json": handler["timeout"] = GEMINI_HOOK_TIMEOUT_MS # Gemini timeouts are milliseconds @@ -598,8 +692,16 @@ def hook_event_container(config: dict, dialect: str) -> dict: Most dialects nest it under "hooks". agy instead keys the file by hook GROUP name at the top level, so our relay lives under ANTIGRAVITY_HOOK_GROUP — reading "hooks" there yields {} and reports a correctly-installed relay as - unregistered (issue #159). Every reader must go through this, or it drifts. + unregistered (issue #159). vibe has no map at all: "hooks" is a flat array + whose entries carry their own event in a `type` field, so the map is derived + by grouping on it. Every reader must go through this, or it drifts. """ + if dialect == "vibe-hooks-toml": + grouped: dict = {} + for entry in config.get("hooks", []) or []: + if isinstance(entry, dict) and isinstance(event := entry.get("type"), str): + grouped.setdefault(event, []).append(entry) + return grouped if dialect == "antigravity-hooks-json": container = config.get(ANTIGRAVITY_HOOK_GROUP, {}) else: @@ -627,6 +729,23 @@ def relay_registered(config: dict, dialect: str, events: Iterable[str]) -> bool: def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tuple[dict, bool]: """Add relay registrations (native event -> command) to a hook config dict.""" changed = False + if dialect == "vibe-hooks-toml": + # vibe reads ONE flat array; unrelated user hooks sit alongside ours and + # are preserved. Dedup goes through the grouped view so the idempotency + # rule is the same serialized-scan every other dialect uses. + entries = config.setdefault("hooks", []) + if not isinstance(entries, list): + raise ProfileError( + "'hooks' in the vibe hooks file is not an array of tables; " + "fix or remove it before registering the Stop hook" + ) + for native_event, command in registrations.items(): + if not _managed_hook_in_handlers( + hook_event_container(config, dialect).get(native_event, []) + ): + entries.append(_hook_entry(dialect, command, native_event)) + changed = True + return config, changed if dialect == "antigravity-hooks-json": # agy keys .agents/hooks.json by hook NAME at the top level (no "hooks" # wrapper); register every handler under one ANTIGRAVITY_HOOK_GROUP group. @@ -671,9 +790,11 @@ def _register_hooks(project: Path, profile: CLIProfile) -> int: config: dict = {} if config_path.is_file(): try: - config = json.loads(config_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - print(f"FAIL: {config_path} is not valid JSON; fix it and re-run init") + config = load_hook_config( + config_path.read_text(encoding="utf-8"), profile.hooks.dialect + ) + except ProfileError as exc: + print(f"FAIL: {config_path} is {exc}; fix it and re-run init") return 1 registrations = { native: _hook_command(project, profile, canonical) @@ -681,7 +802,7 @@ def _register_hooks(project: Path, profile: CLIProfile) -> int: } config, changed = merge_hooks(config, registrations, profile.hooks.dialect) if changed: - config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + config_path.write_text(dump_hook_config(config, profile.hooks.dialect), encoding="utf-8") print(f" hooks registered ({profile.name}): {config_path}") else: print(f" hooks already registered ({profile.name})") diff --git a/src/bmad_loop/policy.py b/src/bmad_loop/policy.py index edb5aed4..a5035412 100644 --- a/src/bmad_loop/policy.py +++ b/src/bmad_loop/policy.py @@ -1124,7 +1124,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 | antigravity | mistral-vibe (alias: vibe) | 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/probe.py b/src/bmad_loop/probe.py index 2e523101..22994dc0 100644 --- a/src/bmad_loop/probe.py +++ b/src/bmad_loop/probe.py @@ -57,8 +57,8 @@ from . import sanitize from .adapters.multiplexer import MultiplexerError, get_multiplexer -from .adapters.profile import CLIProfile -from .install import merge_hooks, relay_registered +from .adapters.profile import CLIProfile, ProfileError +from .install import dump_hook_config, load_hook_config, merge_hooks, relay_registered from .process_host import get_process_host # cmd_probe catches `probe.LeakDetected` around the renderers, mirroring @@ -97,6 +97,12 @@ "antigravity": ( "~/.gemini/antigravity-cli/brain/*/.system_generated/logs/transcript_full.jsonl" ), + # vibe writes one directory per session under its session-log root, named + # session__, holding messages.jsonl plus a + # meta.json. Rooted at $VIBE_HOME (default ~/.vibe), so a VIBE_HOME override + # moves it and this glob misses — the hook payload's transcript_path is + # authoritative and --probe prefers it. + "mistral-vibe": "~/.vibe/logs/session/*/messages.jsonl", } _TOKEN_KEY_RE = re.compile( @@ -407,13 +413,9 @@ def _hooks_registered(project: Path, profile: CLIProfile) -> bool: config_path = project / profile.hooks.config_path if not config_path.is_file(): return False - import json - try: - config = json.loads(config_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return False - if not isinstance(config, dict): + config = load_hook_config(config_path.read_text(encoding="utf-8"), profile.hooks.dialect) + except (ProfileError, OSError): return False return relay_registered(config, profile.hooks.dialect, profile.hooks.events) @@ -587,7 +589,6 @@ def probe( keep_temp: bool = False, pseudo: sanitize.Pseudonymizer | None = None, ) -> ProfileFinding: - import json aliases = {orig: alias for _ns, orig, alias in pseudo.entries()} if pseudo else None binary = hints.binary or profile.binary @@ -641,7 +642,7 @@ def probe( config, _ = merge_hooks({}, registrations, profile.hooks.dialect) config_path = tmpdir / profile.hooks.config_path config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + config_path.write_text(dump_hook_config(config, profile.hooks.dialect), encoding="utf-8") # 2. launch one trivial content-free turn in a fresh tmux window argv = _probe_argv(profile, binary, hints) diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 30504fbe..ce344579 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -19,13 +19,13 @@ from __future__ import annotations -import json from collections.abc import Sequence from importlib import resources from pathlib import Path from typing import TYPE_CHECKING, Callable, NoReturn from . import gates, verify +from .adapters.profile import ProfileError from .install import ( BASE_SKILLS, CUSTOMIZE_DIR, @@ -33,6 +33,8 @@ MODULE_SKILLS, _copy_traversable, _worktree_local_exclude, + dump_hook_config, + load_hook_config, merge_hooks, resolve_review_layers, ) @@ -274,8 +276,10 @@ def provision_worktree( config: dict = {} if config_path.is_file(): try: - config = json.loads(config_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: + config = load_hook_config( + config_path.read_text(encoding="utf-8"), profile.hooks.dialect + ) + except ProfileError: config = {} host = get_process_host() interp = host.hook_interpreter() @@ -285,7 +289,9 @@ def provision_worktree( } config, changed = merge_hooks(config, registrations, profile.hooks.dialect) if changed: - config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + config_path.write_text( + dump_hook_config(config, profile.hooks.dialect), encoding="utf-8" + ) # 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 diff --git a/tests/test_install.py b/tests/test_install.py index eede0ee6..e566b682 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -255,6 +255,127 @@ def test_merge_hooks_antigravity_preserves_other_groups(): assert settings["bmad-loop"]["Stop"][0]["command"].endswith("bmad_loop_hook.py Stop") +def test_merge_hooks_vibe_entry_shape(): + # vibe's .vibe/hooks.toml is a FLAT array: entries carry their own event in a + # `type` field rather than being keyed by it, and each needs a unique `name`. + profile = get_profile("mistral-vibe") + config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + (entry,) = config["hooks"] + assert entry["name"] == "bmad-loop-stop" + assert entry["type"] == "post_agent" # the only turn-end event vibe has + assert entry["timeout"] == 60 # vibe hook timeouts are seconds + # registered under the native event but relaying the canonical name + assert entry["command"].endswith("bmad_loop_hook.py Stop") + + +def test_merge_hooks_vibe_idempotent(): + profile = get_profile("mistral-vibe") + config, _ = merge_hooks({}, _registrations(profile), profile.hooks.dialect) + again, changed = merge_hooks(config, _registrations(profile), profile.hooks.dialect) + assert not changed + assert len(again["hooks"]) == 1 + + +def test_merge_hooks_vibe_preserves_user_hooks(): + # every vibe hook shares ONE array, so an unrelated user hook is not merely + # in a sibling group — it is in the same list we append to. + profile = get_profile("mistral-vibe") + mine = {"name": "guard-bash", "type": "pre_tool", "match": "bash", "command": "echo mine"} + config, changed = merge_hooks({"hooks": [mine]}, _registrations(profile), profile.hooks.dialect) + assert changed + assert config["hooks"][0] == mine + assert len(config["hooks"]) == 2 + + +def test_merge_hooks_vibe_unrelated_bmad_loop_path_does_not_suppress_relay(): + # the #159 guarantee through the flat-array dedup branch. + profile = get_profile("mistral-vibe") + existing = { + "hooks": [ + {"name": "mine", "type": "post_agent", "command": "python ~/bmad_loop_fork/notify.py"} + ] + } + config, changed = merge_hooks(existing, _registrations(profile), profile.hooks.dialect) + assert changed + commands = [h["command"] for h in config["hooks"]] + assert "python ~/bmad_loop_fork/notify.py" in commands + assert any("bmad_loop_hook" in c for c in commands) + + +def test_merge_hooks_vibe_rejects_malformed_shape(): + import pytest + + from bmad_loop.adapters.profile import ProfileError + + profile = get_profile("mistral-vibe") + with pytest.raises(ProfileError): + merge_hooks({"hooks": "oops"}, _registrations(profile), profile.hooks.dialect) + + +def test_dump_vibe_hooks_round_trips_through_tomllib(): + # the hand-rolled emitter is only trustworthy if a real TOML parser reads back + # exactly what went in — especially commands carrying quotes and backslashes + # (Windows paths), which are what naive string interpolation corrupts. + import tomllib + + from bmad_loop.install import dump_hook_config + + config = { + "hooks": [ + { + "name": "guard", + "type": "pre_tool", + "match": "bash", + "strict": True, + "timeout": 12.5, + "description": None, # TOML has no null: the key is dropped + }, + { + "name": "bmad-loop-stop", + "type": "post_agent", + "command": 'C:\\Py\\python.exe "C:\\proj dir\\hook.py"\tStop', + "timeout": 60, + }, + ] + } + text = dump_hook_config(config, "vibe-hooks-toml") + parsed = tomllib.loads(text) + assert parsed["hooks"][1] == config["hooks"][1] + assert parsed["hooks"][0] == {k: v for k, v in config["hooks"][0].items() if v is not None} + + +def test_dump_vibe_hooks_refuses_non_scalar(): + # fail loud at the boundary rather than emitting TOML that vibe will reject + # (or, worse, silently misparse) at hook-load time. + import pytest + + from bmad_loop.adapters.profile import ProfileError + from bmad_loop.install import dump_hook_config + + with pytest.raises(ProfileError): + dump_hook_config({"hooks": [{"name": "x", "env": {"A": "1"}}]}, "vibe-hooks-toml") + + +def test_install_into_vibe(tmp_path): + import tomllib + + assert install_into(tmp_path, clis=("mistral-vibe",)) == 0 + config = tomllib.loads((tmp_path / ".vibe" / "hooks.toml").read_text()) + (entry,) = config["hooks"] + assert entry["type"] == "post_agent" + # absolute path baked in (vibe has no $CLAUDE_PROJECT_DIR equivalent) + cmd = entry["command"] + assert str(tmp_path.resolve()) in cmd and cmd.endswith(" Stop") + # skills land in the shared .agents/skills tree + for skill in MODULE_SKILLS: + assert (tmp_path / ".agents" / "skills" / skill / "SKILL.md").is_file() + + # idempotent re-run does not duplicate the entry + assert install_into(tmp_path, clis=("mistral-vibe",)) == 0 + again = tomllib.loads((tmp_path / ".vibe" / "hooks.toml").read_text()) + assert len(again["hooks"]) == 1 + + def test_install_does_not_clobber_existing_policy(tmp_path): """An existing .bmad-loop/policy.toml is per-machine state: init must leave it alone rather than resetting it to the template.""" diff --git a/tests/test_probe.py b/tests/test_probe.py index 8b6f1255..dfd229c3 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -190,7 +190,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", "antigravity", "mistral-vibe"] +) def test_probe_hook_registers_under_native_events(dialect_cli): from bmad_loop.install import ANTIGRAVITY_HOOK_GROUP, merge_hooks @@ -201,12 +203,16 @@ def test_probe_hook_registers_under_native_events(dialect_cli): } config, changed = merge_hooks({}, registrations, profile.hooks.dialect) assert changed - # agy keys by hook-group name at the top level; the others wrap in "hooks". - container = ( - config[ANTIGRAVITY_HOOK_GROUP] - if profile.hooks.dialect == "antigravity-hooks-json" - else config["hooks"] - ) + # Three container shapes: agy keys by hook-group name at the top level, vibe + # has no map at all (a flat array whose entries name their event in `type`), + # and the rest wrap in "hooks". Derived here rather than via + # hook_event_container so the assertion is independent of that helper. + if profile.hooks.dialect == "antigravity-hooks-json": + container = config[ANTIGRAVITY_HOOK_GROUP] + elif profile.hooks.dialect == "vibe-hooks-toml": + container = {entry["type"] for entry in config["hooks"]} + else: + container = config["hooks"] for native in profile.hooks.events: assert native in container # idempotent re-run @@ -214,12 +220,13 @@ def test_probe_hook_registers_under_native_events(dialect_cli): assert not changed2 -@pytest.mark.parametrize("cli", ["claude", "antigravity"]) +@pytest.mark.parametrize("cli", ["claude", "antigravity", "mistral-vibe"]) def test_scan_reports_registered_state(project, cli): """Registration detection must follow each dialect's container shape. agy keys .agents/hooks.json by hook-GROUP name with no "hooks" wrapper, so reading "hooks" there reports a correctly-installed relay as unregistered. + vibe adds the other axis: its config is TOML, so a JSON reader sees nothing. """ proj = project.project profile = get_profile(cli) diff --git a/tests/test_profile.py b/tests/test_profile.py index f3deeb80..bfc973b8 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -79,8 +79,20 @@ def test_builtin_profiles_load(): # (the "API Error … connection cause" signature); every other built-in ships # none, so classification stays inert until a project overlay adds patterns assert profiles["claude"].env_fault_patterns # non-empty - for name in ("codex", "gemini", "copilot", "antigravity", "opencode-http"): + for name in ("codex", "gemini", "copilot", "antigravity", "opencode-http", "mistral-vibe"): assert profiles[name].env_fault_patterns == () + # mistral-vibe: post_agent is the only turn-end event vibe exposes (no + # SessionStart/SessionEnd/PreCompact analogue) and it fires per response turn, + # so the nudge floor is raised the way copilot's agentStop required. --trust is + # mandatory: without it vibe never reads .vibe/hooks.toml and no Stop arrives. + vibe = profiles["mistral-vibe"] + assert vibe.hooks.dialect == "vibe-hooks-toml" + assert vibe.hooks.config_path == ".vibe/hooks.toml" + assert vibe.hooks.events == {"post_agent": "Stop"} + assert "--trust" in vibe.launch_args + assert vibe.stop_without_result_nudges == 5 + assert vibe.skill_tree == ".agents/skills" + assert vibe.usage_parser == "none" # opencode-http is hookless (HTTP/SSE transport): no hook dialect surfaces, # skills read from the claude tree, usage comes over HTTP (no transcript parser) opencode = profiles["opencode-http"]