From 22e4717ed1e68b48096706dbf67802be86ffecb8 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 01:10:10 -0700 Subject: [PATCH 1/9] docs(reference): add generated configuration reference and a docs link/anchor guard --- docs/README.md | 4 + docs/reference/configuration.md | 259 ++++++++++++++++++++++++++++++++ scripts/gen_policy_reference.py | 193 ++++++++++++++++++++++++ tests/test_docs_hygiene.py | 239 +++++++++++++++++++++++++++++ 4 files changed, 695 insertions(+) create mode 100644 docs/reference/configuration.md create mode 100644 scripts/gen_policy_reference.py create mode 100644 tests/test_docs_hygiene.py diff --git a/docs/README.md b/docs/README.md index 03aa1dc72..59585fcde 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,10 @@ guides below go deeper, roughly in the order you'll need them. - **[Features & functionality](FEATURES.md)** — the full capability matrix and policy reference. - **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux bundled; externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) install as packages), how selection works, and how external backends arrive. +## Reference + +- **[Configuration](reference/configuration.md)** — `.bmad-loop/policy.toml` location and precedence, the `BMAD_LOOP_*` environment variables, and every policy setting with its type, default and allowed values (generated from the settings schema). + ## Extending bmad-loop - **[Authoring CLI adapters & profiles](adapter-authoring-guide.md)** — using `bmad-loop probe-adapter` to collect + sanitize the hook payload shape, transcript location, and token schema a new CLI profile needs, plus the `CodingCLIAdapter` ABC and how an adapter class (and the profile selecting it) ships out-of-tree. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 000000000..0bb1481a5 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,259 @@ +# Configuration reference + +bmad-loop reads its policy from `.bmad-loop/policy.toml` in the project root. `bmad-loop init` writes a commented template there when the file is missing (it never overwrites an existing one) and adds the file to the project's `.gitignore`, so policy is per-machine. Every key is optional: an unset key takes the default listed below, unknown core keys are ignored, and a value of the wrong type or outside its allowed set is rejected with a policy error. The TUI settings screen edits the same file. + +Where a key can be set in more than one place, the first of these wins: + +1. An environment variable from the table below, where one exists for the key. +2. A command-line flag that overrides a single key for that invocation: `run --spec` (`[stories] source`), `sweep --max-bundles` / `--repeat` / `--no-repeat` / `--max-cycles` (`[sweep]`), `clean --retain` (`[cleanup] run_retention`). +3. `.bmad-loop/policy.toml`. +4. The built-in default. + +Plugin activation (`[plugins] enabled`) and plugin settings (`[plugins.]`) are not listed here; see the [plugin authoring guide](../plugin-authoring-guide.md#settings) and each plugin's own guide. + +## Environment variables + +Operator overrides read by the orchestrator: + +| Variable | Value | Effect | +| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `BMAD_LOOP_MUX_BACKEND` | registered backend name (e.g. `tmux`, `psmux`) | Forces the terminal-multiplexer backend, outranking `[mux] backend` and auto-selection. A name matching no registered backend is an error. See [multiplexer backends](../multiplexer-backends.md). | +| `BMAD_LOOP_STATE_DIR` | absolute directory path | Replaces the state root that holds out-of-tree run state (`///`). A relative value is an error; an empty value reads as unset. Unset: `$XDG_STATE_HOME/bmad-loop` (when absolute), else `~/.local/state/bmad-loop`; on Windows `%LOCALAPPDATA%\bmad-loop\state`, else `%USERPROFILE%\AppData\Local\bmad-loop\state`. | +| `BMAD_LOOP_SESSION_TIMEOUT_S` | seconds (float) | Overrides the per-session wall-clock budget (`[limits] session_timeout_min` × 60). A value that is not a finite positive number is ignored. | +| `BMAD_LOOP_PROCESS_HOST` | registered host name (`posix`, `windows`) | Forces the process-lifecycle host (a test hook). A name matching no registered host is an error. Unset: the platform's host. | + +Other `BMAD_LOOP_*` names are not operator settings: + +- Session protocol (`BMAD_LOOP_RUN_DIR`, `BMAD_LOOP_TASK_ID`, `BMAD_LOOP_EVENTS_DIR`, …) — the orchestrator sets these in each session it launches and overwrites any value you export. +- Plugin hook environment (`BMAD_LOOP_STAGE`, `BMAD_LOOP_SETTING_`, …) — see [declarative hooks](../plugin-authoring-guide.md#declarative-hooks). +- Unity plugin (`BMAD_LOOP_UNITY_*`, `BMAD_LOOP_ENGINE_*`) — see the [Unity plugin env-var reference](../game-engine-mcp-guide.md#full-env-var-reference-unity-plugin). + +## Policy settings + +Generated from the core settings schema (`src/bmad_loop/data/settings/core.toml`) by `scripts/gen_policy_reference.py`; do not edit the tables by hand. + + + + +### `[gates]` + +Approval gates, escalation & retrospective behavior. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `mode` | string | `"per-epic"` | `none` / `per-epic` / `per-story-spec-approval` | | +| `retrospective` | string | `"notify"` | `auto` / `never` / `notify` | at each epic boundary, and at run end for a finished last epic: never, notify (suggest /bmad-retrospective), or auto (run a headless /bmad-retrospective session on [adapter.retro] and commit its doc + board) | + +### `[review]` + +Separate adversarial review session toggle. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `enabled` | boolean | `true` | | ON: adversarial review runs in a dedicated 2nd session · OFF: the dev pass runs its own review layers inline, story straight to done | +| `trigger` | string | `"recommended"` | `always` / `recommended` | recommended: run the 2nd-opinion review only when the dev pass flags it · always: run it every story (both bounded by limits.max_review_cycles) | +| `on_timeout` | string | `"retry"` | `defer` / `retry` / `salvage-if-done` | retry: burn a review cycle per timeout (default) · salvage-if-done: commit the verified dev product and refile the follow-up · defer: give up on the first timeout | +| `on_status_contradiction` | string | `"escalate"` | `escalate` / `retry` | escalate: pause naming both sides when a review writes sprint-status back off done (default) · retry: legacy — burn review cycles, then defer + roll back | + +### `[stories]` + +Story-queue source: classic sprint board vs. folder+id dispatch. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `source` | string | `"sprint-status"` | `sprint-status` / `stories` | sprint-status: walk sprint-status.yaml · stories: folder+id dispatch off a typed stories.yaml (needs spec_folder) | +| `spec_folder` | string | `""` | | stories mode only: project-relative path to the epic spec folder (holds stories.yaml + SPEC.md) | + +### `[limits]` + +Cycle/attempt caps, timeout & token budget. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `max_review_cycles` | integer | `3` | ≥ 1 | | +| `max_dev_attempts` | integer | `2` | ≥ 1 | | +| `artifact_file_max_mb` | integer | `5` | ≥ 1 | raw-byte limit for each ignored file selected for isolated artifact publication; tracked declarations ride Git and do not count | +| `artifact_payload_max_mb` | integer | `10` | ≥ 1 | aggregate raw-byte limit across ignored files selected for isolated artifact publication; checked before base64 state encoding | +| `max_followup_reviews` | integer | `1` | ≥ 0 | additional review rounds granted solely because a finalized (status: done) round still recommended a follow-up; once spent, such a round converges + refiles the recommendation instead of burning another cycle · 0 = never honor a pass's own recommendation | +| `session_timeout_min` | integer | `90` | ≥ 1 | | +| `git_timeout_s` | integer | `120` | ≥ 1 | bound on any single git subprocess the orchestrator spawns; exceeding it degrades/pauses (never crashes the run) — raise on a loaded host or a very large worktree | +| `teardown_grace_s` | integer | `20` | ≥ 0 | verified session teardown: poll the killed window up to this long, then force-kill its pane pids and re-kill the window · 0 = single unverified best-effort kill | +| `stop_without_result_nudges` | integer | `1` | ≥ 0 | | +| `dev_stall_grace_s` | integer | `600` | ≥ 0 | silence grace for a dev/review session, armed at launch and re-armed by transport activity or fresh Stop/idle evidence, before bounded nudges/stalled classification · also the transcript-idle notice threshold (journal session-idle/session-active, TUI idle age) · 0 = no launch timer (and no idle notice), but a result-less turn end still fails fast | +| `dev_stall_nudges` | integer | `2` | ≥ 0 | best-effort wake nudges for a silent dev/review session on grace expiry before it is called stalled; transport activity re-arms the grace and fresh Stop/idle evidence restores this budget · 0 = stall on grace expiry | +| `dev_stall_nudges_cap` | integer | `6` | ≥ 0 | total (never-restored) best-effort stall nudges for a dev/review session; bounds launch-time recovery and Stop/idle budget refills because an accepted nudge does not guarantee a wake · 0 = stall on first grace expiry | +| `workflow_stall_nudges_cap` | integer | `3` | ≥ 0 | total (never-restored) stall nudges for an injected plugin-workflow session before it is called stalled; bounds a session that finished its work but never wrote its completion marker · 0 = stall on first grace expiry | +| `dev_contract_nudge` | boolean | `true` | | ON: one targeted nudge per session when a Stop finds a spec finalized to a terminal frontmatter status but missing its `## Auto Run Result` marker, asking the skill to append it and end its turn (sent exactly once, never refilled, touches no stall counters) · OFF: rely only on harness-side frontmatter synthesis | +| `max_tokens_per_story` | integer | `2000000` | ≥ 1 | advisory cap on one story's cumulative weighted spend across all its sessions, re-checked at every session boundary · crossing it raises one ATTENTION + desktop notice per story and nothing else — the story runs on · for a cap that can END a session see session budget mode below | +| `cache_read_weight` | float | `0.1` | 0.0–1.0 | weight of cache-read tokens in every token total — budgets and displays alike · cache reads bill at ~0.1x base input on all supported vendors and are 80-95% of the raw count on agentic work, so the default keeps totals proportional to spend rather than to context re-reads · 1.0 = count cache reads at full price (raw) | +| `session_budget_mode` | string | `"warn"` | `enforce` / `off` / `warn` | mid-session guard on the weighted per-session spend, sampled every ~30s · off: no sampling · warn: one ATTENTION + breadcrumb · enforce: wrap-up nudge then over_budget termination (retry→defer) | +| `max_tokens_per_session` | integer | `4000000` | ≥ 1 | weighted (cache-discounted) token cap a single session may spend before the budget guard trips; healthy sessions run ~1-2.5M weighted, so the default trips only true runaways | +| `session_budget_grace_s` | integer | `240` | ≥ 0 | enforce mode: seconds a tripped session gets to wrap up after the nudge before it is terminated over_budget · 0 = terminate at trip, no nudge | + +### `[verify]` + +Post-implementation verification commands. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `commands` | array of strings | `[]` | | | +| `stream_capture_kb` | integer | `256` | ≥ 0 | per-stream cap on the verifier stdout/stderr retained under the run's verify/ directory (the tail is kept; the journal records the full size and a truncation flag) · 0 = capture nothing | +| `env_fault_rc` | integer | `0` | 0–255 | exit status a verify command uses to declare an environment fault (pause, attempt not charged) instead of a code failure · 0 = disabled; 75 (EX_TEMPFAIL) is a good choice | + +### `[environment]` + +Environment health probes (run before verify, session launches, and charging a failed attempt). + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `probes` | array of strings | `[]` | | health-check commands run in the project root; a failing probe (nonzero exit, timeout, unrunnable) is an environment fault: the run pauses and the attempt is not charged | +| `probe_timeout_s` | integer | `60` | ≥ 1 | per-probe wall-clock bound; a probe running past it counts as failed | + +### `[notify]` + +Desktop & file notifications. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `desktop` | boolean | `true` | | | +| `file` | boolean | `true` | | | + +### `[adapter]` + +CLI client, model & bypass flags (base for all stages). + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `name` | string | unset — CLI profile — default: claude | | | +| `model` | string | unset — CLI default model | | | +| `effort` | string | unset — provider default | | reasoning effort, free-form (provider/model-specific, e.g. high, max); sent by opencode-http as the per-prompt variant, ignored by the tmux CLIs | +| `extra_args` | array of strings | unset | | when set, replaces the CLI profile's permission-bypass flags (its bypass_args), so repeat them (e.g. claude: --permission-mode bypassPermissions) or unattended sessions may stall on permission prompts | +| `cleanup_session_on_finish` | boolean | `true` | | | +| `usage_grace_s` | float | unset — inherit from CLI profile (copilot ships 8s) | ≥ 0 | seconds to poll the transcript for token usage after a session ends; CLIs that flush totals on shutdown (copilot) need a grace | +| `stop_without_result_nudges` | integer | unset — inherit (copilot profile ships 5) | ≥ 0 | result-less Stop signals tolerated before a session is called stalled; overrides the global limits value for this CLI | + +### `[adapter.dev]` + +Dev-stage adapter overrides. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `name` | string | unset — inherit from [adapter] | | | +| `model` | string | unset — inherit / client default | | | +| `effort` | string | unset — inherit / provider default | | | +| `extra_args` | array of strings | unset | | inherits from [adapter] only when the client matches (a stage that switches client falls back to that client's bypass flags); when set, replaces the CLI profile's permission-bypass flags (its bypass_args), so repeat them (e.g. claude: --permission-mode bypassPermissions) | +| `usage_grace_s` | float | unset — inherit from [adapter] | ≥ 0 | | +| `stop_without_result_nudges` | integer | unset — inherit from [adapter] | ≥ 0 | | + +### `[adapter.review]` + +Review-stage adapter overrides. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `name` | string | unset — inherit from [adapter] | | | +| `model` | string | unset — inherit / client default | | | +| `effort` | string | unset — inherit / provider default | | | +| `extra_args` | array of strings | unset | | inherits from [adapter] only when the client matches (a stage that switches client falls back to that client's bypass flags); when set, replaces the CLI profile's permission-bypass flags (its bypass_args), so repeat them (e.g. claude: --permission-mode bypassPermissions) | +| `usage_grace_s` | float | unset — inherit from [adapter] | ≥ 0 | | +| `stop_without_result_nudges` | integer | unset — inherit from [adapter] | ≥ 0 | | + +### `[adapter.triage]` + +Triage-stage adapter overrides. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `name` | string | unset — inherit from [adapter] | | | +| `model` | string | unset — inherit / client default | | | +| `effort` | string | unset — inherit / provider default | | | +| `extra_args` | array of strings | unset | | inherits from [adapter] only when the client matches (a stage that switches client falls back to that client's bypass flags); when set, replaces the CLI profile's permission-bypass flags (its bypass_args), so repeat them (e.g. claude: --permission-mode bypassPermissions) | +| `usage_grace_s` | float | unset — inherit from [adapter] | ≥ 0 | | +| `stop_without_result_nudges` | integer | unset — inherit from [adapter] | ≥ 0 | | + +### `[adapter.retro]` + +Retro-stage adapter overrides. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `name` | string | unset — inherit from [adapter] | | | +| `model` | string | unset — inherit / client default | | | +| `effort` | string | unset — inherit / provider default | | | +| `extra_args` | array of strings | unset | | inherits from [adapter] only when the client matches (a stage that switches client falls back to that client's bypass flags); when set, replaces the CLI profile's permission-bypass flags (its bypass_args), so repeat them (e.g. claude: --permission-mode bypassPermissions) | +| `usage_grace_s` | float | unset — inherit from [adapter] | ≥ 0 | | +| `stop_without_result_nudges` | integer | unset — inherit from [adapter] | ≥ 0 | | + +### `[sweep]` + +Deferred-work sweep automation. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `auto` | string | `"never"` | `never` / `per-epic` / `run-end` | | +| `max_bundles` | integer | `5` | ≥ 1 | | +| `max_triage_attempts` | integer | `2` | ≥ 1 | | +| `repeat` | boolean | `false` | | | +| `max_cycles` | integer | `5` | ≥ 1 | | + +### `[scm]` + +Git isolation, branching & merge-back. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `isolation` | string | `"none"` | `none` / `worktree` | none: work in place on the checked-out branch (default) · worktree: run each story in its own git worktree, merge back to the target branch | +| `branch_per` | string | `"story"` | `run` / `story` | worktree mode: one branch per story, or one shared branch per run (run forces delete-branch off so the shared branch survives) | +| `target_branch` | string | unset — default: the branch checked out at run start | | worktree mode: branch all units merge back into (created if missing) | +| `merge_strategy` | string | `"merge"` | `ff` / `merge` / `squash` | worktree mode: how a unit branch lands on the target — ff, merge, or squash | +| `delete_branch` | boolean | `true` | | worktree mode: delete the unit branch after a successful merge | +| `keep_failed` | boolean | `true` | | worktree mode: keep a failed unit's worktree + branch mounted for inspection | +| `rollback_on_failure` | boolean | `false` | | ⚠ in-place mode (isolation=none): when ON, a failed attempt's tracked changes are auto-reverted and the untracked files this run created are deleted (its uncommitted work is lost). When OFF (default), the orchestrator never touches your tree — it pauses with manual recovery steps. Governs unattended/stopped attempts only: a resolved escalation's re-drive always auto-recovers regardless (reverts the failed source, keeps the corrected spec). Prefer isolation=worktree to keep failures off your main checkout. | +| `preserve_keep` | integer | `20` | ≥ 0 | attempt-preserve/\* recovery branches, attempt-preserve-dirty/\* rollback snapshots and merge-preflight-preserve/\* merge pre-flight snapshots kept at each run start (per family) · the newest by committer date survive and the tail is deleted (0 = never prune) | +| `seed_adapter_defaults` | boolean | `true` | | worktree mode: copy each loaded adapter's gitignored MCP/CLI configs (.mcp.json, .claude/settings.json, .codex/config.toml …) into the worktree so isolated sessions can reach their MCP server | +| `worktree_seed` | array of strings | `[]` | | worktree mode: additional project-relative gitignored files to copy into each worktree, one per line — on top of the adapter defaults above | +| `commit_message_template` | string | unset — blank = built-in default; {story_key} / {run_id} / {story_title} substituted | | commit message dev sessions use for a story/bundle commit when set | +| `failed_diff_max_mb` | integer | `5` | ≥ 1 | per-file size limit for untracked files captured into a kept-failed unit's changes.patch · oversized files are skipped with a marker | +| `failed_diff_unlimited` | boolean | `false` | | ⚠ ON: capture failed-unit diffs with NO size limit (overrides the cap above) — may produce very large patches; a warning is logged when active | + +### `[cleanup]` + +Disk reclamation for .bmad-loop/runs, plus each run's out-of-tree control-plane dir (terminal runs only). + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `run_retention` | integer | `10` | ≥ 0 | newest concluded runs kept whole · older ones are trimmed or archived by `bmad-loop clean` (0 = keep none by count) | +| `retention_days` | integer | `0` | ≥ 0 | 0 = disabled · else also keep runs newer than N days regardless of the count above | +| `trim_artifacts` | boolean | `true` | | drop the heavy worktrees/ tree from concluded runs · the run still lists in the dashboard with full status/journal/logs | +| `archive_old` | boolean | `true` | | archive (.bmad-loop/archive/<id>.tar.gz) rather than permanently delete runs beyond the retention window | +| `auto_clean_on_finish` | boolean | `true` | | reconcile worktrees leaked by a mid-flight stop + apply retention each time a run finishes cleanly | +| `clean_tmp` | boolean | `true` | | let engine plugins clean their /tmp scratch on finish (e.g. the Unity MCP server's downloaded zips) | + +### `[tui]` + +Dashboard rendering (slow-link / SSH tuning). + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `low_frame_rate` | boolean | `false` | | cap to 15fps + disable animations — fixes repaint tearing/garbage over slow or SSH links · takes effect next time the TUI launches | + +### `[operator]` + +Stories that owe external, human-only actions. + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `enabled` | boolean | `true` | | let a session finish + commit a story whose acceptance criteria need a human action (buy a domain, publish a DNS record) and park it as awaiting-operator instead of forcing done or blocked · what is owed is recorded in the story spec's operator_actions: frontmatter, and `bmad-loop confirm ` completes the story once you have done it | +| `on_review_demotion` | string | `"escalate"` | `escalate` / `park` | escalate: a review pass is never offered the park, and one that finalizes a done story at awaiting-operator is not accepted (default) · park: the review may demote it — the board moves done -> awaiting-operator, the story commits and parks · sprint mode with parks enabled only | + +### `[mux]` + +Terminal-multiplexer backend (machine-specific — policy.toml is gitignored). + +| Key | Type | Default | Allowed | Description | +| --- | --- | --- | --- | --- | +| `backend` | string | `""` | a registered backend name — see [multiplexer backends](../multiplexer-backends.md) | force a registered transport backend by name (see `bmad-loop mux`); leave blank to auto-select — BMAD_LOOP_MUX_BACKEND outranks it · takes effect on the next bmad-loop invocation | +| `honor_ambient_psmux_data_dir` | boolean | `false` | | psmux only: on — an absolute PSMUX_DATA_DIR your profile exports into every shell is used as the registry instead of bmad-loop's derived per-project one · off (default) — it is overridden · leave off for a value typed into one shell · takes effect on the next bmad-loop invocation; a running TUI refuses launches until restarted | + + + diff --git a/scripts/gen_policy_reference.py b/scripts/gen_policy_reference.py new file mode 100644 index 000000000..eb9cad0d4 --- /dev/null +++ b/scripts/gen_policy_reference.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Generate the policy settings tables in ``docs/reference/configuration.md``. + +The tables are rendered from the core settings schema +(``bmad_loop.settings_schema.load_core_schema()``, i.e. +``src/bmad_loop/data/settings/core.toml`` with its defaults and options resolved +from ``policy.py``), so the reference can never drift from the runtime model: +``tests/test_docs_hygiene.py`` runs ``--check`` and fails on a stale block. + +Only the text between the BEGIN/END markers is owned by this script; everything +else in the page is hand-written. The block sits inside prettier-ignore markers +so ``trunk fmt`` never re-pads the tables out of sync with this output. + +Rendering rules: one ``### `[section]` `` heading per schema section in schema +order, its description, then a ``Key | Type | Default | Allowed | Description`` +table. Defaults are TOML-style; a field with no default renders as unset (with +its settings-screen placeholder when it has one), except a ``lines`` list, which +reads as ``[]``. ``Allowed`` is the select options or the numeric bounds. The +``[mux] backend`` options are registered at runtime, not in the schema, so that +row links to the multiplexer guide instead. Plugin settings are not rendered — +each plugin's guide owns them. + +Usage:: + + uv run python scripts/gen_policy_reference.py # rewrite the block in place + uv run python scripts/gen_policy_reference.py --check # exit 1 if the block is stale (CI) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Any + +from bmad_loop.settings_schema import SectionSpec, SettingSpec, load_core_schema + +REPO = Path(__file__).resolve().parent.parent +TARGET = REPO / "docs" / "reference" / "configuration.md" + +BEGIN = "" +END = "" + +_TYPES = { + "select": "string", + "int": "integer", + "float": "float", + "str": "string", + "switch": "boolean", + "lines": "array of strings", + "args": "array of strings", +} + +# The one row whose options exist only at runtime (`settings_schema._inject_mux_backends`). +_MUX_BACKENDS = "a registered backend name — see [multiplexer backends](../multiplexer-backends.md)" +_RUNTIME_OPTIONS = {("mux", "backend"): _MUX_BACKENDS} + +_CODE_SPAN = re.compile(r"(`+)(.+?)\1") + + +def _escape(text: str) -> str: + """Make free text safe inside a table cell: escape ``|`` everywhere, and + ``*``/``<``/``>`` outside code spans (a bare ``attempt-preserve/*`` pair would + italicize, a bare ```` would vanish as an HTML tag).""" + out: list[str] = [] + pos = 0 + for m in _CODE_SPAN.finditer(text): + out.append(_escape_prose(text[pos : m.start()])) + out.append(m.group(0).replace("|", "\\|")) + pos = m.end() + out.append(_escape_prose(text[pos:])) + return "".join(out) + + +def _escape_prose(text: str) -> str: + return text.replace("|", "\\|").replace("*", "\\*").replace("<", "<").replace(">", ">") + + +def _toml(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + if isinstance(value, (tuple, list)): + return "[" + ", ".join(_toml(v) for v in value) + "]" + return str(value) + + +def _default(spec: SettingSpec) -> str: + if spec.default is not None: + return f"`{_toml(spec.default)}`".replace("|", "\\|") + if spec.kind == "lines": + return "`[]`" + if spec.placeholder: + return f"unset — {_escape(spec.placeholder)}" + return "unset" + + +def _allowed(section: str, spec: SettingSpec) -> str: + runtime = _RUNTIME_OPTIONS.get((section, spec.key)) + if runtime is not None: + return runtime + if spec.options: + return " / ".join(f"`{o}`" for o in spec.options) + if spec.minimum is not None and spec.maximum is not None: + return f"{str(spec.minimum)}–{str(spec.maximum)}" + if spec.minimum is not None: + return f"≥ {str(spec.minimum)}" + if spec.maximum is not None: + return f"≤ {str(spec.maximum)}" + return "" + + +def _sentence(text: str) -> str: + text = text.strip() + if not text: + return "" + text = text[0].upper() + text[1:] + return text if text[-1] in ".!?" else text + "." + + +def render_section(section: SectionSpec) -> list[str]: + lines = [f"### `[{section.name}]`", ""] + if section.description: + lines += [_escape(_sentence(section.description)), ""] + lines += [ + "| Key | Type | Default | Allowed | Description |", + "| --- | --- | --- | --- | --- |", + ] + for spec in section.fields: + cells = [ + f"`{spec.key}`", + _TYPES.get(spec.kind, spec.kind), + _default(spec), + _allowed(section.name, spec), + _escape(spec.description), + ] + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + return lines + + +def render_block() -> str: + """The whole generated block, markers included, newline-terminated.""" + lines = [BEGIN, "", ""] + for section in load_core_schema(): + lines += render_section(section) + lines += ["", END] + return "\n".join(lines) + "\n" + + +def splice(page: str, block: str) -> str: + """Replace the marked block in ``page`` with ``block``; exit if the markers are missing.""" + start = page.find(BEGIN) + end = page.find(END) + if start == -1 or end == -1 or end < start: + sys.exit(f"gen_policy_reference: {TARGET.relative_to(REPO)} lacks the BEGIN/END markers") + stop = end + len(END) + if page[stop : stop + 1] == "\n": + stop += 1 + return page[:start] + block + page[stop:] + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="gen_policy_reference.py", description=__doc__) + p.add_argument( + "--check", action="store_true", help="exit 1 if the committed block is stale; write nothing" + ) + args = p.parse_args(argv) + page = TARGET.read_text(encoding="utf-8") + updated = splice(page, render_block()) + rel = TARGET.relative_to(REPO).as_posix() + if args.check: + if updated != page: + print( + f"policy-reference: {rel} is STALE against src/bmad_loop/data/settings/core.toml" + " — run `uv run python scripts/gen_policy_reference.py` and commit the result", + file=sys.stderr, + ) + return 1 + print(f"policy-reference: {rel} is current") + return 0 + if updated == page: + print(f"policy-reference: {rel} already current") + else: + TARGET.write_text(updated, encoding="utf-8", newline="\n") + print(f"policy-reference: rewrote {rel}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_docs_hygiene.py b/tests/test_docs_hygiene.py new file mode 100644 index 000000000..13500ff8d --- /dev/null +++ b/tests/test_docs_hygiene.py @@ -0,0 +1,239 @@ +"""Docs hygiene guard: links resolve, anchors resolve, generated tables are current. + +The docs are a cross-linked set — README, AGENTS, CONTRIBUTING and everything +under ``docs/`` — and nothing else checks that a link still lands when a page is +split, a heading renamed, or a section moved. This guard holds that line: + +* ``test_relative_doc_links_resolve`` — every scheme-less ``](target)`` names a + tracked file or directory, resolved against the linking doc's directory. +* ``test_doc_anchor_links_resolve`` — every ``#fragment`` (``](#x)`` within a page, + or ``](page.md#x)`` across pages) is a heading slug or ```` of its target. +* ``test_policy_reference_is_current`` — the generated settings tables in + ``docs/reference/configuration.md`` match ``core.toml`` (``scripts/gen_policy_reference.py --check``). + +The corpus is the tracked ``*.md`` files among README.md, CONTRIBUTING.md, +AGENTS.md and ``docs/**``. Fenced code blocks and inline code spans are stripped +before scanning, so a link shown as an example is not a link. Anchors follow +GitHub's slugger: lowercase, drop every character that is not a word character, +space or hyphen, spaces become ``-``, and a repeated slug gets ``-1``, ``-2``…. + +If this test flags something, fix the link (or restore the heading it named), +never the guard. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from collections import Counter +from functools import cache +from pathlib import Path, PurePosixPath +from urllib.parse import unquote + +REPO = Path(__file__).resolve().parent.parent + +_CORPUS_ROOTS = ("README.md", "CONTRIBUTING.md", "AGENTS.md") +_FENCE = re.compile(r"^ *(`{3,}|~{3,})") +_CODE_SPAN = re.compile(r"(`+)(.+?)\1") +_HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$") +_HTML_ID = re.compile(r"""]*?\b(?:id|name)\s*=\s*["']([^"']+)["']""", re.IGNORECASE) +_HTML_TAG = re.compile(r"<[^>]+>") +_MD_LINK_TEXT = re.compile(r"!?\[([^\]]*)\]\([^)]*\)") +_LINK = re.compile(r"\]\(\s*(<[^>]*>|[^)\s]*)") +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") + + +@cache +def _tracked() -> tuple[str, ...]: + out = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO, + capture_output=True, + check=True, + ).stdout + return tuple(p for p in out.decode("utf-8").split("\0") if p) + + +@cache +def _tracked_paths() -> frozenset[str]: + """Every tracked file plus every directory that holds one (POSIX, repo-relative).""" + paths: set[str] = set() + for f in _tracked(): + paths.add(f) + paths.update(str(parent) for parent in PurePosixPath(f).parents if str(parent) != ".") + return frozenset(paths) + + +def _corpus() -> list[str]: + return sorted( + f for f in _tracked() if f.endswith(".md") and (f in _CORPUS_ROOTS or f.startswith("docs/")) + ) + + +@cache +def _unfenced(rel: str) -> tuple[str, ...]: + """The doc's lines with fenced code blocks blanked (line numbers preserved).""" + lines = (REPO / rel).read_text(encoding="utf-8").splitlines() + out: list[str] = [] + fence: str | None = None + for line in lines: + m = _FENCE.match(line) + if fence is None: + if m: + fence = m.group(1) + out.append("") + continue + out.append(line) + else: + if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence): + if not line.strip().lstrip(fence[0]): + fence = None + out.append("") + return tuple(out) + + +def slug(heading: str) -> str: + """GitHub's anchor for a heading's raw markdown text (before de-duplication).""" + text = _MD_LINK_TEXT.sub(r"\1", heading) + text = _HTML_TAG.sub("", text) + text = text.strip().lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +@cache +def anchors(rel: str) -> frozenset[str]: + """Every fragment a link may name in ``rel``: de-duplicated heading slugs plus + explicit ````/```` ids.""" + seen: Counter[str] = Counter() + found: set[str] = set() + for line in _unfenced(rel): + found.update(_HTML_ID.findall(line)) + m = _HEADING.match(line) + if not m: + continue + base = slug(m.group(2)) + n = seen[base] + seen[base] += 1 + found.add(base if n == 0 else f"{base}-{n}") + return frozenset(found) + + +def _links(rel: str): + """Yield ``(line_no, target)`` for every inline link/image target in ``rel``, + outside fenced blocks and code spans.""" + for no, line in enumerate(_unfenced(rel), start=1): + for m in _LINK.finditer(_CODE_SPAN.sub("", line)): + target = m.group(1) + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + if target: + yield no, target + + +def _resolve(rel: str, path: str) -> str: + """Repo-relative POSIX path a link's path part names (``/x`` is repo-rooted).""" + if path.startswith("/"): + joined = PurePosixPath(path.lstrip("/")) + else: + joined = PurePosixPath(rel).parent / path + parts: list[str] = [] + for part in joined.parts: + if part == "..": + if parts: + parts.pop() + else: + parts.append("..") + elif part != ".": + parts.append(part) + return "/".join(parts) + + +def _local_links(): + """Yield ``(rel, line_no, target_rel, fragment)`` for every scheme-less link.""" + for rel in _corpus(): + for no, target in _links(rel): + if _SCHEME.match(target): + continue + path, _, frag = target.partition("#") + target_rel = _resolve(rel, unquote(path)) if path else rel + yield rel, no, target_rel, unquote(frag) + + +def test_slugger_matches_github_on_known_anchors(): + """Premise for the anchor test: the slugger reproduces anchors GitHub renders + for these docs, so a mismatch below is a broken link, not a slugger bug.""" + assert slug("Trust & safety") == "trust--safety" + assert slug("Policy (`.bmad-loop/policy.toml`)") == "policy-bmad-looppolicytoml" + assert slug("psmux (native Windows, experimental)") == "psmux-native-windows-experimental" + assert slug("### `[adapter.dev]`".lstrip("# ")) == "adapterdev" + assert slug("[Linked](x.md) heading") == "linked-heading" + real = { + "docs/plugin-authoring-guide.md": "trust--safety", + "README.md": "policy-bmad-looppolicytoml", + "docs/multiplexer-backends.md": "psmux-native-windows-experimental", + "docs/FEATURES.md": "resolve-reverify", + } + missing = {rel: a for rel, a in real.items() if a not in anchors(rel)} + assert not missing, f"slugger lost known real anchors: {missing}" + + +def test_corpus_is_the_tracked_docs(): + """Premise: the scan covers the docs it claims to (an empty corpus passes every check).""" + corpus = _corpus() + for required in (*_CORPUS_ROOTS, "docs/README.md", "docs/FEATURES.md"): + assert required in corpus, f"{required} missing from the docs corpus" + assert "CHANGELOG.md" not in corpus + + +def test_relative_doc_links_resolve(): + """Every scheme-less link names a tracked file or directory.""" + tracked = _tracked_paths() + checked = 0 + offenders: list[str] = [] + for rel, no, target_rel, _frag in _local_links(): + if target_rel == rel: + continue + checked += 1 + if target_rel not in tracked: + offenders.append(f" {rel}:{no} → {target_rel}") + assert checked > 50, f"premise: expected many relative links, scanned {checked}" + assert not offenders, ( + "relative doc links naming no tracked file or directory — fix the link " + "(or restore the page it named; a new page counts once `git add`ed):\n" + + "\n".join(offenders) + ) + + +def test_doc_anchor_links_resolve(): + """Every ``#fragment`` into a markdown page is one of that page's anchors.""" + tracked = _tracked_paths() + checked = 0 + offenders: list[str] = [] + for rel, no, target_rel, frag in _local_links(): + if not frag or not target_rel.endswith(".md") or target_rel not in tracked: + continue # a missing page is test_relative_doc_links_resolve's finding + checked += 1 + if frag not in anchors(target_rel): + offenders.append(f" {rel}:{no} → {target_rel}#{frag}") + assert checked > 20, f"premise: expected many anchor links, scanned {checked}" + assert not offenders, ( + "doc links to an anchor the target page does not have — repoint the link " + "(or restore the heading / it named):\n" + "\n".join(offenders) + ) + + +def test_policy_reference_is_current(): + """The generated settings tables match the core schema; regenerate on drift.""" + proc = subprocess.run( + [sys.executable, "scripts/gen_policy_reference.py", "--check"], + cwd=REPO, + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, ( + f"gen_policy_reference.py --check exited {proc.returncode}\n" + f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + ) From ecdf8b3757f9f39975ec503fe4745ccae6b02ba5 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 01:28:02 -0700 Subject: [PATCH 2/9] docs(reference): add CLI and run-state reference pages --- docs/README.md | 2 + docs/reference/cli.md | 663 ++++++++++++++++++++++++++++++++ docs/reference/run-state.md | 165 ++++++++ tests/test_portability_guard.py | 7 +- 4 files changed, 834 insertions(+), 3 deletions(-) create mode 100644 docs/reference/cli.md create mode 100644 docs/reference/run-state.md diff --git a/docs/README.md b/docs/README.md index 59585fcde..6d41ebcfb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ guides below go deeper, roughly in the order you'll need them. ## Reference - **[Configuration](reference/configuration.md)** — `.bmad-loop/policy.toml` location and precedence, the `BMAD_LOOP_*` environment variables, and every policy setting with its type, default and allowed values (generated from the settings schema). +- **[CLI](reference/cli.md)** — every `bmad-loop` subcommand and its options, the `--json` document shapes, exit codes, and the `validate` check ids. +- **[Run state](reference/run-state.md)** — where run state lives, the run and task directory layout, session naming and teardown, stopping a run, and what `cleanup`, `clean`, `delete` and `archive` remove. ## Extending bmad-loop diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 000000000..08772c0b2 --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,663 @@ +# CLI reference + +Every `bmad-loop` subcommand, its options, its `--json` document, the process exit codes, and the `validate` check ids. `bmad-loop --help` prints the same option list from the installed version. For where run state lives on disk, see [run state](run-state.md); for policy keys, see [configuration](configuration.md). + +## Global options + +- The top-level parser documents only `-h` and `--version`. A subcommand is required; a missing or unknown one exits 2. +- **`--project PROJECT`** — every subcommand except `relay` accepts it. It names the target project root; the default is the current directory. Before dispatch, these subcommands apply the project's `[mux] backend` choice; a broken `policy.toml` falls back to auto-selection there. Commands that need the policy load it themselves and fail with `error: …`, exit 1. +- **`--json`** — accepted by `validate`, `plugins`, `probe-adapter`, `decisions`, `confirm`, `list`, `status`, `diagnose`, `cleanup` and `clean`. See [Machine-readable output](#machine-readable-output---json). + +### Run references + +`resume`, `resolve`, `status`, `attach`, `stop`, `delete`, `archive` and `diagnose` take a `run_id` that may be partial. A run's short ref is the segment after the last `-` of its id (`bmad-loop list` prints it). + +| Ref | Resolves to | +| ------------------- | ----------------------------------------------------------------------------------------- | +| exact run id | that run | +| partial | the one run whose short ref starts with it, or whose full id ends with it | +| empty | refused (`empty run ref`) | +| no match | `no such run: `, exit 1 | +| several matches | `ambiguous run ref '' matches N runs:` plus each id, exit 1 | +| omitted | `status`, `attach`, `diagnose` only: the newest run; `no runs found` and exit 1 when none | +| unreadable run list | refused, naming the fault, when the unreadable entries could change the answer | + +`clean --keep RUN_ID` is the exception: it compares full run ids exactly. + +## Subcommands + +Listed in `bmad-loop --help` order. `--project` is omitted from the option tables below. + +### `init` + +Installs the hook relay, the bundled `bmad-loop-*` skills, the policy template, and `.gitignore` entries into the target project. + +```text +bmad-loop init [--project PROJECT] [--cli PROFILE] [--no-skills] [--force-skills] +``` + +| Option | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--cli PROFILE` | Profile to register hooks for; repeatable. A packaged profile (`claude`, `codex`, `gemini`, `copilot`, `antigravity`, `opencode-http`) or an overlay profile name. Default: the profiles `policy.toml` names across all roles, else `claude`. | +| `--no-skills` | Skip the skill copy; write only hooks, policy and `.gitignore`. | +| `--force-skills` | Replace `bmad-loop-*` skill directories that already exist (default: keep them and print a hint). | + +- For each profile, merges a `bmad-loop relay ` registration (absolute path of this installation's console script) into the profile's hook config file. Hookless profiles print `no hooks needed`. +- Copies `bmad-loop-resolve`, `bmad-loop-sweep` and `bmad-loop-setup` into each profile's skill tree. Writes `.bmad-loop/policy.toml` only when it is missing. Appends any missing `.gitignore` lines from `.bmad-loop/runs/`, `.bmad-loop/cache/`, `.bmad-loop/policy.toml`, `_bmad/render/`. +- Exits 1 with `FAIL: …` on an unknown profile, a target path that resolves outside the project, or a hook config file that is not valid JSON. + +### `validate` + +Runs the preflight checks. + +```text +bmad-loop validate [--project PROJECT] [--spec FOLDER] [--render-probe] [--probes] [--json] +``` + +| Option | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `--spec FOLDER` | Validate stories mode against `FOLDER/stories.yaml`. Overrides `[stories] source` and skips the sprint-status gate. | +| `--render-probe` | Also run each dev skill's render command in a throwaway copy of `_bmad/` (needs network access). | +| `--probes` | Also run every `[environment]` probe on this host. All probes run, each bounded by `probe_timeout_s`; any failure is a problem. | +| `--json` | Print the findings as one JSON document. | + +- Exit 0 when no finding has severity `problem`, else 1. Warnings do not change the exit code. +- Runs no project-controlled commands unless `--render-probe` or `--probes` is given. +- Checks are chained: when an earlier gate fails (for example the policy does not load), the checks that depend on it emit nothing. A missing check id means the check did not run, not that it passed. Every id is listed under [validate checks](#validate-checks). + +JSON: [`validate --json`](#validate---json). + +### `mux` + +Lists the registered terminal-multiplexer backends and the selected one, or saves a backend choice. + +```text +bmad-loop mux [--project PROJECT] [--clear] [--force] [{set}] [name] +``` + +| Option / action | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | +| _(none)_ | Print a `NAME PLATFORM AVAILABLE VERSION SELECTED` table with the selection reason. | +| `set ` | Save `name` as `[mux] backend` in `.bmad-loop/policy.toml` (created from the template if missing). | +| `set --clear` | Comment the key back out so auto-selection applies. | +| `--force` | With `set`: save a name that is not registered in this process (for example a backend installed only elsewhere). | + +- Built-in backends: `tmux` (non-Windows) and `psmux` (Windows). Selection precedence is described in [multiplexer backends](../multiplexer-backends.md). +- If the forced backend (`BMAD_LOOP_MUX_BACKEND` or `[mux] backend`) cannot be resolved, the table still prints, then `FAIL: …` and exit 1. Never prompts. + +### `plugins` + +Lists discovered plugins and their trust grants, or manages a project plugin's grant. + +```text +bmad-loop plugins [--project PROJECT] [--json] [{list,enable,accept,revoke}] [name] +``` + +| Action | Effect | +| --------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `list` | Default. Print `NAME ORIGIN SURFACE GRANT ENABLED PATH`. Exits 0 whatever the trust state (`validate` owns the verdict). | +| `enable ` | Hash the plugin directory without running it and record the grant. Refuses a grant that already exists and changed. | +| `accept ` | Re-pin a granted plugin that changed, after printing the file changes. | +| `revoke ` | Remove the grant (works after the plugin directory is gone). | + +- GRANT is one of `granted`, `ungranted`, `mismatch`, `stale`, `unreadable`, `missing` (grant without a plugin) or `n/a` (no grant needed). The grant store lives outside the project tree, under the [state root](run-state.md#state-root). +- `--json` works only with `list`; with another action it exits 2. A name with `list` exits 1. +- Refuses (exit 1) built-in and entry-point plugins (trusted by provenance; enable those with `[plugins] enabled`), plugins with no executable surface, and an unreadable grant store. Never writes `policy.toml`. + +JSON: [`plugins --json`](#plugins---json). + +### `adapters` + +Lists the registered coding-CLI adapter kinds and which profiles select each. + +```text +bmad-loop adapters [--project PROJECT] +``` + +- Prints `NAME ORIGIN NEEDS MUX PROFILES`. ORIGIN is `builtin` or `external`. A profile's `adapter` field selects its kind (default `generic`). +- Warns on stderr about profiles naming an unregistered kind and about external packages that failed to load; exits 0. A malformed project profile overlay exits 1. + +### `probe-adapter` + +Collects and sanitizes the data needed to finalize a coding-CLI profile. Alias: `collect-adapter-data`. See the [adapter authoring guide](../adapter-authoring-guide.md). + +```text +bmad-loop probe-adapter [--project PROJECT] [--probe] [--transcript TRANSCRIPT] [--session-dir SESSION_DIR] + [--binary BINARY] [--model MODEL] [--workspace PATH] [--timeout TIMEOUT] [--out OUT] [--json] cli +``` + +| Option | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `cli` | Profile name. Hookless (HTTP) profiles are refused. | +| `--probe` | Live capture: one trivial turn in a temp workspace, recording only the shape of each hook payload. Default: a scan that launches nothing. | +| `--transcript TRANSCRIPT` | Transcript file to inspect; skips discovery. | +| `--session-dir SESSION_DIR` | Directory searched for the newest transcript. | +| `--binary BINARY` | Binary for a CLI with no profile; produces a reduced report. | +| `--model MODEL` | Model for the probe turn (`--probe` only). | +| `--workspace PATH` | Also report whether PATH is in the profile's `[workspace_trust]` list; exit 1 unless trusted or when the profile declares no such list. | +| `--timeout TIMEOUT` | Probe turn timeout in seconds. Default: 90. | +| `--out OUT` | Write the report (or JSON document) to this file instead of stdout. | +| `--json` | Emit a JSON document instead of the Markdown report. | + +- Exits 1 when the profile is unknown (without `--binary`), the leak self-check fires (nothing is written), or hook or workspace trust is not `trusted` (the report is still written). + +JSON: [`probe-adapter --json`](#probe-adapter---json). + +### `run` + +Runs the story loop (dev → review → verify → commit) over actionable stories from `sprint-status.yaml`, or from `stories.yaml` in stories mode. + +```text +bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] [--max-stories MAX_STORIES] [--dry-run] +``` + +| Option | Description | +| --------------------------- | ------------------------------------------------------------------------------------------- | +| `--spec FOLDER` | Stories mode: dispatch from `FOLDER/stories.yaml`. Overrides `[stories] source`. | +| `--epic EPIC` | Only stories of this epic (integer). Sprint mode only; ignored with a note in stories mode. | +| `--story STORY` | Sprint mode: `E-S` / `E.S`, a slug fragment, or a full key. Stories mode: a story id. | +| `--max-stories MAX_STORIES` | Stop after N stories. | +| `--dry-run` | Print the per-story plan and spawn nothing. | + +- Sprint mode selects stories at `backlog` or `ready-for-dev`. A `--story` that matches nothing, matches several stories, or is not actionable exits 1. +- Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. +- Prints `run starting (attach: bmad-loop attach)`, then the run summary; exits 0 once the engine has run. + +### `sweep` + +Triages open entries in the deferred-work ledger and executes them as bundles through the normal pipeline. With `--archive`, moves closed entries to an archive file instead. + +```text +bmad-loop sweep [--project PROJECT] [--no-prompt] [--decisions-only] [--max-bundles MAX_BUNDLES] + [--only DW-ID,... | --min-severity {low,medium,high,critical}] [--repeat | --no-repeat] + [--max-cycles MAX_CYCLES] [--dry-run] [--archive] [--before DATE] +``` + +| Option | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------- | +| `--no-prompt` | Skip decision prompts; run only decision-free bundles. | +| `--decisions-only` | Triage and record decisions; run no bundles. | +| `--max-bundles MAX_BUNDLES` | Override `[sweep] max_bundles`. | +| `--only DW-ID,...` | Triage only these open entries (comma-separated). Exclusive with `--min-severity`. | +| `--min-severity {low,…}` | Triage entries at this severity or higher. Exclusive with `--only`. | +| `--repeat`, `--no-repeat` | Override `[sweep] repeat` (re-triage newly deferred work after each cycle). | +| `--max-cycles MAX_CYCLES` | Override `[sweep] max_cycles`. | +| `--dry-run` | List open entries (or, with `--archive`, the entries that would move) and write nothing. | +| `--archive` | Move closed entries to `deferred-work-archive.md`, leaving an id-preserving stub in the ledger. | +| `--before DATE` | With `--archive` only: archive entries closed before this ISO date. | + +- `--archive` starts no sessions and combines only with `--dry-run` and `--before`. It is refused while any run of the project may be live. +- A normal sweep has the same launch refusals as `run`, plus a missing `bmad-loop-sweep` skill. +- Prints `sweep starting (attach: bmad-loop attach)`, then the summary. Exits 0, except 1 when the engine crashed on an `--only` sweep. + +### `resume` + +Continues a paused or interrupted run or sweep. + +```text +bmad-loop resume [--project PROJECT] [--accept-baseline] run_id +``` + +| Option | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--accept-baseline` | For this resume only, adopt the current HEAD as the baseline of every restarted in-place story instead of preserving commits above the old baseline. | + +- Refused with exit 1 for a live engine, a finished run, a sweep whose ledger cannot be read, and the same git-floor, isolation, skill and plugin-trust gates as `run`. Does not require a clean worktree. +- Discards any pending stop request and re-reads the policy and config before continuing. + +### `resolve` + +Resolves an escalated story, then re-arms it and resumes the run. Without a mode flag it launches an interactive resolve agent for a CRITICAL escalation. Procedures: [escalation recovery](../FEATURES.md#resolve-reverify). + +```text +bmad-loop resolve [--project PROJECT] [--story STORY] [--no-interactive] + [--restore-patch PATH | --adopt-branch | --reverify | --abandon] [--resume | --no-resume] [--force] run_id +``` + +| Option | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `--story STORY` | Story to resolve. Default: the run's paused story. | +| `--no-interactive` | Skip the resolve agent (the spec was fixed by hand), then re-arm and resume. With `--abandon`: skip the confirm prompt. | +| `--restore-patch PATH` | Re-arm the spec to `in-review` and re-apply this saved patch, so review resumes on the attempted change. | +| `--adopt-branch` | Commit and merge the kept worktree branch as-is and mark the story done. Worktree isolation only; not for sweeps. | +| `--reverify` | Keep a DEFERRED (or environment-fault ESCALATED) story's attempt, replay `[verify]`, then review and commit. No dev session; not for sweeps. | +| `--abandon` | Close an escalated replay run unmerged: park dirty work on a preserve ref, remove the worktree, keep the branch. | +| `--resume` / `--no-resume` | Resume without prompting / only re-arm. Default: prompt, then resume. Not with `--abandon` (exit 2). | +| `--force` | Proceed when engine liveness cannot be verified. A provably live engine still blocks. | + +- `--restore-patch`, `--adopt-branch`, `--reverify` and `--abandon` are mutually exclusive. +- `--reverify --story KEY` on a finished sprint run creates a new replay run for a kept DEFERRED worktree unit; the finished run is not modified. +- Refuses with exit 1, leaving the run unchanged, when the run is not paused at an escalation (`--reverify --story` accepts any pause), the engine is live, there is no escalated story, or a DEFERRED story is targeted without `--reverify`. Declining the confirm prompt exits 0 and leaves the run paused. + +### `decisions` + +Answers deferred-work decisions that earlier sweeps left unanswered. + +```text +bmad-loop decisions [--project PROJECT] [--list] [--json] +``` + +| Option | Description | +| -------- | ------------------------------------------------------------------ | +| `--list` | List pending decisions with their options and the recommended one. | +| `--json` | Emit the pending decisions as one JSON document. Implies `--list`. | + +- Prompts once per decision. `close` marks the ledger entry done immediately; `build` and `keep-open` are saved to `.bmad-loop/decisions.json` for the next sweep. +- Exits 1 if an answer fails to record. Unreadable ledger or run directories produce a `note:` on stderr and exit 0. + +JSON: [`decisions --json`](#decisions---json). + +### `confirm` + +Completes a story parked at `awaiting-operator` once the external actions it lists are done. Nothing is re-driven. + +```text +bmad-loop confirm [--project PROJECT] [--list] [--yes] [--reverify] [--json] [story_key] +``` + +| Option | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------- | +| `story_key` | Parked story to confirm. | +| `--list` | List parked stories and their owed actions. | +| `--yes` | Skip the per-action acknowledgment prompt. | +| `--reverify` | After the acknowledgments, run the environment probes and `[verify]` commands; any failure blocks (exit 1). | +| `--json` | Emit the parked set as one JSON document. Implies `--list`. | + +- After each action is acknowledged, appends an `## Operator Confirmation` section to the spec, sets the spec and board to `done`, removes the park record, and commits the result (best-effort). Declining any action changes nothing and exits 0. +- Exits 2 without a `story_key`, `--list` or `--json`. Exits 1 when the story is not parked in this checkout or its park record disagrees with the committed state. + +JSON: [`confirm --json`](#confirm---json). + +### `list` + +Lists every run and sweep. Alias: `ls`. + +```text +bmad-loop list [--project PROJECT] [--json] +``` + +- Prints `REF TYPE STATUS RUN ID`, oldest first. Status is `running`, `paused`, `finished`, `stopped`, `crashed`, `interrupted` or `unknown` (see [derived status](run-state.md#derived-run-status)). +- Prints `no runs found` and exits 0 when there are none. A run whose `state.json` cannot be read is listed with TYPE `?` and status `unknown`. When the run listing itself is incomplete, stderr gets `warning: run listing incomplete: …`. + +JSON: [`list --json`](#list---json). + +### `status` + +Shows a run's state: status line, token totals, one row per story (phase, dev and review counts, tokens, commit or defer reason, preserve ref), the sprint backlog remaining (stories mode: the stories done count), and the count of pending decisions. + +```text +bmad-loop status [--project PROJECT] [--json] [run_id] +``` + +- The status line (`status: …`) is `finished`, `PAUSED () — `, `in progress — graceful stop pending (will stop after the current item)`, or `in progress (or interrupted)`. +- Exits 1 with `no runs found`, or on a bad or ambiguous ref (stdout stays empty under `--json`). + +JSON: [`status --json`](#status---json). + +### `diagnose` + +Writes a sanitized diagnostic dump of a run or sweep to hand to maintainers. Alias: `diag`. + +```text +bmad-loop diagnose [--project PROJECT] [--all] [--out OUT] [--json] [--max-journal-entries N] [run_id] +``` + +| Option | Description | +| ------------------------- | ----------------------------------------------------------------------------- | +| `--all` | Dump every run in the project (`run_id` is ignored). | +| `--out OUT` | Write the report or JSON document to this file instead of stdout. | +| `--json` | Emit JSON instead of the Markdown report. | +| `--max-journal-entries N` | Maximum scrubbed journal entries per run; `0` = histogram only. Default: 200. | + +- Identifiers are pseudonymized and the output is re-scanned before it is written. If the leak self-check fires, it prints `FAIL: refusing to emit …` on stderr, writes nothing, and exits 1. +- With `--out`, the `ok: …` confirmation goes to stdout in text mode and to stderr under `--json`. + +JSON: [`diagnose --json`](#diagnose---json). + +### `attach` + +Attaches the terminal to a run through the selected multiplexer backend. + +```text +bmad-loop attach [--project PROJECT] [run_id] +``` + +- Attaches to the run's control window when a sweep is waiting on a decision or no agent session is live; otherwise to the live agent session. +- Exits 1 with `nothing to attach for run ` when there is no target; otherwise exits with the multiplexer client's exit code. + +### `stop` + +Stops a run. The default is a hard stop. See [stopping a run](run-state.md#stopping-a-run). + +```text +bmad-loop stop [--project PROJECT] [--graceful | --cancel-graceful] run_id +``` + +| Option | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------- | +| `--graceful` | Let the in-flight story or bundle finish through commit, then stop resumably. Suppresses pending auto-sweeps. | +| `--cancel-graceful` | Withdraw a pending stop request of either mode. | + +- Hard stop: exits 0 once the engine is stopped, or marks a non-finished run with a dead engine `stopped`. A finished run prints `already finished` and exits 1. Exits 1 if the engine's process identity cannot be verified. +- `--graceful` exits 0 when the request is lodged or one is already pending; exits 1 when the run is finished or its engine is provably dead (use `resume`). +- `--cancel-graceful` exits 1 when nothing was pending. + +### `delete` + +Permanently removes a run directory and its out-of-tree control-plane directory. + +```text +bmad-loop delete [--project PROJECT] [--force] run_id +``` + +| Option | Description | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `--force` | Hard-stop a live engine first (this ends its agent session), and skip the live-agent-session guard (skipping it kills no session). | + +- Without `--force`, refuses (exit 1) when the engine is provably alive or an agent session that may belong to the project is live. Unverifiable liveness only warns. +- Also prunes git worktree entries for worktrees the run held. + +### `archive` + +Compresses a run into `.bmad-loop/archive/.tar.gz`, then removes the run directory and its control-plane directory. To archive the deferred-work ledger, use `sweep --archive`. + +```text +bmad-loop archive [--project PROJECT] [--force] run_id +``` + +- `--force` and the refusals are the same as `delete`. The tarball holds the in-tree run directory only. + +### `cleanup` + +Removes leftover multiplexer sessions and parked control windows for this project's runs. Scope: [cleanup vs clean](run-state.md#reclaiming-sessions-and-disk). + +```text +bmad-loop cleanup [--project PROJECT] [--dry-run] [--json] +``` + +| Option | Description | +| ----------- | ----------------------------------------- | +| `--dry-run` | List what would be removed; kill nothing. | +| `--json` | Emit one JSON document instead of text. | + +- Always exits 0. Scan failures and kills that could not be verified are reported on stderr (or in the JSON document). + +JSON: [`cleanup --json`](#cleanup---json). + +### `clean` + +Reclaims disk from concluded runs according to `[cleanup]`. Scope: [cleanup vs clean](run-state.md#reclaiming-sessions-and-disk). + +```text +bmad-loop clean [--project PROJECT] [--dry-run] [--keep RUN_ID] [--retain N] [--hard] [--json] +``` + +| Option | Description | +| --------------- | ----------------------------------------------------------------------------------------------------- | +| `--dry-run` | List what would be reclaimed; remove nothing. | +| `--keep RUN_ID` | Never touch this run (full id, exact match). Repeatable. | +| `--retain N` | Keep the newest N concluded runs whole. Overrides `[cleanup] run_retention`. `0` keeps none by count. | +| `--hard` | Delete runs past retention instead of archiving them. | +| `--json` | Emit one JSON document instead of text. | + +- Exits 0 once the policy loads; per-run faults are warnings on stderr or fields in the JSON document. + +JSON: [`clean --json`](#clean---json). + +### `tui` + +Opens the interactive dashboard. Needs the `tui` extra (`uv tool install 'bmad-loop[tui]'`); without it, prints an install hint and exits 1. See the [TUI guide](../tui-guide.md). + +```text +bmad-loop tui [--project PROJECT] [--low-frame-rate] +``` + +| Option | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--low-frame-rate` | Cap rendering at 15 fps and turn off animations (for slow or SSH links). `[tui] low_frame_rate` does the same. Existing `TEXTUAL_FPS` / `TEXTUAL_ANIMATIONS` values win. | + +### `relay` + +Writes one session event file from a coding-CLI hook payload on stdin. A hook target that `init` registers, not a command to run by hand. + +```text +bmad-loop relay [event] +``` + +- `event` is the canonical event name (`Stop`, `SessionStart`, …); default `Unknown`. +- Does nothing unless `BMAD_LOOP_RUN_DIR` and `BMAD_LOOP_TASK_ID` are set (the session was launched by bmad-loop). Writes the event to `$BMAD_LOOP_EVENTS_DIR` (default `$BMAD_LOOP_RUN_DIR/events`). +- Always exits 0 and never writes to stdout. Takes no `--project` and never reads policy. + +## Machine-readable output (`--json`) + +- **stdout** carries exactly one JSON object and nothing else. +- **`schema_version`** is a top-level integer in every document, versioned per command. Changes are additive: a new field may appear without a bump; removing or renaming a field, or changing a field's type or meaning, bumps that command's version. +- **Errors** produce no document: the message goes to stderr, stdout stays empty, and the exit code is nonzero. +- **Verdict commands** emit the full document and also exit nonzero to carry their answer: `validate` (read `ok`) and `probe-adapter` (read `hook_trust` / `workspace_trust`). Parse non-empty stdout whatever the exit code. +- **stderr** carries notes, warnings and `ok:` confirmations. Warnings that text mode prints become document fields in JSON mode where the schema has one. +- **`--out FILE`** (`diagnose`, `probe-adapter`) writes the same bytes `--json > FILE` would; stdout is then empty, and the exit code still carries any verdict. +- `diagnose` and `probe-adapter` documents have sorted keys and pass the leak guard; an optional top-level `backstop_repairs` appears when the guard pseudonymized a stray value. + +| Command | `schema_version` | +| --------------- | ---------------- | +| `validate` | 1 | +| `plugins` | 1 | +| `probe-adapter` | 2 | +| `decisions` | 1 | +| `confirm` | 1 | +| `list` | 1 | +| `status` | 1 | +| `diagnose` | 4 | +| `cleanup` | 2 | +| `clean` | 1 | + +### `validate --json` + +| Field | Type | Meaning | +| ------------- | ------ | -------------------------------------------------------- | +| `ok` | bool | no finding has severity `problem`; matches the exit code | +| `mode` | str | `sprint` or `stories` | +| `spec_folder` | str | stories spec folder; `""` in sprint mode | +| `counts` | object | `{ok, warning, problem}` counts | +| `findings` | array | one entry per finding, in the order the checks ran | + +Each finding has `check` (the stable [check id](#validate-checks) — the only field safe to match on), `severity` (`ok` / `warning` / `problem`), `message` (free text), and `detail` (check-specific object, or null). + +### `plugins --json` + +| Field | Type | Meaning | +| ------------- | ----------- | -------------------------------------------------------------------------- | +| `plugins` | array | one row per discovered plugin, plus one per grant whose plugin is gone | +| `store_fault` | str \| null | why the grant store could not be read (every row is then judged ungranted) | + +Each row: `name`, `origin`, `path` (POSIX path from the code root), `surface` (`{hooks, workflows, python, seeds}` bools), `grant_required`, `status` (the GRANT values above), `enabled` (listed in `[plugins] enabled`), `active` (a launch would run it), `pinned_digest`, `current_digest`, `changed` (list of paths). + +### `probe-adapter --json` + +| Field | Type | Meaning | +| ------------------------------- | -------------- | --------------------------------------------------------------- | +| `cli`, `binary` | str | probed CLI and binary | +| `mode` | str | `scan` or `probe` | +| `known_profile`, `binary_found` | bool | a packaged profile exists; the binary resolved | +| `dialect` | str \| null | hook dialect | +| `usage_parser` | str | usage parser (`none` without a known profile) | +| `hooks_registered` | bool \| null | relay hooks are present | +| `hook_trust`, `workspace_trust` | str \| null | `trusted`, `untrusted` or `unverifiable` | +| `declared_events` | object | native event → canonical event, as the profile declares | +| `version`, `help` | str \| null | the CLI's own `--version` / `--help` output | +| `captured_events` | array | `{native_event, canonical_event, payload_keys, payload_schema}` | +| `transcript` | object \| null | location, format and size of the discovered transcript | +| `tokens` | object \| null | token-schema inference | +| `warnings`, `next_steps` | list[str] | advisory text | + +### `decisions --json` + +`decisions`: pending decisions in ledger order. Each has `id`, `question`, `context`, `recommendation`, and `options[]`; each option has `key`, `label`, `effect`, `intent`, `resolution`, `bundle_name`, and `recommended` (true when `key == recommendation`). + +### `confirm --json` + +`parked`: stories at `awaiting-operator`, in story-key order. Each has `story_key`, `actions` (list of str), `spec_file`, `spec_status`, `board_status`, `commit` (`""` until a commit carries the park record), `run_id`, `parked_at`, `confirmable`, `confirmation_recorded`, `resumable` (an interrupted confirmation `confirm` will finish), and `drift` (human-readable). + +### `list --json` + +| Field | Type | Meaning | +| --------------- | ----------- | ------------------------------------------------------------------------ | +| `runs` | array | one entry per run, oldest first | +| `listing_fault` | str \| null | what could not be read; when set, an empty `runs` means "could not list" | + +Each run: `ref`, `run_id`, `run_type` (`?` when `state.json` is unreadable), `started_at`, `status`, `paused_stage` (`""` unless paused). + +### `status --json` + +Built from the run's `state.json`. + +| Field | Type | Meaning | +| --------------------------------------------------- | -------------- | ---------------------------------------------------------------------- | +| `run_id`, `run_type`, `source`, `started_at` | str | run identity | +| `status` | str | `finished`, `paused`, `crashed`, `stopped` or `in-progress` | +| `finished`, `stopped`, `crashed` | bool | raw flags | +| `graceful_stop_pending` | bool | a graceful stop request is pending and the engine is not dead | +| `crash_error` | str \| null | crash message | +| `paused_stage`, `paused_reason`, `paused_story_key` | str \| null | pause context | +| `cache_read_weight` | number | cache-read token weight from the run's policy snapshot | +| `tokens` | object | `{raw, weighted}` run totals | +| `adapters` | object \| null | `{dev, review, triage, retro: {name, model}}` from the policy snapshot | +| `sweeps_refused` | object | auto-sweep trigger → refusal reason; `{}` when none | +| `tasks` | array | one entry per story | + +Each task: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `tokens` (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `raw`, `weighted`), `commit_sha`, `defer_reason`, `preserve_ref`, `adapters_used` (`{role: {name, model}}`). + +### `diagnose --json` + +| Field | Type | Meaning | +| -------------- | ------ | -------------------------------------------------------------------------------------------------------- | +| `generated_at` | str | timestamp | +| `tool_version` | str | bmad-loop version | +| `env` | object | OS, Python, package, multiplexer, tmux and git versions | +| `runs` | array | per run: identity, flags, policy, phase histogram, token totals, `tasks`, `journal`, `files`, `warnings` | + +### `cleanup --json` + +The `--dry-run` plan and the outcome share this shape. + +| Field | Type | Meaning | +| ------------- | ------ | ------------------------------ | +| `dry_run` | bool | plan (true) or outcome (false) | +| `sessions` | object | agent sessions (below) | +| `ctl_windows` | object | control windows (below) | + +`sessions`: `removed` (run ids whose session kill was attempted), `live` (left alone), `unverifiable_pid` (subset of `removed` whose engine liveness could not be proven), `legacy_leftovers`, `legacy_unverified`, and `scan_error` (present only when the scan failed). + +`ctl_windows`: `removed` (verified gone, or would close under `--dry-run`), `survived`, `unverifiable` (kill outcome could not be checked), `undetermined` (kept because the window's state could not be read), `scan_error` (str or null; when set, the other lists mean "no answer"). + +### `clean --json` + +The `--dry-run` plan and the outcome share this shape. + +| Field | Type | Meaning | +| --------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| `dry_run` | bool | plan (true) or outcome (false) | +| `policy` | object | effective `retain`, plus `retention_days`, `archive_old`, `trim_artifacts` | +| `freed_bytes` | int | estimated bytes reclaimed | +| `worktrees` | list[str] | worktree paths torn down | +| `trimmed`, `archived`, `deleted`, `protected` | list[str] | run ids | +| `unverifiable_pid` | list[str] | touched runs whose engine liveness could not be proven | +| `state_dirs_swept` | int | orphaned out-of-tree state directories reclaimed | +| `listing_fault`, `state_dir_fault` | str \| null | what could not be read | +| `worktree_faults` | list[str] | `": "` per run whose worktree teardown was incomplete | + +## Exit codes + +| Code | Name | Meaning | +| ---- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | `OK` | Success. On POSIX, Ctrl+C during a run's engine loop ends the run as a resumable `stopped` run and also exits 0. | +| 1 | `FAILURE` | A command failed or refused, an unexpected error was caught (`error: …` on stderr), or a verdict command (`validate`, `probe-adapter`) reported a failing verdict. | +| 2 | `USAGE` | The invocation was malformed: rejected by argument parsing, or a flag combination a command refuses (`plugins --json`, `resolve --abandon --resume`, `confirm` with no story key and no `--list` / `--json`). | +| 130 | `INTERRUPTED` | Ctrl+C outside a run's engine loop (config load, setup, a non-run command). Prints `interrupted` on stderr. | + +Codes 3–129 and 131 and above are unallocated. + +## validate checks + +Every finding's `check` is one of these ids. Severity is `ok`, `warning` or `problem`; only `problem` fails `validate`. + +| Check id | Severity | What it checks | +| -------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `bmad-config` | ok / problem | BMAD config loads and artifact paths resolve | +| `policy` | ok / problem | `.bmad-loop/policy.toml` loads | +| `policy.isolation-repo-root` | problem | `isolation = "worktree"` with the project outside the code root | +| `policy.isolation-shared-artifact-dir` | warning | under worktree isolation, an artifact dir shared with the main checkout | +| `policy.model-qualified` | warning | an opencode-http role model that is not `provider/model` | +| `policy.extra-args-unservable` | warning | an opencode-http role's `extra_args` repeats an adapter-owned serve flag | +| `policy.effort-unsupported` | warning | `effort` set on a profile whose adapter ignores it | +| `policy.bypass-dropped` | warning | an explicit `extra_args` drops the profile's `bypass_args` | +| `adapter.profile` | problem | a role's adapter name does not resolve to a loadable profile | +| `adapter.binary` | ok / problem | the profile binary is on `PATH` | +| `adapter.binary-unrunnable` | warning | a packaged profile's binary is on `PATH` but `--version` fails | +| `adapter.httpx` | ok / problem | `httpx` is importable for an opencode-http profile | +| `adapter.launch-args-unservable` | warning | an opencode-http profile's `launch_args` cannot form a valid serve command | +| `adapter.hookless` | ok | the profile is hookless, so hook checks are skipped | +| `adapter.kind` | ok / problem | the profile's `adapter` kind is registered | +| `adapter.external` | warning | a third-party adapter entry point failed to load | +| `adapter.external-profile` | warning | a third-party profile entry point failed to load | +| `hooks.config-parse` | problem | the hook config file is not valid JSON or has malformed handlers | +| `hooks.registered` | ok / problem | relay hooks are registered for the profile's events | +| `hooks.trust` | ok / problem | Codex project hook trust is current | +| `hooks.relay-path-unsafe` | warning | a registered relay path has shell metacharacters the host's quoting leaves unsafe | +| `hooks.relay-present` | ok / problem | each registered relay executable exists and is usable | +| `hooks.relay-stale` | warning | a registered relay path differs from this installation's relay | +| `queue.sprint-status` | ok / problem | sprint mode: `sprint-status.yaml` loads; counts actionable stories | +| `queue.sprint-status-unknown-keys` | warning | sprint mode: unknown keys in `sprint-status.yaml` were ignored | +| `queue.nested-specs` | warning | sprint mode: spec-like files in artifact subdirectories, or unreadable subdirectories | +| `queue.stories-manifest` | ok / problem | stories mode: `stories.yaml` and `SPEC.md` are valid | +| `git.worktree-clean` | ok / problem | the code root's worktree is clean | +| `git.probe` | problem | the git check itself failed (spawn, timeout, error) | +| `git.version` | ok / problem | host git meets the supported floor | +| `git.render-tracked` | warning | the rendered-skill output dir is tracked by git | +| `deferred.ledger-unreadable` | problem | the deferred-work ledger cannot be read | +| `deferred.ledger-untracked` | warning | the ledger is neither committed nor gitignored | +| `deferred.ledger-ignored-isolated` | warning | the ledger is gitignored under worktree isolation, so session writes are lost | +| `deferred.hard-gate` | ok / problem | an actionable story is gated (`gate:`) by a ledger entry not marked `done` | +| `deferred.hard-gate-unstructured` | warning | a not-done entry declares a gate `validate` cannot enforce (malformed, empty or misspelled `gate:`, or a prose-only `HARD GATE:`) | +| `deferred.closes-malformed` | warning | a spec's `closes_deferred` has an unreadable shape | +| `deferred.closes-entry-unreadable` | warning | `closes_deferred` names entries that are neither open nor done | +| `deferred.closes-unknown` | warning | `closes_deferred` names ids not in the ledger | +| `operator.actions-malformed` | warning | sprint mode: a parked story's `operator_actions` declares nothing readable | +| `operator.confirm-interrupted` | warning | sprint mode: a confirmation signed the spec but never advanced the board | +| `operator.registry-stale` | warning | sprint mode: a park record disagrees with the spec or board, so `confirm` will refuse it | +| `operator.park-record-missing` | warning | sprint mode: the board parks a story that no park record claims | +| `plugins.manifests` | ok / warning / problem | every `plugin.toml` parses (problem); unsupported manifests are skipped (warning) | +| `plugins.trust` | ok / problem | project plugins are granted and unchanged, and the grant store is readable | +| `mux.backend` | ok / problem | the selected multiplexer backend is available | +| `mux.preflight` | problem | multiplexer selection or readiness raised an error | +| `mux.backends-detected` | ok / warning | inventory of detected backends when more than one is registered; detection failure (warning) | +| `mux.backend-probe` | warning | a registered backend's platform or availability probe raised an error | +| `mux.selection` | ok / warning | why the backend was selected; `fallback` is a warning | +| `mux.external-backend` | warning | a third-party multiplexer backend entry point failed to load | +| `host.process` | ok / problem | the process host resolves; an unknown `BMAD_LOOP_PROCESS_HOST` is a problem | +| `host.win32-on-wsl-path` | warning | a Windows interpreter is working on a WSL UNC project path | +| `notify.desktop-unavailable` | warning | `[notify] desktop` is on but the platform has no desktop notifier | +| `environment.probes-not-run` | ok | probes are configured but `--probes` was not passed | +| `environment.probes-none` | ok | `--probes` was passed with no probes configured | +| `environment.probe` | ok / problem | one per probe under `--probes`; `detail.status` is `pass`, `fail`, `timeout` or `interrupted` | +| `skills.base` | ok | the dev primitive and review layers are present | +| `skills.base-missing` | problem | the dev primitive or a review-layer skill is not installed | +| `skills.base-incomplete` | problem | the dev primitive is missing marker files | +| `skills.base-shim` | problem | the legacy dev primitive is unusable and the new one is absent | +| `skills.dev-renderer` | problem | a renderer-stub primitive lacks its renderer script | +| `skills.dev-renderer-sources` | problem | a renderer stub's render sources are unresolved | +| `skills.dev-renderer-config` | problem | a renderer stub's central config layer is missing | +| `skills.dev-render-probe` | ok / problem | `--render-probe` only: the stub's render command succeeds in a temp copy | +| `skills.customize-legacy` | warning | customize overrides named for the legacy dev primitive no longer apply | +| `skills.customize-unreadable` | warning | a customize override is not valid TOML | +| `skills.review-layers-empty` | problem | the dev primitive has no enabled review layer | +| `skills.review-layer-missing` | problem | a referenced review-layer skill is not installed | +| `skills.review-layer-unresolved` | warning | a conditionally referenced review-layer skill is not installed | +| `skills.stories-dispatch` | ok | stories mode: the dev primitive supports folder+id dispatch | +| `skills.stories-dispatch-missing` | problem | stories mode: the dispatch probe file is missing | +| `skills.stories-dispatch-stale` | problem | stories mode: the dev primitive lacks folder+id dispatch | +| `skills.sweep` | ok | the `bmad-loop-sweep` skill is present in the triage skill trees | +| `skills.sweep-missing` | problem | `bmad-loop-sweep` is not installed in a triage skill tree | +| `skills.sweep-incomplete` | problem | `bmad-loop-sweep` is missing files | + +The set is closed (`checks.VALIDATE_CHECKS`); plugins cannot add check ids. diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md new file mode 100644 index 000000000..6b3cbd1d9 --- /dev/null +++ b/docs/reference/run-state.md @@ -0,0 +1,165 @@ +# Run state reference + +Where bmad-loop keeps the state of runs and sweeps, what each file holds, how multiplexer sessions are named and torn down, and what `stop`, `cleanup`, `clean`, `delete` and `archive` remove. Command options are in the [CLI reference](cli.md). + +## Run inventory + +- All run state in `.bmad-loop/runs//` (gitignored by `init`): `state.json`, `journal.jsonl`, `engine.pid`, `logs/.log`, and `tasks//` per agent session, which holds the session prompt, the heartbeat, adapter-private records, and the shared artifacts: [`result.json`, `escalation.json`]. The hook-event channel and the config digest live outside the tree, under the [state root](#state-root). + +A directory under `.bmad-loop/runs/` counts as a run only if it contains a `state.json` file. + +## State root + +Out-of-tree state is user-scoped. The first rule that applies sets the root: + +| Platform | Root | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| any | `$BMAD_LOOP_STATE_DIR`, used as-is (see [environment variables](configuration.md#environment-variables)). Must be absolute; an empty value reads as unset. | +| POSIX | `$XDG_STATE_HOME/bmad-loop` when that variable is absolute, else `~/.local/state/bmad-loop` | +| Windows | `%LOCALAPPDATA%\bmad-loop\state`, else `%USERPROFILE%\AppData\Local\bmad-loop\state` | + +Each project gets a subtree keyed by the first 16 hex characters of the SHA-256 of its resolved path (`runs.project_tag`). Moving or renaming a project changes its key; the old subtree is left behind and `clean` does not reach it. + +```text +/ +├── locks/ advisory-lock files, shared across this user's projects +└── / + ├── / the run's control plane + │ ├── events/ hook-event files written by `bmad-loop relay` (BMAD_LOOP_EVENTS_DIR) + │ └── config-digest baseline digest of the host-exec config, checked on resume + ├── _mux/ per-project multiplexer registry (psmux) + ├── _plugin-snapshots/ verified copies of granted project plugins + └── plugin-grants.json project-plugin trust grants (`bmad-loop plugins`) +``` + +## Project `.bmad-loop/` directory + +| Path | Contents | Gitignored by `init` | +| ------------------------------------ | ------------------------------------------------------ | -------------------- | +| `.bmad-loop/policy.toml` | per-machine [policy](configuration.md) | yes | +| `.bmad-loop/runs//` | run directories (below) | yes | +| `.bmad-loop/cache/` | plugin caches | yes | +| `.bmad-loop/archive/.tar.gz` | runs compressed by `archive` or `clean` | no | +| `.bmad-loop/decisions.json` | sweep decisions answered for the next sweep | no | +| `.bmad-loop/operator/` | per-story `awaiting-operator` park records (committed) | no | +| `.bmad-loop/profiles/*.toml` | project adapter-profile overlays | no | +| `.bmad-loop/plugins/` | project-local plugins | no | + +## Run directory + +```text +.bmad-loop/runs// +├── state.json resumable run state +├── journal.jsonl append-only engine decision log +├── engine.pid engine pid and process identity; rewritten on resume +├── sweep.json sweep runs: the launch options +├── tasks// one per agent session (see Task directory) +├── logs/.log session log (pane capture; opencode: the server transcript) +├── verify/ verifier stdout/stderr streams the journal points at +├── worktrees/ per-story git worktrees ([scm] isolation = "worktree") +├── stop-request.json pending stop request (see Stopping a run) +├── ATTENTION human-readable alerts appended by gates +├── crash.txt traceback of an unexpected engine exception +├── feedback/-.md verification-failure feedback handed to the next session +├── deferred// stashed spec of a deferred story +├── failed//changes.patch diff of a dropped unit +├── resolve// escalation context.json and the resolve agent's resolution.json +├── ctl-window TUI: the control window last opened for this run +├── decisions.json sweep runs: run-scoped triage decisions +└── bundles//intent.md sweep runs: per-bundle intent +``` + +Entries other than `state.json`, `journal.jsonl`, `engine.pid`, `tasks/` and `logs/` appear only when the run reaches the step that writes them. Plugins may add their own files. + +- **`state.json`** — the serialized run state (stories, phases, tokens, the `finished` / `stopped` / `crashed` flags, and `paused_reason` with the pause context). Written atomically under a per-run lock; `bmad-loop status` reads it. +- **`journal.jsonl`** — append-only, one JSON object per line with `ts` and `kind`; the first entry is `run-start`. Written by the engine, and by commands that act on the run (for example `stop`, `resume`, `resolve`). From the first session launch on, lines carry `log_task` / `log_pos`, pointing into the most recent session's log. +- **`heartbeat.json`** (in `tasks//`) — the wait loop's proof of life, overwritten at most every 30 seconds while a session runs: remaining budget, stall-recovery state and probe or sampling failure counts. + +## Task directory + +`tasks//` is created per agent session. The task id is `--`, where `part` is `dev`, `review`, `triage` or a workflow label and `n` is the attempt or review cycle; a re-armed story adds `-g`. + +| File | Written by | Contents | +| ------------------------- | ----------------- | ------------------------------------------------------------------------------------- | +| `prompt.txt` | adapter | the session prompt, rewritten each launch | +| `result.json` | session | the session's result contract (verdict, escalations) | +| `escalation.json` | sweep session | escalations raised during triage | +| `heartbeat.json` | adapter | proof of life (above) | +| `resultless-stops.jsonl` | built-in adapters | one line per Stop event whose result could not be read | +| `session-lifecycle.jsonl` | built-in adapters | one line per lifecycle event (timeout, budget trip, environment-fault classification) | +| `messages.json` | opencode adapter | raw server messages, kept as the transcript | + +`result.json` and `escalation.json` are the shared cycle artifacts (`journal.TASK_CYCLE_ARTIFACTS`). Both built-in adapters delete them before launching a session, so a reused task id never inherits a previous session's result. Adapters refuse a task directory that is a symlink or junction, and an adapter-written file (heartbeat, breadcrumb logs, `messages.json`, session logs) that is a link or special file. + +## Run ids + +A run id is `YYYYMMDD-HHMMSS-<4 hex>` in local time, for example `20261012-011529-6a63`. The segment after the last `-` is the short ref used by [run references](cli.md#run-references). Retention reads the run's age from the timestamp prefix. + +## Multiplexer sessions + +| Object | Name | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| agent session | `bmad-loop-`, one per run, tagged with the project key (`@bmad_project`); multiplexer-driven adapters only (opencode runs none) | +| agent window | one per agent session, named after the task id (last 40 characters); its pane is piped to `logs/.log` | +| control session | `bmad-loop-ctl` on tmux; `bmad-loop-ctl-<16 hex>` on psmux (derived from the per-project registry) | +| control window | `-`, where `kind` is `run`, `sweep`, `resume` or `resolve`; opened by the TUI, parks after the command exits | + +Teardown: + +| Event | Agent session `bmad-loop-` | +| ---------------------------------------- | ------------------------------------------------------------------- | +| an agent session ends | its window is killed; the session is kept | +| run finishes, or a graceful stop ends it | killed when `[adapter] cleanup_session_on_finish` is true (default) | +| hard stop, SIGTERM or SIGINT | killed | +| engine crash | killed; `crash.txt` written; run marked `crashed` | +| run pauses | kept | +| engine process dies | left behind (the run reads as `interrupted`) | +| `resume` | a leftover session is killed before the relaunch | +| `bmad-loop cleanup` / TUI `c` | killed for this project's runs whose engine is not provably alive | + +## Derived run status + +`list` and the TUI show the first status that applies: + +1. `finished` — `state.json` has `finished` +2. `paused` — `state.json` has a non-null `paused_reason` +3. `stopped` — `state.json` has `stopped` +4. `crashed` — `state.json` has `crashed` +5. `running` — the engine process is alive +6. `interrupted` — the engine process is dead +7. `unknown` — engine liveness cannot be determined, or `state.json` cannot be read + +## Stopping a run + +Both stop modes write `stop-request.json` (`{"requested_at": …, "mode": "hard" | "graceful"}`) into the run directory; the engine consumes it. + +| | Hard: `bmad-loop stop` (TUI `x`) | Graceful: `bmad-loop stop --graceful` (TUI `S`) | +| ---------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Request | replaces any pending request | lodged only if no request is pending | +| Signal | SIGTERM to the engine; force-kill after 10 s only if the process identity still matches | none | +| Engine honors it | at the next item boundary, and mid-session (the session is aborted) | at the next item boundary; the in-flight story or bundle completes through commit | +| Agent session | always killed | killed under `[adapter] cleanup_session_on_finish` | +| Auto-sweeps | — | suppressed | +| End state | `stopped`, resumable | `stopped`, resumable from the next item | + +- `stop --cancel-graceful` removes a pending request of either mode. +- `resume` discards any pending request before restarting the engine. +- The engine treats SIGTERM and SIGINT (Ctrl+C in its terminal) as a hard stop. On native Windows it ignores console Ctrl+C / Ctrl+Break; use `bmad-loop stop`. +- A request still pending when the engine exits is deleted and journaled as `stop-request-discarded`. + +## Reclaiming sessions and disk + +| | `cleanup` | `clean` | `delete ` | `archive ` | +| ------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Reclaims | multiplexer sessions and windows | disk, per `[cleanup]` | one run | one run | +| Removes | `bmad-loop-` sessions of this project's runs, and parked control windows whose run is no longer live | leaked worktrees; runs past retention are trimmed then archived (or deleted with `--hard` / `archive_old = false`); runs inside retention are trimmed when `trim_artifacts` is set; orphaned control-plane directories | the run directory, its git worktree entries, its control-plane directory | same as `delete`, after writing `.bmad-loop/archive/.tar.gz` | +| Eligible runs | engine not provably alive (any status, including paused, and runs whose directory is gone) | `finished` or `stopped`, engine not provably alive | any | any | +| Never touches | live runs, other projects' sessions and windows, the window you run it from | `--keep` runs, paused / crashed / interrupted / running runs, runs whose agent session may be live | a live engine or agent session; `--force` hard-stops a live engine first and skips the session guard (the session is left running) | same as `delete` | +| TUI | `c` | — | `D` | `A` | + +- "Trimmed" means the heavy `worktrees/` and `verify/` entries are removed; a trimmed run stays resumable. +- Retention: concluded runs are counted oldest first; all but the newest `[cleanup] run_retention` (or `clean --retain N`) are past retention. When `retention_days` is above 0, a run must also be older than that many days. +- Unverifiable engine liveness produces a warning; it does not block. +- `[cleanup] auto_clean_on_finish` makes each `run` and `sweep` start by tearing down worktrees left under finished runs. + +See the [`[cleanup]` settings](configuration.md#cleanup). diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 501f31d2b..02c0768f1 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -4695,10 +4695,11 @@ def test_task_cycle_artifacts_named_only_through_the_constant(): def test_task_cycle_artifact_docs_track_the_canonical_tuple(): - """The run inventory and extension boundary keep pace with the shared list.""" + """The run-state reference inventory and extension boundary keep pace with the + shared list.""" project_root = Path(__file__).resolve().parents[1] - features = (project_root / "docs/FEATURES.md").read_text(encoding="utf-8") - inventory = features.split("- All run state in", 1)[1].split("\n- ", 1)[0] + run_state = (project_root / "docs/reference/run-state.md").read_text(encoding="utf-8") + inventory = run_state.split("- All run state in", 1)[1].split("\n- ", 1)[0] guide = (project_root / "docs/adapter-authoring-guide.md").read_text(encoding="utf-8") start_session_contract = guide.split("- `start_session", 1)[1].split( "- `wait_for_completion", 1 From c446d3719f3d212181b5e099fc65a524506ee010 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 01:48:40 -0700 Subject: [PATCH 3/9] docs: add sweep and escalation recovery guides Move operator procedures out of FEATURES.md and README.md into docs/sweep-guide.md and docs/escalations-guide.md (keeping the resolve-reverify and resolve-reverify-finished anchors), add a Codex hook-trust-under-worktrees subsection to the setup guide and a stranded-session recovery section to the multiplexer guide, and index both guides. FEATURES.md and README.md are cut in later phases. --- docs/README.md | 2 + docs/escalations-guide.md | 237 ++++++++++++++++++++++++++++++++++ docs/multiplexer-backends.md | 17 +++ docs/reference/cli.md | 2 +- docs/setup-guide.md | 19 +++ docs/sweep-guide.md | 242 +++++++++++++++++++++++++++++++++++ 6 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 docs/escalations-guide.md create mode 100644 docs/sweep-guide.md diff --git a/docs/README.md b/docs/README.md index 6d41ebcfb..9c8b5d920 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ guides below go deeper, roughly in the order you'll need them. - **[Setup guide](setup-guide.md)** — install the tools, pick a CLI, initialize a project, pass preflight, and uninstall. - **[TUI guide](tui-guide.md)** — the dashboard: layout, key bindings, the settings editor, and troubleshooting. +- **[Deferred-work sweeps](sweep-guide.md)** — running a sweep, answering decisions, closing entries from stories, hard gates, and recovering from stale answers, publication refusals and an unreadable ledger. +- **[Recovering paused runs](escalations-guide.md)** — escalations and `resolve` (re-arm, `--adopt-branch`, `--reverify`, `--abandon`), preserved attempts, plateau-defer, `confirm` for human-only actions, environment faults, and worktree merge-back failures. - **[Features & functionality](FEATURES.md)** — the full capability matrix and policy reference. - **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux bundled; externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) install as packages), how selection works, and how external backends arrive. diff --git a/docs/escalations-guide.md b/docs/escalations-guide.md new file mode 100644 index 000000000..da0b6f049 --- /dev/null +++ b/docs/escalations-guide.md @@ -0,0 +1,237 @@ +# Recovering paused runs + +A run pauses when it needs a human: an escalation, an environment fault, a merge-back that failed, or a gate. This guide shows how to find out why a run paused and which command clears each pause. Deferred-work pauses (hard gates, ledger faults, migration) are covered in the [sweep guide](sweep-guide.md). + +## Where a pause shows up + +- **Notification.** A desktop notice (`[notify] desktop`, default on) and a line appended to `.bmad-loop/runs//ATTENTION` (`[notify] file`, default on). If neither channel works, the run warns that no alert channel is configured. Displayed text is capped at 2000 characters; the full reason is in `journal.jsonl`. +- **`bmad-loop status `** shows the pause stage, the paused story and the reason. A parked attempt's recovery ref appears as `[ref]` (`preserve_ref` in `status --json`). +- **The TUI** shows the same, and its keys launch the commands below: `R` resolve, `V` re-verify, `e` resume, `p` review the paused run. + +| Pause stage | Cause | Clear it with | +| ------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `escalation` | a CRITICAL escalation, or a story still escalated at run end | [`bmad-loop resolve`](#resolving-an-escalation) | +| `environment` | an environment probe failed before a session launched | fix the environment, then `bmad-loop resume` ([details](#environment-faults)) | +| `story-gate` | a ledger `gate:`, or a ledger that cannot be read | see the [sweep guide](sweep-guide.md#hard-gates) | +| `spec-approval`, `epic-boundary` | a configured gate or a retrospective | `bmad-loop resume` | +| `plan-checkpoint`, `story-checkpoint` | a stories-mode checkpoint | `bmad-loop resume` | + +## Escalations + +Escalations come in two severities: + +- **CRITICAL**: the story moves to `escalated`, `story-escalated` is journaled, you get a `CRITICAL escalation: ` notice, and the run pauses at `escalation`. +- **PREFERENCE**: journaled as `preference-escalation`; the run continues. + +A story stays escalated until you resolve it. A plain `bmad-loop resume` skips it and runs the rest of the queue, then pauses again with `story '' is still escalated — run bmad-loop resolve `. A run is never marked finished while a story is escalated. + +## Resolving an escalation + +`bmad-loop resolve ` works on a run paused at `escalation` whose engine is not running. `--story ` picks a story other than the paused one. By default it asks before it re-arms and then resumes the run itself: + +- `--resume` re-arms and resumes without asking. +- `--no-resume` only re-arms and prints `resume when ready: bmad-loop resume `. +- `--force` proceeds when the engine's liveness cannot be verified. A running engine always blocks: run `bmad-loop stop ` first. + +`--restore-patch`, `--adopt-branch`, `--reverify` and `--abandon` are mutually exclusive. + +### Re-arm with the resolve agent + +1. `bmad-loop resolve ` (TUI: `R`). An interactive resolve agent starts with the escalation and the frozen spec. +2. Work through the problem with the agent until the spec is correct, then exit the agent. +3. Answer `re-arm and resume run ?`. `y` resets the story to `pending` and its spec to `ready-for-dev`, and resumes. `N` leaves the run paused. + +If the agent exits without writing a resolution, resolve says `no resolution recorded for ` and still asks. Answer `N` to stay paused. + +### Re-arm after fixing the spec yourself + +1. Edit the frozen spec. Under worktree isolation, commit the edit. +2. `bmad-loop resolve --no-interactive`. No agent runs. Answer the re-arm prompt, or pass `--resume`/`--no-resume`. + +Use this path too when the dev CLI has no interactive mode. + +### Restore an intent-gap patch + +When an escalation names a saved patch (in the spec's `## Auto Run Result`), you can re-apply it instead of re-implementing: + +1. `bmad-loop resolve --no-interactive --restore-patch `. (The resolve agent can record the same choice as `restore_patch` in `resolution.json`.) +2. The spec is re-armed to `in-review` and the patch re-applied, so the re-drive resumes at review. A patch that does not apply escalates again. + +In-place runs only: it is refused under worktree isolation, for a task with a worktree, and for a story with no recorded spec. + +### Adopt the kept branch + +Under worktree isolation, when the escalation was a false alarm and the kept branch is good: + +1. `bmad-loop resolve --adopt-branch [--story ]`. A warning says that review, the `[verify]` commands and `pre_commit_gate` are **not** re-run. +2. Confirm `adopt kept branch …?`, or pass `--resume`/`--no-resume`. +3. On resume the branch is committed and merged, and the story becomes `done` (or `awaiting-operator` if it declared operator actions). No session runs. + +Refused for sweep runs, when there is no kept worktree or branch, when the worktree directory is gone, and when there is no spec. Not offered in the TUI. + + + +### Re-verify kept work + +When a story was deferred, or escalated by an environment fault, and its work is sound, replay its verification instead of re-implementing it: + +1. Fix the environment first (container, database, credentials). +2. `bmad-loop resolve --reverify` (TUI: `V`, which also lists other deferred worktree units). For a worktree unit, add `--story `; that works under any pause stage. +3. Read the summary: the target, HEAD and baseline, and a warning that commits and changes made since the pause go into the story's squashed commit. Confirm, or pass `--resume`/`--no-resume`. +4. On resume, the `[verify]` commands re-run on the kept work. On a pass the story is reviewed per policy and committed (a unit is merged). A failure is never retried: a deferred story is deferred again with `reverify failed: …`, and an escalated story (or an environment fault or contradiction) pauses with that reason. + +No dev session and no resolve agent run. `--reverify` is refused, with the run unchanged and the remedy named, when: + +- it is a sweep run; +- the escalation is not an environment fault a replay can clear (use plain `resolve`); +- there is no completed dev result, or the latest dev session failed or was partial; +- an in-place story is not the one the run stopped on, or a later story was already picked; +- the baseline is not an ancestor of HEAD, or nothing sits above the baseline (the attempt was rolled back); +- a worktree unit's worktree is gone, on the wrong branch, or has nothing above its baseline. + +Plain `resolve` on a deferred story points you here: `to re-verify its kept work: bmad-loop resolve --reverify`. + + + +### Replay a finished run's kept unit + +A finished worktree run can keep deferred units. To replay one: + +1. Check out the run's target branch in the main checkout and make sure it is clean. +2. `bmad-loop resolve --reverify --story `. `--story` is required. Not available for stories-mode runs. +3. Confirm. A new replay run is created, the unit's worktree moves into it, verification replays, review follows policy, and the unit merges into the target branch. The original run stays finished. +4. If the replay run pauses at `escalation` (journaled `replay-merge-refused`) because the story is already `done`, close it with `--abandon` (below). If the board could not be read, fix it, then `bmad-loop resolve --adopt-branch`. + +Replay before the next `run` or `sweep` starts: `[cleanup] auto_clean_on_finish` (default on) reclaims kept worktrees of finished runs. + +### Abandon a replay run + +When an escalated replay run is no longer needed: + +1. `bmad-loop resolve --abandon`. `--no-interactive` skips the confirm. Do not combine it with `--resume` or `--no-resume` (usage error). +2. The unmerged changes are parked on `refs/attempt-preserve-dirty/--abandon` and the printed ref names it. The worktree is removed, the branch is kept, and the run is recorded finished. The board, the ledger and the original run are untouched. +3. Copy the ref to a branch if you need it longer than `scm.preserve_keep` keeps it. + +Refused on a run that is not a replay run, a story that is not escalated, or a story with no kept unit. + +### When resolve does not resume + +- `NOT resuming in this gesture — …`: the story is re-armed, but the correction must reach the re-drive first. Do what the line says (commit the corrected spec on the named branch, correct it in the main checkout, or restore the recorded spec path), then `bmad-loop resume `. +- `the escalation for changed while resolve was in progress — not re-arming`: the run moved on. Check `bmad-loop status` and start over. +- `run is still live — stop it first`: run `bmad-loop stop `; `--force` does not help. +- `run : engine may still be live (unverifiable pid) — refusing to re-arm …`: confirm the engine process is gone, then re-run with `--force` (`stop` cannot clear an unverifiable pid). + +## Preserved attempts + +Before work is reset, bmad-loop parks it on git refs: + +| Ref | Holds | +| ------------------------------------------ | --------------------------------------------------------------------- | +| `attempt-preserve/-` (a branch) | an attempt's commits above the baseline | +| `refs/attempt-preserve-dirty/-…` | a snapshot commit of uncommitted work, or of an abandoned replay unit | +| `refs/merge-preflight-preserve/` | your edits in the main checkout restored before a merge-back | + +`scm.preserve_keep` (default 20) is the number of refs kept per family. Older ones are pruned when a run starts; `0` keeps them all. Copy a ref to a branch to keep it for good. + +To recover work: + +1. Inspect: `git log --oneline ..attempt-preserve/-`, or `git diff ~1 ` for a dirty snapshot. +2. Restore after the run has stopped. For an in-place defer, the notice gives the exact line: `git -C merge --ff-only `. Otherwise `git cherry-pick ` or `git restore --source= -- `. +3. If a preserve step failed, the ATTENTION line gives a `git branch ` to run before `git gc` prunes the commits, or points you to the reflog. A failed snapshot of uncommitted work means that work was not saved. + +### Manual rollback pause + +In place, `[scm] rollback_on_failure` defaults to `false`: instead of resetting, a failed attempt pauses with an `ACTION REQUIRED` notice and the tree as it was. To recover: + +1. Save the work: `git -C branch my-rescue HEAD`, or `git -C diff > rescue.patch` plus any new untracked files. +2. Check whether any of it was merged, pushed or opened as a PR. +3. `git -C reset --hard ` and remove leftover untracked files. +4. `bmad-loop resume `. + +To keep the commits instead, check `git log --oneline ..HEAD`, then run `bmad-loop resume --accept-baseline`. It adopts everything at HEAD as the new baseline of each in-place story the resume restarts, and treats every current untracked file as pre-existing. Uncommitted tracked changes still go through the normal rollback. You do not need it right after `resolve` (the re-arm already advances the baseline), but commits you made after `resolve --no-resume` do. + +## Plateau-defer + +When a story runs out of budget without a CRITICAL reason (`[limits] max_dev_attempts`, default 2; `[limits] max_review_cycles`, default 3; or a review timeout with `[review] on_timeout = "defer"`), it is deferred and you get a `story deferred: ` notice. What happens next depends on where the attempt ran: + +- **In place, `[scm] rollback_on_failure = false` (the default), with attempt work in the tree:** the tree is not rolled back and the run pauses for a [manual rollback](#manual-rollback-pause). The notice points to `bmad-loop resolve --reverify` for the kept work. +- **In place with `rollback_on_failure = true`, or a clean tree:** the attempt is parked and reset, its spec is stashed under the run's `deferred//`, and the run continues. The notice gives the recovery ref and the `git merge --ff-only` line that restores it (marked commits-only when the uncommitted part could not be saved). +- **Under worktree isolation:** the unit's branch is kept, the run continues, and the notice names the branch plus any earlier attempt's ref. + +To finish a deferred story, use [`resolve --reverify`](#resolve-reverify) while the run is paused on that defer, or for a kept worktree unit. After an auto-rollback, restore the ref and re-run the story instead. A story being re-driven after you resolved it re-escalates instead of deferring. + +## Human-only actions + +A story that needs an action no agent can take (buy a domain, publish a DNS record, issue an API key) commits everything an agent can do, lists what is owed in its spec's `operator_actions:`, and parks at `awaiting-operator`. The run continues. To finish it: + +1. `bmad-loop confirm --list` shows the parked stories and what each owes (`--json` for scripts). +2. Do the actions. +3. `bmad-loop confirm `. You acknowledge each action in turn (`--yes` skips the prompts). The spec gets an `## Operator Confirmation` section, spec and board move to `done`, and the change is committed. +4. Add `--reverify` to re-run the environment probes and `[verify]` commands first; a failure blocks the confirmation. + +If `confirm` is interrupted, run the same command again; it finishes without prompting twice. If the park is not found, pull the branch that carries the park commit. If the park record has drifted, `confirm` refuses and changes nothing: fix what `bmad-loop validate` reports (`operator.*` checks). `[operator] enabled = false` turns parking off. + +## Environment faults + +An environment fault is a failure caused by the environment rather than the code. It never charges a dev attempt. + +How faults are detected: + +- `[environment] probes`: health-check commands run before verify, before each session launch, and before a failed attempt is charged. A failing probe is a fault. +- `[verify] env_fault_rc`: a verify command exiting with that status (0 disables it). Exit 126 or 127 always counts. +- A provider error in the session's log tail that matches the CLI profile's `env_fault_patterns` (claude ships patterns for an "API Error … Unable to connect to API"-style failure; extend them in a profile overlay). A session that consumed tokens is not treated as an environment fault. + +What to do depends on where it hit: + +**Before a session launched** (pause stage `environment`): + +1. Fix the environment. +2. `bmad-loop resume ` (TUI: `e`). The probes run again. If they pass, the same session launches with no rollback (`env-fault-cleared`); if not, the run pauses again unchanged. + +`resolve` refuses this pause: `an environment pause needs no resolve: fix the environment, then bmad-loop resume `. + +**During verify or after a session** (a CRITICAL escalation with an `env_fault_site`): + +1. Fix the environment. +2. If the work is sound: `bmad-loop resolve --reverify` ([details](#resolve-reverify)). The notice offers this only when a replay can clear that site. +3. Otherwise: `bmad-loop resolve --no-interactive` to re-arm (restoring the attempt budget) and resume. The spec needs no fix, so the resolve agent is not needed. + +| `env_fault_site` | `--reverify` can clear it? | +| ------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| `verify:dev`, `verify:fix`, `verify:review` | yes | +| `probe:decision:review`, `probe:decision:workflow`, `probe:claim:review`, `probe:claim:workflow` | yes | +| `probe:decision:dev`, `probe:decision:fix`, `probe:claim:dev`, `probe:claim:fix` | yes, if the latest dev session completed | +| `probe:dispatch:dev`, `probe:dispatch:review` | no: it is an `environment` pause; use `resume` | + +## Worktree merge-back failures + +Under `[scm] isolation = "worktree"`, each story merges back into the target branch in the main checkout. When the merge fails, the unit escalates: its branch `bmad-loop/[/]` and worktree are kept, the diff is saved to `.bmad-loop/runs//failed//changes.patch`, and the run pauses at `escalation`. + +To recover: + +1. Read the escalation reason. It names the failure (table below). +2. Fix the main checkout as the table says. +3. Finish the story: + - `bmad-loop resolve --adopt-branch` merges the kept branch as it is (not for sweep runs; review and verify are not re-run); + - or `bmad-loop resolve ` (or `--no-interactive`) to re-arm and re-drive the story. + + A plain `resume` does not retry the merge: the story is still escalated. + +| Failure | What happened | Fix | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| merge refused at pre-flight | git refused before starting: an untracked file would be overwritten, a staged change on an incoming path, a file/directory clash, or a fast-forward that cannot happen. Nothing changed. | fix the clash git names | +| merge half-applied | git stopped partway through the checkout, usually a required clean/smudge filter that cannot run | if the reason says the paths were not restored, `git checkout HEAD -- ` (never a repo-wide reset); remove the untracked residue it names; fix the filter | +| post-merge state unverified | a check after the merge failed, so bmad-loop repaired nothing | `git status` in the main checkout, clear residue that is not yours, fix the cause | +| merge commit refused, rolled back | a `pre-merge-commit` or `commit-msg` hook, or `commit.gpgsign`, refused the commit | fix the hook or signing | +| merge commit refused, result left staged | squash strategy; the rollback was withheld or failed | stash or commit your own work, `git reset --hard HEAD`, then fix the hook or signing | +| merge commit refused mid-merge | merge strategy; the abort also failed | `git merge --abort` (or reset to the pre-merge commit), then fix the hook or signing | +| merge conflict | a real content conflict | resolve it by hand | +| stray changes in the main checkout | staged changes to tracked files outside the branch, or changes to a board or ledger this run commits | commit, stash or revert them; the run never cleans these | +| other git error | not classified | `git status`; git's text names the cause | + +Two pauses resume directly instead of escalating: + +- `merge-target-not-checked-out`: the main checkout is not on the target branch. `git checkout `, then `bmad-loop resume `. +- A failed snapshot before the pre-flight cleanup of a sweep bundle: leave the named paths exactly as they are, fix the fault, then `bmad-loop resume `. + +Untracked strays, and tracked files changed only in the working tree, do not block a merge (`merge-target-tolerated`). If the pre-flight restored one of your edits, the journal entry `merge-target-preserved` names its `ref` and `paths`: get it back with `git show :` before `scm.preserve_keep` prunes the ref. diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index d0572f8b1..4d1e90336 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -358,6 +358,23 @@ had in force. `cleanup` then refuses to claim an untagged session on run-directo that evidence only holds in a registry bmad-loop derived: a run id is unique within a project, not across the registry you are sharing with it. +## Recovering a stranded session + +`bmad-loop delete` and `bmad-loop archive` refuse to remove a run while its agent session `bmad-loop-` is still live, unless the session is tagged to another project: `run : refusing to delete its directory while its agent session is still live — … (or pass --force)` (`archive` for archive). To clear the refusal: + +1. Confirm the session is this project's: `bmad-loop attach `. +2. `bmad-loop cleanup`, then run `delete` or `archive` again. +3. Or pass `--force`. It stops a live engine first, then removes the run without checking for a session, and leaves any orphaned session running. + +A removal can still strand a session when the session listing misses it: the multiplexer binary is not on `PATH`, or on psmux a registry entry was briefly reaped. To clear a stranded session: + +1. Put the multiplexer back on `PATH`. On psmux after a reap, wait a few seconds for the server to republish the entry. +2. Run `bmad-loop cleanup`. It prunes a session tagged to this project even when its run directory is gone. +3. For an untagged session, inspect it by hand. `bmad-loop attach` cannot reach it, because it needs the run directory. On psmux, paste the `$env:PSMUX_DATA_DIR = …` line `bmad-loop mux` prints first ([why](#where-psmux-sessions-live-the-per-project-registry)). Then attach with `psmux attach -t bmad-loop-` (`tmux attach -t …` on tmux). +4. Once you have confirmed from its contents that it is yours (the name does not carry the project), kill it: `psmux kill-session -t bmad-loop-` (or `tmux kill-session -t …`). + +Never kill a `bmad-loop-ctl…` control session this way: its windows can be live runs. + ## External backends Every backend beyond the two bundled ones is a separate package that you co-install with bmad-loop; it diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 08772c0b2..d173b1cd7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -207,7 +207,7 @@ bmad-loop resume [--project PROJECT] [--accept-baseline] run_id ### `resolve` -Resolves an escalated story, then re-arms it and resumes the run. Without a mode flag it launches an interactive resolve agent for a CRITICAL escalation. Procedures: [escalation recovery](../FEATURES.md#resolve-reverify). +Resolves an escalated story, then re-arms it and resumes the run. Without a mode flag it launches an interactive resolve agent for a CRITICAL escalation. Procedures: [escalation recovery](../escalations-guide.md#resolve-reverify). ```text bmad-loop resolve [--project PROJECT] [--story STORY] [--no-interactive] diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 8ccefed6f..3e9998a23 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -344,6 +344,25 @@ them to whoever owns the machine: TUI Log tab. The server's own stdout lands beside it in `.server.out`, and a structured trace of the raw SSE frames in `.sse.jsonl`. +#### Codex hook trust under worktrees + +Codex trusts hooks per directory, and an untrusted hook never fires, so a session would never report back. Check trust before the first run: + +1. In the project root, run `codex` and accept both prompts (workspace trust, then "Hooks need review → Trust all and continue"). +2. `bmad-loop validate`: the `hooks.trust` check must be ok. `bmad-loop probe-adapter codex` reports Codex's verdict for the project checkout as `hook_trust` and exits 1 unless it is `trusted`. It does not account for stage `extra_args` or worktree isolation, so always re-check with `validate`. +3. If the verdict is not `trusted`: + - `untrusted`, trust is stale: open Codex in that checkout and accept the hook prompt again. + - `untrusted`, the relay is not usable or Codex omitted it: re-register it with `bmad-loop init --cli codex`. + - `unverifiable`: fix the cause it names, for example stage `extra_args` other than `--dangerously-bypass-approvals-and-sandbox`, profile `launch_args`, Codex missing from `PATH`, or an unreadable `.codex/hooks.json`. Then check again. + +Under `[scm] isolation = "worktree"`, every unit runs in a new worktree that Codex has not trusted yet. `validate` therefore always fails `hooks.trust` for future worktree sessions, and the run checks trust itself before each Codex session in a worktree. When a check fails, the unit escalates and the run pauses. For each such pause: + +1. Read the escalation. It names the role, the worktree path, the status and the reason. For `unverifiable`, fix the cause first. +2. `cd` into the named worktree (it stays mounted under `.bmad-loop/runs//worktrees/`), run `codex`, and accept the hook trust prompt. +3. `bmad-loop resolve --no-interactive` and answer yes: it re-arms the story and resumes the run. (With `--no-resume`, run `bmad-loop resume ` afterwards.) + +Expect one pause per unit: each worktree path needs its own grant. The re-armed story restarts from its baseline. bmad-loop never writes Codex's trust state itself. + ### Skill location `claude` reads skills from `.claude/skills/`; `codex`, `gemini`, `copilot`, and `antigravity` diff --git a/docs/sweep-guide.md b/docs/sweep-guide.md new file mode 100644 index 000000000..8ee23fd02 --- /dev/null +++ b/docs/sweep-guide.md @@ -0,0 +1,242 @@ +# Deferred-work sweeps + +A sweep works through the deferred-work ledger (`deferred-work.md`), the list of follow-ups that dev and review sessions defer. A fresh triage session checks every selected open entry against the code and sorts it: already resolved, buildable bundles, blocked, skip, or a decision that needs a human. You answer the decisions. Each bundle then runs through the normal story pipeline (dev → verify → review → commit), and its review gate also requires every bundled entry to be `status: done` in the ledger. + +```text +bmad-loop sweep [--only DW-1,DW-3 | --min-severity LEVEL] [--no-prompt] [--decisions-only] + [--max-bundles N] [--repeat | --no-repeat] [--max-cycles N] [--dry-run] + │ + ├─ TRIAGE fresh session: /bmad-loop-sweep verifies each selected open entry against + │ the code and returns a validated partition: already-resolved (closed by the + │ orchestrator, with evidence) · bundles · blocked · skip · decisions + ├─ DECIDE attended runs ask you each decision (build / close / keep-open, with a + │ recommendation); answers land in the ledger as `decision:` lines. + │ Unattended runs leave decisions open. + └─ BUNDLES each bundle runs the normal pipeline: dev on the bundle spec → verify + commands → review (per [review] trigger) → commit +``` + +## Running a sweep + +1. Commit or stash everything in the code repo. A sweep refuses a dirty worktree: `git worktree is not clean — commit or stash first`. +2. Preview: `bmad-loop sweep --dry-run`. It lists the open entries and spawns nothing. It fails, naming the file and the fault, when the ledger cannot be read. +3. Run one of: + - `bmad-loop sweep`: triage every open entry. + - `bmad-loop sweep --only DW-1,DW-3`: triage only these open entries. Every id must exist and be open. + - `bmad-loop sweep --min-severity high`: triage entries at `low` < `medium` < `high` < `critical` or above. Entries with no or an unknown severity are left out. + - Add `--max-bundles N` to cap the bundles built (overrides `[sweep] max_bundles`). +4. Answer each decision when asked (see [Answering decisions](#answering-decisions)). +5. Watch it with `bmad-loop status `. For a sweep, status prints a `sweep options:` line with `max_bundles`, `repeat` and `max_cycles`, each marked `override` or `policy`. + +Variants: + +- `bmad-loop sweep --no-prompt` runs unattended. It skips every decision prompt, builds only the bundles that need no decision, and sends a `N deferred-work decisions pending` notice. +- `bmad-loop sweep --decisions-only` triages and records your answers, and builds nothing. + +`--only` and `--min-severity` are mutually exclusive. A paused sweep continues with `bmad-loop resume `. An escalated bundle is resolved with `bmad-loop resolve ` (see the [escalations guide](escalations-guide.md)). `--adopt-branch` and `--reverify` do not apply to sweep runs. + +### Repeat mode + +`bmad-loop sweep --repeat [--max-cycles N]` (or `[sweep] repeat = true` and `max_cycles`, default 5) re-triages after each cycle and keeps going on newly deferred work. `--no-repeat` overrides the policy for one run. The run ends with a `sweep-repeat-done` journal entry whose `stop_cause` says why: + +| `stop_cause` | Meaning | What to do | +| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------ | +| `no-open` | no open entries left | nothing | +| `no-selected` | the selector matches nothing in a later cycle | nothing, or widen the selector | +| `no-progress` | the cycle completed nothing addressable | check the skipped and blocked entries | +| `max-cycles` | `max_cycles` reached | run again, or raise `--max-cycles` | +| `legacy-appeared` | legacy-format entries appeared after cycle 1 | start a fresh `bmad-loop sweep` to migrate them | +| `ledger-unreadable` | the ledger bytes do not decode, or it is unfit to publish | see [When the ledger won't read](#when-the-ledger-wont-read) | +| `ledger-inaccessible` | the OS refused the read | see [When the ledger won't read](#when-the-ledger-wont-read) | + +### Archiving closed entries + +1. Stop every live run. The archive refuses while any run may be live: `run may still be live — stop it before archiving ledger entries`. +2. Preview: `bmad-loop sweep --archive [--before YYYY-MM-DD] --dry-run`. +3. Run: `bmad-loop sweep --archive [--before YYYY-MM-DD]`. Entries closed (`status: done `) before that date move to `deferred-work-archive.md`. A stub with `status: done` and `archived:` stays in the live ledger so ids still resolve. +4. If the ledger is tracked in git, commit both files. + +`--archive` takes no other sweep flag, and `--before` needs `--archive`. + +### Auto-sweep + +Set `[sweep] auto = "per-epic"` or `"run-end"` (default `"never"`) to start a sweep from inside `bmad-loop run`. An auto-sweep runs at most once per trigger. A failed or refused child sweep never fails the parent run. When one did not run, `bmad-loop status` prints `auto-sweep not run: () — deferred work is untouched`. Run `bmad-loop sweep` yourself with a clean worktree. A dirty worktree is the usual cause. + +### Legacy-ledger migration + +A ledger with entries that are not in `DW-` format is migrated automatically at the start of `bmad-loop sweep`, in one session. `--dry-run` labels such entries as pre-migration. If the migration pauses: + +| Pause | What to do | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `migration refused` — duplicate ids | renumber or merge the duplicates, commit, `bmad-loop resume ` | +| `migration refused` — the tracked ledger differs from its committed version | commit the ledger, `bmad-loop resume ` | +| escalated after `max_migration_attempts` (default 2) failed validation | restore the pre-migration ledger, delete the `migrate-*` records in the run dir, resume; or `bmad-loop resolve ` to grade the live ledger as it stands | +| `sweep-migration-recovery-invalid` — HEAD moved past the baseline | leave HEAD where it is, clean the working tree, resume | +| asked to delete `migrate-*` records by hand (native Windows) | delete every `migrate-*` file in the run dir, resume | + +A migration fails validation if it drops an existing `gate:` token, changes an existing status, or breaks the numbering. + +## Story-declared closure (`closes_deferred`) + +A story can close ledger entries when it commits. + +1. Add `closes_deferred: [DW-5, DW-6]` to the story spec's frontmatter or, in stories mode, to the `stories.yaml` entry. Both are combined. +2. Run `bmad-loop validate`. It warns on unknown ids (`deferred.closes-unknown`), a malformed declaration (`deferred.closes-malformed`) and entries whose status is neither open nor done (`deferred.closes-entry-unreadable`). +3. Run as usual. At the story's commit each entry becomes `status: done ` with `resolution: resolved by story `. A story that fails or escalates closes nothing. + +A non-list `closes_deferred` in `stories.yaml` stops the manifest from loading. In a spec it is only journaled. If the ledger cannot be read at commit time, the story still commits, nothing is closed, and you get a `declared deferred closes unapplied` notice. Repair the ledger, then run `bmad-loop sweep` to reconcile. + +## Hard gates + +A ledger entry can block stories until it is done. + +1. In the entry, add a line `gate: 3-2, 3-3`: lowercase, at the start of the line, comma-separated story keys. A token matches a key it equals or prefixes at a `-` (and `3-2` also matches `3-2a-…`), so `3-2` matches `3-2-login` but not `3-20-x`. +2. `bmad-loop validate` fails `deferred.hard-gate` for each actionable story a gate matches, and warns `deferred.hard-gate-unstructured` for a malformed or misspelled gate line (`Gate:`, an indented line, or prose `HARD GATE:` with no `gate:` line). +3. If a run pauses at stage `story-gate` with ` is gated by unlanded deferred work`, either set the entry to `status: done `, drop the token, or run `bmad-loop sweep`. Then `bmad-loop resume `. + +Only an explicit `status: done` releases a gate; a missing or misspelled status still gates. Sweep runs are never gated. If the ledger cannot be read, validate fails `deferred.ledger-unreadable` and a run pauses at stage `story-gate` with ` cannot be read (…)` (journaled `story-gate-unreadable`): fix the file, then resume. + +## Answering decisions + +Each decision offers options, and each option has one effect: + +| Effect | What it does | +| ----------- | --------------------------------------------------- | +| `build` | the entry becomes a bundle and is built | +| `close` | the entry is set to `status: done` now | +| `keep-open` | the answer is recorded and the entry is not bundled | + +**During a sweep.** An attended `bmad-loop sweep` asks each decision in the terminal: `choice [k1/k2/…] (enter = )`. Enter takes the recommendation, and an invalid key asks again. In the TUI, a `⚑ decision needed` banner appears and `a` attaches to the prompt. When you attached this way, the sweep hands the terminal back once the cycle's decisions are answered and prints one of the lines below (a sweep run directly in the foreground keeps the terminal and prints none of them): + +- `✓ decisions recorded — sweep continues in the background` +- `! answers saved, but not every decision reached the deferred-work ledger …` +- `! answers saved, but the deferred-work ledger is not fit to publish … — repair the ledger and re-run` + +**After a sweep** (decisions an unattended or interrupted sweep left open): + +1. `bmad-loop status` shows `deferred-work decisions awaiting an answer: N (bmad-loop decisions)`. +2. Preview with `bmad-loop decisions --list` (or `--json`). +3. Answer with `bmad-loop decisions`, or `d` in the TUI. Each answer prints `closed now`, `queued — the next sweep will build it`, or `kept open (recorded)`. +4. Run `bmad-loop sweep` to build the queued answers. + +### Project store and run store + +| Store | Path | Written by | Holds | +| ------------- | ----------------------------------------- | ------------------------------ | -------------------------------------- | +| Project store | `.bmad-loop/decisions.json` | `bmad-loop decisions`, TUI `d` | `build` and `keep-open` answers | +| Run store | `.bmad-loop/runs//decisions.json` | answers given during a sweep | every in-run answer, including `close` | + +An out-of-band `close` goes straight to the ledger and is not stored. A sweep reads its run store first. It uses a project-store answer only for an id that this triage asks and the run store has not answered (journaled `decision-preanswered`). `bmad-loop decisions` offers only ids that are still open, appear in a past triage, and have no usable project-store answer. + +### Stale answers + +A stored answer is checked against the option its key selects in the current triage (same label and effect); a mismatch is journaled as `sweep-decision-option-mismatch`. A mismatched keep-open answer is dropped as `stale-option`. A mismatched build answer is still built from its own stored intent, and is dropped only if it has none. An answer that cannot be carried out is dropped: the run journals `sweep-decision-answer-dropped` with a `drop_cause`, notifies you, leaves the entry open, and does not ask that id again for the rest of the run. + +| `drop_cause` | Meaning | What to do | +| ----------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `effect-unlanded` | a build answer from this run never reached the ledger (entry or file gone) | restore the entry if you still want it; a new sweep asks again | +| `entry-not-open` | a stored build answer names an entry that is no longer open | nothing, or reopen the entry | +| `no-intent` | a build answer has no intent and no matching option supplies one | answer again in a later sweep | +| `name-collision` | no unique bundle name, even after `-2` … `-9` suffixes | rename the clashing bundles or run again later | +| `stale-option` | a keep-open answer's option is gone or was rewritten | `bmad-loop decisions` offers the id again; answer it if it should stay open | + +Only a `stale-option` drop removes the project-store entry (committed as `chore(sweep): drop stale deferred-work pre-answer`). At the end of each cycle, store entries for ids that are no longer open are pruned (`decision-preanswers-pruned`). If the ledger cannot be read, the prune is skipped (`sweep-preanswer-prune-refused`) and the stored answers survive. + +### Validating decision stores + +A stored answer is usable only if it is a JSON object whose `effect` is `build`, `close` or `keep-open`, and whose `key`, `label`, `intent` and `bundle_name` are strings when present. A `close` is accepted only from the run store. `intent` and `bundle_name` count only on a `build`. + +| Store problem | Result | What to do | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| a project-store value fails the check | treated as unanswered; `bmad-loop decisions` offers the id again | answer it; `build` or `keep-open` overwrites the bad value | +| project store missing, not JSON, or not an object | read as empty, nothing journaled | fix or delete the file | +| a run-store value fails the check | kept in the file, reported in `sweep-decisions-reload-failed` | none needed; the id is asked again | +| run store cannot be read (permissions, I/O) | prompts withheld (`sweep-decisions-prompt-withheld`), `N deferred-work decisions not asked` notice | fix the file, then run `bmad-loop sweep` interactively again | + +## What gets published + +The sweep commits each ledger or store change it makes as its own file-scoped commit: only that file is staged, and the whole file is committed. + +| Commit message | When | +| --------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `chore(sweep): record deferred-work decisions` | after the decision phase | +| `chore(sweep): close resolved deferred-work entries` | already-resolved entries and decided closes | +| `chore(sweep): commit ledger at the sweep cycle boundary` | between repeat cycles | +| `chore(sweep): commit ledger after recovering in-flight bundles` | on resume, after in-flight bundles are recovered | +| `chore(sweep): commit a ledger write an interrupted phase left unpublished` | on resume, for a write an interrupted phase did not commit | +| `chore(sweep): drop consumed deferred-work pre-answers` | end-of-cycle prune of the project store | +| `chore(sweep): drop stale deferred-work pre-answer` | a `stale-option` drop | +| `chore(sweep): migrate legacy deferred-work entries to DW format` | legacy-ledger migration | +| `chore(sweep): ingest retro action items` | retro action items added to the ledger at sweep start | + +`bmad-loop decisions` commits each file it wrote separately as `chore(decisions): pre-answer `: the ledger only if a `decision:` line landed, and the project store only for `build` or `keep-open`. A ledger the sweep marked as in doubt blocks every further ledger publish for that run (`sweep-ledger-commit-withheld`). + +### Publication refusals + +Before committing, the sweep checks the target file. A refusal journals `sweep-ledger-commit-refused` with a `refuse_cause`. A ledger refusal also marks the ledger as in doubt, which withholds later bundles. `bmad-loop decisions` reports the same causes in a `not committed to git: ()` note, keeps the answer on disk, and still exits 0. + +| `refuse_cause` | Meaning | Fix | +| -------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------- | +| `target-absent` | the file is missing (including a dangling symlink) | restore the file or correct its path | +| `target-not-a-file` | the path is a directory, FIFO, device or socket | put a regular file there | +| `target-undecodable` | the ledger is not valid UTF-8 (ledger only) | repair the encoding | +| `target-unreadable` | the OS refused the probe or read (permissions, I/O, symlink loop) | fix the path, permissions or storage | +| `commit-unavailable` | `bmad-loop decisions` only: git ran and failed (not a repo, ignored path, locked index) | commit by hand, or ignore it for a file outside git | + +An empty ledger is publishable. A store that is not valid JSON is still publishable. + +### Diagnostics + +`bmad-loop diagnose [run-id]` collects the publication entries in a "Sweep publication and repeat stops" section. For the raw `message`, `repo` and `error` text, read `.bmad-loop/runs//journal.jsonl`. + +| Journal kind | Meaning | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `sweep-ledger-commit` | committed (`message`, `commit`, `file`) | +| `sweep-ledger-commit-clean` | nothing to commit; also seen when the path is gitignored | +| `sweep-ledger-commit-refused` | refused before staging (`refuse_cause`) | +| `sweep-ledger-commit-unavailable` | the ledger is in no git repo, or a store commit failed (`repo`, `error`) | +| `sweep-ledger-commit-withheld` | blocked because the ledger is in doubt (`reason: ledger-in-doubt`) | +| `sweep-bundles-withheld` | bundles not dispatched (this cycle's, or in-flight ones on resume) because the ledger is in doubt | +| `sweep-bundle-skipped` | a bundle skipped whole (`entry-not-open`, `failed-or-escalated-earlier`, `human-chose-keep-open`) | +| `sweep-decision-effect-unavailable` | an answer's ledger effect did not land | +| `sweep-decision-effect-reapplied` | a stored `close` the ledger had not taken was applied again | +| `sweep-repeat-done` | a repeat run stopped (`stop_cause`) | + +A ledger `git status` fault other than "not a repository", or a commit git refuses, ends the sweep as a crash: the run records `run-crash` and writes `crash.txt` in the run dir. + +## Recovering unpublished decisions and bundles + +**An out-of-band answer was not committed** (`not committed to git: …`): + +1. Fix the cause using the [refusal table](#publication-refusals). +2. Commit the named file yourself, e.g. `git add && git commit -m "chore(decisions): pre-answer DW-7"`. Skip this for a file outside git. +3. Do not answer again: the answer is on disk and the next sweep reads it. + +**`no decision line was written`.** The entry or the ledger file was removed while the prompt was open (`: the ledger file is gone`, `: the ledger holds no entry for this id`) or could not be checked (`; ledger state unavailable`). A build or keep-open answer is still saved to the project store and is used only if the id is open again. Nothing needs undoing. + +**`error: could not record DW-N: …`** (exit 1, the walk stops). Fix the cause it names (config changed mid-prompt, undecodable ledger, lock or write error) and run `bmad-loop decisions` again. Answered ids are not offered again. + +**A sweep paused at `story-gate` because a bundle's intent cannot be rebuilt** (`sweep-intent-regen-refused` or `sweep-intent-ledger-refused`): + +1. Restore the ledger file or the missing entries, or fix the read fault. +2. Commit the fix if the ledger is tracked. +3. `bmad-loop resume `. The intent is regenerated and the bundle re-driven. + +**A sweep paused because a bundle close could not read the ledger** (`sweep-bundle-close-refused`): repair the ledger, then `bmad-loop resume `. The close repeats without a new session, except after a review-leg close: there resume rolls the attempt back and runs the bundle again from dev. + +## When the ledger won't read + +A ledger that does not decode as UTF-8 (`ledger-unreadable`) or that the OS refuses (`ledger-inaccessible`) stops or pauses the run, and the ATTENTION notice names the file. + +If the sweep **stopped** (the run is finished and cannot be resumed): + +1. Repair the ledger: make it valid UTF-8, or fix permissions or storage. +2. Commit the repaired ledger, and commit or stash anything else in the code repo. +3. Start a fresh `bmad-loop sweep`. Completed cycles keep their work. + +If the run **paused** (a story run publishing a close, a bundle close, an intent rebuild): + +1. Repair the ledger as the notice says. +2. `bmad-loop resume `. Resume retries the write, reusing the recorded session result where one exists and otherwise running that leg again. `resume` and `resolve` refuse a sweep run while its ledger still cannot be read. + +A missing ledger is not a fault: the sweep finds nothing open. From 0a067ad3c9269f1944ada87f36c682ecccad3e92 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 02:11:47 -0700 Subject: [PATCH 4/9] docs(features): rewrite FEATURES.md as a feature catalog --- AGENTS.md | 9 +- README.md | 2 +- docs/FEATURES.md | 1304 ++++++++--------------- docs/README.md | 2 +- docs/tui-guide.md | 4 +- src/bmad_loop/adapters/opencode_http.py | 4 +- src/bmad_loop/cli.py | 2 +- src/bmad_loop/engine.py | 5 +- src/bmad_loop/install.py | 5 +- src/bmad_loop/model.py | 2 +- src/bmad_loop/runs.py | 6 +- src/bmad_loop/verify.py | 5 +- src/bmad_loop/worktree_flow.py | 4 +- tests/test_cli.py | 4 +- tests/test_docs_hygiene.py | 2 +- tests/test_engine.py | 5 +- tests/test_engine_worktree.py | 2 +- tests/test_generic_tmux.py | 2 +- tests/test_sweep.py | 8 +- tests/test_tui_app.py | 4 +- tests/test_verify.py | 4 +- 21 files changed, 504 insertions(+), 881 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1c1f8846..1cc248a75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — bmad-loop -bmad-loop is a deterministic Python orchestrator that drives unattended BMAD-method dev loops by spawning coding-CLI sessions (claude, codex, gemini, copilot, antigravity, opencode) inside terminal multiplexers (tmux; psmux on Windows). **The control loop contains no LLM calls — hard rule.** Orchestration is deterministic Python; LLMs run only inside disposable coding-CLI sessions. Never move orchestration into an LLM. User-facing overview: [README.md](README.md); behavior reference: [docs/FEATURES.md](docs/FEATURES.md). +bmad-loop is a deterministic Python orchestrator that drives unattended BMAD-method dev loops by spawning coding-CLI sessions (claude, codex, gemini, copilot, antigravity, opencode) inside terminal multiplexers (tmux; psmux on Windows). **The control loop contains no LLM calls — hard rule.** Orchestration is deterministic Python; LLMs run only inside disposable coding-CLI sessions. Never move orchestration into an LLM. User-facing overview: [README.md](README.md); feature catalog: [docs/FEATURES.md](docs/FEATURES.md); config/CLI/run-state facts: [docs/reference/](docs/README.md#reference). ## Dev environment @@ -87,7 +87,12 @@ These rules apply to code you are already touching — do not initiate refactors | Doc | Read when | | ------------------------------------------------------------------ | ------------------------------------------------------- | | [docs/setup-guide.md](docs/setup-guide.md) | installing/initializing a target project | -| [docs/FEATURES.md](docs/FEATURES.md) | any behavior or policy question | +| [docs/FEATURES.md](docs/FEATURES.md) | what the tool does — feature catalog | +| [docs/reference/configuration.md](docs/reference/configuration.md) | a policy key, default, or env var | +| [docs/reference/cli.md](docs/reference/cli.md) | a subcommand, flag, `--json` shape, or exit code | +| [docs/reference/run-state.md](docs/reference/run-state.md) | run/task dir layout, sessions, stop, cleanup scope | +| [docs/sweep-guide.md](docs/sweep-guide.md) | running sweeps, answering decisions, ledger recovery | +| [docs/escalations-guide.md](docs/escalations-guide.md) | recovering a paused or escalated run (`resolve`) | | [docs/tui-guide.md](docs/tui-guide.md) | TUI work | | [docs/adapter-authoring-guide.md](docs/adapter-authoring-guide.md) | adding/finalizing a coding-CLI profile or adapter class | | [docs/multiplexer-backends.md](docs/multiplexer-backends.md) | mux backend selection/porting | diff --git a/README.md b/README.md index 27447b164..20f229dd5 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ sprint-status.yaml: 1-2-account-mgmt: ready-for-dev **Adopting the kept branch.** Under `[scm] isolation = "worktree"` an escalated story keeps its worktree and branch. When the escalation was a false positive on finished work, `bmad-loop resolve --adopt-branch` finishes the story from that branch instead of re-driving it: no agent session runs, the spec is set to `done` (or `awaiting-operator` when the story declared operator actions), and the branch is committed and merged and the sprint board advanced. **Review, the `[verify]` commands and `pre_commit_gate` workflows are not re-run** — you are vouching for the work, and resolve warns you so. It refuses (state untouched) for sweep runs, a story whose worktree is gone, or one with no spec; plain `resolve` re-arms those instead. `--adopt-branch` and `--restore-patch` are mutually exclusive. -**Re-verifying kept work.** When a story was deferred (or escalated as an environment fault) only because its environment was broken — a container or database was down while verify ran — fix the environment and run `bmad-loop resolve --reverify`. It keeps the attempt as it stands (HEAD plus any uncommitted changes in place, or the kept worktree unit under isolation), replays the `[verify]` commands on it, and on a pass reviews it per policy and commits it (a unit merges) — no dev session and no resolve agent. A failing replay re-defers or re-escalates without retrying. Anything you committed or changed since the pause is squashed into the story's commit, and resolve says so before it asks. A worktree unit is accepted under any pause when `--story` names it; sweep runs are refused, and so is an environment fault caught before a session launched or after a dev or fix session crashed or timed out (nothing finished to verify — use plain `resolve`, see [Re-verifying kept work](docs/FEATURES.md#resolve-reverify)). Mutually exclusive with `--adopt-branch` and `--restore-patch`. +**Re-verifying kept work.** When a story was deferred (or escalated as an environment fault) only because its environment was broken — a container or database was down while verify ran — fix the environment and run `bmad-loop resolve --reverify`. It keeps the attempt as it stands (HEAD plus any uncommitted changes in place, or the kept worktree unit under isolation), replays the `[verify]` commands on it, and on a pass reviews it per policy and commits it (a unit merges) — no dev session and no resolve agent. A failing replay re-defers or re-escalates without retrying. Anything you committed or changed since the pause is squashed into the story's commit, and resolve says so before it asks. A worktree unit is accepted under any pause when `--story` names it; sweep runs are refused, and so is an environment fault caught before a session launched or after a dev or fix session crashed or timed out (nothing finished to verify — use plain `resolve`, see [Re-verifying kept work](docs/escalations-guide.md#resolve-reverify)). Mutually exclusive with `--adopt-branch` and `--restore-patch`. A plain `resume` past an unresolved escalation still runs the rest of the queue, but the run then **pauses at that escalation** rather than finishing, so `resolve` can still act on it and its kept worktree is not reclaimed. Plain `resume` re-pauses there every time; the way past it is `bmad-loop resolve ` (re-arm, or `--adopt-branch`). A replay run (from `resolve --reverify`) that escalated because its story was finished meanwhile is closed with `bmad-loop resolve --abandon`: uncommitted tracked and untracked changes in its worktree (plus the run's own spec/board/ledger copies) are parked on a `refs/attempt-preserve-dirty/*-abandon` ref, kept within `scm.preserve_keep` — other git-ignored files are removed with the worktree — and the worktree is removed, the branch kept, and the run recorded finished. (In stories mode a pick-time wedge you fixed by hand does not count.) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bd9cffb03..a2be837af 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,848 +1,462 @@ -# bmad-loop — Features & Functionality - -For BMAD users who have run `bmad-sprint-planning` and have a `sprint-status.yaml` full of `ready-for-dev` stories. This is what the tool actually does and the problem each capability addresses. - -See [README.md](../README.md) for the narrative overview and [setup-guide.md](setup-guide.md) for installation. - ---- - -## 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 | - ---- - -## Full feature list - -### Core orchestration loop - -- Automated per-story pipeline: `dev → verify → review → verify → commit`, end-to-end, no human in the loop. -- Deterministic control flow in plain Python — story selection, retry budgets, gate checks, and completion checks are code, not an LLM session. -- Owns `sprint-status.yaml`, the single source of truth: `bmad-sprint-planning` generates it, and while a run is in flight the orchestrator is its sole writer (`sprintstatus.advance` — idempotent, never-regress) while the dev and review sessions it dispatches are told never to write or revert it; your own BMAD skill runs still edit the board outside a run. Selects the next `ready-for-dev` story; advances by epic/story. -- Two orchestrator processes can no longer interleave a board advance (#286/#469): `sprintstatus.advance` holds an advisory cross-process lock across every read that decides the **published bytes** and across the write itself, so the never-regress decision and the bytes that decision is applied to can no longer be separated by somebody else's write. Being the board's sole write path was never mutual exclusion on its own — a second `bmad-loop` process runs that same sole writer. Readers stay lock-free, a board that does not exist is still reported missing without a lock being created at all, and a lock that cannot be taken fails the advance on the channel that already carries its errors rather than rewriting the board unserialized. That scope is deliberately narrower than every read the call makes: an advance that would write nothing — an absent row, or a row already at or past target — is answered from one advisory pre-lock read and takes no lock at all (#736), so an idempotent replay such as `bmad-loop confirm` against a story the board already records as done cannot fail on contention, or on a state root it never needed. The probe runs the same never-regress predicate the locked body applies, so it cannot answer "no write" where the writer would write; every other answer, and any fault while probing, falls through to the hold, which re-reads and decides authoritatively. Same sidecar mechanism as the deferred-work ledger, described under _Deferred-work sweeps_. -- Scoping flags: `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run` (prints the plan, spawns nothing). - -### Spec + implementation (dev stage) - -- Drives the upstream dev primitive (unmodified) in a fresh tmux session — `bmad-build-auto`, or `bmad-dev-auto` on pre-rename releases, resolved from disk per skill tree and invoked under the name it resolves to, so either era runs with no config edit (the legacy name is accepted only when marker-complete, which refuses the forwarding shim the rename leaves behind): it plans a 1.5–4k-token spec, auto-approves it, implements, and self-finalizes the spec; the orchestrator syncs `sprint-status` and synthesizes `result.json` from the spec the skill leaves on disk. -- Deterministic missing-marker catch + repair (#276): the review HALT intermittently finalizes a spec's frontmatter to a terminal `status:` without appending the `## Auto Run Result` section the harvest scan keys on, which once livelocked a finished story into a DEFER-drop (#224). Four mechanisms catch and fix that **without ever mutating the launch frontmatter** (load-bearing skill routing): a launch-state content hash — a candidate still byte-identical to the spec as it stood at launch is provably untouched by this session, so synthesis is refused — mid-session observation of the spec leaving its launch status (which outranks the hash), artifact repair that appends the owed marker, and one contract nudge per session (`limits.dev_contract_nudge`, default on). -- Spec-only contract between stages — review consumes the frozen spec, not the dev session's context. - -### Verification (trust-nothing gate) - -- After each session, checks on-disk artifacts before proceeding: spec frontmatter status, independent baseline validity, non-empty diff (skipped only on the two legs that can legitimately produce none: a park this attempt newly elected — see parking under Failure handling below — and a stories plan halt) (#676), and sprint-status sync. The exact recorded commit remains valid. A different claim must uniquely resolve from its immutable object ID to a direct commit that descends from the recorded baseline and is reachable from the checkout's `HEAD`; symbolic or movable refs, ambiguous prefixes, non-commit objects, older claims (except the deferred-work bundle case below), diverged commits, and off-HEAD descendants are refused. Proof for an accepted descendant is re-anchored after that commit and counts only tracked, staged, or committed changes because no snapshot can date untracked files relative to the later claim. A deferred-work bundle may still use an older ancestor when it adopts a pre-existing story spec. In the default shared checkout, the gate proves later tracked work exists but cannot attribute it to a particular session; `[scm] isolation = "worktree"` is the provenance-preserving mode. -- Runs _your_ commands (`[verify].commands`, e.g. `pytest -q`, `ruff check .`) in the git root the code lives in (`repo_root`): your project dir by default, the mounted per-unit worktree under `[scm] isolation = "worktree"`, and an explicit `repo_root:` when you set one (under `isolation = "worktree"` only a `repo_root` containing the project is accepted, and the mount is a checkout of it). The gate's own artifact READS — the spec's frontmatter, the sprint board, the deferred-work ledger — stay project-rooted either way (#695). What follows the code, besides the command `cwd`, is every git question the gate asks about it: the recorded baseline is written in the git root, so the commit-identity lookup, both ancestry checks and the non-empty-diff probe are all asked there, and the pathspecs they exclude are spelled relative to that same root (#716). Anchoring those on the project dir meant a correct attempt could be refused forever under an explicit `repo_root:`. A broken build never reaches review or commit. -- Environment preflight (DW-523): with `[environment] probes` set (operator health checks — `pg_isready`, a container's health URL), every verify composition runs them first — the dev, fix and review-gate legs, a `resolve --reverify` replay, and `confirm --reverify` — in the same git root, through the same shell runner, fail-fast, each bounded by `probe_timeout_s` (default 60). A probe that exits nonzero, times out or cannot be started (on Windows, also one `cmd` cannot run) is an environment fault and no `[verify]` command runs. In a run, `env-probe-failed` is journaled and the story escalates as an environment fault, not a code failure, without charging the attempt; during `confirm --reverify` it blocks the confirmation and journals nothing to the completed run. A verify command can declare one itself by exiting with `[verify] env_fault_rc` (`0` disables it; `75`, EX_TEMPFAIL, is a good choice) — the declared code outranks the shell's `126`/`127`. Both settings are inert when unset: nothing spawns or journals and no prompt changes, and the probes enter the config digest only when non-empty. To check the probes before a run, use `bmad-loop validate --probes` (DW-526; see the command reference). - -### Adversarial review (review stage) - -- The follow-up review is a re-invocation of the dev primitive on the `done` spec — a fresh-context session with no anchoring bias from the implementer (BMAD-METHOD#2508 routes a `done` spec to a fresh step-04 review pass), so there is no separate review skill. -- Parallel adversarial layers resolved from the skill's `customize.toml` (defaults: Blind Hunter (Adversarial-General back when the layer named the standalone skill), Edge-Case-Hunter, Verification-Gap — the third added by BMAD-METHOD#2550 — and the inline Intent Alignment Auditor added by #2560) → verify findings against code → triage → auto-apply patches → log → defer ambiguity → commit. Which skills a layer invokes is a property of the installed primitive, not a catalog pinned here, so `bmad-loop validate` derives the prerequisite set per tree and both topologies run unchanged: on pre-6.11 sources the hunter layers hand off to upstream `bmad-review*` skills and those are checked for as bmm prerequisites; on 6.11 sources every layer is a self-contained prompt — the edge-case-hunter and verification-gap layers read the primitive's own `review-prompts/*.md`, the blind-hunter and intent-alignment ones carry their prompt inline — so no review skill is required at all. -- Bounded review loop (`limits.max_review_cycles`, default 3 cycles); done when the pass finishes `done` and no longer recommends a follow-up. A second guard, `limits.max_followup_reviews` (default 1), damps the structurally non-convergent case: a finalized pass that keeps recommending its own follow-up is honored only this many times, after which the round converges (verify + commit) and the lingering recommendation is journaled — `review-followup-damped`, carrying the cap and whether the re-review cap fired with it — instead of burning cycles to the hard cap. `0` never honors a pass's own recommendation. **No ledger entry is filed** on this path or on plain budget exhaustion (`review-budget-committed`): the DW-55/64/90 class showed such rows re-litigate a converged story's review rather than record work anyone chose to defer, and a follow-up that matters resurfaces through review of later work, not through a process ticket. (The one path that still files is the review _timeout_ salvage, under its own `review-timeout-salvage` origin.) (Upstream BMAD-METHOD#2580 has since made the flag convergent by construction — a severity-weighted score over the pass's patched findings rather than a judgment — so the damping guard is now belt-and-suspenders; it stays as the orchestrator-side bound, which #2580 explicitly leaves to the driver.) -- Optional (`[review].enabled`, default `true`): set `false` to skip the follow-up review session. The dev pass's own inline review (same layers, in-context) is then the only review and it finalizes the story to `done` — one session per story instead of two. Verify commands still gate the commit. Applies to story runs and deferred-work sweeps alike. -- Trigger (`[review].trigger`, default `recommended`): when review is enabled, decides _when_ the follow-up pass runs. `recommended` runs it only when the primitive sets `followup_review_recommended` on a `done` spec (it self-reviews inline and computes the flag from a severity-weighted score over the final pass's patched findings — flag introduced by BMAD-METHOD#2505, scoring by #2580). `always` runs it on every story (pre-0.7.0 behavior). - -### Failure handling & resilience - -- CRITICAL session reasons are composed uniformly for dev, review, fix, migration, and triage. Their complete detail remains in session results, `state.json`, `journal.jsonl`, and `status --json`; `ATTENTION`, text status, summaries, the TUI pause header, and its resume dialog cap only the displayed copy at 2,000 characters, name `journal.jsonl` as the complete source, and separately point to the originating spec as a recovery trail when one exists. A blocked generic-dev escalation carries its spec path through the pause, so an intent-gap notice leads directly to the `Auto Run Result` that names any saved recovery patch. - -- Bounded dev retries (default 2): verify-failures keep the tree and feed the failing output to the next session via `--feedback`; other failures roll back to baseline. -- Silent dev/review sessions enter bounded stall recovery from launch: transport activity (pane output or parent/child OpenCode SSE) re-arms the grace, and a provable OpenCode `busy`/`retry` status protects active work from a nudge. Wake prompts are bounded attempts, not guaranteed recovery; if a dead multiplexer window rejects one, the loop degrades to its next liveness classification instead of escaping. None of these are completion signals — completion still requires Stop/idle evidence or process/window death, followed by deterministic artifact verification. -- An auto-rollback parks the attempt before it resets — commits above baseline on an `attempt-preserve/*` branch, the uncommitted tree (tracked edits + run-created untracked files) on a `refs/attempt-preserve-dirty/*` snapshot — and **refuses the reset if it could not** (#340): the run pauses with rescue instructions naming the tree, rather than discarding work the safety net failed to capture. Sweep-migration recovery resets (the validation retry, both resume restores and the `escalated` restart) park the whole checkout the same way and pause under the same #340 gate, so unrelated uncommitted work in the checkout stays recoverable by name (DW-313). Ordinary resolved re-drive preservation is best-effort and proceeds after journaling a fault; when a leg fails (the commits could not be counted or parked, or the uncommitted snapshot could not be captured) the reset still runs, but it also journals `attempt-preserve-fallthrough` (`leg`, `head`) and, once the reset completes, appends an `ATTENTION` line naming the failed leg and the attempt HEAD sha (or the reflog, when HEAD could not be read) for a rescue, and saying so when uncommitted work was not preserved (DW-481); restoring a changed snapshot-backed spec is the exception, because replacing the only unparked child copy is unsafe. A configured external artifact cannot enter a Git recovery ref, so that case pauses for manual adoption. `scm.preserve_keep` (default 20) bounds retention of each recovery-ref family separately — these two plus the merge pre-flight's `refs/merge-preflight-preserve/*` snapshots (below), which never share the rollback budget. A fresh-baseline dev retry after such a rollback is told where that work is parked (#777) — the ref, a `git log`/`git diff` against the baseline, and that the work is unverified and every gate must pass fresh — but only when Git confirms the ref still resolves, carries this run's name and descends from this task's baseline, and a dev session of the rolled-back attempt was dispatched — recorded, or cut off mid-session by a hard stop or crash. A pruned ref, a resolve re-drive's reset, or a sweep bundle that adopted another bundle's ids gets no paragraph; commits-only preservation says so. Nothing is replayed onto the tree. -- Plateau-defer: when review won't converge the story is skipped, the spec stashed into the run dir (`deferred//`) once its rollback completes, deferred-work preserved, and the run continues. The defer notification names where the attempt survives — in place, the recovery ref plus the `git merge --ff-only` line that restores it (flagged commits-only when the uncommitted snapshot could not be captured); isolated, the kept-failed unit branch plus any earlier attempt's ref, named rather than offered as a merge. That ref is projected as `preserve_ref` in `status`/`--json`; the unit branch never is (#333). When the recovery itself pauses the run, the defer record still lands first, pointing at the manual-recovery notice instead of a ref (#342), and the spec stays live instead of being stashed: the stash follows the rollback's decision, so a paused rollback still sees the attempt's spec, and `resolve --reverify` uses the live spec (or restores a stashed one) (DW-528). -- Stories owing human-only external actions park at `awaiting-operator` instead of lying (#335). A story owing something no agent can do (buy a domain, publish a DNS record, grant an API key) **commits** everything an agent can, records what is owed in its spec's `operator_actions:` frontmatter, and parks. The board moves forward, the run continues, and nothing is rolled back — a park is a success that commits, so there is no stash and no recovery ref. It clears the deterministic gates that still apply (spec/board pair, your verify commands, a non-empty action list) and skips two: the review loop, and the dev gate's proof-of-work — a park's whole output can legitimately be the spec and the board (#676). Proof-of-work is skipped only when verification observes both a valid parked spec and `park_asserted: true` in the synthesized dev result. That strict boolean is minted only from the current session's last genuine, non-fenced `## Auto Run Result` marker reporting `awaiting-operator`; frontmatter-only fallback, orchestrator-repaired markers, legacy results, and malformed values fail closed onto the ordinary diff requirement. This prevents previous-run, out-of-band, and re-armed specs from inheriting waiver authority through retained frontmatter or `operator_actions:` while preserving crash and fixable-retry result replay. Nothing else narrows: the status pair, action list, workflow tag, baseline match and board sync all still select on the observed parked state, so an unasserted park that did real work passes as before. A park that clears the artifact gate with the waiver is journaled as `park-proof-of-work-skipped`; `zero_diff` reports whether the waived gate would have found non-excluded residue (`true` means none, `false` means some, `null` means the probe could not answer). The record means only that this attempt cleared the artifact gate with proof-of-work waived; later verify commands, review verification and repair, pre-commit workflows, or the commit may still reject it. The committed half is the later `story-awaiting-operator` event. Parking is notify-only and never halts the run; `[operator] enabled = false` restores the old two-outcome behavior, where such a story could only be `done` or `blocked`. -- A review pass may demote a `done` story to a park, opt-in (DW-383). By default (`[operator] on_review_demotion = "escalate"`) a review session is never offered the park, and one that finalizes the spec at `awaiting-operator` anyway is not accepted — the status reads as non-terminal and the review loop cycles on it as before. Under `"park"` the review prompt carries the same park contract as a dev prompt, and a pass that finalizes `awaiting-operator` with a non-empty `operator_actions:` list converges: the board moves `done -> awaiting-operator` — the one allowlisted regression of the never-regress rule (`statemachine.BOARD_REGRESSIONS`), performed by the sole board writer only on explicit opt-in; any other backward move raises — then the ordinary review verify gate (park pair, non-empty actions, your verify commands), `pre_commit_gate` and the normal commit path run, and the story ends `awaiting-operator` with its park record, `story-awaiting-operator` journal event and notification, while the run continues. A demotion that fails the gate latches no actions and puts the board back at `done` before the failure is routed like a failed `done` gate (repair when fixable, else retry or escalate). Inert unless parking itself is live: `[operator] enabled = false`, stories mode and sweep runs ignore it. -- A successful stories-mode plan halt is likewise journaled at the artifact-gate boundary as `plan-halt-proof-of-work-skipped`. Its `zero_diff` uses the same tri-state projection (`true` = the waived gate found no non-excluded residue, `false` = it found changes, `null` = Git could not answer), including the stories manifest/spec and orchestrator-written exclusions the live gate would have used. The record is emitted only after the `ready-for-dev` artifact verification passes and never substitutes for the independent `result.json` `plan_halt: true` marker that authorizes the halt; an absent marker or an earlier gate failure produces no waiver record. -- Completing a park: `bmad-loop confirm ` walks the outstanding actions one at a time, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair together with the park record's deletion. Nothing is re-driven — the agent-doable work was committed at park time; `--reverify` re-runs your `[verify]` commands first and a failure blocks the confirmation. Each park is a **committed per-story file** under `.bmad-loop/operator/`, written inside the story's commit window so it rides the park's own commit through the merge-back to every clone — a teammate, a fresh clone or CI can confirm a story parked elsewhere (#356). `validate` warns on drift in every direction (`operator.registry-stale`, `operator.actions-malformed`, `operator.park-record-missing`), and `confirm` refuses a drifted record. A park written before #356 lives in the machine-local `.bmad-loop/operator-actions.json`, which `confirm` still reads and prunes but nothing writes anymore — so an in-flight park from an older version stays confirmable on the machine that wrote it. -- A confirmation is resumable. Every write is checked — the spec is read back from disk, so a story is never declared done over a write that did not land — and the park record is dropped last, so a failure part-way leaves the story findable. Interrupted between the spec writes and the board write, what survives is a signed-off spec at `done` with the entry still pointing at it; re-running `confirm` **finishes** that rather than refusing it as stale, with no second prompt and no second audit section (the section on disk _is_ the acknowledgment, and the check is fence-aware). It resumes equally from a board a human fixed by hand, which is what the failure message asks for — advancing an already-`done` board is idempotent. `--list`, `--json` (`resumable`, `confirmation_recorded`) and `validate` (`operator.confirm-interrupted`) name that state rather than calling it stale. -- Dispatched sessions are told the sprint board is orchestrator-owned (#437) — the sibling of the park contract above, injected into the prompt the same way. The board advances as soon as dev verifies, but the story's single commit lands only after the review loop, so a session dispatched in between opens on an uncommitted, unattributed change to `sprint-status.yaml` with nothing in the repo naming its author (one read it as a spec violation, reverted it, and tripped the sign-off-regression gate on a story both sessions agreed was finished). Story dev prompts and the review prompts of sprint and sweep runs carry the same prohibition: never write the board, never revert it, and a row at `done` or `awaiting-operator` is the orchestrator's own bookkeeping — not a defect to fix, and not proof that the work is verified, deliberately, since the row is written _before_ the deterministic dev verification runs and a repair session opens on a red tree under a `done` row. Only the **review** prompt adds where to go instead: a story that cannot be finished without a human decision is finalized to `status: blocked` with a reason — the one hand-back that both withholds the commit and reaches a human, where any other non-terminal status just burns the review budget onto a defer that rolls the work back. A dev prompt gets no such invitation, because `blocked` halts the whole run — the exact failure park exists to avoid — and a dev session that cannot finish already has park. A deferred-work bundle's dev prompt carries nothing (a bundle has no board row) while a bundle's _review_ prompt does, since a sweep runs inside a project whose board exists and is just as revertible; every injected plugin-workflow session carries the prohibition too — `post_dev_phase`, `post_review_result` and `pre_commit_gate` all fire inside that same window — as its own `## Sprint board` section appended _after_ the session-gate hooks, so a plugin prompt rewrite cannot strip it, and without the `blocked` redirect for the same reason a dev prompt has none; stories mode carries none of it, having no board at all. -- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. A story's escalation trail is append-only and deliberately survives a re-arm (it is the run-dir audit a later resolve cycle reads), so a second `bmad-loop resolve` used to re-present every CRITICAL the story ever raised, interleaved with the new ones and with nothing marking which was which — against a resolve skill whose contract is singular. An interactive resolve session that records a `resolution.json` now **watermarks** the trail at its current length, and every later cycle hands the agent only the escalations recorded since; how many earlier ones were withheld is printed to your terminal, never added to the agent's `context.json`. When that filtered list contains entries, the resolve skill presents them newest-first under the existing globally de-duplicated contract. When a new pause precedes any newer recorded escalation and the filtered list is empty, the skill presents `paused_reason` as the available current-pause evidence and discloses that no newer recorded detail exists; it does not read below the watermark, recover an older artifact entry, or synthesize an escalation object. The watermark moves only on a gesture that actually accepted a resolution — a resolve session that exited without writing one, `resolve --no-interactive`, and the TUI's Re-arm button all leave it where it stands. Leaving a watermark is not clearing it: a watermark already standing still filters on those paths, which show everything recorded since the last accepted resolution rather than the whole trail. That is where the bias is deliberate, and it is a claim about which GESTURES move the watermark: one that accepted nothing never moves it. Within a cycle that DID accept a resolution the watermark covers everything that cycle PRESENTED — it is stamped at the trail's length, not at the entries individually answered — so answering one of five escalations shown together retires all five. A task's watermark is reported as the `esc-upto` column of `bmad-loop diagnose`'s markdown task table, and as `escalations_resolved_upto` under `--json` (that is the key to grep in a support bundle), which is what explains a short or empty `context.json` escalation list on a bug report. -- Every notice is shaped before it reaches `ATTENTION` or the desktop toast (DW-13, DW-332). Terminal control characters (ESC, BEL, NUL, DEL, C1) become visible `\xNN` text and TAB becomes a space. On the default path a multi-line message is **folded**: its non-blank lines are joined with `⏎` on the notice's single `[stamp] title: message` record, so `ATTENTION` stays one record per line. A 4000-character backstop cuts any longer line and ends it with `[… truncated; full detail in journal.jsonl]`, the marker a CRITICAL display uses; a CRITICAL display or a verify reason fits under it, so in practice only control- or line-dense text that shaping has expanded reaches it. Notices that are multi-line by design (a park's numbered action list, ACTION REQUIRED recovery steps, the run-finished and graceful-stop summaries) keep their lines, with control characters still escaped. Inside the run summary the embedded `PAUSED:` and `CRASHED:` reasons are folded onto their own line, and stdout shows the same shape. The journal and state keep the raw text. -- A rejected dev attempt notifies too, with its reason (#640). RETRY was the only dev outcome that rejected an attempt silently, and it is the one that discards a completed implementation — the non-fixable leg resets the tree to baseline. The notice fires once per rejected attempt in an uninterrupted run (so ordinarily at most `max_dev_attempts` per story) and has no suppression knob of its own; it follows `[notify]` like every other notice. One attempt can raise it twice: the notice precedes the rollback, so a host that dies in between replays that verdict on resume and announces it again — treat the count as a floor on attempts rejected, not an exact tally. The reason is carried whole but **folded** onto the notice's single line by the notice shaping described above — a `Decision.reason` routinely carries a verify-output tail, which before this spilled into `ATTENTION` as loose, un-prefixed lines. The raw reason stays in the `dev-decision` journal entry. It fires above the fixable/non-fixable split, so on a leg that goes on to pause for manual recovery the operator sees both notices. -- Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a verify command whose _environment_ is broken (it exits with the configured `[verify] env_fault_rc`, or with rc `126`/`127` — the shell's codes for a command it cannot find or cannot execute, which a command may also exit with deliberately, so the pause names that convention rather than asserting the cause; a failing `[environment]` probe; on Windows a missing tool is caught by its `is not recognized` message or by resolving the command's leading token, and a command naming a file `cmd` cannot execute — a `.sh`, or any extension outside `PATHEXT`, which cmd hands to the file association and which exits `0` without running anything — is a fault rather than a silent rc `0` pass, #302; and on either OS a verify command whose child could not be started at all — most often because the directory it was to run in is missing, is a file, or cannot be searched, but any spawn-time `OSError` counts — is translated into the same fault instead of crashing the run, since no exit code exists to classify) **or** a session whose log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure, or a provider quota/usage-limit refusal, that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt and deferring the story as if its code were broken. Re-arm restores the budget. Patterns are per-profile: `claude` seeds three, each a prefix match on the first sentence of an error its CLI was captured printing (connection loss, and the two captured provider 5xx refusals — statuses enumerated, never ranged, so an uncaptured `503` stays prose), so a story that merely writes _about_ a provider error cannot trip them (#507) — though one that quotes such a first sentence verbatim still can, since nothing anchors the sentence's end; `opencode` seeds a provider quota/rate-limit and connection pair (#323), matched against the `opencode serve` process's own stdout, which the model cannot write to; the other four profiles ship none. Each adapter matches them against the log named by its `ENV_FAULT_LOG_SUFFIX` — the tmux pane capture `logs/.log`, or `.server.out` (the `opencode serve` process's own stdout) for `opencode-http`, never that adapter's model-written transcript. A pattern is only sound against a log the model cannot write to; where that does not hold — the pane capture — the pattern has to reproduce a whole captured sentence, because an error token plus a cause on the same line is precisely the shape a story writing about the error emits, and that framing is what the guard now refuses (#507). A usage-limit / quota cause stays unseeded on the pane-capture profiles for the same evidentiary reason: no captured line exists for them (#323). Extend or disable them in a project profile overlay. A pattern match is overruled when the session's tracked usage is nonzero (#764): measured token use disproves "the CLI never reached the API" — a late transient provider 5xx in the tail of a productive session is a real timeout — so the session is charged like any timeout/stall/crash (retried while budget lasts, deferred when spent), `env_fault_overruled` rides `session-end` with the matched line, and the retry/defer reason names it; untracked or zero usage, or a usage read that fails, keeps the pause. A session of any role (dev, review, fix, workflow, triage, migration) that times out having consumed zero tokens is classified the same way (DW-364): when the adapter's usage read is _tracked_ and totals `0`, the CLI never got a usable response from the provider (a quota stall, an API error), so the run pauses with a zero-token evidence line instead of charging the attempt. Untracked usage never reads as free and is charged as before — for `claude-jsonl`, no transcript or no usage block carrying a token count; for `opencode-http`, no assistant message with a non-empty `tokens` block, or a zero total with no completed assistant message (an in-flight first step; a step the timeout's own abort cancelled does not count as completed). A matched log pattern takes precedence, and `stalled`/`crashed`/`over_budget` sessions are never classified this way. -- `[environment] probes` are checked at three sites (DW-523): **before `[verify]` commands** (the preflight under Verification); **at every failure decision** — before a dev, fix, review or blocking-workflow failure is charged as a retry, a defer or an exhausted budget, the probes re-run, and a failing one replaces that decision with an environment-fault escalation (`env-fault-reclassified`; the attempt is not charged); and **before each dev and review session launch**. A probe failing before a launch pauses the run at the `environment` stage, which is not an escalation: no session starts, phase and counters are untouched, and a plain `bmad-loop resume` re-probes first — still failing, it re-pauses unchanged; healthy, it journals `env-fault-cleared` and launches that same session with no rollback (`resume-env-dispatch`). `resolve` refuses the stage with that resume hint. A launch is not re-gated when probes already passed since the last session ended. Every environment-fault escalation records where it was caught in the story's `env_fault_site` — `verify:`, `probe:decision:`, `probe:claim:`, or `probe:dispatch:` for the pause — which `resolve --reverify` reads to decide whether a replay can clear it. With probes configured, dev and review prompts also ask the session to add one `Environment fault: ` line to its final Auto Run Result when something outside the code blocks it. That line is only a **trigger**: the orchestrator runs its own probes, journals `env-fault-claim` (`probe_outcome` `passed` / `failed` / `not-configured`), and pauses (site `probe:claim:`) only when a probe fails — a claim alone never pauses, passes or changes a decision. -- A session that never did anything pauses instead of retrying (#727). A dev CLI parked on a permission dialog, a login, or a dead-on-arrival window ends `stalled`/`crashed`/`timeout`, and all three used to RETRY while budget lasted — a fresh session into the identical wall, `max_dev_attempts` burned without a line of work. The tmux adapter now stamps `produced_work` on every non-completed result: `true` when a `Stop` arrived (a turn ended), when there is no pane log to read (opencode-http, "unknown never blocks"), when the pane log changed on a tick more than 30 s (`FIRST_FRAME_S`) after the wait loop started and before the first stall wake nudge went out, or when a model-side transcript record was appended after its sampled baseline before the first stall wake nudge, or the usage sampler saw nonzero spend before that nudge (signals a misbound pane sink, #254/#217, cannot hide; user/setup-only transcript writes and post-nudge writes do not count); `false` otherwise. The first frame is excluded because a parked CLI paints exactly once — the #727 capture is a 1,930-byte dialog, which clears the #261 read-back gate's 256-byte floor, so that floor is deliberately not the predicate — and post-nudge growth is excluded because the nudge's `Enter` confirms the dialog's default and the pane grows with the echo and exit text, the loop's own keystrokes. The window closes on the first nudge _attempt_: a send that raised may still have pasted its text before `Enter` failed, so a failed attempt excludes later growth exactly as a delivered nudge does (DW-449). `decide_dev` PAUSEs a `produced_work=false` result ahead of the budget, exactly as an environment fault does (`no work produced: dev session (no completed turn or qualifying activity was observed — the CLI may be waiting on a human: a permission prompt, a login, a confirmation; the attempt is not charged)`), the env-fault arm outranks it, the `#489` lost-session suffix survives inside it, and re-arm resets `attempt` to `0`. The flag rides `dev-decision` beside `session_vanished` and, when `false`, every role's `session-end`. Two deliberate costs, both toward today's behavior: a CLI slower than 30 s to paint at all retries as before, and a session that woke only after a nudge and then never ended a turn pauses for the operator instead of retrying. The review, fix and sweep deciders are unchanged, and `stall_deadline`, its pane-growth re-arm and the nudge budget are untouched. -- A session parked on a human prompt is escalated instead of nudged (DW-348/DW-350). The stall wake nudge ends in `Enter`, which can _answer_ the prompt a parked CLI shows (#727: it confirmed "No, exit" on Claude Code's Bypass Permissions dialog). Two opt-in, profile-declared signals now gate it. **Hook-reported**: the relay forwards a `Notification` payload's `notification_type`, and the profile's `[hooks.notification_types]` maps the subtypes that mean "waiting on a human" onto the canonical parked kinds `PermissionPrompt` / `IdlePrompt` / `QuotaPrompt` (a profile may also map a native event straight to one). The claude profile maps `permission_prompt`, `idle_prompt`, `quota_auto_resume_stale`, `quota_auto_resume_disabled` and the MCP elicitation dialogs `elicitation_dialog` / `elicitation_url_dialog` (as `PermissionPrompt`, DW-434); `agent_needs_input` stays unmapped. One of its triggers is a different (background) session waiting while agent view is open, and the payload does not say which trigger fired. The other, a teammate setup question, needs experimental agent teams, which bmad-loop does not enable. An overlay may map it. The adapter latches the signal, and a `Stop` clears it. Because claude sends `idle_prompt` about 60 s after every finished turn, a dev, review or workflow session that ends a turn without a result and then sits idle re-latches and, at stall-grace expiry, pauses as parked instead of receiving the stall wake nudge — intended; a project overlay that drops `idle_prompt` from `[hooks.notification_types]` restores the nudge. **Pane-matched**: at every stall-grace expiry with no latch set — a nudge due, or the final stall once the nudges are spent or when none are configured (DW-433) — the visible pane (`capture-pane -p`) is matched line by line against the profile's `parked_prompt_patterns` (claude ships the two captured #727 dialog lines). Neither signal completes a session or acts on arrival. The one decision point is the stall-grace expiry: with a parked signal, no nudge is typed and the session ends `stalled` with `parked` / `parked_evidence` (a dead window still ends `crashed`). Only an adapter with a stall grace armed reaches that point — the dev/review adapter (which also drives fix and workflow sessions) with `dev_stall_grace_s > 0`; the plain triage adapter (sweep triage and migration) has no stall grace, so its sessions still time out or retry as before. Every dev, review, fix, blocking-workflow, migration and triage site PAUSEs a parked result (`parked: session stalled (; …)`), after the env-fault arm and ahead of the no-work arm. Re-arm restores the attempt. `parked` rides `dev-decision`, `fix-decision`, `workflow-end`, `migrate-decision`, `triage-decision` and (when true) `session-end`. Capture or regex failures degrade to "no match": a due nudge goes out, or the final stall ends unparked, as before. A project initialized before this has no `Notification` relay; `hooks.registered` still passes, and hook-reported signals start after the next `bmad-loop init`. The opencode HTTP adapter is unchanged. -- An idle session is visible while it sits (#680). A session idling inside a tool call (`sleep 590; cat …`) keeps its pane log growing through spinner repaints, so the stall re-arm — correctly — never fires and nothing separated it from a working session but `session_timeout_min`. The adapter now stats the live transcript's `(mtime_ns, size)` — a baseline the moment the first hook event names it, then on the heartbeat cadence (a stat, never parsed usage, so it works for `usage_parser = "none"`) — stamps the age on `heartbeat.json` as `transcript_idle_s` (`null` before a transcript is known), and — when the run's journal is attached, which the engine does for every adapter it owns — journals one `session-idle` (`task_id`, `idle_s`, `since_ts`, `threshold_s`) when the age crosses `limits.dev_stall_grace_s` and one `session-active` (`task_id`, `idle_s`) when the transcript moves again; a later stretch emits a fresh pair. The threshold is the stall grace on purpose — the event fires exactly when the session _would_ have stalled had its pane not kept repainting, so the two records are directly comparable — and `0` disables the events with no new knob. The TUI's agent line shows the open stretch as `· idle `. Observability only: nothing bounds the stretch, and the session is neither nudged, stalled nor killed for it. Scope: the notice starts when a hook event names the transcript — `SessionStart` on the `claude`, `codex`, `gemini` and `copilot` profiles, so it covers a session's first turn there; `antigravity` fires no `SessionStart` and names the transcript only on its `Stop`, so its first turn is invisible to the notice (the heartbeat's `transcript_idle_s` stays `null` until then), and the `opencode-http` transport has no pane wait loop and emits neither the field nor the events. -- A session the multiplexer lost says so (#489). Sessions complete on a hook `Stop` or on window death, and a window is gone whether the CLI exited or something destroyed the whole mux session out from under the run — an external reaper, a concurrent prune or `bmad-loop stop`, an operator `kill-session`, a server crash, the host sleeping. Both are `crashed`, so the retry/defer reason an operator reads said only `dev session crashed` — pointing at the agent when the host was at fault. The crash verdict now asks whether the _session_ still exists and, when it does not, says so in the reason (`… session crashed: the multiplexer no longer reports the session, so the window's disappearance is not evidence the CLI exited`), as `session_vanished` on `dev-decision` and `fix-decision` either way, beside the routing each fed, on every role's `session-end` journal entry when it is true (the convention `env_fault` already uses there), and as a `session-vanished` breadcrumb in `session-lifecycle.jsonl`. When the probe itself cannot ask (`has_session` raises `MultiplexerError`), the verdict stays an undiagnosed `crashed` and a `session-probe-failed` breadcrumb (`session`, `error`) records that the question went unanswered (DW-382). A negative lookup is confirmed before it is recorded (DW-459): `has_session` maps every nonzero backend result to False, including psmux's `Invalid session key` and `connection timed out` from a session that is still alive, so the adapter then lists the session's windows and writes `session-vanished` only when that listing proves the session gone; a listing that raises or still finds windows writes `session-probe-failed` instead. The repair path carries it the same way: when fix attempts are exhausted the defer names the lost session instead of blaming the tree for repairs that never ran. The wording states what the evidence _withdraws_, not what it proves: a session the multiplexer no longer reports says nothing about what removed it — enough to stop an operator reading window death as a CLI exit, not enough to name a destroyer. It composes with an environment-fault pause instead of being swallowed by it. A session reaped _after_ flushing its result still scores `completed` and is not diagnosed — it produced something. Diagnosis only — the routing is unchanged, and a retry re-creates the session. -- Hook events from a nested coding CLI are attributed, not trusted (#767). A CLI process started from inside a session inherits the relay environment, so its `SessionStart`/`Stop`/`SessionEnd` land in the parent's event stream under the parent's task id and could complete or crash it. The generic adapter applies a second layer after the task-id filter (`SessionAttribution`): the first `SessionStart` is the launched session's — identified or not, so an anonymous start (a payload the relay could not read) still takes the parent's slot — and a later `SessionStart` with a new id marks that id foreign, unless its `source` is `clear` or `compact`, which is the launched session rotating its id and rebinds (`resume` and `startup` stay foreign). `source` alone does not rebind: an id already found foreign stays foreign (a child compacting), and a `clear` start right after a foreign id's `SessionEnd` is that child clearing, so its new id is foreign too. A foreign id's events are dropped before they can complete the session, re-point its transcript, spend nudges or re-arm the stall timer; the first drop per foreign id writes a `foreign-hook-event-ignored` breadcrumb (`hook_event`, `foreign_session_id`, `dropped_so_far`), every drop counts into `foreign_hook_events` on `heartbeat.json` and the `timeout-fired` crumb, and the sweep's failed-session diagnostic replays the same rule (`hook_foreign_ids`, `; foreign ignored: N` in the escalation suffix). The rule fails toward acceptance: id-less events, ids that never announced a `SessionStart` (a rotated id, a copilot `toolu_…` subagent `Stop`) and everything before the first start — including an identified `SessionEnd` from a CLI that exited before its `SessionStart` fired (#727) — are admitted, so attribution only ever drops a known child's events and never adds a completion path. A profile declaring `session_id_flag` pins the launched session's id instead (DW-505/508): the `claude` profile ships `--session-id`, so the adapter mints a UUID4 per launch, passes it on the command line and knows the parent before any event arrives — every id ever bound (the pin and each `clear`/`compact` rebind) is the launched session's own, and an identified `SessionStart` or `SessionEnd` from any other id is foreign, including a child `SessionEnd` whose child never announced a start; other events (`Stop` included) from an unannounced id are still admitted. If the CLI does not honour the pin — its first identified `SessionStart` that is not a `clear`/`compact` rotation reports another id (a future claude ignoring `--session-id`, or an `extra_args`/`launch_args` overlay adding `--resume`, `--continue` or `--fork-session`) — attribution treats the launched session as foreign, so its own `Stop` is dropped and it ends only on window death or its timeout. One `pinned-session-id-mismatch` breadcrumb (`pinned_session_id`, `reported_session_id`, `source`) makes that visible (DW-509). It is observation only and leaves the attribution rules unchanged. A start the pin rejects and the relay tags `mismatch` (a nested CLI, e.g. one a parallel `SessionStart` hook launched, reaching the events directory first) is not checked; the next start is. So a CLI whose own starts all read `mismatch` (a miscalibrated relay, below) and that ignores the pin writes no crumb. The check needs the launched session's own identified start: if that start was anonymous (a payload the relay could not read) or was lost behind a `clear`/`compact` rotation, the next identified non-rotation start is the first one checked, and it may be a nested child's. The crumb then names the child's id while the launched session completes normally, so read it as "the pin was not confirmed" rather than as proof the session is stuck. Accepted limitation, for profiles without `session_id_flag` (codex, gemini, copilot, antigravity): a child `SessionEnd` whose child never announced a `SessionStart` is indistinguishable from the parent's own and is admitted; nested CLIs announce their start. The sweep's failed-session diagnostic always replays the unpinned rule. A profile that maps no `SessionStart` (Stop-only, e.g. `antigravity`) gets no nested-CLI protection. A relay vendored before this change forwards no `source`, so there a `clear`/`compact` start with a new id reads as foreign and the session falls back to window death or its timeout — re-run `bmad-loop init` to re-vendor the relay. Relay-side lineage closes the remaining gap (DW-507). A tmux launch runs the window command behind a POSIX prelude, `/bin/sh -c 'BMAD_LOOP_LAUNCH_PID=$$; export BMAD_LOOP_LAUNCH_PID; exec "${SHELL:-/bin/sh}" -c "$1"' sh `, so the variable holds the launched pid and the command runs under tmux's `default-shell` exactly as before (tmux sets `SHELL` to it in every pane), so whatever that shell sources for a `-c` command (fish's `config.fish`, zsh's `.zshenv`) still applies. Both relays (`bmad-loop relay` and the legacy copied script) tag every event `lineage`: `match` when the launched CLI itself fired the hook, `mismatch` when a nested CLI did, `unknown` when that cannot be read. The relay walks its parent chain from `/proc` and skips only two kinds of process: the launch chain — any process started within 5 s of the launched pid, which covers a shell that forks the CLI (fish, or dash as Debian/Ubuntu `/bin/sh`: the launched pid is the shell, the CLI its child) and a node shim's real binary a level below (a limitation: anything the CLI itself starts at launch, such as an MCP server or a project `SessionStart` hook running another CLI, is inside the window too and reads `match`, so only the rules above apply to it) — and a hook-command wrapper, read structurally from its argv: a shell (`sh`, `bash`, `zsh`, `fish`, …) whose `-c` command string holds the relay invocation (`…/bmad-loop relay` or `…/bmad_loop_hook.py`) followed by the event name as consecutive words, split on whitespace, quotes and shell operators (so `sh -c '…/bmad-loop relay Stop && true'` counts), or a runner whose argv ends in it (`uv run --no-project python …/bmad_loop_hook.py Stop`). Nothing else in a process's argv is read, so a nested CLI whose prompt merely names the relay is still a nested CLI. Any other process on the way means a nested CLI. The relays only tag, never drop. The launched session's own first `SessionStart` calibrates lineage (with a pin, the first identified start the pin admits, so a child's start that reaches the events directory first calibrates nothing, identified or anonymous): tagged `match`, it is trusted, and every later `mismatch` event is foreign — id-less events and a child's `clear`/`compact` rotation without a preceding `SessionEnd` included, which the rules above alone would admit or rebind. Tagged `mismatch` (the CLI's hook architecture defeats the walk) or `unknown`/untagged (psmux on Windows and macOS have no `/proc`; a relay that predates the tag sends none), lineage is ignored for the attempt, the rules above apply unchanged, and one `hook-lineage-untrusted` breadcrumb (`reason`: `miscalibrated` or `unavailable`, `lineage`: the first start's tag) records the degrade. Lineage needs a `SessionStart` to calibrate on: a Stop-only profile (e.g. `antigravity`) never engages it and writes no crumb. An event carrying one of the launched session's own ids is never made foreign by lineage, and events before calibration ignore it. Id-less drops share one `foreign-hook-event-ignored` crumb with `foreign_session_id: null`, and the sweep's failed-session diagnostic, which replays the same rule, counts them as `hook_foreign_idless` (`; foreign id-less events ignored: N` in the escalation suffix). -- Transport faults in the generic (multiplexer-driven) adapter leave crumbs instead of healthy-looking answers (DW-447/449/453/454). Like `session-probe-failed`, a window-liveness probe that raises `MultiplexerError` never reads as death, and every verdict is unchanged; what changes is the record. `liveness-probe-failed` (`site`, `error`) is written by the `tick` site at a wait-loop streak's first failed tick only, never once per tick, and by the one-shot sites (`over-budget`, `stall`, `post-kill`) once per verdict probe that raised. `liveness-probe-recovered` (`failures`) closes a tick streak when a later tick probes cleanly; a session that ends mid-streak (Stop, SessionEnd, abort, over-budget) leaves no recovered crumb. `heartbeat.json` carries the running streak as `probe_failures`, and `timeout-fired` carries the final one. `over-budget-fired` and `kill-escalated` gain `liveness_unknown`, which is true when the probe behind that verdict (for the kill, the last poll before escalation) raised. A stall, budget, stop or contract nudge whose send raised writes `nudge-send-failed` (`nudge`, `error`) and is never reported as sent: `stall_nudges_sent` on the heartbeat counts delivered nudges, `stall_nudges_failed` counts the failed attempts, and `contract-nudge-sent` is written only after a successful send (a failed contract nudge is still not retried). The stall-nudge cap and the #727 activity window count attempts, delivered or not. A post-kill rescue abandoned on unknown liveness or an unreadable artifact writes `post-kill-rescue-abandoned` (`reason` = `liveness-unknown` / `unreadable-artifact`, `status`, plus `error` for the read fault). The opencode HTTP adapter's heartbeat carries neither new key (its `stall_nudges_sent` counts attempts), but its nudges are crumbed the same way (DW-503): its `send_text` raises a `MultiplexerError` when the prompt POST fails, so an undelivered contract nudge writes `nudge-send-failed` instead of `contract-nudge-sent`, and its own budget, stall and stop nudges write `nudge-send-failed` and carry on exactly as before. Its dev sessions do share the post-kill reconcile, so they write `post-kill-rescue-abandoned` too, on the `unreadable-artifact` arm only (their liveness probe never answers "unknown"). -- Three generic-adapter observation folds are crumbed the same way, verdicts unchanged (DW-448/450/451). A stall-expiry look at the pane whose capture raises `MultiplexerError`, or whose parked-prompt search blows `PARKED_PROMPT_MATCH_TIMEOUT_S`, still reads as "not parked", and writes `parked-probe-failed` (`reason` = `capture-failed` / `match-timeout`, `pattern` for a timeout, `error`) once per expiry; a profile with no `parked_prompt_patterns` or a backend without `capture_pane` stays silent. A pane-log stat fault other than absence still leaves the #261/#727 proof-of-work signal unknown, and writes `log-evidence-failed` (`error`) once per session however many verdict sites consult it. A present `result.json` the read-back refuses (unreadable, unparseable, not an object) still reads as no result: the Stop read-back's give-up record in `resultless-stops.jsonl` says `malformed-result-json` with the refusal instead of `no-result-json`, once per give-up rather than per poll, and the exit read-back writes `result-json-refused` (`error`). The opencode HTTP adapter shares the `resultless-stops.jsonl` verdict but not the other crumbs. -- A nested sprint spec explains its own timeout (#780). The unpinned sprint-mode dev read-back reads only specs directly under the artifacts dir, never its subdirectories — widening the scan would widen the #261 shared-dir hazard — so a spec kept in, say, `implementation-artifacts/stories/` is never found and the session rides to timeout. The `no-artifact` crumb in `resultless-stops.jsonl` now names any qualifying spec one level down ("qualifying spec(s) found in subdirectories, which are never read back: … — move the spec directly under the artifacts dir, or use stories mode ([stories] source) for a stories/ layout"); a nested hit is named, never read back as a result, and a nested `*.md` the probe cannot read, or a subdirectory it cannot list, is reported as a probe fault rather than read as "nothing nested". A dev session that ends non-completed carries the last such verdict on its `session-end` journal entry as `resultless_verdict` / `resultless_detail` (present-only, diagnostic, never routing), so the reason surfaces without opening the task dir. `bmad-loop validate` warns `queue.nested-specs` in sprint mode on a `*.md` with a non-empty frontmatter `status:` one level down, before any tokens are spent (a nested `*.md` it cannot read, or a subdirectory it cannot list, is warned on too, never silently skipped); stories mode never warns, since `stories/` is its own layout. -- Four more generic-adapter folds are crumbed, verdicts unchanged (DW-452/455/456/457). A budget usage sample whose transcript read raises still reads as "no sample" — with a persistent fault, `token_budget_mode = "enforce"` stays off for the session — and its streak writes `usage-sample-failed` (`error`) at the first failure and `usage-sample-recovered` (`failures`) at the next clean sample, never once per tick (one torn mid-append read is one pair); `heartbeat.json` carries the running count as `usage_sample_failures`. A #727 transcript activity scan that raises still counts as no model-side evidence, with its streak crumbed the same way (`transcript-scan-failed` / `transcript-scan-recovered`). A launch-snapshot fault that turns the #276 M1 refuse gate inert — the identity check's `resolve()` raising (the 3.11 symlink-loop `RuntimeError` included, which used to escape) or the digest read raising — still reads NEUTRAL, and writes `spec-identity-unreadable` (`spec`, `snapshot`, `error`) or `spec-digest-unreadable` (`spec`, `error`) once per read-back, not per grace poll. A spec read-back that gives up on a fault says so: `stat-failed` for a stat fault the stories read-back used to file as `stale-mtime`, `unreadable-spec` for an unreadable or undecodable spec it filed as `not-terminal` (and the scan fallback as `no-artifact`), each with the error, in `resultless-stops.jsonl` on a Stop read-back and as `spec-readback-failed` (`reason`, `spec`, `error`) on a one-shot or dead-window (post-kill reconcile) read, which recorded nothing before. -- CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. A DEFERRED or environment-fault escalated story whose kept work is sound takes [`--reverify`](#resolve-reverify) instead, which replays verification rather than re-driving a dev session. The re-arm advances the story's - baseline in the **code tree** and is honest when it cannot: a failed advance is narrowed to typed git - errors, journalled, echoed to stderr, and explicitly NOT followed by a re-stamp it did not earn, so - spec and task never silently agree on a stale sha (#640). The re-arm's other warn-only git probe — the one that lists the commits an abandoned attempt left below the re-drive's new baseline — is honest the same way (DW-81): its Git failures journal `rearm-commits-probe-failed` and echo to the same surfaces, because that probe's silence is otherwise indistinguishable from a clean answer, and the absent warning is the operator's only sign that those commits are now a permanent starting point nothing will revisit. A re-stamp that does overwrite a differing - claim records what it replaced, and warns on either leg: the record fires only when the spec claimed - a baseline the run never recorded, which is the only remaining trace of a divergence the gate can no - longer report. `spec_file` is persisted relative to a worktree for an isolated task, so every out-of-process reader - re-anchors it on the tree the run owns before reading or writing — resolved against the process cwd it named the main checkout's copy - of the same story spec, and both writes landed on a file the run never used. The same - anchor backs the dashboard's review modals and their replan write, `context.json`'s - `spec_file`, and the paths the pause notifications print. The fields beside it (the sentinel - indicator, the stories block) take a DIFFERENT root by design: they resolve against the - workspace stories root, not the spec's confinement root, whose out-of-mount arm falls back to - the project so a `confine_root` can always contain the path it validates — borrowing that - answer for a READ would look the stories folder up in the main checkout while the dev session - answered the worktree. Each names the tree the run owns, so a single surface cannot describe - two trees. The dev session's own prompt keeps the relative spelling, - because that session runs inside the mount. A spec re-arm still - cannot read has its baseline re-stamp skipped rather than silently no-oped - (`rearm-baseline-restamp-skipped`), and a status flip that quietly changed nothing is reported - too (`rearm-spec-flip-skipped`) — though not when the spec was simply already at the target status, - which is an ordinary re-arm rather than a failure; neither record depends on the git advance having - succeeded. On a spec the re-drive will actually READ, that skip also refuses the re-arm outright: the - routing status is what the re-drive runs on, and a spec without one halts the re-driven session on - an unrecognized status, so the escalation stays armed for a corrected spec instead of being spent - on a session that cannot route. The spec is left byte-identical, down to the stale `## Auto Run -Result` section. Every other spec keeps warn-and-continue, and the record says which happened - (`refused`) so neither surface prints a refusal's remedy for a re-arm that completed. One a re-arm - cannot read at all is warn-and-continue because the failed flip says nothing about what the - re-drive will read: the path is worktree-relative and the re-drive mounts a fresh worktree and - reads the COMMITTED spec either way. A worktree-local one is warn-and-continue for the sharper - version of the same reason — the readable file is the copy that fresh mount destroys, so refusing - over it would demand a repair to a file nothing opens, even once the corrected spec is committed - and routable. Under worktree isolation those writes land in a worktree the re-drive discards, and the - re-driven session reads the COMMITTED spec, so re-arm says so (`rearm-spec-write-unreachable`) and - asks you to commit the corrected spec — but only when the spec really is worktree-local and the - committed spec does not already carry the status the re-drive needs, so an isolated re-arm whose - correction is already committed says nothing, and neither does one whose spec sits in an artifact - directory configured outside the project: those are shared across checkouts rather than rebased - onto each worktree, so the flip lands on the one file every re-drive reads. That record also HOLDS - the resume: both surfaces re-arm and resume in one gesture, which made its own "before resuming" - advice unactionable the moment it printed and then burned the escalation on a session that halts - blocked. They now stop after the re-arm — the story stays armed, `bmad-loop resume ` picks - it up once the correction lands, and `--resume` does not override it, since the record is written - on proof rather than suspicion. Each surface names the HELD record's own remedy rather than one - hardcoded literal, because the holding records do not share one. Each remedy also names the - `status:` the re-drive routes on, so a correction that lands byte-correct but still terminal cannot - burn the escalation a second time — the sentinel remedy below renders that clause on the same - contract, and empty, since its leg has no spec status at all. This one asks you to commit, while - its in-place arm — isolation edited to `none` while the story was escalated, so the re-arm's writes - went into the escalated attempt's worktree and the re-drive now reads the main checkout — asks you - to correct the spec in the MAIN checkout, where committing is not the remedy and naming a branch - would be actively wrong. The advisory warnings do not hold. - A pre-planning **sentinel** gets the same treatment on its own artifacts. It is cleared by - deletion rather than a status flip, so there is no spec write to measure — but the correction that - stops it recurring is upstream (`SPEC.md` / `stories.yaml`, where the resolve skill sends the agent - instead of the sentinel), and an isolated re-drive re-plans from the committed tree of a fresh - mount. Re-arm now says so (`rearm-upstream-write-unreachable`), names the folder and the branch, - and holds the resume the same way. Narrowed on the same principle: it fires only while the branch - the re-drive mounts from does not already hold this checkout's copy of those two files, so a - correction already committed there resumes in one gesture, and an in-place re-drive never records - at all — it reads the main checkout, which is where the resolve session runs. - A **failed status flip** holds too, on exactly one of its three arms, which is why that one is - keyed on the record's flags rather than on its kind. `rearm-spec-flip-skipped` is journalled with - `refused = spec_path.is_file() and write_reaches_the_redrive`, so the arm that reaches the re-drive - without being refused proves the recorded spec path is not a readable file here — the re-drive - reads that same path and finds no spec there to route on, futile on the same proof as the two - above, and it reaches this arm from BOTH re-drive modes. Its remedy is to restore the recorded - path carrying the status the re-drive routes on, never to commit: the arm's own condition means - there is no corrected spec at that path to commit, and the path can be a shared artifact directory - outside the project that is not a repository at all. The refused arm raises rather than resuming, so there is no gesture left to hold, and the - remaining arm carries no imperative of its own — `rearm-spec-write-unreachable` holds that leg. - The whole re-arm is one **transaction**, and what it covers is stated narrowly: the SPEC's - BYTES, from the first spec write to `save_state`. That save is the commit point — until it - returns the run still calls the story escalated, so any fault escaping the window in between - (a journal write that fails, a non-git fault from the stale-restore commits probe, an - interrupt during one of the three git probes, or the state write itself) used to leave a spec - flipped to the re-drive's status and stripped of its `## Auto Run Result` against a task - nothing had moved. Every one of them now restores the spec to the bytes the re-arm found and - re-raises the original fault unchanged, so the escalation stays armed. One in-window tree - change is deliberately outside that scope: clearing a **sentinel** unlinks the file rather - than writing spec bytes, and it is not re-created, because `_clear_sentinel` already - preserves a copy under `{run_dir}/sentinels/` and a retried resolve re-clears it - idempotently. The re-arm says which of those it did. `rearm-aborted` is journalled from the - rollback and echoed by both surfaces, carrying `restored` (a write had landed and was put - back), `unchanged` (the file was read and PROVED byte-identical — a refusal sequenced ahead - of every write), `failed` (the restore itself could not write, so the spec may be part-written - — that one raises rather than degrading, keeps the original fault in the exception chain, and - names the file to restore — from git _or_ your own copy, since the bytes it failed to write - are gone with the process and a spec is not necessarily tracked — in both the message and its - next step), or `unknown`. The last covers everything the undo could not confirm: the cleared - sentinel, a re-arm that resolved no spec path at all (the record then carries an empty - locator and the notice says `(none)`), and a spec that is gone or unreadable by the time the - undo looks, which it declines to re-create rather than fight whatever removed it. The - surfaces then report that nothing was persisted and the story is still escalated WITHOUT - claiming the file on disk is intact — an unconfirmed outcome must never render as the - reassuring one. Without this record the surfaces described the residue of a re-arm that had - been rolled back — files "excluded from the re-drive baseline" for a baseline never saved. - Two boundaries keep the undo honest. It refuses to write at all when it could not first - CAPTURE the spec's bytes from a file that is there — a transient read fault followed by - writes that succeed would otherwise leave a published flip with nothing to put back — and it - does NOT undo a re-arm whose `save_state` demonstrably committed, since that state write is - a single atomic rename whose call can still be interrupted on its way out, and rolling the - spec back underneath it would build the same defect mirrored. - All of these warnings reach the TUI's re-arm as well as `resolve`'s — both route every - kind through one shared table, so neither surface can silently learn a kind the other drops, - though each still owns where it calls the echo from and the TUI drops the trailing "before - resuming" advice, since it otherwise resumes in the same gesture. A successful re-arm returns - those rendered notices and its hold verdict as one immutable authoritative outcome, captured - only after each journal append succeeds and in append order. The CLI and TUI consume that - outcome directly, so an unreadable journal cannot erase a successfully appended hold from the - combined re-arm/resume gesture; best-effort journal diffing remains only for diagnostics already - appended by a call that aborts before it can return an outcome. Each re-arm also bumps a per-task - **generation**, so the re-minted session id cannot collide with the abandoned attempt's record — ids - already on disk keep their exact spelling, since the suffix appears only above generation zero - (#705). Sweep migration and triage tasks make the same rollover automatically when an - `ESCALATED` task restarts with a fresh attempt budget; mid-flight, non-escalated restarts keep - their current generation because their continuing attempt counter already provides a fresh id. -- Kept-branch adoption (DW-386): `bmad-loop resolve --adopt-branch` finishes an escalated worktree story from its kept branch instead of re-driving it. Under the run lock the task moves `escalated → committing` (a deliberate direct assignment, journaled `escalation-adopted`) with a persisted adopt latch; the resume's commit-window recovery arm (`resume-adopt`) reopens the kept worktree, sets the spec to `done` (`awaiting-operator` when the story declared operator actions), mirrors the board through the sole writer, then squashes, marks the story done and merges it like any completion. No session runs, and review, the `[verify]` commands and `pre_commit_gate` workflows are not re-run — the operator vouches for the work, and resolve warns that those checks are bypassed. Refused with state untouched for sweep runs and for a task with no kept worktree/branch or no spec; a spec gone from the worktree by resume re-escalates the story. Mutually exclusive with `--restore-patch`; not offered in the TUI. -- Re-verifying kept work (DW-522): `bmad-loop resolve --reverify` finishes a story whose attempt was sound but whose verification failed because the environment was broken (a container or database was down). It accepts a DEFERRED story, or an ESCALATED one whose `env_fault_site` a replay can clear (any site but `probe:dispatch:*`; for `probe:decision:dev`/`fix` and `probe:claim:dev`/`fix` only when that leg's latest session completed). Under the run lock the task moves straight to `dev-verify` — a deliberate direct assignment, journaled `story-reverify-armed` — keeping the baseline, the attempt counter and the session records, and, in place, restoring the spec from its defer stash. The resume replays verification on the kept work (`resume-reverify`): the latest completed dev result, the artifact gate, the `[verify]` commands with the environment preflight, and `post_dev_verify`, then journals `reverify-decision`. No dev session and no resolve agent run. A pass continues exactly as a fresh attempt would — `post_dev_phase` workflows, the spec-approval gate, review per policy, then commit (a worktree unit merges); a review that needs repair can run fix sessions only while the kept attempt budget has room, which in practice means an escalated origin. A failing replay never retries: a deferred origin re-defers (unless it was a resolved re-drive), and an environment fault, a CRITICAL or any escalated origin re-escalates (an environment fault at `verify:dev`), each reason prefixed `reverify failed:`. A nonterminal story whose replay passed and whose review or fix session then died with the host re-runs the replay on resume (journaled `resume-reverify` with `replay: post-proceed`) on the tree as the dead session left it, with a fresh review loop, instead of taking resume-restart; a later re-arm clears that marker (DW-527). The work is HEAD plus the uncommitted tree, so commits and edits you made since the pause are squashed into the story's commit — resolve says so before it asks. In place the run must be paused on that story, it must be the last story the run picked, and the code root, a baseline that is an ancestor of HEAD and some product above it must all still hold; a worktree unit needs its kept, registered tree on `task.branch` (a detached `branch_per = "run"` unit is refused) and is accepted under **any** pause stage when `--story` names it. Refused, state untouched, for sweep runs, unpaused runs, a story with no spec or no completed dev result (or whose latest dev or fix session crashed or timed out after it), and a rolled-back attempt (the refusal names the preserve ref or the saved `failed//changes.patch`). Mutually exclusive with `--restore-patch` and `--adopt-branch`. The TUI drives it with `V` (and `R`/`p` on a deferred pause) by launching the command in a control window, where the statement, confirm and resume run (DW-524): `V` lists a DEFERRED paused story or worktree unit, and the paused story when it is ESCALATED at a replayable environment-fault site (DW-532), which `R` still opens in the resolve agent. -- Replaying a finished run's kept unit (DW-525): an isolated defer never pauses the run, so a run can FINISH holding a DEFERRED worktree unit whose worktree and branch `scm.keep_failed` kept. `bmad-loop resolve --reverify --story ` replays it in a NEW **replay run** — its own id, directory, journal and state, recording `replay_of` (the finished run's id) — seeded with one copy of the task, latched exactly as the paused-run re-arm latches it. The kept worktree is `git worktree move`d into the replay run's `worktrees/`, so the replay run owns it (and a second replay of the same unit refuses, naming the replay run that took it over from the finished run's journal). The replay run's journal opens with `unit-replay-start`; the finished run stays `finished`, its `state.json` is never written, and its journal gains one `unit-replay-handoff` entry naming the replay run. The replay run then resumes like any run: verify replays, review follows policy, the unit merges into the finished run's target branch, and the replay run finishes — it processes only that unit (no further stories, no run-end retrospective, no auto-sweep). A replay that re-defers ends with the unit deferred and — under `scm.keep_failed` as the replay run's policy reads it — kept under the replay run, which can be replayed the same way; one that escalates or pauses leaves the replay run paused for the ordinary `resolve`/`resume`. The replay run re-reads the main checkout's board right before its merge (DW-534): a replay minted with `--no-resume`, or paused and resumed after another run finished the story, whose row is now `done` does not merge — it journals `replay-merge-refused`, keeps the branch and escalates, leaving the replay run paused; an unreadable board does the same, and once it is fixed `resolve --adopt-branch` merges the kept branch. A replay run escalated as superseded is closed with `resolve --abandon` — see [Abandoning a superseded replay run](#resolve-abandon). `--no-resume` mints the replay run and prints its `bmad-loop resume` line; without `--resume`/`--no-resume` resolve states the claim and asks first. `--story` is required. Refused, nothing minted or moved, for in-place stories, a detached `branch_per = "run"` unit, sweep runs, stories-mode runs, a story that is not DEFERRED, a story already `done` on the main checkout's sprint board (a later run re-drove and finished it — an unreadable board refuses too; DW-533), a torn-down or unregistered worktree, a main checkout that is dirty or not on the run's target branch, and every kept-work precondition of the paused path; on a finished run each refusal points at recovering the branch or saved patch by hand. A failure after the move puts the worktree back and removes the half-minted run. Git cannot move a worktree that contains initialized submodules, so such a unit is refused at the move — after the prompt, with nothing moved and the half-minted run removed. A finished run's kept worktrees — including a re-deferred unit kept under a finished replay run — are reclaimed at the next `run`/`sweep` start when `[cleanup] auto_clean_on_finish` is on, so replay before then. Shell-only: the TUI's `V` re-verifies paused runs, not finished ones. -- Abandoning a superseded replay run (DW-535): `bmad-loop resolve --abandon` closes a replay run paused at an escalation whose unit is superseded — typically the DW-534 done-row refusal, though no `replay-merge-refused` row is required: the operator vouches that the work is superseded. Under the replay run's lock it parks uncommitted tracked and untracked changes in the kept worktree (plus the run's own spec/board/ledger copies) on a `refs/attempt-preserve-dirty/--abandon` ref, bounded by `scm.preserve_keep` like every snapshot — other git-ignored files are removed with the worktree. A clean worktree on a detached HEAD holding commits the unit branch lacks gets the same ref, at a fresh commit of that HEAD (same tree, HEAD as parent, so `scm.preserve_keep` retention dates it from the abandon). The ref is journaled as `replay-abandon-snapshot` before the teardown. It then removes the worktree but keeps its branch, verifies the worktree is gone from disk and from `git worktree list` (a leftover refuses with the run still paused), journals `replay-abandoned`, and records the run `finished` with the pause cleared; the task stays `escalated` with its branch and worktree path as the record. A worktree already gone (an abandon that crashed after the teardown) skips straight to the journal and state write, reporting the snapshot ref that attempt journaled. A branch-existence check that cannot read the refs refuses with the run still paused rather than recording the branch as gone. Refused, nothing changed, for a run that is not a replay run, a run not paused at an escalation, a task that is not `escalated`, or a plain directory standing where the worktree was registered. The board, the deferred-work ledger, the finished origin run and the run's agent session are never touched. It never resumes: `--resume`/`--no-resume` with it is a usage error (rc 2), and `--no-interactive` skips its confirm. Shell-only: not offered in the TUI, where `R` would re-arm and re-drive the superseded story. -- A run is never recorded `finished` while any story is still `escalated` (DW-386) — except a replay run closed by `resolve --abandon` (DW-535), whose escalated task stays as the record: a plain `resume` past an escalation runs the rest of the queue, then pauses at that escalation (`run-paused`, stage `escalation`), so `resolve` still accepts the run and its kept worktree is not reclaimed. Plain `resume` re-pauses there every time; the way past it is `bmad-loop resolve` (re-arm, or `--adopt-branch`; a replay run's superseded unit is closed with `--abandon`, DW-535). In stories mode only an in-run escalation counts: a pick-time wedge (`stories-wedged`/unknown `--story` id) is re-read from disk every pick, so once it is fixed by hand a bare resume finishes past its stale record. -- Attempt-owned sprint-spec recovery (#123, #630): a bound plain attempt whose only residue is its own lifecycle flip is normalized back to its pre-attempt lifecycle status, proven Git-clean, and retried. Every bound retry chain snapshots its first spec input byte-for-byte and retains it across both dev-verification and review-verification repair sessions; a resolved re-drive therefore retains the operator-corrected `ready-for-dev` input rather than a failed child's later body. A resolved re-drive that began with no recorded spec has no operator snapshot, so its fixable repairs bind the spec the re-drive created (#860). Repair entry points validate retained authority before constructing a prompt that can reset the spec. A non-fixable retry parks the failed child first, restores that snapshot, and re-establishes the promised route after resetting sibling residue. Restoration is anchored at a handle on the spec's parent directory on both supported hosts — the POSIX `dir_fd` family, and on Windows NT handle-relative opens (`win32_at`: `NtCreateFile` with a `RootDirectory`, `FileRenameInformationEx`/`FileDispositionInformationEx` with POSIX semantics, reparse points refused as a link) — retains the staged inode across publication and verifies that exact inode before accepting it. Lifecycle normalization is inode-bound the same way, for in-project and configured external specs alike: one target identity (inode, size/mtime/ctime, bytes) is bound at the read and re-checked before staging and immediately before the replace, the published inode is verified after it, and restore-then-normalize carries the identity restoration just published — a spec edited or swapped after restoration is detected at the read or the re-checks and pauses instead of being overwritten (DW-319, DW-323). The last re-check is not a compare-and-swap: an in-place edit landing between it and the replace is not detected. A host with neither arm refuses before staging or lifecycle normalization, leaves the current attempt-owned spec bytes untouched by restoration, and pauses for manual adoption. The same snapshot restores pre-launch operator edits when a plain child puts a tracked spec back at Git baseline. Git-ignored and pre-existing-untracked bound specs use the byte snapshot as their dirtiness oracle and are force-included only in the private recovery ref before restoration; index-only force-adds and cached removals also trigger cleanup and restore baseline index ownership. That real repair reports `rollback-owned-spec-restored`, never `rollback-skipped-clean`. Missing, unreadable, deleted, retargeted, changed external, or unsafe legacy authority pauses once with spec-specific adoption instructions and clears the unusable pair so manual recovery can converge. Clearing the pair does not move the attempt baseline: resume re-checks the checkout against that same recorded baseline, so the convergent step is returning the checkout to it (the notice names the baseline and the reset step). On a plain attempt, approved edits to a Git-tracked spec that differ from the baseline version — uncommitted or committed on top of it — are ordinary attempt residue on resume (`[scm] rollback_on_failure` parks and resets them; with it off the run pauses again) and are never adopted: the next attempt starts from the baseline spec, so kept edits can only be re-applied afterwards; a latched re-drive that resume rolls back automatically (its armed first unwind, or rollback on) instead keeps an approved spec under the BMAD artifact folders through the reset, and its notice says so (DW-321). Recovery also refuses a reset whose baseline would replace the canonical path or a parent directory with a symlink, tree, file, or other unsafe shape. An initial Sprint binding fault may safely degrade to an unbound bare-key launch; an existing Stories folder+id target instead aborts unless it can be snapshotted. Once an explicit binding is durable, a later snapshot fault aborts before child launch while retaining that authority for recovery. Fresh sprint tasks with no recorded path remain bare-key dispatches; other substantive changes or sibling residue follow rollback policy; Stories remains folder+id; Sweep remains intent-bundle routing; snapshots are retired after commit; and recovery never auto-commits the human correction. -- Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, the dev primitive saves the attempted change as a patch file (referenced from the halt output) before reverting the tree. If that reading turns out to be correct, the resolve agent adds `"restore_patch": ""` to its `resolution.json`; the orchestrator re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing. `bmad-loop resolve --no-interactive --restore-patch ` does the same by hand. A patch that fails to apply escalates rather than dispatching onto a half-restored tree. Sweep bundles get the same recovery. - -### Git worktree isolation (opt-in) - -- Off by default (`[scm] isolation = "none"` — work in place on the checked-out branch, byte-for-byte the prior behavior). Set `isolation = "worktree"` and each story (and each sweep bundle) runs in its own `git worktree` on a `bmad-loop/[/]` branch cut from the target branch, then merges back **locally** — the main checkout stays free while a run is in flight. -- Explicit bundle artifacts (DW-283, DW-289, DW-290, DW-300, DW-301, DW-302, DW-303): successful isolated integration publishes the accepted ignored bundle spec before removing the worktree. To publish additional ignored regular files, name exact paths relative to configured `implementation_artifacts` in the accepted spec's optional `artifact_deliverables` frontmatter list (for example, `artifact_deliverables: [errata/correction.md]`). That directory must lie strictly inside the code repository. Directories, globs, absolute paths, traversal, symlinks, and orchestrator-owned ledger/board files are refused. Tracked files continue to ride Git; undeclared ignored files are not copied. At every newly accepted dev, repair, or review verification boundary, the run durably binds the complete selected ignored path set to SHA-256 digests and the complete tracked or pending-tracked path set to Git-clean-filter-normalized blob identities, all under an append-only session-record identity. The final source commit stages once, validates each declared Git path against that accepted blob identity, then commits the already-validated index without restaging; a committed-tree proof detects hook or concurrent-index mutation and restores the original commit chain before refusal. Target integration carries that same immutable authority through merge, squash, fast-forward, and crash replay: ordinary target hooks still run, but the final target commit and post-hook index must contain every accepted tracked blob and keep every accepted ignored path absent, every incoming path must sit in the post-hook index and checkout exactly as the integrated commit has it — its index flag word included: a fresh entry carries none (skip-worktree too on a sparse target, where git sets it outside the cone and strips a hook's from an in-pattern entry), or the word the receipt captured for the path, so a hook's `update-index --assume-unchanged` or `--skip-worktree` on an incoming path, which changes no blob and hides later edits from git, is refused by path — (an incoming path that commit deletes must be absent from the checkout, not recreated unstaged by a hook — a path the commit holds through a prefix, a file or submodule it turned into a directory, is held, not deleted, and a leaf beneath a symlink the commit holds, a directory it turned into a link, is absent by topology rather than read through the link; a populated submodule checkout git leaves behind when the commit deletes its gitlink is accepted only as the exact captured checkout — this repository's (its git dir under the target's own git dir's `modules/`, which also holds for a target that is itself a linked worktree), clean, at the captured HEAD — and stays on disk as `?? path/` for the operator, exactly as git left it — and when the commit replaces a populated submodule with a tracked directory, git writes the commit's files into the checkout it could not remove, and that leftover (its `.git` still inside the directory) is accepted only owned, at the captured HEAD, and reporting nothing through its own status but paths the integrated tree holds under it; every incoming path the integrated commit holds as a gitlink, captured or newly added, must carry exactly that gitlink in the post-hook index (a cone-mode sparse checkout's skip-worktree flag on an out-of-cone gitlink is that entry's ordinary shape and is accepted — on a sparse target only; elsewhere it is a hook's — and an operator's `update-index --assume-unchanged` bit on a captured gitlink is recorded in the receipt and preserved — a gitlink the commit rewrote may carry the captured word or a fresh one, since `git merge` writes the entry anew and clears the bit where a fast-forward or squash keeps it, an untouched gitlink must carry exactly the captured word, and any other word is a hook's; the restore puts the captured word back where `git restore` cleared it), and a populated checkout there must be this repository's, clean, and at the gitlink (for a gitlink the commit introduces, "clean" counts ignored entries too — the receipt proved the path absent, so everything there is attempt-era; a captured checkout's ignored entries are read against the listing the receipt sealed of them beside its HEAD, below) — read directly, because an incoming `.gitmodules` with `submodule..ignore = all` hides a hook's `submodule update --init`, its writes into the new checkout, a moved HEAD, and even a rewritten gitlink from every `git diff` reading), and the squash leg's own commit must have sealed the tree `merge --squash` resolved (so a target `pre-commit` hook cannot rewrite an incoming source file into the bundle's commit), and after the hooks the target may hold no dirt outside every one of those sets but the strays the guard tolerated before the merge and an ignored file that was already there which an incoming `.gitignore` change uncovered (untracked now, and held by the receipt's ignored listing at the identity it still has; one held under another identity was written during the attempt and is named) — a whole-tree status reading names any other entry, and the receipt's digest of every other index entry's flag word — with the entries carrying a word no fresh entry may, so a flip can be named — refuses a hook's `update-index --assume-unchanged` or `--skip-worktree` on a clean tracked file outside the incoming set, which status never lists — and the receipt's `lstat` identity of the file behind every entry outside that set the index already trusted unread when it was armed (assume-unchanged or skip-worktree; `None` for one not on disk, a sparse target's out-of-cone entries among them) refuses a hook's overwrite, truncation or removal of such a file, which moves no word, no blob and no status or diff reading, the way an ignored file's overwrite is refused (the incoming set names both sides of a rename, rename detection off, so the source a merge deletes is never an entry outside it) (a clean tracked file a hook edited, staged, deleted or renamed, a new file it wrote — under the orchestrator's own `.bmad-loop/` as anywhere else: the hook relay script, a committed `policy.toml`, a profile overlay, with only the run's own records — `runs/`, `archive/`, `cache/`, `decisions.json`, `operator/`, `operator-actions.json` — left out, and the pre-merge guard reading that directory on the same terms, an unstaged stray there tolerated and a staged one blocking; a writer racing the merge window is named the same way), and the receipt's sealed listing of the whole tree's ignored entries, taken when it is armed with each entry's `lstat` identity — size, mtime, ctime, inode, device, mode; bytes are never read (a NUL-delimited sidecar under the operation's capture root, the run's own records left out) — names every ignored entry the tree holds after the hooks that it did not record, or recorded under another identity, and every entry it recorded that is gone — a hook's gitignored write beside an incoming path in a directory the target already held populated, which `status` never lists and the introduced-directory walk has no root at, inside a directory that was already wholly ignored, over an ignored file that was already there, overwritten, truncated or touched in place, or its deletion of one, which `status` and `diff` are as silent about — with a tolerated stray an incoming `.gitignore` change turned ignored left out, and, from the removal reading, every incoming path and every entry beneath one (git's own write clobbers an ignored `d/x` when the commit puts the file `d`); a recorded entry that left the listing but still stands is not a removal (uncovered by the incoming `.gitignore`, it is the stray reading's); the refused receipt's residue reading skips the recorded side, so deleting a named rewrite — whose identity nothing can restore — clears it, and a named removal is the operator's to weigh before resuming (ceiling: a writer that puts size, times and inode back is not read) — a listing that also walks the tree for every nested `.git` entry, directory, gitfile or symlink, which git lists in no reading at all (`.git` is administrative, and a fresh directory holding nothing but one is passed over too), so a hook's `git init` beneath a populated tracked directory, or a repository it puts inside an ignored one, is refused by path the same way; each repository boundary is one entry, nothing inside it read, and the `.git` of a checkout the submodule reading accepts at a gitlink the commit introduced, or the receipt captured unpopulated, is tolerated rather than named twice — and the same listing sealed for each captured populated submodule checkout beside its HEAD, which the tree's listing never descends to, the checkout reading never asks for, and the superproject's own `status` never reports (it names a submodule's modified and untracked content, never its ignored content), names a hook's write the checkout's own `.gitignore` covers after the hooks, into a submodule the commit leaves alone, one whose gitlink it rewrites (the hook having moved the checkout to the new gitlink, which the checkout reading allows), or either leftover — with the commit's own paths written into a replaced leftover left out, and a receipt armed before the listing was recorded reading as it did, and a directory the commit created — where the receipt proved nothing was, or proved an empty untracked directory (the first existing ancestor of an incoming path, which git never tracks and no reading lists; on a refusal it is left absent or empty again, residue there pausing the run as not safely restorable), or where it captured a tracked file, a symlink, or an unpopulated gitlink the commit now holds only as a prefix — is walked on disk against that commit — every file a path it holds, every directory a prefix it holds or a gitlink left to the submodule reading — so a hook's gitignored write or nested `.git` there, which no `status` reading lists, is refused by path (on that refusal the restore keeps the proved-absent directory's doctrine below: it removes nothing it cannot attribute, and the run pauses as not safely restorable, the path named) — before `unit-merged`, publication, carries, or teardown can be recorded (a target-side hook that rewrites paths during the run's later pathspec'd bookkeeping commits is outside this check). On that refusal the restore reverts what the hook staged, which is in the post-hook index delta it already restores, and leaves unstaged, untracked and ignored entries where they are — the run cannot tell a hook's write from an operator's in that window — naming them in the pause. The accepted artifact paths are resolved on the target before the receipt is armed; a resolver refusal pauses there, ahead of collision cleanup and any Git mutation, rather than snapshotting short. Before collision cleanup or any Git mutation, each modern attempt atomically persists a versioned transaction receipt covering its target/source/strategy identity, target ref epoch, exact index stages, worktree kind/mode/bytes, absent parent topology (and an incoming path's first existing ancestor proved an empty directory), a digest of the flag words of every index entry outside the snapshot set (and the `lstat` identity of the file behind each of those the index trusts unread), cleaned collisions, tolerated dirt, declared ignored presence or expected absence (a leaf beneath a tracked file or symlink the unit turns into a directory is absent by topology — a dangling symlink reaches nothing, and one that resolves, into the repository or out of it, reaches another path's entry, which git never tracks through a link — so no receipt reading dereferences the link: the capture, the completeness and post-hook readings, and the checked-path restore's own ancestry preflight all confine the leaf by the link's parent, the link itself captured and put back as bytes under its own path; a leaf beneath a symlink the incoming set does not name is refused before any mutation; and the restore goes through the parent path alone, since git refuses the pair — the put-back symlink is the receipt's own shape, not a redirection, and a proved-absent parent beneath the restored file is absent by topology), and clean populated submodule checkouts (an unpopulated gitlink — a clone without `--recurse-submodules` — is an empty directory, recorded unpopulated: a checkout a target hook's `submodule update --init` makes there is attempt-era whole and a refusal's restore removes it, leaving git's empty directory; a repository of any other kind there — a fresh `git init`, which git also names a submodule while the gitlink is indexed — refuses the restore; a tracked directory the commit put in the gitlink's place is git's to empty on the restore, which then puts the empty directory back, or refuses over anything left in it; and after the hooks such a checkout is read as one the commit introduced, ignored entries counted). Collision cleanup advances through durable pending/applied phases, so replay restores a partial cleanup before replanning; an in-process cleanup fault restores only the paths the cleanup finished or had in flight, never a planned path it had not reached (fresh operator state there is left alone). Exact recovery bytes stream into no-follow confined run-owned sidecars with size and SHA-256 metadata; their aggregate uses `limits.artifact_payload_max_mb` (10 MiB by default), so neither memory nor `state.json` grows with destination bytes and an over-limit capture refuses before target mutation. Wide restoration pathsets travel through NUL-delimited stdin rather than process argv, and a receipt path is held to the Win32 name rules (reserved characters, device aliases, a drive prefix, a backslash) on a Windows host alone — on POSIX git permits `:`, `?`, `*`, `\` and control characters in a name and every reading round-trips them literally; containment (absolute, `..`, `.git`, NUL) holds everywhere — and its absent-parent topology is read by git's slash hierarchy, the one the capture wrote (a name such as `a:` or one holding a backslash is a plain segment to that reading, a drive or several segments to a Windows one). The operation identity is coupled to the target reflog update, so host-loss replay reads the exact old/new transition under a live receipt without trusting commit messages, parent shapes, or the append journal — but no record retires a receipt or stands for a modern bundle's completion: a coding session holds the writable run directory and can append a `unit-merged` row under its own story, branch and target, the receipt's operation identity included, and it holds the shared repository, so the reflog transition under that identity is its to write too (`git update-ref -m bmad-loop-integrate:` writes exactly that row, on a branch checked out in another worktree as well). A live receipt always replays the merge, which finds the moved ref under the receipt and validates the landed result, or pauses with evidence; with no live receipt — a host lost before one was armed, or after a successful integration retired it — the merge replays while the unit's source is still mounted (it stages nothing again over a landed result, re-validates the target's bytes and re-records; an integration that made no ref update, an artifact-only bundle's squash that staged nothing, the same way — a target that already holds the source proves nothing about bytes another writer changed since), and once the completed integration has consumed the source (worktree torn down, branch gone) the completion stands only on the target as it is now — the unit's commit in the target's history, or, since a squash seals a commit of its own and never that one, every change the unit made over its baseline folded into the target's tree blob for blob (mode and object id per added or rewritten path, each deleted path absent) — and every accepted artifact blob in its tree and index — or the run pauses naming the reason (the released legacy payload keeps its bare row). A mismatch restores only while the exact receipt-owned result still owns the ref and the reflog old side matches the snapshot epoch; it restores and verifies the complete index/worktree, trackedness, expected absences, sidecar bytes, and confined indexed submodule HEADs before recording `refused-restored`; a captured submodule checkout is restored to its captured HEAD with whatever the attempt wrote into it undone (tracked content reset, untracked files removed; ignored files are named from the receipt's listing and never touched), including a checkout whose gitlink the refused integration had deleted; a submodule checkout of this repository standing at a path the receipt proved absent (the integration introduced the gitlink, a hook populated it) is removed whole, whatever it holds, while a directory of any other kind there may be fresh operator state and refuses the restore. Tolerated operator dirt and pre-cleaned untracked bytes survive. A moved ref, stale epoch, redirected submodule, missing/corrupt snapshot, malformed receipt, or incomplete restore never authorizes a reset or a success claim: concurrent state is preserved, path-only refusal evidence is journaled, and the source remains for manual recovery. Typed Git refusals and successful no-ref-update attempts retire authority only after proving all receipt-owned state stayed unchanged; a refused no-ref-update attempt records its unchanged revision on both sides so its receipt replays. Restoration completeness is read over the receipt-attributable inventory (commit delta, post-hook index delta, accepted artifact paths): a tracked file the operator edits, unstaged, after the receipt is armed is theirs and never turns a completed restore into an incomplete one. A well-formed frozen dictionary payload from an earlier version keeps its released integration/publication path without acquiring a reflog prerequisite or synthesized rollback authority; malformed or partial modern shapes fail closed. A pre-staging Git-content change, missing or malformed index entry, unmerged entry, non-regular entry, or ignored-to-tracked classification change pauses through the ordinary publication-refusal route with path-only evidence and retains the source mount and original binding; restoring Git-equivalent accepted content lets resume continue without another session. If a deterministic commit hook or other writer repeats the mutation on every attempt, correct or disable that writer before resuming. A writer landing after staging cannot enter the retained commit because validation and commit consume that same index and the committed tree is checked before integration; a clean no-op (nothing staged after the validated snapshot, as when a concurrent writer resets the index to baseline) is checked against baseline the same way rather than recorded as the bundle's commit. Payload preparation rereads the same exact ignored selection and re-derives the tracked path set (every pending-tracked path is tracked by then, the final commit having staged it), and freezes base64 bytes only when the whole ignored mapping and the tracked path set both match — so a spec that lives outside `implementation_artifacts`, which is never itself a selected deliverable, cannot swap one tracked declaration for another after acceptance; the accepted tracked paths' blob identities were already proven on the index and committed tree, so the working tree behind the sealed commit is neither reopened nor re-hashed as authority (a tracked deliverable rewritten or removed there after the commit is tolerated). A post-verification byte, selected ignored path-set, or tracked declaration change pauses through the ordinary publication-refusal route, leaves the destination untouched, and retains the source mount and accepted binding for recovery; restoring the accepted bytes lets resume continue without another session. Replaying the same accepted result never refreshes authority from current source bytes, while a distinct accepted repair or review replaces the binding. Ignored inputs are deduplicated and measured as raw bytes: defaults admit at most 5 MiB per file and 10 MiB total (`limits.artifact_file_max_mb`, `limits.artifact_payload_max_mb`), inclusive, where 1 MiB is exactly 1,048,576 bytes. Metadata preflight precedes every payload encoding, and fixed-chunk bounded reads recheck both caps so a growing file stops at one sentinel byte over budget without turning an arbitrarily large configured limit into one enormous read request. A binding or preparation size refusal leaves `artifact_payload` unset and retains the source even with `keep_failed = false`; because the accepted identity is already armed, changed bytes or policy cannot be adopted by replaying that same result—a new accepted dev, repair, or review result is required. A frozen payload from an earlier version remains publishable and idempotent without synthesizing a binding; a legacy COMMITTING replay skips re-finalization only when its persisted commit already equals HEAD. Destination baselines are captured before execution, independently of both accepted-source binding and the artifact-only receipt. Their SHA-256 capture and every publication comparison stream fixed-size chunks, so operator-owned destination size does not become a destination-sized allocation; growth or shrinkage observed during a read is refused. A completed destination read also compares the opened descriptor's device, inode, and regular-file state with a fresh confined, no-follow pathname observation; a leaf detached by the time of that sampled post-read observation is refused. The observation is not a lock or compare-and-swap: a later rename can still race it, and checked fallback platforms retain their check/read race. Publication writes only when the destination still matches that baseline or already holds the intended bytes. A conflict or publication fault pauses integration and retains recoverable source artifacts even with `keep_failed = false`. Durable publication state lets resume finish an interrupted publication idempotently before successful cleanup; an older task without baseline authority cannot acquire overwrite permission by resuming. An accepted artifact-only attempt needs no tracked delta for publication and teardown. Publication preserves file bytes, but creates files privately at mode `0600`; it does not preserve file modes or xattrs. The destination check runs after staging, immediately before replacement. It is not a lock or atomic compare-and-swap guarantee: noncooperating filesystem writers can race the final check and replacement. - - Capacity regression basis: on CPython 3.13.15/Linux, five saves of a 10 MiB raw payload produced about 13.34 MiB of base64 state in 0.278 s with 34.4 MiB peak traced memory; preparation plus one save peaked near 45.3 MiB. The regression test pins the exact 4/3 base64 structure and uses deliberately cross-platform ceilings of 160 MiB peak and 20 seconds for five saves; these ceilings detect unbounded copying without serving as performance promises. - -- Merge knobs: `merge_strategy` (`ff` / `merge` / `squash`), `target_branch` (default = branch checked out at run start; created if missing — a detached HEAD or unborn repo pauses the run instead of merging onto an unreferenced commit; pinned for the run, so a merge that finds the main checkout on another branch pauses (`merge-target-not-checked-out`) until you check the target out and resume), `branch_per` (`story` or a shared `run` branch; `run` forces `delete_branch = false`), and `delete_branch`. -- Dirt in your MAIN checkout only blocks a unit merge when the merge — **or the run's own post-merge bookkeeping** — could commit it (#460, #618). Two questions, asked per path over the strays that lie outside the branch's incoming set. What the MERGE can commit is what git has **staged**, so the axis is the index column rather than trackedness: an untracked stray and a tracked stray edited in the working tree only are both inert — measured on git 2.55.0 across both topologies and both strategies, rc 0, the edit survives uncommitted, and it is absent from the resulting commit — so both are left exactly where they are and the guard journals `merge-target-tolerated` naming them. Before this, one unrelated `notes.txt` (untracked, #460) or one saved-but-unstaged edit (#618) in the main checkout escalated the first story and paused an unattended run. A **staged** stray still escalates, because `merge --no-ff` refuses it outright and a fast-forwardable `merge --squash` folds it into the story's commit — against a diverged target `--squash` refuses too, with the same error as `--no-ff`, so the fold is a fast-path artifact rather than a squash property. What the RUN can commit is the second question: the post-merge carries stage the sprint board and the deferred-work ledger **by pathspec** (`git add -- :(literal)`), which takes whatever the working tree holds no matter who wrote it, so **any** dirt on one of those two paths escalates whatever its index column says — otherwise an operator's private reopen of a story row rides out under a `chore(sprint-status): carry …` message with the tree left clean and nothing to read the substitution back from. Each path is protected only when the task actually carries a write to it (DW-354): the ledger when the task holds a ledger payload (harvested deferrals, story closes, a sweep bundle's closes), the board when the task recorded a board advance (`board_advance_intended` — a generic story that reached its terminal; sweep bundles and stories-mode tasks never record one). A carry that never runs never stages its path, so an unrelated unstaged edit there on a task with nothing to carry is tolerated like any other unstaged stray. That protection covers artifacts git already **tracks**: an untracked one has no baseline to diverge from, the orchestrator has been reading that exact file as its own all along, and committing it whole is how a non-ignored board first reaches git (#350) — protecting it would halt the first story of every project that has yet to commit its board. Either escalation names the paths and asks you to commit, stash or revert them (it never cleans them for you), and each names its own remedy, since staged work has to be committed or unstaged while dirt on a carried path has to leave the path entirely. `merge-target-tolerated` records what the **guard** decided and is emitted before the merge, so a stray it waved through by path can still clash with the incoming commit by **shape** — a file where the merge needs a directory, or the reverse — and git then refuses at pre-flight over the very path the event called harmless; that run journals a corrective `merge-preflight-refused` beside it, naming the same paths and carrying git's raw text (#623). A `per_worktree` engine Editor leaking _this branch's own_ files into the main checkout is still auto-cleaned and journaled `merge-target-cleaned`, since those are duplicates of content the branch already committed. A dirty **tracked** path in that incoming set may instead be your own uncommitted edit to a file the branch also changes, so before restoring any such path the pre-flight parks the working-tree bytes of all of them in one snapshot commit under `refs/merge-preflight-preserve/` (its own retention family under `scm.preserve_keep`) and journals `merge-target-preserved` naming the ref and paths (DW-356). Recover with `git show :` or `git checkout -- `; for a path you had deleted the ref simply lacks it — there is nothing to recover. That ref may be the only copy of your uncommitted edit, and a later run start prunes it once more than `scm.preserve_keep` newer ones exist — copy the bytes out, or raise `preserve_keep`. A path whose working tree already matches HEAD (dirty only in the index) holds no bytes to park and mints no ref. If the snapshot cannot be made, nothing is cleaned and the unit escalates (no-receipt leg) or pauses (a sweep bundle's integration-receipt leg) with a reason naming the failed preservation and the paths. -- A merge-back that fails escalates in one of **five** typed shapes rather than one, plus an honest fallback for anything unclassified (#619). Git declining at **pre-flight** — an untracked file the merge would overwrite, a staged change on an incoming path, a file/directory shape clash, a `merge_strategy = "ff"` target that cannot fast-forward — is not a conflict: nothing was merged, the target checkout is exactly as it was, and there are no markers to find. That escalation now says so and lets git's appended text name the cause and the paths, instead of sending you to resolve a conflict that does not exist; a genuine content conflict keeps the resolve-by-hand wording, and both keep the unit's branch and worktree mounted for manual recovery. The third shape is a `--no-ff` that merged cleanly and was then refused at the **commit** — a `pre-merge-commit` or `commit-msg` hook exiting non-zero, or a `commit.gpgsign` that cannot sign. Nothing conflicted, so it leaves no unmerged stages, but it does leave `MERGE_HEAD`: an index-only reading calls that started merge a pre-flight refusal and sends you to clear a clash that does not exist. bmad-loop aborts it, restores the checkout, and points you at the policy that declined instead. Telling the three apart takes **both** probes — `git ls-files -u` leads and answers content, and `MERGE_HEAD`, read before the abort that erases it, parts a merge that never started from one that started and could not be sealed. Neither alone is enough: a conflicted `merge --squash` writes three unmerged stages and conflict markers while creating no `MERGE_HEAD` at all. The `squash` leg reaches the third shape through a different door: `--squash` itself stops before committing by design, so no hook or signature can refuse the merge invocation — but the leg then seals the staged result with its own plain `git commit`, where commit hooks and `commit.gpgsign` run like anywhere else, and a refusal there is the same third shape. That one is rolled back with `git reset --hard HEAD`, gated on the pre-merge reading having found the tree clean — a checkout already carrying uncommitted work of yours is never reset, and the reset is deliberately whole-tree, since it undoes a merge that SUCCEEDED, whose staged result spans the entire incoming set (the stated ceiling: an edit landing after that clean reading rides the reset); when the rollback is withheld the squash result is left **staged**, and the escalation says so and names clearing it as your first step. Neither the exit code nor the message text can stand in, the same refusal being rc 1 or rc 2 depending on topology, rc 1 also being what a conflict returns, and the message being fully translated. The fourth shape is the one **all three** strategies reach and no index reading can see: git dying part-way through the CHECKOUT. It materializes the incoming files in index order, so a failure partway — measured under a **required** clean/smudge filter that cannot run, on all three strategies — stops with HEAD where it was and the tree already partly rewritten. `--ff-only` is not exempt: it declines the _topology_ question before touching anything, but once the fast-forward is possible it checks the incoming tree out like any other merge, so the "it never starts a merge" premise that used to excuse this leg from checking was simply wrong. That leaves no unmerged stages, no `MERGE_HEAD`, and a tree that reads clean against HEAD (an untracked file is in neither HEAD nor the index), so every probe above calls it a pre-flight refusal and tells you the checkout is untouched — while the residue refuses the NEXT merge as an untracked-overwrite, identically on every resume, over paths nothing named. Residue probes answer it: the untracked set and the dirty-tracked set are each sampled **before** the merge, differenced after, and the deltas intersected with the branch's incoming set — the only paths a merge can write — so the answer is "git wrote this", not "this is here" and not "something changed while the merge ran". The intersection closes the seventh mislabeled state: with attribution read off a repo-wide "the tree is dirty now" boolean, an edit YOU made to an unrelated tracked file while the merge was failing was attributed to git — a genuine pre-flight refusal classified as "failed part-way through checkout", and the repo-wide `reset --hard HEAD` riding on that attribution destroyed your edit (measured on all three strategies). The residue has two axes and they get different answers. An incoming path the target did not already track lands **untracked**, and nothing reaches it — neither `git merge --abort` (which exits 128 here anyway, there being no merge to abort) nor `git reset --hard` touches untracked files — so it is named for you to clear rather than cleaned, since the delta proves git wrote a path, not that the bytes now there are yours or git's. An incoming path the target **did** track is rewritten in place, which a path-scoped `git checkout HEAD -- ` over exactly the attributed paths does undo, so that half is undone for you on every leg — never by a repo-wide reset, whose blast radius would take your own uncommitted work on paths the attribution deliberately left alone — and the escalation asks only for whichever residue actually survived, rather than reciting both; a failed restore names the exact paths and prescribes the same path-scoped command. Two ceilings worth knowing, both per path rather than per tree: a path that was _already_ dirty before the merge stays unattributable — its delta cannot say which bytes are whose — and an edit landing **during** the merge window on a path **inside** the incoming set is indistinguishable from git's own write, so it is restored with it; "cannot tell" and "git did nothing" fail to the same side on purpose, the alternative being a restore over your uncommitted work. The differencing is load-bearing and not tidiness: an absolute reading would reclassify every genuine pre-flight refusal that happens to have an untracked stray in the checkout, which is exactly the stray the guard above deliberately tolerates. Relatedly, a `squash` merge git refuses no longer discards your uncommitted work: `--squash` has no `--abort`, so the recovery is that same path-scoped restore over whatever the deltas attribute to git — your pre-existing dirt is never touched, and a conflict landing on top of it has the conflicted incoming paths restored while your edit stays put. The fifth shape is one of the classifier's own post-merge readings failing after a failed merge — the residue deltas or the incoming set that attributes them, the unmerged-stages reading, or the `MERGE_HEAD` reading: what rested on that reading is then **unverified**, and the escalation says exactly that — every verdict still standing on its own live measurement keeps its class, cleanup not gated on the dead reading still runs, and an unread `MERGE_HEAD` skips the abort it gates and says so rather than repairing on uncertainty — and you are sent to the one reading the run could not take, your own `git status`, instead of a probe failure impersonating a verdict. A reading can die on the far side of success too: a `merge --squash` that succeeded still needs one index reading to tell a no-op replay from a result to commit, and with that reading dead the same unverified escalation fires — nothing committed, nothing reset, any staged result left in place — rather than the doomed `git commit` that used to dress the failure as a commit refusal and roll the tree back on the fiction. The conflict itself is typed from its own measurement too (the unmerged stages), so the final catch-all no longer equates "anything unrecognized" with "content conflict": a failure none of the probes classified escalates saying just that — the run cannot say what state the checkout is in; `git status` and git's own appended text are the sources — rather than prescribing conflict resolution for a state nothing measured. -- Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (`keep_failed`, default on) and its full diff is preserved to `run_dir/failed//changes.patch`, which for a DEFERRED unit, and for a merged unit escalated on a failed merge-back (DW-501), also carries a commented-out section (skipped by `git apply`) diffing any story edit to a skip-worktree-pinned hook config against the provisioned rewrite, since `git diff` reads a pinned file as clean (DW-479); `failed_diff_max_mb` caps per-file untracked-file size (oversized skipped with a marker), `failed_diff_unlimited` lifts the cap. -- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches, together with every non-hookless adapter's resolved hook `config_path` — which is gitignored for codex, so without it the project's own hook configuration never reached the worktree and the session ran against a file holding bmad-loop's relay registrations alone (#471). Seeding does not decide whether that relay hook registers — the hook-merge writes it either way, with one refusal: a seeded config that is present but **will not parse** stops provisioning outright (`GitError` → CRITICAL escalation, run paused) instead of being read as an empty document. JSON has no partial read, so an unparseable config is evidence of an earlier fault rather than a blank slate — and merging the relay into `{}` would publish a hooks-only file over the operator's allowlist, `env` and MCP entries, erasing that evidence along with them. The bytes are left exactly as found, and the refusal names whichever source actually supplies them rather than the copy it read: an escalated story re-enters the run only through a re-arm, which discards the worktree and provisions a fresh one, so a repair applied to the disposable copy is undone before the next drive. Which source that is comes from the seed bookkeeping rather than from a counterpart's existence — seeding is copy-when-absent, so a config the project TRACKS is skipped as an occupied destination and arrives with the branch checkout, and that lane is told to commit the repair on the target branch while a genuinely seeded one is sent to the main checkout. That bookkeeping is recorded per path actually written rather than per seed entry: a directory entry seeds child by child, so its presence proves that some child landed and never that this config did, and inferring from it would advise committing a gitignored settings file the checkout supplied. The remedy names the re-arm (`bmad-loop resolve --no-interactive`) because `ESCALATED` is terminal: repairing the file alone leaves the story out of the run (#592). Invalid UTF-8 takes the same lane, which before this crashed the engine instead of escalating. `worktree_seed` adds extra paths. Every configured seed path is refused at **config load** if any component names a Windows device or ends in a period or space (#480) — `worktree_seed`, an adapter's `seed_files`/`seed_globs`, its hook `config_path` and `skill_tree`, a plugin's `[python] module`, and the Unity seeder's guard dir all take the same refusal, on every platform. Such a component names a _different_ path on Windows than the one it spells: `NUL` and `aux.json` are devices rather than files, and `.claude/skills.` is created as `.claude/skills` because Win32 strips a trailing run of periods and spaces before the path reaches the filesystem — so the copy lands somewhere the operator did not configure, and the exclude pattern the shield later renders from the authored spelling names a path that does not exist. Refusing at load is what keeps the render site below from ever seeing one. It is a compatibility break on config that previously loaded, and a hard refusal rather than a warning for the reason the value is dangerous at all — it means one thing here and another there, and a run that quietly seeds a different file than the one configured is the failure the guard exists to prevent. Copy-when-absent at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — seeded before the hook-merge (a seeded `settings.json` keeps its content, but its relay entry is replaced rather than kept — an older seeded copy can name a workspace relay missing in the worktree; left in place it would read as already registered and the session would stall silently, #352; a _tracked_ config that needs the same rewrite is pinned `skip-worktree` in the worktree's own index, so the local command never rides `git add -A` into a story commit — the pin hides a story's own edit to that file from the unit commit too, so success teardown — and the run-end worktree GC — compares the pinned config with the recorded rewrite, relay hooks and formatting ignored, and on any other difference (or a config it cannot read or parse) journals `pinned-config-edit-refused` and pauses with the worktree kept instead of discarding the edit (DW-368): carry the change into the target branch by hand — the pin hides it from `git diff`, so compare with `git -C show HEAD:` and leave the relay hook entries out — then `git worktree remove --force `, delete the unit branch if you do not keep it, and `bmad-loop resume `), and shielded from the unit's `git add -A` — in a private exclude scoped to that worktree alone (see below), never repo-wide. -- Seeding is verified **result-side**, by re-probing the worktree on disk rather than trusting the copier's bookkeeping — so a user-authored `worktree_seed` rel can neither forge nor mask a report: `worktree-seed-skipped` (a seed entry that copied nothing), `worktree-seed-dropped` (a repo-carried seed that never arrived, or an untracked ledger/board whose leaf symlink targets a file outside the project — named, still not seeded, DW-432), and `worktree-module-skills-dropped` (#464 — a wheel-bundled `bmad-loop-*` skill whose content came up short, e.g. a checkout file squatting the skill's directory, which the per-file no-clobber refuses to replace) are all **informational**, journaled and never a pause. Presence is the whole contract; content is never compared, since no-clobber is per _file_ and a checkout's own divergent fork of a bundled skill is a healthy shape. Only the upstream dev/review skill and renderer completeness probes escalate, because those have a deterministic in-worktree consumer — the session dispatches them inside the worktree and stalls having written nothing — while the bundled operator/triage skills are dispatched at the **main checkout** (`/bmad-loop-sweep` from sweep triage, `bmad-loop resolve`; `bmad-loop-setup` has no session consumer at all), so their absence in a worktree stalls nothing. -- Workspace-trust seeding (DW-390): a profile declaring `[workspace_trust]` (the shipped `antigravity` one: `~/.gemini/antigravity-cli/settings.json` `trustedWorkspaces`, which agy matches by exact path and otherwise blocks on with an interactive trust dialog) gets each freshly provisioned unit's mount project (the worktree itself, or `/` under a nested `repo_root`: the session cwd, DW-484) appended to that list before any session launches — **only when the main project is already listed** (as-passed or resolved spelling), so a cloned project profile can only extend a grant the operator made, never create one. Only the declared top-level key is touched; other keys and their order survive, and the write is atomic and keeps the file's mode. `worktree-trust-seeded` records a write; `worktree-trust-unseeded` (with `reason`) records the degrade when the root is not trusted or the file/key is missing — informational, the run continues (the session will meet the dialog). A malformed file (not JSON, not an object, the key not a list of strings) or a write fault escalates the unit. Already-listed worktrees write nothing; entries are never removed on teardown. `bmad-loop probe-adapter --workspace ` reports the verdict read-only (`trusted` exit 0, `untrusted`/`unverifiable` exit 1; `--json` key `workspace_trust`). -- A fresh mount that supersedes an **accepted-but-uncommitted spec** says so (`accepted-spec-write-unreachable`, DW-101). The `pause_after_spec` gate hands you a spec that is uncommitted by construction, and a re-drive's worktree is a checkout of a commit — so for a **tracked** artifacts dir `git worktree add` delivers the pre-approval bytes, seeding skips an occupied destination, and the delivery probe above passes on existence alone while the session reads text your corrections were meant to replace. The mount's copy is byte-compared against the main checkout's with line endings normalized (CRLF ≡ LF — the one translation a checkout under `core.autocrlf` applies on its own, and one git folds away again on commit; any other byte still counts), and a difference — or a read that fails either side, recorded as `compared: false`, since a probe that cannot read cannot prove delivery — is journaled naming the **main-checkout** spec path (the file to commit) and the **target branch** to commit it on. **Informational**, journaled and never a pause, exactly like the seeding records above: it does not overwrite the mount's copy — a dirty tracked file there is not covered by the worktree-scoped exclude, so the unit's `git add -A` would fold your in-progress edits into the story commit — and it does not refuse the mount, which would hard-fail every isolated unit in a project that tracks its artifacts dir. The escalation path's counterpart is `rearm-spec-write-unreachable`, whose remedy is the same sentence. -- A mount that cannot be shown to carry the accepted spec **at all** says so too (`accepted-spec-delivery-unreachable`, DW-104/DW-115). Two silences end here, both on a spec the task already spells **project-relative** — the spelling a resume persists, and the one leg with no escalating guard over it. Either the spec's mounted parent escapes the worktree (a real directory in your checkout, a committed **outward symlink** in the commit the worktree was cut from), so seeding refuses it on containment; or the spec could not be located at all — a filesystem fault while resolving it was swallowed, or the path no longer resolves because the file is gone. Both used to produce the same nothing: the unit dispatched against a mount lacking your spec, fell back to the bare story key, and no journal named why. Now the refused rel is also nominated as a seed — nothing is copied outside the mount, because the copier re-checks containment itself, but `worktree-seed-dropped` names it — and the record is journaled naming the **main-checkout** spec path and the **target branch**. `located` says which silence it was, and the two have different remedies: `true` means the spec was found in your checkout but the mount could not be shown to carry it, so commit it on the named branch (or unpick the outward symlink that makes the artifacts dir unseedable) and re-drive. A file probe fault also produces `true`, even if the bytes are present: check filesystem access and I/O health when the file is already delivered. `false` means it could not be resolved in your checkout at all, so the named path is the one to restore or re-point before re-driving. **Informational**, journaled and never a pause, exactly like the seeding records above. The **relocated** leg — an absolute spec normalized for the mount — gets no record here, because it already escalates on the same loss a few lines earlier; it may still carry a `worktree-seed-dropped` entry for the refused rel, which is the seeding report rather than a second advisory. -- The git-add shield is scoped to the worktree and expires with it (#384). Provisioned tool files (skill trees, hook config, seeded configs) go into a private `.git/worktrees//info/exclude`, activated by a worktree-scoped `core.excludesFile` that shadows rather than concatenates with your own — so yours is copied into the private one **byte for byte**, the shield covers that unit only, `git worktree remove` takes it away, and the shared, permanent `.git/info/exclude` is never written. An explicitly empty `core.excludesFile` is honored literally — no excludes file at all — rather than read as unset, so git's XDG fallback is not consulted and there is nothing to copy. Where that fallback IS consulted, the file it names is asked of git rather than computed in Python — and on **Git for Windows >= 2.46** the file is `%APPDATA%/Git/ignore` whenever that file exists, which the fork prefers over the `$HOME/.config/git/ignore` upstream resolves (#403). Seeding the `$HOME` one there was wrong in both directions: with only the APPDATA file present the seed came back empty and the activation then shadowed the ignore file git really reads, and with both present the worktree over-ignored with patterns git was not applying, so session-created files went missing instead. The gate is the reported version's own `.windows.` fork string plus that floor rather than the platform — Cygwin, MSYS2 and WSL gits run on Windows hardware without the patch — and anything else, an unanswerable `git version` included, keeps the `$HOME` answer. Read from the fork's source through #403, **not measured on a Windows machine**. It then **proves it applies or stands down**: bmad-loop asks git which excludes file it actually resolves **and from which scope** (`git config --show-scope`), so the degrade reason names the winner rather than inferring one — ambient command-scope config (`git -c`, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_COUNT`) is told apart from a worktree-scoped write that is not in force at all, which sends you to a different repair (#692). Anything but a confirmed byte-identical match — either of those, an unreadable excludes file, an answer naming no scope, an unanswerable probe — skips the shield with a journaled and notified reason; a match activates it whatever scope supplied the value, since the thing being proved is that git reads the file bmad-loop wrote. Two runs against one repository serialize on an exclusive lock, leaving a zero-length `.git/bmad-loop-shield.lock` — never in the working tree, so nothing your `git add -A` can see; on Windows the wait gives up after ~10s and the shield is skipped naming the lock. Caveats: its gate is the project's **git 2.34** support floor (an older git — or one that cannot say what it is — skips the shield, and the repo-format flag is deliberately not written); that is a _support_ refusal rather than a capability one, since the two things the shield is built out of, `extensions.worktreeConfig` and `git config --worktree`, have existed since git 2.20 — runs are refused outright below the floor, so what this gate still catches on a current host is a git that cannot be spawned, times out, or answers unparseably. Enabling it sets `extensions.worktreeConfig` — a **permanent** repo-format flag, rolled back wherever it could be left set without a working shield, but surviving in two cases the reason distinguishes: a sibling worktree still depends on it, or the rollback could not be made at all. Where it cannot be set safely at all (`core.bare = true` or `core.worktree` in the shared config) the shield is skipped instead. It is skipped the same way where the shared config already carries an explicit `extensions.worktreeConfig` that is not `true` (#396): that line is an operator's own declaration, and enabling over it would rewrite it to `true` permanently with nothing recording what stood there — so the shield stands down and the reason quotes the spelling it found, raw rather than `--type=bool`-normalized, since `false`, `off`, `no` and `0` all read back as `false`. A **valueless** `worktreeConfig` line is not that case: git reads it as `true`, so the flag counts as already carried and the shield proceeds. Lines an older bmad-loop wrote into `.git/info/exclude` are **not** removed for you — delete them by hand. A **file** your project tracks gets no pattern: git applies ignore rules only to untracked paths, so the pattern would shield nothing while making the file read as tracked-and-ignored to `git ls-files -ci --exclude-standard` and to repo-hygiene gates built on it (#392). A tracked **directory** gets no directory pattern either — it is replaced by one pattern per file this provisioning run actually wrote below it (#484). A dir pattern does hide new children, and no pattern shape both hides them and keeps the report clean (`dir/*` with per-child negations clears the report and leaks a new file into the commit), but over a tracked tree it bought new-child coverage alone at the price of a false tracked-and-ignored report for every tracked child — modifications to those stage regardless. The accepted trade: a file the **session** creates under such a directory can be staged, which matches your own decision to track that tree, while everything the orchestrator seeded there keeps a pattern of its own. An **untracked** tool directory is unchanged — it keeps the single `/dir` pattern. If git cannot say what a path is — or a file provisioning wrote below a tracked directory has a name no exclude line can spell (an embedded newline, a trailing carriage return), which would shield nothing and loose an orphan pattern on unrelated files — the pattern is kept in its ORIGINAL shape, dir shape included, and the reason is journaled. Patterns are escaped as they are rendered (#476): a rel holding a trailing space or a wildmatch special (`*`, `?`, `[`, `\`) otherwise names something else, leaving the seeded path unshielded while the broken character class silently hides an unrelated file the unit meant to commit. The spellings that made a **trailing period or space** diverge no longer reach this render site at all: a configured component Win32 would trim, or that names a device, is refused at config load (#480), so the shield never has to reconcile an authored spelling against a directory Win32 created under a different name. That is the only direction in which the trailing-space case closes — escaping it to `/skills\ ` fixes the gitignore parse and still names a path `mkdir` never created, because the trim happens below git rather than inside it, so the dot and the space are one defect with one shape rather than two. -- Run state never moves into a worktree — `.bmad-loop/` always lives in the main repo; spec paths are persisted relative to the worktree so a kept-failed run stays portable. -- The sprint board is **worktree-canonical for the duration of a story** (#350). A board your project _tracks_ needs nothing special: the story's advance is an ordinary modification of a checked-out file and rides the unit commit through the merge. A **gitignored** board is neither checked out nor delivered by one, so it is seeded into the worktree alongside the deferred-work ledger — the orchestrator writes the board through the worktree and `verify_dev` then reads the file it just wrote, which without the seed is a missing file the run dies on rather than a lost write. Its advance is re-applied to the main checkout's board **after** the merge, journaled `board-advance-carried` — or `board-advance-carry-uncommitted` where `git add` refused the ignored path, which is the ordinary outcome for such a board and not a fault (the status on disk is the value; the commit is best-effort) — or `board-advance-carry-refused` where the board is no longer a publishable file at all by the time the commit runs, naming which of `target-absent` / `target-unreadable` / `target-not-a-file` fired (a board is validated as a `"store"`, which asks nothing about bytes, so the fourth cause below cannot arise for it). That last outcome is the board's share of a rule that now holds everywhere: **every exact-commit publisher proves its target is publishable before it hands that operand to git** (DW-237). `git add` reads its operand as a literal pathspec and stages a directory's descendants recursively, so a directory left at a board, ledger, spec or park-record name would otherwise publish an unrelated tree under a `chore(...)` message. The other publishers journal the same refusal under their own names — `harvest-carry-refused` (the harvested-deferral carry), `story-deferred-close-carry-refused` (#458's declared closes), `sweep-bundle-close-carry-refused` (a sweep bundle's closes) — and `bmad-loop confirm`, which has no journal, drops the offending operand instead, per operand, with one `warning:` line on stderr for each (its gitignored-board probe still runs ahead of the guard; that git call asks about the board, it does not publish anything). A refusal never raises. The causes split DURABLE against TRANSIENT, and the guard draws the line, not the caller: `target-absent`, `target-not-a-file` and `target-undecodable` (a ledger holding bytes nobody can decode — the ledger family's own cause, split out of `target-unreadable` so the two are never confused) are shapes a replay re-reads and refuses identically, so there is nothing a retry could repair, and every publisher refuses them. `target-unreadable` is an OS fault a probe RAISED — an unsearchable parent, a UNC provider that is registered but not serving — which the next pass may not see, so the one publisher holding a durable commit latch — the harvested-deferral carry — does not refuse that cause but keeps it on its ordinary git-failure path, where the `GitError` is raised (latch kept, the next pass retries) unless the ledger is one git cannot own, in which case it degrades to `harvest-carry-uncommitted` exactly as any other `GitError` there does; the other publishers, holding no latch, refuse it too. Before the split, an undecodable ledger arrived at that carry as `target-unreadable`, fell through, and was committed — git accepts any bytes. The carry replays from its record if a crash lands between the merge and its latch, and `sprintstatus.advance` never regresses, so a double application is a no-op. On that replay leg the merge — and with it the pre-flight above — has already happened, so the carry proves its own ownership before committing: it recomputes HEAD's content through `advance` and asks **git** whether the board holds that and nothing else, which a crashed pass's half-written advance does and an operator's edit does not. Sameness is git's own — both sides are hashed through the path's clean filter — so a board spelled CRLF by one host and LF by another still matches, where a byte compare would have to guess which spelling is on disk and would refuse a pristine board on every host that chose the other. **Both** places git holds the path are proved, because the carry's `git add` overwrites both: the working tree it copies into the commit, and the index it stages over — an edit staged and then restored in the working tree exists nowhere afterwards, so proving the working tree alone would authorize destroying it. That proof guards the **commit**, which is one write too late for the story's own row: `advance` would already have replaced an operator's status with the target, leaving exactly the bytes the proof accepts — and skipping the commit saves nothing when the value scheduling reads is the one on disk. So that one row is checked **before** the advance as well, and a status that is neither HEAD's nor this pass's own is refused there with nothing written. Both refusals journal `board-advance-carry-foreign-dirt`, and they refuse at different points with different stakes: the pre-advance row check refuses with **nothing written**, so your status is still on the board; the post-advance proof withholds only the **commit** — the advance is on disk by then — and the dirt it declined to take is still escalated by the next run's merge pre-flight. A board that is **gone** by carry time is refused before either question is asked and journaled `board-advance-carry-failed`, the same record a vanished row gets — and a row the writer refuses to rewrite (a quoted key, an alias, a block scalar), which also names the writer's reason token as `refuse_cause` (#842). Every probe fails closed, and git's own dirt answer decides whether either comparison is asked at all — a board nobody wrote is not their question (#618). Both comparisons apply to a board git **tracks**. A gitignored one has no baseline anywhere in git to be compared against, so a replayed carry can still overwrite a row you edited on it while the host was down; the commit half cannot arise there at all, since `git add` refuses an ignored path outright. Scheduling is what depends on it: `_pick_next` reads the **main** board, so an advance that never came back hands finished work to the next run's dev session. -- Only the orchestrator writes the board — the session prompts forbid it, and `sprintstatus.advance` is the sole write path. The consequence under isolation, stated rather than left as a surprise: in a _gitignored_ board a session's edit to any OTHER story's row lives only in the worktree copy and evaporates when the worktree is removed. Only the story's own advance is carried back, because only that one is the orchestrator's own write. -- A **gitignored deferred-work ledger** follows the board's shape (#426): it is seeded into the unit worktree so the orchestrator's own writes (harvested deferrals, declared story closes, sweep bundle closes) land on a file the gates can read back, the copy is shielded from the unit's `git add -A`, and those recorded writes are re-applied to the main ledger after the merge. A ledger or board that is itself a **symlink** is seeded at its configured path — the one the worktree's readers use — not at the link target's path, which left the configured path empty and the unit back in the #426 defer loop (DW-377, was #462); the target is seeded instead only when the checkout carries the link as a tracked, dangling entry, and a target outside the repo is still not seeded. The consequence stated as for the board: anything else a session writes to the seeded ledger copy — a canonical entry of its own, an edit to a seeded entry, a flat `- source_spec:` block — is **not** carried back and is lost at teardown. It is not silent (DW-375): `validate` warns `deferred.ledger-ignored-isolated` for a gitignored ledger under `isolation = "worktree"` (even before the file exists, since the first harvest creates it), and success-path teardown diffs the worktree ledger against the text provisioning seeded, journaling `isolated-ledger-writes-uncarried` with the unexplained `dw_ids` and a `count` of changed non-entry lines (or an `error` when the copy cannot be read). The engine's recorded writes and its `seen-again:` stamps are excused; a review-timeout salvage refile is engine-authored but never carried, so it is reported. Warn-only: nothing is carried back, nothing pauses, and a DEFERRED unit — whose work is discarded by design — is never checked. Track the ledger in git to keep session-written entries. -- `bmad-loop confirm` leaves a gitignored board **out** of its commit (#577). `git add` refuses an explicitly named ignored path and refuses the whole operand list with it, which would take the spec's flip to `done` and the park record's deletion down too — leaving the tree dirty for the next run's preflight to refuse. The board is still advanced on disk; a board git will not track has no commit to ride. Each of the three operands is proved publishable on its own before the commit (DW-237): one that is **present but not a regular file** (a directory left at the spec, board or park-record name) or that a metadata probe cannot read is dropped from the list alone, with a one-line `warning:` on stderr naming the path and the cause, and `confirm` still exits 0 with the surviving operands committed — the on-disk flip is the value. An **absent** operand is never dropped: the park record is unlinked before the commit by design, and staging that deletion is exactly what #356 needs it in the list for. -- The harvested-deferral carry — an isolated unit's harvested findings re-filed into the MAIN ledger and committed `chore(deferred-work): carry harvested findings from ` — holds a durable commit latch, set before the append, so a commit that fails (a rejecting pre-commit hook, a crash) is retried on resume even though the replay's provenance scan then finds the rows already on disk and appends nothing. Every replay — a pass that began with the latch already set, whether it now finds the rows on disk and appends nothing or, after a crash between the latch and the append, appends them again (DW-413) — proves ownership first, the way the board carry's replay does (DW-355): it recomputes the intended ledger from HEAD's blob plus this task's own rows, through the carry's own spec builder and the writer's own fold, and asks **git** whether both the working tree and the index hold that and nothing else. The same proof runs when a first pass's novel rows were all dropped by the writer's cross-spec dedupe, leaving the latch set with nothing carried. Any other change to a tracked ledger — an operator edit made while the run was down, unstaged or staged, a pre-commit hook that rewrote the ledger when it rejected the commit, or an earlier write that never got committed — fails the proof, and the run pauses at `escalation` (journaled `harvest-carry-foreign-dirt`, with an `ACTION REQUIRED` notice) with nothing committed, the bytes on disk untouched, the phase unchanged and the latch still set: any rows this carry filed are on disk but in no commit. Commit the whole ledger (the carried rows included), or move the other changes out of it, and `bmad-loop resume ` re-runs the carry, which commits the rows or, when HEAD already holds them, commits nothing and clears the latch. The proof fails closed: a probe fault (a git error, an undecodable HEAD blob) pauses under the same row with the fault named in its `error` field and a notice asking for the fault to be fixed. It is skipped where the old path already applies: a ledger outside the repo, and a ledger HEAD does not track (the #460 boundary the board draws). Only a fresh first pass that appends rows is not proved: it still commits the whole working-tree ledger, an uncommitted edit already in it included, as before. -- Merge-back is serialized; `max_parallel` is a validated knob clamped to `1` until parallel fan-out is built. The `repo_root` key in `_bmad/bmm/config.yaml` (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos), and your `[verify] commands` run there too, as does every git question the dev gate asks about the code — baseline identity, ancestry, and the non-empty-diff proof, with their exclude pathspecs spelled relative to the same root (#695, #716) — while the orchestrator's own artifact reads stay project-rooted. It works with `isolation = "worktree"` when the project lies **inside** `repo_root`: the unit worktree mirrors the main checkout, and provisioning lands every project-local surface (`_bmad/`, skill trees, hook configs, seeds, the ledger/board/accepted-spec seeds) at the project's offset inside the mount, and the session runs there, in the mount project, as it runs in the project in place: every coding CLI finds its hook config and skills from its cwd or upward from it, never below it, so a session started at the checkout root of a nested layout loaded no Stop hook and no dev/review skill (DW-379, DW-484). An artifact dir (`implementation_artifacts`, `planning_artifacts`, `output_folder`) configured inside `repo_root` but outside the project is not moved into the mount, so it is shared with the main checkout rather than per-worktree; `validate` warns `policy.isolation-shared-artifact-dir` naming each such dir (advisory, exit code unchanged; DW-485). A project **not** inside `repo_root` (a sibling, or `repo_root` nested inside the project) has no copy in the checkout, so worktree isolation is refused there: `validate` reports `policy.isolation-repo-root` and run/sweep/resume/resolve refuse to start (#414). `validate`'s `git.worktree-clean` and the TUI launch guard probe `repo_root`, as `run` and `sweep` do. -- `commit_message_template` (`{story_key}` / `{run_id}` / `{story_title}` substituted) customizes story/bundle commit messages. - -### Plugins (extensibility) - -- A first-class **plugin system** extends the orchestrator without touching the core loop. A plugin is a folder-drop `plugin.toml` manifest (under `.bmad-loop/plugins//`, overlaying bundled `bmad_loop/data/plugins//`) that can: **observe / veto / mutate** the run at every lifecycle stage via a hook bus; contribute **settings** that render in the settings TUI and persist to `[plugins.]`; and inject its own **workflow** sessions at `post_dev_phase` / `post_review_result`. -- **Origin-tier trust with content grants** ([#496](https://github.com/bmad-code-org/bmad-loop/issues/496), [#497](https://github.com/bmad-code-org/bmad-loop/issues/497)) — dropping a folder in never runs code: - - **Builtin** (and entry-point) plugins are trusted by provenance. A builtin's in-process `[python]` module runs only when its name is in `[plugins] enabled`. Un-enabled, such a plugin is wholly inert: its hooks, workflows and seeds stay off too. - - A **project** plugin (`.bmad-loop/plugins//`) with an executable surface — `[hooks.*]`, `[workflows.*]`, `[python]`, `seed_files` or `seed_globs` — runs only under an operator **content grant**: a sha256 over every file in the plugin dir plus its repo-relative path, recorded by `bmad-loop plugins enable ` (or a TUI toggle-on) in `//plugin-grants.json`, outside the agent-writable tree. For project origin the grant replaces `[plugins] enabled`; nothing writes policy.toml. A settings-only project plugin needs no grant. A project override of a builtin name needs its own grant, and a refused override never falls back to the builtin. - - `run`, `sweep` and `resume` read the grants **once**, judge every plugin before their first side effect (resume: before the journal opens), and thread the same frozen grants to the engine and into the auto-sweep child, which never re-reads the store. A never-granted plugin that is not in `enabled` is **inert**: a `note: plugin "" () is not granted and will not run` line on stderr, one `plugin-refused` journal row (`plugin`, `reason`, `origin`, `path`, `fatal: false`), and the run goes on. A granted plugin whose files or path changed, a project name in `enabled` with no grant, or a granted dir that cannot be read **fails startup** (rc 1) with an `error:` naming `bmad-loop plugins accept ` or `enable `; so does a granted plugin stripped of its whole executable surface, or a grant no discovered plugin claims (its dir deleted or moved, or a builtin answering to a removed override's name), naming `bmad-loop plugins revoke `; the engine journals each as `plugin-refused` with `fatal: true` before raising, and the auto-sweep child records `sweep-auto-not-started` plus a notification. A stock run journals no `plugin-refused` rows. Nothing on a run, sweep or child path prompts. An unreadable grant store is warned about and treated as no grants (fail closed). - - A granted plugin runs from a **verified snapshot**: its files are copied to `//_plugin-snapshots//`, re-verified, and used as its `{scripts}` root; a `[python]` module is compiled from the exact hashed bytes (no `.pyc` read or written), and hooks run with `PYTHONDONTWRITEBYTECODE=1`. A mid-run edit of the live plugin dir changes nothing until the next launch, which refuses it. Links inside a plugin dir are refused; caps are 2048 files, 16 MiB per file, 64 MiB total. - - Residuals (documented in the [plugin guide](plugin-authoring-guide.md#trust--safety)): same-user tampering with the store or snapshots; a moved project loses its grants ([#572](https://github.com/bmad-code-org/bmad-loop/issues/572), fails closed); scripts a hook names outside `{scripts}` are not pinned; `[plugins.]` settings still steer a granted plugin; builtin provenance inside an editable install; interrupted-snapshot `.tmp-`/`.stale-` leftovers; a merged story that edits a tracked plugin dir makes the next launch fail with a mismatch (expected — review and `accept`). -- Every hook (subprocess or Python) is failure-isolated: a raise is caught, journalled, and disables that instance for the run — never crashes it. -- Veto maps onto the engine's **existing** control flow (`skip`/`defer`/`pause`), and mutation is confined to a per-stage whitelist (`proposed_prompt`, `proposed_commit_message`, …) plus a persisted `shared` dict — no new abort path. Distribution is folder-drop now, with a documented `bmad_loop.plugins` entry-point seam for pip-installed plugins later. -- See **[Writing a bmad-loop plugin](plugin-authoring-guide.md)** for the manifest, settings, hook, stage, trust, and workflow reference, plus a worked walkthrough; a complete example ships under [`examples/plugins/guardrails/`](../examples/plugins/guardrails/). - -### Game-engine projects (opt-in) - -- A niche engine layer — built **on the plugin system** — for projects whose dev/sweep cycle drives a **live engine Editor** via an Editor MCP (Unity bundled as `bmad_loop/data/plugins/unity/`; Godot/Unreal later). Off by default; enable with `[plugins] enabled = ["unity"]` + a `[plugins.unity]` table. (The legacy `[engine]` policy block still loads, folded onto `[plugins.unity]` with a deprecation warning; project-local overrides now live under `.bmad-loop/plugins//`.) -- `editor_mode` is coupled to `[scm] isolation` because a live Editor MCP can only act on the folder its Editor has open: **`shared`** (requires `isolation = "none"`) runs the agent in place on the project the operator's warm Editor already has open — zero relaunches, full live MCP; **`per_worktree`** (requires `isolation = "worktree"`) gives each worktree its own managed Editor. -- Readiness gate: before each unit, the plugin's `pre_ready_gate` hook blocks until the Editor + MCP report ready (Unity: `wait-for-ready` for IvanMurzak, connectivity check for CoplayDev); on timeout the unit is deferred with an `ATTENTION` notice rather than starting a session against a half-open Editor. -- `per_worktree` lifecycle (Unity/IvanMurzak): a setup hook launches the worktree's own Editor (MCP port auto-derived from the worktree path, so it self-isolates from the operator's main Editor), writes the worktree `.mcp.json`, and primes the worktree's `Library` with a reflink/CoW copy of the warm main `Library` (so Unity reimports incrementally instead of a cold full reimport that crashes the import workers; deep-copy then symlinked-empty-cache fallbacks off-CoW); the readiness gate then waits for it; a teardown hook quits the Editor on completion **and** on pause/escalation. The MCP-generated skill tree (gitignored) is copied into each worktree via the plugin's `seed_globs`; a setup failure defers the unit instead of running it against no Editor. -- The Unity plugin's settings are editable in the TUI under its plugin section. To target another engine or a different Editor MCP, see [Writing a Game Engine plugin](game-engine-plugin-guide.md) (now built on the general [plugin system](plugin-authoring-guide.md)) and [Writing a plugin for a specific Editor MCP](game-engine-mcp-guide.md). - -### Resumability & state - -- Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. -- A resume that restarts an interrupted in-place story rolls it back to that story's baseline, and a rollback parks every commit above the baseline — the interrupted attempt's, or commits you made while the run was down — on an `attempt-preserve/*` branch before resetting. When that restart-time reset parks work it also appends one `ATTENTION` line naming every ref it parked: the commits branch with the baseline, the commit count and a `git log` to inspect them (DW-371), the `refs/attempt-preserve-dirty/*` snapshot of uncommitted changes with a `git diff` and a `git restore --source=` to recover them (DW-480), or both refs on the same line when it parked both (DW-482). In-run retry/defer rollbacks only journal `attempt-commits-preserved` / `attempt-worktree-preserved`. To keep such commits instead, run `bmad-loop resume --accept-baseline`: every in-place story that resume restarts first adopts the current HEAD (and untracked-file snapshot) as its baseline — journaled `baseline-accepted` — so the rollback no longer parks or resets past those commits and the story re-runs on top of them. The flag adopts **everything** at HEAD, including any commits the interrupted attempt itself made, and treats every current untracked file as pre-existing (never cleaned) — check `git log ..HEAD` first. It only moves the baseline: uncommitted tracked changes still follow the normal rollback (parked on a snapshot ref and reset with `rollback_on_failure` on; a manual-rollback pause with it off). The flag covers that one resume only (every later resume, including a TUI or `resolve` one, clears it), though a baseline it already adopted — journaled `baseline-accepted` — stays adopted if that resume then pauses; a git fault while reading HEAD pauses the run (`baseline-accept-failed`) rather than falling through to the reset. `resolve` has no such flag because its re-arm already advances the baseline to HEAD; commits made after a `resolve --no-resume` re-arm still need the flag on the later `resume`. The parked-commits notice's restore hint is for after the run has stopped or finished, since the resumed run re-drives the story in that same checkout. The manual-recovery notice for committed work (rollback OFF) names the flag, with the same caveats, as the alternative to a reset. A `resolve --reverify` resume never rolls back: it replays verification on the tree as it stands; only a replay that fails and re-defers the story then takes the ordinary defer recovery. -- `resume` does not require a clean git tree, while `run` and `sweep` refuse a dirty one (DW-360). The launch gate already held when the run started, and a dirty tree at resume is often the sanctioned recovery: when a CRITICAL resolution's `redrive_base_ref` is `HEAD`, the corrected spec is re-applied in the main checkout uncommitted, because the re-drive reads that working tree as-is. Anything else changed while the run was paused is yours to commit or stash before resuming — under `isolation = "none"` the resumed loop works in that same checkout. -- A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- Every `state.json` publication is serialized by one advisory lock per run, keyed on the resolved run directory plus the logical `state.json` name and stored under the user state root rather than in git. Ignoring a final-component `state.json` symlink keeps that identity stable when atomic publication replaces the directory entry; alternate spellings of the run directory still converge. Multi-step control mutations (`resolve`, `resume`, code-root restamping, and stop's external fallback) hold that same lock from their authoritative read through atomic publication, so a waiter reloads the state its predecessor left instead of overwriting it from a stale snapshot. A fresh run or sweep likewise holds it from its initial state save through trusted-digest and PID publication, preventing an explicit-id resume from observing resumable state before the composer is live. Readers remain lock-free because publication is atomic. Stop does not hold the lock while it requests, signals, polls, or kills: a live engine must be able to publish its own stopped state; only the fallback's final reload/check/write is serialized. That final check preserves an engine that finished during delivery and retries against any newer live engine generation a concurrent resume published. POSIX lock acquisition blocks, while Windows can surface an `OSError` after its bounded wait; either failure aborts the mutation rather than writing unlocked. Two callers acquire without waiting instead: `clean`, sweeping many runs, and the TUI's re-arm, which runs on the dashboard's message loop where a blocking POSIX acquisition would freeze the whole UI for as long as the holder kept the lock. Both report the contended run rather than queueing behind it. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json` (which records `repo_root`, the git root code work happens in, so an out-of-process re-arm reads back the tree the run measured, #716 — resume re-stamps it from the config.yaml it just re-read, since that is the tree it arms the engine against, and warns when the root moved: the baselines, preserve refs and branches already recorded name objects in the previous one. `resolve` and the TUI's re-arm re-stamp it themselves, before they re-arm: both re-arm and then resume in one gesture, so resume's own re-stamp lands after the re-arm has already advanced the baseline in whichever tree the mirror still named); `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276, plus one `verify-command-result` per verifier command — emitted on the dev, repair and review legs alike, carrying `verification_stage` and a per-story `verification_sequence` that orders the passes across all three; the two passes that leave no record are `bmad-loop confirm --reverify`, which runs after the run is over, and any pass with no `[verify] commands` configured, which records nothing because nothing ran — each entry also carrying `spawn_error`, set when the verify command's child could not be started at all — typically because its working directory is missing, is not a directory, or cannot be searched, though any spawn-time `OSError` (a missing shell, EMFILE, ENOMEM) reaches the same field and the wrapped exception is what names the cause — which is an environment fault that pauses the run rather than a command that failed — and `interrupted: true` (with `returncode` −1001, `INTERRUPTED_RC`), present only on a pass a hard stop cut short (DW-353): the command the stop killed says `interrupted by a hard stop request` in its `output_tail`, each later command, which was never started, says `not started: …`, and the run then stops rather than classifying the pass — whose stream pointers name the `verify/` directory below, and one `park-proof-of-work-skipped` per attempt that cleared the dev artifact gate on an `awaiting-operator` park with proof-of-work waived — not per park that committed, since the stages after that gate can still reject the attempt — carrying `zero_diff`: `true` when the waived gate found no non-excluded changes (its exclusions include the spec, board, any restore-patch artifact, and an orchestrator-authored deferred-work ledger append), `false` when it found changes, and `null` when the probe could not answer (a git fault, a git refusal such as an unresolvable baseline, or an attempt with no recorded baseline to measure from) and the gate was waived anyway, so the waiver itself is recorded whatever the probe managed to say. `false` is a statement about the tree, not about who wrote what: the gate this stands in for cannot attribute residue to a session in a shared checkout, and the record inherits that limit rather than improving on it), and one `bundle-artifact-only-accepted` per sweep-bundle attempt that cleared the dev artifact gate on the artifact-only receipt (DW-273) — carrying `story_key`, `attempt`, `dw_ids` and `count`, the number of ignored files under `implementation_artifacts` this attempt created or changed against its start-of-attempt snapshot), and one `bundle-artifact-baseline-unavailable` per attempt whose snapshot could not be taken (a git fault; the receipt then refuses for that attempt); `tasks//` (per-session prompt + shared artifacts: [`result.json`, `escalation.json`] — respectively the per-session result and escalation outputs — plus adapter-specific breadcrumbs: `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194; `env-fault-overruled` with `tokens` when tracked nonzero usage withheld the verdict, #764, and `env-fault-usage-read-failed` with `error` (the exception class) when that usage read raised, so the verdict stood unmeasured; `env-fault-scan-failed` when the log could not be read or a pattern timed out, so the classification never ran, DW-460), a mux session lost under the run (`session-vanished`, #489) or a probe that could not ask (`session-probe-failed`, DW-382), generic-adapter transport faults (`liveness-probe-failed` / `liveness-probe-recovered`, `nudge-send-failed` (opencode-http too, DW-503), `post-kill-rescue-abandoned`, and `liveness_unknown` on `over-budget-fired` / `kill-escalated`, DW-447/449/453/454), generic-adapter observation faults (`parked-probe-failed`, `log-evidence-failed`, `result-json-refused`, DW-448/450/451; `usage-sample-failed` / `usage-sample-recovered`, `transcript-scan-failed` / `transcript-scan-recovered`, `spec-identity-unreadable`, `spec-digest-unreadable`, `spec-readback-failed`, DW-452/455/456/457), opencode-http usage and event-stream faults (`usage-sample-failed` / `usage-sample-recovered` at a failing budget sample's streak transitions, `usage-capture-failed` with `stage` = `fetch` / `payload` / `dump` for the pre-teardown usage read, whose fault leaves usage untracked rather than zero, DW-461; `sse-stream-failed` once per stream break with `frames_dropped`, `sse-stream-recovered` on reconnect, `sse-frame-dropped` at the session's first undecodable frame, DW-462 — the polling fallback is unchanged), parked-session signals (`parked-signal` when a hook event latches one, `stall-nudge-withheld` when it or a pane match parks the stall at grace expiry, its `nudge_due` saying whether a nudge was actually withheld, DW-348/DW-350/DW-433) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life, carrying `transcript_idle_s` — seconds since the live transcript last changed, `null` until a hook event names it (#680) — and, on the generic adapter, `probe_failures` (the running liveness-probe failure streak), `stall_nudges_failed` (stall nudges whose send raised; `stall_nudges_sent` counts delivered ones), DW-447/DW-449, and `usage_sample_failures` (the running budget usage-sample failure streak, DW-452; the opencode-http heartbeat carries it too, DW-461, plus `sse_frames_dropped`, the running count of undecodable event-stream frames, DW-462); `resultless-stops.jsonl` records give-up Stops with a verdict — `no-result-json` / `malformed-result-json` (DW-451), `no-artifact`, `stat-failed` / `unreadable-spec` (a spec read fault, DW-457), `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `verify/` (verifier command stdout/stderr, one file per stream, pointed at by the journal's `verify-command-result` records — its own store, because every name in `logs/` is a session task id the TUI resolves as a pane log; each stream is retained tail-first up to `[verify] stream_capture_kb` (256 KiB, `0` = keep nothing) and the record carries the full byte count, a truncation flag, and a `capture_error` when the write itself failed); `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482). -- The run dir and each unit worktree mount are pinned to their **mint-time identity** (DW-446, DW-486): `state.json` records `run_dir_identity` (when the run is composed) and each task's `worktree_identity` (when its mount opens), as `[st_dev, st_ino]`. The verify stream and every orchestrator write into a mount — spec status flips, `## Auto Run Result` strips, park records, re-arm and TUI replan writes — compare the directory they open against that record, reaching a nested mount project by a no-follow walk from the recorded mount. So with `runs/`, `runs//`, `worktrees/` or the mount swapped for a link to a tree holding a real directory of the same name, the verify stream and the mount writers refuse instead of writing outside the repository. A refusal is surfaced where that site already surfaces one: a verify stream keeps its `verify-command-result` record with a `capture_error` and no pointer, and a mount write refuses as an unconfined write. A missing record refuses too; it is never replaced by a fresh `lstat`. State written before this change records its roots at the first `resume` or re-arm (journaling `root-identity-recorded`; until then a TUI replan of such a run refuses). Because `st_dev` is not stable across reboots (btrfs, NFS, overlay remounts), a `resume` or re-arm re-binds a record whose root still `lstat`s as a real directory with the same inode and a different `st_dev`, journaling `root-identity-rebound`. An inode mismatch, a link or a gone root is never re-bound, and its pinned writes keep refusing. A live engine never re-binds. On Windows a run dir that reports no inode (`st_ino` 0) records no identity, so its verify streams refuse and each loss is journaled as `capture_error`. -- One piece deliberately lives **outside** that directory: the hook-event channel (#494) is at `///events/` under the user-scoped state root (`BMAD_LOOP_STATE_DIR`, see the [transport section](#hook-based-transport-no-pane-scraping) below and the README's env-var table), not `/events/`. The orchestrator still polls the legacy in-tree location, so a project whose installed relay predates the move keeps completing its sessions. `delete`, `archive` and `clean` remove the out-of-tree counterpart along with the run dir, and `clean` sweeps counterparts whose run dir is already gone; an **archived** run's tarball therefore no longer contains `events/` — those files are transient completion signals, consumed while the run was live, and everything an archive is read for later is in the run dir. -- `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. - -### Hook-based transport (no pane-scraping) - -- Coding-agent hooks (`Stop` / `SessionStart` / `SessionEnd` / `PreCompact`, plus `Notification` and the parked kinds `PermissionPrompt` / `IdlePrompt` / `QuotaPrompt`, which never complete a session) write structured event files the orchestrator watches; skills write a machine-readable `result.json`. -- The event channel lives **outside the project tree** (#494), at `///events/` — a branch switch, a worktree mount or a rollback must not be able to take a live run's control plane away. Each session is told where to write via `BMAD_LOOP_EVENTS_DIR`; the state root itself resolves per `BMAD_LOOP_STATE_DIR` (see the env-var table in the README). The relay falls back to the legacy in-tree `/events` when that variable is absent, and the orchestrator keeps polling that location too — older sessions may still carry a copied relay, and without both halves those sessions would stall to `session_timeout_min`. Fresh `init` registrations call the installed `bmad-loop relay ` executable by absolute path and require no workspace relay copy. -- An engine the TUI launches into its parked control-session window is handed the TUI's own state root on its command line (a hidden top-level `--state-root`), and on psmux the registry root in force (`--registry-root`), rather than inheriting them from the multiplexer server (#731). A server started under another state root, or psmux's `PSMUX_BARE_ENV` clearing the pane's environment (#730), therefore cannot move a launched run where the TUI does not look. A TUI with no derivable state root refuses the launch. A shell opened in the control session still inherits the server's root; the TUI notes once when that differs from its own (see [docs/multiplexer-backends.md](multiplexer-backends.md)). -- What the move is and is not worth: it eliminates the whole in-tree redirect class — nothing an agent writes inside the project can any longer point the completion channel somewhere else — and it is a hard boundary for a sandboxed session, which cannot reach outside the tree at all. It is **not** a boundary against a session running with permissions bypassed: that session is told the directory by `BMAD_LOOP_EVENTS_DIR`, so it can reach it by construction. This is why the relay keeps its own `_is_link_like` refusal on the write path (#493) as belt-and-braces rather than retiring it as redundant. - -### Deferred-work sweeps - -- Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. -- Legacy-ledger migration recovery (DW-296/DW-297) persists a current-format marker plus run-owned `migrate-baseline.md` and `migrate-rewrite.md` text snapshots. The baseline and its exact reconstructed manifest are durable before dispatch; migration rechecks the live cycle input before and after publishing those records and once more after executable pre-session hooks at the actual adapter-launch boundary (DW-316). Drift or a read fault first persists `PENDING` with no baseline authority and refunds an attempt that launched no adapter, then retires the stale records, so even a cleanup fault cannot resurrect stale dispatch authority. The rewrite becomes authoritative only after deterministic validation. A result-publication fault therefore leaves `sweep-migrate` nonterminal, and cycle-one resume compare-and-set restores the accepted legacy text before redispatching without overwriting concurrent ledger bytes or resetting past an advanced HEAD. Once `migrate-result.json` is durable the task enters `committing`: resume validates the baseline/manifest/rewrite/result set and retries only the exact-path publication tail. For migration, that tail captures the checkout's immediate and terminal branch identity, refuses rival committed ledger content (including a ledger the baseline commit tracked that a later commit deleted — absence is accepted only when the baseline commit proves it was never tracked), stages an isolated hook-observed candidate, and validates its exact parent, one-path scope, Git-normalized accepted blob, and regular resolved target. The candidate carries the live ledger's own bytes, read once they decode to the accepted text under the ledger readers' universal-newline reading — so the committed blob is what `git add` of the validated file would stage under any line-ending configuration, and a CRLF checkout is not left dirty beside an LF commit — and every later validation re-reads those bytes and holds the target's inode, size, mtime and ctime, refusing a rewrite in place (rival bytes, or the same text under other line endings) before the transaction commits. Where Git honours `core.fileMode` the live ledger's exec bit must also match the committed mode (a never-tracked ledger publishes as `100644`); a mismatch is refused before any ref moves, never repaired, and clears once the live ledger's exec bit is set to match (DW-328). The tail then prepares an expected-old transaction against only the captured terminal branch, proves that ref is still direct while its lock is held, and commits the transaction without updating symbolic `HEAD` (DW-311). A lost commit acknowledgement is settled only by re-observing and fully validating the candidate or by a later retry from unchanged authority; it is never compensated. Post-publication target-index synchronization brackets target-local resets with bounded checkout observations, preserves unrelated stages, repairs toward the newest observed committed tree, and refuses a moving attempt for replay. Every success return then ends with a final observation: a clean or ignored outcome re-checks the checkout identity and the target's index entry after its cleanliness probe, and both publishing returns re-validate the live ledger after that synchronization, so a rival writer during it is refused for replay (DW-329/DW-330). Resume can recognize the validated ledger transition beneath unrelated first-parent descendants without moving them. `DONE` is earned only by that accepted commit, by a clean outcome that still contains the accepted rewrite, or by an empty manifest. Two pre-dispatch checks run on every path to a migration session (DW-437/DW-440). A tracked migration input that differs from its committed blob, or whose probe faults, pauses at `story-gate` with no session and the task `pending` without a baseline, journaling `migrate-ledger-dirty`; the remedy is to commit the ledger and resume. An input with no legacy entries goes `pending` → `done` with no session, journals `migrate-empty-manifest`, retires the marker and the `migrate-*` records (the records stay on hosts without dir-fd), and emits no `sweep-migrated` row or `post_migrate`. Missing, nonregular, unreadable, malformed, or mutually inconsistent current-format records required by the task's persisted recovery phase escalate without reset or publication; a missing result during `triage-verify` instead triggers the intentional restore-and-redispatch path, while unmarked pre-upgrade tasks retain the older reset-and-reread route. A missing rewrite record during `triage-verify` escalates unless the validation-retry leg rejected that attempt's rewrite (DW-436): the leg persists a `migration_rewrite_rejected` latch before its reset, so a crash, `safe_reset` preflight pause or snapshot-failure pause there resumes through the same snapshot restore and redispatch as an interrupted `triage-running` session (an inherited ledger doubt leaves it to the cycle doubt stop); every redispatch, escalation and restart clears the latch. An interruption on the final allowed attempt restores the snapshot and then escalates with `migration failed deterministic validation` instead of dispatching past `max_migration_attempts`. On hosts without dir-fd anchoring (DW-315, native Windows), a fresh migration commits from its own in-memory evidence, and retirement leaves the records in place. A resume in `committing`, in `triage-verify` or `triage-running` with the current-format marker, or of a migration escalated from `committing`, escalates (or stays escalated) instead of reading the records; other resumes (a `pending` task with the marker, or an unmarked pre-upgrade task) read none and proceed. To recover, restore the pre-migration ledger, delete the run's `migrate-*` records, then resume: a stale `migrate-rewrite.md`/`migrate-result.json` at a fresh start refuses until the operator deletes them. Three further cycle-one recovery arms run ahead of the cycle ledger reader (DW-405/DW-407/DW-426/DW-314). A migration escalated from `committing` (a persisted flag, re-stamped on every migrate escalation; an escalation recorded before this upgrade carries no flag and is never retried, so it needs the restore-delete-resume remedy) re-enters the idempotent commit tail when the durable baseline/manifest/rewrite/result set validates and the live ledger equals the accepted rewrite, earning `DONE`, the `sweep-migrated` row and `post_migrate` exactly as an uninterrupted tail would, a landed commit included; drift, an unreadable ledger, an unknown marker or a no-dir-fd host keeps the task escalated and paused at `escalation`, so a plain resume never triages over an uncommitted migrated ledger. A live ledger that is legacy again bypasses that arm, so the restore-delete-resume remedy keeps its existing restart. Any other marked migration escalation — every one the commit-tail arm does not own, including a pre-flag `committing` escalation and an unknown marker — takes the second pre-reader arm (DW-426): it reads only the live ledger, and anything but legacy text — a non-legacy, absent or unreadable ledger — journals `sweep-migration-recovery-invalid` and keeps the task escalated and paused at `escalation` instead of triaging over text the migration never accepted or finishing the run on the generic ledger-fault stop. It dispatches nothing, runs no reset and never writes the ledger or any task field. `bmad-loop resume`, `bmad-loop resolve` and the TUI normally refuse an unreadable sweep ledger first, so this re-pause is the backstop for a ledger that becomes unreadable after that gate. Its two exits are the operator's: restore the pre-migration ledger (deleting the `migrate-*` records) to re-enter the migration through the existing restart, or resolve the escalation explicitly; unmarked pre-upgrade escalations keep their existing routing. A current-format migration interrupted in `triage-running` compares the live ledger with the durable baseline snapshot rather than Git cleanliness: live bytes equal to the pre-reset observation, or to the text the reset republished, are the dead session's residue and are replaced by compare-and-set with the snapshot before one replacement session is dispatched; any other value journals `sweep-migration-restore-diverged`, and a ledger read fault is a typed migration refusal instead of the generic cycle stop; these refusals escalate through `triage-verify`, the phase graph's only edge out of `triage-running`. A migrate escalation that refused a readable rival ledger latches a persisted flag (DW-429; re-stamped on every migrate escalation, never set by a read fault or an anchor-less probe): the next `escalated` restart observes that rival, parks and resets the checkout, restores the rival by compare-and-set against the text the reset republished — a third writer in that window re-pauses with `sweep-migration-restore-diverged`, the latch kept; one landing after the rival was read but before the reset is caught from the worktree snapshot parked just ahead of it, and re-pauses the same way with no reset, the row naming `snapshot_ref` and the newer text left live (a snapshot read fault journals `ledger-snapshot-probe-failed` and re-pauses too) (DW-435) — and makes it the replacement session's migration input (a tracked rival that differs from its committed blob is then refused before dispatch, as below, until the rival is committed); leaving `escalated` clears the latch. Any other `escalated` restart of a current-format migration grades the durable `migrate-baseline.md` snapshot rather than a rejected rewrite that `git reset` cannot put back (DW-439): when the live ledger differs from the snapshot and the ledger is ignored, untracked at the baseline or external, the snapshot is restored by compare-and-set after any dirty-tree reset (journaled `sweep-migration-snapshot-restore` with `outcome=restored`). A plain resume therefore restores the snapshot over any uncommitted edit of an ignored, untracked-at-baseline or external ledger that still holds legacy entries (the DW-426 arm above re-pauses on any other); to migrate a hand-edited ledger instead, resolve the escalation (`bmad-loop resolve` re-arms the task and the live ledger is graded) or delete the `migrate-*` records first. Bytes that change inside the restore window re-pause with `sweep-migration-restore-diverged` and latch the rival flag, so the next resume keeps and migrates the ledger then on disk; a read fault there re-pauses without the latch (fix the fault, then resume). Either re-pause keeps the task baseline. A ledger git tracks at the baseline is left to the reset, or to an operator commit the re-stamp adopts; an unreadable ledger or a failed baseline probe re-pauses with its own remedy. Without dir-fd anchoring, or once the operator deleted the `migrate-*` records, the live ledger is graded as before (`outcome=no-dir-fd` / `no-snapshot`). Every migration recovery or retry reset (the `escalated` or pre-upgrade restart over a dirty tree, the marked `triage-running` restore, the validation-retry reset) first refuses a HEAD that moved past the task baseline, or one it cannot read (DW-427/DW-428) — it journals `sweep-migration-recovery-invalid`, runs no reset and pauses at `escalation` (at `story-gate` for a `done` migration whose legacy text returned, since `done` has no outgoing edge) with its own remedy (leave HEAD where it is, make the working tree clean, then resume) — and a resume over a clean tree re-stamps the baseline at the current HEAD, as the marked `triage-running` restore now always does, so no later reset in that run rewinds those commits (DW-430). Completion delivery is at-least-once (DW-317). The save that records `DONE` also persists a fresh delivery id, the `sweep-migrated` counts and a pending latch. The latch clears only after the row is journaled and `post_migrate` returns. Cycle-one resume replays a still-pending delivery from those persisted fields alone, without reading any record and ahead of the cycle ledger read. The row is written once per id; the hook may repeat with the same `delivery_id`, and a failed or vetoed `post_migrate` handler is not retried. The guarantee covers process or host-process death, not power loss: journal rows are not fsynced, so a latch-clear save can outlive an unsynced row. -- Ledger read contract (DW-146/DW-279): `read_for_write` returns `None` for the existing metadata-absence cases and nonregular targets. Nonabsence metadata faults and all text-read `OSError` failures, including disappearance/type-change races after a successful probe, raise `LedgerReadFault(LedgerReadError)` with the original exception chained as `__cause__`. Invalid UTF-8 still raises `LedgerReadError` with a `UnicodeDecodeError` cause. A refused authoritative read publishes nothing. Pre-lock presence probes, lock acquisition and writes retain raw `OSError`; observation reads retain their empty-text-plus-attributed-fault degradation. Consumers distinguish OS refusal from decoding before handling the parent exception. -- Frontmatter harvest bridge (BMAD-METHOD#2640/#2651; shipped 0.9.1, hardened #433): since BMAD-METHOD 6.10.1-next.33 the unattended primitive records defer-triaged review findings in its spec's frontmatter `deferred:` list (summary/evidence, optional location/severity) and writes nothing to the ledger. The orchestrator harvests them itself — post-session but _above_ the artifact gate, so before verification and before the attempt is accepted — into canonical `### DW-` entries, so `deferred-work.md` stays the sweep's sole read surface. Entries therefore appear even when the attempt goes on to fail verification: a fixable retry deliberately keeps them (the attribution reference moves onto the kept tree), and `_harvest_gate_exclude` stops the engine's own append from counting as the session's proof of work. Dedupe key is the fingerprinted `origin: spec-deferred ` plus `source_spec:`, scanned across entries of _every_ status, so a replay neither doubles an entry nor re-opens a closed one. Era-agnostic (the gate is the field's presence, never the skill name resolved on disk) and bounded to sessions bmad-loop drove to a success status — `in-review` with the follow-up review enabled, else `done`, plus an operator park; a plan-halt checkpoint keeps its notes for the implementation pass. A spec outside the orchestrator-owned roots is refused (`spec-deferrals-skipped-out-of-tree`) and an unreadable one retries the session rather than accepting it with findings silently dropped; `deferred:` items that will not parse are journaled (`spec-deferrals-malformed`) and filed as one low-severity entry naming the spec. A ledger whose bytes do not decode is routed at every one of the engine's own four `read_for_write` sites (DW-231) according to what the read was about to do: the observation reads — the proof-of-work digest, the pre-harvest snapshot, the defer snapshot and the two restores' compare-and-set probes — degrade to a typed answer nothing can write back or anchor a write on (the digest hashes the raw bytes, so "did the ledger change" stays exact; the snapshots stay unarmed; a restore skips and journals) and journal `ledger-read-degraded` naming the site; the two reads that precede a publish — this harvest's append and the isolated unit's carry into the main ledger — normally journal `ledger-read-refused`, raise an `ACTION REQUIRED` notice naming the ledger, and pause the run at `escalation`; a sweep's terminal post-merge harvest carry instead journals `sweep-bundle-close-refused` and pauses at `story-gate`, while its direct pre-terminal defer carry retains the engine route. Both routes leave the task's phase untouched, so `bmad-loop resume` after the hand repair retries the write — resume recovery replays the recorded session result where one exists (the dev and review legs) and otherwise re-drives the leg (the unlatched review-timeout salvage and fix legs) — rather than, as `_escalate` would, demanding a `bmad-loop resolve` session and a clean rebuild over a fault that is not the story's. Bare, the first of those reads ended a story run as `run-crash` with the completed session's work on disk. The route also covers the window INSIDE each write, for decode faults (DW-259) and OS metadata/text-read faults (DW-279): every `deferredwork` mutator takes its own locked `read_for_write` — after the routed pre-read at the harvest and the harvest carry, after an observation snapshot at the commit-boundary close, and with no pre-read at all at the review-timeout salvage refile (`deferredwork.append_entry`) and the isolated close carry — so a `LedgerReadError` raised from the mutator call itself — the harvest's seen-again mark and append, the commit-boundary `closes_deferred:` close, the salvage refile, the isolated unit's harvest carry and close carry — pauses through its owning repair route under a site name ending in `-locked` (`spec-deferrals-harvest-mark-locked`, `spec-deferrals-harvest-append-locked`, `story-close-locked`, `review-timeout-salvage-refile-locked`, `harvest-carry-append-locked`, `story-close-carry-locked`); the locked read fires ahead of every write, so a pause there proves the mutator wrote nothing, which is what lets the commit-boundary close disarm its rollback first rather than journal a `deferred-close-rollback-failed` against bytes it cannot read. The notice names both kinds of write (findings to file, a declared close to record); `bmad-loop resume` re-drives the close through the COMMITTING arm, and a pending salvage refile through its persisted retry latch (DW-278): resume verifies the preserved product again, refiles the outstanding follow-up, and commits without new dev/review sessions or additional attempt/cycle charges under either rollback policy; ordinary commit gates still run, including any configured `pre_commit_gate` workflow sessions. An unrepaired ledger pauses again with the latch retained; a failed salvage verification follows the usual retry/exhaust routing. Successful refile records the publication while retaining recovery authority through notification and commit gates — the latch is set at every salvage's handoff save, the first fault-free one included, not only after a repair pause, so a host lost between that save and the commit replays the salvage rather than restarting it; the durable COMMITTING transition clears the latch. Legacy and unlatched timeouts keep the baseline restart or manual recovery behavior governed by `scm.rollback_on_failure`. The replay is the story engine's: a sweep bundle's recovery (`_recover_inflight_bundle`) has no session-replay arm at all, so a latched bundle restarts as every other post-session bundle does and its restart clears the latch, so the abandoned product's salvage cannot force a review on the replacement attempt. `LedgerReadFault(LedgerReadError)` wraps OS metadata/text-read failures with the original `OSError` as `__cause__`, so the locked-read catches cover them without catching lock/write failures. Pre-lock presence probes, lock acquisition and writes keep their raw `OSError` behavior. A read the OS refuses (EACCES, EIO, a symlink cycle) is routed the same way at the same four sites (DW-258): the observation reads degrade to a typed answer that carries NO digest — nothing is read from a ledger the OS refused — so the proof-of-work digest becomes an `` sentinel and the attribution compare treats a sentinel on either side as UNKNOWN, which is never credited: the ledger path stays excluded from proof of work, so the engine's own harvest append after the hand repair cannot pass a session that wrote nothing (a session whose only work was a ledger edit over a refused baseline is rolled back and retried over a readable one); the publish reads pause with the same `ACTION REQUIRED` notice, whose repair sentence now names both repairs (valid UTF-8; the path's permissions or storage). Where no publish read is reached — a spec with no findings — the story completes over the refused ledger, and a declared `closes_deferred:` then journals `deferred-close-ledger-unavailable` and sends a best-effort notice through the configured notification channels (including ATTENTION when file notifications are enabled), naming the story, every unapplied declared ID, and the fault (DW-277). The snapshot outage writes nothing to the ledger and does not pause or crash the story. Pre-rename primitives and the attended `bmad-build` still append flat `- source_spec:` blocks directly, which `sweep --migrate` normalizes. -- Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to `status: done ` + `resolution: resolved by story ` — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and `bmad-loop validate` warns about all of them before the run starts. (A non-list `closes_deferred` in `stories.yaml` is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (`deferred-close-external-ledger`). If the advisory ledger snapshot cannot be read, the declared closes remain unapplied and open entries stay open; the outage is journaled and notified as above. Restore ledger readability (valid UTF-8 and accessible permissions or storage), then run `bmad-loop sweep`: the next sweep re-triages those IDs against the actual code and can close verified resolutions as `already_resolved`, with the completed story's commit serving as evidence. -- Hard gates (`gate: 3-2, 3-3` on an entry): until the entry lands, `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable story a token matches and `run` pauses (`story-gate`) rather than dispatch one — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. Only an explicit `status: done` retires a gate; an unreadable status (`opne`, or no status line) still gates. The dispatch pause precedes the story's own run record, so closing the entry and resuming runs it; sweeps are exempt, since a sweep is what closes the gating entry. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the four gates nothing can enforce: a token that cannot name a story key (`3-2 3-3`, or an unmatchable `3.2` — `.`/`_` are legal inside a sprint slug), an empty `gate:` line, a `gate:` not lowercase at the start of a line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. Migration is checked rather than merely instructed: a `sweep --migrate` rewrite that drops a `gate:` token a pre-existing entry declared is refused, restoring the original ledger (#519); an added token is accepted, since over-blocking fails loudly and in the safe direction. A ledger already carrying duplicate `DW-` ids is refused before any rewrite is dispatched, pausing with the ids to renumber: no rewrite of it can both preserve the entries and produce a valid ledger, so the only one that would pass collapses the pair and drops a twin's gate. -- Ledger tracking state (DW-361): `bmad-loop validate` warns (`deferred.ledger-untracked`) when the ledger file exists but git neither tracks nor ignores it — `git add` takes such a path, so the first isolated merge carrying a ledger write (or a non-isolated story commit's `git add -A`) commits it. Silent on a tracked, ignored, absent or out-of-repo ledger, and on a git fault (`git.probe` owns that). Under git's defaults the file also fails `git.worktree-clean`; with `status.showUntrackedFiles=no` it is the only line that speaks. Advisory only: the exit code is unchanged. -- Isolated ignored ledger (DW-375): `bmad-loop validate` warns (`deferred.ledger-ignored-isolated`) when `scm.isolation = "worktree"` and the ledger is gitignored — each unit then works on a seeded copy, and entries a session writes there itself are lost at teardown (journaled `isolated-ledger-writes-uncarried`). Fires before the file exists, since the first harvest creates it. Silent under `isolation = "none"`, on an out-of-repo ledger, on a path that exists but is not a regular file, and on a git fault. Advisory only: the exit code is unchanged. -- `bmad-loop sweep` triages every selected open entry against the actual code (all open entries by default; ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. `--only DW-1,DW-3` requires every named id to exist and be open on the initial post-migration ledger; repeats and resumes intersect that fixed scope with entries still open. `--min-severity low|medium|high|critical` is re-evaluated each cycle and includes the named level and higher; missing or unknown severity is excluded and explicitly reported. Selection precedes triage, bundle formation, and `--max-bundles`; dry-run uses the same rules; both exclusion classes are journaled; selectors persist in `sweep.json`, are mutually exclusive, and cannot combine with archive mode. On a mixed legacy ledger, named dry-run selection projects provisional ids by continuing from the highest canonical id in manifest/file order, labels selected and excluded projections, and rejects projected done/unknown ids without spawning migration; semantic duplicate merging may compact the final mapping, so the real run revalidates against the actual post-migration ids. Before the first-attempt validation, an otherwise-valid lowercase ASCII/kebab bundle name or decision-option `bundle_name` over 40 characters is truncated to 40 and journaled; malformed shapes and post-truncation collisions still fail through the existing retry path. Text `bmad-loop status` on a sweep run prints a `sweep options:` line giving `max_bundles`, `repeat` and `max_cycles` at their effective values — the run's `sweep.json` override, else its `policy_snapshot` (never live policy.toml) — labelled `override` or `policy`, plus any selector; options that fail the resume loader's bound or digest checks read `unverifiable` with the reason, and `--json` is unchanged (#815). -- Retro action items (DW-388): `bmad-retrospective` appends committed action items to sprint-status.yaml's top-level `action_items:` list, each with a stable `id` and a `ref` to the retro document. At the start of a **fresh** sweep (cycle 1, before triage; never once triage has started — a resumed run past triage does not ingest, while one that stopped before triage does — never under the run's ledger doubt, never in `sweep --dry-run`), every id-keyed item whose status is not `done` and that the ledger has not seen is appended as a canonical entry — title = the item's `action`, `origin: retro action item `, `source_spec` = its `ref` (else `sprint-status.yaml`), `severity: low` — and the ledger is committed before triage reads it, so the new entries are triaged in the same cycle. "Seen" means ANY entry carries that origin line, open, closed or an archived stub, so a finished item is never re-filed. Items without an `id` are skipped. No board or no `action_items` key is silent; an unreadable board, a non-list `action_items`, or a ledger fault before the write journals `sweep-retro-ingest-unavailable` (`ingest_cause`: `sprint-status-unreadable` / `action-items-malformed` / `ledger-unavailable`) and the sweep carries on; `sweep-retro-items-ingested` names the minted ids. Sprint-status.yaml is only read, never written. -- Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. -- Artifact-only bundles (DW-273): a bundle whose only permitted deliverable lives under a gitignored `implementation_artifacts` dir (a spec-only erratum) can never satisfy the ordinary dev proof-of-work probe, which measures tracked and untracked-not-ignored paths only — and a bundle has no `awaiting-operator` park to fall back on. The bundle dev session may instead assert it by appending an `Artifact only: true` line inside its `## Auto Run Result` marker, beside `Status:`. Accepted spellings: `Artifact only`, `artifact_only`, `Artifact-only` — a run of at least one space, underscore or hyphen between the two words (the fused `Artifactonly` is no spelling and asserts nothing), case-insensitive, in the same bulleted or bolded label/value shapes as `Status:` (`**Artifact only:** **true**` and `- **Artifact only: true**` both assert) — with the value `true` alone on the line (anchored to end of line: `Artifact only: true for the ledger, false for code` is prose, not an assertion) and, like `Status:`, on the SAME line as its label (`Artifact only:` with `true` on the next line is a bare label, not an assertion; any horizontal whitespace, NBSP included, is fine); a line inside a fenced block within the marker is documentation and mints nothing. The synthesized result carries `artifact_only: true` only when that line sits in the current session's last genuine, non-fenced marker with no orchestrator repair note — the same four-part shape and the same session-authorship proof (`park_marker_session_authored`) that mint `park_asserted`; frontmatter never mints it, and a loose `"true"`/`1` is no assertion. Gate rule: on the bundle path only (`verify_dev_bundle` — a story result asserting it still owes the ordinary diff), after the ordinary probe has positively answered "nothing changed", a strict `artifact_only: true` makes the gate run `git status --ignored --porcelain --untracked-files=all` scoped to the configured `implementation_artifacts` dir and count only its IGNORED (`!!`) records — the tracked and untracked-not-ignored ones are what the ordinary probe already measured, so under the `bmad-loop init` default layout (where `_bmad-output/` is not gitignored) a bundle's own spec flip or newly written spec is no receipt — and keep only the entries THIS ATTEMPT created or changed: ignored paths carry no git baseline, so the sweep engine fingerprints (`lstat` mtime and size) every ignored entry under the dir at each genuinely new attempt's start (`StoryTask.baseline_artifacts`, stamped beside `baseline_commit` and persisted in state.json; a fixable repair keeps the chain's snapshot, a rolled-back retry takes a fresh one since rollback never removes ignored files), and an entry absent from that snapshot or carrying a different fingerprint now is the attempt's, while one unmeasurable at either end is not. A positive owned count is accepted as proof of work; residue that all predates the attempt (`lists N ignored entries, none created or changed by this attempt`), an empty listing, a task with no snapshot (a pre-upgrade run, or a capture that degraded to `bundle-artifact-baseline-unavailable` on a git fault — the attempt is still driven, only the relaxation is withheld), an artifacts dir outside the code tree (or equal to it), or a git refusal keep the ordinary retry with the refusal's cause appended to the verbatim `no changes in worktree since baseline commit` reason, and a git fault at the gate escalates like the ordinary probe's. An accepted receipt is journaled as `bundle-artifact-only-accepted` (`story_key`, `attempt`, `dw_ids`, `count` — the number of owned entries). Under `scm.isolation = "worktree"` an in-tree artifacts dir is rebased into the unit worktree, so the receipt measures the worktree's copy and the success teardown removes that worktree — an artifact-only unit lands no tracked change, and the ledger carry re-applies the close but copies no file. What survives is what publication carries (DW-283, below): the accepted ignored spec and the exact files its `artifact_deliverables` list names are published to the main checkout ahead of the teardown; undeclared ignored residue is not copied, and the bundle prompt says so (DW-284). An artifacts dir OUTSIDE the code tree is not rebased and survives the teardown, but the receipt is refused for it regardless of isolation, since git lists nothing outside the repo (the "outside the code tree" refusal above). The limit: ownership is read off fingerprints, not content, so a rewrite that lands byte-identical with a preserved mtime is invisible to it, as it is to the ordinary probe; the assertion selects the receipt, the snapshot is what makes it proof. Nothing else relaxes — workflow tag, expected status, baseline match, dw_ids cross-check, your `[verify]` commands and the review gate's every-id-`done` check all still apply. -- Artifact-only bundle guidance (DW-284): initial, restored-review, and repair bundle prompts advertise this conditional receipt: assert it only for the current session's actual qualifying deliverables, never for ordinary changes, other nonqualifying deliverables, or old artifacts alone. Sweep triage describes qualifying deliverables and their location in the existing bundle `intent` field; the executing session owns the assertion, with no added triage receipt field. Successful isolated integration publishes explicit ignored deliverables through the separate publication contract in Git worktree isolation; accepting the receipt alone does not publish files. -- Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. -- A deferred-work ledger that will not read no longer ends the sweep as crashed (DW-166). The already-resolved close and an attended decision's own ledger effect both degrade to a journal row and carry on — `sweep-resolved-close-unavailable` (naming the ids it was about to close, which stay open for the next cycle to re-triage — since DW-193 that row has two further producers, both the same probe deciding whether an already-landed close still needs publishing, where the ids may already be `done` and nothing was about to close at all — one inside the close phase, one at the sweep loop's nothing-open exit, where the phase never runs; in every case the row means no usable ledger was read and nothing was published) and `sweep-decision-effect-unavailable` (naming the decision and its effect). The decision arm is the reachable one and the one with a hazard behind it: the prompt blocks on the human, so a ledger corrupted or locked while it is open faults only after the answer is already saved to `/decisions.json` and journaled `decision-answered`. That ordering is deliberate — the human's answer must survive a crash — so the degrade is the fix rather than a reorder, and the faulted decision emits no `post_decision` and is not counted closed. What the human is left with then differs by effect. A `close` leaves the entry open, and since DW-167 the re-apply walk below picks it up without a new run: a later cycle (or a resume) finds the stored `close` over a still-open entry and applies it, so a ledger that reads again repairs itself. A `build` still materializes its bundle from the saved answer, since a read fault does not prove the entry is absent. Dispatch requires the phase's final ledger doubt to clear, for example when a later effect succeeds; the faulted build may then run without its own `decision:` audit line. The same row also fires with no raise involved (DW-186): `record_decision` returns a boolean, and it reports writing no line when there is no ledger file at all or no entry carrying the id — a rival writer can retire one while the prompt is open. That non-write shares the raise's dispositions (no `post_decision`, not counted closed, the row naming which state it was) but not the build trade: a False return is positive proof there is no entry to build against, so since DW-200 such a `build` answer takes the fourth drop lane below instead of running. The boolean neither sets nor clears the commit withhold below, because a missing ledger answers False without ever reading the bytes. The same boolean now reaches the two out-of-band surfaces that answer decisions outside a sweep (DW-198): `decisions.apply_pre_answer` returns it instead of discarding it, so `bmad-loop decisions` replaces its per-decision outcome with one naming the non-write (and which of the two states it was, since it holds the ledger path) rather than printing `closed now`, and the TUI decision modal raises a `warning` toast naming the id and does not count the answer into `recorded N decision(s)`. Neither treats it as an error: both walk on to the next decision and the command still exits 0. Nothing is withheld either — the pre-answer store write and the best-effort commit both still run — so a `build`/`keep-open` answer really was saved to the pre-answer store, which both surfaces say. Neither promises more than that: a sweep's triage is derived from the ledger's open ids, so an id the ledger no longer carries is never surfaced again and the stored answer is pruned as no longer open. The `close` half of that trade is no longer left to a later run either (DW-167). The interactive arm persists the answer and journals `decision-answered` BEFORE it applies the effect — deliberately, so the human's answer survives a crash — which left a crash in that window with `/decisions.json` claiming `effect: "close"` over an entry the ledger still listed as open, and every consumer read that answer as consumed: the reload accepts it, `pending` filters the id out so it is never re-asked, and no materialization lane matches `close`. The write order is unchanged; the READ side now repairs it. Before the walk prompts, every id in this cycle's decisions whose stored answer is a `close` is checked against the ledger's live open set, and one still listed open is an effect that has not landed, so it is re-applied through the same `_apply_decision_effect` the prompt uses — `sweep-decision-effect-reapplied` names it, it counts toward the cycle's closures, and it feeds the same commit gate. "Still open in the ledger" is the only discriminator, so the mirror-image crash (the effect landed, the answer write was lost) adds no second `decision:` line. The gate is a REPAIR/WRITE read because it gates a write: an absent or undecodable ledger re-applies nothing and writes one `sweep-decision-effect-unavailable` per candidate id rather than one row per file, so each row names an id to chase. Scoped to ids this cycle still asks about, since `pending`'s filter over them is the suppression being repaired — an id the fresh triage no longer raises has its own routing already. The gate is the FILTER and not the enforcement: it reads the open set outside `record_decision`'s lock, and a snapshot cannot hold across the write it authorizes, so the walk alone passes `require_open` and the still-open predicate is re-checked inside the same locked read/edit/write as the mutation. A rival writer closing the entry in that window is therefore refused rather than given a second `decision:` line over a close already recorded — nothing is counted closed, and the row names the retired-but-present state distinctly from the missing-entry and missing-file ones. Every other caller — `bmad-loop decisions`, the TUI decision modal, the pre-answer applier, the sweep's own interactive arm — is unchanged and does not pass it, so a decision recorded on an entry someone else already closed still lands. The `build` half is narrowed rather than repaired (DW-200): a `build` answer whose `record_decision` reported writing no line now takes a fourth drop lane instead of materializing a bundle, so no dev session is spent on an id the ledger holds no entry for. Scoped to that False RETURN — a read fault retains the saved build, but dispatch still requires the decision phase to end without ledger doubt. What neither arm degrades is the PUBLISH itself: a mutator whose atomic write fails (`ENOSPC`, `EROFS`, a failed rename) raises `deferredwork.LedgerWriteError` — an `OSError` subclass, so `bmad-loop decisions` and the TUI modal keep their degrade — and both sweep sites re-raise it ahead of the degrade arm, because a repair write that failed is not a phase that closed nothing (observation may degrade, repair writes must raise). The decision-phase raise is safe for the same ordering reason the degrade exists: the answer is already in `/decisions.json`. The far side of the publish is typed too: when the ledger lock's release faults after the body landed (Windows `LK_UNLCK`, `os.close`), `ledger_lock` raises `LedgerLockReleaseError` instead of the bare `OSError` that arm read as "nothing was written", and both sites re-raise it — the closure is on disk, and the sweep does not get to say otherwise. -- Four of the sweep's run-dir bookkeeping I/O sites degrade the same way — the triage cache's metadata probe and write-back, and the run-local decision store's metadata probe and seeded write-back — so a fault at any of them no longer ends an otherwise healthy sweep. At the decision store's SEEDED write-back (`atomic_write_text_confined` of `/decisions.json` after project pre-answers are adopted; DW-262) a directory planted at the store — silent at the `S_ISREG` probe — or a refused or redirected parent (`UnconfinedWriteError`, the #593 confinement refusal) journals `sweep-decisions-store-write-failed` (`file`, the `dw_ids` adopted this cycle, `error`) and the sweep carries on with the answers in memory, since a resume re-adopts them from the project store; the INTERACTIVE write-back deliberately stays bare, so a human's answer at the prompt whose write FAILS still stops the sweep loudly. And when the store's metadata probe or content read was refused with an `OSError` this cycle (DW-264): the SEEDED write-back is withheld (`sweep-decisions-store-write-withheld` naming the adopted ids whose answers stay in memory — a resume re-adopts them from the project store), because the bytes on disk may hold valid answers that merely could not be read and replacing them from an answer map that started empty would turn a transient refusal into permanent loss (the withheld check precedes the write, so a withheld write is never also reported as failed); and the interactive PROMPT is withheld outright (`sweep-decisions-prompt-withheld` with `file`, the pending `dw_ids` not asked and the refusal's `error`, plus an ATTENTION notice naming the file to repair): an answer taken there could not be persisted, has no second copy, and nothing reads a `build` back off the ledger's `decision:` line, so an answer held only in memory was lost by a crash before its bundle was materialized. The decisions stay pending and unquarantined, so the next interactive sweep over a readable store asks them. The triage cache's metadata probe uses `stat()` rather than `is_file()` so an OS refusal degrades uniformly across Python 3.11–3.14 to `sweep-triage-reload-failed` and a fresh triage, with an absent cache staying silent (DW-224); the cache WRITE-BACK after a validated fresh triage degrades to `sweep-triage-cache-write-failed` (`unwritable: ...`) and the plan is still returned and acted on (DW-247) — its own kind, since `sweep-triage-reload-failed` is a reader's row and overloading it would report a healthy triage as a corrupt cache. What a lost cache costs, and why the row is worth reading: a resume of that cycle re-triages, `_publish_stranded_close` finds no cache at the no-open exit and so recovers no stranded close, and `bmad-loop decisions` cannot reconstruct that cycle's decisions from it; a directory planted at the cache path is silently "no cache" at the read and lands on this same row at the write. Since DW-263 a cache whose READ faults is unlinked before the fresh triage (`sweep-triage-cache-invalidated`; a refused unlink journals `sweep-triage-cache-unlink-failed` and the fresh triage still runs), and the write-back is atomic (`atomic_write_text_confined`), so a refused or short overwrite leaves either no cache or a complete one — never torn JSON, and never the older plan for the next resume to replay, except where the unlink was itself refused or the cache faulted at the metadata probe (that arm does not unlink): there a refused write-back does leave the older plan in place. The write-back is also confined to the project that owns the run dir (DW-269): a link planted at any directory component below the project root (`.bmad-loop/`, `runs/` or the run dir) refuses with `UnconfinedWriteError` (the #593 refusal) and lands on this same `sweep-triage-cache-write-failed` row instead of publishing the cache outside the project. Only the read-failure arm invalidates: the metadata-fault arm cannot reliably unlink what it cannot stat, and the validation-failed arm's bytes cannot be replayed as a plan at all. The run-local decision store `/decisions.json` gets the same `stat()` probe (DW-248): a metadata refusal journals `sweep-decisions-reload-failed` (`unreadable: ...`) and the affected decisions take the pending/skip path instead of the sweep aborting over a bookkeeping read on 3.11–3.13 before saved answers can be consumed; absence stays silent. -- The pre-answer prune refuses rather than wiping when the ledger will not read for a write: absent (DW-176), holding undecodable bytes (DW-182), or refused by the OS (DW-197). The open set it derives is the KEEP list for a store write, so treating any of them as an empty ledger would read as "nothing is open" and drop every pre-answer the human recorded — and commit the wipe. All three take `sweep-preanswer-prune-refused`, whose `reason` is one of three fixed tokens (`ledger-absent`, `ledger-unreadable`, `ledger-inaccessible` — there, undecodable, versus there and the OS refused the read, which are different operator repairs), with the decode or errno detail in `error`, and every stored answer survives to be re-offered next sweep. An empty-but-PRESENT ledger is not a refusal: it genuinely has zero open ids, so the prune still runs. A FOURTH token, `ledger-in-doubt` (DW-217), is the only one of the four taken with the ledger perfectly READABLE: the read succeeded, but this cycle already declared the ledger unfit to publish — a close batch or a decision effect that could not land — so the open set derived from those bytes is not a KEEP list to trust. It covers the decodable fault class the three reads above cannot see, where a half-landed write flipped an id to `done` without recording its line and the human's pre-answer for that id was pruned and the deletion committed. It sits BELOW the three read arms deliberately, so each of those keeps its own token and its own carry, and it carries nothing of its own — the latch it read already reaches the repeat boundary — and writes no `error`, since there is no fault to quote. The refusal degrades rather than raising because this prune is the LAST call of a cycle, so a raise would crash a cycle with nothing left to do over bookkeeping — including a cycle that deliberately ran no bundles at all, since the dispatch gate above withholds them when the ledger is in doubt. -- The decision phase withholds its ledger commit when the LAST effect in the walk faulted, so the bytes an effect could not read are never published — and since that commit's pathspec IS the ledger, narrowing the scope bounds what else can ride along without making the withheld file itself committable (the withhold is unchanged; what changed is that a LATER commit in the same cycle can no longer undo it — DW-187). It also commits only when an effect actually LANDED: a walk that answered nothing wrote nothing, and spawns no git at all (DW-185). It is the last attempt's verdict, not the walk's: an effect that succeeds afterwards proves the ledger reads again and clears the doubt, and that commit then carries the earlier decisions' lines too — which matters because without `--repeat` there is no later cycle to pick them up, and this cycle's bundles need a clean baseline. The attended hand-back reports it: where every effect landed it still prints `✓ decisions recorded — sweep continues in the background`, and where an effect did not land while final ledger doubt is clear — including a False return that leaves the doubt clear without a later success — it says the answers are saved, that not every decision reached the ledger, and to look for `sweep-decision-effect-unavailable` in the journal. If the last attempt faulted, it instead reports that the ledger is not fit to publish and needs repair, without promising background work or claiming bundles were already withheld. The hand-back itself is unconditional either way — its trigger is that nobody can be relied on to answer here any more, not that the effects landed. The withhold is only half the story, though, because it withholds only its OWN commit: bundles dispatched later in the same cycle commit through `verify.commit_story` / `verify.finalize_commit`, both of which open with a whole-tree `git add -A`, so a bundle running behind a faulted effect published exactly the bytes the withhold refused to publish (DW-194/202/210). A ledger left in doubt by the decision phase is therefore also a DISPATCH gate: `_cycle` runs none of that cycle's bundles, journals `sweep-bundles-withheld` (`bundles_not_run`, `reason="ledger-unreadable"`) when there were any to withhold, and a repeating run ends on the existing `ledger-unreadable` stop below with repair instructions. Non-repeat and `--decisions-only` runs retain their existing early returns, with no repeat-stop record or new ledger-repair ATTENTION notification. Those two `git add -A` pathspecs are deliberately unchanged: narrowing them would bound what else rides along without making the ledger itself safe to publish, and the fix is that nothing corrupt is in the tree when they run. The gate covers both fault classes the withhold does — bytes that will not decode, where dispatch would otherwise die on the intent document's own ledger read, and a lock or write failure (`OSError`, `ValueError`, `StateRootError`) that leaves a half-recorded answer which reads back perfectly and would be swept into HEAD under a bundle's commit message. Materialization still runs ahead of the gate: its drop lanes are ledger-independent bookkeeping and feed the cycle's progress signal. The latch that gate reads is armed from every fault site that can raise the doubt, not just the two decision-effect handlers (DW-216/217/220). `_close_resolved` gets a latch of its OWN — it runs ahead of the decision phase, which publishes its verdict with `=` so a healthy phase can clear an earlier cycle's doubt, and folding the two would let that assign erase a close-phase fault — armed in its existing `sweep-resolved-close-unavailable` degrade and reset at the top of the phase, so it stays cycle-scoped. The DW-167 re-apply gate arms from its own read refusal, which it used to concede as a residual: it attempts no effect, but it OBSERVED the fault, and the class it observed is exactly the class that makes the bundle intent document's own ledger read raise. And a phase that attempted nothing at all — the commonest unattended shape, every decision skipped for a human — has no observation to publish, so it takes ONE ledger read it does not otherwise take and journals `sweep-decision-ledger-refused` (`ledger`, a fixed `reason` of `ledger-unreadable` or `ledger-inaccessible`, the fault in `error`) when it refuses. That probe is conditional on the phase having attempted nothing and observed nothing, which is what keeps it from masking the arms above it; ABSENCE arms nothing on any of the three legs, keeping DW-176's discipline. All three read the doubt through one helper shared with the repeat stop and the pre-answer prune, so a future arm cannot be wired into one reader and missed by the others. Since DW-218/219 that helper also reads a RUN-scoped mirror of the verdict, written to `state.json` at EVERY site that arms a latch — the close phase's degrade, the five arms inside the decision phase, and the decision phase's tail publish, which is a defensive no-op in every reachable state rather than a backstop that ever writes first — rather than at the gate or at the tail alone: the latches themselves are instance state, so a stop request observed in the withheld branch (which takes its item-boundary stop check ahead of the `sweep-bundles-withheld` row) or any crash in the same span ended the process with the verdict held only in memory, and the resume then dispatched — on a DECODABLE half-write every in-memory arm of the resumed cycle passes, so the bundle's `git add -A` swept the unauthorized flip into HEAD. Writing only at the tail leaves the longest span of all open, since the decision walk's own fault arm continues into an iteration that blocks on a human at a prompt. It tracks the latches it mirrors rather than simply outliving them: the two sites that clear the walk's local verdict — an effect that lands afterwards, proving the ledger reads and writes again — release the mirror too, unless the close phase's separate latch is armed this cycle, or the mirror was inherited across a resume: "the ledger reads and writes again" is a statement about THIS process's LAST ATTEMPT and nothing wider — not about a write a different phase made, nor about bytes a previous process left on disk. So the mirror IS clearable inside the run, but only for an arm this process made; those two exceptions are deliberately stickier and wait for the documented repair — a human edits the ledger and re-runs `bmad-loop sweep`, which is a new run with fresh state (a cycle whose doubt remains armed ends the run at its boundary; a cycle that releases its doubt can continue repeating with a clear mirror). A `state.json` written before DW-218/219 carries no such key and loads it False: a compatibility default, so such a run resumes exactly as it does today, not evidence that the run which wrote it had no doubt. The persisted verdict also reaches the three ledger publishers that run on a RESUME ahead of the dispatch gate (DW-246): the already-resolved close's own publish, its DW-193 stranded-close republish, and the post-recovery publish after in-flight bundles are re-driven all read the same one verdict and, when the ledger is held unfit, journal `sweep-ledger-commit-withheld` (`message`, `file`, and a fixed `reason` of `ledger-in-doubt` — the token the pre-answer prune already uses for a ledger that reads but is unfit) instead of committing; before that a resumed run that KNEW its ledger held a half-write published the whole file, half-write included, before the gate that exists to withhold it was consulted. A withheld publish is not a refusal — no target was probed and no git was spawned — which is why it is its own row rather than a fifth `refuse_cause`. In the other direction, a ledger-family `sweep-ledger-commit-refused` now ARMS the persisted doubt after its row (DW-244): a refusal after a landed decision effect used to leave every latch clear, so the cycle's bundles reached the bundle intent document's own ledger read and the run crashed on the same ledger the publisher had just declined; all four `refuse_cause` tokens arm, `target-absent` included — an absent tracked ledger cannot crash the intent read, but a bundle dispatched over it commits with a whole-tree `git add -A`, which would stage the ledger's deletion under the bundle's message, so absence is withheld on too, at the cost that the repair notice then names a file to restore rather than repair; a store-family refusal says nothing about the ledger and arms nothing; a refusal-armed doubt is released on the same contract as any arm this process made (a later landed effect while the close phase's latch is clear); and a refusal at the cycle-boundary publisher, which sits below the unfit stop, is honoured one cycle later — cycle N+1 withholds its bundles and ends on the unfit stop, unless one of its own effects lands and releases the arm. A ledger-family `sweep-ledger-commit-unavailable` from the publisher's RESOLVE arm — a `Path.resolve` that raises `OSError`/`RuntimeError`, the same unwalkable component the bundle intent document's own ledger read would meet — arms the same doubt after its row for the same reason (DW-260); the git-fault arm (a `GitError` after a successful resolve, the best-effort skip described below) arms nothing, since git declining to publish a file it could reach says nothing about the ledger's readability. And the sweep loop's nothing-open exit writes the ledger-repair ATTENTION notice (headline `the deferred-work ledger is not fit to publish`, naming the ledger path and the clean-worktree precondition) whenever the doubt is armed (DW-251) — the class that arms the doubt never writes a notice of its own, so a run that withheld publication and then found nothing open used to end on `sweep-nothing-open` / `no-open` with no repair instruction at all; the exit's rows and its return are unchanged. The nothing-open exit's stranded-close publisher (`_publish_stranded_close`) reads the same verdict as a fourth call-site gate since DW-250, above BOTH of its probes, and its withheld row alone adds `dw_ids` (the cached plan's already-resolved and decision ids it declined to prove); the post-migration publisher does not read the verdict itself because the migration it publishes is refused one gate earlier: `_loop` meets an inherited doubt ahead of `_ensure_migration` and takes the doubt's own `ledger-unreadable` stop and repair notice before any rewrite session is spent. Accepted residual, unchanged: the gate does not extend to `Engine._maybe_auto_sweep`'s parent leg. -- A bundle close whose mutator cannot read the ledger under its own lock pauses the run rather than crashing it (DW-280). Every `deferredwork` mutator takes its own locked `read_for_write` ahead of every write, so a ledger that turns undecodable or suffers an OS metadata/text-read fault (DW-279) inside that window raises from the `mark_done_many_reopenable` call itself with nothing flipped; the sweep's three such calls — the accepted-dev close, the review-leg reclose and the isolated close carry into the main checkout — used to let that raise escape bare (an isolated bundle crashed after its merge with the carry unlatched; an in-place one crashed at the accepted close with the session's work on disk, or at the review-leg reclose after an accepted review with dev and review work uncommitted in place). Each now journals `sweep-bundle-close-refused` (`site` one of `bundle-close-locked`, `bundle-reclose-locked`, `bundle-close-carry-locked`; `dw_ids` the ids the close was about to publish; `reason` `ledger-unreadable` for undecodable bytes or `ledger-inaccessible` when the OS refused the metadata/text read — the same split `sweep-cycle-ledger-refused` and `sweep-preanswer-prune-refused` make), writes the `ACTION REQUIRED` repair notice steering at the UTF-8 or at the path's permissions/storage to match, with the `bmad-loop resume ` route, and pauses at the story gate with the task's phase and recorded close intent untouched — so the existing resume arms re-drive the close with no session spent: the accepted-dev close replays off the persisted PROCEED receipt, the isolated carry replays through the unlatched-carry pre-pass, and the review-leg reclose takes the sweep's restart arm (the bundle re-driven from dev). The sweep's own route, beside `sweep-bundle-close-carry-refused`, not the engine's `ledger-read-refused`; the `LedgerReadError` catch includes the OS-read subclass `LedgerReadFault` (DW-279), while pre-lock probes and lock/write faults retain their raw `OSError` behavior. Terminal post-merge harvested carries share that sweep-owned row and story-gate lifecycle (DW-286): `harvest-carry` names the pre-read and `harvest-carry-append-locked` the mutator read, `dw_ids` is empty because the failed append has not assigned an ID; unreadable existing state may still contain a merged or deduped copy, and the durable reason and notice identify the harvested-deferral append. Repair + resume replays the composite carry with zero sessions, appending harvests before bundle closes; direct pre-terminal sweep defer carries and ordinary story carries retain the engine escalation route. -- Decision answers and Git publication: [answering](#answering-decisions), [stale answers](#handling-stale-answers), [store validation](#validating-decision-stores), [publication eligibility](#deciding-what-can-be-published), [refusal causes](#understanding-publication-refusals), [diagnostics](#reading-publication-diagnostics), and [recovery](#recovering-unpublished-decisions-and-bundles). -- The default Markdown diagnostic dump displays scrubbed sweep publication and `sweep-repeat-done` records from the same bounded collection as JSON, including their identity fields and presence booleans. -- Decision-bundle naming, and quarantine for a dropped decision (DW-118). A decision bundle whose own `bundle_name` is absent or discarded falls back to `decision-`, which is a fallback and not a reserved namespace — a triage plan may legally author a bundle of that exact name, and `_bundle_key` would then alias both onto one task and one intent directory, losing the human's bundle without a record. The final name is therefore made unique against the bundles already materialized this cycle (plan bundles and earlier decision bundles alike): a taken name gains a bounded numeric suffix (`-2` … `-9`, first free wins, journaled `sweep-bundle-name-deduped` with the resulting name). Exhausting the bound is the one point in name assignment at which a buildable answer yields no bundle — the decision is journaled as dropped (`drop_cause: name-collision`), notified with the contested name, and its entry stays open. Every drop lane — `no-intent`, `name-collision`, the keep-open lane's `stale-option` (DW-123), and the two ledger-fact lanes `effect-unlanded` (DW-200) and `entry-not-open` (DW-214) — also quarantines the decision for the rest of the sweep **run**, so a later `--repeat` cycle whose re-triage agrees with the answer again neither revives nor re-notifies it. Since DW-124 the quarantine is persisted run state (`sweep_dropped_decisions` on `state.json`, published immediately after the drop is announced), as is the unattended `decision-skipped-unattended` latch beside it (`sweep_skipped_decisions`): once state publication succeeds, pausing and resuming the SAME run no longer re-announces a disposition it already made, where before both were instance state and a resume started empty. Deliberately run-scoped and deliberately not the RUN-LOCAL answer: that answer stays on disk in `/decisions.json` — auditable by design, and untouched by every drop lane — while a NEW run re-evaluates every decision from scratch. What the keep-open lane's drop does now take is the PROJECT-level store entry (DW-143; see [stale answers](#handling-stale-answers)): the quarantine is what keeps one run from re-announcing, and pruning that entry is what keeps the NEXT run from re-reading the same stale answer and re-dropping it forever. A `state.json` written before DW-124 carries neither key and loads both empty, resuming exactly as it did before. -- Bundle keys agree on deferred-work ids, not just on names (DW-125). A bundle's task key is a pure function of `(name, cycle)` (`dw-`, or `dw-` from cycle 2), and a cycle skips a bundle whose key already names a task that has reached a terminal phase — that skip is what makes a resume cheap. Names, though, are re-authored freely by each triage: lose `/triage.json` on a resume and the regenerated plan partitions the still-open ids under whatever names it likes, so a bundle reusing a finished bundle's name used to be skipped with its work never run (the materialization-time uniqueness pass above cannot see this — it compares names against the CURRENT cycle's list, never against persisted state). The skip therefore now also requires the persisted task's `dw_ids` to agree with the bundle's. Agreement is SET equality, so a re-triage that merely reorders the same ids is still the same bundle and is still skipped; an EMPTY persisted list agrees with anything, since that is the shape a `state.json` written before bundles carried ids loads as, and reading it as divergence would re-run every bundle of every paused legacy run. Where the ids diverge, the bundle is new work that happens to want a taken name, and it gets the same bounded `-2` … `-9` suffix (first free wins, journaled `sweep-bundle-key-deduped` with the original and resulting names) — applied to the NAME rather than the key alone, so the key, the `/bundles//` directory, the `bundle_name:` line inside `intent.md` and the key→name round-trip a degraded intent rebuild runs all stay consistent. Exhausting the bound journals `sweep-bundle-key-collision` with the contested name and its ids and notifies the operator; the bundle is not run and its entries stay open for the next sweep, which is the loud direction — the alternative is the silently swallowed bundle this rule exists to prevent. Scoped to terminal tasks only: an in-flight task at the key still goes through the usual in-flight bundle recovery, never a rename. Where that recovery cannot finish the bundle and instead resets the task to PENDING for re-dispatch, the task additionally adopts the current bundle's `dw_ids` before its `intent.md` is written (DW-144), so the intent document, the task and the `bundle_closes_intended` the ledger close is derived from all name one set of ids — otherwise stale task ids can reject a dev result for the new bundle or supply the wrong ids to the ledger-close step. Adoption is unconditional; it is journaled `sweep-bundle-dwids-adopted` (with both the previous and the adopted id lists) only when the two diverge as sets, since a pure reorder is the same bundle by the rule above and is not worth announcing once per resume. An empty persisted list counts as divergence here — unlike in the skip rule, where it agrees with anything — because such a task genuinely has no ids and must take the bundle's. A DIVERGENT adoption drops the rest of the superseded bundle's state too (DW-162/163/165), since the task is no longer the one that bundle was minted for: its `bundle_closes_intended` (which would otherwise close ids this task never ran), its `spec_file` and `restore_patch` (which would make the dispatch prompt resume review of the superseded amended contract, and make `_record_dev_spec` — a no-op once set — refuse the replacement's own spec), and its spent retry and review counters (`attempt`, `review_cycle`, `followup_reviews_spent`). It also clears `defer_reason` and advances `generation` for fresh session ids, as a human-resolved re-arm does, so a replacement cannot inherit an exhausted review budget and force-converge or defer on its first round. `resolved_redrive` deliberately SURVIVES the clear: it records that a human resolved this task, not which spec the task owns. The clears are scoped to divergence alone — an agreeing or merely reordered re-dispatch is the same bundle the task already attempted, and keeps its budget, its spec ownership and its intended closes. -- A persisted bundle intent document is graded against the task that owns it, not merely checked for existence (DW-164). `task.bundle_file` is written before the adopted ids are saved, so a crash between the two left a task reusing a still-present `intent.md` whose `dw_ids:` line named ids the task no longer carried; re-ordering the two writes only inverts that pairing, so the document's ids are instead compared against `task.dw_ids` as a SET and the document is regenerated when they disagree. Regeneration is the same degraded rebuild used for a lost file (triage prose replaced by a note that the verbatim ledger entries are now the contract), and `sweep-intent-regenerated` carries a `regen_cause` saying which of `missing` / `dw-ids-mismatch` / `unreadable` applied — a closed slug rather than a free-text `reason`, so `bmad-loop diagnose` renders the cause itself instead of a presence boolean. A document with no `dw_ids:` line at all cannot be graded and so regenerates too. An EMPTY `task.dw_ids` is not an authority here — that is the pre-`dw_ids` `state.json` shape, and grading a real document against it would trade the bundle's actual brief for a rebuild naming nothing — so such a task keeps whatever document it has. Since DW-269 `intent.md` is written through `atomic_write_text_confined` against the project that owns the run dir: a link planted at any directory component below the project root (`.bmad-loop/`, `runs/`, the run dir, `bundles/` or `bundles//`) refuses with `UnconfinedWriteError`, which propagates as the write fault it is — no degrade arm, no ATTENTION notice (DW-243). -- Auto-sweep at epic boundaries or run-end (`[sweep] auto`); a failed or paused child sweep is journaled + notified and leaves the parent running — including the `SystemExit` an unusable multiplexer or an unresolvable profile raises, which used to end the parent at exit 1 with an orphaned session (#600). A stop (`bmad-loop stop`) or a Ctrl-C delivered through the child is the deliberate exception and propagates, so a parent stays stoppable while a child sweep is mid-flight (#601). Accepted residual (DW-194/202/210): a per-epic auto-sweep that ends on the ledger-in-doubt dispatch gate withholds its OWN bundles, but the parent run continues, and the parent's next story commit takes the same whole-tree `git add -A` — so it can publish the dirty ledger the child refused to. Both legs need a ledger TRACKED in the code repository (not the default shape) for that to be reachable, and repeating children report the repair through the existing ATTENTION notification; non-repeat children add no repair notification. Closing this residual would require a new child-to-parent signal and parent end state. Second residual, from the same entry: the gate is evaluated once, BEFORE dispatch, so a ledger that goes bad DURING a bundle is outside its reach — that bundle's own commit takes the whole-tree `git add -A` and publishes it. Closing that would mean re-checking the ledger at every commit boundary rather than at the cycle's, which is a different mechanism from this gate; the sweep's existing per-cycle refusals are what catch it on the following cycle. A child sweep stopped by a ledger fault is a plain RETURN since DW-197, so the parent records it as a child that ran: the trigger is spent and `sweep-auto-finished` is journaled, where the same fault previously raised through the child and reached the parent's `sweep-auto-failed` arm (a `failed` entry in `sweeps_refused`, plus a notification). That is the correct record — the child finalized its run dir and reported its own stop with its own repair notice; after repair, start a fresh `bmad-loop sweep` because the finished child cannot be resumed — but it means the PARENT's journal no longer announces a ledger fault the child already announced, and `bmad-loop diagnose` on the child run is where the stop token lives. -- A trigger is spent only once its child has actually started, so a refusal no longer consumes it (#501) — but nothing re-asks it either, since both call sites close their boundary within a few statements of the refusal. What the run keeps instead is a record: `sweeps_refused` (trigger → `not-started` / `failed` / `dirty`), surfaced by the end-of-run summary, `bmad-loop status`, `status --json` and `bmad-loop diagnose`, naming `bmad-loop sweep` (which needs a clean worktree) as the human-present follow-up. -- Repeat mode (`--repeat` / `[sweep] repeat`): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable, hits `max_cycles`, or could not read the ledger — either because the pre-answer prune refused it (`sweep-repeat-done` `reason="ledger-unreadable"` on undecodable bytes — DW-182/186 — or `reason="ledger-inaccessible"` when the OS refused the read — DW-197 — both counting the cycle that completed) or because `_loop`'s own top-of-cycle read did (DW-197: the read that drives migration and the whole write-bearing cycle, and the re-read after a migration; both journal `sweep-cycle-ledger-refused` with the same two tokens and the fault in `error`, then stop on that token counting `cycle - 1`, since that cycle did no work — the `legacy-appeared` convention). Bare, either loop read ended the run as CRASHED at the top of cycle N+1, discarding the report for cycles 1..N that had already completed. Every one of the seven stops (`no-open`, `no-progress`, `max-cycles`, `legacy-appeared`, `ledger-unreadable`, `ledger-inaccessible`, and `no-selected` — the repeat exit when the `--only` / `--min-severity` selector selects nothing on a cycle after the first) also writes its token as `stop_cause` beside `reason` (DW-201) — a closed slug rather than the free-text `reason`, which `bmad-loop diagnose` renders as a presence boolean, so a dump named no stop at all. `reason` is unchanged and still written. The `no-progress` and `max-cycles` stops, by contrast, now pass THROUGH the cycle-boundary ledger commit on their way out (DW-223): it sits above both returns, so a close whose own publisher already degraded on a git fault ahead of its commit attempt (a tree git could not read at that moment — a commit git was asked to make and refused ends the sweep instead) gets another best-effort publication attempt instead of being stranded solely by where the run stopped. That is also a widening — those two stops now reach git where they previously spawned none, and the ledger is published whole, so an out-of-band edit to it can ride along on the same accepted terms the nothing-open publisher carries. The prune-carried stops also WITHHOLD the cycle-boundary ledger commit, which is the point of them: that commit's pathspec is the ledger, so continuing would publish the bytes the prune just refused to read and the next cycle would crash on them anyway. The completed cycle's work is kept, the undecodable bytes stay dirty in the working tree for a human to repair, and, when file notifications are enabled and writable, an ATTENTION line says so — naming the ledger's own path, since `implementation_artifacts` is configurable to any absolute path and the ledger may be symlinked out of the project. Re-running `bmad-loop sweep` after the repair is the resume, but changes in the configured code repository (`repo_root`) have to be committed or stashed first: `sweep` checks that worktree for cleanliness. A ledger tracked only in an external repository does not affect this check. The journal stop record remains available even if notification delivery fails. They outrank `no-progress` and `max_cycles` (and `ledger-inaccessible` outranks `ledger-unreadable`, so the prune's own last-observed fault is what gets reported), and a run without `--repeat` (or under `--decisions-only`) is unaffected by the PRUNE's refusal: it stays a journal row, with no repeat-stop record and no ATTENTION line. `_loop`'s own read sits above that early return, so a fault there stops any run. An ABSENT ledger is not one of these stops (DW-176) — the next cycle ends cleanly on `no-open`. `deferredwork.read_for_write` wraps OS metadata/text-read faults as `LedgerReadFault(LedgerReadError)` with the original `OSError` chained (DW-279); the sweep handles that subclass before the decode parent to preserve `ledger-inaccessible`, the original OS attribution, and the permissions/storage repair notice. Its `stat` + `S_ISREG` probe (DW-221) preserves this refusal on Python 3.14, where `is_file()` had reported refused metadata as absence. Accepted residual (DW-197): the degrade covers the three reads that GATE a cycle — the prune and `_loop`'s two — and not the other `read_for_write` calls in `sweep.py` (`_ensure_migration`'s validation and restore re-reads, and `_run_bundle`'s read of the ledger for a fresh bundle's intent document), where an OS refusal still ends a run as crashed. Those sit inside phases whose own failure already ends the run, so degrading them would need a stop story of its own rather than this one's. A second accepted residual sits beside that one since DW-252: `_run_bundle` calls `_write_intent` bare, so a rival write that retires an entry between the keep-loop screen's read and the intent read raises `MissingLedgerEntriesError` and ends the run crashed with no journal row, where the old code briefed a thin document. The intent document's REGENERATION read on a resume, by contrast, PAUSES rather than stops (DW-243): `_finish_inflight_bundles` re-drives an in-flight bundle ahead of any cycle gate, and a bundle whose document was lost regenerates it off that read, which bare crashed the resume at that site. It now journals `sweep-intent-ledger-refused` (naming the bundle, the ledger, the same two tokens and the fault in `error`), notifies with the ledger path, a repair-and-COMMIT steer and the `bmad-loop resume ` route, clears the task's baseline pair and raises the story-gate pause on that task — no `sweep-repeat-done`, no new stop token, no new pause stage. A stop could not keep the re-drive promise: `_loop`'s return is persisted `finished`, which `resume` refuses; the pause leaves the run resumable, and `runs.unreadable_sweep_ledger` fronts that resume until the ledger reads again. Not a doubt latch either — the regeneration read has no verdict of its own to arm; the recovery pass IS withheld whole under an inherited doubt (`sweep-bundles-withheld`), and the regeneration only runs when it is not. The dispatch gate above reaches the two-token `sweep-repeat-done` stop — a cycle whose decision phase left the ledger in doubt withholds its bundles and ends here, on the same `ledger-unreadable` token, rather than through a stop of its own (DW-194/202/210). Since DW-216/217/220 the close phase's own fault, the re-apply gate's read refusal and the end-of-phase probe all reach that same gate and therefore this same stop and token — no new stop was added, because the repair is identical. The "could not be decoded mid-sweep" ATTENTION headline still belongs to the prune's undecodable arm alone, since it is the only latch that observed bytes nobody could decode; the three new legs report the generic "not fit to publish" wording, and a persistent OS refusal still reports `ledger-inaccessible` through the prune's own latch, which is read first. Since DW-218/219 a resumed run reaches this same stop on the SAME token off a verdict its own cycle never observed: the doubt is mirrored into run state when it is armed, so a stop or crash between the arm and the gate's report resumes into the same withholding rather than into a dispatch, and the resumed cycle withholds and stops even though its own close and decision phases run clean. No new stop, token or journal kind was added — the repair is identical. -- Ledger archiving (`sweep --archive`, optionally `--before DATE` to bound by close date; `--dry-run` previews): moves closed (`status: done `) entries to sibling `deferred-work-archive.md` (body preserved, an `archived: ` marker appended), leaving an id-preserving stub (`status: done ` + `archived: `) so grep and `closes_deferred` cross-references keep resolving and the live ledger stays proportional to open work rather than all history. The stub keeps load-bearing field lines — `gate:` (validate's closed-gate report keeps speaking), `origin:`/`source_spec:` (the harvest-replay dedupe stays status-agnostic), live `severity:`/`priority:` metadata (reopened work remains selectable by severity), and the reopenable-close undo tail (a paused sweep's bundle rollback still works). Reopening an archived stub — what that rollback does — demotes its `archived:` stamp to `archived-body:` rather than dropping it: the entry is live work again, so the stamp would be a lie and its shape would strand the entry outside every future archive, but the body its close moved out is still in the archive file and that line is what a later triage follows to it. Reopening a stub written before severity preservation recovers its severity from the stamp-matching archive block (DW-334/DW-394). Deterministic, no LLM, atomic writes with crash-safe ordering (archive before ledger; a retry keyed on id + close date completes the move without duplicate bodies). Refuses while any engine run is live or its liveness is unverifiable — it is the one out-of-band ledger writer. When the ledger is tracked, the move is durable only once both files are committed; a gitignored ledger — the default shape — or an artifact dir outside the repo has nothing to commit. Unrelated to `bmad-loop archive ` (run-tarball archiving). -- Sweeps are their own resumable runs (`bmad-loop resume `). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: `bmad-loop resolve --restore-patch ` re-arms the bundle spec to `in-review` and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing. -- Ledger writes serialize across processes (#286/#469). Every orchestrator mutation of `deferred-work.md` — an append, a close, a reopen, a recorded decision, `sweep --archive`'s two-file rewrite — holds an advisory lock for its whole read-modify-write, so a second `bmad-loop run`, a run plus a sweep, or a run plus the TUI decision modal can no longer both read, both edit, and let the last atomic write win (lost entries, silently reverted closures, two appenders minting the same `DW-`); multi-row work is batched into one locked pass rather than one per row. The lock is a sidecar under the state root (`/locks/-.lock`), never beside the ledger, because the ledger is tracked by design and the engine stages with `git add -A`; it is keyed on the resolved path, so every spelling of one file contends on one lock while two worktrees' in-tree ledgers correctly get their own. Readers stay lock-free — every writer already replaces the file atomically, so a reader sees one whole version or another. The wait is platform-asymmetric: POSIX blocks, while Windows bounds it at roughly ten seconds and then surfaces contention as an error rather than proceeding unlocked. A dev or review session's own ledger writes are deliberately outside this — the orchestrator sequences its writes against the sessions it dispatches. Here too the hold covers every read that decides the **published bytes** rather than every read: a mutator whose work turns out to be a no-op — ids already done, a decision on an entry that is not there, specs that all dedupe, nothing eligible to archive, or no ledger at all — is answered from one advisory pre-lock read running the same pure decision helper the locked pass runs, and takes no lock (#736), so a replayed rollback or a `sweep --archive` over a ledger holding nothing closed cannot fail on a lock it had no write to serialize. Only a would-write-nothing answer is acted on; anything else, and any fault while probing, falls through to the hold. `append_entries_published` deliberately keeps no missing-ledger answer — an absent ledger there means create, which is a write. The rollback paths that span a `git reset --hard` cannot be covered by a lock at all, so each is instead compare-and-set — and the **write** is anchored on the ledger's committed blob at the run's baseline commit, the text that reset republished, read out of git before the lock rather than off a working tree a rival could have written inside the reset window (#735); a ledger the reset republishes no text for — untracked, configured outside the repo tree, or symlinked into it, where the committed blob is a target pathname rather than ledger text — has no baseline text to anchor on, so the sweep anchors instead on the rejected rewrite the attempt itself graded, while the engine's two restores decline to read a missing file there as the reset's own work. A post-reset observation still authorizes the **skips** — declining to act is safe whoever wrote those bytes — and never a write. Divergence, and an anchor no probe could derive — an unreadable blob or a failed path resolution, both journaled `ledger-baseline-probe-failed`, or a run with no baseline commit, which stands down silently rather than filing a row an operator would have to triage — degrade each site in its own direction rather than writing over a concurrent writer: `defer-ledger-restore-diverged` merges by appending the entries disk has since lost, `ledger-restore-skipped-diverged` skips, and `sweep-migration-restore-diverged` escalates for a human to re-run the sweep. And a failed commit reopens exactly the entries the story itself closed, journaling `deferred-close-rolled-back` with their ids and `deferred-close-reopen-unmatched` for an entry whose undo marker a foreign edit has displaced. - -#### Answering decisions - -Use `bmad-loop decisions` (or `d` in the TUI) to answer skipped or missed -decisions reconstructed from past triage output. Only ids still open in the -ledger and without a usable project pre-answer are offered. The most recent -valid triage containing each decision supplies its options. - -Writing the answer and publishing it to Git are separate outcomes: - -| Effect | Ledger write | Project store and next sweep | -| ----------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `build` | Record a `decision:` audit line. | Save the chosen option's full semantics in `.bmad-loop/decisions.json`; a later sweep can create a bundle without re-prompting. | -| `close` | Record the audit line and mark the entry done in one transaction. | Write no project-store entry; the closed id leaves the open set immediately. | -| `keep-open` | Record a `decision:` audit line. | Save the answer in the project store; it suppresses bundles for the id while it remains applicable. | - -The run-local `/decisions.json` is a separate audit record. Interactive -sweep answers live there, including `close`, and persist through repeat cycles -and resumes of that run. Project pre-answers carry answers across runs. - -If the ledger file or entry disappeared while the prompt was open, -`record_decision` reports that no line landed. The CLI reports the non-write; -the TUI warns and excludes it from its recorded-answer count. Both continue, -and the command still exits 0. A `build` or `keep-open` answer is still saved to -the project store, but an id no longer open may be pruned without being consumed. -A `close` in this situation writes neither file and runs no Git command. - -A publication refusal does not undo an answer already written. See -[publication eligibility](#deciding-what-can-be-published) for which files may be -committed and [diagnostics](#reading-publication-diagnostics) for the separate -`not committed to git:` report. - -#### Handling stale answers - -Option keys are positions in a list that each triage may rewrite. Before using -a stored answer, the sweep checks its `label` and `effect` against the current -option selected by its key. This applies to project pre-answers and run-local -answers, including answers carried into another repeat cycle. - -If the option disagrees, none of its intent, label or bundle name is used. -`sweep-decision-option-mismatch` records the mismatch without label, question -or intent prose. A resulting bundle's decision note identifies an earlier -triage instead of quoting a question the human did not answer. When the option -agrees, the answer's own non-empty prose takes precedence; the option supplies -missing or empty fields. - -The effect determines what remains actionable: - -- **Build:** an answer carrying its own intent still supplies a bundle after an - option mismatch. Out-of-band build answers normally have that intent because - the project store saves the full chosen option. Without either its own intent - or an agreeing option's intent, the answer is dropped and the entry stays open - for a future sweep to re-ask. -- **Close:** for an id still asked by this cycle and still open in the ledger, - reapply the run-local close using the saved label. An agreeing option supplies - resolution/intent; a vanished or mismatched option still closes the entry with - bare `closed by human decision` and no rationale detail. -- **Keep-open:** when this cycle re-asks the decision, the answer needs a - resolvable, agreeing option. A renamed or vanished option drops the protection, - making the id eligible for bundling again. If fresh triage does not re-ask the - id at all, an existing run answer continues suppressing its bundles, unless - already quarantined by a drop this run. - -Every drop emits `sweep-decision-answer-dropped`, notifies the operator and uses -one of five `drop_cause` tokens: - -| Cause | Meaning and disposition | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `no-intent` | A build answer has no trusted intent; leave its entry open to re-ask. | -| `name-collision` | A decision bundle cannot obtain a unique name, including the bounded `-2` through `-9` alternatives; leave its entry open. | -| `stale-option` | A keep-open answer's option vanished or disagrees; remove its protection. A disagreement also emits the mismatch row; a vanished option cannot. | -| `effect-unlanded` | This run observed the interactive build answer's `record_decision` return false; do not build from an answer whose audit line never landed. | -| `entry-not-open` | The live ledger no longer holds an adopted build answer's id open, whether absent or already retired; do not build it. | - -All five drops count as [repeat progress](#sweep-repeat-mode). The id is -quarantined in persisted `state.sweep_dropped_decisions`, so the same run neither -revives nor re-announces it after a later triage or resume. This bounds progress -to one drop per id per run. The run-local answer and any ledger audit line stay -untouched; a new run evaluates its answers anew. - -For `effect-unlanded`, the observed non-write is first persisted in -`state.sweep_unlanded_decisions`. That verdict survives an interruption before -the drop can be announced. Announcing the drop removes it from this list in -the same state publication that adds the quarantine. When both ledger-related -causes apply, `effect-unlanded` takes precedence over `entry-not-open`. Older -state files without these lists load them empty. - -Only a dropped `stale-option` keep-open answer is immediately removed from the -project store. If an entry was actually removed, -`sweep-decision-preanswer-pruned` records the id and drop cause, and the store -edit is published best effort. This makes the decision available again through -`bmad-loop decisions` and prevents the next run from reloading the same stale -protection. Other drops leave their project answers intact at this point; the -ordinary end-of-cycle prune retires answers for ids no longer open. - -#### Validating decision stores - -Both readers use the same answer-shape predicate, parameterized by store. A -project answer rejected by that predicate does not count as answered for -`bmad-loop decisions`, so an otherwise eligible id is re-offered. Shape-valid -answers dropped later for missing intent or name collision are outside this -guarantee. - -- Each value must be a JSON object with a recognized `effect`: `build`, `close` - or `keep-open`. `close` is accepted only from the run-local store; the - out-of-band writer applies closes directly to the ledger and never stores - them in the project file. -- `key` and `label` must be strings **when present**, for every effect. - `intent` and `bundle_name` must be strings **when present** for `build` only. - Missing optional fields remain valid at this screening step; later - materialization determines whether the answer has enough trusted intent. -- Extra fields are not screened. In particular, `resolution` and `answered_at` - are not trusted answer inputs. A resumed close takes its resolution from the - agreeing current option, not from an answer's `resolution` field. Build-only - fields on a keep-open answer are also ignored. - -An unusable value is excluded from the in-memory answer map and reported as -`sweep-decisions-reload-failed`, naming its id, store, field/type or effect -problem without answer prose. Valid neighboring answers remain usable, and -unanswered decisions return to the pending/skip path. The refusal itself does -not repair or trim the file: run-store write-backs preserve unusable values -unless replaced by a new answer. A project-store `close` likewise remains on -disk until repaired; its rejection makes the id reachable to re-answer. - -Whole-file faults have different dispositions: - -| Store/read result | Disposition | -| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project store missing, unreadable, invalid JSON, invalid UTF-8, non-object, or a non-file | `load_pre_answers` returns an empty map. This tolerant loader does not journal the whole-file fault. | -| Run store invalid JSON/UTF-8 or non-object | Journal `sweep-decisions-reload-failed`; a later write-back replaces the corrupt file with the available answers. | -| Run-store metadata or content read raises `OSError` | Journal the reload failure; withhold the seeded write-back for that cycle (`sweep-decisions-store-write-withheld`) and do not put the interactive prompt at all (`sweep-decisions-prompt-withheld`, ATTENTION notice; the decisions stay pending). The hidden bytes may contain valid answers, and an answer that cannot be persisted is not taken. | - -A malformed project value considered for seeding also produces the reload-failed -row, with the project-store suffix. A missing run store starts empty; a -non-regular run-store target does not pass its read probe. Seeded write failures -and interactive write failures have distinct handling, described in -[run-directory bookkeeping](#sweep-run-directory-bookkeeping). - -Project-store writers serialize their whole read/edit/write transaction under -a cross-process lock keyed on the store path. The ledger uses a different lock; -callers must not hold both at once. Readers remain lock-free because writes -replace the file atomically, and Git runs outside the locks. - -A prune with nothing to remove, or a drop for an id absent from the store, takes -no lock and leaves bytes and mtime untouched. An unreadable project store also -looks empty to these no-op probes. Real writes can fail while acquiring the lock, -for example when no state root can be derived or a Windows sidecar is contended; -those write failures propagate. - -#### Deciding what can be published - -The out-of-band publisher and sweep publishers name the file they wrote, then -resolve it and run Git from that target's parent directory. The repository is -therefore the one holding the file: the project, a disjoint `repo_root`, or a -repository reached through a configurable `implementation_artifacts` path or -ledger symlink. The project itself need not be a Git repository. - -Each publish is file-scoped. The sweep's dirty check and commit use only that -file's pathspec, preserving unrelated in-flight changes elsewhere in the -repository. The whole ledger file is published, however: unrelated edits inside -that same file can enter its commit, including during recovery of an earlier -close. Resolution and validation narrow the race before staging; they do not -lock the target against a later replacement or disappearance. - -Each publisher applies its own eligibility rule before the shared target guard: - -| Publisher | Eligibility | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Out-of-band answer | Ledger only if a `decision:` line landed; project store only for `build`/`keep-open`, which wrote it. An empty operand list runs no Git. | -| Already-resolved close phase | At least one close landed, or no close landed this pass but every already-resolved id in the plan is provably done on disk. Both arms honor ledger doubt. | -| Decision phase | At least one effect landed and neither the phase nor the run retains ledger doubt. A no-write phase does not publish merely because the ledger is dirty. | -| Stale-answer or end-of-cycle project-store prune | Publish the store only when the prune removed an entry. The ordinary prune first requires a readable ledger and honors ledger doubt. | -| Nothing-open recovery | This cycle's valid triage cache must prove a landed close, with no ledger doubt. File dirt alone does not qualify; see [recovery](#recovering-unpublished-decisions-and-bundles). | -| Post-recovery | A nonempty in-flight bundle recovery pass and no ledger doubt; publish any resulting ledger dirt before triage. | -| Repeat-cycle boundary | No landed-write gate: after passing the ledger-fault/doubt stop checks, publish any dirty ledger, including out-of-band edits in a skip-only terminal cycle. Non-repeat, decisions-only and no-open exits return earlier. | - -The prune's ledger refusal is about the ledger it needs to inspect, not the -store being published. See [ledger doubt](#sweep-ledger-doubt) for withholding -and dispatch consequences, and [target refusals](#understanding-publication-refusals) -for validation after eligibility. - -Out-of-band operands publish separately, each in its own resolved parent, under -`chore(decisions): pre-answer `. A refused, ignored or non-Git operand cannot -sink its sibling. If both files need publishing in one repository, this can -produce two commits carrying the same message. The pair is not atomic: an -interrupt or a hook failure can leave only one committed, and hooks may run -twice. Both answers were already written on disk, where the next sweep reads -them. Successful publication into two different repositories is an ordinary -success and adds no warning. - -#### Understanding publication refusals - -`verify.unpublishable_target` is shared by out-of-band answers and sweep -publication. Each caller declares the family, `ledger` or `store`; the guard -does not infer it from a filename. A refused operand reaches neither the dirty -check nor `git add`; other eligible operands can still publish. - -| `refuse_cause` | Meaning | Repair to consider | -| -------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `target-absent` | The resolved target is absent under the family's probe rules. | Restore the intended file or correct its path. | -| `target-not-a-file` | A present target has an unacceptable type, such as directory, FIFO, device or socket. | Restore a regular file at the intended target. | -| `target-undecodable` | The ledger's repair/write read could not decode its UTF-8 bytes. The store never emits this cause. | Repair the ledger contents as valid UTF-8. | -| `target-unreadable` | A read or metadata probe failed with an OS fault; out-of-band publication also uses this for resolution faults. | Inspect the reported error and repair the path, permissions or storage. | - -The ledger guard takes the ledger's own repair/write read. When that answers -absence, a `stat()` probe distinguishes a missing target from a present -non-file. An empty, present UTF-8 ledger is publishable. This gate checks -readability and type, not whether every ledger entry is semantically valid. - -The store guard takes one `lstat()` of the resolved argument. It accepts a -regular file **or a residual link entry**, and never validates the contents: -invalid JSON or non-UTF-8 bytes in a regular store remain publishable. A store -symlink to a regular file resolves to that file; a symlink to a directory is -refused, and a dangling link resolves to an absent target. - -The residual-link exception matters for symlink loops: on Python 3.13+ a loop -can survive non-strict resolution as a link entry, which the store guard -accepts while the ledger reader reports `target-unreadable`. On Python -3.11–3.12 the resolve itself raises and follows the publisher-specific fault -route below. Store-write confinement still refuses writing through a link at -the store's own name; publication eligibility is a separate check. - -Probe classification also differs from resolution: - -- The ledger reader and store guard share `deferredwork.probe_absence`. - `ENOENT`/`ENOTDIR`, Windows errors 21 (`ERROR_NOT_READY`), 123 - (`ERROR_INVALID_NAME`) and 1921 (`ERROR_CANT_RESOLVE_FILENAME`), and a probe's - `ValueError` for a non-encodable path are treated as absence. -- Other OS faults, including `EACCES`, `EIO`, `ESTALE`, `EBADF` and `ELOOP`, are - unreadable faults. Explicit `stat`/`lstat` probes expose these rather than - relying on interpreter-dependent boolean path predicates. -- Resolution runs first. Both publishers catch `OSError`, `RuntimeError` and - `ValueError` (including encoding-error subclasses). The sweep journals - `sweep-ledger-commit-unavailable`; a ledger operand also arms persisted ledger - doubt. The out-of-band publisher reports `target-unreadable` with the error. - -`commit_paths` has a distinct uncertainty policy: only `ENOENT`/`ENOTDIR` -mean absence; other probe faults omit that candidate and raise if no usable -operand survives. It deliberately accepts a missing tracked file as a deletion -to stage. The publisher gates prevent an absent ledger from reaching that path -and being committed away under a decision or sweep message. They also keep an -undecodable ledger out of HEAD and prevent a directory pathspec from staging -its descendants recursively. - -Refusal is best effort and does not itself raise or roll back the on-disk -answer. A sweep ledger refusal arms ledger doubt; the surrounding loop still -applies its own read and stop rules. Ahead of the commit attempt, only a ledger -whose directory is in no git repository (and the store, on any Git fault) is also -reported best effort; a ledger `git status` that times out, fails to spawn or -cannot read the index ends the sweep (DW-336). A ledger commit git -was ASKED to make and refused (a hook, an unwritable index, a full disk) is not: -`path_clean` has already answered for the tree by then, so the publication itself -failed, and the sweep ends loudly rather than letting the cycle's bundles run -against the dirty baseline the commit was meant to clean. The store's two prunes -keep degrading on an attempted commit (DW-160): the on-disk record is what -matters for a pre-answer, and re-dropping later is cheap. -Journal-write failures continue to propagate. - -#### Reading publication diagnostics - -Every sweep `_commit_ledger` journal row carries `file`, the lexical basename -`deferred-work.md` or `decisions.json`, rather than an operator-chosen symlink -target name. The resolved `repo` directory, when available, is the file's parent, -which may be a subdirectory of its Git repository. A resolve failure reports -the lexical parent because it could not obtain the resolved one. - -| Journal kind | Meaning | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `sweep-ledger-commit` | A commit was made; the row includes its SHA. | -| `sweep-ledger-commit-clean` | The path was already clean, or the commit found nothing to do after the dirty check. Nothing was published. | -| `sweep-ledger-commit-refused` | Target validation refused the operand; `refuse_cause` gives one of the four tokens above, with `error` for decode/OS faults. | -| `sweep-ledger-commit-unavailable` | Resolution failed, or the ledger's directory is in no git repository (any Git failure, for the store); the row names the file, directory, message and error. | -| `sweep-ledger-commit-withheld` | An earlier ledger-doubt verdict prevented a gated publication. Recovery probes can include the ids whose writes were not proved. | - -An ignored path can appear clean. The clean row does not distinguish ignored -from already-published content, and no `git check-ignore` is run. It makes a -publisher that ran and published nothing visible alongside successful commits. - -`bmad-loop diagnose` retains publication identity fields and refusal tokens; -free-text directory, message and error fields become presence booleans. Its -default Markdown dump and JSON use the same bounded collection, including -`sweep-repeat-done` records. See [diagnostic output](#machine-readable-output---json) -for the surrounding contract. For an affected sweep's raw error, inspect -`.bmad-loop/runs//journal.jsonl`; see [run state](#resumability--state). - -Out-of-band publication has no sweep journal. `PreAnswerResult.publish_note` -adds `not committed to git:` to the CLI outcome and produces a TUI warning toast. -Each entry names the lexical file and its refusal cause, or `commit-unavailable` -for a Git failure. Git's error is collapsed to one line and clipped. - -Publication refusals and Git failures do not change the recorded-answer count, exit code or -continuation to the next decision; a ledger non-write is reported separately as -described under [answering](#answering-decisions). Follow the -[recovery steps](#recovering-unpublished-decisions-and-bundles) to repair an -unpublished answer. - -#### Recovering unpublished decisions and bundles - -**Repairing an unpublished answer.** Inspect the named file and refusal/error -and repair its contents or path as needed. If its resolved target belongs to a -Git repository, commit the intended file there. For an intentionally non-Git -location, `commit-unavailable` is expected and the on-disk answer remains usable. -An answer already written remains available even if Git failed. - -Re-answer an unusable project-store value through `bmad-loop decisions` when -offered. Choosing `build` or `keep-open` replaces it with a usable answer; -choosing `close` records the ledger closure without writing a project-store -value. A stale keep-open drop already removes the project entry so it can be -re-offered. - -**Publishing a close left on disk by an interruption.** The already-resolved -phase can publish a pass that closed nothing when every planned already-resolved -id is now provably done. It emits no `sweep-resolved-closed` row for that no-write -pass. An absent id or unparseable status proves no write; file dirt alone does -not qualify. - -When the last open entry was closed, the loop would stop before reaching that -phase. Immediately before its nothing-open stop, it therefore checks this -cycle's on-disk `triage.json` (cycle 1) or `triage-.json` cache. Either of two -separate views of the per-id repair/write reader can authorize publication: - -- **All** cached already-resolved ids are done, reflecting a batch close. -- **Any** cached decision id is done, reflecting effects applied one at a time. - -These are separate tests combined with OR. An absent or unparseable id proves -nothing for itself; it does not veto another decision id's proven close. Empty -sets authorize nothing and take no read, and the decision set is read only if -the already-resolved set did not prove a write. - -The cache must validate and name at least one relevant id. A fresh sweep has no -cache, and an earlier cycle's cache cannot authorize this cycle's publication. -The recovery probe reads the cache directly without dispatching a session. -Unreadable or invalid cache data journals `sweep-triage-reload-failed`; a -ledger-probe fault journals `sweep-resolved-close-unavailable`. Both publish -nothing and keep the same stop outcome. - -Ledger doubt vetoes the entire recovery publisher before either proof — both -terms, not only the decision one, since the commit is of the file and a stranded -already-resolved close beside an unaudited `done` flip would otherwise carry it -into HEAD — emitting `sweep-ledger-commit-withheld` with all relevant ids. An empty plan produces no -proof read or withheld row. When publication succeeds its row precedes the stop; -when doubt is armed the ledger-repair notice follows the stop. - -See [ledger doubt](#sweep-ledger-doubt) and [repeat stops](#sweep-repeat-mode): a -finished sweep requires a fresh `bmad-loop sweep` after repair. Persisted ledger -doubt survives resume: repair the ledger by hand, commit or stash changes in -the code worktree so it is clean, and start a fresh sweep. Under an inherited -doubt the resume's in-flight bundle recovery pass is withheld whole — a bundle -`bmad-loop resolve` re-armed is not re-driven, since its commit is a whole-tree -`git add -A`, and is left in flight (journaled `sweep-bundles-withheld`, then -named by the stranded-bundle warning) — and the legacy migration is refused on -the doubt's own `ledger-unreadable` stop and repair notice before any rewrite -session is spent, since it would normalize and commit the very bytes every gate -below withholds. An ordinary paused -intent repair resumes the same un-finished run when it carries no inherited -ledger doubt. - -**Screening bundles against live open work.** Before an adopted build answer -creates a bundle, materialization consults the ledger's live open set. This is -one lazy repair/write read per materialization, shared with the final check of -plan-authored and decision-created bundles. It is skipped when no candidate -reaches either check. - -If the ledger is absent, undecodable, inaccessible or already in doubt, the -screen is disabled and one `sweep-decision-open-set-refused` row names -`ledger-absent`, `ledger-unreadable`, `ledger-inaccessible` or `ledger-in-doubt`. -Decode and OS faults include `error`. This refusal neither writes nor runs Git, -and does not itself arm ledger doubt. Existing answer dispositions and other -dispatch gates still apply; unknown open work is not treated as an empty set. - -With a usable open set, an adopted build answer for a non-open id takes the -`entry-not-open` **drop** above. A bundle reaching the final check must have -**all** ids open; otherwise it is **skipped whole**, never trimmed, under -`sweep-bundle-skipped` with `reason: entry-not-open` and the non-open ids. -`failed-or-escalated-earlier` and `human-chose-keep-open` checks retain precedence. -A bundle skip has no `drop_cause`, adds no quarantine and is not repeat progress. -A cycle containing only such skips stops on `no-progress`; its still-open work -waits for the next sweep. - -**Restoring a paused bundle's intent.** `_write_intent` refuses a missing ledger -entry with `MissingLedgerEntriesError` before creating the bundle directory. -A present but closed entry can still be reproduced verbatim. On resume, -regeneration refuses an absent ledger or missing entries under -`sweep-intent-regen-refused`, with `reason: ledger-absent` or `entry-missing` -and the affected ids. Decode and OS read faults use -`sweep-intent-ledger-refused` instead. - -The refusal notifies with `bmad-loop resume `, clears the task's baseline -pair and pauses at `run-paused`, `stage=story-gate`. The task remains PENDING with -its name, ids and attempt intact; the run remains un-finished, and no later -in-flight bundle or fresh triage proceeds. - -Restore the ledger/entries, repair read faults and **commit the fix** when the target belongs to a Git repository, -then resume the same run to regenerate its intent and re-drive the task, -provided it carries no inherited ledger doubt. See [run state](#resumability--state) for the -persisted artifacts. - -### Stories mode (folder+id dispatch) - -- Opt-in second story source (`[stories] source = "stories"` + `spec_folder`, or `bmad-loop run --spec `): drives the same loop off a typed `stories.yaml` (a `bmad-spec` Story Breakdown, sibling of `SPEC.md`) instead of `sprint-status.yaml`. -- Dispatches each entry by **folder + id** (`/bmad-build-auto Spec folder: . Story id: .`, spelled with whichever primitive name resolves on disk); the story spec lands at `/stories/-.md` and is read back by a deterministic id-keyed glob — no shared board to line-edit, no result-artifact mtime-scan. -- Strictly linear schedule (list order, no `depends_on`); `done` skipped, non-terminal statuses resumed on re-dispatch, `blocked`/sentinel/ambiguous stops the run for resolve. `bmad-loop run --dry-run --spec ` and `bmad-loop status` print the schedule/board (id · live disk state · checkpoint markers · title). -- Preflight content-probe: stories mode requires a dev primitive new enough for folder+id dispatch, or the run aborts with remediation. Sprint mode keeps working with any installed version. -- Sentinel recovery: a pre-planning-halt sentinel spec (`-unresolved.md` / `-ambiguous.md`) is auto-deleted with a preserved copy under the run dir on re-arm, matching the contract's delete-to-retry. - -### Gates & human checkpoints - -- Gate modes (`[gates].mode`): `none` (fully unattended) / `per-epic` (pause at epic boundaries, default) / `per-story-spec-approval` (pause after each spec for approval). Note: `per-epic` is inert in stories mode — the flat `stories.yaml` list has no epics, so the boundary never fires; use the per-story checkpoints (below) or `per-story-spec-approval` there. -- `[gates].on_escalation` is reserved: `pause` is its only documented value and the default. It is parsed but has no effect — CRITICAL escalations always pause, whatever it is set to — and the settings screen does not show it (DW-376). -- Per-story checkpoints (stories mode): independent `spec_checkpoint` (pause before code to review the plan; approve → implement, or request a replan) and `done_checkpoint` (pause after the story commits; skipped when it is the last story). Additive to `gates.mode` — a story can pause twice. -- Every mid-run pause is surfaced in the TUI: a per-run pause-kind badge, a global attention count, and a `p` viewer per stage (plan-checkpoint spec review, story-checkpoint summary card, escalation with story context, gate spec review) — all calling the same CLI code paths. -- Retrospective handling (`retrospective = never | notify | auto`) on epic boundaries and at run end. `notify` sends one "retrospective suggested" nudge; `never` is silent. To give `bmad-retrospective` the run journals and per-session logs as its session-log evidence, add the `_bmad/custom/bmad-retrospective.toml` override described in the [setup guide](setup-guide.md#pointing-retrospectives-at-run-journals). bmad-loop doesn't ship it (DW-387). -- `auto` (DW-389) runs one headless `/bmad-retrospective -H ` session on the `retro` adapter role (`[adapter.retro]`; unset keys inherit from the base `[adapter]`, like every stage) at each epic boundary. It runs after `pre_epic_boundary` and before the per-epic auto-sweep, so the sweep sees the committed retro and its action items. The prompt tells the session to write `tasks//result.json` (`{"workflow": "bmad-retrospective", "status": "done"|"blocked"}`) and end its turn. The session completes only on its Stop event or window death. Success is judged deterministically, and all four checks must hold: the session completed, its result.json says `done`, sprint-status reads `epic-N-retrospective: done`, and at least one regular `epic-N-retro-*.md` exists in implementation artifacts. The skill writes the board; the orchestrator only reads it back. On success, the dirty members of {sprint-status, the retro docs} are committed as `chore(retro): epic N retrospective`. After the session, success or failure, any path still dirty pauses the run at the epic boundary (`retro-auto-dirty`, up to 10 paths named in the reason) without advancing the epic, so the next story's rollback cannot destroy or absorb it. Resume re-enters the boundary, and an already-done retro is not re-run. The session is not a story: it never counts toward `--max-stories`. A failed retro is not retried within one pass of the boundary; a resume after a `retro-auto-dirty` pause re-enters the boundary (re-journaling `epic-boundary`, re-firing `pre_epic_boundary`) and re-dispatches the retro unless the board already reads done. - - Journal rows: `retro-auto-start`, then `retro-auto-finished` (`docs`, `verdict`) or `retro-auto-failed` (`errors`, one per failed check; `error` when the session raised). A commit `GitError` journals `retro-auto-uncommitted`. Pre-dispatch refusals journal `retro-auto-skipped` with a `reason`: `already-done` (silent), `board-unreadable`, `stop-requested`, `dirty` / `git-error`, or `skill-missing` (no `bmad-retrospective/SKILL.md` in the retro adapter's skill tree). Every refusal except `already-done` notifies you to run `/bmad-retrospective` by hand, as does a failure. bmad-loop does not install the `bmad-retrospective` skill. - - The verdict is surfaced, not gated (DW-487). The skill marks the board `done` whatever the verdict, so a succeeded retro's `verdict` is read from the newest `epic-N-retro-*.md`'s frontmatter `verdict:` (by mtime, so a leftover doc from an earlier attempt does not decide it): `accepted`, `accepted-with-open-items` or `rejected`, the skill's closed vocabulary, matched case-insensitively. It rides `retro-auto-finished` and the done notice (`…; verdict: `). A `rejected` verdict also sends an `ATTENTION` line (`epic N retrospective rejected`) naming the doc and saying the run continues. No frontmatter, no `verdict:`, an unknown spelling or an unreadable doc journals `verdict: unknown`, never an acceptance. No verdict pauses the run. - - Run end is a trigger too (DW-488). A boundary fires only when a story of a different epic is picked, so a run's last epic (every epic of a single-epic run) reaches the gate when the run ends instead: when the queue is exhausted, before the run-end auto-sweep, and when `--max-stories` stops the run. It fires for the epic of the last story the run dispatched, and only when every story of that epic reads `done` on the board and `epic-N-retrospective` does not. `auto` runs the same retro session as a boundary, with the same checks, commit, and dirty-tree pause; `notify` sends the same nudge. A run truncated with the epic's stories left fires nothing, and neither does a pause, stop, or crash. The gate journals `retro-run-end` (`epic`) when it fires, or `retro-run-end-skipped` with a `reason`: `epic-incomplete`, `already-done`, or `board-unreadable`. `never` journals nothing. Stories mode has no epics, so it never fires. - -### Multi-CLI / multi-agent support - -- Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — tmux ships bundled (with an experimental native-Windows `psmux` backend alongside it), and external backends (e.g. the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr)) co-install as packages that self-register ([how](multiplexer-backends.md)), behind a pluggable seam that lets a new backend slot in without touching the engine. The CLI axis has the same seam: a new adapter **class** registers via `register_adapter` and arrives through the `bmad_loop.adapters` entry-point group, with its selecting profile through `bmad_loop.profiles` — so an out-of-tree adapter family needs no core edit either (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). -- The OS is abstracted by a **registry of seams**, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (`register_multiplexer`, with availability-aware selection: env var → persisted `[mux] backend` via `bmad-loop mux set` → platform default → first available platform match), the process-lifecycle `ProcessHost` (`register_process_host` — `terminate`/`force_kill`/`is_alive`/`identity`), and the hook interpreter (`ProcessHost.hook_interpreter()`); `bmad-loop validate` runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see [Porting bmad-loop to a new OS](porting-to-a-new-os.md). -- Supported, E2E-verified: `claude` (reference), `codex` (≥ 0.139), `gemini` (≥ 0.46), `copilot` (GitHub Copilot CLI ≥ 2026-02 — the `copilot` binary, not the VS Code extension; `agentStop` turn-end, `-i` interactive launch, `--allow-all-tools`; pin a capable model — the free GPT-5 mini default is unreliable for multi-step skills). -- Supported, E2E-verified over HTTP/SSE (no tmux window): `opencode` (OpenCode ≥ 1.18, profile `opencode-http`, alias `opencode`) — one headless `opencode serve` per session, SSE `session.idle` completion with an HTTP poll fallback, per-session server password, token usage read back over the API. Hookless (`[hooks] dialect = "none"`, no hook registration). With no pane to replay, the run logs split three ways: a curated readable transcript in `logs/.log` (agent/user prose, tool calls, slash commands, file edits, permission asks/replies, errors), the server's own stdout in `.server.out`, and a structured SSE trace in `.sse.jsonl`. Install the extra (`pip install 'bmad-loop[opencode]'`), auth once globally (`opencode auth login`), and set `model` as `provider/model`; the Unity plugin's window guards don't apply (there is no window). -- Experimental: `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 works only through workspace-trust seeding, which requires the project root to be trusted first ([#169](https://github.com/bmad-code-org/bmad-loop/issues/169), DW-390). 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]`, `[adapter.retro]`). -- 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. -- `extra_args` (`[adapter]` or `[adapter.]`) **replaces** the profile's `bypass_args` rather than extending it, so an override must repeat the profile's permission-bypass flags (e.g. claude's `--permission-mode bypassPermissions`) or an unattended session can stall on a permission prompt. A stage that switches client inherits no `extra_args` and falls back to that client's `bypass_args`. `bmad-loop validate` reports `policy.bypass-dropped` per affected role (a warning, exit code unchanged) and every `--dry-run` preview prints a stderr `warning:` line per role its run type launches (sprint/stories: dev + review, plus retro for a sprint run under `gates.retrospective = "auto"`; sweep: triage + dev + review), stdout unchanged; a real `run`, `sweep` (auto-sweep children included) or `resume` prints the same lines before the engine starts and journals one `bypass-dropped` event (`role`, `profile`, `missing`) per affected role, rc unchanged; tmux generic-adapter profiles only (DW-349, DW-410). -- Add a CLI without touching Python: drop a TOML profile in `.bmad-loop/profiles/.toml` (binary, prompt template, bypass flags, hook dialect, native→canonical event map). A CLI that needs its own adapter _class_ still needs Python — but not a core edit: the profile's `adapter` field names a kind resolved against the registry, which a co-installed package extends. -- `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). - -For Codex, `validate` and `probe-adapter` ask Codex's read-only `hooks/list` API -whether the configured SessionStart and Stop relays are enabled and trusted. -Stale, missing, or unverifiable hook trust is a failing result. A worktree run -uses a different directory, so `validate` cannot certify its future trust from -the main checkout. Instead, an isolated run queries Codex's trust for the exact -worktree before any Codex session starts there, using the binary and stage -`extra_args` that session will launch with: once for every Codex dev or review -stage when a unit is dispatched, and again before each Codex session in a -worktree, including the sessions a resumed run drives in an already-mounted unit -(whatever isolation policy the resumed run loaded). Sessions in the main checkout -are not queried. A failed `hooks/list` query (Codex could not be spawned, or timed -out) is retried once; a real verdict is not. Anything but trusted escalates the -unit (CRITICAL, run paused) before the session starts, and the worktree stays -mounted at its deterministic path. Each worktree path needs its own Codex grant, -so Codex with `isolation = "worktree"` pauses once per unit. To recover: open -Codex in that worktree, accept its hook trust prompt, then run -`bmad-loop resolve --no-interactive` to re-arm the escalated unit and -`bmad-loop resume ` to drive it (a bare `resume` leaves an escalated unit -escalated). The re-arm restarts the unit from its baseline, so work done before a -mid-drive escalation (e.g. a finished dev session ahead of a review launch) is -redone. An `unverifiable` verdict usually needs a fix first: stage -`extra_args` or profile `launch_args` that may move hook discovery, a Codex binary -missing from PATH, or an unreadable hook config. bmad-loop never writes Codex's -own trust state. A live probe checks its temporary hook directory before launch; -a fresh directory without a Codex trust grant stops with a hook-trust diagnostic. -Profile or stage arguments that can change Codex hook discovery make the trust -verdict unverifiable rather than certifying a different launch configuration. - -### Budgeting & cost tracking - -- Mid-session per-session token budget (`max_tokens_per_session`, default 4M weighted): both adapter wait loops sample cumulative usage on the ~30s heartbeat and trip once on crossing, per `session_budget_mode` — `warn` (default) raises an ATTENTION + lifecycle breadcrumb only; `enforce` also sends a wrap-up nudge, grants `session_budget_grace_s` (default 240s) to finish, then terminates the session `over_budget` (ordinary retry→defer routing; an artifact flushed at kill time is still honored). Sampling is live-verified on `claude` and best-effort on other transcript-reading profiles (two independent unknowns there: whether the CLI delivers the transcript path early — until a hook event carries it the guard is inert — and whether it flushes usage mid-turn); the nudge into a busy pane is best-effort everywhere (the termination is the guarantee), and adapters with no mid-session usage signal (`usage_parser = "none"`, Copilot's shutdown-only flush) leave the guard inert; for `claude` the sampled total counts each API response once and includes the session's subagent transcripts (#836). -- Per-story token budget (`max_tokens_per_story`, default 2M weighted, advisory) on the same cost-weighted total — cache reads counted at `cache_read_weight` (default 0.1, matching ~0.1x vendor billing). Cumulative spend is re-checked at **every session boundary**, so an overrun surfaces while the story is still running and regardless of how it ends. The first crossing raises one ATTENTION + desktop notice (`story token budget exceeded: `) and a `token-budget-exceeded` journal entry carrying `weighted`, `total` and `budget`; the warning is latched per story and persisted, and nothing is terminated. Every operator-facing total leads with the weighted figure and labels both units — the run-finished summary and `bmad-loop status` each lead with ` weighted` and name the raw count as `( raw incl. cache reads)`, the TUI pairs weighted and raw columns, and `session-end` entries carry `tokens` beside `tokens_weighted`. -- Token usage read from each CLI's local session transcript (per-profile `usage_parser`), aggregated per story (`bmad-loop status`). - -### Configuration (`.bmad-loop/policy.toml`) - -- Single policy file written by `init`, stamped into the run at every engine start — `run`, `sweep`, `resume` — so it always describes the policy that process enforces (applies to new runs and resumes; editable live from the TUI). -- Rewrites of it are confined and permission-honoring (#593, #597). Such a write walks the components below the project no-follow and lands through the directory handle that walk produced — `O_DIRECTORY|O_NOFOLLOW` opens with `dir_fd` on POSIX, `NtCreateFile` relative to the handle above with reparse points refused on Windows (`win32_at`) — so a symlink or junction planted at `.bmad-loop/` is refused rather than followed; refusing a link at the file alone never covered its parent, and `mkdir(parents=True, exist_ok=True)` accepts a symlinked directory. A host with neither arm degrades to a documented check-then-write; `init`'s one-time seeding of a missing policy predates any session and stays a plain write. A `policy.toml` an operator marked read-only is refused with a `PermissionError` instead of being replaced and left still reading `0444`. The confined walk also covers story specs inside the checkout, park records, the decisions store, and the sweep's triage cache and bundle intent document (DW-269); the read-only refusal reaches further — story specs, `sprint-status.yaml`, park records, the decisions store, hook `settings.json` — but `sprint-status.yaml` deliberately keeps its symlink-following writer (an operator may keep the board behind a link) and the hook-settings and worktree-provisioning writers keep their own pre-existing link checks rather than the descriptor walk. The read-only refusal deliberately skips machine-minted state (run archives, stop requests, the config-digest stamp) — those are channels, not operator signals. -- Sections — all 16: `[gates]`, `[limits]`, `[verify]` (+ `env_fault_rc`), `[environment]` (operator health probes + `probe_timeout_s`), `[notify]`, `[review]`, `[stories]` (which planning pipeline drives the loop: sprint-status or a typed `stories.yaml`), `[dev]` (see below), `[adapter]` (+ per-stage `[adapter.dev|review|triage]`), `[sweep]`, `[scm]` (worktree isolation + merge-back), `[cleanup]` (run-dir retention + disk reclamation), `[plugins]` (the builtin `[python]` trust allowlist — project plugins are granted out-of-tree with `bmad-loop plugins` — + per-plugin `[plugins.]` config — e.g. the opt-in game-engine layer via `[plugins.unity]`, off by default), `[tui]` (`low_frame_rate` for slow/SSH links; persisted dashboard pane sizes), `[operator]` (whether a dev session may park a story at `awaiting-operator`, and whether a review pass may demote a `done` story to one — `on_review_demotion`), `[mux]` (machine-scoped multiplexer backend choice, plus `honor_ambient_psmux_data_dir`: on psmux, use an absolute `PSMUX_DATA_DIR` your profile exports into every shell as the session registry instead of the derived per-project root — a yes/no switch, never a path; off by default; see [multiplexer-backends.md](multiplexer-backends.md)). -- `[dev] skill` names the inner dev skill the orchestrator drives. `"bmad-dev-auto"` — the generic upstream dev primitive — is the only accepted value; the field is retained as the seam for a future alternative dev skill, and any other value is rejected at load. It is **not** the name sessions are dispatched with: upstream renamed the primitive to `bmad-build-auto`, so the invoked name is resolved from what is actually installed and a project on either era works with this field untouched. It has no entry in the core settings schema, so it is edited in the file rather than from the TUI settings editor. -- Tunable limits: `max_review_cycles`, `max_dev_attempts`, `artifact_file_max_mb`, `artifact_payload_max_mb` (binary MiB, exactly 1,048,576 raw bytes each), `max_followup_reviews`, `session_timeout_min`, `git_timeout_s`, `teardown_grace_s` (one shared budget bounding the verified window kill _and_ the follow-on reap of any straggler descendant the session detached — e.g. a `setsid` background writer — combined; whatever remains after the window dies is what the straggler reap gets, before the worktree is merged and removed), `stop_without_result_nudges`, `dev_stall_grace_s`, `dev_stall_nudges`, `dev_stall_nudges_cap`, `workflow_stall_nudges_cap`, `max_tokens_per_story`. +# bmad-loop — Features + +## Overview + +bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches coding-CLI sessions in a terminal multiplexer, checks their work itself, and commits it. This file catalogs what the tool does, one entry per user-facing capability, grouped by category. Each entry lists its stability, the guarantees you can rely on, where they are tested, and where to read more. For the narrative overview see the [README](../README.md); for installation see the [setup guide](setup-guide.md). + +**Stability levels:** + +- **stable** — shipped, covered by tests, and kept compatible. +- **in-progress** — shipped and usable, but its surface is still growing or may change. +- **experimental** — available behind an opt-in or on a limited platform; expect rough edges. +- **planned** — designed or partly scaffolded, not yet functional; see the [roadmap](ROADMAP.md). + +**Maintaining this file:** add one `###` entry per user-facing capability, never per internal mechanism. Every property must be a short, testable statement that holds in the current code, and its **Tested by** files must cover it. No issue or ledger references and no history: mechanics live in [reference/](reference/cli.md), the guides, and module docstrings; change history lives in the [CHANGELOG](../CHANGELOG.md). + +## Core loop + +### Deterministic story pipeline + +- **Stability:** stable +- **Description:** Each story runs dev, verify, a review session when triggered, verify, then an orchestrator commit, driven by plain Python with no LLM in the control loop. LLMs work only inside disposable coding-CLI sessions, and bmad-loop is the only writer of story rows in `sprint-status.yaml` while a run is in flight. +- **Properties:** + - Per story, the engine runs a dev session, verifies its artifacts, runs a bounded review loop when triggered, runs verify commands, then commits the work itself. + - Phase moves are checked against one transition table; an illegal move raises instead of advancing the story. + - Board writes never move a row backward. The only exception is done → awaiting-operator, and only when the caller explicitly opts in. + - Board updates are serialized across processes, so two concurrent bmad-loop processes cannot interleave an advance. + - A session completes only on a hook Stop event or on window death, never because of what the model writes in its reply. +- **Config:** `bmad-loop run --epic N`, `bmad-loop run --story KEY`, `bmad-loop run --max-stories N`, `bmad-loop run --dry-run`; `[limits] max_dev_attempts` +- **Tested by:** `tests/test_engine.py`, `tests/test_statemachine.py`, `tests/test_sprintstatus_advance.py` +- **Docs:** [CLI reference: run](reference/cli.md#run), [Configuration: [limits]](reference/configuration.md#limits), [Run state reference](reference/run-state.md) + +### Stories mode + +- **Stability:** stable +- **Description:** An opt-in second story source. It drives the same pipeline from a `bmad-spec` `stories.yaml` instead of `sprint-status.yaml`, and dispatches each story by spec folder and story id. +- **Properties:** + - Each story is dispatched as `/ Spec folder: . Story id: .`, and its spec is read back from `/stories/-*.md`. + - Stories run in list order. Done stories are skipped, and interrupted non-terminal stories are re-dispatched. + - A blocked, sentinel, ambiguous or unknown-status story stops the scan and pauses the run for resolve; later stories never leapfrog it. + - If the installed dev skill lacks folder+id dispatch, the run aborts before spawning any session. Sprint mode does not need this support. + - On re-arm, a pre-planning sentinel spec is deleted, and a copy is kept under the run's `sentinels/` directory. +- **Config:** `[stories] source`, `[stories] spec_folder`; `bmad-loop run --spec FOLDER`, `bmad-loop run --dry-run --spec FOLDER` +- **Tested by:** `tests/test_stories.py`, `tests/test_stories_engine.py`, `tests/test_stories_e2e.py`, `tests/test_install.py` +- **Docs:** [Configuration: [stories]](reference/configuration.md#stories), [CLI reference: run](reference/cli.md#run), [Recovering paused runs](escalations-guide.md#resolving-an-escalation) + +### Trust-nothing verification + +- **Stability:** stable +- **Description:** After every session, bmad-loop checks the work itself: what is on disk, the git state, and your own test and lint commands. Optional environment probes separate a broken environment from broken code. +- **Properties:** + - A dev result is accepted only if the spec exists with the expected workflow tag and status, the board matches in sprint mode, and code changed (operator parks and plan-only legs excepted). + - `[verify] commands` run in the code's git root, which is the unit's worktree under worktree isolation, after dev, fix and review sessions. + - With probes configured, a failing, timed-out or unstartable probe stops verification before any `[verify]` command runs. + - `bmad-loop validate --probes` runs every probe, not stopping at the first failure, and fails validate if any probe fails. + - A `repo_root` key in `_bmad/bmm/config.yaml` moves verify commands and git checks to a monorepo's code root. +- **Config:** `[verify] commands`, `[verify] env_fault_rc`, `[environment] probes`, `[environment] probe_timeout_s`; `bmad-loop validate --probes` +- **Tested by:** `tests/test_verify.py`, `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_bmadconfig.py` +- **Docs:** [Configuration: [verify]](reference/configuration.md#verify), [Configuration: [environment]](reference/configuration.md#environment), [Environment faults](escalations-guide.md#environment-faults), [Re-verify kept work](escalations-guide.md#resolve-reverify) + +### Fresh-context review + +- **Stability:** stable +- **Description:** When the dev pass flags a follow-up (or on every story with `trigger = "always"`), a separate review session re-reviews the finished spec without the implementer's context, before anything is committed. The review loop is bounded and you can turn it off. +- **Properties:** + - The review runs as its own session, apart from the dev session, and can use a different CLI and model via `[adapter.review]`. + - With `trigger = "recommended"`, the review session runs only when the dev pass flags a follow-up; `always` reviews every story. + - `[review] enabled = false` skips the review session. The verify commands still gate the commit. + - Review passes are capped at `max_review_cycles`. A pass that keeps recommending its own follow-up is honored at most `max_followup_reviews` times, then the story converges. + - If a clean review leaves verify commands failing, a fix session repairs them and the story is reviewed again. +- **Config:** `[review] enabled`, `[review] trigger`, `[limits] max_review_cycles`, `[limits] max_followup_reviews`, `[adapter.review]` +- **Tested by:** `tests/test_engine.py`, `tests/test_escalation.py` +- **Docs:** [Configuration: [review]](reference/configuration.md#review), [Configuration: [limits]](reference/configuration.md#limits), [Configuration: [adapter.review]](reference/configuration.md#adapterreview) + +## Resilience + +### Bounded retries and attempt preservation + +- **Stability:** stable +- **Description:** Failed dev and review sessions retry within fixed budgets, silent sessions are nudged and then classified stalled, and any attempt work that is reset is first parked on git refs you can restore from. +- **Properties:** + - A failed dev attempt retries until `[limits] max_dev_attempts` (default 2); a failed review retries until `max_review_cycles` (default 3). + - A dev attempt that fails only its verify commands keeps its tree, and the failing output is handed to the retry as feedback. + - A session silent for `dev_stall_grace_s` (default 600) gets up to `dev_stall_nudges` wake nudges, then ends `stalled` and is charged as a failed attempt. + - Every dev and review session ends as `timeout` after `[limits] session_timeout_min` (default 90). + - A dev session that did no work, or any session parked on a prompt only a human should answer, pauses the run instead of being charged. + - Before an attempt is reset, its commits go to an `attempt-preserve/-` branch and uncommitted work to a `refs/attempt-preserve-dirty/*` snapshot. +- **Config:** `[limits] max_dev_attempts`, `[limits] max_review_cycles`, `[limits] session_timeout_min`, `[limits] dev_stall_grace_s`, `[limits] dev_stall_nudges`, `[limits] dev_stall_nudges_cap`, `[scm] preserve_keep` +- **Tested by:** `tests/test_escalation.py`, `tests/test_engine.py`, `tests/test_generic_tmux.py`, `tests/test_verify.py`, `tests/test_recovery_flow.py` +- **Docs:** [Preserved attempts](escalations-guide.md#preserved-attempts), [[limits] reference](reference/configuration.md#limits), [[scm] reference](reference/configuration.md#scm) + +### Plateau-defer + +- **Stability:** stable +- **Description:** A story that uses up its retry budget without a CRITICAL reason is deferred and skipped, and the run moves on to the next story. +- **Properties:** + - A story that exhausts `max_dev_attempts` or `max_review_cycles` without a CRITICAL reason is deferred and the run continues. + - With `[review] on_timeout = "defer"`, a timed-out, stalled or over-budget review defers at once instead of retrying. + - Each defer journals `story-deferred` and sends a `story deferred: ` notice naming where the attempt's work survives. + - In place with `[scm] rollback_on_failure = false` (default) and attempt work in the tree, the tree is left untouched and the run pauses for manual rollback. + - A re-drive of a human-resolved CRITICAL escalation that exhausts its budget re-escalates instead of deferring. +- **Config:** `[limits] max_dev_attempts`, `[limits] max_review_cycles`, `[review] on_timeout`, `[scm] rollback_on_failure` +- **Tested by:** `tests/test_escalation.py`, `tests/test_engine.py`, `tests/test_recovery_flow.py` +- **Docs:** [Plateau-defer](escalations-guide.md#plateau-defer), [Manual rollback pause](escalations-guide.md#manual-rollback-pause), [[limits] reference](reference/configuration.md#limits) + +### Typed escalations and resolve + +- **Stability:** stable +- **Description:** Sessions report escalations as CRITICAL, which pauses the run for a human, or PREFERENCE, which is only journaled. `bmad-loop resolve` clears a CRITICAL pause by re-arming the story, re-verifying work that was kept, or adopting a kept branch. +- **Properties:** + - A CRITICAL escalation marks the story `escalated`, sends a `CRITICAL escalation: ` notice and pauses the run at `escalation`. + - A PREFERENCE escalation is journaled as `preference-escalation` and the run continues. + - `--abandon` (in-progress) closes an escalated replay run unmerged, keeping its branch and parking dirty work on a preserve ref. + - Re-arming resets the story to `pending` with fresh dev-attempt and review-cycle budgets. + - `--reverify` re-runs `[verify]` on a deferred or environment-fault story's kept work without a dev session; on a finished run it replays a kept worktree unit in a new run. +- **Config:** `bmad-loop resolve --story`, `--no-interactive`, `--restore-patch`, `--adopt-branch`, `--reverify`, `--resume` / `--no-resume`, `--force`, `--abandon` +- **Tested by:** `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_escalation.py`, `tests/test_resolve.py` +- **Docs:** [Escalations](escalations-guide.md#escalations), [Resolving an escalation](escalations-guide.md#resolving-an-escalation), [Replay a finished run's kept unit](escalations-guide.md#resolve-reverify-finished), [Abandon a replay run](escalations-guide.md#abandon-a-replay-run), [resolve CLI reference](reference/cli.md#resolve) + +### Human-only actions + +- **Stability:** stable +- **Description:** A story whose acceptance criteria need an action no agent can take, such as buying a domain, commits the agent's work, parks at `awaiting-operator`, and waits for `bmad-loop confirm`. +- **Properties:** + - In sprint mode with `[operator] enabled` (default on), a story owing human-only actions commits its agent work, parks at `awaiting-operator`, and the run continues. + - `bmad-loop confirm --list` shows each parked story and what it owes; `--json` emits it as a machine-readable document. + - A confirmation adds an `## Operator Confirmation` section, moves spec and board to `done`, and commits. + - `confirm --reverify` runs the environment probes and `[verify]` commands first; a failure blocks the confirmation. + - `[operator] on_review_demotion = "park"` lets a review demote a `done` story to `awaiting-operator` (sprint mode only); the default `escalate` rejects the demotion. +- **Config:** `[operator] enabled`, `[operator] on_review_demotion`, `bmad-loop confirm --list`, `bmad-loop confirm --yes`, `bmad-loop confirm --reverify`, `bmad-loop confirm --json` +- **Tested by:** `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_operatoractions.py` +- **Docs:** [Human-only actions](escalations-guide.md#human-only-actions), [confirm CLI reference](reference/cli.md#confirm), [[operator] reference](reference/configuration.md#operator) + +### Environment-fault pause + +- **Stability:** stable +- **Description:** Failures caused by the environment rather than the code, such as a dead database, a missing binary or an unreachable provider, pause the run without charging the story an attempt. +- **Properties:** + - A failing `[environment] probes` command before a session launch pauses the run at `environment`; nothing is started or charged. + - `bmad-loop resume` re-runs the probes; if they pass, the withheld session launches with no rollback, and otherwise the run pauses again unchanged. + - A verify command exiting 126, 127 or `[verify] env_fault_rc` is an environment fault, not a code failure. + - A non-completed session whose log tail matches its profile's `env_fault_patterns` pauses instead of spending a dev attempt. + - A pattern match is overruled when the session's tracked token usage is nonzero; that session is charged like any failure. +- **Config:** `[environment] probes`, `[environment] probe_timeout_s`, `[verify] env_fault_rc`; profile key `env_fault_patterns` +- **Tested by:** `tests/test_engine.py`, `tests/test_escalation.py`, `tests/test_env_fault_patterns.py`, `tests/test_generic_tmux.py` +- **Docs:** [Environment faults](escalations-guide.md#environment-faults), [[environment] reference](reference/configuration.md#environment), [[verify] reference](reference/configuration.md#verify), [Profile field reference](adapter-authoring-guide.md#cliprofile) + +### Resumable runs and graceful stop + +- **Stability:** stable +- **Description:** A paused, stopped or interrupted run picks up where it left off with `bmad-loop resume`. A run can be stopped at once, or gracefully after the item in flight commits. +- **Properties:** + - `bmad-loop resume` continues a paused, stopped or interrupted run; a finished run, or one whose engine is alive, is refused. + - A session that completed before the interruption is not re-run; its recorded result is used on resume. + - `bmad-loop stop` signals the engine and kills the agent session; a wedged engine is force-killed after 10 s only while its process identity still matches. + - `stop --graceful` lets the in-flight story or bundle finish through commit, suppresses pending auto-sweeps, then stops resumable. + - `resume --accept-baseline` keeps commits above an in-place story's baseline instead of parking and resetting them, for that resume only. +- **Config:** `bmad-loop resume --accept-baseline`, `bmad-loop stop --graceful`, `bmad-loop stop --cancel-graceful`, `[adapter] cleanup_session_on_finish` +- **Tested by:** `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_runs.py` +- **Docs:** [Stopping a run](reference/run-state.md#stopping-a-run), [Derived run status](reference/run-state.md#derived-run-status), [resume CLI reference](reference/cli.md#resume), [stop CLI reference](reference/cli.md#stop) + +## Human control + +### Gates and checkpoints + +- **Stability:** stable +- **Description:** Run-global gates pause an unattended run at epic boundaries or after each story's dev pass so a human can review. Per-story checkpoints in stories mode pause before implementation or after the commit. `bmad-loop resume ` continues the run. +- **Properties:** + - `[gates] mode = "none"` never pauses at a gate. `per-epic` (the default) pauses at each epic boundary. Any other value fails policy load. + - Stories mode has no epics, so `per-epic` never pauses there. + - `per-story-spec-approval` also pauses each story after its verified dev pass and before review and commit. On resume the story goes straight to review. + - In stories mode, a `stories.yaml` entry with `spec_checkpoint: true` pauses after planning, before any code. On resume the implementation leg runs. + - `done_checkpoint: true` pauses after the story commits. It is skipped for the last story to dispatch, at `--max-stories`, or when a graceful stop is pending. + - `[gates] on_escalation` is reserved. It is parsed but has no effect, and CRITICAL escalations always pause whatever its value. +- **Config:** `[gates] mode`, `[gates] on_escalation` (reserved); stories.yaml per-entry `spec_checkpoint` / `done_checkpoint`; `bmad-loop resume ` +- **Tested by:** `tests/test_engine.py`, `tests/test_stories_engine.py`, `tests/test_policy.py`, `tests/test_settings_schema.py` +- **Docs:** [Configuration: [gates]](reference/configuration.md#gates), [Where a pause shows up](escalations-guide.md#where-a-pause-shows-up), [Reviewing a paused run in the TUI](tui-guide.md#reviewing-a-paused-run-p), [bmad-loop resume](reference/cli.md#resume) + +### Retrospective handling + +- **Stability:** in-progress +- **Description:** When an epic finishes, bmad-loop can stay silent, suggest a retrospective, or run a headless `/bmad-retrospective` session and commit its output. The retro's verdict is reported to you but never gates the run. +- **Properties:** + - `[gates] retrospective = "notify"` (the default) sends one "retrospective suggested" notice per finished epic. `never` sends nothing. + - `auto` runs `/bmad-retrospective -H ` on the `[adapter.retro]` role (falling back to the base `[adapter]` when that is unset), before the per-epic auto-sweep. + - An auto retro succeeds only if the session completed, result.json says done, the board reads done and a retro doc exists. It is then committed as `chore(retro): epic N retrospective`. + - A failed or refused auto retro (unless already done) tells you to run it by hand and never fails the run. If it leaves uncommitted changes, the run pauses at the epic boundary. + - The retro doc's verdict is reported in the done notice. A `rejected` verdict sends an extra attention notice, and the run continues. +- **Config:** `[gates] retrospective`, `[adapter.retro] name` / `model` / `effort` / `extra_args` +- **Tested by:** `tests/test_engine.py`, `tests/test_policy.py` +- **Docs:** [Configuration: [gates]](reference/configuration.md#gates), [Configuration: [adapter.retro]](reference/configuration.md#adapterretro), [Pointing retrospectives at run journals](setup-guide.md#pointing-retrospectives-at-run-journals), [Roadmap: automate epic retro action items](ROADMAP.md#automate-epic-retro-action-items) + +## Deferred work + +### Ledger sweeps + +- **Stability:** stable +- **Description:** `bmad-loop sweep` triages the deferred-work ledger in a fresh session, then builds the buildable entries as bundles through the normal dev → verify → review → commit pipeline. It can repeat on newly deferred work, archive closed entries, and run automatically from `bmad-loop run`. +- **Properties:** + - A bundle's review gate passes only when every ledger entry it owns is `status: done` on disk. + - With `--repeat`, the sweep re-triages after each cycle and ends with a `sweep-repeat-done` journal entry naming its `stop_cause`. + - `--archive` moves dated closed entries to `deferred-work-archive.md`, leaves an id-preserving `status: done` stub, and refuses while any run may be live. + - Legacy (non-`DW-`) entries are migrated automatically at a sweep's start; a rewrite that drops a `gate:` token or changes a status fails validation. + - `[sweep] auto` starts a child sweep per epic or at run end, at most once per trigger; a failed child never fails the parent run. +- **Config:** `[sweep] auto`, `[sweep] max_bundles`, `[sweep] repeat`, `[sweep] max_cycles`, `[sweep] max_triage_attempts`; `bmad-loop sweep --dry-run`, `--only DW-ID,...`, `--min-severity`, `--max-bundles`, `--repeat`/`--no-repeat`, `--max-cycles`, `--no-prompt`, `--decisions-only`, `--archive`, `--before DATE` +- **Tested by:** `tests/test_sweep.py`, `tests/test_cli.py`, `tests/test_deferredwork.py`, `tests/test_engine.py` +- **Docs:** [Sweep guide](sweep-guide.md), [Repeat mode](sweep-guide.md#repeat-mode), [Legacy-ledger migration](sweep-guide.md#legacy-ledger-migration), [CLI: sweep](reference/cli.md#sweep), [Configuration: [sweep]](reference/configuration.md#sweep) + +### Deferred-work decisions + +- **Stability:** stable +- **Description:** A sweep's triage can raise questions only a human can answer. You answer them during an attended sweep, or afterwards with `bmad-loop decisions` or `d` in the TUI; each answer builds, closes, or keeps the entry open. +- **Properties:** + - `bmad-loop decisions` offers only ids still open, surfaced by a past triage, and without a usable stored answer. + - An out-of-band `close` marks the ledger entry done immediately; `build` and `keep-open` are saved to `.bmad-loop/decisions.json` for the next sweep. + - A sweep reads its run store first and uses a project-store answer only for an id this triage asks that the run store has not answered. + - A keep-open answer whose option is gone or re-authored is dropped as `stale-option`, notified, and removed from the project store. + - An unattended sweep (`--no-prompt`) asks nothing, builds only decision-free bundles, and notifies `N deferred-work decisions pending`. +- **Config:** `bmad-loop decisions`, `bmad-loop decisions --list`, `bmad-loop decisions --json`, `bmad-loop sweep --no-prompt`, `bmad-loop sweep --decisions-only` +- **Tested by:** `tests/test_decisions.py`, `tests/test_sweep.py`, `tests/test_cli.py`, `tests/test_tui_app.py` +- **Docs:** [Answering decisions](sweep-guide.md#answering-decisions), [Project store and run store](sweep-guide.md#project-store-and-run-store), [Stale answers](sweep-guide.md#stale-answers), [CLI: decisions](reference/cli.md#decisions), [TUI: answering missed decisions](tui-guide.md#answering-missed-decisions-d) + +### Story closure and hard gates + +- **Stability:** stable +- **Description:** A story can declare the ledger entries it closes with `closes_deferred`, and a ledger entry can block named stories with a `gate:` line until it is done. +- **Properties:** + - At a story's commit, each `closes_deferred` id becomes `status: done ` with `resolution: resolved by story `. + - A story that defers or escalates closes none of its declared entries. + - A story named by an entry's `gate:` line pauses at stage `story-gate` until that entry is `status: done`; any other status still gates. + - A gate token matches a key it equals or prefixes at a boundary: `3-2` gates `3-2-login` and `3-2a-x`, not `3-20-x`. + - `bmad-loop validate` fails `deferred.hard-gate` for a gated actionable story and warns on unknown or malformed `closes_deferred` ids. +- **Config:** `closes_deferred:` in story-spec frontmatter or a `stories.yaml` entry; `gate:` line in a ledger entry; `bmad-loop validate` +- **Tested by:** `tests/test_engine.py`, `tests/test_deferredwork.py`, `tests/test_cli.py`, `tests/test_stories_engine.py` +- **Docs:** [Story-declared closure](sweep-guide.md#story-declared-closure-closes_deferred), [Hard gates](sweep-guide.md#hard-gates), [CLI: validate](reference/cli.md#validate) + +### Retro action items + +- **Stability:** in-progress +- **Description:** A fresh sweep files the action items a retrospective recorded in sprint-status.yaml into the deferred-work ledger, where they are triaged like any other entry. +- **Properties:** + - A fresh sweep files each not-`done`, id-keyed `action_items` entry from sprint-status.yaml as a low-severity ledger entry before triage. + - Ingest runs only at a fresh sweep's start: never on `--dry-run`, on a resumed sweep, or while the ledger is in doubt. + - Each item is filed once: its `retro action item ` origin dedupes against entries of any status, archived stubs included. + - An unreadable board or a non-list `action_items` is journaled `sweep-retro-ingest-unavailable` and the sweep proceeds. + - An auto-sweep that did not run is reported: `bmad-loop status` prints `auto-sweep not run`; `status --json` and `diagnose` list `sweeps_refused`. +- **Config:** `action_items:` list in sprint-status.yaml (written by bmad-retrospective); `[sweep] auto` +- **Tested by:** `tests/test_sweep.py`, `tests/test_sprintstatus.py`, `tests/test_cli.py` +- **Docs:** [What gets published](sweep-guide.md#what-gets-published), [Auto-sweep](sweep-guide.md#auto-sweep), [Roadmap: retro action items](ROADMAP.md#automate-epic-retro-action-items) + +## Isolation + +### Worktree isolation + +- **Stability:** stable +- **Description:** Opt-in mode that runs each story (and each sweep bundle) in its own git worktree and branch, then merges the result back into a target branch locally, so your main checkout is never edited mid-session. +- **Properties:** + - Off by default: with `isolation = "none"` work happens in place on the checked-out branch; only `none` and `worktree` are accepted. + - Each unit gets a `bmad-loop//` branch (`bmad-loop/` with `branch_per = "run"`), mounted under `.bmad-loop/runs//worktrees/`. + - A gitignored sprint board is seeded into the unit, and only the story's own status advance is carried back to the main board. + - A finished unit merges into the target branch with `ff`, `merge` or `squash`; on success the worktree is removed and, by default, the branch deleted. + - A deferred unit's diff is saved to `.bmad-loop/runs//failed//changes.patch`; its worktree and branch stay with `keep_failed`. An escalated unit's worktree stays mounted. + - A failed merge-back keeps the unit's branch and worktree and escalates; if the main checkout is off the target branch, the run pauses (`merge-target-not-checked-out`). +- **Config:** `[scm] isolation`, `[scm] branch_per`, `[scm] target_branch`, `[scm] merge_strategy`, `[scm] delete_branch`, `[scm] keep_failed`, `[scm] seed_adapter_defaults`, `[scm] worktree_seed`, `[scm] failed_diff_max_mb`, `[scm] failed_diff_unlimited`, `[scm] commit_message_template`; `bmad-loop resolve --adopt-branch` +- **Tested by:** `tests/test_engine_worktree.py`, `tests/test_sweep.py`, `tests/test_policy.py` +- **Docs:** [[scm] settings](reference/configuration.md#scm), [Worktree merge-back failures](escalations-guide.md#worktree-merge-back-failures), [Adopt the kept branch](escalations-guide.md#adopt-the-kept-branch), [Run directory](reference/run-state.md#run-directory) + +### Parallel units + +- **Stability:** planned +- **Description:** Running several units at once, each in its own worktree, with merge-back serialized. The config key exists and is validated, but runs execute one unit at a time. +- **Properties:** + - `[scm] max_parallel` accepts integers ≥ 1; 0 or less fails policy load. + - Any value above 1 is clamped to 1, so units always run and merge back one at a time. + - The key is hidden from the TUI settings editor and from the configuration reference until the scheduler lands. +- **Config:** `[scm] max_parallel` +- **Tested by:** `tests/test_policy.py`, `tests/test_settings_schema.py` +- **Docs:** [Roadmap: parallel unit execution](ROADMAP.md#parallel-unit-execution-scm-max_parallel) + +## Agents & platforms + +### Multi-CLI profiles + +- **Stability:** stable +- **Description:** bmad-loop can drive several coding CLIs: claude, codex, gemini, copilot, antigravity and opencode. What differs between them lives in declarative TOML profiles, so adding a CLI that reuses an existing hook dialect needs a profile file, not Python. Antigravity is experimental. +- **Properties:** + - Bundled profiles: claude, codex, gemini, copilot, antigravity and opencode-http. The names `opencode` and `claude-code-tmux` resolve as aliases. + - A `.bmad-loop/profiles/*.toml` file whose `name` matches a bundled profile overrides it; a new `name` adds a CLI. Project files win over packaged and entry-point profiles. + - opencode runs as a headless `opencode serve` over HTTP/SSE. It needs no tmux window and registers no hooks. + - `bmad-loop probe-adapter ` does a zero-launch scan by default. `--probe` opts in to one live, content-free turn. Every report is sanitized before it is printed. + - Antigravity (experimental) never reports token usage. Under worktree isolation, it trusts each unit worktree only when the project root is already trusted. +- **Config:** `[adapter] name`; `bmad-loop probe-adapter --probe`; `bmad-loop probe-adapter --workspace `; `bmad-loop adapters` +- **Tested by:** `tests/test_profile.py`, `tests/test_probe.py`, `tests/test_adapter_registry.py`, `tests/test_workspace_trust.py` +- **Docs:** [Choosing which CLIs to drive](setup-guide.md#choosing-which-clis-to-drive), [Adapter authoring guide: finalizing a profile](adapter-authoring-guide.md#walkthrough-finalizing-a-profile), [CLI reference: probe-adapter](reference/cli.md#probe-adapter), [CLI reference: adapters](reference/cli.md#adapters) + +### Per-stage adapters + +- **Stability:** stable +- **Description:** Each stage (dev, review, sweep triage, auto-retrospective) can run on its own CLI, model and reasoning effort. Any setting a stage leaves out is taken from `[adapter]`. +- **Properties:** + - `[adapter.dev]`, `[adapter.review]`, `[adapter.triage]` and `[adapter.retro]` override `name`, `model`, `effort` and `extra_args` per stage. A key left unset inherits from `[adapter]`. + - A stage that switches to a different CLI does not inherit the base model, effort or extra_args. It uses that CLI's own defaults. + - Naming an alias of the base CLI, such as `opencode` versus `opencode-http`, counts as the same CLI, not a switch. + - `effort` is free-form and only opencode sends it, as the per-prompt variant. `bmad-loop validate` warns when it is set on a tmux CLI. + - `extra_args` replaces the profile's permission-bypass flags instead of adding to them. `validate` warns with `policy.bypass-dropped` when a bypass flag would go missing. +- **Config:** `[adapter] name` / `model` / `effort` / `extra_args`; `[adapter.dev]`, `[adapter.review]`, `[adapter.triage]`, `[adapter.retro]` (same keys) +- **Tested by:** `tests/test_policy.py`, `tests/test_opencode_http.py`, `tests/test_cli.py` +- **Docs:** [Configuration reference: [adapter]](reference/configuration.md#adapter), [Configuration reference: [adapter.dev]](reference/configuration.md#adapterdev) + +### Multiplexer and OS seams + +- **Stability:** stable +- **Description:** Sessions run in a terminal multiplexer chosen from a registry: tmux on POSIX, psmux on native Windows, or an installed external backend. Session completion comes from hook-written event files, never from screen text. +- **Properties:** + - Backend selection order: `BMAD_LOOP_MUX_BACKEND`, then `[mux] backend`, then the platform default if available, then the first available match, then a fallback. + - A forced backend name that matches no registered backend fails with an error listing the known backends. It never falls back to tmux silently. + - `bmad-loop mux` lists the backends and the current selection. `mux set ` saves a choice to `.bmad-loop/policy.toml`. `mux set --clear` goes back to auto-select. + - A tmux-driven session completes only on a hook event or when its window dies. The pane log's contents never decide completion. + - Hook events are written outside the project tree, under the per-user state root. Sessions get that path in `BMAD_LOOP_EVENTS_DIR`. +- **Config:** `[mux] backend`; `BMAD_LOOP_MUX_BACKEND`; `bmad-loop mux set `; `bmad-loop mux set --clear`; `bmad-loop mux set --force` +- **Tested by:** `tests/test_backend_registry.py`, `tests/test_cli.py`, `tests/test_generic_tmux.py`, `tests/test_signals.py` +- **Docs:** [Which backend is running, and how to switch](multiplexer-backends.md#which-backend-is-running-and-how-to-switch), [Configuration reference: [mux]](reference/configuration.md#mux), [CLI reference: mux](reference/cli.md#mux), [Porting bmad-loop to a new OS](porting-to-a-new-os.md) + +### psmux on native Windows + +- **Stability:** experimental +- **Description:** On native Windows, the bundled psmux backend (a ConPTY tmux re-implementation driven through PowerShell) runs sessions without WSL. It is experimental. +- **Properties:** + - On a win32 host, psmux is the default backend whenever it reports itself available. + - psmux counts as available only when `psmux` and `pwsh` are both on PATH and psmux is 3.3.8 or newer. Otherwise selection moves on to the next backend. + - psmux refuses to open a window under PowerShell older than 7.3, or when the version cannot be read. This applies even when psmux is forced. + - psmux sessions are registered in a per-project registry under the state root. To use an ambient `PSMUX_DATA_DIR` instead, set `[mux] honor_ambient_psmux_data_dir`. +- **Config:** `[mux] backend`; `[mux] honor_ambient_psmux_data_dir`; `BMAD_LOOP_MUX_BACKEND=psmux` +- **Tested by:** `tests/test_psmux_backend.py`, `tests/test_psmux_live.py`, `tests/test_backend_registry.py`, `tests/test_runs.py` +- **Docs:** [psmux (native Windows, experimental)](multiplexer-backends.md#psmux-native-windows-experimental), [Platform prerequisites](setup-guide.md#platform-prerequisites), [Roadmap: native Windows multiplexer backend](ROADMAP.md#native-windows-multiplexer-backend) + +### Out-of-tree adapters and backends + +- **Stability:** stable +- **Description:** A separately installed package can add a coding-CLI adapter class, a profile, or a multiplexer backend through Python entry points. Nothing in core needs to change, and once installed it is selectable without configuration. +- **Properties:** + - An adapter class registers with `register_adapter` from a `bmad_loop.adapters` entry point. The profile's `adapter` field then selects it. + - A package can ship profiles through the `bmad_loop.profiles` entry point. A project's `.bmad-loop/profiles/` file still overrides them. + - A multiplexer backend registers with `register_multiplexer` from a `bmad_loop.mux_backends` entry point. + - An external package cannot replace a bundled adapter kind or backend name. The bundled ones register first, and the first registration of a name wins. + - A broken third-party package is recorded as a failure and reported by `bmad-loop adapters`, `bmad-loop mux` and `validate`. Selection keeps working. +- **Config:** entry-point groups `bmad_loop.adapters`, `bmad_loop.profiles`, `bmad_loop.mux_backends`; `bmad-loop adapters`; `bmad-loop mux` +- **Tested by:** `tests/test_adapter_registry.py`, `tests/test_external_backends.py`, `tests/test_profile.py`, `tests/test_backend_registry.py` +- **Docs:** [Shipping a new adapter class out-of-tree](adapter-authoring-guide.md#shipping-a-new-adapter-class-out-of-tree), [Two axes: CLI vs transport](adapter-authoring-guide.md#two-axes-cli-vs-transport), [External backends](multiplexer-backends.md#external-backends), [Shipping a mux backend out-of-tree](porting-to-a-new-os.md#shipping-out-of-tree-the-bmad_loopmux_backends-entry-point) + +## Extensibility & ops + +### Plugins + +- **Stability:** stable +- **Description:** Extend a run without changing core. A `plugin.toml` manifest can add lifecycle hooks, settings and extra workflow sessions. A project plugin that can execute anything runs only after the operator grants its exact contents. +- **Properties:** + - A project plugin with hooks, workflows, `[python]` or seeds never runs until `bmad-loop plugins enable ` records a content grant. + - An ungranted plugin not listed in `[plugins] enabled` is skipped with a `note:` on stderr, and the run proceeds. + - If a granted plugin's files, name or location change, startup fails with exit 1 until `bmad-loop plugins accept ` re-pins it. + - Granted plugins run from a verified out-of-tree snapshot, so edits to the live plugin directory have no effect mid-run. + - A hook that raises is journalled and disabled for the rest of the run. It never crashes the run. +- **Config:** `[plugins] enabled`, `[plugins.]`; `bmad-loop plugins [list|enable|accept|revoke] [name]`, `bmad-loop plugins --json` +- **Tested by:** `tests/test_plugin_trust.py`, `tests/test_plugin_grants.py`, `tests/test_hook_bus.py`, `tests/test_plugin_snapshot.py` +- **Docs:** [Plugin authoring guide](plugin-authoring-guide.md), [Plugin trust & safety](plugin-authoring-guide.md#trust--safety), [CLI: plugins](reference/cli.md#plugins) + +### Unity plugin + +- **Stability:** stable +- **Description:** A bundled, opt-in plugin for Unity projects. It lets dev and sweep sessions drive a live Unity Editor through an Editor MCP (IvanMurzak or CoplayDev). +- **Properties:** + - The plugin is inert unless `"unity"` is in `[plugins] enabled`. Until then its hooks, workflows and seeds stay off. + - Startup refuses `editor_mode = "shared"` unless `[scm] isolation = "none"`, and `per_worktree` unless `isolation = "worktree"`. + - Before each unit, a readiness gate waits for the Editor and MCP. If they are not ready, the unit is deferred instead of started. + - In `per_worktree` mode each worktree gets its own managed Editor. If setup fails, the unit is deferred. + - Around a failed-attempt rollback, the plugin saves and closes open scenes on a best-effort basis. A failure never blocks the rollback. +- **Config:** `[plugins] enabled`, `[plugins.unity] editor_mode`, `[plugins.unity] mcp`, `[scm] isolation`, `[cleanup] clean_tmp` +- **Tested by:** `tests/test_engine_plugin.py`, `tests/test_unity_quiesce.py`, `tests/test_unity_scene_guard.py`, `tests/test_unity_dialog_probe.py` +- **Docs:** [Bundled Unity plugin reference](game-engine-plugin-guide.md#reference-the-bundled-unity-plugin), [editor_mode and isolation coupling](game-engine-plugin-guide.md#the-editor_mode--scm-isolation-coupling), [Editor MCP guide](game-engine-mcp-guide.md), [Roadmap: game-engine work](ROADMAP.md#integrate-bmad-gds-game-test-items) + +### TEA plugin + +- **Stability:** experimental +- **Description:** A bundled, opt-in plugin that adds BMAD Test Architect (TEA) workflow sessions to every run and sweep. The steps are advisory by default; any of the three gate steps can be made blocking. +- **Properties:** + - The plugin is inert unless `"tea"` is in `[plugins] enabled`. + - With `require_tea` on (the default), a run fails at startup if `_bmad/tea/config.yaml` is missing. + - The plugin adds six TEA sessions: three after the dev phase and three just before every commit. Runs and sweep bundles get the same steps. + - If a gate set to `_blocking = true` returns FAIL or CONCERNS, the unit pauses for review instead of committing. + - Gates are advisory by default. A missing or unparseable gate artifact never blocks a commit. +- **Config:** `[plugins] enabled`, `[plugins.tea] require_tea`, `[plugins.tea] _enabled`, `[plugins.tea] trace_blocking` / `nfr_blocking` / `review_blocking` +- **Tested by:** `tests/test_plugin_tea.py` +- **Docs:** [TEA plugin guide](tea-plugin-guide.md), [How blocking gates work](tea-plugin-guide.md#escalate-on-gate-how-blocking-works), [Roadmap: TEA integration](ROADMAP.md#integrate-bmad-test-design--test-automation-runs-tea--testarch) + +### Token budgets + +- **Stability:** stable +- **Description:** Token spend is weighted by cost, so cache reads are discounted. An advisory per-story budget warns once. A per-session guard can warn, or can end a runaway session. +- **Properties:** + - Every token total counts cache reads at `cache_read_weight` (default 0.1), so budgets follow cost rather than context re-reads. + - A story's spend is checked at every session boundary. Crossing `max_tokens_per_story` warns once and journals `token-budget-exceeded`; the story keeps running. + - With `session_budget_mode = "enforce"`, a session over `max_tokens_per_session` gets a wrap-up nudge, then ends `over_budget` once `session_budget_grace_s` has passed. + - In `warn` mode (the default) the crossing is flagged once and the session finishes normally. In `off` mode usage is never sampled. + - The session guard stays inert for profiles that cannot report usage mid-session. +- **Config:** `[limits] max_tokens_per_story`, `[limits] cache_read_weight`, `[limits] session_budget_mode`, `[limits] max_tokens_per_session`, `[limits] session_budget_grace_s` +- **Tested by:** `tests/test_engine.py`, `tests/test_generic_tmux.py`, `tests/test_opencode_http.py`, `tests/test_policy.py` +- **Docs:** [[limits] settings](reference/configuration.md#limits), [CLI: status](reference/cli.md#status) ### TUI dashboard -- Read-only observer + launcher (`bmad-loop tui`): runs table, expandable sprint tree (epics → stories/retro), severity-colored deferred-work ledger, per-story phase table (phase · agent · dev attempts · review cycles · tokens · commit/defer), a run header naming the live-or-configured active agent, tabs tailing journal / pane log / `ATTENTION`. -- Launch & manage from keys: start run/sweep (`r`/`s`), resume (`e`), resolve escalation (`R`), answer missed decisions (`d`), attach (`a`), cleanup (`c`), validate (`v`), settings editor (`g`), theme/mode toggle (`M`), quit (`q`). -- Resizable panes: every boundary is drag-adjustable by mouse (the divider bars double as the Sprint / Deferred Work section headings) or a `ctrl+w` keyboard resize mode; sizes persist per-project to `[tui]` in `policy.toml` and re-apply on the next launch. -- Survives TUI exit/crash: runs launched from the TUI are detached `bmad-loop` processes in a dedicated `bmad-loop-ctl` tmux session (per-project name suffix on psmux); the dashboard watches purely via run-dir artifacts, so shell-started runs appear identically. -- Comment-preserving policy editor (`g`): grouped form, sections collapsed by default with one-line descriptions (`ctrl+e` toggles all), validated with the engine's own parser, unset keys show defaults as placeholders. - -### tmux session management - -- Each run drives agents in a dedicated `bmad-loop-` session; `attach` to watch live. -- Auto-teardown on finish (`cleanup_session_on_finish`, disable to inspect); a hard `stop` always kills it, a graceful `stop --graceful` tears it down under the same `cleanup_session_on_finish` gate a normal finish uses; paused/interrupted runs keep the session for `resume`. -- `bmad-loop cleanup` (or `c` in the TUI) sweeps leftover sessions/windows for finished/stopped/orphaned runs **of the current project**; live runs, and anything belonging to another project, are never touched. A control window is closed only once it has parked after its command exited (its screen ends on the `[bmad-loop exited N — press enter]` banner), so a window whose command is still running, such as an interactive `resolve` of a paused run or a run still starting, stays open; a window whose screen cannot be read stays open too and is named on stderr (`ctl_windows.undetermined` in `--json`, a warning toast in the TUI) (#876). -- `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) (schema-versioned; the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a `dry_run` flag) instead of the text. `ctl_windows` is a three-way partition — `removed` (verified gone after the kill; under `--dry-run` it is the would-close plan), `survived` (still listed) and `unverifiable` (the liveness listing itself failed) — because killing a window is best-effort and reports nothing; a survivor is retried by the next `cleanup`, and text mode marks the stdout count and names both non-removed arms on stderr rather than counting them as removed. If the candidate scan reports a failure, no window was chosen or killed: the arms are empty and `ctl_windows.scan_error` carries the reason (text mode puts it on stderr), so a reported preflight failure is never document-shaped like "nothing to prune". (`scan_error` carries the failures the scan can see; the multiplexer seam deliberately degrades a listing transport fault to an empty listing, the same documented ceiling the post-kill verdict accepts.) An unavailable backend counts as such a failure only when this project has evidence a control window could exist — a control window recorded for one of its runs, a run whose engine is or may be alive (an unverifiable pid counts), or a runs dir that cannot be listed; on a host with no multiplexer and no such evidence the scan stays clean (#864). A recorded control window is sticky: nothing drops the record when its window goes, so a project that launched from the TUI reports `scan_error` on every cleanup once the backend becomes unavailable, until its run dirs are removed. A process host that cannot be used (a misconfigured `BMAD_LOOP_PROCESS_HOST`) fails the liveness read behind both checks and is reported the same way, as a `scan_error`. The session half follows the same rule (minus the recorded window): `sessions.scan_error` is present only when the backend is unavailable while a run is or may be alive or the runs dir cannot be listed, and then `sessions.removed`/`live` mean no answer rather than verified empty; it is absent, not `null`, otherwise, and text mode prints it on stderr as `session prune failed: …`. Exit stays 0 either way — the verdict is the text/document, not the code. `sessions.removed` keeps its older, weaker meaning: an attempted kill. Plan and outcome share one schema — same fields, same meanings, with `dry_run` saying which one you are holding — so a script can pre-flight a sweep and compare it against what actually happened. (Values are each invocation's own sample, not a promise the two agree: a live session can die between the preview and the real run.) The unverifiable-pid warning, which text mode writes to stderr, becomes `sessions.unverifiable_pid` in the document, leaving stderr empty. - -### Disk reclamation (`[cleanup]`) - -- `bmad-loop clean` reclaims **disk** (distinct from `cleanup`, which is only tmux). It tears down git worktrees a mid-flight stop left mounted — the main accumulation source: each carries a real Unity `Library/` (incl. the MCP-server build), which `git worktree remove` cannot reach once the engine was killed before teardown. It then trims the heavy `worktrees/` tree from runs kept for history (the run still lists in the dashboard — discovery reads `state.json`, not the worktree), and archives or deletes runs past the retention window. -- It also collects the **out-of-tree** half of a run. Removing a run dir no longer removes everything the run owns (#494), so `delete`/`archive`/`clean` remove the run's control-plane dir under the state root too, and `clean` additionally sweeps this project's orphans there — subtrees whose run dir is gone, from a hand-removed run or a delete that predates this. The sweep keys on the run directory _existing_, not on its `state.json` parsing, so a corrupt run an operator is trying to recover keeps its control plane; a trimmed run keeps its own for the same reason (it is still resumable). Their bytes are not in the reclaim estimate — a state dir holds consumed event files, the run's `config-digest` (#498), and little else. Known limit: the state root is keyed by the project's resolved path, so a project that is deleted, moved or renamed leaves its old subtree unsweepable — after a move the project keys somewhere new, and no project can name the old key. A move does not cost the run its config-change baseline, though: `state.json` carries a second copy that travels with the run directory, and `resume` falls back to it exactly when the out-of-tree file is out of reach (#498). A file that is there but cannot be read (not a regular file, a link, an OS fault, non-UTF-8 bytes) takes the same fallback with a `warning:` naming the path and the fault, since that copy is one the session can rewrite (DW-467). -- Safe by construction: only **finished or stopped** runs are touched; running, unknown-host, paused and interrupted (resumable) runs are never reclaimed. `--keep ` protects a specific run (e.g. a finished one whose Editor is still live), `--dry-run` previews, `--retain N`/`--hard` tune the window and archive-vs-delete. -- Delete/archive and resume serialize on the same per-run lifecycle lock. Cleanup re-checks engine liveness after acquiring it and holds exclusion through archive snapshot/publication, run removal, and control-plane cleanup; a provably live engine refuses even under `--force`, while unverifiable liveness remains warn-and-proceed. If cleanup wins first, a waiting resume re-checks existence inside the hold and reports the run missing without recreating files or constructing an engine. `clean` records a racing refusal per run (protected when untouched, trimmed when earlier reclaim steps already ran) and continues with sibling candidates; the TUI reports the refusal and keeps the run visible. -- `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) (schema-versioned; the effective retention policy, `freed_bytes` as a raw integer, and the paths and run ids under `worktrees`/`trimmed`/`archived`/`deleted`/`protected`, and `state_dirs_swept` as a count) instead of the text. Plan and outcome share one schema, with `dry_run` saying which one you are holding, so a script can pre-flight a reclaim and compare it against what happened — though values are each invocation's own sample, not a promise the two agree. It names every item the text only counts or renders, and the unverifiable-pid warning text mode writes to stderr becomes `unverifiable_pid` in the document, leaving stderr empty. -- Prevention is automatic: every `run`/`sweep` start reconciles worktrees leaked by a prior **finished** run (`[cleanup] auto_clean_on_finish`), and the Unity plugin's `post_run` hook removes the IvanMurzak MCP server's downloaded `/tmp///*.zip` and truncates its unbounded editor log (`[cleanup] clean_tmp`). For recurring housekeeping of stopped runs, schedule `bmad-loop clean`. - -### Setup & install - -- `bmad-loop init` installs the three `bmad-loop-*` skills (`bmad-loop-setup`, `bmad-loop-resolve`, `bmad-loop-sweep`, into `.claude/skills/` and/or `.agents/skills/`), an absolute hook registration for the installed relay (on Windows, init prints a `warning:` line — and `validate` reports `hooks.relay-path-unsafe` per registered relay — when the relay path holds a shell metacharacter the host's hook quoting leaves exposed, e.g. `&` in an unspaced path or `%`/`$` inside a quoted one; advisory, the registration is unchanged (DW-346)), `.bmad-loop/policy.toml`, and a gitignore covering the runs dir, plugin caches, and policy.toml itself (per-machine config). Flags: `--cli` (repeatable), `--no-skills`, `--force-skills`. -- `bmad-loop validate` preflights every prerequisite: BMAD config, plugin manifests (`plugins.manifests`: every bundled and project-local `plugin.toml` — project-local read from the configured `repo_root`, the tree a run loads them from — is parsed exactly as a run will parse it, and never imported — a malformed one is a **problem** naming the manifest, so it fails here instead of at engine start after the run is published; a third-party manifest on an unsupported `api_version` is a warning, since a run skips it — [#765](https://github.com/bmad-code-org/bmad-loop/issues/765)), plugin trust (`plugins.trust`: every discovered plugin judged against the out-of-tree grants exactly as a launch will, hashing only and never importing — a project plugin that is ungranted, changed since its grant, named in `[plugins] enabled` without a grant, or unreadable is a **problem** whose detail carries `plugin`, `origin`, `path`, `status`, `pinned`, `current` and the `changed` relpaths, and an unreadable grant store is a problem too — [#496](https://github.com/bmad-code-org/bmad-loop/issues/496), [#497](https://github.com/bmad-code-org/bmad-loop/issues/497)), sprint-status, git (including its version — a host below the **2.34** support floor gets a `git.version` **problem** and exit 1, so validate's verdict cannot disagree with run/sweep/resume's outright refusal), the selected terminal-multiplexer backend (listing all detected when more than one is registered), CLI binary (**probed, not just resolved**: a name that is on `PATH` but fails `--version`, typically a dead WSL/npm shim, adds an `adapter.binary-unrunnable` finding at warning severity — `adapter.binary` itself still reports ok, and validate's exit code is unchanged — [#294](https://github.com/bmad-code-org/bmad-loop/issues/294); only **packaged** profiles are probed — a project overlay's binary is resolved but never launched, so a clone cannot choose which binary this diagnostic launches; resolution still goes through your `PATH`, so what a probed name resolves to is whatever the session launch would itself run), an `opencode-http` profile's `launch_args` shape (`adapter.launch-args-unservable`, warning only: the server argv is `[binary, *launch_args, "serve", ]`, so a token repeating `serve`/`--port`/`--hostname`/`--print-logs` (or a `--flag=` spelling), or an interpreter / package-runner `binary` such as `python3` or `npx` with no program in `launch_args` (empty or options only, e.g. `python3 -u`), cannot start a server — a pure shape check, never a probe; DW-374), each role's resolved `extra_args` under an `opencode-http` profile (`policy.extra-args-unservable`, warning only: they land after the owned flags, so a repeated `--port`/`--hostname`/`--print-logs` or `--flag=` spelling overrides the port the health poll dials; DW-483), hook registration, and the review skills the installed dev primitive actually invokes (reporting which name it resolved) — derived from its `customize.toml` review layers (or from `step-04-review.md` on releases that name reviewers inline), so both the merged `bmad-review` topology and the standalone-hunter one validate, and configured layers naming an uninstalled skill are caught — plus its `customize.toml`. The triage adapter's skill tree is checked separately for the complete bundled `bmad-loop-sweep` (every file the wheel bundles for it; existence only): `skills.sweep` when present, a `skills.sweep-missing` / `skills.sweep-incomplete` **problem** (the latter's `missing_files` names the absent files) otherwise, remediated by `bmad-loop init --force-skills`. `sweep`, a sweep-run `resume` and the auto-sweep child refuse on the same finding before any side effect, and `sweep --dry-run`'s banner lists it (DW-367). -- `bmad-loop validate --render-probe` (opt-in, DW-381) goes one step past the renderer presence checks (`skills.dev-renderer`, `-config`, `-sources`, which only prove the files exist): for each dev tree whose primitive resolves to a renderer stub it **executes the stub's own render command** — the first line of a fenced code block in its `SKILL.md` that names `render_skill.py`, split POSIX-style, with `{project-root}` / `{skill-root}` substituted — against a throwaway temp copy of `_bmad/` (minus `_bmad/render/`) and the skill dir, and reports `skills.dev-render-probe`: `ok` on rc 0 with a `read and follow` line; a **problem** carrying the renderer's `HALT:` text (`detail.halt`) — a missing or ambiguous config value, a bad `customize.toml` — or, for anything else (a bare traceback), naming the rc and last output line. An unparseable command, a launcher not on `PATH`, a spawn fault or a 120 s timeout are problems too, so a render that would HALT a dev session fails validate (exit 1) instead of surfacing as a result-less session. No coding CLI is launched and no prompt is sent; the project is never written (paths in the finding are mapped back to the project). It is opt-in because it runs project-controlled content — the same content a run executes — and plain `validate` stays non-executing; the stub's launcher (typically `uv run --no-cache`) fetches the renderer's dependencies on each probe, so the probe needs network access. With no stub resolved it reports one `ok` "nothing to render". -- **Where the artifact paths come from** ([#769](https://github.com/bmad-code-org/bmad-loop/issues/769), [#154](https://github.com/bmad-code-org/bmad-loop/issues/154)): `implementation_artifacts`, `planning_artifacts`, `output_folder` and `repo_root` are read from BMAD's central TOML — `_bmad/config.toml`, `_bmad/config.user.toml`, `_bmad/custom/config.toml`, `_bmad/custom/config.user.toml`, each overriding the one before, merged as BMAD's renderer merges them — and from the legacy `_bmad/bmm/config.yaml`. Each key is looked up the way the renderer resolves a short config key: a key found under more than one table (e.g. both `[core]` and `[modules.bmm]`) is refused as ambiguous, naming every location. A TOML value wins over the YAML for every key; the YAML only fills a key the TOML lacks, and with no TOML layer the YAML is read as before. A malformed or non-UTF-8 layer, a blank or non-string value, or an ambiguous key is a `bmad-config` failure, never a silent fallback to the YAML. The two artifact dirs are required; `output_folder` defaults to `{project-root}/_bmad-output` and `repo_root` to the project dir. -- The preflight also **names the multiplexer selection reason wherever selection resolves** (`mux.selection`, e.g. `platform default for win32`), not only when a `BMAD_LOOP_MUX_BACKEND`/`[mux] backend` choice forced it. A `fallback` selection is reported as a warning (its own label says no available backend matches this platform); a selection that outright failed is carried by `mux.preflight`, and a detection that failed by `mux.backends-detected` at warning — so a missing `mux.selection` line is normally explained by another finding (the historical unregistered-tmux fallback is the one silent exception; see the `--json` contract note in `documents.py`). On top of that, `host.win32-on-wsl-path` warns when a **native-Windows interpreter is working on a `\\wsl.localhost\...` project** ([#332](https://github.com/bmad-code-org/bmad-loop/issues/332) — see [multiplexer-backends.md](multiplexer-backends.md) for why WSL can hand a bash prompt the Windows build). Both are diagnostics only: neither changes which backend is selected (psmux _is_ correct for a `win32` interpreter) and neither flips validate's exit code. `bmad-loop diagnose` carries the same two facts in its Environment block as `sys.platform` and `win32 on WSL distro path` (`yes`/`no`). -- Non-invasive: drives the upstream dev primitive unmodified — there is no fork to keep in sync — and review is just a re-invocation of it on the `done` spec. Your standard BMAD install is never modified. - -### Command reference - -- `bmad-loop init` — install skills, hooks, policy, gitignore. -- `bmad-loop validate` — preflight all prerequisites. `--render-probe` also executes the dev skill's render command in a throwaway copy (`skills.dev-render-probe`; see above). `--probes` also executes every `[environment] probe` on this host (DW-526): all of them, not fail-fast, through the same runner and failure predicates a run uses, each bounded by `probe_timeout_s`. They run in the run's code root (`repo_root`), which is where a run probes too except under `[scm] isolation = "worktree"`: there each unit probes its own mounted worktree, a fresh checkout validate cannot reproduce, so a probe relying on gitignored or untracked files can pass here and fail in a unit. Each probe is one `environment.probe` finding: `ok` on a pass, a **problem** (exit 1) on a nonzero exit, a timeout, a probe that cannot be started, or (on Windows) one `cmd` cannot run (status `fail`, `returncode` 0), with `detail` `command`, `index`, `status` (`pass`/`fail`/`timeout`), `returncode` (null when no exit status exists), `reason`, `timeout_s` and `cwd`. `--probes` with none configured reports `environment.probes-none`. Without the flag nothing executes: configured probes get an `environment.probes-not-run` note (`ok` severity, `detail.run: false`), and a project with no probes prints no `environment.*` line. `--json` instead emits a stable machine-readable document (schema-versioned; the `ok` verdict, the queue `mode`/`spec_folder`, per-severity `counts`, and every check as a flat emission-ordered finding with a stable `check` id, `severity`, human `message` and structured `detail`) per the [contract below](#machine-readable-output---json); a failing check still emits the whole document, at exit 1 — the nonzero code is the verdict, not a failure to produce one. -- `bmad-loop plugins` (`plugins list`) — list every discovered plugin as `NAME ORIGIN SURFACE GRANT ENABLED PATH` (grant: `granted` · `ungranted` · `mismatch` · `stale` · `missing` · `unreadable` · `n/a`; a `missing` row is a grant whose plugin is gone); always exits 0, since `validate` owns the verdict. `plugins enable ` hashes a project plugin's dir (never imports or runs it) and records the grant: `plugin "" granted: sha256:<12 hex> ( files) in `. `plugins accept ` re-pins a changed one after printing `renamed: -> ` (a granted plugin whose `[plugin].name` was edited in place is a changed grant, never a fresh ungranted one), `moved: -> ` and `added:` / `removed:` / `changed:` relpath lines; on an unchanged grant both verbs print `unchanged` and rewrite nothing. `enable` on a changed grant refuses and points to `accept`; `accept` with no grant points to `enable`. `plugins revoke ` drops a grant (a deleted plugin's too). Builtins and settings-only plugins are refused (rc 1) with the reason; a grant store that exists but cannot be read is never rewritten. Never prompts and never writes policy.toml. `--json` (list only; rc 2 on a mutating action) emits a schema-versioned document — per plugin `name`, `origin`, `path`, `surface` (`hooks`/`workflows`/`python`/`seeds`), `grant_required`, `status`, `enabled`, `active`, `pinned_digest`, `current_digest`, `changed` — plus `store_fault` ([#496](https://github.com/bmad-code-org/bmad-loop/issues/496), [#497](https://github.com/bmad-code-org/bmad-loop/issues/497)). -- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why; a backend whose binary is present but crashed the version probe gets a `warning:` on stderr carrying the probe's own failure, since the `-` in the VERSION column cannot tell that apart from a binary that reports no version); on a backend with a registry namespace it also prints the **registry root** this project's sessions live in plus the export that reaches them from a bare client — on psmux that is `//_mux`, so a plain `psmux ls` shows none of them and says so rather than erroring ([#537](https://github.com/bmad-code-org/bmad-loop/issues/537)) — or, with `[mux] honor_ambient_psmux_data_dir = true` and an absolute `PSMUX_DATA_DIR` set, your own root, labelled as honoured ([#729](https://github.com/bmad-code-org/bmad-loop/issues/729)); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md). -- `bmad-loop adapters` — list registered coding-CLI adapter **kinds** (name · builtin/external · whether the family drives a multiplexer · which profiles select it), the CLI axis's counterpart to `mux`. Unlike `mux` there is no global choice to persist: a kind is selected per profile by its `adapter` field. A profile referencing an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a `warning:` on stderr; `validate` reports the same as `adapter.kind` / `adapter.external` / `adapter.external-profile`. -- `bmad-loop run` — drive the dev → review → verify → commit loop. -- `bmad-loop sweep` — triage + execute open deferred-work entries. -- `bmad-loop resume ` — continue a paused/interrupted run. A resume is fresh intent, so a stop request the prior run left behind is discarded first, in either mode — and if it cannot be removed, resume refuses and names the file rather than re-arming into a run that would stop again at its first item. Resume also rendezvouses with delete/archive on the run's lifecycle lock; when cleanup removed the run while resume waited, it reports `no such run` before any state helper can recreate the directory. A **sweep** run whose deferred-work ledger bytes do not decode is refused at entry, before the run is armed at all: the refusal names the ledger path and the decode fault, and steers to a hand repair plus a fresh `bmad-loop sweep` in a clean worktree, noting that the paused run stays resumable (so its in-flight bundle recovery is not thrown away). It is not the only answer these runs can get, and where a better one exists it stands down for it: a `finished` run still gets `already finished`, and a run whose id aliases the control session's own keeps that refusal and its `bmad-loop delete` route. (It also stays silent whenever it cannot answer at all — no run state, no project config — so the fault's own owner reports that instead.) Where it does fire it comes FIRST — displacing the git-floor, worktree-isolation, base-skills and `no such run` refusals, since it is the cheapest and most actionable of the set and each of those is reachable again on the retry — but a provably-live engine still wins outright and the ledger is never probed. Both fault classes are refused, each with its own repair: a _decode_ fault steers to a hand repair of the bytes, and a read the OS refuses (permissions, storage) — since DW-234, which reversed the DW-204 resolution's frozen exclusion of it — steers to repairing the path's permissions or storage — that attribution is the gate's own; the `bmad-loop sweep` route it pairs with is the one the sweep run's own `ledger-inaccessible` stop gives; every other clause (the path, the `bmad-loop sweep` route, commit-or-stash in a clean worktree, stays resumable) is shared. `deferredwork.read_for_write` wraps OS faults as `LedgerReadFault` (DW-279); the gate handles that subclass before the decode parent, retaining the original OS attribution and permissions/storage repair route. Nothing is armed or re-armed either way. Story runs are out of scope — the gate never looks at them — and that decline is safe rather than merely decided: since DW-231 (a decode fault) and DW-258 (a read the OS refuses) the engine routes both fault classes at its own four direct `read_for_write` sites — and, since DW-259/DW-279, both fault classes at the mutators' locked re-reads, the salvage refile and the close carry included (see the harvest bridge above) — so a story run over the same ledger no longer crashes at them. `bmad-loop resolve` and the TUI's re-arm gesture take the same probe at their own entries — ahead of resolve's interactive session and ahead of either surface's re-arm — so an escalated sweep over an unreadable ledger is refused with its escalation still armed rather than spent. `--accept-baseline` keeps the current HEAD as the baseline of every in-place story this resume restarts (see [Resumability & state](#resumability--state)). -- `bmad-loop resolve ` — resolve a CRITICAL escalation, then re-arm + resume (`--story`, `--no-interactive`, `--restore-patch ` for intent-gap patch-restore, `--adopt-branch` to finish the story from its kept worktree branch without re-running review, `[verify]` commands or `pre_commit_gate` workflows, `--reverify` to replay verification on a DEFERRED story's kept work, or an environment-fault escalated one's at a replayable fault site, with no dev session — see [Re-verifying kept work](#resolve-reverify); on a finished run, `--reverify --story ` replays a kept worktree unit in a new replay run — see [Replaying a finished run's kept unit](#resolve-reverify-finished), `--abandon` to close an escalated replay run whose unit is superseded — its worktree removed, its branch kept, the run recorded finished, `--resume`/`--no-resume`). A run paused at the `environment` stage needs no resolve: fix the environment and `resume`. -- `bmad-loop decisions` — answer deferred-work decisions past sweeps left unanswered (`--list` to just show them). `--json` instead emits a stable machine-readable document (schema-versioned; per decision the id, question, context, recommendation and every option's key/label/effect/intent/resolution/bundle-name plus a derived `recommended` flag) per the [contract below](#machine-readable-output---json); it implies the listing and never prompts, and nothing pending yields a valid empty document. -- `bmad-loop confirm ` — complete a story parked at `awaiting-operator` once you have carried out the external actions it owes: acknowledges each in turn (`--yes` skips the prompts), writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair. `--list` shows every parked story and what it owes; `--reverify` re-runs your `[verify]` commands (after any `[environment] probes`) first and blocks on failure; re-running it on an interrupted confirmation finishes that confirmation. `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) — per parked story the key, actions, spec file, spec/board status, the parking run and the `commit` carrying the park (empty until the record is in a commit), plus derived `confirmable`/`resumable` flags, the `confirmation_recorded` reading behind the latter, and a human `drift` reason; it implies the listing and never prompts, and nothing parked yields a valid empty document. -- `bmad-loop list` (`ls`) — list every run/sweep with its short ref, type, and status. `--json` instead emits a stable machine-readable document (schema-versioned; one entry per run, oldest first: short ref, run id, type, started-at, liveness-aware status, paused stage) per the [contract below](#machine-readable-output---json); an empty runs dir yields a valid empty document. A runs dir (or run dir) that exists but cannot be read is not "no runs" (DW-468): text mode warns `run listing incomplete: …` on stderr, `--json` carries it as `listing_fault` (`null` when complete), and a run ref that the incomplete listing cannot settle — nothing readable matched, or an unreadable run's name matches too — is refused naming the fault rather than `no such run`. `status`/`attach`/`diagnose` without a ref warn the same way before falling back to the newest readable run, `clean` warns (`listing_fault` in its `--json`) and reclaims only what it could read, and a run/sweep start's stale-worktree reconcile warns. -- `bmad-loop status []` — run + sprint summary with per-story token totals, cost-weighted with the raw count alongside. `--json` instead emits a stable machine-readable document (schema-versioned; run state, snapshot `cache_read_weight`, per-story phase/attempt/review-cycle/tokens/commit/defer-reason, plus the additively-added run-level `adapters` — the dev/review/triage/retro adapter the policy snapshot resolves to, `null` on a run predating adapter stamping — and per-story `adapters_used`, the adapter identity actually recorded per role) per the [contract below](#machine-readable-output---json) — the supported surface for scripts; the text output is best-effort. -- `bmad-loop diagnose []` (`diag`) — emit a sanitized diagnostic dump of a run/sweep to hand maintainers (histograms, counts, env, file sizes — no code/spec/prompts/paths/PII); a stray pseudonymized identifier is auto-substituted with its alias (disclosed in the report), while PII/secret hits still refuse to emit; defaults to the latest run (`--all`, `--out`, `--max-journal-entries`). `--json` emits the same dump as a stable machine-readable document per the [contract below](#machine-readable-output---json) instead of the markdown report. -- `bmad-loop attach []` — tmux-attach to a run's live agent session. -- `bmad-loop stop ` — stop a live run. The default is a **hard stop**: stop now, abandoning the in-flight item and killing the agent session. `--graceful` instead requests a **graceful stop** — the engine finishes the in-flight item (a story through commit, a sweep bundle through commit, or an in-progress sweep triage — after which no bundles start), then finalizes cleanly and stops as a resumable `stopped` run, suppressing any pending auto-sweeps; `--cancel-graceful` withdraws a pending request. Both modes ride the same `stop-request.json` control file, which carries the mode: a hard stop lodges `mode: "hard"` **before** it signals, and that atomic, project-confined write is also what supersedes a pending graceful request. The engine honors a hard request at the next item boundary and mid-session, where each adapter's wait loop polls it twice per iteration — before and after the loop's own up-to-5s wait — so a quiet session normally lands the stop well inside the 10s grace window. That is the common case rather than a bound: an iteration blocked on a transport call, or waiting out `RESULT_GRACE_S` for an artifact, can exceed the window on either adapter before the next poll — an in-flight socket read or tmux call cannot be interrupted from the polling thread, so no placement of the check makes the interval unconditionally short. What the file does guarantee is reach: a hard stop lands on every platform and multiplexer backend, including one where an inter-process signal is never delivered at all (#319). A nested auto-sweep runs inside its parent but mints its own run dir, so it also polls the _owning_ run's channel: stopping the parent stops the child mid-session rather than leaving it to the force-kill backstop. The child's read of the parent channel is hard-only — a graceful stop already keeps a child sweep from starting, and lets one already in flight finish. Teardown is unbounded on top of that — the opencode HTTP adapter then asks the server to abort and to report usage, and a server that will not answer those leaves the stop to the force-kill backstop, exactly as it would have before #319. SIGTERM still goes out alongside it as the POSIX fast path, but it is no longer the mechanism; the engine stays the single writer of `stopped`, and the external force-kill + `run-stop fallback=True` past the grace window now marks a stop this tool had to finish from outside — a teardown that outran the window reaches it as readily as an engine that never read the request — where before #319 it marked every native-Windows stop. The same hard request reaches the engine's blocking child commands (DW-353): `[verify] commands` and declarative plugin hooks run through a stop-aware runner that re-reads the channel every 0.25s and, on a hard request, kills the command's whole process tree — `taskkill /F /T` on the `cmd.exe` root first on Windows; SIGTERM to the shell root, then any surviving descendants, on POSIX — so a stop landing mid-verify or mid-hook records `stopped` with `run-stop via=stop-request` within a few seconds instead of reaching the force-kill. The cut-short pass is journalled (`verify-command-result` with `interrupted: true`, or `plugin-hook-interrupted`) and is never read as a failure, a retry, a hook error or a fail-closed veto: nothing is committed or repaired on it, and later verify commands are not started. An interrupted `post_run` hook is the exception — the run still finishes. Like the adapters, a nested auto-sweep's children read both its own channel and the owning run's, so `stop ` and `stop ` both reach them. Once the engine has honored a hard stop, the hooks that run as part of the stop's own unwind (worktree teardown, rollback) are not interrupted and run to completion. A verify or hook timeout kills the whole tree the same way, where it used to kill only the shell. While the root shell runs, the runner re-scans its tree once a second and keeps what it finds. The kill re-scans every 50 ms while the root winds down after its signal, then takes in each surviving known process's own children in one final scan. Of everything found, only processes still alive with a matching recorded identity are signalled, so a reused pid is never hit. A background job is killed after its shell exits if the job's parent chain was still under the live root at one of those scans. A process the root forks in its signal handler is killed if the root lives at least one 50 ms re-scan after forking. What is still missed: a process that starts and is reparented away from the root between two scans (a double-fork whose middle process lives under a second, or a shell that exits right after backgrounding a job); a TERM handler that forks and exits at once; once the root has exited, any descendant of a surviving job that was already reparented away from the job, since only the job's current children are found at kill time; and a process started while the survivors are being reaped. On macOS without `psutil` only the root is killed. In every case the runner stops reading after a short bounded drain instead of hanging; on Windows it leaves a pipe that such a process still holds open until that process exits, rather than blocking on it. Outside a run (`confirm`'s re-verify) nothing is polled. The TUI surfaces the same pair: `x` hard-stops, `S` requests a graceful stop. -- `bmad-loop delete ` — delete a run directory and its out-of-tree control-plane dir (`--force` stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot remove a run a rival resume claimed after that stop; unknown liveness still warns and proceeds. -- `bmad-loop archive ` — compress a run into `.bmad-loop/archive` and remove it, control-plane dir included (`--force` stops it first if live). The destructive transaction re-checks liveness after acquiring the per-run lock, so force cannot archive a run a rival resume claimed after that stop; unknown liveness still warns and proceeds. The hold covers tar snapshot, durable publication, source removal, and control-plane cleanup. The tarball holds the run dir, so it carries no `events/`. It is staged through an exclusively created temp under a fresh unpredictable name per attempt, so a planted name is never followed or reused, the failure cleanup is provably its own, and a temp stranded by a kill cannot deny later attempts; the tarball is `fsync`ed before the publish — the run dir is removed immediately after, so it is the only remaining copy. A published archive lands at mode `0600` rather than a umask-derived one (#591). -- Removal refuses while a matching agent session is live that the project cannot prove is another one's, even when the engine is dead: for an untagged session the run dir is the last ownership proof `cleanup` can read, so removing it would leak the session ([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)). A session tagged to another project carries its own proof and never blocks. Run `cleanup` first, having confirmed the session is this project's (`attach`): for an _untagged_ session `cleanup` proves ownership by that same run dir, so two projects sharing a run id can prune each other's. Or pass `--force`, which removes anyway and kills nothing. `clean` leaves such a run untouched and reports it as protected. The check is an observation with a measured ceiling ([#732](https://github.com/bmad-code-org/bmad-loop/issues/732)): a listing that omits a live session reads as "no session", so removal proceeds. (A listing that fails outright reads the same, but no longer silently. A backend that cannot be selected, or a listing that raises, is reported by the removal itself — a `warning:` on stderr from the CLI, a warning toast from the TUI's `D`/`A` (DW-466). The bundled tmux-family backends never raise there: a failed `list-sessions` is folded into an empty listing and reported unless the failure proves no server is running (DW-458) — on stderr from the CLI, and from the TUI's `D`/`A` as the same kind of warning toast, which then replaces the stderr line rather than joining it. A missing multiplexer and a gone server are answers, and report nothing on either.) Two ways to meet it, both measured on psmux 3.3.8 — a `has-session` whose 500 ms connect does not land reaps a live session's registry entry, and until the server's registry maintenance re-writes it `ls` omits that session while exiting 0 with nothing on stderr (that maintenance runs on a nominal 5 s check in the server's own loop, so the window has no hard bound; 1.7 s was one measured sample); and a process whose PATH lacks the multiplexer binary reads every session as absent for as long as that PATH lasts, however long the server keeps running. The first is a successful query with an incomplete answer and the second is a missing binary, and they do not even look alike — a reap takes one entry, so its listing may still name every other session, while a missing binary yields nothing at all — but both reach the guard as the same verdict, this run's name is not in the listing, which is what a genuinely dead session produces too. So it accepts both rather than wedging every removal path behind `--force`. If a removal does strand a session, clear it with `kill-session` on `bmad-loop-` — but neither failure leaves it reachable straight away: restore the multiplexer to PATH first, and after a reap wait for the server's next registry tick to republish the entry, or the target will not resolve. On psmux the sessions live in the per-project registry, so run it with the export `bmad-loop mux` prints. Confirm the session is this project's before killing, since the name carries no project — and note that `attach` is no longer the way to do it: it resolves the run directory first, which the removal just deleted. Attach in the multiplexer directly instead, and for an _untagged_ session accept that the run dir it lost was its only ownership proof, so what is left is the session's own contents and what you know of the run. -- `bmad-loop cleanup` — remove leftover tmux artifacts for finished/stopped runs. `--json` emits the sessions and ctl windows removed (or, with `--dry-run`, that would be) as a stable machine-readable document per the [contract below](#machine-readable-output---json). -- `bmad-loop clean` — reclaim disk from concluded runs per `[cleanup]`: tear down worktrees a mid-flight stop orphaned, trim heavy `worktrees/` from runs kept for history, archive/delete past the retention window, and sweep orphaned run control-plane dirs from the out-of-tree state root (`--dry-run`, `--keep`, `--retain N`, `--hard`). A run that resumes before its final removal is classified as protected or trimmed according to work already completed, and unrelated candidates continue. `--json` emits what was reclaimed (or would be) as one stable machine-readable document per the [contract below](#machine-readable-output---json), with `freed_bytes` a raw integer and `listing_fault` naming any run the listing could not read (those runs are never reclaimed or counted toward retention). A worktree reconcile git could not list for (or a removal that left a worktree on disk) and an orphaned state-dir sweep that could not run (an unnameable or unreadable state root, an unreadable runs dir) or skipped entries are not "nothing orphaned" (DW-470): text mode warns on stderr and still runs the other passes, and `--json` carries them as `worktree_faults` (one `: ` line each) and `state_dir_fault` (`null` when the sweep completed). -- `bmad-loop tui` — the interactive dashboard (`--low-frame-rate` for slow/SSH links). -- `bmad-loop probe-adapter ` (`collect-adapter-data`) — collect + sanitize adapter-finalization data for a CLI profile; default zero-launch scan, opt-in `--probe` live capture. -- Every command takes `--project ` (default: current directory). Any `` accepts a partial — the tail after the last `-`, shortened to any unique prefix. - -### Machine-readable output (`--json`) - -A command's `--json` mode emits exactly one JSON object on stdout and nothing else — no trailers, no fenced blocks. Every document carries an inline integer `schema_version` owned by that command; evolution is additive-only, anything breaking bumps it. Errors never produce a partial or error document: the message goes to stderr, stdout stays empty, and the exit code is nonzero. A command reporting a **verdict** is different: it exits nonzero to carry the answer while still emitting its document (`validate --json`). So the rule is positive: **parse non-empty stdout whatever the exit code, and take the verdict from the document's own field** — `ok` on `validate`, which unlike `rc` separates "the checks failed" from "the command broke". Codified in `src/bmad_loop/machine.py`, it holds for every command taking the flag — `status`, `list`, `decisions`, `confirm`, `validate`, `clean`, `cleanup`, `diagnose`, `probe-adapter`, `plugins` ([#195](https://github.com/bmad-code-org/bmad-loop/issues/195)). On `diagnose` and `probe-adapter`, `--json` replaces the human report rather than appending; with `--out FILE` the document goes to the file, stdout stays empty, and the confirmation goes to stderr. +- **Stability:** stable +- **Description:** An optional Textual dashboard (`bmad-loop tui`) for watching runs and launching them. It never runs an engine in its own process. +- **Properties:** + - The dashboard needs the `tui` extra. Without it, `bmad-loop tui` exits with an install hint and every other command still works. + - Runs, sweeps and resumes started from the TUI run as detached processes in a control session, so they survive the TUI exiting or crashing. + - The dashboard has no private channel to the engine. It reads the files a run writes, so runs started from a shell show up exactly like TUI-started runs. + - The settings editor checks edits with the same policy parser a run uses, and will not save an invalid value. + - Resized pane sizes are saved per project under `[tui]` in policy.toml and restored on the next launch. +- **Config:** `bmad-loop tui --low-frame-rate`, `[tui] low_frame_rate` +- **Tested by:** `tests/test_tui_app.py`, `tests/test_tui_launch.py`, `tests/test_tui_settings.py`, `tests/test_tui_data.py` +- **Docs:** [TUI guide](tui-guide.md), [Observer/launcher architecture](tui-guide.md#architecture-observerlauncher-never-the-engine), [CLI: tui](reference/cli.md#tui) + +### Session and disk cleanup + +- **Stability:** stable +- **Description:** `cleanup` removes this project's leftover multiplexer sessions. `clean` reclaims disk from concluded runs according to `[cleanup]`. `delete` and `archive` remove a single run. +- **Properties:** + - `cleanup` removes sessions only for this project's runs whose engine is not live. Other projects' sessions are left alone. + - `clean` only touches runs that are finished or stopped and have no live engine. Paused, interrupted and running runs are never reclaimed. + - Runs past retention are archived to `.bmad-loop/archive/.tar.gz`, or deleted with `--hard` or `archive_old = false`. + - `delete` and `archive` refuse a run whose engine is provably live, unless `--force` is given. + - With `auto_clean_on_finish` on, every `run` and `sweep` start removes worktrees left behind by finished runs. Stopped runs keep theirs for `resume` or `clean`. +- **Config:** `[cleanup] run_retention`, `retention_days`, `trim_artifacts`, `archive_old`, `auto_clean_on_finish`, `clean_tmp`; `bmad-loop cleanup --dry-run --json`; `bmad-loop clean --dry-run --keep RUN_ID --retain N --hard --json`; `bmad-loop delete|archive --force ` +- **Tested by:** `tests/test_cleanup.py`, `tests/test_runs.py`, `tests/test_cli.py` +- **Docs:** [Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk), [[cleanup] settings](reference/configuration.md#cleanup), [CLI: clean](reference/cli.md#clean), [CLI: cleanup](reference/cli.md#cleanup) + +### Init and validate + +- **Stability:** stable +- **Description:** `init` installs the hook relay, the bundled `bmad-loop-*` skills, a policy template and `.gitignore` entries. `validate` checks every prerequisite before a run. +- **Properties:** + - `init` writes `.bmad-loop/policy.toml` only when the file is missing. An existing policy is never overwritten. + - `init` appends only the `.gitignore` lines that are missing, so running it again changes nothing. + - `validate` exits 1 only when a finding has severity `problem`. Warnings never change the exit code. + - Every finding carries a stable check id taken from a fixed, documented registry. + - `validate` runs no project-controlled commands unless `--render-probe` or `--probes` is given. +- **Config:** `bmad-loop init --cli PROFILE --no-skills --force-skills`; `bmad-loop validate --spec FOLDER --render-probe --probes --json` +- **Tested by:** `tests/test_cli.py`, `tests/test_install.py` +- **Docs:** [Setup guide](setup-guide.md), [CLI: init](reference/cli.md#init), [CLI: validate](reference/cli.md#validate), [validate check ids](reference/cli.md#validate-checks) + +### Diagnostics and JSON output + +- **Stability:** stable +- **Description:** `bmad-loop diagnose` writes a sanitized dump of a run that is safe to share with maintainers. Every `--json` mode follows one schema-versioned contract, and exit codes come from a fixed set. +- **Properties:** + - In `--json` mode stdout holds exactly one JSON object, or nothing when `--out` redirects it. On an error stdout stays empty and the exit code is nonzero. + - Every document carries an integer `schema_version` owned by its command. Breaking changes bump it; new fields are only ever added. + - Exit codes are limited to 0 (OK), 1 (FAILURE), 2 (USAGE) and 130 (INTERRUPTED). + - `diagnose` defaults to the latest run. Its dump contains no code, specs, prompts, paths or PII; story keys and branches are pseudonymized. + - If the final leak check finds PII or a secret, `diagnose` emits no dump and exits 1. +- **Config:** `bmad-loop diagnose [run_id] --all --out FILE --json --max-journal-entries N`; `--json` on status, list, decisions, confirm, validate, clean, cleanup, plugins, diagnose, probe-adapter +- **Tested by:** `tests/test_cli.py`, `tests/test_diagnostics.py`, `tests/test_sanitize.py`, `tests/test_entry_point.py` +- **Docs:** [Machine-readable output](reference/cli.md#machine-readable-output---json), [Exit codes](reference/cli.md#exit-codes), [CLI: diagnose](reference/cli.md#diagnose) diff --git a/docs/README.md b/docs/README.md index 9c8b5d920..e89ad2ec4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ guides below go deeper, roughly in the order you'll need them. - **[TUI guide](tui-guide.md)** — the dashboard: layout, key bindings, the settings editor, and troubleshooting. - **[Deferred-work sweeps](sweep-guide.md)** — running a sweep, answering decisions, closing entries from stories, hard gates, and recovering from stale answers, publication refusals and an unreadable ledger. - **[Recovering paused runs](escalations-guide.md)** — escalations and `resolve` (re-arm, `--adopt-branch`, `--reverify`, `--abandon`), preserved attempts, plateau-defer, `confirm` for human-only actions, environment faults, and worktree merge-back failures. -- **[Features & functionality](FEATURES.md)** — the full capability matrix and policy reference. +- **[Features](FEATURES.md)** — what the tool does: the feature catalog, with each feature's stability, guarantees, tests and docs. - **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux bundled; externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) install as packages), how selection works, and how external backends arrive. ## Reference diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 3f8f0d026..ee5e3489d 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -517,7 +517,7 @@ predates that, or that the flag deliberately keeps around. `c` only reaps tmux artifacts, not disk. To reclaim run-dir **disk** — worktrees a mid-flight stop orphaned (each holding a Unity `Library/`), and old run dirs — use -the `bmad-loop clean` CLI command (see [`[cleanup]`](FEATURES.md#disk-reclamation-cleanup)); +the `bmad-loop clean` CLI command (see [Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk)); it is intentionally not bound to a TUI key since it deletes/archives run history. ## Resolving an escalation (`R`) @@ -549,7 +549,7 @@ and `p` open the **re-verify** picker instead (below). ### Re-verifying kept work (`V`) `V` re-verifies a story's kept work with -[`bmad-loop resolve --reverify`](FEATURES.md#resolve-reverify). It opens a +[`bmad-loop resolve --reverify`](escalations-guide.md#resolve-reverify). It opens a small picker listing every target in the selected paused run: the paused story first when it is DEFERRED, or ESCALATED at an environment-fault site a replay can clear (marked `(paused story)`), then each DEFERRED worktree unit under any diff --git a/src/bmad_loop/adapters/opencode_http.py b/src/bmad_loop/adapters/opencode_http.py index a4111a204..ff08f9af0 100644 --- a/src/bmad_loop/adapters/opencode_http.py +++ b/src/bmad_loop/adapters/opencode_http.py @@ -109,7 +109,9 @@ when that agent table also pins its own ``model`` — inert otherwise, which is the measured negative result in #643. It is sent instead as the per-call ``variant`` in every ``prompt_async`` body (``_prompt``), initial prompt and - nudges alike, and omitted entirely when empty. A per-session + nudges alike, and omitted entirely when empty. The value is passed through + unvalidated: bmad-loop does not reject an effort name the provider does not + know. A per-session ``OPENCODE_SERVER_PASSWORD`` makes the health poll self-discriminating against a foreign server on a reused port and keeps other local processes from driving an allow-all server. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 27487a8ac..4dcc01e03 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4283,7 +4283,7 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo # ...and the in-tree secondary is re-stamped in the same breath, for the same # reason it is written at launch: the state root is keyed by the project's # RESOLVED PATH (`runs.project_tag`), so moving or renaming the project — a - # documented operation, FEATURES.md states the GC half of it — keys the run + # documented operation, docs/reference/run-state.md states the GC half of it — keys the run # somewhere new and the file above becomes unreachable. Leaving only that copy # made a move silently retire the pin, and the advisory this whole guard exists # to raise never fired again. Writing both keeps the warning alive across a move diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 274cfa698..a6136f882 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -8534,7 +8534,7 @@ def _note_story_token_budget(self, task: StoryTask) -> None: """Warn once when a story's cost-weighted spend crosses ``limits.max_tokens_per_story``, at the session boundary that crossed it. - The cap is advisory by contract (docs/FEATURES.md) — this warns, it never + The cap is advisory by contract (docs/reference/configuration.md) — this warns, it never terminates. Enforcement is the separate mid-session guard (#158, ``max_tokens_per_session``), a different cap with its own latch, untouched here. @@ -10353,6 +10353,9 @@ def _carry_board_advance(self, task: StoryTask) -> None: ``ready-for-dev`` — inside ``ACTIONABLE_STATUSES`` — and ``_pick_next``, which reads the MAIN board, re-picks finished work on the next run. + Only this story's own advance is carried. Any other row a session edits in + a gitignored worktree board is lost with the worktree at teardown. + Through ``sprintstatus.advance``, the orchestrator's sole write path to this file, never a copy of the worktree's: that keeps the never-regress rule and the comment-preserving line edit in one place, and a copy would also diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 548f53057..633efc745 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -56,7 +56,8 @@ # The bmad-loop-* skills bundled in the wheel (bmad_loop/data/skills/) that # `bmad-loop init` lays down. The inner dev primitive (`bmad-build-auto`, formerly # `bmad-dev-auto`) is upstream (not bundled here): the orchestrator drives it as an -# already-installed skill. +# already-installed skill. These dirs are the only skills init writes: the +# BMAD-installed skills and `_bmad/` are read, never modified. MODULE_SKILLS = ( "bmad-loop-resolve", "bmad-loop-sweep", @@ -3404,7 +3405,7 @@ def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> str | No # # The rollback exists because the enable one line above is a PERMANENT # repo-format change, and the guarantee this code owes (docstring, - # CHANGELOG, docs/FEATURES.md) is that the flag outlives a failed shield + # CHANGELOG) is that the flag outlives a failed shield # only where a rollback was DECLINED or could not be made, and the reason # says which. `needs_enable` gates it — a safety property, not a # micro-optimization, and see `_shield_undo_extension` for the second gate diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 2cd367da2..7e3825a98 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -1191,7 +1191,7 @@ class RunState: # only surviving pin: # * the state root is keyed by the project's RESOLVED PATH (`runs.project_tag`), # so moving or renaming the project keys the run somewhere new and orphans - # its state subtree (FEATURES.md documents the GC half of this). state.json + # its state subtree (docs/reference/run-state.md documents the GC half of this). state.json # lives in the run dir and travels with it. Same for a BMAD_LOOP_STATE_DIR # that changes between launch and resume. # * a run PAUSED before #498 has its baseline here and nowhere else; the first diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 602143df3..125abb68f 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -1008,7 +1008,7 @@ def read_trusted_config_digest(project: Path, run_id: str) -> tuple[str | None, **Known limit: a file at this key can be stale (#572).** The key is the project's resolved path, so a project that moves away and later returns finds - its old subtree still here — nothing can sweep it in between (FEATURES.md) — + its old subtree still here — nothing can sweep it in between (docs/reference/run-state.md) — holding the baseline blessed before it left, while the blessing it picked up in between is the one in ``state.json``. Preferring the file means that older pin wins for one resume, which re-stamps this key and heals it. Preferring the @@ -3205,7 +3205,7 @@ def live_session_may_be_ours( multiplexer is unusable fires on every box that simply has no multiplexer, which is the noise ``BaseTmuxBackend._warn_unproven_listing`` already stays silent about. What is left is the accepted ceiling, written down here and in - docs/FEATURES.md so an operator meets it as a documented limit rather than a + docs/multiplexer-backends.md so an operator meets it as a documented limit rather than a surprise. The psmux half is a *defect*, not a design: four sibling reap sites in that @@ -4062,7 +4062,7 @@ def unreadable_sweep_ledger(project: Path, run_dir: Path) -> str | None: The run's own read degrades rather than raising (`sweep._read_cycle_ledger`, DW-197), so the cost is not a crash but a wasted arm and a stop whose repair route this surface never offered: every repair steer in the product - (`sweep._notify_ledger_repair`, the ATTENTION line, docs/FEATURES.md) routes to + (`sweep._notify_ledger_repair`, the ATTENTION line, docs/sweep-guide.md) routes to a fresh `bmad-loop sweep`, and `resume` was the one that silently accepted the attempt instead. diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 01e04780e..8574daec7 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -8900,9 +8900,8 @@ def verify_dev( something, ``None`` when the probe could not answer. Read ``False`` as exactly that and no further — the residue the gate counts is not attributed to a session, here or in the gate itself, because under a shared checkout it cannot - be (see the newer-claim paragraph above, and `docs/FEATURES.md` on - ``isolation``). What separates "unknown" from "no skip happened" is - ``park_proof_skipped``, not this field — collapsing the two into + be (see the newer-claim paragraph above). What separates "unknown" from "no + skip happened" is ``park_proof_skipped``, not this field — collapsing the two into ``park_zero_diff is not None`` would make a park whose probe faulted look like a leg that never waived anything, and it would go unrecorded — the silence this record exists to end. ``None`` means "the probe could not answer", and diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index c134f3594..97b217d17 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -1946,8 +1946,8 @@ def _codex_trust_reason(self, role: str, worktree: Path, status: str, reason: st ``unverifiable`` verdict usually has a cause a prompt cannot fix, so that text names the likely fixes first. Both end with the remediation and leave out the recovery command: :meth:`escalate_unit` appends its own - "resolve, then resume" suffix, and docs/FEATURES.md spells out the exact - ``resolve --no-interactive`` + ``resume`` sequence. + "resolve, then resume" suffix, and docs/setup-guide.md spells out the + ``resolve --no-interactive`` recovery sequence. """ head = ( f"Codex hook trust is {status} for the {role} session's worktree {worktree} " diff --git a/tests/test_cli.py b/tests/test_cli.py index 5741deebd..76b089586 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8552,8 +8552,8 @@ def test_resume_warns_when_the_trusted_baseline_is_unreadable(project, monkeypat def test_resume_still_warns_after_the_project_is_renamed(project, monkeypatch, capsys): """The out-of-tree baseline is keyed by the project's RESOLVED PATH (`runs.project_tag`), so moving or renaming the project keys the run somewhere - new and orphans the subtree holding its `config-digest` — FEATURES.md documents - the GC half of the same fact. state.json travels with the run dir instead. + new and orphans the subtree holding its `config-digest` — + docs/reference/run-state.md documents the GC half of the same fact. state.json travels with the run dir instead. Stamping ONLY out of tree therefore made a rename silently retire the pin: the reader answers `None`, the in-tree copy was never written under that code, and diff --git a/tests/test_docs_hygiene.py b/tests/test_docs_hygiene.py index 13500ff8d..bf5eef9f1 100644 --- a/tests/test_docs_hygiene.py +++ b/tests/test_docs_hygiene.py @@ -173,7 +173,7 @@ def test_slugger_matches_github_on_known_anchors(): "docs/plugin-authoring-guide.md": "trust--safety", "README.md": "policy-bmad-looppolicytoml", "docs/multiplexer-backends.md": "psmux-native-windows-experimental", - "docs/FEATURES.md": "resolve-reverify", + "docs/escalations-guide.md": "resolve-reverify", } missing = {rel: a for rel, a in real.items() if a not in anchors(rel)} assert not missing, f"slugger lost known real anchors: {missing}" diff --git a/tests/test_engine.py b/tests/test_engine.py index c04256925..c56de02cf 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -15868,9 +15868,8 @@ def test_auto_sweep_records_a_child_stopped_by_a_ledger_fault_as_finished(projec That is the truthful record, which is why the row asserts it rather than trying to restore the old one: the child finalized its run dir and reported its own stop, with its own - repair notice naming the ledger. Repair is followed by a fresh sweep. The cost, named in - `docs/FEATURES.md` beside the other auto-sweep residuals, is that the PARENT's - journal no longer says anything about the ledger — the stop token lives in the + repair notice naming the ledger. Repair is followed by a fresh sweep. The cost is + that the PARENT's journal no longer says anything about the ledger — the stop token lives in the child's run dir. This row is where that trade is written down. The child is driven through the real `sweep_factory` seam with a real diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 856876815..40e907a04 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -6661,7 +6661,7 @@ def test_per_worktree_setup_then_gate_then_teardown_and_seed(project): waits (and only passes because setup ran first), the agent runs, teardown fires. Ordering is proven by the gate depending on a setup marker.""" # The MCP-generated skill tree really is gitignored in a per_worktree project - # (docs/FEATURES.md), and this fixture has to say so itself: until #384 the + # (docs/game-engine-mcp-guide.md), and this fixture has to say so itself: until #384 the # git-add shield wrote its patterns into the repo-wide `.git/info/exclude`, so # the untracked dir below was hidden in the MAIN checkout too and the pre-merge # cleanliness gate never saw it. The shield is per-worktree now, so the gitignore diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 5a72d008b..527f8433c 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -5685,7 +5685,7 @@ def test_window_death_distinguishes_a_destroyed_session_from_an_exited_cli( # Only a negative lookup is confirmed (DW-459); a live or unaskable session # never reaches the listing. assert adapter.mux.listed == ([adapter.session_name] if has_session is False else []) - # The durable half of the diagnosis: CHANGELOG and FEATURES both promise this + # The durable half of the diagnosis: the CHANGELOG promises this # crumb, and without an assertion deleting the write keeps the suite green. crumbs = _lifecycle_events(adapter, "session-vanished") assert len(crumbs) == (1 if expect_vanished else 0) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index ec6d2edad..48758b483 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -9032,7 +9032,7 @@ def test_a_new_run_re_evaluates_a_dropped_keep_open_answer(project): """DW-124's quarantine is run-scoped BY DESIGN: persisted so the SAME run does not re-announce a disposition it already made, but never project-scoped, so a NEW run re-evaluates every decision from scratch. That second half is the claim - the CHANGELOG, docs/FEATURES.md and both the `RunState` and `__init__` comments + the CHANGELOG and both the `RunState` and `__init__` comments all make, and the resume rows above cannot catch it — they share one run_dir, so a quarantine that leaked across runs would leave them green. @@ -14273,7 +14273,7 @@ def test_a_faulted_close_is_reapplied_by_a_resume_off_the_cached_triage(project, would fail, and an empty prompter input, so re-asking DW-1 would raise `StopIteration`. Both are load-bearing: this is what makes the `plan.decisions` scoping meaningful, since the cached plan is where a suppressed decision actually - comes from, and it is what pins the FEATURES claim that a later cycle repairs the + comes from, and it is what pins the claim that a later cycle repairs the DW-166 degrade without needing a new run. Five claims: the first phase really did leave the limbo (open entry, stored @@ -18621,8 +18621,8 @@ def test_a_repeat_stop_journals_a_stop_cause_beside_its_reason(project): and `ledger-unreadable` ("a human has to repair the file") are opposite findings, and a dump could tell them apart only by inferring from other rows. - `stop_cause` is added BESIDE `reason`, never in place of it: the raw journal, - `docs/FEATURES.md` and the existing rows above all name `reason`, so both are + `stop_cause` is added BESIDE `reason`, never in place of it: the raw journal + and the existing rows above all name `reason`, so both are written and both carry the same token. Same closed-slug convention as `regen_cause` (DW-164) and `drop_cause`. diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 6092aa57c..39b8fe3b7 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -8495,8 +8495,8 @@ async def test_escalation_rearm_echoes_residue_when_the_rearm_aborts(project, mo used to `return` inside the `except` and drop the whole family — including `stale-restore-commits`, which `cli._echo_rearm_events`' own docstring calls the one record a human must act on. The two surfaces had been unified on ROUTING while - still drifting on the abort path, and `docs/FEATURES.md` claimed they could not - drift at all. + still drifting on the abort path, while the docs claimed they could not drift + at all. Ablation: move the `self._echo_rearm_events(...)` call out of the `finally` and back below the `try`, and this reddens — the commits warning never fires — while diff --git a/tests/test_verify.py b/tests/test_verify.py index ed5d5e216..b2a4d8c0d 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1272,8 +1272,8 @@ def test_verify_dev_park_zero_diff_is_unknown_without_a_recorded_baseline(projec one, so there is nothing to measure from and the observation never happens — yet the waiver did, and the record still has to say so. - All three causes are named in `verify_dev`'s docstring, in `VerifyOutcome`'s - field comment and in `docs/FEATURES.md`; the sibling rows cover the `GitError` + All three causes are named in `verify_dev`'s docstring and in `VerifyOutcome`'s + field comment; the sibling rows cover the `GitError` and the git refusal, and this one covers the missing baseline, so no claim rests on prose. From 260a4493f2b84ffa5dfe97c23923496db8695cf4 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 02:27:20 -0700 Subject: [PATCH 5/9] docs(readme): slim README to an overview and link the reference pages --- README.md | 756 +++++--------------------------- docs/multiplexer-backends.md | 2 +- docs/reference/configuration.md | 2 +- docs/setup-guide.md | 56 ++- docs/sweep-guide.md | 2 + src/bmad_loop/envvars.py | 4 +- tests/test_cli.py | 4 +- tests/test_docs_hygiene.py | 2 +- tests/test_envvars.py | 6 +- 9 files changed, 163 insertions(+), 671 deletions(-) diff --git a/README.md b/README.md index 20f229dd5..e5d944bd0 100644 --- a/README.md +++ b/README.md @@ -15,746 +15,210 @@ Plain Python drives the loop — **pick story → implement → adversarially re The bmad-loop TUI dashboard: run picker, sprint tree, deferred-work ledger, a live per-story task table, and a colour-coded journal. -The live TUI dashboard — run picker, sprint tree, deferred-work ledger, per-story task table, and a tailing journal. Jump to the TUI tour ↓ +The live TUI dashboard. Jump to the TUI tour ↓ A walkthrough of the bmad-loop TUI: the live run dashboard, the sprint tree, a deferred-work entry, answering a missed decision, the start-run modal, a sweep blocked on a human decision, and the policy editor with its worktree-isolation settings expanded. -A tour of the dashboard — walking the runs table, unfolding the sprint tree, opening a deferred-work entry, answering a decision a past sweep left unanswered, typing a story into the start-run modal, a sweep blocked on a decision, and scrolling the policy editor out to its worktree-isolation + config-seeding knobs. More on the TUI ↓ +A tour of the dashboard: runs, the sprint tree, a deferred-work entry, answering a decision, the start-run modal, a sweep blocked on a decision, and the policy editor. --- -> ⚠️ **Early open beta — experimental, and moving fast.** bmad-loop is a young project that -> only just started shipping publicly. Expect rough edges, docs that lag the code, and -> **breaking changes in any release while it is pre-1.0** — CLI flags, policy keys, on-disk -> run state, and skill contracts can all shift between versions. It also drives real coding -> agents that write and commit in your repository, so point it at work you can review and -> roll back (`[scm] isolation = "worktree"` keeps a failed attempt off your main checkout), -> pin a release tag if you want a stable base, and read the [CHANGELOG](CHANGELOG.md) before -> upgrading. Bug reports and feedback are what the beta is for — open an -> [issue](https://github.com/bmad-code-org/bmad-loop/issues) with `bmad-loop diagnose` output -> attached, or come talk to us on [Discord](https://discord.gg/gk8jAdXWmj). +> ⚠️ **Early open beta — experimental, and moving fast.** Expect rough edges and **breaking changes in any pre-1.0 release** (CLI flags, policy keys, on-disk run state, skill contracts). bmad-loop drives real coding agents that write and commit in your repository, so point it at work you can review and roll back (`[scm] isolation = "worktree"` keeps attempts off your main checkout), pin a release tag for a stable base, and read the [CHANGELOG](CHANGELOG.md) before upgrading. +> Report bugs as an [issue](https://github.com/bmad-code-org/bmad-loop/issues) with `bmad-loop diagnose` output attached, or talk to us on [Discord](https://discord.gg/gk8jAdXWmj). -## Why bmad-loop +bmad-loop runs BMAD stories unattended. It reads your sprint board (or a `bmad-spec` story breakdown), launches a fresh coding-agent session for each story inside a terminal multiplexer, checks what the session left on disk, runs your tests, and commits — then moves to the next story. It was inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad-automator) (a separate, legacy project), but here the orchestrator is ordinary code, not an LLM session. -Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad-automator) (a separate, legacy project), it takes a token-optimized approach in which the orchestrator is ordinary code rather than an LLM session in the control loop: +## Why bmad-loop -- 🧠 **No LLM in the control loop.** Story selection, retry budgets, gates, and completion checks are code, not prompts — so they're deterministic, debuggable, and free. -- 📡 **No pane-scraping.** Coding-agent hooks (`Stop` / `SessionStart` / `SessionEnd` / `PreCompact`) write structured event files the orchestrator watches; skills in automation mode write a machine-readable `result.json` at the end of each workflow. -- 🔍 **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). -- 🌿 **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. +- 🧠 **No LLM in the control loop.** Story selection, retry budgets, gates and completion checks are plain Python: deterministic, debuggable, and free. A session counts as complete only on a completion event from the coding agent or the death of its session, never on model prose; timeouts, stalls and budget caps end it as a failure. +- 🔍 **Trust nothing, verify everything.** After each session the orchestrator checks the spec status, the baseline commit, that code actually changed, and that the board matches, then runs _your_ `[verify]` commands before it commits. +- 🪟 **Fresh-context review.** Review runs as a separate session that never inherits the implementer's context, optionally on a different CLI or model. It is bounded, so a story cannot oscillate forever. +- 📒 **One board writer.** During a run bmad-loop is the only writer of `sprint-status.yaml` rows; board writes are serialized and never move a story backward, except that a review can return a `done` story to `awaiting-operator`. +- ♻️ **Built for unattended runs.** Every run is resumable on-disk state. A story that never converges is deferred and the run continues; a CRITICAL escalation pauses for `bmad-loop resolve`; work only a human can do parks until `bmad-loop confirm`. +- 📋 **Deferred work gets done.** Split-off goals and review findings land in a ledger that `bmad-loop sweep` triages against the code and builds through the same pipeline. +- 🔌 **Any of six coding CLIs.** `claude`, `codex`, `gemini`, `copilot`, `antigravity` and `opencode` are TOML profiles, mixable per stage, with cost-weighted token budgets. +- 🌿 **Optional worktree isolation.** Each story runs on its own branch and merges back locally, so your checkout stays free during a run. ## 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). -- **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. - - the review-layer skills its step-04 invokes inline — `bmad-review-adversarial-general` + `bmad-review-edge-case-hunter`, or the merged `bmad-review` skill that supersedes them in newer releases. - - the bmad-loop skill module from this repo (`bmad-loop-resolve`, `bmad-loop-sweep`) — see [Installing the skill module](#installing-the-skill-module). +- **Python 3.11+** and **git 2.34+** (enforced: `run`, `sweep` and `resume` refuse to start below it). +- **Linux, macOS, or Windows via WSL**, with a terminal multiplexer: tmux (3.2+) is the default. Native Windows uses the bundled psmux backend, which is experimental — see [Terminal multiplexer backends](docs/multiplexer-backends.md). +- A supported coding CLI — `claude` by default, or another [profile](#other-coding-clis). +- A **BMAD v6 project** on **BMAD-METHOD ≥ 6.10.0**, with the upstream dev skill `bmad-build-auto` installed (`bmad-dev-auto` on pre-rename releases). `bmad-loop validate` checks it and any review skills it invokes. Sprint mode also needs a `sprint-status.yaml` from `bmad-sprint-planning`. ## Quick start ```bash -uv sync --extra tui # core is pyyaml-only; [tui] adds the dashboard +uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" cd /path/to/your/bmad/project -bmad-loop init # installs bmad-loop-* skills + hooks + .bmad-loop/policy.toml + gitignore -bmad-loop validate # preflight: config, sprint-status, git, tmux, CLI, hooks +bmad-loop init # bundled skills, hooks, .bmad-loop/policy.toml, gitignore +bmad-loop validate # preflight: config, board, git, multiplexer, CLI, hooks bmad-loop run --dry-run # print the plan without spawning anything bmad-loop run # go bmad-loop tui # …or drive everything from the dashboard ``` -> **One-time setup:** if the coding CLI has never run in the target project, start it once (`claude`) and accept the workspace-trust dialog (and any hooks-approval prompt) before `bmad-loop run`. Spawned sessions can't answer first-run dialogs, and a pending dialog reads as a session timeout to the orchestrator. - -## Command reference - -| Command | What it does | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bmad-loop init` | Install the bundled `bmad-loop-*` skills, the hook relay, `.bmad-loop/policy.toml`, and a gitignore for the runs dir, plugin caches, and policy.toml itself (policy is per-machine — see the CHANGELOG migration note for repos initialized before this). `--cli ` (repeatable) targets specific agents; `--no-skills` / `--force-skills` control skill copying. | -| `bmad-loop validate` | Preflight every prerequisite: BMAD config, sprint-status, git, CLI binary, hook registration, and a platform check that reports the selected multiplexer's readiness and process host (listing every registered backend when more than one is detected). `--spec ` validates that folder's `stories.yaml` for a stories-mode run instead of the sprint-status queue. `--json` emits the preflight as a stable machine-readable document (see [Scripting `validate`](#scripting-validate)). | -| `bmad-loop mux` | List registered terminal-multiplexer backends — platform match, availability, version, and which one is selected (and why). `mux set ` persists a machine-scoped choice into `.bmad-loop/policy.toml` (`--clear` reverts to auto-select, `--force` allows a name that only registers on the target machine); the `BMAD_LOOP_MUX_BACKEND` env var outranks it. | -| `bmad-loop plugins` | List discovered plugins with origin, executable surface and grant status (`--json` for a schema-versioned document). `plugins enable ` grants a project plugin by recording the sha256 of its whole dir outside the repo; `plugins accept ` re-pins a changed one after listing what changed; `plugins revoke ` drops a grant. Never prompts, never writes policy.toml. | -| `bmad-loop adapters` | List registered coding-CLI adapter **kinds** — name, builtin/external, whether the family drives a multiplexer, and which profiles select each — the CLI axis's counterpart to `mux`. Unlike `mux` there is no global choice to persist: a kind is selected per profile by its `adapter` field. A profile naming an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a `warning:` on stderr. | -| `bmad-loop run` | Drive the dev → review → verify → commit loop. `--epic N`, `--story KEY`, `--max-stories N`, `--dry-run`. `--spec ` forces **stories mode** (folder+id dispatch off `/stories.yaml`), overriding `[stories].source`; `--story` then filters by story id. | -| `bmad-loop sweep` | Triage + execute open `deferred-work.md` entries. `--only DW-1,DW-3` or `--min-severity high` selects the pre-triage universe; also supports `--no-prompt`, `--decisions-only`, `--max-bundles N`, `--repeat`, `--max-cycles N`, `--dry-run`. `--archive [--before DATE]` instead moves closed ledger entries to `deferred-work-archive.md`, leaving id-preserving stubs. | -| `bmad-loop resume ` | Continue a run paused at a gate, escalation, or interruption. The resume command rendezvouses with delete/archive on the run lifecycle lock; if cleanup removed the run while resume waited, resume reports it missing without recreating files or launching an engine. | -| `bmad-loop resolve ` | Resolve a CRITICAL escalation: open an interactive resolve agent to fix the frozen spec, then re-arm the story and resume — the resume is held when the correction provably has not reached the tree the re-drive reads (see [Resolving a CRITICAL escalation](#how-a-story-flows)). On an _intent gap_ the re-drive can resume review on the attempted change instead of re-implementing it. `--story KEY`, `--no-interactive`, `--restore-patch ` (intent-gap patch-restore), `--adopt-branch` (finish the story from its kept worktree branch as-is, without re-running review, `[verify]` commands or `pre_commit_gate` workflows), `--reverify` (replay verify on a DEFERRED story's kept work, or an env-fault escalated one's at a replayable fault site, no dev session), `--abandon` (close an escalated replay run whose unit is superseded: remove its worktree, keep its branch, record the run finished), `--resume` / `--no-resume`, `--force` (proceed when engine liveness is unverifiable; a provably-live engine still blocks). | -| `bmad-loop decisions` | Answer deferred-work decisions earlier sweeps left unanswered (skipped by `--no-prompt`, or an abandoned interactive sweep). Recorded so the next sweep acts on them without re-asking. `--list` shows them without answering; `--json` emits them as a stable machine-readable document — id, question, context, recommendation, and every option's key/label/effect/intent/resolution/bundle-name with a derived `recommended` flag. It implies the listing and never prompts, so a script can select an option by policy instead of scraping the text. | -| `bmad-loop confirm ` | Complete a story parked at `awaiting-operator` once you have carried out the external actions it owes (buy the domain, publish the DNS record). Acknowledges each action in turn, writes the spec's `## Operator Confirmation` audit section, advances spec and board to `done`, and commits the pair — nothing is re-driven. `--list` shows every parked story and what it owes; `--yes` skips the prompts; `--reverify` re-runs the project's `[verify]` commands first and blocks the confirmation if they fail; `--json` emits the parked set as a stable machine-readable document. Every write is checked and the spec is read back from disk, so a story is never declared done over a write that did not land; a confirmation interrupted before its board write is **finished** by re-running the command, with no second prompt and no second audit section. The index it reads is machine-local, so a park is confirmed on the machine that ran it. | -| `bmad-loop list` (`ls`) | List every run/sweep with its short ref, type, and status — the handle you pass to the commands below. `--json` emits a stable machine-readable document instead — one entry per run, oldest first (short ref, run id, type, started-at, liveness-aware status, paused stage); an empty runs dir yields a valid empty document. | -| `bmad-loop status []` | Run + sprint summary with per-story token totals — cost-weighted, with the raw count alongside — plus a count of decisions awaiting an answer. A stories-mode run instead prints its stories board — id, live on-disk state, checkpoint markers, title. `--json` replaces all of that with a stable machine-readable document (see [Scripting `status`](#scripting-status)). | -| `bmad-loop diagnose []` (`diag`) | Emit a **sanitized** diagnostic dump of a run/sweep to hand maintainers when reporting a bug — phase/token/session histograms, escalation counts, adapter/model, env, and run-dir file sizes, with no code, spec content, prompts, transcripts, paths, or PII. Identifiers are pseudonymized to stable per-dump aliases and the output is re-scanned by a fail-closed leak check before writing; a stray pseudonymized identifier is auto-substituted with its alias (disclosed in the report), while PII/secret hits still refuse to emit. Defaults to the latest run. `--all`, `--out`, `--max-journal-entries N`; `--json` emits the dump as a stable JSON document (one object on stdout, no fences) instead of the markdown report. | -| `bmad-loop attach []` | tmux-attach to a run's live agent session. | -| `bmad-loop stop ` | Stop a live run — the engine and its agent tmux session. `--graceful` instead finishes the in-flight item (a story through commit, a sweep bundle through commit), then stops cleanly and stays resumable, and suppresses pending auto-sweeps; `--cancel-graceful` withdraws a pending request. The hard stop is the default and always wins over a pending graceful one. | -| `bmad-loop delete ` | Delete a run directory. `--force` stops the run first if it is still live, but cannot override a rival resume that becomes live before removal. Unknown liveness still warns and proceeds. | -| `bmad-loop archive ` | Compress a run into `.bmad-loop/archive` and remove the run dir. `--force` stops the run first if it is still live, but cannot override a rival resume that becomes live before archival. Unknown liveness still warns and proceeds. | -| `bmad-loop cleanup` | Remove leftover tmux artifacts **for the current project**: kill `bmad-loop-` sessions for finished/stopped/interrupted runs (and orphans whose run dir is gone) and close parked `bmad-loop-ctl` windows. `--dry-run` lists without killing. Live runs — and any session/window belonging to another project — are never touched. `--json` emits a stable machine-readable document instead of the text — the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a `dry_run` flag — so a preview and the real run share one schema and can be compared. | -| `bmad-loop clean` | Reclaim **disk** from concluded runs per `[cleanup]`: tear down git worktrees a mid-flight stop orphaned (freeing their Unity `Library/` + MCP-server builds), trim the heavy `worktrees/` tree from runs kept for history (they stay viewable in the TUI), and archive/delete runs past the retention window. Only finished/stopped runs are touched; a run that resumes during clean is recorded as protected or trimmed according to work already done, and siblings continue. `--dry-run` previews, `--keep ` protects, `--retain N` overrides the window, `--hard` deletes instead of archiving. `--json` emits one stable machine-readable document instead of the text — the effective retention policy, `freed_bytes` as a raw integer, and the worktree paths and run ids reclaimed, trimmed, archived, deleted or protected. | -| `bmad-loop tui` | The interactive dashboard (needs the `[tui]` extra). `--low-frame-rate` caps it to 15fps + disables animations (fixes repaint tearing over slow/SSH links; also `[tui] low_frame_rate`). | -| `bmad-loop probe-adapter ` (`collect-adapter-data`) | Collect + sanitize the data needed to finalize a CLI adapter profile (hook payload shape, transcript location/format, token schema). Default is a zero-launch **scan**; `--probe` opts into a live capture (`--model` picks the probe turn's model, `--timeout` bounds it, default 90s). `--transcript`, `--session-dir`, `--binary` (CLIs with no profile yet), `--out`; `--json` emits the finding as a stable JSON document instead of the report. See the [adapter authoring guide](docs/adapter-authoring-guide.md). | - -Every command takes `--project ` (default: the current directory). Any `` may be a -partial — the tail after the last `-` (e.g. `a1b2`), shortened to any prefix that stays unique; -`bmad-loop list` shows each run's short ref. - -> One subcommand is deliberately left out of the table: `bmad-loop relay ` writes a single -> session event file from a coding-CLI hook payload on stdin. Its own help calls it "a hook target -> for machines, not a command to run by hand" — it takes no `--project`, and `bmad-loop init` -> registers this installed command with an absolute path. Never invoke it yourself. +> **One-time setup:** start the coding CLI once in the project (`claude`) and accept its workspace-trust and hooks prompts before `bmad-loop run`. Spawned sessions can't answer first-run dialogs; see [First-run notes](docs/setup-guide.md#first-run-notes) for each CLI. + +Every subcommand, flag, `--json` document and exit code is in the [CLI reference](docs/reference/cli.md). + +## How a story flows + +```text +sprint-status.yaml: 1-2-account-mgmt: ready-for-dev + │ + ├─ DEV fresh session: /bmad-build-auto 1-2-account-mgmt + │ plans a spec, implements, self-reviews, finalizes the spec → done; + │ the CLI's Stop hook tells the orchestrator the session ended + ├─ VERIFY spec status · baseline · non-empty diff · board in sync + │ · your [verify] commands — a failure feeds a fix session the output + ├─ REVIEW fresh session re-invokes /bmad-build-auto on the done spec, when the + │ (gated) dev pass recommends it ([review] trigger); at most + │ [limits] max_review_cycles rounds (default 3) + ├─ VERIFY the same checks again after review's patches + └─ COMMIT the orchestrator squashes the work into one story commit + (worktree isolation then merges the unit branch locally); + epic boundary → gate / retrospective +``` + +A failed attempt is retried within `[limits] max_dev_attempts`. A story that exhausts its budget is plateau-deferred and the run moves on. An environment fault (a failing `[environment]` probe, or a verify command exiting 126, 127 or `[verify] env_fault_rc`) pauses the run without charging the attempt. +A CRITICAL escalation pauses the run and notifies you (desktop notification and an `ATTENTION` file); `bmad-loop resolve ` fixes the spec with an interactive agent and re-drives the story. In place (`[scm] isolation = "none"`), a failed or deferred attempt that left changes pauses for you to roll back by hand unless `[scm] rollback_on_failure` is on. +Every pause and its remedy is in [Recovering paused runs](docs/escalations-guide.md). + +## Two planning pipelines, one loop + +- **Sprint mode (default).** Stories come from `sprint-status.yaml`; the loop picks `backlog` and `ready-for-dev` stories by key. +- **Stories mode (opt-in).** Stories come from the typed `stories.yaml` that `bmad-spec`'s Story Breakdown writes beside `SPEC.md`, dispatched by spec folder and story id in list order. Each entry can set `spec_checkpoint` (pause after planning, before code) and `done_checkpoint` (pause after the commit). + +Turn stories mode on with `[stories] source = "stories"` and `spec_folder`, or per run with `bmad-loop run --spec `. Everything downstream is the same loop. See [Stories mode](docs/FEATURES.md#stories-mode). + +## Deferred-work sweeps + +Dev and review sessions append split-off goals, pre-existing findings and open questions to a ledger (`deferred-work.md`, `DW-` entries). `bmad-loop sweep` starts a triage session that checks each open entry against the code and sorts it: already resolved (closed with evidence), buildable bundles, blocked, skipped, or a decision for you. +An attended sweep asks each decision on the terminal; bundles then run through the normal dev → verify → review → commit pipeline. Unanswered decisions can be answered later with `bmad-loop decisions` or `d` in the TUI. A story can also close entries itself (`closes_deferred`), and a `gate:` line in an entry blocks the stories it names until it is done. +See the [sweep guide](docs/sweep-guide.md). ## The TUI ```bash -uv sync --extra tui # textual + tomlkit + pyte + rich -bmad-loop tui +bmad-loop tui # needs the [tui] extra ``` -A live, read-only dashboard over everything below — and a launcher for new runs. It's the fastest way to understand what the orchestrator is doing. - -### Dashboard +A live dashboard over every run, and a launcher for new ones. It observes runs only through the files the engine writes, so runs started from a shell show up identically, and the runs it launches are detached processes that survive a TUI exit. Full guide, every key and modal: [docs/tui-guide.md](docs/tui-guide.md).
Dashboard: runs table, run header with token totals, per-story phase table, sprint tree, deferred-work ledger, and the journal tab.
-The left column stacks the **runs table** (newest auto-selected; paused runs carry a kind badge, and the title a global _⚑ N need attention_ count), an expandable **sprint tree** — replaced by a **stories board** in stories mode — and the severity-coloured **deferred-work ledger**. The right column shows the run **header** (status, epic, task counts, weighted token total, and the **active agent** driving the current stage), a **per-story table** (phase · agent · attempts · review cycles · tokens · commit/defer), and tabs tailing the **journal**, the session's **pane log**, and the **ATTENTION** file. Every pane boundary is resizable — drag a divider bar or press **`ctrl+w`** — and sizes persist per-project to `[tui]` in `policy.toml` ([TUI guide](docs/tui-guide.md#resizing-panes)). On a paused run, **`p`** opens the stage-appropriate HITL viewer, calling the exact code paths the CLI uses. - -### A sweep blocked on a human decision +**Dashboard.** Runs, the sprint tree (a stories board in stories mode) and the deferred-work ledger on the left; the run header, per-story task table, and journal, pane-log and `ATTENTION` tabs on the right. Panes are [resizable](docs/tui-guide.md#resizing-panes).
A sweep run showing a yellow 'decision needed' banner and the decision-pending journal event.
-Sweeps run as their own `[sweep]`-tagged runs. When an attended sweep hits a "needs human decision" item it blocks on its own terminal prompt; the dashboard spots the `decision-pending` journal event and raises a banner + toast — press **`a`** to attach to the sweep's window, answer, and detach. - -### Answering decisions a past sweep left unanswered +**A sweep blocked on a decision.** The dashboard raises a banner; press `a` to attach to the sweep's window and answer.
-A modal answering deferred-work decision DW-1, with the question, context, and build/keep-open/close options (recommended marked). +A modal answering a deferred-work decision, with the question, context, and build/keep-open/close options (recommended marked).
-Unattended sweeps (`--no-prompt`) skip decisions, and an attended one can be abandoned mid-way — those answers would otherwise be lost. The Deferred Work pane shows the outstanding count (**`— N to answer (d)`**); press **`d`** (or run `bmad-loop decisions`) to walk each one. A `close` is applied immediately; a `build` / `keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep with no re-prompt. - -### Deferred-work entry & the start-run modal +**Answering decisions later.** `d` walks the decisions past sweeps left open; a `close` applies at once, and `build` / `keep-open` are saved for the next sweep.
-A modal showing the full body of deferred-work entry DW-1. +A modal showing the full body of a deferred-work entry. The start-run modal with epic, story, max-stories, and dry-run fields.
-`enter` on any ledger row opens the full entry; `r` / `s` open modals to launch a run or sweep (epic, story, max-stories, dry-run). The start-run modal also carries a **source select** (sprint vs. stories mode, prefilled from `[stories]`) and a spec-folder field with a live schedule preview that validates `stories.yaml` and lists the linear schedule with checkpoint markers. - -### The policy editor +**Ledger entries and launching.** `enter` on a ledger row opens the full entry; `r` opens the start-run modal (sprint or stories source, epic, story, max stories, dry run) and `s` the sweep modal.
The settings screen editing .bmad-loop/policy.toml, grouped by section with defaults shown as placeholders.
-Press **`g`** to edit `.bmad-loop/policy.toml` in a form grouped by section — comment-preserving (tomlkit), validated with the engine's own parser before saving, with unset keys showing their defaults as placeholders. Every section starts collapsed with a one-line description; **`ctrl+e`** expands/collapses all at once. +**Policy editor.** `g` edits `.bmad-loop/policy.toml` by section, preserving comments and validating with the engine's own parser before saving. -### Key bindings +## Installing and upgrading -| Key | Action | -| --------- | -------------------------------------------------------------------------------------------------------- | -| `r` / `s` | start a run / sweep (modal for epic, story, max-stories, dry-run…) | -| `e` | resume the selected paused/interrupted run | -| `p` | review the selected paused run in the stage-appropriate viewer (checkpoint, gate, escalation, env fault) | -| `R` | resolve a run paused at an escalation (interactive, then re-arm) | -| `d` | answer deferred-work decisions past sweeps left unanswered | -| `a` | attach to the live agent session (or the orchestrator window) | -| `x` | stop the selected live run immediately (engine + agent session) | -| `S` | graceful stop: finish the in-flight item (through commit), then stop cleanly — stays resumable | -| `D` / `A` | delete / archive the selected run (refuses while its engine is live) | -| `c` | clean up tmux sessions/windows for finished & stopped runs | -| `v` | run `bmad-loop validate`, output in a modal | -| `g` | settings editor for `.bmad-loop/policy.toml` | -| `y` | copy the active Log/Attention pane to the clipboard | -| `ctrl+w` | enter/leave pane resize mode (arrows resize, `Tab` picks the boundary) — or drag any divider bar | -| `M` / `q` | toggle theme (light/dark mode) / quit | +`bmad-loop init` installs the three bundled skills — `bmad-loop-resolve`, `bmad-loop-sweep` and `bmad-loop-setup` — into each CLI's skills directory, plus the hook relay and the policy file. Your BMAD install is never modified. If the BMAD-method installer already placed the skills, run `claude "/bmad-loop-setup accept all defaults"`: it installs the tool, wires the hooks, and runs the preflight. -**The TUI is an observer/launcher, never the engine.** Runs started with `r`/`s` are detached `bmad-loop` processes in windows of a dedicated tmux session (`bmad-loop-ctl`; on psmux the name carries a per-project registry suffix), so they survive a TUI exit or crash; the dashboard watches runs purely through the run-dir artifacts the engine writes atomically, so runs started from a plain shell show up identically. Launch and attach need tmux; the dashboard itself does not. Pid-based liveness is local-only — a run whose engine died shows `interrupted` (press `e`); runs on other hosts show `unknown`. +To upgrade, run `claude "/bmad-loop-setup upgrade"`, or by hand `uv tool upgrade bmad-loop --reinstall` followed by `bmad-loop init --force-skills` in each project. Pinned-tag installs, other CLIs, upgrading and uninstalling are in the [setup guide](docs/setup-guide.md). -> 📖 See **[docs/tui-guide.md](docs/tui-guide.md)** for the full guide — layout, every key and modal, status glyphs, the settings field reference, and troubleshooting. Vector (SVG) versions of every screenshot live in [`docs/images/`](docs/images). +## Configuration -## Two planning pipelines, one loop - -bmad-loop drives the same dev → verify → review → commit loop from **either** of two story sources — chosen per project, or per run: - -- **Sprint mode (default).** Stories come from `sprint-status.yaml` (written by `bmad-sprint-planning` from your PRD/epics). The loop walks the board by `ready-for-dev` status, keyed by story ref (`1-2-account-mgmt`). This is what the rest of this README describes. -- **Stories mode (opt-in).** Stories come from a typed `stories.yaml` — the **Story Breakdown** output of `bmad-spec`, a fixed-name sibling of `SPEC.md` in the epic's spec folder. The loop dispatches each entry by **folder + id** (`/bmad-build-auto Spec folder: . Story id: .`, spelled with whichever primitive name resolves on disk); the dev skill creates-or-resumes the story spec at `/stories/-.md`, and the orchestrator reads that id-keyed path back deterministically — no shared board to line-edit, no mtime-scan of result artifacts. - -Turn it on per project with `[stories] source = "stories"` + `spec_folder = ""`, or per run with `bmad-loop run --spec ` (which overrides the policy). Everything downstream — dev/verify/review/commit, worktree isolation, gates, crash resume, the TUI — is identical; only the story source and the per-story controls below differ. - -**Per-story human checkpoints (stories mode).** Each `stories.yaml` entry carries two independent boolean flags: - -- **`spec_checkpoint`** — pause _before_ code, to review the plan. The dev session halts right after planning (`Halt after planning.`) with the spec at `ready-for-dev`; the run pauses at a **plan checkpoint**. Approve to resume straight to implementation, or request a replan (resets the spec to `draft` so the next dispatch re-plans). -- **`done_checkpoint`** — pause _after_ the story commits, to review the result before the loop moves on (skipped automatically when it is the last story). - -An entry may also carry **`closes_deferred`** — the `DW-` ledger ids that story's work closes (see [Deferred-work sweeps](#deferred-work-sweeps)): - -```yaml -- id: '3-2' - title: Export digests - description: … - closes_deferred: [DW-5, DW-6] -``` - -Declaring it here rather than in the story spec is what lets the annotation happen without touching each generated spec: `bmad-build-auto` generates the spec and knows nothing of the ledger, whereas the breakdown is authored while the ledger is in view. A spec that _does_ carry the field in frontmatter is honored too — the two are unioned. - -Like `spec_checkpoint` and `done_checkpoint`, this is a **human-authored, caller-only field**. Breakdown time, with the ledger open, is where it belongs — but it is not a deadline: the declaration is read when the story commits, so adding one to a story spec's frontmatter mid-run is honored, and withdrawing one before the commit means it is not closed. Upstream Story Breakdown does not emit it yet ([BMAD-METHOD#2619](https://github.com/bmad-code-org/BMAD-METHOD/issues/2619)), and re-deriving `stories.yaml` rewrites the file — so record the intent in `.memlog.md` alongside the story, or the declaration is lost on the next re-derive. - -A story may set both checkpoints (it pauses twice); `gates.mode` pauses stack on top. A blocked story escalates exactly as in sprint mode — `bmad-loop resolve`, then re-arm + resume — now with the story's title/description and the blocking condition surfaced; a pre-planning-halt **sentinel** spec is auto-deleted (a copy preserved under the run dir) on re-arm for a clean re-dispatch. - -`bmad-loop run --dry-run --spec ` prints the linear schedule (list order, checkpoint markers, live on-disk state); `bmad-loop status` shows the same stories board. - -> Stories mode requires a dev primitive new enough to support folder+id dispatch; the run preflight (and `bmad-loop validate`) checks for it and tells you to update the BMAD module if it is missing. Sprint mode is unaffected and remains the default indefinitely. - -## How a story flows - -> This is sprint mode's flow. Stories mode swaps the story source (above) and adds the per-story plan/done checkpoints — the loop below is otherwise identical. - -```text -sprint-status.yaml: 1-2-account-mgmt: ready-for-dev - │ - ├─ DEV tmux window: claude "/bmad-build-auto 1-2-account-mgmt" - │ bmad-build-auto: plans a 1.5–4k-token spec, auto-approves it, - │ implements, self-reviews inline (parallel review layers), - │ commits, finalizes spec → done … Stop hook signals the orchestrator - ├─ VERIFY spec exists · status done · baseline matches · diff non-empty - │ (except a park, which may legitimately have produced none) - │ · run [verify].commands in repo_root (pytest, ruff…) — a broken build never - │ reaches review; a failure spawns a fix session fed the output - ├─ REVIEW fresh window: claude "/bmad-build-auto " — re-invoking on a - │ (gated) done spec runs a fresh independent step-04 review pass (parallel review - │ layers → triage → auto-apply patches → ledger → defer ambiguity → - │ commit). Gated on the skill's `followup_review_recommended` flag - │ (review.trigger = "recommended") or every story ("always"); bounded - │ loop, default 3 cycles - ├─ VERIFY spec done · sprint done · run [verify].commands again (repo_root) — a failure - │ routes a feedback-driven dev fix session, then a fresh review cycle - └─ COMMIT orchestrator squashes the iteration's commits into one story commit - (then, under [scm] isolation = "worktree", merges the unit branch - back into the target branch locally); epic boundary → gate / retro -``` - -**Failure handling:** bounded dev retries (verify-command failures keep the tree and feed the failing output to the next session via `--feedback`; other failures roll back to baseline), **plateau-defer** when review won't converge (story skipped, spec stashed into the run dir, `deferred-work.md` additions preserved, run continues), **environment faults** that pause instead of charging the attempt (a failing `[environment]` probe, or a verify command exiting with `[verify] env_fault_rc` — a probe failing before a session launch pauses at the `environment` stage, and a plain `resume` re-probes and launches it), and typed escalations — `CRITICAL` pauses the run and notifies you (desktop + `ATTENTION` file), `PREFERENCE` is journaled and the run continues. CRITICAL detail stays complete in `state.json`, `journal.jsonl`, session results, and `status --json`; human displays cap an overlong reason at 2,000 characters with a visible marker naming `journal.jsonl` as the complete source and, when present, the story spec as a separate recovery trail. - -**Resolving a CRITICAL escalation:** the escalated story is parked in a terminal `escalated` phase — `resume` skips it. To un-stick it, run `bmad-loop resolve ` (or press `R` in the TUI). That opens an interactive **resolve agent** seeded with the escalation and the frozen spec; you converse with it to disambiguate the spec, it records the resolution, and on your confirmation the orchestrator re-arms the story (`escalated → pending`, spec status reset to `ready-for-dev`) and resumes — a clean rebuild against the corrected spec, then on through the rest of the sprint. Already fixed the spec yourself? `bmad-loop resolve --no-interactive` skips straight to re-arm + resume. - -**Adopting the kept branch.** Under `[scm] isolation = "worktree"` an escalated story keeps its worktree and branch. When the escalation was a false positive on finished work, `bmad-loop resolve --adopt-branch` finishes the story from that branch instead of re-driving it: no agent session runs, the spec is set to `done` (or `awaiting-operator` when the story declared operator actions), and the branch is committed and merged and the sprint board advanced. **Review, the `[verify]` commands and `pre_commit_gate` workflows are not re-run** — you are vouching for the work, and resolve warns you so. It refuses (state untouched) for sweep runs, a story whose worktree is gone, or one with no spec; plain `resolve` re-arms those instead. `--adopt-branch` and `--restore-patch` are mutually exclusive. - -**Re-verifying kept work.** When a story was deferred (or escalated as an environment fault) only because its environment was broken — a container or database was down while verify ran — fix the environment and run `bmad-loop resolve --reverify`. It keeps the attempt as it stands (HEAD plus any uncommitted changes in place, or the kept worktree unit under isolation), replays the `[verify]` commands on it, and on a pass reviews it per policy and commits it (a unit merges) — no dev session and no resolve agent. A failing replay re-defers or re-escalates without retrying. Anything you committed or changed since the pause is squashed into the story's commit, and resolve says so before it asks. A worktree unit is accepted under any pause when `--story` names it; sweep runs are refused, and so is an environment fault caught before a session launched or after a dev or fix session crashed or timed out (nothing finished to verify — use plain `resolve`, see [Re-verifying kept work](docs/escalations-guide.md#resolve-reverify)). Mutually exclusive with `--adopt-branch` and `--restore-patch`. - -A plain `resume` past an unresolved escalation still runs the rest of the queue, but the run then **pauses at that escalation** rather than finishing, so `resolve` can still act on it and its kept worktree is not reclaimed. Plain `resume` re-pauses there every time; the way past it is `bmad-loop resolve ` (re-arm, or `--adopt-branch`). A replay run (from `resolve --reverify`) that escalated because its story was finished meanwhile is closed with `bmad-loop resolve --abandon`: uncommitted tracked and untracked changes in its worktree (plus the run's own spec/board/ledger copies) are parked on a `refs/attempt-preserve-dirty/*-abandon` ref, kept within `scm.preserve_keep` — other git-ignored files are removed with the worktree — and the worktree is removed, the branch kept, and the run recorded finished. (In stories mode a pick-time wedge you fixed by hand does not count.) - -**When the resume is held.** Under `[scm] isolation = "worktree"` the re-drive mounts a fresh worktree cut from the committed target branch, so a correction living only in your working tree never reaches it. Where the re-arm can _prove_ that — the committed spec does not carry the status the re-drive routes on, or, for a pre-planning sentinel, the ref it mounts from does not hold this checkout's `SPEC.md` / `stories.yaml` — the re-arm still stands but the resume stops there, `--resume` notwithstanding, and both surfaces name the branch to commit on. Commit the correction, then `bmad-loop resume `. Every other re-arm warning stays advisory and resumes in the one gesture as before. - -**Intent-gap patch-restore.** When review halted on an **intent gap** — the implementation was sound but read the spec differently than intended — `bmad-build-auto` saves the attempted change as a patch before reverting ([BMAD-METHOD#2564](https://github.com/bmad-code-org/BMAD-METHOD/issues/2564)). The escalation notice names that spec, whose `Auto Run Result` records the saved patch, so the recovery artifact is discoverable without searching terminal logs. If the attempted reading was in fact correct, `resolve` re-arms the spec to `in-review` and re-applies that patch onto baseline after every reset, so the re-driven session resumes **review** on the restored diff instead of re-implementing from scratch. The interactive agent supplies the patch automatically via `resolution.json`; on the hand-driven path pass `bmad-loop resolve --no-interactive --restore-patch `. A patch that fails to apply escalates rather than running on a half-restored tree, and deferred-work `sweep` bundles get the same recovery. - -## Deferred-work sweeps - -Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries) of split-off goals, pre-existing review findings, and items deferred as "needs human decision". `bmad-loop sweep` processes it: - -```text -bmad-loop sweep [--only DW-1,DW-3 | --min-severity LEVEL] [--no-prompt] [--decisions-only] [--max-bundles N] [--repeat] [--max-cycles N] [--dry-run] - │ - ├─ TRIAGE fresh window: claude "/bmad-loop-sweep" - │ verifies every selected open entry against the actual code (all - │ open entries when no selector is given; ledger - │ statuses are unreliable) and returns a machine-validated - │ partition: already-resolved (orchestrator closes them, with - │ evidence) · bundles (cohesive buildable groups) · blocked · - │ skip · decisions (frozen-block renegotiations, scope reversals) - ├─ DECIDE interactive runs walk you through each decision on the - │ terminal (build / close / keep-open per option, with a - │ recommendation); answers land in the ledger as `decision:` - │ lines. Unattended runs skip this and leave decisions open. - └─ BUNDLES each bundle runs the normal pipeline: bmad-build-auto (on the bundle - spec, then re-invoked on the done spec for review) → verify commands - → commit. The review gate also checks every bundle entry is - `status: done` in the ledger. -``` - -`--only` accepts an exact, comma-separated set of currently open `DW-` ids; -unknown or non-open ids fail the initial run instead of silently narrowing it. -`--min-severity low|medium|high|critical` includes the named level and higher; -entries with missing or unrecognized severity are excluded and reported. Both -selectors apply before triage, bundle formation, and `--max-bundles`, appear in -dry-run output, persist across resume, and journal the excluded ids. A repeated -severity sweep re-evaluates the current ledger, while a repeated named sweep can -only contract as its original ids close. Selectors are mutually exclusive and -cannot be combined with `--archive`. - -For a mixed legacy ledger, `--dry-run --only` projects provisional canonical ids -by continuing from the highest existing `DW-` in legacy manifest/file order. -Projected rows are labeled as pre-migration, participate in selection and -exclusion reporting, and let a named preview reject projected done or unknown -ids without spawning migration. Semantic duplicate merging may compact those -ids, so the real run revalidates the requested set against the actual -post-migration universe before triage. - -**Closing entries from a story.** Sweeps are not the only way an entry gets closed. A story spec can declare the entries its work closes, in frontmatter: - -```yaml -closes_deferred: [DW-5, DW-6] # DW- ids this story closes -``` - -In stories mode the same declaration can live on the `stories.yaml` entry instead (`closes_deferred: [DW-5, DW-6]`), which is where it belongs for an unattended run — the breakdown is authored while the ledger is in view, whereas the story spec is generated later by a dev skill that knows nothing of the ledger. Both channels are unioned. Either way the field is human-authored — like `spec_checkpoint` / `done_checkpoint`, no upstream skill emits it yet, and re-deriving `stories.yaml` drops it unless the intent is logged in `.memlog.md`. - -The orchestrator writes the same annotation a bundle close writes — `status: done ` and `resolution: resolved by story ` — into each declared entry. Without it the ledger is one-way: entries are filed automatically but only ever marked resolved by hand, so a multi-epic run ends with entries that were satisfied epics ago still reading `open`. - -**Closure happens at the commit boundary**, after artifact verification, the verify commands, every checkpoint, the review loop and the pre-commit workflows — and just before the story's commit is squashed, so an in-repo ledger carries the annotation in that same commit. A story that fails, blocks, is rejected by review, or escalates closes nothing, and there is nothing to undo. Closure is declared, never inferred from the diff; a resume re-driving the same close is a no-op; and an id that matches no entry is journaled rather than failing the story. The declaration is re-read at that boundary, so an edit made after the story was implemented is the one that counts. - -A declaration in a shape nothing can read — a bare `closes_deferred: DW-5` where a list belongs — depends on which channel it is in. In a **story spec** it is journaled and dropped, like an unknown id: the spec is generated mid-run by a dev skill, and a malformed field there must not be able to fail a story that succeeded. In **`stories.yaml`** it is a schema error, exactly like every other manifest field of the wrong type, and the manifest fails to load — the breakdown is hand-authored before the run, where `bmad-loop validate` reports it up front and a typo is still cheap to fix. `validate` warns about unknown ids in both queue modes and about a malformed spec declaration; a malformed manifest is the manifest's own `queue.stories-manifest` failure. - -**Blocking a story from the ledger.** Some entries do not merely defer work, they block it: a leg nobody has wired yet is not a nice-to-have for the first story that consumes it. Entries said so in prose (`HARD GATE: must land before 3-2`) long before anything could act on it, and prose gates nothing — `run` picked the story off the board and drove it anyway, and the gate surfaced afterwards, in a diff built on the missing leg. A `gate:` field line makes the claim enforceable: - -```markdown -### DW-1: wire the blob-storage credentials - -status: open -gate: 3-2, 3-3 -``` - -Until that entry lands, `bmad-loop validate` **fails** for every actionable story a token matches, and `run` **pauses** rather than dispatch one — the gate is enforced at dispatch as well as at preflight, so it no longer depends on remembering to run `validate` first. A token gates a story key when it is that key, or is its prefix at a key boundary — a `-`, or the split-story suffix (one lowercase letter then `-`). So `3-2` covers the sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both halves of a split (`3-2a-…`/`3-2b-…`), but never `3-20-later-story`; the split arm is there because breakdown can split a gated story after the gate was written, and a gate that quietly stops matching is worse than none. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. - -Only an explicit `status: done ` retires a gate. An entry whose status the format cannot read — `status: opne`, or no `status:` line at all — still gates, because an unreadable status is not evidence the work landed; letting it read as closed would have meant one keystroke silently disabling the refusal. - -The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story whose session already completed **finishes** when the run resumes — its recorded result replays through to commit rather than stranding half-done work; the gate is about work that must not _start_. A resume that instead **restarts** a story, discarding its worktree or resetting to baseline and re-running from scratch, is a start and is asked again: so a gate landing while a run was down still stops the story that run had picked but never got underway, and stops a re-drive of one whose escalation or wedge you have just resolved. - -Four shapes declare a gate nothing can enforce, and each is a warning while the entry is unlanded: a token that cannot name a story key (a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones, or an unmatchable `gate: 3.2` — note `.` and `_` are fine inside a sprint slug, so `gate: 3-2-a_b` is a real gate); a `gate:` line with nothing usable after the colon; a `gate:` not written lowercase at the very start of a line (`Gate:`, or indented — surfaced rather than accepted, so a fenced example inside an entry cannot become a refusal); and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. - -> **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. The annotation is written all the same, at the same moment, and the run journals `deferred-close-external-ledger` so its absence from git history is not a surprise. A location that cannot be read or written when the write comes due (a shared mount that has gone away) closes nothing and is journaled — those entries stay `open` for a sweep to re-verify, and an outage is never read as "no such entries". - -**Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. - -Sweeps are their own resumable runs (`bmad-loop resume `). `[sweep] auto` in the policy fires an unattended sweep automatically at epic boundaries or run end; a failed/paused child sweep never interrupts the parent run. - -Bundle dev sessions can themselves append new deferred entries (split-off goals, review findings). With `[sweep] repeat` (or `--repeat`) the sweep re-triages after each cycle and keeps going on that newly generated work, stopping when a cycle completes nothing addressable — nothing closed as already-resolved or by decision, no bundle done — or at `max_cycles`. Bundles that failed in an earlier cycle and entries a human chose to keep open are never re-bundled. - -## Installing the skill module - -The orchestrator drives the upstream dev primitive — `bmad-build-auto`, or `bmad-dev-auto` on pre-rename releases — unmodified, so there is no fork to keep in sync; it both implements and (re-invoked on the done spec) runs the follow-up review — plus its own bundled `bmad-loop-*` skills for escalation, sweep, and setup. Your standard BMAD install is never modified. The three bundled skills ship in the `bmad-loop` wheel (canonical source: `src/bmad_loop/data/skills/`, BMAD module code `bmad-loop`) so `bmad-loop init` lays them down for you; the dev primitive and the review-layer skills its step-04 invokes inline are prerequisites installed by the BMad Method (bmm) module: - -| Skill | Role | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bmad-build-auto` | unattended implementation + follow-up review (**upstream** — bmm prerequisite, not bundled; named `bmad-dev-auto` before the rename, and either era is driven) | -| `bmad-review-adversarial-general` | inline step-04 review layer (**upstream** — bmm prerequisite, not bundled) | -| `bmad-review-edge-case-hunter` | inline step-04 review layer (**upstream** — bmm prerequisite, not bundled) | -| `bmad-review` | merged lens-based reviewer, supersedes the hunters (**upstream** — bmm prereq, not bundled) | -| `bmad-loop-resolve` | interactive CRITICAL-escalation resolution (`/bmad-loop-resolve `) | -| `bmad-loop-sweep` | deferred-work ledger triage (automation-only) | -| `bmad-loop-setup` | installs the orchestrator tool from Git, then runs `bmad-loop init` + `validate` | - -`bmad-loop validate` preflights the dev primitive (always — reporting which name it resolved) plus the review skills that copy of the skill will actually invoke — read from its `customize.toml` review layers, or from `step-04-review.md` on releases that name their reviewers inline. So a merged-`bmad-review` install needs only `bmad-review`, a v6.10.0 install needs the two hunters it names, and a tree whose configured layers reference a skill it does not have is reported instead of failing on every dev run. Missing skills (or a dev primitive without its `customize.toml`) are reported with bmm-module remediation before any run starts. - -**Via uv + `bmad-loop init` (self-sufficient).** Installing the tool and running `init` is all you need — `init` installs the `bmad-loop-*` skills into `.claude/skills/` (claude) and/or `.agents/skills/` (codex/gemini) for the CLIs you select, alongside the hooks and policy: - -```bash -# latest from main (tracks HEAD — newest features, less stable): -uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" - -# OR a pinned release tag (reproducible — recommended for day-to-day use): -uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git@v0.9.1" - -bmad-loop init --project /path/to/project --cli claude # add --cli codex/gemini as needed -claude "/bmad-loop-setup accept all defaults" # installs the tool + wires the project -``` - -The `[tui]` extra pulls in the dashboard/settings UI (textual); drop it for a headless install. `bmad-loop --version` confirms what you've got. Existing skill dirs are left untouched (`--force-skills` to overwrite a stale copy, `--no-skills` to manage skills yourself). - -### Upgrading - -**Easiest — let the setup skill do it.** Re-running `/bmad-loop-setup` (or `/bmad-loop-setup upgrade`) on an already-installed project performs the two-step ritual for you: it detects the existing install, upgrades the tool with `--reinstall`, and re-lays the per-project skills with `--force-skills` — then reports the before → after version. - -```bash -claude "/bmad-loop-setup upgrade" -``` - -**Manual — the two steps it runs.** Use these directly for non-Claude CLIs, CI, or scripting. Upgrading is two steps — the tool **and** the per-project skill copies, which `init` froze at install time and a tool upgrade does not touch: - -```bash -# 1. upgrade the tool. --reinstall is required for a git source: a plain -# `uv tool upgrade` reuses the cached commit and won't pull new code. -uv tool upgrade bmad-loop --reinstall # follows main or your pinned tag -# to move to a newer tag, re-run install with the new ref: -# uv tool install --force "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git@v0.9.1" - -# 2. re-lay the refreshed skills into EACH project that uses bmad-loop: -bmad-loop init --project /path/to/project --force-skills -``` - -Your `.bmad-loop/policy.toml` is left untouched on upgrade — new keys are optional and fall back to their defaults, so configs survive. `init` now also gitignores policy.toml (it is per-machine: it carries the `[mux] backend` choice); if yours is already committed, run `git rm --cached .bmad-loop/policy.toml` once to stop sharing it (your local copy is kept). Check the [CHANGELOG / releases](https://github.com/bmad-code-org/bmad-loop/releases) for what changed between tags. - -To remove bmad-loop from a project, see [Uninstalling](docs/setup-guide.md#uninstalling) — it reverses what `init` laid down (state, skills, hooks, gitignore) and uninstalls the tool. - -**Via the BMAD-method installer.** The installer copies the bundled `bmad-loop-*` skills into your project (but not the orchestrator tool), alongside the upstream dev primitive the orchestrator drives. Finish setup with `/bmad-loop-setup`, which installs the tool from Git, asks which coding CLIs to drive, registers their hooks (`init` skips the already-present skills), and runs the preflight: - -```bash -claude "/bmad-loop-setup accept all defaults" -``` - -See **[docs/setup-guide.md](docs/setup-guide.md)** for the full walkthrough — choosing CLIs, installing the tool and TUI together or separately, and initializing codex/gemini. - -The bundled skills must be installed together with the upstream dev primitive: `bmad-loop-sweep` owns the canonical `deferred-work-format.md` that the orchestrator normalizes the ledger to, and the primitive appends the flat deferred-work entries it normalizes. The primitive is driven unmodified, so its own `customize.toml` applies as-is; it needs no merge — it is consumed directly from the bmm module. There is no review fork to keep in sync: review is just a re-invocation of the primitive on the done spec. - -## Policy (`.bmad-loop/policy.toml`) - -`bmad-loop init` writes this template; every engine start — `run`, `sweep` and `resume` — stamps it into the run, so edits apply to new runs and to the next resume of an existing one (edit it live from the TUI with `g`). +`.bmad-loop/policy.toml` is per-machine (gitignored by `init`), and every key is optional. A minimal example: ```toml [gates] -mode = "per-epic" # none | per-epic | per-story-spec-approval -retrospective = "notify" # never | notify | auto - -[stories] # which planning pipeline drives the loop (see "Two planning pipelines") -source = "sprint-status" # sprint-status (default) | stories (typed stories.yaml, folder+id dispatch) -spec_folder = "" # required when source = "stories": the epic spec folder holding stories.yaml - # (per-story spec_checkpoint / done_checkpoint pauses live in stories.yaml, not here) - -[limits] -max_review_cycles = 3 -max_dev_attempts = 2 -# Artifact publication uses binary MiB: 1 MiB = 1,048,576 raw bytes. -artifact_file_max_mb = 5 # per ignored artifact selected for isolated publication -artifact_payload_max_mb = 10 # aggregate before base64 state encoding -max_followup_reviews = 1 # extra review rounds granted when a finalized (done) round still recommends a follow-up; once spent, the round converges + refiles instead of burning a cycle; 0 = never honor one -session_timeout_min = 90 -git_timeout_s = 120 # bound on any single git subprocess; exceeding it pauses/degrades, never crashes the run -teardown_grace_s = 20 # verified session teardown: poll a killed session up to this long, then force-kill its pane pids and re-kill; 0 = one best-effort kill -stop_without_result_nudges = 1 # times to re-prompt a session that stopped with no result.json -dev_stall_grace_s = 600 # silence grace armed at dev/review launch; transport activity or fresh Stop/idle evidence re-arms it; 0 = no launch timer, but a result-less turn end still fails fast. Also the transcript-idle notice threshold (journal session-idle/session-active, TUI `idle `); 0 disables the notice -dev_stall_nudges = 2 # best-effort wake nudges per silent grace; fresh Stop/idle evidence restores this budget; 0 = stall on grace expiry -dev_stall_nudges_cap = 6 # total never-restored nudge bound per dev/review session; an accepted nudge does not guarantee a wake; 0 = stall on first grace expiry -workflow_stall_nudges_cap = 3 # same monotonic cap for an injected plugin-workflow session that finished but never wrote its completion marker -max_tokens_per_story = 2000000 -cache_read_weight = 0.1 # cache reads bill at ~0.1x input everywhere; 1.0 = count raw -session_budget_mode = "warn" # off | warn | enforce — mid-session per-SESSION guard (sampled ~30s); warn = one ATTENTION; enforce = wrap-up nudge then over_budget termination (retry→defer) -max_tokens_per_session = 4000000 # weighted per-session cap the guard trips on; healthy sessions run ~1–2.5M weighted, so the default trips only true runaways -session_budget_grace_s = 240 # enforce mode: seconds to wrap up after the nudge before over_budget termination; 0 = terminate at trip - +mode = "per-epic" # none | per-epic | per-story-spec-approval +[adapter] +name = "claude" # claude | codex | gemini | copilot | antigravity | opencode-http +[adapter.review] +name = "codex" # optional: review on a different CLI [verify] commands = ["pytest -q", "ruff check ."] -env_fault_rc = 75 # a [verify] command exiting 75 declares an environment fault (pause, attempt not charged) instead of a code failure; 0 = disabled (the default) - -[environment] -# Orchestrator-run health checks, run before [verify] commands, before session -# launches, and before a failed attempt is charged. A failing probe pauses the -# run as an environment fault and charges nothing. -probes = ["pg_isready -h localhost"] -probe_timeout_s = 60 # per-probe wall-clock bound; running past it is a failure - -[notify] -desktop = true # desktop notification on gate pauses / escalations — notify-send (Linux) / osascript (macOS) / PowerShell toast (Windows), best-effort -file = true # append the same alerts to the run's ATTENTION file - -[review] -enabled = true # false = skip the separate review session; the dev pass - # runs bmad-build-auto's own inline review layers and finalizes to done -trigger = "recommended" # when enabled: "recommended" runs the separate review only when - # bmad-build-auto flags followup_review_recommended; "always" = every story - # (the loop is bounded by limits.max_review_cycles either way) -on_status_contradiction = "escalate" - # a review that writes sprint-status back off "done" (revoking the - # sign-off the orchestrator recorded at dev time) pauses the run naming - # both sides; "retry" = legacy — burn review cycles, then defer + roll back - -[dev] # which inner dev skill the orchestrator drives -skill = "bmad-dev-auto" # the only supported value — the generic upstream dev primitive. - # Retained as the seam for a future alternative dev skill, and it is - # NOT the name sessions are dispatched with: upstream renamed the - # primitive to bmad-build-auto, so the invoked name is resolved from - # what is on disk and projects on either era work with this untouched. - # 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 -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 - # channel for it and ignore it (`bmad-loop validate` warns) -cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection) -# extra_args replaces the profile's default bypass flags when set: -# extra_args = ["--permission-mode", "bypassPermissions"] - -# Optional per-stage overrides — run the review pass on a different CLI/model -# than the dev pass. Unset keys inherit from [adapter] when the stage runs the -# same client; switching client falls back to that profile's defaults (model, -# effort and extra_args are client-specific). -# [adapter.dev] -# model = "opus" -# [adapter.review] -# name = "codex" -# model = "gpt-5-codex" -# [adapter.triage] # sweep triage stage -# model = "opus" -# [adapter.retro] # gates.retrospective = "auto" sessions (inherits [adapter]) -# model = "opus" -# With an opencode-http base, effort tunes reasoning per stage (opencode-http -# only — a tmux CLI ignores it and `bmad-loop validate` warns). An unrecognized -# name is not rejected: the session silently runs at the provider default, so -# spell it exactly as the model's variant list names it. -# [adapter.review] -# effort = "max" # e.g. a deeper review pass than dev - -[sweep] -auto = "never" # never | per-epic | run-end (auto sweeps never prompt) -max_bundles = 5 # bundles executed per sweep; triage excess truncated -max_triage_attempts = 2 # triage validation retries before escalating -max_migration_attempts = 2 # legacy-ledger migration retries before escalating -repeat = false # re-triage after each cycle, continue on new deferred work -max_cycles = 5 # safety cap on cycles per sweep run when repeat = true - -[cleanup] # disk reclamation for .bmad-loop/runs (terminal runs only) -run_retention = 10 # newest concluded runs kept whole; older ones trimmed/archived by `clean` (0 = none) -retention_days = 0 # 0 = off; else also keep runs newer than N days regardless of count -trim_artifacts = true # drop the heavy worktrees/ tree from concluded runs (run stays viewable in the TUI) -archive_old = true # archive (vs hard-delete) runs past the window -auto_clean_on_finish = true # reconcile worktrees leaked by a mid-flight stop at each run/sweep start -clean_tmp = true # let engine plugins clean their /tmp scratch on finish (e.g. Unity MCP zips) - -[scm] # source-control isolation + merge-back; defaults = work in place -isolation = "none" # none | worktree -branch_per = "story" # story | run (worktree mode only; "run" forces delete_branch = false) -target_branch = "" # "" = the branch checked out at run start -merge_strategy = "merge" # ff | merge | squash (how a unit branch lands on the target) -delete_branch = true # delete the unit branch after a successful merge -keep_failed = true # keep a failed unit's worktree + branch mounted for inspection -rollback_on_failure = false # in-place (isolation="none") recovery after a failed attempt: false = pause with manual steps; true = auto-revert the attempt (WARNING: discards its uncommitted work). A resolved escalation's re-drive always auto-recovers regardless; prefer isolation="worktree" to keep your main checkout untouched -preserve_keep = 20 # attempt-preserve/* refs + dirty snapshots kept per family at run start (newest by committer date); the tail is pruned. 0 = never prune -failed_diff_max_mb = 5 # per-file cap (MB) for untracked files in a kept-failed unit's changes.patch -failed_diff_unlimited = false # true = no size cap on the failed-unit diff (warns when active) -commit_message_template = "" # {story_key} / {run_id} / {story_title} substituted; empty = built-in default -max_parallel = 1 # units in flight at once (parallel fan-out unbuilt; values > 1 clamp to 1) -seed_adapter_defaults = true # copy each loaded adapter's gitignored MCP/CLI configs into the worktree -worktree_seed = [] # extra project-relative gitignored files to seed, on top of adapter defaults - -[mux] # terminal-multiplexer backend — per-machine (policy.toml is gitignored) -backend = "" # "" = auto-select; else force a registered backend by name (see `bmad-loop mux`) - # env var BMAD_LOOP_MUX_BACKEND outranks this - -[tui] -low_frame_rate = false # true = cap to 15fps + disable animations (= bmad-loop tui --low-frame-rate) -# Persisted dashboard pane sizes (terminal cells). 0 = unset → default proportions; -# the TUI writes these when you resize by mouse-drag or the Ctrl+W resize mode. -# left_width = 0 # sidebar width, columns -# runs_height = 0 # Runs pane height, rows -# deferred_height = 0 # Deferred pane height, rows -# tasks_height = 0 # Tasks table height, rows +[scm] +isolation = "worktree" # none (default, in place) | worktree +merge_strategy = "squash" # ff | merge | squash +[limits] +session_budget_mode = "warn" # off | warn | enforce ``` -**Gate modes:** `none` runs everything unattended; `per-epic` (default) pauses at epic boundaries; `per-story-spec-approval` pauses after each spec is written so you approve it before implementation is reviewed. - -> In **stories mode** the `stories.yaml` list is flat — there are no epics — so the default `per-epic` gate never fires (nothing to bound). Use the per-story `spec_checkpoint` / `done_checkpoint` flags for HITL there, or `per-story-spec-approval` for a run-global spec gate. `none` and `per-story-spec-approval` behave the same as in sprint mode. - -**Review:** `[review].enabled = false` drops the separate fresh-context review session; the dev pass instead runs `bmad-build-auto`'s own internal review layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) and finalizes the story straight to `done` — one session per story instead of two, verify commands still gating the commit. Governs deferred-work sweeps too. When review is enabled, `[review].trigger` decides _when_ that separate pass runs: `recommended` (default) only when the `bmad-build-auto` session flags `followup_review_recommended` — it already reviews inline and computes the flag from a severity-weighted score over the final pass's patched findings (BMAD-METHOD#2580); `always` runs it every story. The follow-up loop is bounded by `limits.max_review_cycles` (default 3), which caps oscillation. - -`bmad-loop init` (without `--cli`) registers hooks for every CLI profile the policy references, so a dual-client setup needs no extra flags. +Every key, default, allowed value and environment variable is in the [configuration reference](docs/reference/configuration.md). ### Worktree isolation -By default work happens **in place** on the checked-out branch (`[scm] isolation = "none"` — byte-for-byte the prior behavior). Set `isolation = "worktree"` and each story (and each sweep bundle) runs in its own `git worktree` on a dedicated `bmad_loop/[/]` branch cut from the target branch, then merges back into the target **locally** (`merge_strategy` = `ff` / `merge` / `squash`). The main checkout stays free while a run is in flight, and run state never moves into a worktree — `.bmad-loop/` always lives in the main repo. - -- **`branch_per`** — `story` (a branch per story) or `run` (one shared branch across the run; this forces `delete_branch = false` so the shared branch survives between units). -- **`target_branch`** — the branch every unit merges into; empty means the branch checked out at run start. A configured branch is created if missing (a detached HEAD or unborn repo pauses the run rather than merging onto an unreferenced commit). -- **`keep_failed`** (default on) — a deferred/escalated unit's worktree + branch stay mounted for inspection, and its full diff (tracked + untracked) is preserved to `run_dir/failed//changes.patch`. `failed_diff_max_mb` caps the per-file size of untracked files in that patch (oversized files skipped with a marker); `failed_diff_unlimited` lifts the cap. -- **`artifact_file_max_mb` / `artifact_payload_max_mb`** (defaults 5 / 10) — inclusive per-file and aggregate raw-byte admission limits for ignored artifacts published from an isolated bundle. Values are binary MiB (`1 MiB = 1,048,576 bytes`); tracked declarations ride Git and do not count. - Raising these limits also raises persisted state and transient memory use: base64 alone expands admitted bytes by roughly one third, and the payload is copied on each state save. -- **`rollback_on_failure`** (default off) — governs in-place recovery when `isolation = "none"`. Off pauses a failed unattended attempt with manual-recovery steps and leaves the tree untouched; on auto-reverts its tracked changes and removes only the untracked files the run created (never a blanket `git clean`). A resolved escalation's re-drive always auto-recovers regardless. Under `isolation = "worktree"` the worktree is disposable, so it auto-reverts either way. Any auto-revert first parks the attempt's commits on `attempt-preserve/*` refs, and pauses rather than reset work it could not park; **`preserve_keep`** (default 20) bounds how many such refs are kept per family, pruned oldest-first at run start (`0` = never prune). A parked ref is named in the story's defer notification — with the `git merge --ff-only` line that recovers it, flagged commits-only when the uncommitted snapshot could not be captured — and as `preserve_ref` in `bmad-loop status`/`--json`, verbatim from run state, so a ref a later run's pruning has since deleted is still shown. -- **`commit_message_template`** — when set, the message used for story/bundle commits (`{story_key}` / `{run_id}` / `{story_title}` substituted; the title comes from the spec's `title:` frontmatter, else a first `#` heading, else the story key). -- **`seed_adapter_defaults`** (default on) / **`worktree_seed`** — a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) are absent from a fresh worktree — without them an isolated session can't reach its MCP server and stalls on readiness. With `seed_adapter_defaults` on, each loaded adapter's own configs are copied in from the main repo before the session launches (the defaults live in each CLI profile's `seed_files`); `worktree_seed` adds extra project-relative paths on top. Seeding is copy-when-absent and runs before the signal-hook merge, so a seeded `settings.json` keeps its real content and just gains the Stop hook — unless it won't parse, in which case provisioning refuses and the run pauses with the file untouched, rather than the merge replacing it with a hooks-only config (#592). The seeded paths are shielded from the unit's `git add -A`. - -Merge-back is always **serialized** — `max_parallel` is a validated knob clamped to `1` until parallel fan-out lands. PRs aren't created automatically; open them by hand from the unit branches afterward if you want them. +With `[scm] isolation = "worktree"`, each story and sweep bundle runs in its own git worktree on a `bmad-loop//` branch and merges back into the target branch locally (`ff`, `merge` or `squash`). Run state stays in the main repo, failed units keep their worktree and branch for inspection, and no PRs are opened. For a monorepo, set `repo_root` in `_bmad/bmm/config.yaml`; worktree isolation then needs the project inside `repo_root`. See [Worktree isolation](docs/FEATURES.md#worktree-isolation).

The settings editor with the [scm] section expanded: isolation, branch_per, merge_strategy, the seed-adapter-configs switch, and the extra-worktree-seed-files field.

-For a monorepo or any layout where the git root differs from the project dir, set an optional `repo_root` key in `_bmad/bmm/config.yaml` — it decouples where git/code work happens from where run state lives (defaults to the project dir). Your `[verify].commands` run there too — the code's root, not the BMAD project dir — while the orchestrator's own artifact reads stay project-rooted. It works with `isolation = "worktree"` when the project dir is **inside** `repo_root` (a monorepo whose BMAD project is `/app`, say): each unit worktree is a checkout of `repo_root`, and provisioning lands `_bmad/`, the skill trees, hook configs and seed files at the project's offset inside it (`/app/…`), mirroring the main checkout. When the project is **not** inside `repo_root` (a sibling directory, or `repo_root` nested inside the project) the checkout carries no copy of the project, so worktree isolation is refused: `validate` reports the pair and `run`, `sweep`, `resume` and `resolve` refuse to start — `resolve` before it opens the interactive agent, since its re-arm advances the attempt baseline against that same root. Move the project inside `repo_root`, drop the override, or use `isolation = "none"`. `validate` and the TUI launch guard check `repo_root` for a clean tree, as `run` and `sweep` do. - ### Plugins -The orchestrator is extensible through a **plugin system** — a general layer that adapts the run/sweep cycle without touching the core loop. A plugin is a folder-drop `plugin.toml` manifest (metadata, declarative `[hooks.]` shell commands, a `[[settings]]` schema, and an optional in-process `[python]` module), bundled under `bmad_loop/data/plugins//` and overridable per project at `.bmad-loop/plugins//`. At every run/sweep lifecycle stage a plugin can **observe, veto** (defer / pause / skip), and **mutate** a shared context; a zero-plugin run pays nothing (O(1) no-op fast path) and stays byte-identical to before. - -Trust is by **origin**, pinned by **content** — dropping a folder in never runs code ([#496](https://github.com/bmad-code-org/bmad-loop/issues/496), [#497](https://github.com/bmad-code-org/bmad-loop/issues/497)). A **bundled** plugin is trusted by provenance; its in-process `[python]` module loads only when its name is listed in `[plugins] enabled`: - -```toml -[plugins] -enabled = ["unity"] # bundled plugins only: these [python] modules load -``` - -A **project** plugin under `.bmad-loop/plugins//` with anything executable (shell hooks, workflows, seeds, or `[python]`) runs only after you grant it: - -```bash -bmad-loop plugins # roster: origin, surface, grant status -bmad-loop plugins enable my-plugin # review the folder first; records a sha256 of the whole dir -bmad-loop plugins accept my-plugin # after reviewing a change to a granted plugin -``` - -The grant lives outside the repo (the repo and its policy.toml are agent-writable), is read once per launch, and the plugin runs from a verified out-of-tree snapshot of exactly the bytes you granted. An ungranted plugin is inert (a `note:` at launch); a granted plugin that changed, or a project name listed in `enabled` without a grant, fails startup with the command that fixes it. `bmad-loop validate` reports both under `plugins.trust`. Settings-only plugins need no grant. Every hook is failure-isolated: a raise is caught, journalled, and disables that instance for the rest of the run rather than crashing it. A plugin's `[[settings]]` render in the TUI settings editor and persist under `[plugins.]`. - -See **[Writing a bmad-loop plugin](docs/plugin-authoring-guide.md)** for the manifest, hook, stage, settings, trust, and workflow reference; a complete worked example ships under [`examples/plugins/guardrails/`](examples/plugins/guardrails/). +A plugin is a folder with a `plugin.toml` manifest: hooks on run and sweep stages, settings, workflows, and an optional in-process Python module. Bundled plugins are trusted by origin, and their Python loads only when listed in `[plugins] enabled`. A project plugin under `.bmad-loop/plugins//` runs nothing until `bmad-loop plugins enable ` records a content hash of it outside the repo. See [Writing a bmad-loop plugin](docs/plugin-authoring-guide.md) and the worked example in [`examples/plugins/guardrails/`](examples/plugins/guardrails/). ### Game-engine projects (Unity) -A niche game-engine layer — built **on the plugin system** — for projects whose dev/sweep cycle needs the agent to drive a **live engine Editor** — e.g. a Unity project the agent manipulates through an Editor MCP ([IvanMurzak/Unity-MCP](https://github.com/IvanMurzak/Unity-MCP) or [CoplayDev/unity-mcp](https://github.com/CoplayDev/unity-mcp)). It's off by default; normal projects never list it in `[plugins] enabled` and nothing changes. Unity ships bundled at `bmad_loop/data/plugins/unity/`, overridable per project under `.bmad-loop/plugins/unity/` — a project copy is a project plugin, so `[plugins] enabled` does not cover it: grant it with `bmad-loop plugins enable unity` (and re-run `bmad-loop plugins accept unity` after each edit), or startup fails. - -The core constraint: a live Editor MCP can only act on the folder its Editor has open, and Unity binds one Editor per folder and can't be repointed live. So `editor_mode` is coupled to `[scm] isolation`: - -- **`shared`** (default; requires `isolation = "none"`) — the agent works **in place** on the project your warm Editor already has open. Zero relaunches, full live MCP, the Editor stays open across stories. Before each unit runs, a **readiness gate** blocks until the Editor + MCP report ready (so a session never starts against a half-open Editor); if it never comes up the unit is deferred with an `ATTENTION` notice instead of failing mid-session. -- **`per_worktree`** (requires `isolation = "worktree"`) — one **managed Editor per worktree**, run serially. For each unit a setup hook makes the fresh worktree a usable Unity project (launches its own Editor on the worktree path, writes the worktree's `.mcp.json`, primes the worktree's `Library` with a reflink/CoW copy of the warm main `Library` so the import is incremental, not a crash-prone cold reimport), the readiness gate waits for it, the agent drives it, then a teardown hook quits that Editor — on completion **and** on pause/escalation, so it never outlives its worktree. The MCP server's generated skill tree is gitignored (absent from a fresh checkout), so the plugin seeds it into each worktree via `seed_globs`. If setup fails the unit is deferred rather than run against no Editor. - -Enable shared mode (the recommended Unity workflow) in `.bmad-loop/policy.toml`: - -```toml -[plugins] -enabled = ["unity"] - -[plugins.unity] -editor_mode = "shared" # requires [scm] isolation = "none" (the default) -mcp = "ivanmurzak" # ivanmurzak | coplaydev -unity_path = "" # explicit Editor binary for per_worktree; "" = auto-detect -ready_timeout_sec = 600 -ready_grace_sec = -1 # delay before the first readiness probe; -1 = auto -``` - -All five `[plugins.unity]` keys are editable in the TUI settings editor (`g`) under the -Unity plugin's section (shown once `unity` is in `[plugins] enabled`, or a project copy is granted). To run a project on -a different engine — or reshape the Unity plugin — see **[Writing a Game Engine -plugin](docs/game-engine-plugin-guide.md)** (manifest schema, lifecycle hooks, a minimal -Godot example) and **[Writing a plugin for a specific Editor MCP](docs/game-engine-mcp-guide.md)** -(IvanMurzak vs CoplayDev, readiness probing, per-worktree isolation, and the full -`BMAD_LOOP_UNITY_*` env-var reference). The legacy `[engine]` block still loads — it's -folded onto `[plugins.unity]` with a deprecation warning — but will be removed in a future -release; migrate to `[plugins] enabled = ["unity"]`. - -The readiness gate runs the plugin's `ready_cmd` (`unity_ready.py`), which for `ivanmurzak` shells out to the Unity-MCP CLI's `wait-for-ready` (with an explicit `--timeout`, since the CLI's own default is only 120s) and for `coplaydev` does a connectivity check against the MCP server. It first waits `ready_grace_sec` for the Editor to start before probing — `-1` (the default) auto-picks **120s for a cold `per_worktree` Editor** and **0s for a warm `shared` one** — then retries so a fast connection-refused against a not-yet-listening Editor doesn't abort the gate; the grace counts against `ready_timeout_sec`. The exact CLI name/subcommand and endpoint move between MCP releases — verify against your installed version and override `ready_cmd` (or the whole plugin) under `.bmad-loop/plugins/unity/` if they differ, then grant the override with `bmad-loop plugins enable unity`. - -For `per_worktree`, set `editor_mode = "per_worktree"` with `[scm] isolation = "worktree"`. The bundled plugin wires the worktree-Editor lifecycle against the **IvanMurzak** CLI (`open` / `setup-mcp` / `close`; verified against v0.81.1). A fresh worktree has no `Library` (gitignored), and a cold full reimport crashes Unity's import workers on a real project, so the setup hook **primes** it with a reflink/CoW copy of your warm main `Library`, with deep-copy and empty-cache fallbacks. Tune that via `BMAD_LOOP_UNITY_LIBRARY_SEED`/`…_SEED_MODE` and `BMAD_LOOP_UNITY_LIBRARY_CACHE` for the fallback cache root ([env reference](docs/game-engine-mcp-guide.md)); a [Unity Accelerator](https://docs.unity3d.com/Manual/UnityAccelerator.html) helps further, `unity_path` pins the Editor binary, and a long first import wants bigger `ready_grace_sec`/`ready_timeout_sec`. CoplayDev is not wired for a managed per-worktree launch — point `worktree_setup_cmd`/`worktree_teardown_cmd` at your own scripts, or use shared mode. - -## Environment variables - -A handful of `BMAD_LOOP_*` variables override behavior at runtime, taking precedence over the policy file. Most operators only ever touch `BMAD_LOOP_MUX_BACKEND` and, on a host with an unusual home directory, `BMAD_LOOP_STATE_DIR`; the rest are override/test hooks. - -| Variable | Value | Effect | -| ----------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BMAD_LOOP_MUX_BACKEND` | registered backend name (e.g. `tmux`, `psmux`) | Forces the terminal-multiplexer backend, outranking the `[mux] backend` policy key and auto-selection. A name matching no registered backend is an error — it never silently falls back. Unset ⇒ auto-select. | -| `BMAD_LOOP_PROCESS_HOST` | registered host name (e.g. `posix`, `windows`) | Forces the process-lifecycle host (an override/test hook). A name matching no registered host raises rather than silently using POSIX. Unset ⇒ this platform's default. | -| `BMAD_LOOP_STATE_DIR` | absolute directory path | Overrides the user-scoped **state root** — the out-of-tree home of per-run control-plane state, keyed `///`. Used as the root itself, so nothing is appended to it. Must be **absolute**: the root is read both by the orchestrator and by the session it launches, which run from different working directories, so a relative value names two different places — bmad-loop refuses it rather than picking one. Unset ⇒ `$XDG_STATE_HOME/bmad-loop` when that names an absolute path, else `~/.local/state/bmad-loop`; on Windows `%LOCALAPPDATA%\bmad-loop\state`, else `%USERPROFILE%\AppData\Local\bmad-loop\state`. Set this when none of those is derivable or writable (a home on a network share, a locked-down service account). On Windows it moves one more thing with it: the psmux **registry** (`//_mux`, the per-project `PSMUX_DATA_DIR` bmad-loop exports) — see [multiplexer backends](docs/multiplexer-backends.md#where-psmux-sessions-live-the-per-project-registry). There is deliberately no second variable naming the registry: two knobs that can disagree would put two processes on different registries, each blind to the other's live sessions. | -| `BMAD_LOOP_EVENTS_DIR` | directory path | **Session protocol, not an operator knob.** The orchestrator exports it into every session it drives, naming that run's hook-event directory under the state root; the hook relay writes there, falling back to the legacy in-tree `/events` when it is absent. Setting it yourself in a shell has no effect on a run (the engine overwrites it per session) and only misdirects a hand-invoked relay. | -| `BMAD_LOOP_SESSION_TIMEOUT_S` | seconds (float) | Overrides the per-session wall-clock budget (normally `limits.session_timeout_min × 60`) — mainly a test/E2E hook for sub-minute timeouts. A value that is not a finite positive number is ignored — non-positive, unparseable, or non-finite (`inf`, `1e999`), the last of which would otherwise disable the timeout outright. A large finite value is honoured. Unset ⇒ the policy value. | - -Game-engine (Unity) runs read a wider `BMAD_LOOP_UNITY_*` / `BMAD_LOOP_ENGINE_*` set documented in the [Game Engine MCP guide](docs/game-engine-mcp-guide.md). - -## Run state - -Everything about a run lives in `.bmad-loop/runs//` (gitignored): `state.json` (resumable engine state), `journal.jsonl` (every decision), `tasks//` (per-session prompt + result + escalations, plus diagnostic breadcrumbs — `session-lifecycle.jsonl` records when a timeout fired, `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops), `logs/` (raw pane output, debugging only), `verify/` (verifier command stdout/stderr, pointed at by the journal's `verify-command-result` records; the retained tail is capped per stream by `[verify] stream_capture_kb`, `0` to keep nothing), `deferred/` (stashed specs from deferred stories), `resolve//` (escalation `context.json` + the resolve agent's `resolution.json`), `ATTENTION` (human-readable alerts), and — only while a stop request is pending — `stop-request.json` (the control file carrying the requested mode, consumed by the engine when it honors it). - -One piece deliberately lives elsewhere: the **hook-event channel** (the session completion signals the orchestrator waits on) sits under the user-scoped state root at `///events/`, outside the project tree — a branch switch, a worktree mount or a rollback must not be able to take a live run's control plane away. See `BMAD_LOOP_STATE_DIR` above for where that root resolves. The orchestrator also keeps polling the old in-tree `events/` location, so a project whose installed hook relay predates the move still completes its sessions; re-run `bmad-loop init` to refresh the relay. - -That out-of-tree directory is collected with the run: `delete`, `archive` and `clean` remove it alongside the run dir, and `clean` also sweeps this project's orphans there — control planes whose run dir is already gone, e.g. from a hand-removed run (`clean --dry-run` previews the count; `--json` reports it as `state_dirs_swept`). Two consequences worth knowing: an archived run's tarball no longer contains `events/` (transient completion signals, consumed while the run was live), and a project that is deleted, moved or renamed leaves its old subtree behind — the key is derived from the project's resolved path, so after a move the project itself now keys somewhere new and nothing can name the old key to sweep it. Remove it by hand if you care; it is events-sized, not run-sized. - -A run can be stopped two ways, and both requests travel over the same `stop-request.json` control file — no signal needed, so a stop works on every platform and multiplexer backend. A **hard stop** (`bmad-loop stop`, TUI `x`; Ctrl+C in the run's own terminal does the same thing directly) abandons the in-flight item and always kills the agent session: `stop` lodges the file with `mode: "hard"` _before_ it signals — the same atomic write supersedes any pending graceful request — and the engine honors it at the next item boundary, or mid-item, where each adapter's wait loop reads the file on both sides of the up-to-5s wait it blocks in — so a quiet session normally aborts within a few seconds, while one already waiting out its result grace, or blocked in a transport call, sees the request only when that call returns. SIGTERM still goes out as the POSIX fast path, but the file is what makes the stop land; the force-kill past the 10s grace window — and the `run-stop fallback=True` it stamps — now marks a stop this tool had to finish from outside, which a slow teardown reaches as readily as an engine that never read the request. A **graceful stop** (`bmad-loop stop --graceful`, TUI `S`) lodges the same file in its default `graceful` mode, which the engine consumes at the next item boundary only: the in-flight story (or sweep bundle) finishes through commit — or, mid-triage, the sweep's triage session completes and no bundles start — the run finalizes as `stopped` (not `finished`) and stays resumable, and pending auto-sweeps are suppressed. Its session teardown follows `cleanup_session_on_finish` like a normal finish, rather than the hard stop's unconditional kill. - -`journal.jsonl` records a `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared elapsed), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both`); `wall` alone fingerprints a host suspend (e.g. macOS sleep) that froze the monotonic clock. Every entry whose usage was read also carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the read failed and absent on an `aborted` end. `tokens_weighted` is the end-of-session total, distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. Per-session `tokens_weighted` sums to within a token or two of the run total, which rounds per story rather than per session. - -Token usage is read from each CLI's local session transcript (selected by the profile's `usage_parser`) and aggregated per story (`bmad-loop status`); the hookless `opencode` profile is the exception — its adapter pulls token usage from the OpenCode server over HTTP just before teardown (server state is sqlite; there is no local transcript). - -Each run drives its agents inside a dedicated tmux session, `bmad-loop-`. It is torn down automatically when the run finishes (disable with `[adapter] cleanup_session_on_finish = false` to inspect agent windows afterwards); a hard `stop` kills it regardless of that setting, while a graceful `stop --graceful` tears it down under the same gate. A paused or interrupted run keeps its session for `resume`, which clears any stale session and spins up a fresh one. Sessions left behind by older runs — or by a `cleanup_session_on_finish = false` policy — can be swept any time with `bmad-loop cleanup` (or `c` in the TUI). - -### Scripting `status` - -`bmad-loop status --json` is the **supported machine-readable surface** — one JSON document on stdout, nothing else. It carries `schema_version` (currently 1; changes are additive, anything breaking bumps it), the run's identity and state (`run_id`, `run_type`, `source`, `started_at`, a derived `status` — `finished`/`paused`/`crashed`/`stopped`/`in-progress` — beside the raw flags, `paused_stage`/`paused_reason`/`paused_story_key`, and a boolean `graceful_stop_pending` — a graceful stop is on disk and the engine still live), the snapshot's `cache_read_weight`, run-level `tokens` (`raw` + `weighted`), and per-story `tasks` entries: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `commit_sha`, `defer_reason`, `preserve_ref` (else `null`), and a `tokens` object with the four raw counters plus derived `raw`/`weighted`. Weights come from the run's own policy snapshot, so the numbers match what it enforced. On error (no runs, unknown ref) stdout stays empty and the exit code is 1. The text output, by contrast, is **best-effort and not a stable interface** — parse the JSON. - -### Scripting `validate` - -`bmad-loop validate --json` turns the preflight into findings a script can act on: stdout becomes a single JSON document carrying `schema_version` (currently 1; additive changes only), the `ok` verdict, the queue `mode` (`sprint`/`stories`) and `spec_folder`, per-severity `counts`, and a flat `findings` array in the order the gates ran — each with a stable `check` id (`hooks.registered`, `adapter.binary`, `queue.sprint-status`, `skills.base-missing`, …), a `severity` of `ok`/`warning`/`problem`, the human `message`, and a `detail` object holding what the check knew before it flattened itself into that sentence (`mux.backends-detected` keeps every detected backend row, `skills.base-incomplete` keeps `missing_markers` as a list). - -**A failing check still emits the whole document, at exit 1.** That is the verdict being reported, not a failure to produce one — so branch on `.ok`, not on the exit status: `bmad-loop validate --json | jq -e '.ok'`, or `jq '.findings[] | select(.severity == "problem") | .check'` to see what to fix. The exit code is unchanged from the text mode, so an existing `bmad-loop validate || exit 1` in CI keeps working. - -Two things to know when matching. `message` is **not** contracted — several problems are the raw text of an underlying config/policy/profile exception, so the wording moves with those modules; `check` is the matchable identity. And a check id's _absence_ is not a pass: the gates are chained, so a policy that fails to load leaves the binary, hook and skill gates with nothing to check, and they contribute no finding at all. Read `ok` for the verdict. - -### Exit codes - -Normal command dispatch, argparse usage errors, and interruption resolve to one of four released codes — safe to branch on in CI: - -| Code | Name | Meaning | -| ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `0` | `OK` | the command did its job | -| `1` | `FAILURE` | the command could not do its job — a known error (bad config, git failure, unready project) or an unexpected one; the reason goes to stderr as `error: …` | -| `2` | `USAGE` | the invocation was malformed — an unknown subcommand or bad flag, caught by argument parsing before the command runs | -| `130` | `INTERRUPTED` | Ctrl+C (SIGINT) interrupted the process outside a run — 128 + signal 2, the shell's convention; a one-line `interrupted` notice goes to stderr, no traceback | - -No `ExitCode` values `3`–`129` or `≥131` are assigned yet. `INTERRUPTED` (`130`) covers only a Ctrl+C that lands **outside** a run — during config load or engine construction; a Ctrl+C **during** a run is caught by the engine and finalized as a clean, resumable `stopped` run, which exits `0`. The names come from the `ExitCode` enum in `bmad_loop.cli`. - -One caveat repeated from above: a `--json` command reporting a **verdict** exits `1` to _carry_ that verdict, not because the command broke — `validate --json` on an unready project exits `1` but still prints its whole document. So for those commands read the document's own field (`ok`), not the exit status; `1` conflates "the checks failed" with "the command failed". For everything else, `0` vs non-zero is the answer. +The bundled `unity` plugin (`[plugins] enabled = ["unity"]`) lets sessions drive a live Unity Editor through an Editor MCP (IvanMurzak or CoplayDev). `editor_mode = "shared"` works in place on your open Editor; `per_worktree` runs one managed Editor per worktree. A readiness gate defers a unit when the Editor never comes up. See the [game-engine plugin guide](docs/game-engine-plugin-guide.md) and the [Editor MCP guide](docs/game-engine-mcp-guide.md). ## Other coding CLIs -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/`: +Each coding CLI is a declarative TOML profile; built-ins ship in `bmad_loop/data/profiles/`. Per-CLI setup is in the [setup guide](docs/setup-guide.md#first-run-notes). -| 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 | 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"` works only once the project root is trusted: each unit worktree is then appended to `trustedWorkspaces` before its sessions launch ([#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. The prompt uses `$skill` mentions instead of slash commands. No SessionEnd hook; window death covers crashes. | +| `gemini` | supported, E2E-verified | Gemini CLI ≥ 0.46. Launched with `-i`; `AfterAgent` maps to Stop. | +| `copilot` | supported, E2E-verified | Copilot CLI (GA ≥ 2026-02), not the VS Code extension. Pin a capable model; the free default skips workflow steps. | +| `antigravity` | experimental | `agy` ≥ 1.1.3. Folder trust is exact-path, so worktree isolation needs the project root trusted. Reports no token usage. | +| `opencode` | supported, E2E-verified | OpenCode ≥ 1.18 (profile `opencode-http`) over HTTP/SSE, no tmux window. Needs the `[opencode]` extra. Unverified on native Windows. | -**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]`. +To add a CLI without Python, drop a profile in `.bmad-loop/profiles/.toml` and finalize it with `bmad-loop probe-adapter `; see the [adapter authoring guide](docs/adapter-authoring-guide.md). -**On budgets:** agentic sessions are dominated by cache reads, billed at ~0.1x base input, so budgets and every operator-facing total are cost-weighted at `limits.cache_read_weight` (default 0.1; `1.0` means raw, and changing it re-weights a resumed run's _whole_ history — the `run-resume` journal entry records both weights, so per-session totals written under the old one stay reconstructible). `max_tokens_per_story` is re-checked after every session a story runs, and the first crossing raises one latched ATTENTION + desktop notice; the cap is advisory, so nothing is terminated. `limits.max_tokens_per_session` (default 4M weighted) is enforced mid-session per `limits.session_budget_mode` (`off`/`warn`/`enforce`, default `warn`): a running session is sampled every ~30s and, under `enforce`, nudged to wrap up then terminated `over_budget` if it overruns `limits.session_budget_grace_s`. That sampling is live-verified on `claude` and best-effort elsewhere (mid-turn flush is unverified vendor behavior); profiles with no mid-session usage signal (`usage_parser = "none"`, Copilot's shutdown-only flush) leave the guard inert. - -**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`). +## Documentation -**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. +[docs/README.md](docs/README.md) indexes every guide. The most used: -Cursor CLI is currently blocked on two gaps, for whoever picks it up: token usage is not exposed anywhere (hooks, JSON output, or on-disk chats), and slash-command expansion of the initial prompt argument is unverified — its `sessionStart`/`stop` hooks do fire in the CLI, so a profile using the window-death fallback plus `usage_parser = "none"` is feasible. +- [Feature catalog](docs/FEATURES.md) — what bmad-loop does, feature by feature. +- [Setup guide](docs/setup-guide.md) — installing, initializing each CLI, upgrading, uninstalling. +- Reference: [configuration](docs/reference/configuration.md), [CLI](docs/reference/cli.md), [run state](docs/reference/run-state.md). +- [Recovering paused runs](docs/escalations-guide.md) and the [sweep guide](docs/sweep-guide.md). +- [TUI guide](docs/tui-guide.md) and [Terminal multiplexer backends](docs/multiplexer-backends.md). ## Development ```bash -uv sync --all-extras # adds pytest, ruff, pytest-asyncio (+ the [tui] extra) -uv run pytest -q # unit + engine scenarios (mock adapter) + tmux integration -uv run ruff check src tests scripts -``` - -**Regenerating the screenshots** in this README: they're rendered headlessly from a populated mock project (no live engine needed) — see [`scripts/gen_screenshots.py`](scripts/gen_screenshots.py). - -```bash -uv sync --extra tui -uv run python scripts/gen_screenshots.py # writes docs/images/*.svg + *.png (PNG needs `resvg` on PATH) -uv run python scripts/gen_demo.py # writes docs/images/demo.gif (needs `resvg` + `ffmpeg`) +uv sync --all-extras # dev tools plus every extra +uv run pytest -q # test suite +uv run pyright # typecheck +trunk check # lint, as CI runs it ``` -The hero **demo GIF** (`docs/images/demo.gif`) is generated the same headless way — `gen_demo.py` drives the read-only TUI through a scripted walkthrough and stitches the frames with `ffmpeg`. ([`scripts/record-demo.sh`](scripts/record-demo.sh) is an alternative that records a _real_ live run via VHS or asciinema, if you'd rather show actual agent sessions.) - -## Documentation - -**[docs/README.md](docs/README.md) is the full index** — every guide, grouped by when you need it. The highlights: - -- **[docs/FEATURES.md](docs/FEATURES.md)** — full feature & functionality list and the capability matrix (feature → problem addressed). -- **[docs/setup-guide.md](docs/setup-guide.md)** — installing the module + the `/bmad-loop-setup` walkthrough. -- **[docs/tui-guide.md](docs/tui-guide.md)** — the complete TUI reference. -- **[src/bmad_loop/data/skills/README.md](src/bmad_loop/data/skills/README.md)** — the `bmad-loop` skill module overview. -- **[docs/ROADMAP.md](docs/ROADMAP.md)** — planned/deferred orchestrator work and the rationale behind it. -- **[docs/multiplexer-backends.md](docs/multiplexer-backends.md)** — which backend drives your agent sessions, how selection resolves, and how external backends register. -- **[docs/adapter-authoring-guide.md](docs/adapter-authoring-guide.md)** — authoring CLI adapters & profiles (and transport backends). -- **[docs/plugin-authoring-guide.md](docs/plugin-authoring-guide.md)** — authoring plugins (hooks, workflows, settings). -- **[docs/game-engine-plugin-guide.md](docs/game-engine-plugin-guide.md)** — the game-engine plugin shape (Unity reference). -- **[docs/game-engine-mcp-guide.md](docs/game-engine-mcp-guide.md)** — Editor-MCP specifics for the bundled Unity plugin, and its `BMAD_LOOP_*` env-var reference. -- **[docs/tea-plugin-guide.md](docs/tea-plugin-guide.md)** — the bundled `tea` plugin and the test-architecture steps it injects. -- **[docs/porting-to-a-new-os.md](docs/porting-to-a-new-os.md)** — the four OS seams and what a native-Windows port costs end to end. -- **[docs/testing.md](docs/testing.md)** — the layer model, fixture/ablation doctrines, quality guards, and the zero-token invariant. +The screenshots and demo GIF are rendered headlessly from a mock project: `uv run python scripts/gen_screenshots.py` and `uv run python scripts/gen_demo.py` (they need `resvg`, and the GIF also needs `ffmpeg`). See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/testing.md](docs/testing.md). ## Contributing diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index 4d1e90336..e1760e8d3 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -146,7 +146,7 @@ psmux resolves every target through a **registry** — the directory named by `P holding one `.port`/`.key`/`.sid`/`.pid` set per session — and defaults it to `%USERPROFILE%\.psmux`. bmad-loop points it at a per-project root instead: `//_mux`, where `` is the location -[`BMAD_LOOP_STATE_DIR`](../README.md#environment-variables) resolves to and `` is +[`BMAD_LOOP_STATE_DIR`](reference/configuration.md#environment-variables) resolves to and `` is the same digest of the project's resolved path that tags session ownership. The export happens once per bmad-loop process, before anything spawns psmux, so `run`, `cleanup`, `stop`, `attach` and the TUI all agree on one registry without any of them persisting it. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0bb1481a5..fe4508a80 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,6 +1,6 @@ # Configuration reference -bmad-loop reads its policy from `.bmad-loop/policy.toml` in the project root. `bmad-loop init` writes a commented template there when the file is missing (it never overwrites an existing one) and adds the file to the project's `.gitignore`, so policy is per-machine. Every key is optional: an unset key takes the default listed below, unknown core keys are ignored, and a value of the wrong type or outside its allowed set is rejected with a policy error. The TUI settings screen edits the same file. +bmad-loop reads its policy from `.bmad-loop/policy.toml` in the project root. `bmad-loop init` writes a commented template there when the file is missing (it never overwrites an existing one) and adds the file to the project's `.gitignore`, so policy is per-machine. Every key is optional: an unset key takes the default listed below, unknown core keys are ignored, and a value of the wrong type or outside its allowed set is rejected with a policy error. The TUI settings screen edits the same file. Each `run` and `sweep` snapshots the policy into its run state when it starts, and `resume` re-stamps that snapshot from the current file, so an edit applies to new runs and to the next resume of an existing one. Where a key can be set in more than one place, the first of these wins: diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 3e9998a23..b9361635d 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -14,8 +14,7 @@ the three skills, so the simplest path is **pip + `bmad-loop init`**, which inst itself. Alternatively the **BMAD-method installer** copies them. Either way the `/bmad-loop-setup` skill installs the orchestrator tool, picks which coding CLIs to drive, and bootstraps the project. For the one-page summary, -see the [Installing the skill module](../README.md#installing-the-skill-module) section -of the README. +see [Installing and upgrading](../README.md#installing-and-upgrading) in the README. ## Platform prerequisites @@ -110,8 +109,10 @@ claude "/bmad-loop-setup accept all defaults" # install the to ``` Add `--cli codex --cli gemini` to also populate `.agents/skills/`. `init` always -installs all the bundled skills together (`bmad-loop-resolve`, `bmad-loop-sweep`, -`bmad-loop-setup`); `bmad-loop-sweep` owns the canonical `deferred-work-format.md` +installs all the bundled skills together: `bmad-loop-resolve` (the interactive +`/bmad-loop-resolve ` escalation workflow), `bmad-loop-sweep` (automation-only +ledger triage) and `bmad-loop-setup` (installs the tool, then runs `init` and +`validate`); `bmad-loop-sweep` owns the canonical `deferred-work-format.md` the orchestrator normalizes the ledger to. The dev primitive `bmad-build-auto` (spelled `bmad-dev-auto` before the 6.10.1 rename — the orchestrator resolves whichever name is on disk and invokes that one) is @@ -246,6 +247,10 @@ The `[tui]` extra pulls in the Textual dashboard (`textual` + `tomlkit` + `pyte` uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" ``` +**A pinned release tag** (reproducible; recommended for day-to-day use): append the tag to the URL, +as in `...bmad-loop.git@v` (see the [releases](https://github.com/bmad-code-org/bmad-loop/releases) for tags). +Without a tag the install tracks `main`. + **Tool first, TUI later (separately):** install the core without the extra, then add the dashboard whenever you want it by re-running the same command **with** `[tui]`: @@ -264,15 +269,36 @@ failing obscurely. `uv tool install` drops `bmad-loop` into uv's own managed tool environment, so there's no PEP 668 externally-managed conflict and no need for a virtualenv, `--user`, or `--break-system-packages`. -To upgrade later, the simplest path is to re-run `/bmad-loop-setup` (or `/bmad-loop-setup -upgrade`) — it detects the existing install, upgrades the tool with `uv tool upgrade -bmad-loop --reinstall` (the `--reinstall` is **required** for a git source — a plain -`uv tool upgrade` reuses the cached commit and won't pull new code), and re-lays the -per-project skills with `bmad-loop init --force-skills`. To do it by hand, run those two -commands yourself (see the [Upgrading](../README.md#upgrading) section of the README). - Confirm with `bmad-loop --version`. +### Upgrading + +Upgrading is two steps: the tool, and the per-project skill copies that `init` laid down +(a tool upgrade does not touch them). The simplest path is to re-run the setup skill, which +detects the existing install and runs both steps: + +```bash +claude "/bmad-loop-setup upgrade" +``` + +By hand (non-Claude CLIs, CI, scripts): + +```bash +# 1. upgrade the tool; --reinstall is required for a git source, or uv reuses the cached commit +uv tool upgrade bmad-loop --reinstall +# to move to a newer tag instead, re-install with the new ref: +# uv tool install --force "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git@v" + +# 2. re-lay the refreshed skills into each project that uses bmad-loop +bmad-loop init --project /path/to/project --force-skills +``` + +`.bmad-loop/policy.toml` is left untouched: new keys are optional and take their defaults. +`init` gitignores policy.toml because it is per-machine; if yours is already committed, run +`git rm --cached .bmad-loop/policy.toml` once to stop sharing it (your local copy is kept). +Read the [CHANGELOG](../CHANGELOG.md) or the +[releases](https://github.com/bmad-code-org/bmad-loop/releases) for what changed between tags. + ## Initializing CLIs other than claude `bmad-loop init` registers hooks and installs the bundled `bmad-loop-*` skills per CLI. The @@ -384,7 +410,7 @@ yet, or `bmad-sprint-planning` hasn't run). On a fresh project that is **expecte output as a readiness checklist, not an install failure. For the dashboard itself, see [docs/tui-guide.md](tui-guide.md). For the full policy -reference, see the [Policy section](../README.md#policy-bmad-looppolicytoml) of the README. +reference, see the [configuration reference](reference/configuration.md). ## Uninstalling @@ -410,9 +436,9 @@ bmad-loop cleanup --project # kill leftover tmux sessions `clean --hard` permanently deletes runs instead of archiving them (we're removing the tool, so there's nothing to keep). Running it **first** also matters for the out-of-tree half: each run's control-plane directory lives under the user-scoped state root, and `clean` is what collects it -(step 2 covers what is left over). See the disk-reclamation coverage in -[docs/FEATURES.md](FEATURES.md) and the [command reference](../README.md#command-reference) for -what each command touches. Make sure no run is still live (Editor open, session attached) first. +(step 2 covers what is left over). See +[Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk) and the +[CLI reference](reference/cli.md#subcommands) for what each command touches. Make sure no run is still live (Editor open, session attached) first. ### 2. Remove the orchestrator state diff --git a/docs/sweep-guide.md b/docs/sweep-guide.md index 8ee23fd02..6343c2c51 100644 --- a/docs/sweep-guide.md +++ b/docs/sweep-guide.md @@ -86,6 +86,8 @@ A story can close ledger entries when it commits. A non-list `closes_deferred` in `stories.yaml` stops the manifest from loading. In a spec it is only journaled. If the ledger cannot be read at commit time, the story still commits, nothing is closed, and you get a `declared deferred closes unapplied` notice. Repair the ledger, then run `bmad-loop sweep` to reconcile. +If the ledger lives outside the repo (`implementation_artifacts` configured outside the project tree), the annotation is still written at the commit but is part of no commit; the run journals `deferred-close-external-ledger`. The `stories.yaml` channel is hand-authored: upstream Story Breakdown does not emit `closes_deferred`, so re-deriving `stories.yaml` drops it. Note the intent beside the story if you expect to re-derive. + ## Hard gates A ledger entry can block stories until it is done. diff --git a/src/bmad_loop/envvars.py b/src/bmad_loop/envvars.py index 6146590b9..729b83a54 100644 --- a/src/bmad_loop/envvars.py +++ b/src/bmad_loop/envvars.py @@ -3,8 +3,8 @@ These operator/test override knobs used to be read inline at scattered call sites (`engine`, `adapters.multiplexer`, `cli`, `process_host`), which left them undiscoverable and undocumented. This module is the one place each is named, -typed, and given a reader; the operator-facing table lives in the README under -"Environment variables". +typed, and given a reader; the operator-facing table lives in +`docs/reference/configuration.md` under "Environment variables". Each reader preserves its call site's exact semantics — same parse, same fallback — so routing a site through here changes nothing observable. Only the diff --git a/tests/test_cli.py b/tests/test_cli.py index 76b089586..8364da235 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16483,8 +16483,8 @@ def test_validate_isolation_gate_needs_both_halves( project, monkeypatch, capsys, override, policy_text ): """Either half alone is a configuration this release supports and documents: a - `repo_root` override under `isolation = "none"` is the monorepo knob README's - "Worktree isolation" section documents, and + `repo_root` override under `isolation = "none"` is the monorepo knob the + FEATURES "Trust-nothing verification" entry documents, and worktree isolation without an override is the ordinary isolated setup. The gate stays silent on both, and validate still passes — the green rc is the second witness, since a gate that fired would take the whole verdict with it.""" diff --git a/tests/test_docs_hygiene.py b/tests/test_docs_hygiene.py index bf5eef9f1..e62d3e56b 100644 --- a/tests/test_docs_hygiene.py +++ b/tests/test_docs_hygiene.py @@ -171,7 +171,7 @@ def test_slugger_matches_github_on_known_anchors(): assert slug("[Linked](x.md) heading") == "linked-heading" real = { "docs/plugin-authoring-guide.md": "trust--safety", - "README.md": "policy-bmad-looppolicytoml", + "docs/reference/configuration.md": "environment-variables", "docs/multiplexer-backends.md": "psmux-native-windows-experimental", "docs/escalations-guide.md": "resolve-reverify", } diff --git a/tests/test_envvars.py b/tests/test_envvars.py index 93caa81f6..2eee6cdf9 100644 --- a/tests/test_envvars.py +++ b/tests/test_envvars.py @@ -2,8 +2,8 @@ `envvars` is the one place each core var is named, typed, and given a reader, so what is pinned here is the *contract* the call sites (`engine`, -`adapters.multiplexer`, `cli`, `process_host`, `runs`) and the README's -"Environment variables" table both depend on: the literal names, and each +`adapters.multiplexer`, `cli`, `process_host`, `runs`) and the "Environment variables" +table in `docs/reference/configuration.md` both depend on: the literal names, and each reader's parse and fallback. The two name readers pass their value through verbatim on purpose — validation lives downstream in the registry that resolves the name — so a test asserting rejection here would be asserting the wrong @@ -29,7 +29,7 @@ def test_constants_are_the_literal_env_var_names(): """The constants ARE the public contract: call sites import the name rather - than spelling the string, and the README table documents these literals for + than spelling the string, and the configuration reference documents these literals for operators. Renaming one silently retires an override — the variable an operator exports simply stops being read, with no error anywhere — so the strings are pinned, not merely the readers that consume them.""" From d911ac3956630b8d92217c61cb31724af8949c31 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 02:43:09 -0700 Subject: [PATCH 6/9] docs(adapters): condense the adapter guide and dedupe the transport docs --- docs/adapter-authoring-guide.md | 1261 ++++++++++++------------------- docs/multiplexer-backends.md | 570 ++++++-------- docs/porting-to-a-new-os.md | 614 ++++++--------- src/bmad_loop/runs.py | 11 +- 4 files changed, 951 insertions(+), 1505 deletions(-) diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index fc1d4f7e0..98440fae4 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -1,380 +1,224 @@ # Authoring CLI adapters & profiles -bmad-loop drives any coding CLI that fits the **tmux-injection + hook-signal** -transport through one generic adapter (`adapters/generic.py`); everything +bmad-loop drives any coding CLI that fits the **multiplexer-injection + hook-signal** +transport through one generic adapter (`adapters/generic.py`). Everything CLI-specific lives in a declarative **TOML profile** (`adapters/profile.py`). This -guide is the canonical home for the profile schema and the two ways to teach -bmad-loop a new CLI: - -- **The common case — a TOML profile.** If the CLI fits tmux + hook-signal, you - write no Python. The [Profile field reference](#profile-field-reference) is the - complete `CLIProfile` / `HookSpec` schema; the - [walkthrough below](#walkthrough-finalizing-a-profile) shows how `probe-adapter` - finalizes one against a real run. -- **The advanced case — a new adapter class.** If the CLI does _not_ fit that - transport (e.g. an HTTP/SSE service), see - [Writing a new adapter class](#writing-a-new-adapter-class) for the - `CodingCLIAdapter` ABC — and - [Shipping a new adapter class out-of-tree](#shipping-a-new-adapter-class-out-of-tree) - to register it (and the profile that selects it) from a co-installed package - with **zero core edits**, the same way a transport backend ships out-of-tree. +guide covers the profile schema and the two ways to teach bmad-loop a new CLI: + +- **The common case: a TOML profile.** If the CLI runs in a terminal pane and fires + lifecycle hooks, you write no Python. The [Profile field reference](#profile-field-reference) + is the complete schema; the [walkthrough](#walkthrough-finalizing-a-profile) shows how + `probe-adapter` finalizes a profile against a real run. +- **The advanced case: a new adapter class.** If the CLI does _not_ fit that transport + (an HTTP/SSE service, say), see [Writing a new adapter class](#writing-a-new-adapter-class) + and [Shipping a new adapter class out-of-tree](#shipping-a-new-adapter-class-out-of-tree). ## Two axes: CLI vs transport -These are independent and abstracted separately: - -- **CLI axis** — `CodingCLIAdapter` (`adapters/base.py`): _which_ binary to launch, - how the prompt is rendered, the hook dialect, where the transcript lives. The - generic adapter + a TOML profile cover the common case; a CLI needing its own - adapter class registers one via `register_adapter(...)` (`adapters/registry.py`), - selected by the profile's `adapter` field and shippable out-of-tree just like a - transport backend. Most of this guide is about this axis. -- **Transport axis** — `TerminalMultiplexer` (`adapters/multiplexer.py`): how - sessions, windows, and panes are created, observed, and torn down. The generic - adapter never shells out itself — it goes through `self.mux`, obtained from - `get_multiplexer()`. The bundled family is tmux-shaped: argv construction and - the single spawn primitive live in `BaseTmuxBackend` (`adapters/tmux_base.py`), - with the thin POSIX leaf `TmuxMultiplexer` (`adapters/tmux_backend.py`) and the - native-Windows leaf `PsmuxMultiplexer` (`adapters/psmux_backend.py`), which - points the same spawn seam at psmux's own binary via the `_BINARY` class - attribute; the base and its POSIX leaf are the **only** files allowed to invoke - `tmux` (and the only place POSIX-shell trailers live). Any other backend - registers itself via `register_multiplexer(...)` and slots in behind - `get_multiplexer()` with no change to the adapters. A backend author reads - `multiplexer.py` for the contract and `tmux_backend.py` / `tmux_base.py` for the - reference implementation. Transport is one of **four OS seams** — the others - (process lifecycle, hook interpreter, validate preflight) are mapped in - [Porting bmad-loop to a new OS](porting-to-a-new-os.md). - -### The transport contract (for a backend author) - -Every part of the codebase that touches sessions, windows, or clients now goes -through `get_multiplexer()` — not just the generic adapter but also `runs.py` -(session listing/tagging, kill, attach argv), `tui/launch.py` (the control -session and its parked orchestrator windows), `probe.py` (the throwaway probe -session), and `tui/data.py` (legacy-run liveness). A grep for `"tmux"` outside the -tmux backend (`adapters/tmux_base.py` + `adapters/tmux_backend.py`) should turn up -only `shutil.which("tmux")` presence checks, never an invocation. - -To add a backend, build a `TerminalMultiplexer` (`adapters/multiplexer.py`) and -**register** it — `register_multiplexer(name, matches, factory)`, where -`matches(sys.platform)` decides automatic selection and `name` is the key both -the `BMAD_LOOP_MUX_BACKEND` env var and the persisted `[mux] backend` policy key -force. `get_multiplexer()` resolves by precedence: env var → `[mux] backend` -(set with `bmad-loop mux set `, machine-scoped — policy.toml is -gitignored) → the platform default when registered and `available()` → the -first available platform match → the historical tmux fallback. `bmad-loop mux` -lists every registered backend and the selection; same-platform backends need -discriminating `available()` probes (see the -[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)). -An out-of-tree backend package makes its registration run by advertising the -module under the `bmad_loop.mux_backends` entry-point group — core imports it -before every selection, so co-installing the package is the whole setup (see -[the porting guide](porting-to-a-new-os.md#shipping-out-of-tree-the-bmad_loopmux_backends-entry-point); -a broken package degrades to a `bmad-loop mux` warning, never a selection failure). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`) -for a tmux-family backend — overriding only its single spawn primitive `_run()` -plus the shell-dialect hooks (`_shell_wrap`, `_join_argv`, `_parked_trailer`, -`_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` -fragments; the POSIX `/bin/sh -c` launch-pid prelude, DW-507, is added by the -`TmuxMultiplexer` leaf, not the base, so a leaf overriding `_shell_wrap` for -another dialect never inherits POSIX launch source) — or implement -`TerminalMultiplexer` fresh for a host with no tmux-shaped CLI. The non-transport -seams of a full OS port are in -[Porting bmad-loop to a new OS](porting-to-a-new-os.md). The contract groups into: - -- **Sessions** — `has_session`, `new_session` (geometry is - optional: agent sessions pin a fixed pane size because they are observed - while detached; the control session omits it), - `kill_session`, `list_sessions`, `session_options` (read a user option - across all sessions), `set_session_option`. - - `list_sessions_reporting(on_fault=)` is non-abstract and defaults to - `list_sessions()`, which is right if your listing raises - `MultiplexerError` when it fails. If it instead answers `[]` for a failed - listing (as the tmux family does), override it and hand the fault to - `on_fault` in place of your own warning. The removal guard reads the listing - as "no live session", and this is how the TUI, which cannot see stderr, - learns that the listing was never really taken. - - One session method carries a default the seam cannot verify for your - transport: `session_name_key(name)`, the canonical comparison key — two - names denote the same live session exactly when their keys are equal. The - default is identity (exact comparison — correct for tmux, whose session - names are case-sensitive). **If your transport resolves session names - case-insensitively — or folds them any other way — you MUST override** - (psmux does: its NTFS port-file store opens names case-insensitively). - With the inherited identity key, a case-variant of the control session's - name is not discounted by the removal guard, so the documented recovery - `bmad-loop delete ctl` wedges behind a false live-session refusal on a - persisted `CTL` run. - -- **Windows** — `new_window` (run a command in a fresh window), `new_parked_window` - (run a command, then _park_ on a keypress so the exit - status stays inspectable, then return any attached client to its origin — the - POSIX `sh -c` recipe is composed from the base's overridable shell-dialect - hooks, so a non-POSIX backend swaps the dialect fragments, not the method - body), `list_window_ids` - (which MUST emit the same id form your `new_window` returns — `window_alive` is - a membership test over it, so qualifying one side and not the other reads every - live window as dead), `list_windows` (selected fields per window), - `window_alive`, `kill_window`, `select_window`, `set_window_option`, - `unset_window_option`, `show_window_option`, `pipe_pane` (tee a pane to a log), - `send_text`. -- **Client / attach** — `attach_target_argv` (argv that reaches a target, nesting- - aware), `current_pane_id` / `current_window_id` / `current_session`, - `current_return_target` (the value an interactive attach records for the - parked-window return hop, replayed opaquely by `switch_client` and the parked - trailer; concrete default = the native pane id, so most backends inherit it — - override only when your ids do not resolve from another session's context, - as psmux does to emit `=session:%N`), `detach_client` / `switch_client` (with - an optional last-client fallback — see the rule below), - `available` (is this backend usable on the current host), `version` (the - binary's version string or `None` — **one bounded line**, folded with - `fold_version()`; see [the porting guide](porting-to-a-new-os.md)). -- **Registry namespace** (optional; all three default to "no namespace", so a - transport without one writes nothing) — `has_registry_namespace` (a property - of the transport: does it namespace sessions by registry at all? Answer - `True` on every instance if it does, even when no root is currently in - force: cleanup uses it to tell "no namespace exists" from "running on the - transport's shared default registry", and only the first lets an untagged - session be claimed on run-directory evidence — a namespaced backend that - leaves this at the inherited `False` keeps the pre-namespace historical - reach, which on a shared registry can kill another project's session), - `registry_root` (the root your verbs - currently resolve targets through, for `bmad-loop mux` to disclose; `None` - from a namespaced backend means "no root in force" — the shared default — - not "no namespace") and - `legacy_registries` (instances bound to roots your sessions may predate, for - the cleanup sweep). Only for a transport that addresses sessions through a - directory of per-session files, as psmux does through `PSMUX_DATA_DIR`; the - rules are in [the porting - guide](porting-to-a-new-os.md#registry-namespaces-only-if-your-transport-has-one). - - **Both client verbs report effect, not dispatch.** They answer what the - parked-window return path trusts — a bool from `detach_client`, a tri-state - from `switch_client` (see below) — so a backend with no real detach (herdr — - only a manual chord releases its client) answers `False`. If your CLI's exit - code already means "a client moved" you are done (tmux: `detach-client` fails - with _no current client_); if it does not, **measure** what the verb is meant - to change — psmux counts the session's attached clients across `detach_client` - and the last-client fallback, and answers on the drop — and record what the - measure cannot see in your degradation ledger. Where no measure is available, - answer `False` from `detach_client` and `None` from `switch_client`, and - record that gap in the ledger too — per _call_, though, never as the wiring: a - `switch_client` hard-coded to `None` pins the return path to `UNREACHABLE`, and - an attended sweep then stops prompting for good. Use the exit code wherever it - carries the effect. Check the ledger against every verb before you - trust one measure for all of them: psmux's drop is blind - to a `switch_client` whose target sits in the same session, which is the common - case for the return path, so that verb gates on the client count read _before_ - the call and takes the exit code as the verdict instead (#659). A fallback verb - belongs to the same audit — hang it on the primary verb's failure, never on - "the primary could not be vouched for", or a hand-back that worked gets undone - by its own fallback and the undo is read as the success. - `tui.launch.return_attached_client` reads a `False` from `switch_client` as - `ATTENDED` — the client never left this window, so an attended sweep keeps - prompting — and a `False` from `detach_client` as `UNREACHABLE`, which is evidence - of nothing, so the sweep goes unattended and defers this cycle's decisions to - `bmad-loop decisions`. That makes `switch_client`'s `False` a **joint claim**: - no switch happened _and_ the client is still here. A move you cannot _vouch_ - for is only its first half — a timed-out verb, an unreadable count, nothing - attached to move — so answer `None`, which routes to `UNREACHABLE`: the return - option survives and the sweep still stops prompting a window the client may - already have left. Do not reach for that surviving option as the recovery. The - parked trailer sits behind a blocking read, so it never runs while a `--repeat` - cycle is stuck on `input()` in the same window — which is exactly what an - `ATTENDED` the client has walked away from produces. A vacuous `True` remains - the one answer no backend may give: it announces a hand-back that never - happened and sends the sweep unattended with the human still sitting there - (#227). - -**Window targets.** The target-taking methods (`kill_window`, `select_window`, -the window-option trio, `attach_target_argv`, `switch_client`) receive one of two -families: the **seam-canonical target token** `=session[:window]` — formatted by -the concrete `TerminalMultiplexer.target(session, window=None)`, decoded by the -module-level `parse_target()` — or the backend's own **native id** (whatever your -`new_window` returned). Core never hand-assembles the grammar; it calls -`target()`. (Two values are composed by the backend itself rather than by -`target()`, precisely so the seam grammar never has to carry a pane or window -id: the parked-window return target — `current_return_target`, above — and the -native window id, which psmux qualifies to `session:@N` because its ids are -per-server (falling back to the bare id where that grammar would not survive). -Both are replayed opaquely; neither is parsed by core. psmux applies the same -qualification to `new_parked_window`, the `window_id` columns of `list_windows` -and `current_window_id`. To preserve unambiguous lookup, `new_parked_window` must -agree with the `list_windows` column; a backend that qualifies one side only -remains usable but falls back to resolving parked windows by name, which is -ambiguous whenever several kinds share a run id (#482). tmux consumes the token -natively (it coincides with tmux exact-match syntax), so `BaseTmuxBackend` passes -it straight through. A native-id backend calls `parse_target()` first — `None` -means "already a native id, use as-is", otherwise resolve `(session, window)` -yourself; the herdr adapter's -`_parse_target` -([backend.py](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/src/bmad_loop_adapter_herdr/backend.py)) -is the worked example (workspace-by-label → tab-by-name → root pane, resolved -lazily at use time). You MAY override `target()` to emit native ids, but the token must -stay a stable _by-name_ reference: core formats targets ahead of use (a parked -window's return target, for one), so eager resolution to a live id goes stale — -inheriting the default and resolving lazily is almost always right. - -**The qualified-id obligation.** The psmux qualifications above are instances of -one rule, and it is the rule — not the instances — a native-id backend needs: -**if an id-minting seam returns anything other than a bare native id, every seam -whose output core compares that id against must emit the identical form, and -every verb the id is replayed through must accept it.** Where a verb cannot take -that form — or cannot take a window scope at all — the backend translates inside -that verb rather than exempting the id from qualification: psmux has no -per-window _user_ options to write to (#310), so its option trio runs the -qualified target through `_option_scope` and writes a session-scoped key carrying -the window's id, refusing a bare id rather than guessing a server (the built-ins -pass straight through to the base). It carried a second such translation on -`select_window` until its 3.3.8 floor made the server resolve a scoped id itself -(psmux/psmux#497). Where the composed grammar cannot carry the value at all, the -backend degrades uniformly across every seam that mints or lists the id, so the -pairings still line up under the fallback — but the fallback is lossy, not a free -escape hatch: a bare psmux id routes by the _caller's_ server, so the degrade -condition must stay the narrow one the grammar forces (#221), never a -convenience. Those pairings are not a closed set: a new one appears whenever a -caller compares two id-producing seams to each other, so treat the rule as the -contract and each documented pairing as an instance of it. Every way of getting -the _form_ wrong is quiet — these are id-shape faults, never transport faults, -which `list_window_ids` must still raise: a mint/list split reads every live -window as instantly dead; a `list_windows`/`current_window_id` split makes the -ctl-window prune kill the window it is running in (#291); a -`list_windows`/`list_window_ids` split reports every kill candidate as verifiably -gone, survivors included (#435). And a verb handed a form it rejects fails or -silently no-ops — the seam is best-effort or its caller swallows the raise, so no -error reaches core either way. - -Operations that can race a window dying (`pipe_pane`) or a session already being -gone (`kill_session`) must tolerate it rather than raise; everything else raises a -`MultiplexerError` subclass on failure, which call sites catch at the seam (e.g. -`tui/launch.start_detached` turns it into a `LaunchError`) without importing the -backend. `window_alive` uses `list-windows` membership, not `display-message`, because -`display-message -t ` exits 0 on tmux. - -`tmux_backend.py` is the reference implementation; reading it alongside the ABC is -the fastest way to see exactly what a `new_parked_window` or `session_options` must -produce. - -For the **implement-fresh** path, the external herdr adapter -([pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr), -`src/bmad_loop_adapter_herdr/backend.py`) is the reference worked example — a -backend over [herdr](https://herdr.dev), a cross-platform, agent-aware workspace -manager whose CLI is a different binary family from tmux. Its -mapping: a bmad-loop **session** is a herdr **workspace** (label == session name), a -**window** is a **tab** (one shell pane, whose `root_pane.pane_id` is the native -window id handed back), and the launched command runs via a typed `exec ` -(`pane run` = type + Enter) so process-exit == pane-close == tab-close == -tmux-identical window death. Where herdr has no analogue, the backend degrades -honestly rather than faking it: session/window **options** (which herdr lacks -entirely) live in a cross-process JSON **sidecar** (atomic swaps for readers, an OS -advisory lock around each read-modify-write so concurrent writers never lose -updates), and `pipe_pane` — herdr has no -tee — runs a per-window **poller** thread that snapshots `pane read` into the log -whenever the content changes, which is exactly enough to drive the two log consumers -a tmux tee would (`generic._log_activity_key`'s stall re-arm and `probe`'s marker -discovery). Its module docstring is a **degradation ledger** of every such -divergence (sidecar options, poller `pipe_pane`, the no-op `detach_client` — -which #317's widening requires to answer `False`, not `None`, so the -parked-return path reads it as `UNREACHABLE` and an attended sweep stops -prompting into a window whose client only a manual chord can release; that is a -different widening from `switch_client`'s tri-state above, and `detach_client` -is the verb that stays a bool — the attach -argv, the advisory geometry, the protocol-version policy) — the reference for what -"implement fresh" costs when the host has no tmux-shaped CLI. The operator-facing -view — what a herdr _user_ notices and does — is -[the adapter's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). - -The hard part of a new profile isn't the TOML — it's the **facts that live in no -doc**: the CLI's exact hook payload shape (field names and casing, whether -`session_id` / `transcript_path` / `cwd` are present), where it writes its session -transcript and in what format, and the token-usage schema a `usage_parser` has to -read. `session_id` matters beyond bookkeeping: it is how the generic adapter tells the +The two axes are independent: + +- **CLI axis:** `CodingCLIAdapter` (`adapters/base.py`) decides which binary to launch, how + the prompt is rendered, the hook dialect, and where the transcript lives. The generic + adapter plus a TOML profile covers the common case. A CLI that needs its own class + registers one with `register_adapter(...)` (`adapters/registry.py`), selected by the + profile's `adapter` field. Most of this guide is about this axis. +- **Transport axis:** `TerminalMultiplexer` (`adapters/multiplexer.py`) creates, observes + and tears down sessions, windows and panes. Adapters never shell out themselves; they go + through `self.mux`, obtained from `get_multiplexer()`. Only `adapters/tmux_base.py` + (`BaseTmuxBackend`) and its POSIX leaf `adapters/tmux_backend.py` (`TmuxMultiplexer`) + invoke `tmux`. The native-Windows leaf `adapters/psmux_backend.py` drives the `psmux` + binary through the same base. Transport is one of four OS seams; registration, the two + build paths and the other seams are in [Porting bmad-loop to a new OS](porting-to-a-new-os.md). + Operators choosing a backend read [Terminal multiplexer backends](multiplexer-backends.md). + +### The transport contract for a backend author + +A backend subclasses `TerminalMultiplexer` and registers with +`register_multiplexer(name, matches, factory)`. How to register, ship out-of-tree, and +choose between extending `BaseTmuxBackend` and implementing fresh is in the +[porting guide](porting-to-a-new-os.md#shipping-out-of-tree-the-bmad_loopmux_backends-entry-point); +same-platform backends need +[discriminating `available()` probes](porting-to-a-new-os.md#availability-discriminators-same-platform-backends). +`tmux_base.py` is the reference implementation. + +Rules that span methods: + +- **Failures.** Raise a `MultiplexerError` subclass, so call sites catch the seam type + without importing the backend. Each method below states whether it raises, tolerates, or + answers a sentinel. +- **Targets.** Target-taking methods receive either the seam token `=session[:window]`, + built by `target()`, or a native id your backend minted. `parse_target()` decodes a token + to `(session, window)` and answers `None` for a native id, which you use as-is. Core never + builds the grammar by hand. +- **Qualified ids.** If a minting method returns anything other than a bare native id, + every method whose output core compares that id against must emit the identical form, and + every verb the id is replayed through must accept it. The pairings: `new_window` ↔ + `list_window_ids`; `new_parked_window` ↔ the `list_windows` `window_id` column; that column + ↔ `list_window_ids` ↔ `current_window_id`. A verb that cannot take the form translates + inside itself. +- **Client verbs report effect, not dispatch.** `detach_client` and `switch_client` answer + what actually happened to the client. A vacuous `True` is never allowed. + +**Sessions** + +- `has_session(name)`: true iff the session exists. A failed lookup answers `False`; a transport failure raises. +- `new_session(name, cwd, cols=None, lines=None)`: create a detached session with one shell window; pin the geometry when given. +- `kill_session(name)`: tolerate the session already being gone. +- `list_sessions()`: names of all live sessions. On a failed listing, raise, or answer `[]` and override `list_sessions_reporting`. +- `list_sessions_reporting(*, on_fault=None)` (default: `list_sessions()`): a backend that folds a failed listing to `[]` hands a one-line fault to `on_fault`. +- `session_name_key(name)` (default: identity): the comparison key. Override it if the transport folds names, for example case-insensitively. +- `session_options(option)`: map of session name to that option's value. +- `set_session_option(name, option, value)`: raises on a transport failure. A value the transport cannot carry verbatim may be refused with a warning. + +**Windows** + +- `new_window(session, name, cwd, env, command)`: run `command` with `env` layered on; return the native window id. +- `new_parked_window(session, name, cwd, argv, return_opt)`: run `argv`, print `PARKED_BANNER`, park on a key, then return any attached client via `return_opt`. +- `list_window_ids(session)`: same id form as `new_window`. Answer `[]` only for an empty listing or a proved-gone session; otherwise raise. +- `list_windows(session, fields)`: one tuple per window in field order; best-effort `[]` on failure. +- `window_alive(session, window_id)`: membership test over the listing. Raise when liveness cannot be known; never answer `False` for that. +- `kill_window(target)`: tolerate the window already being gone; no-op on a transport failure. +- `select_window(target)`: make the window current; best-effort. +- `set_window_option(target, option, value)` / `unset_window_option(target, option)`: best-effort. An unset option must read back as unset, not empty. +- `show_window_option(target, option)`: the value, or `''` when unset or on failure. +- `pipe_pane(window_id, log_file)`: tee the pane to `log_file`; tolerate a dead window. +- `send_text(window_id, text)`: send `text` literally, then Enter. +- `capture_pane(window_id)` (default: raises `MultiplexerError`): the visible screen as text. Override it to enable `parked_prompt_patterns`. + +**Client and attach** + +- `attach_target_argv(target)`: argv that attaches the caller's terminal to a target, nesting-aware. +- `current_pane_id()` / `current_window_id()` / `current_session()`: this process's pane, window and session, or `None` outside the multiplexer. +- `current_return_target()` (default: `current_pane_id()`): what an attach records for the parked-window return hop. Override when native ids do not resolve from another session. +- `detach_client()`: `True` only when a client was actually detached. A failure, or a backend with no real detach, answers `False`. +- `switch_client(target, last_fallback=False)`: `True` = switched; `False` = nothing switched and the client is still here; `None` = cannot vouch (timeout, unreadable effect, no client). + +**Probes** + +- `available()`: can this backend run on this host. Keep it cheap and side-effect-free; a raise reads as unavailable. +- `version()` (default: `None`): one bounded line built with `fold_version()`, identifying version first. +- `version_error()` (default: `None`): why the last `version()` answered `None` although the binary exists. Must not raise. + +**Registry namespace** (all default to "no namespace"; rules in the +[porting guide](porting-to-a-new-os.md#registry-namespaces-only-if-your-transport-has-one)) + +- `has_registry_namespace()` (default: `False`): `True` on every instance if the transport namespaces sessions by registry. +- `registry_root()` (default: `None`): the root the verbs currently resolve through. Must not raise. +- `legacy_registries()` (default: `[]`): independent instances bound to older registries, for the cleanup sweep. + +**Other defaults** + +- `target(session, window=None)` (default: `=session[:window]`): override only to emit another stable by-name reference, never an eagerly resolved live id. +- `inherited_env(session, name, *, on_fault=None)` (default: `None`): the value a new pane would inherit, `UNSET` when confirmed absent, `None` when unknown. Must not raise. +- `window_pane_pids(target)` (default: `[]`): best-effort pids of the pane's root processes. Must not raise. + +The external herdr backend ([pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr)) +is a worked example of a native-id backend: its `_parse_target` resolves the seam token +lazily at use time, and its module docstring lists every place it degrades. + +## Collecting CLI facts with `probe-adapter` + +The hard part of a new profile is the facts no doc records: the CLI's hook payload shape +(field names and casing; whether `session_id`, `transcript_path` and `cwd` are present), +where it writes its transcript and in what format, and the token-usage schema a +`usage_parser` has to read. `session_id` matters: it is how the generic adapter tells the launched session's events from a nested CLI's (see -[Session identity and nested CLIs](#session-identity-and-nested-clis)). Historically the only way to get these was to hand a volunteer a manual -recipe and ask them to sanitize the output by hand — error-prone and PII-risky. +[Session identity and nested CLIs](#session-identity-and-nested-clis)). -**`bmad-loop probe-adapter`** (alias `collect-adapter-data`) pulls all of that and -runs it through an audited sanitizer, so a user of any coding CLI can run one -command and paste back a clean, content-free report. +`bmad-loop probe-adapter` (alias `collect-adapter-data`) gathers these and runs them +through an audited sanitizer, so anyone can run one command and paste back a clean, +content-free report. ```bash bmad-loop probe-adapter --project . # default: zero-launch scan bmad-loop probe-adapter --probe --project . # opt-in live capture ``` ---- +### Two modes + +Both modes emit the same sanitized finding: markdown on stdout, or a file with `--out`; +`--json` emits a machine-readable JSON document instead. + +#### SCAN (default, no process launch) -## Two modes +Runs ` --version` and `--help`, finds the newest existing session transcript by +convention, reads the declared hook config, and infers the token schema from the +transcript. Use it first for any CLI that already has a profile or that you have run by +hand. -Both modes emit the **same single sanitized finding** (markdown to stdout, or to a -file with `--out`; `--json` emits it as a machine-readable JSON document instead). +#### PROBE (`--probe`, opt-in live capture) -### SCAN (default — no process launch) +In an ephemeral `mkdtemp` workspace, `probe` registers a full-payload capture hook for every +native event in the profile, launches **one trivial content-free turn** +(`Reply with exactly: OK`) in a window of the selected multiplexer, captures each hook +event's payload, finds the transcript, and tears everything down. Use it to confirm the +exact hook payload shape and that the CLI accepts the hook dialect your profile declares. -Runs ` --version` / `--help`, locates the newest **already-existing** -session transcript by convention, reads the declared hook config, and infers the -token schema from the transcript. Works whenever you've used the CLI before, with -zero execution risk. This is the right first step for any CLI that already has a -profile (claude/codex/gemini/copilot/antigravity) or that you've run by hand. +`--probe` needs a known profile. If the multiplexer backend is unavailable or the binary is +missing, it falls back to a scan with a warning. -### PROBE (`--probe` — opt-in live capture) +### PII safety model -In an ephemeral `mkdtemp` workspace, `probe` registers a full-payload capture hook -for every native event in the profile, launches **one trivial content-free turn** -(`Reply with exactly: OK`) in a tmux window, captures each hook event's complete -payload, locates the transcript, then tears everything down. Use it to confirm the -**exact hook payload shape** and that the CLI actually **accepts the hook dialect** -your profile declares — facts scan can't see without running the CLI. +The report is built to be safe to paste into an issue or PR. One audited sanitizer +(`src/bmad_loop/sanitize.py`) is the only chokepoint: -`--probe` needs a known profile (it uses the profile's hook dialect and event map). -If `tmux` or the binary is missing, probe degrades gracefully to a scan. +- **Hook payloads ship as a schema, never as values.** Per event you get the field names + and dotted key paths with leaf types (`tool_input.command:str`). A dynamic or + credential-shaped key collapses to ``. +- **Numbers, booleans and `null` pass through** elsewhere; token counts are not PII. +- **Transcript locations are redacted per component.** Home becomes `~`; anything that is + not a plain machine identifier, or that embeds your username, becomes ``; your + project directory name becomes a salted alias (`project-3f2a9c…`), as in `diagnose`. +- **`--help` / `--version` text and log tails** have the home dir and emails redacted, with + a line cap. +- **The rendered report re-scans itself before emitting** (the same `sanitize.guard` egress + check as `diagnose`). A stray project name is repaired and disclosed. An email, secret, + home path or username in the final bytes makes the command refuse to emit: a message on + stderr, empty stdout, nonzero exit, no `--out` file. ---- +In PROBE mode the raw capture exists only inside the temp dir, which is removed in a +`finally` (even on an exception or Ctrl-C). The CLI's own transcript stays in its home dir; +the command reads its structure and never copies it. The hidden `--keep-temp` flag keeps +the raw temp dir for debugging and adds a `RAW probe data retained at … — DO NOT SHARE` warning to the report. Never +paste a `--keep-temp` run. -## PII safety model +### Flags reference -The report is built to be **safe to paste into an issue or PR**. A single audited -sanitizer (`src/bmad_loop/sanitize.py`) is the only chokepoint: +| Flag | Purpose | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | +| `--probe` | Opt-in live capture (default is scan). Needs a known profile. | +| `--transcript PATH` | Inspect this exact transcript file, bypassing convention discovery. | +| `--session-dir DIR` | Glob this dir (`**/*.jsonl`, then `**/*.json`; newest wins) — for custom/unknown CLIs. | +| `--binary NAME` | Binary to probe for a CLI that has no profile yet (enables a reduced report). | +| `--model NAME` | Model passed to the probe turn (PROBE mode). | +| `--workspace PATH` | Also report (read-only) whether PATH is in the profile's `[workspace_trust]` list; exit `1` unless trusted. | +| `--timeout SECONDS` | Probe turn timeout (default 90). | +| `--out FILE` | Write the output to a file instead of stdout (the only file the command writes). | +| `--json` | Emit a machine-readable JSON document _instead of_ the report (honours `--out`). | +| `--keep-temp` | (hidden, debug) keep the raw probe temp dir — prints a "do not share" warning. | -- **captured hook payloads ship as a schema, never as values** — per event you - get the field names and the dotted key paths with leaf _types_ - (`tool_input.command:str`); no payload value of any kind reaches the report, - and a dynamic or credential-shaped key collapses to ``; -- **numbers, booleans, and `null` pass through** elsewhere — token _counts_ are - not PII; -- **transcript locations are redacted per component** — home → `~`, anything - that isn't a plain machine identifier (or that embeds your username) → - ``, and your project directory name → a salted alias - (`project-3f2a9c…`), the same pseudonymization `diagnose` uses; -- `--help` / `--version` text and log tails have the home dir and any emails - redacted, with a line cap; -- **the rendered report re-scans itself before emitting** — the same - `sanitize.guard` egress backstop as `diagnose`. A stray occurrence of the - aliased project name is repaired and disclosed; an email / secret / home path / - username in the final bytes makes the command **refuse to emit** (message on - stderr, empty stdout, exit ≠ 0, no `--out` file) rather than ship it. +Exit codes mirror `validate`: `0` whenever a report is produced (warnings are fine), `1` +only when nothing could be produced. An unknown CLI with `--binary` still yields a reduced +report (version/help and discovery, no hook events); an unknown CLI without `--binary` +fails and lists the available profiles. -In PROBE mode the raw capture exists **only transiently** inside the temp dir, -which is `rmtree`'d in a `finally` (even on exception or Ctrl-C). The CLI's own -transcript stays in its home dir — the command reads its _structure_, never copies -it. A hidden `--keep-temp` flag retains the raw temp dir for debugging and prints a -loud **"raw retained — do not share"** warning; never paste a `--keep-temp` run. +The two trust verdicts are exceptions, because their exit code carries the answer: ---- +- A Codex `hook_trust` other than `trusted` exits `1`. +- `--workspace PATH` exits `0` only when PATH is listed (`workspace trust: trusted`), and + `1` when it is `untrusted` (file, key or entry missing) or `unverifiable` (malformed + settings file), each with a warning and a next step. A profile without + `[workspace_trust]` fails with `FAIL:` on stderr. The path is never rendered. `--json` + carries the verdict as the `workspace_trust` key (`null` without `--workspace`). ## Walkthrough: finalizing a profile ### 1. Draft a profile -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 -a native→canonical event map, and a `usage_parser` (start with `"none"` until -you've written one). +Drop a TOML file in `/.bmad-loop/profiles/.toml` with the fields from the +[Profile field reference](#profile-field-reference). The minimum is a `binary`, a +`prompt_template`, bypass flags, a `[hooks]` block with a dialect and a native→canonical +event map, and a `usage_parser` (start with `"none"`). ### 2. Scan @@ -384,205 +228,154 @@ bmad-loop probe-adapter --project . Read three sections of the report: -- **CLI flags** — your profile's launch/bypass flags plus the scrubbed - `--version` / `--help`, so you can confirm the flags you chose exist. -- **Transcript** — the redacted location, format, size, line count, and modified - date of the newest transcript the convention glob found. -- **Token usage schema** — the structural key paths (types only, never values) and - the **token-field candidates** (int leaves whose names look token-ish). When a - real parser is already declared, its parsed counts are shown as a self-check. +- **CLI flags:** the scrubbed `--version` / `--help` output, so you can confirm the flags + your profile declares exist. +- **Transcript:** the redacted location, format, size, line count and modified date of the + newest transcript the convention glob found. +- **Token usage schema:** structural key paths (types only) and the **token-field + candidates** (int leaves whose names look token-ish). When a real parser is declared, its + parsed counts appear as a self-check. -### 3. Probe (confirm the live payload + dialect) +### 3. Probe (confirm the live payload and dialect) ```bash bmad-loop probe-adapter --probe --project /tmp/scratch ``` -The **Hook payload shape** section now shows, per captured event, the native→ -canonical pairing, the payload keys, and the payload **schema** (key paths + leaf -types, never values) — so you can confirm `session_id` / `transcript_path` casing -and that the CLI accepted the hook config for your dialect. If the CLI rejects the config or never fires a hook, the report -says so (with a scrubbed log tail) instead of silently producing nothing. +The **Hook payload shape** section shows, per captured event, the native→canonical +pairing, the payload keys and the payload schema, so you can confirm `session_id` / +`transcript_path` casing and that the CLI accepted the hook config. If the CLI rejects the +config or never fires a hook, the report says so, with a scrubbed log tail. ### 4. Write the `usage_parser` Turn the report's `token_field_candidates` into a parser in -[`src/bmad_loop/tokens.py`](../src/bmad_loop/tokens.py), following the existing -ones (`tally` for claude, `tally_codex_rollout`, `tally_gemini_chat`) and -registering it in `read_usage`. The report flags **per-call vs cumulative** as a -human call — a `token_count`-style event that carries running totals (codex) is -read differently from per-message blocks that are summed (claude/gemini). Re-run -scan after wiring the parser: the **parsed counts** self-check should now appear. - -**`none` is a legitimate final answer, not only a placeholder.** If the report -comes back with no `token_field_candidates`, the CLI may simply not expose usage -anywhere a parser can reach — `antigravity` is the worked example: it counts -tokens (its TUI displays them) but writes them only into an internal SQLite -protobuf blob, never into the transcript. Leave `usage_parser = "none"`, and -record _why_ in the profile so nobody re-litigates it. Do not reach outside the -`(transcript_path) -> TokenUsage | None` contract to scrape a vendor's internal -store: it is undocumented, unversioned, and will break. - -**Trust the payload over the docs.** A CLI that reports its own transcript path -in the hook payload is telling you the truth; a path in its documentation may be -illustrative. agy's `hooks.md` shows a workspace-relative -`/.gemini/antigravity/transcript.jsonl`, but the live payload's -`transcriptPath` is home-rooted under `brain//` — and named -`transcript_full.jsonl`. `--probe` prefers the captured path for exactly this -reason; the convention glob is the fallback. - ---- - -## Flags reference - -| Flag | Purpose | -| ------------------- | ----------------------------------------------------------------------------------------------------------- | -| `--probe` | Opt-in live capture (default is scan). Needs a known profile. | -| `--transcript PATH` | Inspect this exact transcript file, bypassing convention discovery. | -| `--session-dir DIR` | Glob this dir (`**/*.jsonl` then `*.json`, newest) — for custom/unknown CLIs. | -| `--binary NAME` | Binary to probe for a CLI that has no profile yet (enables a reduced report). | -| `--model NAME` | Model passed to the probe turn (PROBE mode). | -| `--workspace PATH` | Also report (read-only) whether PATH is in the profile's `[workspace_trust]` list; exit `1` unless trusted. | -| `--timeout SECONDS` | Probe turn timeout (default 90). | -| `--out FILE` | Write the output to a file instead of stdout (the only file the command writes). | -| `--json` | Emit a machine-readable JSON document _instead of_ the report (honours `--out`). | -| `--keep-temp` | (hidden, debug) keep the raw probe temp dir — prints a "do not share" warning. | - -Exit codes mirror `validate`: `0` whenever a report is produced (warnings are -fine), `1` only when nothing could be produced. An **unknown CLI with `--binary`** -still yields a _reduced_ report (version/help + discovery, no hook events); an -unknown CLI without `--binary` fails and lists the available profiles. - -The exceptions are the two trust verdicts, whose exit code carries the answer: a -Codex `hook_trust` other than `trusted` exits `1`, and so does `--workspace PATH` -(DW-390): `0` only when PATH is listed (`workspace trust: -trusted`), `1` when it is `untrusted` (file, key or entry missing) or -`unverifiable` (malformed settings file), each with a warning and a next step. A -profile without `[workspace_trust]` (or no loadable profile) fails with `FAIL:` -on stderr. The path is never rendered. `--json` carries the verdict as the additive -`workspace_trust` key (`null` when `--workspace` was not given). - ---- +[`src/bmad_loop/tokens.py`](../src/bmad_loop/tokens.py), following the existing ones +(`tally` for claude, `tally_codex_rollout`, `tally_gemini_chat`), and register it in +`read_usage`. Decide **per-call vs cumulative** yourself: a `token_count`-style event that +carries running totals (codex) is read differently from per-message blocks that are summed +(claude, gemini). Re-run the scan; the **parsed counts** self-check should now appear. + +**`none` is a legitimate final answer.** If the report has no `token_field_candidates`, the +CLI may not expose usage anywhere a parser can reach. `antigravity` is the example: it +writes token counts only into an internal SQLite blob, never into the transcript. Leave +`usage_parser = "none"` and record why in the profile. Do not scrape a vendor's internal +store outside the `(transcript_path) -> TokenUsage | None` contract. + +**Trust the payload over the docs.** A CLI that reports its transcript path in the hook +payload is telling the truth; a path in its documentation may be illustrative. `--probe` +prefers the captured path; the convention glob is the fallback. ## Worked example: copilot -The `copilot` profile was finalized from a real probe run — a good illustration of -why `probe-adapter` exists, because the as-drafted profile was wrong in ways no doc -would reveal: +The `copilot` profile was finalized from a real probe run: ```bash bmad-loop probe-adapter copilot --probe --project /tmp/scratch ``` -On Copilot CLI 1.0.63 this surfaced three corrections: +It surfaced three corrections to the draft: -- **Turn-end event.** The draft registered PascalCase `Stop`, which never fires — - the turn-end hook is `agentStop` (camelCase). Without this, every session reads - as a timeout. The profile now maps `agentStop = "Stop"` (and `sessionStart` / - `sessionEnd`; there is no `PreCompact` equivalent). -- **Payload casing.** Keys are camelCase (`sessionId`, `transcriptPath`), not - snake_case — so the shared relay (`bmad_loop_hook.py`) reads both casings. -- **Token schema.** The probe located `~/.copilot/session-state/*/events.jsonl` and - inferred its token fields (`data.modelMetrics..usage.*`), which became the - `copilot-events` parser in `tokens.py`; the profile's `usage_parser` is now wired - to it instead of `"none"`. +- **Turn-end event.** PascalCase `Stop` never fires; the turn-end hook is `agentStop`. + Without the mapping every session reads as a timeout. The profile maps + `agentStop = "Stop"`, plus `sessionStart` and `sessionEnd` (there is no `PreCompact` + equivalent). +- **Payload casing.** Keys are camelCase (`sessionId`, `transcriptPath`), so the shared + relay (`bmad_loop_hook.py`) reads both casings. +- **Token schema.** The probe found `~/.copilot/session-state/*/events.jsonl` and its token + fields (`data.modelMetrics..usage.*`), which became the `copilot-events` parser in + `tokens.py`. Confirm the `mkdtemp` dir is gone afterward. ---- - ## Profile field reference A profile is the `CLIProfile` dataclass in -[`src/bmad_loop/adapters/profile.py`](../src/bmad_loop/adapters/profile.py), -loaded from TOML. **Built-ins** ship as packaged TOML -(`bmad_loop/data/profiles/*.toml`); **project overrides** in -`/.bmad-loop/profiles/*.toml` overlay them — same `name` overrides a -built-in, a new `name` extends the set. The legacy alias `claude-code-tmux` -resolves to `claude`. +[`src/bmad_loop/adapters/profile.py`](../src/bmad_loop/adapters/profile.py), loaded from +TOML. Built-ins ship as packaged TOML (`bmad_loop/data/profiles/*.toml`). Project overrides +in `/.bmad-loop/profiles/*.toml` overlay them: the same `name` replaces a built-in, +a new `name` extends the set. The aliases `claude-code-tmux` and `opencode` resolve to +`claude` and `opencode-http`. Unknown top-level keys are ignored. ### `CLIProfile` -| Field | Required | Default | Meaning | -| ---------------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | ✅ | — | Profile id, also the `--cli` value and override key. | -| `binary` | ✅ | — | Executable to launch (resolved on `PATH`). `bmad-loop validate` also probes it — a name that resolves but fails `--version` (typically a dead WSL/npm shim) is reported as `adapter.binary-unrunnable` at warning severity, #294. Only a **packaged** profile's binary is probed. A project overlay (or an entry-point package's) profile is resolved and reported found but never launched, whatever its `binary` is called — profile fields arrive with a clone, so a project's own config cannot choose which binary this diagnostic launches. The boundary is provenance rather than the spelling of `binary`, because a bare name still resolves into the checkout whenever a checkout-local directory is on `PATH`. Note what it bounds: **which name** is probed, not what that name resolves to. Resolution runs through your `PATH`, so validate launches whatever `PATH` says the CLI is — the same file the session launch itself would run. | -| `[hooks]` | ✅ | — | The `HookSpec` table (see below). | -| `adapter` | | `generic` | Which adapter **class** drives this CLI — a key resolved against the [adapter registry](#shipping-a-new-adapter-class-out-of-tree), not a fixed enum. `generic` = the bundled tmux + hook-signal adapter; `opencode-http` = the bundled HTTP/SSE adapter; an out-of-tree package registers its own. Membership is checked against the **live** registry (at run start, and by `bmad-loop validate`'s `adapter.kind`), never at parse time — so an unknown kind is a clear config error rather than a schema change. Orthogonal to `hooks.dialect = "none"` — hooklessness is about the transport, this is about the driving class — with two qualifications. A back-compat carve-out for files written before this field existed: when the key is **absent** and the dialect is `none`, the kind is `opencode-http` (what hooklessness used to select), not `generic`. And one refused pairing: an explicit `generic` beside `dialect = "none"` is rejected at load — that adapter completes on a `Stop` hook a hookless profile never registers, so the session could only wait out `session_timeout_min`. Hookless on any **other** kind stays legal, which is the decoupling this field exists for; a provider shipping a hookless profile must therefore set `adapter` rather than leave it at the default. | -| `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | -| `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | -| `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). Under `opencode-http` it sits between `binary` and `serve` (`npx` + `["-y", "opencode-ai"]` launches `npx -y opencode-ai serve …`); `bmad-loop validate` warns (`adapter.launch-args-unservable`) when it repeats an adapter-owned token (`serve`, `--port`, `--hostname`, `--print-logs`, or a `--flag=` spelling) or leaves an interpreter / package-runner `binary` (`python3`, `node`, `npx`, …) with no program to run (empty or options only). | -| `bypass_args` | | `()` | Flags that bypass permission/approval prompts for unattended runs (e.g. `--allow-all-tools`). A policy `extra_args` override replaces them, so it must repeat them; `bmad-loop validate` warns (`policy.bypass-dropped`) when it does not. | -| `model_flag` | | `--model` | Flag used to pass the model name when one is configured. | -| `session_id_flag` | | `""` | The CLI's launch flag for a caller-chosen session id (claude: `--session-id`). When set, the generic adapter mints a UUID4 per launch, appends `[flag, uuid]` at the end of the launched argv (never `interactive_argv`), and pins hook-event attribution to that id, so a nested child's `SessionEnd` is foreign even when the child never announced a `SessionStart`. Must be one whitespace-free token starting with `-`; `""` = off (attribution learns the id from the first `SessionStart`). Only set it when the CLI's `SessionStart` payload reports the id you passed; hookless profiles must not set it. | -| `env` | | `{}` | Extra environment variables for the session. | -| `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | -| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | -| `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | -| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. Session attribution runs first, but a subagent's tool-use id never announces a `SessionStart`, so attribution admits it and this filter still owns it. | -| `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | -| `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | -| `env_fault_patterns` | | `()` | Regex patterns matched line-by-line against the ANSI-stripped tail of a **non-completed** (`timeout`/`stalled`/`crashed`) session's log to classify a transport/API **environment fault** (#194); a match stamps `env_fault` / `env_fault_evidence` on the `SessionResult`. **Which log is per-adapter**, named by `EnvFaultMixin.ENV_FAULT_LOG_SUFFIX`: the tmux adapters scan the pane capture `logs/.log`, `opencode-http` scans `logs/.server.out` (the `opencode serve` process's own stdout) and never its `.log` conversation transcript. A pattern is only **sound** if the model cannot write to the log it is matched against — a pane capture carries the model's own output, so a story that merely _implements_ rate limiting prints `429`/`quota` in ordinary healthy work. So for a pane-capture profile a pattern must reproduce the **whole first sentence** of one of the CLI's error messages, taken from captured output — the shipped `claude` patterns are prefix matches on that sentence, with no end-of-sentence anchor, so a line quoting the first sentence verbatim still matches. An error-shaped token **plus** a cause on the same line is _not_ sufficient — that is exactly the shape a story writing about the error produces, and it is why the previous doctrine had to be withdrawn (#507). A shipped pattern must also not match **its own line in the profile file**, or a session that prints or diffs its profile reads its own configuration as an outage; the seeded patterns carry single-character classes (`t[o]`, `respons[e]`) for that, changing nothing about what they match and everything about what their source line matches. `test_shipped_patterns_do_not_match_their_own_profile_line` pins the self-match rule and `test_pane_capture_patterns_do_not_match_this_repo` extends the same scan to every tracked file, so a doc bullet or profile comment written in the forbidden shape fails the suite. No quota patterns are seeded for the pane-capture profiles: no captured line exists, and that vocabulary is what a rate-limiting story prints all day. Must be a **list of strings**; compiled and validated at parse time (an invalid regex is a profile error). Matched with the `regex` module under a per-pattern timeout (`ENV_FAULT_MATCH_TIMEOUT_S`), so a pathological pattern can't hang a session teardown. Seeded for `claude` (three first-sentence prefix patterns over its captured errors — connection loss and the two captured provider 5xx refusals, whose statuses are enumerated rather than ranged so an uncaptured `503` stays prose, #507) and `opencode` (provider quota/rate-limit + connection, #323); empty = inert. | -| `parked_prompt_patterns` | | `()` | Regex patterns matched line-by-line against the ANSI-stripped **visible pane** (`mux.capture_pane`), consulted at each stall-grace expiry with no hook latch — whether a wake nudge is about to be typed or the stall is final (DW-350, DW-433). A match withholds any nudge and ends the session `stalled` + `parked` — see [Parked-session signals](#parked-session-signals). Same evidentiary bar as `env_fault_patterns`: seed only lines captured off the CLI's own screen, with single-character classes so the profile line does not match itself. Must be a **list of strings**; compiled and validated at parse time; matched with the `regex` module under a per-search timeout. Seeded for `claude` only (two lines of #727's captured Bypass Permissions dialog); empty = inert. | -| `[workspace_trust]` | | unset | Optional table for a CLI that gates sessions on a home-level exact-path workspace allowlist (see below). Unset = nothing under `~` is ever read or written. | +| Field | Required | Default | Meaning | +| ---------------------------------- | -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | ✅ | — | Profile id: the `--cli` value, the policy `[adapter] name`, and the override key. | +| `binary` | ✅ | — | Executable resolved on `PATH`. `validate` runs `--version` only on binary names a packaged profile declares (`adapter.binary-unrunnable`). | +| `[hooks]` | ✅ | — | The [`HookSpec`](#hookspec-the-hooks-table) table. | +| `adapter` | | `generic` (`opencode-http` if `dialect=none`) | Adapter kind, resolved against the live [registry](#shipping-a-new-adapter-class-out-of-tree) at run start and by `validate` (`adapter.kind`). `generic` with `dialect = "none"` is refused. | +| `skill_tree` | | `.claude/skills` | Project-relative tree the CLI reads skills from; `init` installs the `bmad-loop-*` skills here. Must stay inside the project. | +| `prompt_template` | | `{prompt}` | Renders the canonical `/skill args` prompt. Placeholders: `{prompt}` (whole string), `{skill}` (command name, no `/`), `{args}` (the rest). | +| `launch_args` | | `[]` | Argv after `binary` (`["-i"]` keeps gemini/copilot interactive). Under `opencode-http` it precedes `serve`; `validate` warns on unservable shapes. | +| `bypass_args` | | `[]` | Permission-bypass flags for unattended runs. A policy `extra_args` replaces them; `validate` warns (`policy.bypass-dropped`) when one is dropped. | +| `model_flag` | | `--model` | Flag passed with the model name when one is configured. | +| `env` | | `{}` | `[env]` table of extra session environment variables. It cannot override the run's state-root pin. | +| `usage_parser` | | `none` | Transcript token parser: `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events` or `none`. | +| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends; `0` reads once. Must be ≥ 0. | +| `stop_without_result_nudges` | | unset | Per-profile default for Stop-without-result nudges. An `[adapter]` / `[adapter.]` value overrides it; unset inherits `[limits] stop_without_result_nudges`. Must be ≥ 0. | +| `subagent_stop_without_transcript` | | `false` | When `true`, a `Stop` with no transcript path is a subagent turn-end and is ignored. | +| `first_run_note` | | `""` | Note `init` prints about a manual first-run or auth step. | +| `seed_files` | | `[]` | Project-relative gitignored files copied into each provisioned worktree. Must stay inside the project. | +| `env_fault_patterns` | | `[]` | Regexes that classify a non-completed session as an environment fault. See [Environment-fault patterns](#environment-fault-patterns). | +| `parked_prompt_patterns` | | `[]` | Regexes matched against the visible pane at each stall-grace expiry. See [Parked-session signals](#parked-session-signals). | +| `[workspace_trust]` | | unset | The [`WorkspaceTrustSpec`](#workspacetrustspec-the-workspace_trust-table) table. Unset: nothing under `~` is read or written. | +| `session_id_flag` | | `""` | Launch flag for a caller-chosen session id (claude: `--session-id`). The generic adapter appends `[flag, uuid4]` and pins attribution to that id. | + +`session_id_flag` must be one `-`-prefixed token with no whitespace or `=`, and is refused +on hookless profiles. Set it only when the CLI's `SessionStart` payload reports the id you +passed. ### `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`, `Notification`, or a parked kind (`PermissionPrompt`, `IdlePrompt`, `QuotaPrompt` — see [Parked-session signals](#parked-session-signals)); the native side is whatever the CLI emits (e.g. `agentStop = "Stop"`). At least one entry. | -| `notification_types` | | `[hooks.notification_types]`: map of the CLI's **notification subtype** → **parked kind** (`PermissionPrompt` / `IdlePrompt` / `QuotaPrompt`). The relay forwards a `Notification` payload's `notification_type`; a mapped subtype latches a parked signal, an unmapped one is ignored. Non-empty requires some native event mapped to `Notification`; hookless profiles must not set it. Default `{}` (inert). | +| Field | Required | Default | Meaning | +| -------------------- | ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dialect` | ✅ | — | Hook-config format: `claude-settings-json`, `codex-hooks-json`, `gemini-settings-json`, `copilot-settings-json`, `antigravity-hooks-json`, or `none` (hookless). | +| `config_path` | ✅ unless `dialect = "none"` | `""` | Project-relative path the hook config is written to. Must stay inside the project; empty when hookless. | +| `events` | ✅ unless `dialect = "none"` | `{}` | Native event → canonical event (`SessionStart`, `Stop`, `SessionEnd`, `PreCompact`, `Notification`, or a parked kind). Empty when hookless. | +| `notification_types` | | `{}` | `[hooks.notification_types]`: notification subtype → parked kind. Unmapped subtypes are ignored. Needs an event mapped to `Notification`. | ### Session identity and nested CLIs The relay stamps every hook event with the session's task id from the inherited -environment, so a coding CLI launched from _inside_ a session — a nested process, not a -built-in subagent — writes its events into the parent's stream. The generic adapter -tells them apart by `session_id` (`SessionAttribution` in `signals.py`, #767), which -sets these expectations for a profile: - -- **`SessionStart`'s `session_id` is the binding identity.** The first `SessionStart` is - the launched session's; a later one with a new id marks that id foreign and its events - are dropped, unless the payload's `source` is `clear` or `compact` (the relay forwards - `source`; such a start rebinds to the new id). Map `SessionStart` whenever the CLI has - one, and check that its payload carries the id. +environment, so a coding CLI launched from _inside_ a session writes its events into the +parent's stream. The generic adapter tells them apart by `session_id` +(`SessionAttribution` in `signals.py`): + +- **`SessionStart`'s `session_id` binds the session.** Without `session_id_flag`, the first + `SessionStart` is the launched session's; with it, the id is pinned at launch. A later one with a new id marks that id foreign and drops its + events, unless the payload's `source` is `clear` or `compact`, which rebinds. Map + `SessionStart` whenever the CLI has one, and check that its payload carries the id. - **`Stop` and `SessionEnd` should carry the same id.** Attribution fails toward acceptance: an id-less event, or an id that never announced a `SessionStart`, is - admitted. A CLI that rotates its id without a new `SessionStart` still completes, but - a CLI whose events carry no id cannot be protected. -- **Subagent turn-ends stay with `subagent_stop_without_transcript`.** Subagent ids - (copilot's `toolu_…`) never announce a `SessionStart`, so attribution admits them and - the transcript filter decides. + admitted. Two exceptions: under `session_id_flag` an identified `SessionStart` or + `SessionEnd` from any other id is foreign, and once relay lineage is calibrated trusted a `mismatch`-tagged + event is dropped, id-less or not. +- **Subagent turn-ends stay with `subagent_stop_without_transcript`.** Subagent ids never + announce a `SessionStart`, so attribution admits them and the transcript filter decides. - **Stop-only profiles get no nested-CLI protection.** With no `SessionStart` mapped, - nothing is ever bound and every event is admitted (`antigravity` today). + every event is admitted (`antigravity`). -`bmad-loop probe-adapter --probe` shows the payload's field names, so confirm the -`session_id` (and `source`) casing before relying on them. +`probe-adapter --probe` shows the payload's field names; confirm the `session_id` and +`source` casing before relying on them. ### `WorkspaceTrustSpec` (the `[workspace_trust]` table) -Some CLIs refuse to start in a workspace that is not listed verbatim in a -home-level settings file, and block on an interactive trust dialog a spawned -session cannot answer — so every fresh `isolation = "worktree"` path would hang. -Declaring the file lets provisioning append each unit worktree to that list -right after it is provisioned, before any session launches (DW-390): - -| Field | Required | Meaning | -| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings_path` | ✅ | `~/`-prefixed path of the JSON settings file inside the home directory. Refused at load if not `~/`-anchored, if it contains `..`, or names a Windows device. | -| `key` | ✅ | One **top-level** key of that file holding a list of path strings (no nested paths). | - -Rules the seeder keeps (`bmad_loop/workspace_trust.py`): a worktree is added -**only when the main checkout root is already in the list** (as-passed or -resolved spelling) — the project profile overlay arrives with a clone, so it may -only extend a grant the operator already made. Only the declared key is touched; -other keys and their order survive, the write is atomic and keeps the file's -permission bits (a symlinked file is followed). Outcomes are journaled as -`worktree-trust-seeded` or `worktree-trust-unseeded` (with the reason); a -malformed file or a write fault escalates the unit. Entries are never removed on -teardown. The shipped `antigravity` profile declares: +Some CLIs refuse to start in a workspace that is not listed in a home-level settings file, +and block on a trust dialog a spawned session cannot answer. Declaring the file lets +provisioning append each unit worktree to that list before any session launches. + +| Field | Required | Meaning | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------- | +| `settings_path` | ✅ | `~/`-prefixed path of a JSON settings file inside the home directory. No `..` and no Windows device names. | +| `key` | ✅ | One **top-level** key of that file holding a list of path strings. | + +Any other key in the table is refused. The seeder (`bmad_loop/workspace_trust.py`) adds a +worktree **only when the main checkout root is already in the list**. It touches only the +declared key, writes atomically, and keeps the file's permission bits. Outcomes are +journaled as `worktree-trust-seeded` or `worktree-trust-unseeded`; a malformed file or a +write fault escalates the unit. Entries are never removed on teardown. The shipped +`antigravity` profile declares: ```toml [workspace_trust] @@ -590,56 +383,62 @@ settings_path = "~/.gemini/antigravity-cli/settings.json" key = "trustedWorkspaces" ``` +### Environment-fault patterns + +`env_fault_patterns` are matched line by line against the ANSI-stripped log tail of a +session that ended `timeout`, `stalled` or `crashed`. A match stamps `env_fault` / +`env_fault_evidence` on the `SessionResult`, unless the session tracked nonzero token +usage, which records the match in `env_fault_overruled` instead. What the engine does next +is in [Environment faults](escalations-guide.md#environment-faults). + +- **Which log** is per adapter, named by `EnvFaultMixin.ENV_FAULT_LOG_SUFFIX`: the tmux + adapters scan the pane capture `logs/.log`; `opencode-http` scans + `logs/.server.out`. +- **A pane capture carries the model's own output**, so a pattern must reproduce the whole + first sentence of one of the CLI's error messages, taken from captured output. An + error-shaped token plus a cause is not enough; that is what a story writing about the + error prints. +- **A pattern must not match its own line in the profile file.** The shipped patterns use + single-character classes (`t[o]`) for this. `test_shipped_patterns_do_not_match_their_own_profile_line` + and `test_pane_capture_patterns_do_not_match_this_repo` enforce it across every tracked + file. +- Patterns are a list of strings, compiled at load (an invalid regex is a profile error), + and matched with the `regex` module under a per-pattern timeout. +- Seeded for `claude` (connection loss and two captured provider 5xx errors) and + `opencode` (provider quota/rate-limit and connection). Empty means inert. + ### Parked-session signals -A CLI parked on a permission, idle or quota prompt looks exactly like a stalled -one, and the stall wake nudge's trailing Enter can **answer** that prompt (#727: -it confirmed "No, exit" on Claude Code's Bypass Permissions dialog). Two -profile-declared signals let the generic adapter tell the two apart. Both are -opt-in: a profile that declares neither behaves exactly as before. - -- **Hook-reported (DW-348).** Map a native event to one of the canonical parked - kinds `PermissionPrompt`, `IdlePrompt` or `QuotaPrompt` directly, or relay the - CLI's notification event as the canonical `Notification` carrier and map its - subtypes in `[hooks.notification_types]`. The shipped `claude` profile does the - latter: `permission_prompt`, `idle_prompt`, `quota_auto_resume_stale`, - `quota_auto_resume_disabled` and the MCP elicitation dialogs - `elicitation_dialog` / `elicitation_url_dialog` (as `PermissionPrompt`) are - parked; every other subtype is ignored. That includes `agent_needs_input`: one - of its two triggers is a different (background) session waiting while agent view - is open, and the payload does not say which trigger fired. The other, an - agent-team teammate's setup question, needs experimental agent teams, which - bmad-loop does not enable. Map it in an overlay if your sessions run agent teams. The - adapter **latches** the signal; a `Stop` clears the latch, because a finished - turn proves the prompt was answered. -- **Pane-matched (DW-350).** `parked_prompt_patterns` are matched against the - visible pane at every stall-grace expiry with no latch set, including the - final stall once the nudges are spent or when none are configured. A backend that cannot - capture (the `TerminalMultiplexer.capture_pane` default raises - `MultiplexerError`), a capture failure or a regex timeout reads as "no match". - -Neither signal completes a session or acts on arrival. The single decision point -is the **stall-grace expiry**, where the adapter would otherwise send the wake -nudge: with a parked signal it sends nothing and ends the session `stalled` with -`SessionResult.parked` / `parked_evidence` set (a window already dead still ends -`crashed`, unparked). The engine and sweep then **PAUSE** that session for a -human (`parked: session stalled (...)`), the way they pause an -`env_fault`, which outranks it. Re-arm restores the attempt; nothing is charged. - -Mapping an idle subtype has a plain consequence. Claude sends `idle_prompt` about -60 s after **every** finished turn, so a dev, review or workflow session that ends a turn -without a result and then sits idle re-latches after its `Stop` and, at grace -expiry, pauses as parked instead of receiving the stall wake nudge. That is the -intended DW-348 decision (an idle CLI waits for a human rather than being typed -at). A project overlay (`.bmad-loop/profiles/claude.toml`) that drops -`idle_prompt` from `[hooks.notification_types]` restores the wake nudge. +A CLI parked on a permission, idle or quota prompt looks like a stalled one, and the stall +wake nudge's trailing Enter can answer that prompt. Two opt-in profile signals let the +generic adapter tell them apart: + +- **Hook-reported.** Map a native event directly to a parked kind (`PermissionPrompt`, + `IdlePrompt`, `QuotaPrompt`), or map the CLI's notification event to `Notification` and + its subtypes in `[hooks.notification_types]`. The shipped `claude` profile parks + `permission_prompt`, `idle_prompt`, `quota_auto_resume_stale`, + `quota_auto_resume_disabled`, `elicitation_dialog` and `elicitation_url_dialog`; every + other subtype is ignored. The adapter latches the signal, and a `Stop` clears it. +- **Pane-matched.** `parked_prompt_patterns` are matched against the visible pane + (`mux.capture_pane`) at every stall-grace expiry with no latch set, including the final + one. A backend that cannot capture, a capture failure, or a regex timeout reads as no + match. Seed only lines captured off the CLI's own screen, with single-character classes + as for env-fault patterns. + +Neither signal acts on arrival. At the stall-grace expiry, a parked signal suppresses the +nudge and ends the session `stalled` with `SessionResult.parked` / `parked_evidence` set (a +window already dead still ends `crashed`). The engine and sweep then pause for a human; an +`env_fault` outranks it. Re-arm restores the attempt without charging it. + +Claude sends `idle_prompt` about 60 s after every finished turn, so a session that ends a +turn without a result and sits idle pauses as parked instead of being nudged. A project +overlay (`.bmad-loop/profiles/claude.toml`) that drops `idle_prompt` from +`[hooks.notification_types]` restores the wake nudge. ### Worked TOML — copilot -The shipped `copilot` profile exercises the non-default tuning knobs -(`usage_grace_s`, `stop_without_result_nudges`, `subagent_stop_without_transcript`) -and a camelCase event map — all discovered by the -[copilot probe walkthrough](#worked-example-copilot) above: +The shipped `copilot` profile uses the non-default tuning knobs and a camelCase event map, +all found by the [copilot probe walkthrough](#worked-example-copilot): ```toml name = "copilot" @@ -659,53 +458,45 @@ config_path = ".github/copilot/settings.json" events = { agentStop = "Stop", sessionStart = "SessionStart", sessionEnd = "SessionEnd" } ``` -(The shipped profile under `bmad_loop/data/profiles/copilot.toml` also carries a -`prompt_template` and `first_run_note`, trimmed here for focus — read it for the -exact shipped values.) - ---- +The shipped file (`bmad_loop/data/profiles/copilot.toml`) also sets `prompt_template`, +`model_flag` and `first_run_note`. ## Writing a new adapter class -Reach for this **only** when a CLI does not fit the tmux-injection + hook-signal -transport — for example an HTTP/SSE service with no terminal. A CLI that _does_ -fit (a binary you launch in a pane that fires lifecycle hooks) needs no Python: -reuse `generic.py` with a [profile](#profile-field-reference) instead of -subclassing. +Reach for this only when a CLI does not fit the pane-injection + hook-signal transport, for +example an HTTP/SSE service with no terminal. A CLI that does fit needs no Python: reuse +`generic.py` with a [profile](#profile-field-reference). The contract is the `CodingCLIAdapter` ABC in [`src/bmad_loop/adapters/base.py`](../src/bmad_loop/adapters/base.py). ### Declare the three capability axes -Set these class attributes so the engine can reason about transport quality -instead of treating every CLI as a dumb terminal: +Set these class attributes so the engine can reason about transport quality: + +- `injection`: how a prompt reaches the CLI: `tmux-initial-prompt` | `launch-flag` | `http`. +- `observation`: how completion is detected: `hook-signal` | `sse` | `transcript-poll`. +- `state`: where session state is readable: `local-jsonl` | `local-json-tree` | `remote`. -- `injection` — how a prompt reaches the CLI: `tmux-initial-prompt` | `launch-flag` | `http`. -- `observation` — how completion is detected: `hook-signal` | `sse` | `transcript-poll`. -- `state` — where session state is readable: `local-jsonl` | `local-json-tree` | `remote`. +Also set `name`. The engine attaches `journal`; gate every adapter-side journal write on it +being non-`None`. ### The data contracts -Three frozen dataclasses cross the seam: - -- **`SessionSpec`** (engine → adapter) — `task_id`, `role` (`"dev"` / `"review"` / - `"retro"`), `prompt`, `cwd`, `env`, `model` (empty = CLI default), - `timeout_s`, and `effort` (empty = provider default; a free-form reasoning-effort - name resolved per stage from `[adapter] effort`). Only `opencode-http` carries - `effort` — as the per-prompt `variant` — and the generic tmux adapter ignores it, - because no profile key maps it onto a CLI flag; `bmad-loop validate` warns when a - stage on that family sets it. An out-of-tree adapter class may read it or not. -- **`SessionHandle`** (returned by `start_session`) — `task_id`, `native_id` (tmux - window id, HTTP session id, …), `launched_ns` (wall-clock ns just before launch; - the floor for hook events). -- **`SessionResult`** (returned by `wait_for_completion`) — `status` (one of - `completed`, `stalled`, `timeout`, `crashed`, `over_budget`, `aborted`), `result_json`, - `session_id` (on the generic adapter, the id of the last hook event session - attribution admitted — never a nested CLI's), `transcript_path`, and the optional post-mortem forensics - `env_fault` / `env_fault_evidence` (set by `_classify_env_fault` when a - non-completed session is matched as a transport/API **environment fault** — - see the `env_fault_patterns` profile key). +Frozen dataclasses cross the seam; read their docstrings in `base.py` for every field: + +- **`SessionSpec`** (engine → adapter): `task_id`, `role` (`"dev"` / `"review"` / + `"retro"`), `prompt`, `cwd`, `env`, `model` (empty = CLI default), `timeout_s`, the + stall-nudge and token-budget settings, the `spec_snapshot` / `expected_spec` pair, and + `effort` (empty = provider default). Only `opencode-http` uses `effort`; `validate` warns + when a stage on the generic family sets it. +- **`SessionHandle`** (returned by `start_session`): `task_id`, `native_id` (window id, + HTTP session id, …), `launched_ns` (wall-clock ns just before launch, the floor for hook + events), and `pinned_session_id`. +- **`SessionResult`** (returned by `wait_for_completion`): `status` (`completed`, + `stalled`, `timeout`, `crashed`, `over_budget` or `aborted`), `result_json`, + `session_id`, `transcript_path`, and post-mortem fields including `env_fault` / + `env_fault_evidence` / `env_fault_overruled` and `parked` / `parked_evidence`. ### Methods @@ -722,145 +513,93 @@ Required (abstract): readers. Adapter-private breadcrumbs such as `heartbeat.json`, `resultless-stops.jsonl`, `session-lifecycle.jsonl`, and `messages.json` remain outside the shared cleanup contract and are managed by their owning adapter. -- `wait_for_completion(handle, spec) -> SessionResult` — block until the session - ends (or stalls/times out), then report status. Poll - `runs.read_stop_request_mode(run_dir) == "hard"` on both sides of the loop's - own blocking wait and return `SessionResult(status="aborted")` when it is true - (#319): that is what makes `bmad-loop stop` land mid-session where a signal to - the engine cannot be delivered. Return the verdict — never raise, never unlink - the file (the engine consumes it and attributes the stop). Keep that wait - short — both bundled adapters cap theirs at 5s and inherit the poll from - `_ResultFileMixin` — but do not read it as a bound on the stop: a dispatch leg - that waits out an artifact grace or blocks on a transport call outlasts - `stop_run`'s 10s grace window on its own, and the force-kill backstop is what - catches that. Skipping it is - not fatal: the engine still honors the request at the next item boundary, which - is where an adapter without the poll leaves the operator waiting. - -The base class provides `run(spec)`, the template that chains -`start_session` → `wait_for_completion` → `kill` (the kill runs in a `finally`). -You normally don't override it. - -`run(spec)` then chains two post-processing template hooks over the returned -`SessionResult` (both identity no-ops on the base class, so an adapter that needs -neither leaves them alone): - -- `_post_kill_reconcile(handle, spec, result)` — runs right after the - `finally`-kill. A session that finished but lost its final `Stop` can be - rescued to `completed` here, once window death has settled the liveness the - live-window verdict had to leave open (see `GenericDevAdapter`). -- `_classify_env_fault(handle, spec, result)` — runs **last**, and only for a - non-`completed` result with `result_json is None`. An adapter may re-inspect - its post-mortem log tail and stamp `env_fault` / `env_fault_evidence` on the - result (see the `env_fault_patterns` profile key). Because it runs after the - reconcile, a rescue-to-`completed` is never re-classified. Mixing in - `EnvFaultMixin` supplies the profile-driven implementation for free; set its - `ENV_FAULT_LOG_SUFFIX` if your adapter's diagnostics do not land in - `logs/.log`, since the file scanned is part of the patterns' safety - contract. - -Optional capabilities (default to "unsupported" / no-op): - -- `send_text(handle, text)` — nudge a running session. Raises `NotImplementedError` - by default (an HTTP adapter that can't inject mid-turn leaves it). -- `interactive_argv(spec)` / `interactive_env(spec)` — argv + env that launch the - CLI **attached** to the caller's terminal, seeded with the prompt, for the - interactive escalation-resolution flow. HTTP adapters have no terminal and leave - `interactive_argv` raising. -- `kill(handle)` — tear down the session (no-op default). -- `read_usage(result) -> TokenUsage | None` — parse token usage from the result - (returns `None` by default). +- `wait_for_completion(handle, spec) -> SessionResult` — block until the session ends, + stalls or times out, then report status. On both sides of the loop's own blocking wait, + check for a hard stop (`_ResultFileMixin._hard_stop_requested()` reads this run's and the + owning run's `stop-request.json`) and return `SessionResult(status="aborted")` when one + is lodged. Never raise and never unlink the request; the engine consumes it. Keep the + wait short: the bundled adapters cap theirs at 5 s. An adapter without the poll is + stopped at the next item boundary instead. + +The base class provides `run(spec)`, the template you normally leave alone: `start_session` +→ `wait_for_completion` → `kill` (in a `finally`), then three post-processing hooks over +the result: + +- `_post_kill_reconcile(handle, spec, result)`: identity by default. A session that + finished but lost its final `Stop` can be rescued to `completed` here, once window death + has settled liveness (see `GenericDevAdapter`). +- `_classify_env_fault(handle, spec, result)`: identity by default; runs only for a + non-`completed` result with `result_json is None`. Mix in `EnvFaultMixin` for the + profile-driven implementation, and set its `ENV_FAULT_LOG_SUFFIX` if your diagnostics do + not land in `logs/.log`. +- `_classify_zero_token_timeout(result)`: stamps `env_fault` on a `timeout` whose tracked + usage is zero, unless an env fault or `env_fault_overruled` is already set. + +Optional capabilities (each has a default): + +- `send_text(handle, text)`: nudge a running session. Raises `NotImplementedError` by + default. +- `interactive_argv(spec)` / `interactive_env(spec)`: argv and env that launch the CLI + attached to the caller's terminal, seeded with the prompt, for interactive escalation + resolution. `interactive_argv` raises by default; `interactive_env` returns `spec.env`. +- `kill(handle)`: tear down the session (no-op default). Must be safe in `run()`'s + `finally`. +- `read_usage(result) -> TokenUsage | None`: token usage (default `None`, untracked). +- `_observe_tick(handle, spec)`: heartbeat-cadence observation hook (no-op default). It + must not mutate session state. ### Worked example: the opencode adapter -[`adapters/opencode_http.py`](../src/bmad_loop/adapters/opencode_http.py) is the -shipped non-tmux adapter: it drives [OpenCode](https://opencode.ai) entirely over -`opencode serve`'s HTTP API + SSE event stream (`injection = "http"`, -`observation = "sse"`, `state = "remote"`). Every API fact it relies on was -pinned live against a real 1.18.2 binary and is recorded in the module's -API-contract docstring — start there when the upstream API drifts. The design -decisions worth stealing: - -- **One server per session.** The API has no per-session env, but the engine's - `BMAD_LOOP_*` contract must reach tool subprocesses — so each session gets its - own `opencode serve` spawned with `cwd = spec.cwd` and `env ⊇ spec.env`. - Permissions, the model, and a hermetic skills path are injected via the - `OPENCODE_CONFIG_CONTENT` env var (zero worktree pollution), and each server - gets its own `OPENCODE_SERVER_PASSWORD` so a foreign process on a recycled - port can never impersonate it. The server argv is - `[binary, *launch_args, "serve", "--port", , "--hostname", "127.0.0.1", "--print-logs", *extra_args]`, - so a wrapper profile (`binary = "npx"`, `launch_args = ["-y", "opencode-ai"]`) - works; `validate` warns on launch_args shapes that argv cannot serve, and on a role's - `extra_args` repeating an owned flag (`policy.extra-args-unservable`). Reasoning effort (`SessionSpec.effort`) is the - one knob that does NOT go through the config: it is a per-call `variant` on - every `prompt_async` body instead, because the config has no top-level - `variant` and its `agent..variant` is inert unless that agent pins a model. -- **Map the transport onto the hook-signal semantics** instead of inventing new - ones: the SSE `session.idle` event ≙ the Stop hook, server-process death ≙ - window death (`crashed`, landed artifact honored), and a poll fallback - (`GET /session/status` + message `time.completed` proof-of-work) covers SSE - loss. Completion evidence is gated by a forward-advancing floor so an idle - event without proof of new work never completes a session — the same - artifact-distrust invariant the tmux adapters enforce. -- **A transport with no pane still owes the operator a transcript.** The tmux - adapters get `logs/.log` for free by replaying the pane; over HTTP that - file would hold nothing but `opencode serve`'s own INFO stdout, leaving finished - runs unreadable. So the transcript is _curated_ off the SSE stream the adapter - already consumes for control — a second consumer on the same - `sessionID`-filtered dispatch, costing one `write()` per interesting frame. - Three sinks, one per audience: `.log` is the readable transcript - (role-prefixed prose plus `[bmad]`-marked `tool:` / `cmd:` / `file:` / - `perm ask:` / `perm reply:` / `error:` lines), `.server.out` takes the - server's own stdout so it cannot drown that transcript, and - `.sse.jsonl` keeps the raw frames for post-hoc debugging (behind the - `sse_trace` module knob, per-token deltas excluded). That split is also a - safety boundary: env-fault classification points `ENV_FAULT_LOG_SUFFIX` at - `.server.out` and never at the curated transcript, whose lines are the - model's own words. The catch of curating: - what you render is only as good as the event names you pinned — check every - branch against the running binary, not against the names that read plausibly. -- **Hookless profile.** The profile sets `[hooks] dialect = "none"`: no hook - registration, no hook-config merge into worktrees; `init`, `validate` and - worktree provisioning all understand `profile.hookless`. Skills still install - and copy normally — opencode discovers `.claude/skills//SKILL.md` - natively. -- **Reuse the synthesis mixins, never fork them.** `_ResultFileMixin` - (`result.json` read-back) and `_DevSynthesisMixin` (the whole bmad-build-auto - dev/review synthesis machinery) live in +[`adapters/opencode_http.py`](../src/bmad_loop/adapters/opencode_http.py) is the shipped +non-tmux adapter. It drives [OpenCode](https://opencode.ai) over `opencode serve`'s HTTP API +and SSE stream (`injection = "http"`, `observation = "sse"`, `state = "remote"`). Its module +docstring records the API contract it relies on. Patterns worth reusing: + +- **One server per session.** Each session gets its own `opencode serve`, spawned with + `cwd = spec.cwd` and `spec.env`, so the engine's `BMAD_LOOP_*` contract reaches tool + subprocesses. The argv is + `[binary, *launch_args, "serve", "--port", , "--hostname", "127.0.0.1", "--print-logs", *extra_args]`. + Permissions, the model and a hermetic skills path go in `OPENCODE_CONFIG_CONTENT`, with a + per-server `OPENCODE_SERVER_PASSWORD`. Reasoning effort rides each `prompt_async` body as + `variant`. +- **Map the transport onto hook-signal semantics.** SSE `session.idle` ≙ the Stop hook; + server-process death ≙ window death (`crashed`); on SSE loss, a poll of + `GET /session/status` plus proof of a completed assistant message takes over. An idle + event without proof of new work never completes a session. +- **Three log sinks, one per audience.** `.log` is a readable transcript curated + off the SSE stream; `.server.out` takes the server's own stdout and is what + env-fault classification scans; `.sse.jsonl` keeps raw frames behind the + `sse_trace` knob. +- **Hookless profile.** `[hooks] dialect = "none"`: no hook registration and no hook-config + merge into worktrees. Skills still install; opencode discovers `.claude/skills//SKILL.md`. +- **Reuse the synthesis mixins.** `_ResultFileMixin` (`result.json` read-back) and + `_DevSynthesisMixin` (dev/review result synthesis) live in [`adapters/generic.py`](../src/bmad_loop/adapters/generic.py). - `OpencodeDevAdapter(_DevSynthesisMixin, OpencodeHttpAdapter)` plugs into two - seams: `_probe_alive(handle) -> bool | None` (post-kill liveness; `None` = - unknown ⇒ the verdict stands) and `_configure_dev_knobs()` (stall-nudge - budgets). A new adapter class that should run dev/review sessions composes - the same way. -- **Usage before teardown, kill in `finally`.** Token usage only exists inside - the server (`state = "remote"`), so it is captured over HTTP before every - return path; `kill()` is idempotent, sweeps via `atexit`, and force-kills the - process tree first on Windows (the npm shim is a `.cmd` wrapper — a polite - kill orphans the real server). - -The test story mirrors the transport split: -[`tests/test_opencode_http.py`](../tests/test_opencode_http.py) runs the full -adapter against a scripted stdlib FakeOpencode (no binary, no network beyond -127.0.0.1), and [`tests/test_opencode_live.py`](../tests/test_opencode_live.py) -smoke-checks the pinned HTTP contract against a real local binary — skipped -when absent, zero tokens spent. + `OpencodeDevAdapter(_DevSynthesisMixin, OpencodeHttpAdapter)` overrides + `_probe_alive(handle) -> bool | None` (post-kill liveness; `None` = unknown, the verdict + stands) and `_evict_task_state(task_id)`, and calls `_configure_dev_knobs()` from + `__init__`. +- **Usage before teardown, kill in `finally`.** Usage is read over HTTP before every return + path. `kill()` is idempotent, an `atexit` sweep backs it up, and on Windows the process + tree is force-killed first. + +[`tests/test_opencode_http.py`](../tests/test_opencode_http.py) runs the adapter against a +scripted stdlib fake server; [`tests/test_opencode_live.py`](../tests/test_opencode_live.py) +smoke-checks the HTTP contract against a real local binary, skipped when absent, zero +tokens spent. ### Shipping a new adapter class out-of-tree -How does a new adapter class ever get _selected_ when it lives in its own package? -Which class drives a CLI is data — the profile's `adapter` field, resolved against -the adapter registry -([`adapters/registry.py`](../src/bmad_loop/adapters/registry.py)) — so a -co-installed package registers its own kind with no edit to any core `.py`, exactly -as a transport backend ships via `bmad_loop.mux_backends` +Which class drives a CLI is data: the profile's `adapter` field, resolved against the +adapter registry ([`adapters/registry.py`](../src/bmad_loop/adapters/registry.py)). A +co-installed package registers its own kind with no core edit, as a transport backend does ([the transport contract](#the-transport-contract-for-a-backend-author)). Advertise **two entry points** in the package's `pyproject.toml`: -- **`bmad_loop.adapters`** → a module whose import registers the kind. Core scans - the group and imports each advertised module (after the builtins, so a bundled - name always keeps first registration) before it resolves any adapter: +- **`bmad_loop.adapters`** → a module whose import registers the kind. Core imports each + advertised module before it resolves any adapter. A bundled kind name always wins, because + the builtins are seeded first and the first registration wins: ```python # acme_adapter/__init__.py @@ -883,10 +622,9 @@ Advertise **two entry points** in the package's `pyproject.toml`: acme = "acme_adapter" ``` -- **`bmad_loop.profiles`** → a callable returning `CLIProfile`s (or an iterable of - them), so the profile that _selects_ your kind ships with it — no project TOML - required. Precedence is packaged < entry-point < project, so a project TOML can - still override it: +- **`bmad_loop.profiles`** → a callable returning `CLIProfile`s (or an iterable of them), + so the profile that selects your kind ships with it. Precedence is packaged < entry-point + < project, so a project TOML can still override it: ```python # acme_adapter/__init__.py (same package) @@ -902,71 +640,44 @@ Advertise **two entry points** in the package's `pyproject.toml`: acme = "acme_adapter:profiles" ``` -The two entry-point **values** differ on purpose: the adapter group's is a bare -module path (core only imports it — registration is the import's side effect, and -the entry-point name is just a diagnostic label), while the profile group's names a -provider object core actually calls. Installing the package into bmad-loop's -environment is the entire setup — e.g. -`uv tool install bmad-loop --with ` — with no core edit and no config -step. - -Once co-installed, `bmad-loop adapters` lists the kind and the profiles that select -it, `bmad-loop validate` checks the reference (an `adapter.kind` finding, resolved -against the live registry — never a hardcoded set), and a run whose policy -`[adapter] name` points at a profile carrying `adapter = "acme"` selects it. Note -those are two different keys: policy's `[adapter] name` picks the **profile**; the -profile's own `adapter` field picks the **kind**. - -A broken package can never break selection: the failure is recorded and reported by -`bmad-loop adapters` (a `warning: external adapter '' failed to load: ` -line, and the same for a failed profile provider) and by the `validate` preflight, -and selection proceeds without it. There is no out-of-tree adapter-**class** package -to copy yet; the packaging pattern is identical to the transport axis's -[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr), which -registers a `TerminalMultiplexer` rather than an adapter class. - -Two seam facts worth internalizing: - -- **`needs_mux`** gates whether the run bootstrap resolves and usability-checks the - shared terminal multiplexer for your family. A tmux/hook family sets it `True`; a - self-hosted HTTP/SSE family (like opencode) sets it `False` and is never handed a - `mux`. -- **The `dev` / `plain` split is a pipeline concept, not a per-family branch.** When - a dev/review session runs the dev primitive (which writes no `result.json`), the - bootstrap builds the `dev` variant — the `_DevSynthesisMixin`-composed class — and - threads the project `paths` into it so it can synthesize the result from the spec - on disk; every other role builds `plain`. Both variants of a family share the - `(*args, paths, **kwargs)` dev `__init__` contract, so honoring it is all an - out-of-tree class must do to slot into that machinery. -- **The bootstrap's keyword set grows.** Accept `**kwargs` in both variants. - `runsetup.make_adapters` builds every class with `cls(**build_kwargs)`, and that - dict is a _description of the run_ — `run_dir`, `policy`, `profile`, the usage and - nudge settings, `events_dir` (the run's hook-event channel), plus `mux` for a - `needs_mux=True` kind and `paths` for the `dev` variant. Core adds to it as the run - gains things worth describing; `events_dir` arrived that way (#494). A class with a - closed signature raises `TypeError` on the first keyword it has not heard of, - before the session starts. - - The bundled families carry closed signatures instead, which is not a second - pattern to copy: they are edited in the same commit that adds the keyword, and an - out-of-tree class cannot be. What they do model is the other half of the - obligation — accept what you have no use for rather than refusing it. - `opencode_http` takes `events_dir` and immediately `del`s it, because that family - observes over SSE and fires no hooks, so there is no channel for it to point at. - -An entry-point profile is held to the same invariants a TOML profile is (hook -dialect, path containment, `env_fault_patterns` compilation, …): it is validated on -arrival, so a provider that ships an invalid profile is reported rather than -half-installed. +The adapter group's value is a bare module path (registration is the import's side +effect); the profile group's value names a callable. Installing the package into +bmad-loop's environment is the whole setup, for example +`uv tool install bmad-loop --with `. + +Once installed, `bmad-loop adapters` lists the kind and the profiles that select it, +`bmad-loop validate` checks the reference (`adapter.kind`, against the live registry), and +a run whose policy `[adapter] name` points at a profile with `adapter = "acme"` selects it. +Policy's `[adapter] name` picks the **profile**; the profile's `adapter` field picks the +**kind**. + +A broken package never breaks selection. `bmad-loop adapters` prints +`warning: external adapter '' failed to load: ` (and the same for a failed +profile provider), `validate` reports it, and selection proceeds without it. An +entry-point profile is validated like a TOML profile on arrival, so an invalid one is +reported rather than half-installed. + +Seam facts for the class itself: + +- **`needs_mux`** decides whether the run bootstrap resolves and usability-checks the + terminal multiplexer for your kind. A pane/hook family sets it `True`; a self-hosted + HTTP/SSE family sets it `False` and is never handed a `mux`. +- **`dev` vs `plain`.** When a dev or review session runs the dev skill (policy + `dev.skill = "bmad-dev-auto"`), which writes no `result.json`, the bootstrap builds the `dev` variant and passes it `paths` + so it can synthesize the result from the spec on disk. Every other role builds `plain`, + without `paths`. +- **Accept `**kwargs`in both variants.**`runsetup.make_adapters`builds every class with`cls(\*\*build_kwargs)`: `run_dir`, `policy`, `profile`, `extra_args`, `usage_grace_s`, +`stop_without_result_nudges`and`events_dir`, plus `mux`for a`needs_mux=True`kind and`paths`for the`dev`variant. Core adds keys as the run gains things worth describing. +A class that rejects a keyword aborts the run with`error: profile '': adapter kind '' rejected this run's adapter keywords: …`. +Accept what you do not use: `opencode_http`takes`events_dir` and discards it. ### References - [`adapters/registry.py`](../src/bmad_loop/adapters/registry.py) — the adapter-kind - registry and the two entry-point scans described above. -- [`adapters/opencode_http.py`](../src/bmad_loop/adapters/opencode_http.py) — the - worked example above: a real non-tmux (HTTP/SSE) transport. + registry and the two entry-point scans. +- [`adapters/opencode_http.py`](../src/bmad_loop/adapters/opencode_http.py) — a non-tmux + (HTTP/SSE) adapter. - [`adapters/mock.py`](../src/bmad_loop/adapters/mock.py) — the test-only reference implementation. -- [`adapters/generic.py`](../src/bmad_loop/adapters/generic.py) — the tmux + - hook-signal adapter to reuse with a profile rather than subclass; also home of - the `_ResultFileMixin` / `_DevSynthesisMixin` seams. +- [`adapters/generic.py`](../src/bmad_loop/adapters/generic.py) — the pane + hook-signal + adapter to reuse with a profile; home of `_ResultFileMixin` and `_DevSynthesisMixin`. diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index e1760e8d3..a71f8e19a 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -1,362 +1,242 @@ # Terminal multiplexer backends -The orchestrator drives every agent session — and the TUI's launch/attach keys — through -a terminal multiplexer behind a pluggable seam (`TerminalMultiplexer`). Two backends ship -in the box: **tmux** (the POSIX default) and the experimental **psmux** (the native-Windows -default). Additional backends install as separate packages and register themselves -automatically. This page is for operators: which backend is running, how to switch, and how -external backends arrive. -Contributors porting a new backend should start with -[Porting bmad-loop to a new OS](porting-to-a-new-os.md). +The orchestrator drives every agent session, and the TUI's launch and attach keys, through +a terminal multiplexer behind a pluggable seam (`TerminalMultiplexer`). Two backends ship: +**tmux** (the POSIX default) and the experimental **psmux** (the native-Windows default). +Other backends install as separate packages and register themselves. This page is for +operators: which backend is running, how to switch, and what each one needs. Authors of a +new backend start with [Porting bmad-loop to a new OS](porting-to-a-new-os.md) and +[the transport contract](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). ## Which backend is running, and how to switch `bmad-loop mux` lists the registered backends (platform · availability · version) and shows which one is selected and why. Selection precedence, highest first: -1. `BMAD_LOOP_MUX_BACKEND=` — forces a backend for one invocation. -2. `bmad-loop mux set ` — persists the choice into the gitignored, machine-scoped - `[mux] backend` key in `.bmad-loop/policy.toml` (`mux set --clear` reverts to auto). -3. The platform default — tmux on POSIX, psmux on native Windows — when registered - and available (psmux's availability rules are below). +1. `BMAD_LOOP_MUX_BACKEND=` forces a backend for one invocation. +2. `bmad-loop mux set ` persists the choice in the `[mux] backend` key of the + gitignored, machine-scoped `.bmad-loop/policy.toml`. `mux set --clear` reverts to auto. +3. The platform default (tmux on POSIX, psmux on native Windows), when registered and + available. 4. The first registered backend that matches the platform and is available. -5. If none of the above is available, a historical fallback keeps older setups working: - the first backend matching the platform regardless of availability, else tmux. The - selected backend then probes unavailable, and `bmad-loop validate` reports it as such. - -The choice applies to the next invocation — switch between runs, not while one is live: -`attach`, `cleanup`, and the TUI all look for sessions in the currently selected backend. -After switching, `bmad-loop validate` reports the selected backend's availability and -version as part of the preflight. - -The `AVAILABLE` column answers "does this backend's binary answer on this host", not -"can it be selected here". The two diverge when a foreign-platform binary shares a name -with a local one — on Windows, `tmux` is psmux's compatibility shim, so the tmux row -reads as a real tmux install. Only steps 3–5 above consult the platform, so `bmad-loop -mux` prints a `note:` naming exactly the rows that are **available, foreign to this -platform, and not the selected one** — the combination that looks like a contradiction. -A forced choice (step 1 or 2) reaches those backends anyway, which is why the column is -not gated instead; and a backend forced _into_ selection is left out of the note, since -calling it unselectable would contradict its own `*` marker. +5. Fallback: the first backend matching the platform regardless of availability, else + tmux. The selected backend then probes unavailable, and `bmad-loop validate` reports it. + +A forced name (1 or 2) skips the platform and availability checks; a forced backend that +probes unavailable is still used, with a one-time warning. A name that matches no +registered backend fails. + +Switch between runs, not while one is live: `attach`, `cleanup` and the TUI look for +sessions in the currently selected backend. `bmad-loop validate` reports the selected +backend's availability and version. + +The `AVAILABLE` column answers "does this backend's binary answer on this host", not "can it +be selected here". On Windows, `tmux` can be psmux's compatibility shim, so the tmux row +reads as available. `bmad-loop mux` prints a `note:` naming each row that is available, +foreign to this platform, and not selected; such a backend is reachable only by forcing it. ## tmux (the default) -tmux is the reference backend: everything else in these docs — the `bmad-loop-` -and `bmad-loop-ctl` session names, the `ctrl-b d` detach chord — describes tmux behavior. -While tmux is the selected backend it is required for launching, attaching, and driving -runs (an external backend brings its own session mechanism instead); pure TUI observation +tmux is the reference backend: the `bmad-loop-` and `bmad-loop-ctl` session names +and the `ctrl-b d` detach chord elsewhere in these docs describe tmux. While tmux is +selected it is required for launching, attaching and driving runs; pure TUI observation works without any backend. -**The supported floor is tmux 3.2.** No version gate enforces it — tmux is selected on the -presence of the binary alone — so an older tmux is not refused up front. It does not follow -that an older tmux merely runs untested, because these adapters do not all speak the same -vintage: environment injection goes out as `new-window -e KEY=VALUE`, which tmux gained in -3.0, so anything older fails loudly at window creation, while other forms — the `=` -exact-match target prefix among them — are considerably older and parse fine. Do not take the -oldest form the argv happens to accept as the floor either. Between whatever an old tmux still -parses and 3.2 lies a range that may run without complaint, and that nobody tests. -psmux carries a separate version requirement of its own, for unrelated reasons — see below. -The two floors are independent and neither implies the other. +The supported floor is **tmux 3.2**. Nothing enforces it: tmux counts as available when the +binary is on `PATH`. Environment injection uses `new-window -e KEY=VALUE`, which a much +older tmux rejects at window creation. ### A server started under another state root A tmux server is long-lived, and its panes inherit the environment the _server_ started -with, not the environment of the client asking for the pane. A server started while -`BMAD_LOOP_STATE_DIR` (or `XDG_STATE_HOME`, or `HOME`) named one state root, and reused -later by a bmad-loop under another, hands every new shell there the first root. The engine -windows the TUI parks are not exposed: the TUI hands each one its own state root on the -command line (a hidden `--state-root` option ahead of the subcommand, #731), so the run lands -where the TUI watches whatever the server holds, and two projects under different state roots -can share one server. A TUI that cannot derive a state root of its own refuses the launch -rather than let the engine inherit one it could not watch. - -What stays exposed is a shell: window 0 of the control session, or any shell opened there, still -inherits the server's root, so a `bmad-loop` command typed into one would use it. When the TUI -launches into its control session it asks tmux (`show-environment`) what a new window there -would inherit, resolves the state root from that, and notes once — naming both roots — when it -differs from its own. It offers no remedy: on tmux `bmad-loop-ctl` is one session shared by every -bmad-loop project on the server, so no value set there is right for all of them, and nothing -bmad-loop launches needs one. A shell that must agree can `export BMAD_LOOP_STATE_DIR=`. - -psmux cannot answer the question — its `show-environment` does not report inherited values -— so there is no such note on psmux. Its per-project registry is keyed on the state root, -so a psmux launcher under one root never reaches a server started under another through -the ordinary path. +with. A server started under one state root (`BMAD_LOOP_STATE_DIR`, `XDG_STATE_HOME` or +`HOME`) and reused by a bmad-loop under another hands every new shell the first root. + +- Engine windows the TUI parks are not affected: the TUI passes each one its state root on + the command line. A TUI that cannot derive a state root refuses the launch. +- A shell is affected: window 0 of the control session, or any shell opened there, inherits + the server's root, so a `bmad-loop` command typed there uses it. When the TUI launches into + its control session it compares the root a new window would inherit with its own and + notes once, naming both, when they differ. A shell that must agree can + `export BMAD_LOOP_STATE_DIR=`. + +psmux cannot report inherited values, so there is no such note on psmux. Its registry is +keyed on the state root, so a launcher under one root never reaches a server started under +another. ## psmux (native Windows, experimental) On a native-Windows host the bundled **psmux** backend is the platform default. psmux is a -ConPTY tmux re-implementation that speaks the tmux CLI through its own `psmux` binary, so it -reuses tmux's session/window model — the `bmad-loop-` session names carry over, and -the control session's does with a per-registry suffix -([below](#where-psmux-sessions-live-the-per-project-registry)). It is selected automatically -when available; `available()` requires the -`psmux` and `pwsh` (PowerShell) binaries on `PATH` and a psmux **3.3.8 or newer** (older -releases report unavailable and selection falls through: 3.3.6 and below can force-kill a -recycled PID during teardown, and the backend's verbs are written against fixes that landed -in 3.3.8 rather than routing around the defects they close). Every psmux window launch also -needs **PowerShell 7.3 or newer** as `pwsh`: older versions corrupt the arguments of the -command a window runs (a path with a space and a trailing backslash swallows the next -argument; an empty or quoted argument is mangled), so the backend reads the `pwsh` version -once per process and refuses to open a window or create a session under an older one (or -one whose version it cannot read), naming what it found and asking you to upgrade pwsh. A -forced backend (`BMAD_LOOP_MUX_BACKEND=psmux` or `[mux] backend`) is refused the same way -(#861). The backend resolves `pwsh` to one absolute path on its own `PATH`, reads that -executable's version, and launches every window and the pipe-pane log sink with that same -path, so the psmux server's `PATH` never picks a different one; a `pwsh` that does not -resolve is refused. A new session's initial window still runs psmux's own default shell -(#863). Window commands run with PowerShell's Standard argument passing, so a `.cmd` or -`.bat` launcher (an npm-installed CLI's shim) receives an empty or quoted argument intact; -batch launchers have remaining argument limits, tracked separately. Native Windows is still -experimental — see the -[roadmap](ROADMAP.md#native-windows-multiplexer-backend) for the remaining work. WSL is -unaffected: it _is_ Linux and uses tmux — provided bmad-loop was installed with the -distro's own Python. WSL appends the Windows `PATH` to its own, so a Windows-installed -bmad-loop is reachable from the bash prompt; that process reports `win32` and takes the -psmux default no matter how Linux the shell looks. `bmad-loop validate` names the -selection reason on every host, and warns (`host.win32-on-wsl-path`) when a `win32` interpreter -is working on a `\\wsl.localhost\...` project (#332). - -Two model differences matter if you port a backend or read psmux argv. psmux runs one server -per session, so window ids are minted per server and the backend session-qualifies every id it -hands out (`session:@N`). And psmux has no per-window user options — one scope exists per -server — so the window-option verbs and the `@`-prefixed columns of `list_windows` are served -by a session-scoped option whose key carries a seam-owned marker plus the window id -(`@bmad_project__blw@3` for window `@3`; no real-world naming convention collides with the -marker, so the channel's cleanup sweeps stay off hand-written user options unless one -deliberately imitates the seam). Both are properties of psmux's model, not gaps awaiting an -upstream release. Practical consequence: such a value is **not** readable via -`psmux show-options -w` by hand — read it with -`psmux show-options -qv -t "@bmad_project__blw@N"` instead. Session-scoped options need -no such substitute — one server per session means that server's single map _is_ the session's — -but the value has to read back verbatim either way, so session-scoped `@` options are gated the -same way. One visible limit: a value that cannot make that round trip is refused with a stderr -warning and the option reads as unset. Ordinary Windows paths pass — spaced, UNC, apostrophed, -trailing-separator alike. What is refused is what the backend's own listing parse cannot carry -back: a value containing a double quote or a line break, or one with leading/trailing -whitespace (both reads strip it). A `-`-leading value is refused too, because psmux still -treats it as a flag. The project ownership tag no -longer meets this gate: it is stored as a hex digest of the project path, transportable by -construction ([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)), so sessions stay -tagged whatever the path and the run-dir fallback remains only for genuinely untagged state. +ConPTY tmux re-implementation driven through its own `psmux` binary, so it reuses tmux's +session and window model and the `bmad-loop-` session names. + +Requirements: + +- **psmux 3.3.8 or newer** and **`pwsh`** on `PATH`. `available()` requires both binaries + and reads psmux's reported version; an older or unreadable version reports unavailable + and selection falls through. +- **PowerShell 7.3 or newer** as `pwsh`. The backend resolves `pwsh` to one absolute path, + reads its version once per process, and refuses to create a session or open a window + under an older, unreadable or unresolvable one, naming what it found. A forced psmux is + refused the same way. Every window and the pipe-pane log sink run with that same `pwsh`. +- A new session's initial window runs psmux's own default shell. Window commands use + PowerShell's Standard argument passing, so a `.cmd` or `.bat` launcher (an npm CLI shim) + receives empty or quoted arguments intact. + +Native Windows is experimental; the remaining work is on the +[roadmap](ROADMAP.md#native-windows-multiplexer-backend). + +**WSL** is Linux and uses tmux, provided bmad-loop was installed with the distro's Python. +WSL appends the Windows `PATH`, so a Windows-installed bmad-loop is reachable from the bash +prompt; that process reports `win32` and selects psmux. `bmad-loop validate` names the +selection reason and warns (`host.win32-on-wsl-path`) when a `win32` interpreter works on a +`\\wsl.localhost\...` project. + +**Window options.** psmux has one option scope per server, so bmad-loop stores a window +option as a session option whose key carries the window id: `@bmad_project__blw@3` for +window `@3`. `psmux show-options -w` does not show these values; read one with +`psmux show-options -qv -t "@bmad_project__blw@N"`. Window ids are +session-qualified (`session:@N`). + +**Refused option values.** An `@` option value must read back verbatim. A value that is +empty, starts with `-`, contains a double quote or a line break, or has leading or trailing +whitespace is refused with a stderr warning, and the option reads as unset. Ordinary +Windows paths (spaced, UNC, apostrophed, trailing separator) pass. The project ownership +tag is a hex digest of the project path, so it always passes. ### Where psmux sessions live: the per-project registry -psmux resolves every target through a **registry** — the directory named by `PSMUX_DATA_DIR`, -holding one `.port`/`.key`/`.sid`/`.pid` set per session — and defaults it to -`%USERPROFILE%\.psmux`. bmad-loop points it at a per-project root instead: -`//_mux`, where `` is the location -[`BMAD_LOOP_STATE_DIR`](reference/configuration.md#environment-variables) resolves to and `` is -the same digest of the project's resolved path that tags session ownership. The export happens -once per bmad-loop process, before anything spawns psmux, so `run`, `cleanup`, `stop`, `attach` -and the TUI all agree on one registry without any of them persisting it. +psmux resolves every target through a **registry**: the directory named by +`PSMUX_DATA_DIR`, holding one `.port`/`.key`/`.sid`/`.pid` set per session. psmux defaults it +to `%USERPROFILE%\.psmux`. bmad-loop uses a per-project root instead: +`//_mux`, where `` is what +[`BMAD_LOOP_STATE_DIR`](reference/configuration.md#environment-variables) resolves to and +`` is the 16-hex digest of the project path that also tags session ownership. +Every bmad-loop process exports this root before it spawns psmux, so `run`, `cleanup`, +`stop`, `attach` and the TUI agree on one registry. -**The consequence to know: a bare `psmux ls` does not show these sessions.** It reads psmux's -default registry, finds nothing there, and answers "no sessions" — not an error. The same goes -for `psmux attach -t bmad-loop-` typed by hand. To reach them, export the same root -first: +**A bare `psmux ls` does not show these sessions.** It reads psmux's default registry and +answers "no sessions". The same goes for a hand-typed `psmux attach -t bmad-loop-`. +`bmad-loop mux` prints the root and a paste-ready export line; use it rather than composing +the path: ```powershell -$env:PSMUX_DATA_DIR = 'C:\Users\you\AppData\Local\bmad-loop\state\\_mux' +$env:PSMUX_DATA_DIR = '' psmux ls ``` -`bmad-loop mux` prints that root and a paste-ready export line for the current project, which is -the reliable way to get it — do not compose the path by hand. `bmad-loop attach` needs none of -this: it runs the client itself, under the root it just exported. - -Two further consequences: - -- **The control session is now per project, and its name says so.** `bmad-loop-ctl` was one - session shared by every project on the machine; separate registries mean one per project — and - the name must change with the scope, because psmux's duplicate-server guard is a mutex keyed on - the session name alone, across every registry in your login session (`Local\psmux-session-` - — a per-login-session object namespace, not a machine-global one), so a fixed name would let only - one registry there hold a control session and every other project's launch would fail as a - duplicate. On psmux the session is therefore `bmad-loop-ctl-<16-hex registry digest>`; the - TUI's toasts name the exact session. On tmux (no registries) the shared `bmad-loop-ctl` is - unchanged. -- **Sessions created before this change are in whichever registry the old build inherited**, - and `bmad-loop cleanup` sweeps those too — but only for sessions that carry this project's - ownership tag and are not still running. There are two such registries, because the old build - simply used whatever `PSMUX_DATA_DIR` it found: psmux's **default** registry on a machine that - never set the variable, and **your own exported root** on one that did — the same root - bmad-loop now overrides (it remembers what it displaced, for exactly this sweep). The leftovers - line names the registry each session is in, so the one to open is the one it names. - - Whatever the sweep leaves standing is **named on stderr** (and in `cleanup --json`, at - `sessions.legacy_leftovers`) so a removal count never quietly stands for a partial migration. - A legacy registry that could not be asked at all — its listing failed, or no backend could be - selected to name one — is named the same way (`sessions.legacy_unverified` in `--json`, a toast - in the TUI), since "not looked at" is not "nothing left". - Three kinds stay behind by design: - - - An **untagged** `bmad-loop-` session. In a shared registry a matching run directory - here is not proof of ownership — run ids are only unique within one project, and `--run-id` - is caller-supplied — so claiming one could kill a live session belonging to another project. - - **A live session of your own.** Cleanup never kills a run whose engine is still going, - wherever it lives. - - The pre-upgrade **`bmad-loop-ctl` control session**. The ctl-window sweep runs against the - current registry only. - - The list is taken by _presence_ — what the registry still holds once the sweep has run — so it - covers a kill that silently did not land as well as one that was never attempted. (`cleanup`'s - own `removed` count is still the pre-kill plan, the ceiling `cleanup --json`'s - `sessions.removed` has always documented; the leftovers line is what catches the difference.) - - These are one-time artifacts of the upgrade. To see them, put the shell on the registry the - leftovers line named. For psmux's default registry that means a shell with **no** - `PSMUX_DATA_DIR` set; for a root of your own, export that root: - - ```powershell - Remove-Item Env:PSMUX_DATA_DIR # psmux's default registry - # ...or, for the root bmad-loop displaced, the one the message named: - $env:PSMUX_DATA_DIR = 'D:\your-own-registry' - - psmux ls - psmux list-windows -t bmad-loop-ctl -F '#{window_index}: #{window_name}' - ``` - - > **Do not reach for `psmux kill-session -t bmad-loop-ctl`.** That session is machine-wide, and - > `kill-session` kills every child process in every one of its windows — across every project - > that used it. bmad-loop's own windows run the engine first and park only once it exits, so a - > window there is a **live run** until it isn't. If `bmad-loop cleanup` reported a leftover - > control session while any pre-upgrade run was still going, that run is what you would be - > killing. - - **`list-windows` cannot tell you which is which.** A live window and a parked one render - identically — `1: run-… (1 panes) [120x30]` — and so do `#{pane_pid}` and `#{pane_dead}`: the - parked window's shell is still alive, holding the prompt. The `[bmad-loop exited …]` banner is - pane _text_, so the one command that answers it is `capture-pane`: - - ```powershell - psmux capture-pane -p -t bmad-loop-ctl:1 # per window index from the listing above - ``` - - A window whose last line reads `[bmad-loop exited — press enter]` has finished. One that - does not is still running — take its `run-` / `sweep-` name to - `bmad-loop list --project ` and stop it through bmad-loop - (`bmad-loop stop --project `, or run it from that project) - rather than through psmux. - - **`stop` works there, but it does not sweep the old registry.** The stop reaches the engine - _process_, not a session: it lodges a request in the run directory and signals the recorded pid, - and a run directory belongs to a project and a run id, not to a registry. So a pre-upgrade run - stops, and a still-live engine tears its own window down under the registry it was launched with. - What `stop` does not reach is its own backstop kill — that one addresses the registry bmad-loop - exported for this project, so an agent session an already-dead engine left behind in an older - registry stays standing and the run is still marked stopped. It is `bmad-loop cleanup` that - reaches those, through the legacy pass described above: a session carrying this project's tag is - killed there, and anything the tag rule declines is named on the leftovers line with the registry - it is in. `stop` is deliberately not widened to match — a by-name kill in a registry shared with - other projects, without that tag proof, could take a neighbour's same-named session, since run - ids are unique only within one project. - - Finished windows can simply be left parked — a parked window costs one idle shell. To close one, - `psmux kill-window -t bmad-loop-ctl:` kills only that window's own children, so it is - safe once `capture-pane` has shown you the banner. Closing the last window ends the session too, - but only while psmux's `exit-empty` is on; it is on by default, and `psmux show-options -g -exit-empty` says which you have. With it off, an empty session stays. - -By default, setting `PSMUX_DATA_DIR` yourself does **not** move bmad-loop's registry. bmad-loop -derives the root from the project and the state root and exports it over whatever it finds, saying -so once on stderr when it replaced something. Your value is left alone for your own psmux sessions — it is -psmux's variable, not bmad-loop's, so bmad-loop overrides it rather than refusing to run. - -That is deliberate, and the reason is worth having: an honoured export would make the registry a -function of the shell a command happened to start in. A TUI launched from the Start menu carries no -profile environment and would derive; a run started from a dev shell whose profile exports a root -would honour it — two registries on one machine, and a live session reading as gone in one of them. -Nor can bmad-loop tell the two apart: a value typed once in one shell and a value a profile exports -into every shell arrive identically, and they want opposite answers. - -To reach these sessions from a bare psmux, point your shell at bmad-loop's root rather than the -other way round. `bmad-loop mux` prints it ready to paste: +`bmad-loop attach` needs none of this; it runs the client under the root it exported. -```powershell -$env:PSMUX_DATA_DIR = '' -psmux ls -``` +**The control session is per project on psmux:** `bmad-loop-ctl-<16-hex registry digest>`. +The TUI's toasts name the exact session. When no state root can be derived it is plain +`bmad-loop-ctl`, as on tmux. + +**If the state root cannot be derived** (for example, `BMAD_LOOP_STATE_DIR` is a relative +path), bmad-loop says so and leaves whatever `PSMUX_DATA_DIR` was in force. `cleanup` then +claims only sessions carrying this project's ownership tag. + +### Using your own `PSMUX_DATA_DIR` + +By default an ambient `PSMUX_DATA_DIR` does **not** move bmad-loop's registry: bmad-loop +exports its derived root over it and notes once on stderr that it replaced a value. Your +value still applies to your own psmux sessions. -One registry serving both bmad-loop and your own psmux is a preference you state rather than one -bmad-loop guesses at ([#729](https://github.com/bmad-code-org/bmad-loop/issues/729)). If your -profile exports `PSMUX_DATA_DIR` into **every** shell, turn on the switch in the project's -`policy.toml`: +If your profile exports `PSMUX_DATA_DIR` into **every** shell and you want one registry for +both, turn on the switch in the project's `policy.toml`: ```toml [mux] honor_ambient_psmux_data_dir = true ``` -bmad-loop then uses your value as the registry, a pane child inherits and honours it, and a clean -process carrying your profile honours it too, so every process agrees. An engine window the TUI -parks is handed the registry in force on its command line (a hidden `--registry-root`, beside -`--state-root`), so it agrees even under `PSMUX_BARE_ENV`, where a pane inherits no -`PSMUX_DATA_DIR`; a run typed into a bare window-0 shell derives the registry instead. The handed -value is judged by the same rules as an inherited one. `bmad-loop mux` says -`your own $PSMUX_DATA_DIR, honoured` and prints the derived root it would otherwise use. Leave the -switch off for a value you typed into one shell: a process started without it would derive, and the -session would read as gone there. Rules, all fixed: - -- It is a yes/no switch, never a path. `policy.toml` is writable by the sessions bmad-loop drives, - so a policy-supplied root would let one aim cleanup's kills at a registry of its choosing. -- With no `PSMUX_DATA_DIR` set, or a relative or empty one, the switch changes nothing: the derived - root is used. -- A value shaped like a bmad-loop-derived root (`<16-hex project key>\_mux`) is never treated as - your pin. That is what a pane child inherits when the outer process derived, and it re-derives - like a clean process does. -- The TUI's control session gets its own name in your registry, so turning the switch on while an - old control session still runs in the derived root does not collide with it. -- A running TUI keeps watching the registry it started with. After you flip the switch either way, - it refuses to launch a run until you restart it (`bmad-loop tui`). A run started then would land - in a registry the TUI no longer watches. -- Sessions started in the derived root before you turned the switch on are still reached by - `bmad-loop cleanup`'s tag-scoped sweep. Your registry is shared with your own sessions, so cleanup - claims a session there only by its ownership tag. - -Two consequences of deriving, both benign: - -- A pane child agrees with a clean process by construction — `bmad-loop --project ` run from - a pane of this project's session gets the other project's root, not this one's. -- The registry follows `BMAD_LOOP_STATE_DIR`, so a process under a different state root uses a - different registry. It moves with the out-of-tree state the state root already holds — each run's - control-plane directory and its hook-event channel. The run _directory_ is not among them: that - stays in-tree at `/.bmad-loop/runs` and moves with the project. - -How the state root reaches the windows bmad-loop opens: coding-CLI windows (the engine's sessions -and the probe launcher's window) are **told** it through their env dict, which travels inside the -command the window runs; the engine windows the TUI parks are handed it on their command line -(`--state-root`, #731); a session's initial shell window inherits it from the multiplexer server, -as it always has. The told entry is -always this process's own answer: a `BMAD_LOOP_STATE_DIR` declared in a profile's `[env]` table is -overwritten with the resolved root, and when no root can be derived the entry is removed rather -than forwarded — the window then inherits and fails exactly as its parent does, instead of being -aimed at a state root (and so a registry) its own orchestrator cannot see. - -One psmux mode breaks that inheritance and is **not supported**: `PSMUX_BARE_ENV=1` (psmux's -escape hatch for a Windows environment block near the 32 KB `CreateProcessW` limit) empties a pane -child's environment and rebuilds it from a 14-name allowlist that drops both -`BMAD_LOOP_STATE_DIR` and the `LOCALAPPDATA` its default falls back to — so an inherited value is -no value at all. (`TMUX` is still set in the pane, but by psmux afterwards, not by the allowlist, -which does not contain it.) The windows bmad-loop launches keep their roots: a parked engine window -is handed its state root and registry root on its command line, and a coding-CLI window's env -rides the in-command transport, which psmux applies after the bare-env clear (source-read at -v3.3.8). A `bmad-loop` command typed into a window-0 shell under that switch re-derives the state -root from what survived, and lands somewhere else — reading a live session as gone — whenever -`BMAD_LOOP_STATE_DIR` was in force or `LOCALAPPDATA` points outside `%USERPROFILE%\AppData\Local`; -`USERPROFILE` survives the clear, so a default profile re-derives the same root. The clear also -drops every variable a window's env dict does not name, silently: a coding CLI can miss -credentials or configuration, and without `APPDATA` git stops reading your global excludes in -`%APPDATA%\Git\ignore`. bmad-loop warns once per process when the switch is on in its environment -(the server, not this process, is what reads the switch at pane spawn, so a server already running -with it on under a clean client is not detected), and the TUI shows the warning again once its -screen is up; the remedy is to unset `PSMUX_BARE_ENV` for bmad-loop's sessions. - -If the state root cannot be derived at all — `BMAD_LOOP_STATE_DIR` set to a relative path, say — -bmad-loop has no registry of its own to point at, says so, and leaves whatever `PSMUX_DATA_DIR` you -had in force. `cleanup` then refuses to claim an untagged session on run-directory evidence, because -that evidence only holds in a registry bmad-loop derived: a run id is unique within a project, not -across the registry you are sharing with it. +bmad-loop then uses your value as the registry, and `bmad-loop mux` reports it as your own +`$PSMUX_DATA_DIR`, honoured, alongside the derived root it would otherwise use. Engine +windows the TUI parks are handed the registry on their command line. Leave the switch off +for a value typed into one shell: a process started without it would use the derived root, +and the session would read as gone there. + +- It is a yes/no switch; policy can never set a path. +- An empty or relative `PSMUX_DATA_DIR`, or none, changes nothing: the derived root is used. +- A value shaped like a derived root (`<16-hex project key>\_mux`) is never treated as + yours; a pane child re-derives. +- The control session gets its own name in your registry. +- A running TUI keeps the registry it started with and refuses to launch runs after the + switch flips, until you restart it (`bmad-loop tui`). +- Sessions left in the derived root are still reached by `bmad-loop cleanup`, which claims a + session in your registry only by its ownership tag. + +The registry follows `BMAD_LOOP_STATE_DIR`, so a process under a different state root uses a +different registry. Coding-CLI windows receive the state root in their env dict, which +travels inside the command the window runs; a `BMAD_LOOP_STATE_DIR` in a profile's `[env]` +table is overwritten with the resolved root. + +### `PSMUX_BARE_ENV` is not supported + +`PSMUX_BARE_ENV=1` makes psmux rebuild a pane child's environment from a short allowlist +that drops `BMAD_LOOP_STATE_DIR` and `LOCALAPPDATA`. The windows bmad-loop launches keep +their roots (parked engine windows get them on the command line; coding-CLI windows carry +their env inside the command). What breaks: + +- A `bmad-loop` command typed into a window-0 shell re-derives the state root from what + survived and can read a live session as gone, whenever `BMAD_LOOP_STATE_DIR` was set or + `LOCALAPPDATA` points outside `%USERPROFILE%\AppData\Local`. +- Every variable a window's env dict does not name is dropped. A coding CLI can miss + credentials or configuration, and without `APPDATA` git stops reading global excludes in + `%APPDATA%\Git\ignore`. + +bmad-loop warns once per process when the switch is set in its environment, and the TUI +repeats the warning. A server already running with it on is not detected. Unset +`PSMUX_BARE_ENV` for bmad-loop's sessions. + +### Sessions in another registry + +`bmad-loop cleanup` also sweeps the registries this project's sessions may sit in from +before the per-project root: psmux's default registry, and any `PSMUX_DATA_DIR` root +bmad-loop displaced. It kills only sessions that carry this project's ownership tag and +whose run is not still going. Whatever it leaves is named on stderr as +`left in (not migrated): …` and in `cleanup --json` at +`sessions.legacy_leftovers`; a registry that could not be asked at all is named as +`legacy registry not checked …` (`sessions.legacy_unverified`, a toast in the TUI). + +Three kinds stay behind by design: + +- An **untagged** `bmad-loop-` session. Run ids are unique only within a project, so + a matching run directory is not proof of ownership in a shared registry. +- **A live session of your own.** Cleanup never kills a run whose engine is still going. +- The shared **`bmad-loop-ctl` control session** in that registry. + +To inspect one, put the shell on the registry the message named: + +```powershell +Remove-Item Env:PSMUX_DATA_DIR # psmux's default registry +# ...or the root the message named: +$env:PSMUX_DATA_DIR = 'D:\your-own-registry' + +psmux ls +psmux list-windows -t bmad-loop-ctl -F '#{window_index}: #{window_name}' +``` + +> **Do not run `psmux kill-session -t bmad-loop-ctl`.** That session can be shared by every +> project that used the registry, and `kill-session` kills every child process in every +> window. A window there runs its engine first and parks only once the engine exits, so it +> is a **live run** until it shows the exit banner. + +`list-windows` cannot tell a live window from a parked one. Read the pane text: + +```powershell +psmux capture-pane -p -t bmad-loop-ctl:1 # per window index from the listing above +``` + +A window whose last line reads `[bmad-loop exited — press enter]` has finished. One +that does not is still running: find its `run-` / `sweep-` name with +`bmad-loop list --project ` and stop it with +`bmad-loop stop --project `, not through psmux. `stop` reaches the +engine process wherever its session lives, but an agent session a dead engine left in an +older registry is reached only by `bmad-loop cleanup`. + +A finished window can stay parked; it costs one idle shell. To close one, +`psmux kill-window -t bmad-loop-ctl:` kills only that window's children, so it is +safe once `capture-pane` has shown the banner. ## Recovering a stranded session @@ -377,9 +257,9 @@ Never kill a `bmad-loop-ctl…` control session this way: its windows can be liv ## External backends -Every backend beyond the two bundled ones is a separate package that you co-install with bmad-loop; it -registers itself through the `bmad_loop.mux_backends` entry-point group, so installation -is the entire setup — the new backend simply appears in `bmad-loop mux`, selectable and +Every backend beyond the two bundled ones is a separate package co-installed with +bmad-loop. It registers through the `bmad_loop.mux_backends` entry-point group, so +installing it is the whole setup: the backend appears in `bmad-loop mux`, selectable and persistable like a bundled one. With bmad-loop installed as a `uv` tool: ```bash @@ -390,30 +270,20 @@ bmad-loop mux set # persist for this machine ``` The reference external backend is -**[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr)** — -[herdr](https://herdr.dev) is a cross-platform, agent-aware terminal workspace manager -whose agent-status sidebar is a natural fit for watching runs. What changes from your -seat on herdr (one manual detach chord, `ctrl+b q`; polled logs; a JSON state sidecar) is -documented in -[that repo's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). - -Two operational notes that apply to any external backend: - -- **A broken adapter package never breaks bmad-loop.** If an installed backend fails to - import, selection proceeds without it; `bmad-loop mux` prints a - `warning: external backend '' failed to load: ` line and `validate` notes - the same. The fix is usually reinstalling or upgrading the adapter. -- **A raising probe reads as unavailable, but says so.** When a backend's platform - predicate, factory or `available()` raises, selection skips it with a one-time - `warning:` on stderr, `bmad-loop mux` prints `warning: backend probe failed: -`, and `validate` adds a `mux.backend-probe` warning. A `version()` that raises - is reported as a failed version probe. None of these reads the same as an - ordinary unavailable row (DW-464). -- **`mux set --force` covers late registrations.** A backend that only registers on some - other machine (where the package IS installed) can still be persisted in a shared - workflow with `bmad-loop mux set --force`. -- **`capture_pane` is optional.** `TerminalMultiplexer.capture_pane(window_id)` (the - window's visible screen as text) is non-abstract: its default raises - `MultiplexerError`, which the generic adapter reads as "no match" at a stall-grace - expiry, so an existing backend keeps working unchanged. Override it to enable the - profile's `parked_prompt_patterns` (DW-350) on your transport. +**[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr)**. +[herdr](https://herdr.dev) is a cross-platform, agent-aware terminal workspace manager. What +changes on herdr (one manual detach chord, `ctrl+b q`; polled logs; a JSON state sidecar) is +in [that repo's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). + +Operational notes for any external backend: + +- **A broken package never breaks bmad-loop.** If a backend fails to import, selection + proceeds without it; `bmad-loop mux` prints + `warning: external backend '' failed to load: ` and `validate` reports a + `mux.external-backend` warning. Reinstalling or upgrading the package usually fixes it. +- **A raising probe reads as unavailable, and says so.** A raising `available()` is skipped + by selection with a one-time stderr warning. `bmad-loop mux` prints + `warning: backend probe failed: ` (or `version probe failed` for a raising + `version()`), and `validate` adds a `mux.backend-probe` warning. +- **`mux set --force` persists a backend that is not registered here**, for a shared + workflow where the package is installed on other machines. diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 1500f1ecb..ac1ab66f7 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -1,487 +1,351 @@ # Porting bmad-loop to a new OS -bmad-loop runs on Linux and macOS today, and on Windows **via WSL** (which _is_ -Linux — every fast path works unchanged). A **native** Windows host, or any other -OS, is not yet shipped. This guide is the map for adding one. - -## The promise - -The OS-specific work is quarantined behind four seams. Porting to a new OS is -**new files plus one registration line per seam** — no edits to the bodies of the -core `.py` modules or their call sites. Each seam selects its implementation by -platform from a registry, with an env-var override for tests. - -| # | Seam | Contract / registry | Override env var | -| --- | -------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | -| 1 | Terminal multiplexer | `TerminalMultiplexer` / `register_multiplexer` | `BMAD_LOOP_MUX_BACKEND` (or `bmad-loop mux set `) | -| 2 | Process lifecycle | `ProcessHost` / `register_process_host` | `BMAD_LOOP_PROCESS_HOST` | -| 3 | Hook interpreter | `ProcessHost.hook_interpreter()` | (rides on seam 2) | -| 4 | Validate preflight | `_platform_preflight(project)` (no new code — reads seams 1–2) | — | - -The **one** bundled caveat: a backend you ship _in this repo_ needs its import -added to the relevant `_load_builtin_*` loader so it self-registers (one line). An -out-of-tree backend skips even that — it registers at its own import time. - -This guide covers porting **the OS axis** (transport + process lifecycle). The -orthogonal **CLI axis** — teaching bmad-loop a new coding CLI — lives in the -[adapter authoring guide](adapter-authoring-guide.md). The deep transport contract -(every method a multiplexer must implement) also lives there; this guide links to -it rather than duplicating it. +bmad-loop runs on Linux, macOS, WSL (which _is_ Linux), and native Windows through the +bundled, experimental psmux backend. This guide maps the seams a new OS or transport plugs +into. Operators choosing or running a backend read +[Terminal multiplexer backends](multiplexer-backends.md); the orthogonal **CLI axis** +(teaching bmad-loop a new coding CLI) is in the +[adapter authoring guide](adapter-authoring-guide.md). + +## The seams + +The OS-specific work sits behind four seams. Each selects its implementation by platform +from a registry, with an env-var override for tests. + +| # | Seam | Contract / registry | Override env var | +| --- | -------------------- | -------------------------------------------------------- | ------------------------------------------------------- | +| 1 | Terminal multiplexer | `TerminalMultiplexer` / `register_multiplexer` | `BMAD_LOOP_MUX_BACKEND` (or `bmad-loop mux set `) | +| 2 | Process lifecycle | `ProcessHost` / `register_process_host` | `BMAD_LOOP_PROCESS_HOST` | +| 3 | Hook interpreter | `ProcessHost.hook_interpreter()` | (rides on seam 2) | +| 4 | Validate preflight | `runsetup.platform_preflight(project)` (reads seams 1–2) | — | + +A new backend or host is new files plus a registration. A bundled one adds its import and +its `register_*` call to the matching `_load_builtin_*` loader; an out-of-tree one calls +`register_*` at import time. Transport features beyond the seam contract, such as a +[registry namespace](#registry-namespaces-only-if-your-transport-has-one), also need core +support in `runs` and `cli`. --- ## Seam 1 — terminal multiplexer (transport) -**Contract:** `TerminalMultiplexer` (`src/bmad_loop/adapters/multiplexer.py`) — how -sessions, windows, and panes are created, observed, and torn down. Everything that -touches a session goes through `get_multiplexer()`; nothing else shells out to a -multiplexer directly. +**Contract:** `TerminalMultiplexer` (`src/bmad_loop/adapters/multiplexer.py`) creates, +observes and tears down sessions, windows and panes. Everything that touches a session goes +through `get_multiplexer()`. The per-method obligations are in +[the transport contract](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). -**Register** at import time: +**Register:** ```python from bmad_loop.adapters.multiplexer import register_multiplexer -register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultiplexer) +register_multiplexer("mymux", lambda platform: platform == "win32", MyMultiplexer) ``` `register_multiplexer(name, matches, factory)`: -- `name` — the key the `BMAD_LOOP_MUX_BACKEND` override selects by. -- `matches(sys.platform) -> bool` — decides automatic selection. -- `factory() -> TerminalMultiplexer` — builds the backend. - -`get_multiplexer()` resolves by precedence: - -1. `BMAD_LOOP_MUX_BACKEND` — forces a backend by name (per-invocation). -2. `[mux] backend` in `.bmad-loop/policy.toml` — the persisted, machine-scoped - choice (`bmad-loop init` gitignores policy.toml, so it never reaches - teammates). Set it with `bmad-loop mux set `. -3. The **platform default** — win32: `psmux`, everywhere else: `tmux` — when - that backend is registered _and_ `available()`. -4. The first registered backend whose `matches(sys.platform)` is true _and_ - whose `available()` reports usable (registration order breaks ties). -5. The historical fallback: first platform match regardless of availability, - bottoming out at tmux — so a POSIX host without tmux still selects - `TmuxMultiplexer` and `validate` reports it unavailable. - -A forced name (1–2) bypasses both the platform predicate and `available()` — an -explicit choice is trusted — and fails loudly when it matches no registered -backend. Launch preflights and TUI observers share one forced-aware gate -(`mux_usable()`), which warns once on stderr when a forced backend probes -unavailable instead of silently proceeding or refusing. `bmad-loop mux` lists every registered backend with its availability, -version, and the current selection. (The result is cached — see -[Testing a port](#testing-a-port).) +- `name`: the key `BMAD_LOOP_MUX_BACKEND` and `[mux] backend` select by. The first + registration under a name wins, and the builtins register first. +- `matches(sys.platform) -> bool`: decides automatic selection. +- `factory() -> TerminalMultiplexer`: builds the backend. Keep it a plain constructor; + `bmad-loop mux` instantiates every registered backend to list it. + +Selection precedence is in +[Which backend is running](multiplexer-backends.md#which-backend-is-running-and-how-to-switch). +A forced name bypasses both `matches` and `available()`, and fails loudly when it matches +no registered backend. Launch preflights and TUI observers share one forced-aware gate, +`mux_usable()`, which warns once on stderr when a forced backend probes unavailable. ### Shipping out-of-tree: the `bmad_loop.mux_backends` entry point -How does the registration snippet above ever _run_ when the backend lives in its -own package? Advertise the module in the package's `pyproject.toml`: +Advertise the module that calls `register_multiplexer(...)` in the package's +`pyproject.toml`: ```toml [project.entry-points."bmad_loop.mux_backends"] -psmux = "my_package.backend" +mymux = "my_package.backend" ``` -Before every selection, core scans that entry-point group and imports each -advertised module (builtins first, so tmux keeps first registration and the -precedence above is unchanged); the module's top-level `register_multiplexer(...)` -call does the rest. Installing the package into bmad-loop's environment — e.g. -`uv tool install bmad-loop --with ` — is the entire setup; no core -edit, no config step. The entry-point _value_ is a bare module path (core only -imports it; the name is just a diagnostic label). - -A package that fails to import can never break selection: the failure is -recorded and reported by `bmad-loop mux` (a `warning:` line under the table) and -the `validate` preflight, and selection proceeds without it. The reference -out-of-tree adapter is +On first selection or detection, core imports each advertised module once per process, +after the builtins. Installing the package into bmad-loop's environment (for example +`uv tool install bmad-loop --with `) is the whole setup. The entry-point +value is a bare module path; the name is a diagnostic label. A package that fails to import +never breaks selection: the failure is recorded and +[reported](multiplexer-backends.md#external-backends). The reference out-of-tree backend is [bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr). ### Two build paths -- **Extend `BaseTmuxBackend`** (`adapters/tmux_base.py`) for a **tmux-family** - backend. `BaseTmuxBackend` holds every argv construction and routes every spawn - through one primitive, `_run(argv, *, check=..., env=...)`. A native-Windows - "psmux" that speaks a tmux-like CLI sets the `_BINARY` class attribute to the - binary it drives (every spawn, PATH probe, and in-source client verb follows - it) and the `_ENCODING` class attribute for output decoding (e.g. `"utf-8"`), - and passes a per-call `env=` where needed — overriding `_run()` itself only - to tweak the timeout — plus the - shell-dialect hooks that `new_window` / `new_parked_window` compose from - (`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`, - `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` fragments) — - **without editing** `tmux_base.py` or its POSIX leaf `tmux_backend.py` - (`TmuxMultiplexer`). The one method-body override left is `pipe_pane`, whose - POSIX `cat >>` redirection is not behind a hook. The base `_window_launch` is - dialect-neutral (`-e` flags plus the raw command); the POSIX `TmuxMultiplexer` - leaf adds a literal `/bin/sh -c` prelude that records the launched pid for the - relays' hook lineage (DW-507) and then execs `$SHELL -c `. A leaf on - the base that does not record `BMAD_LOOP_LAUNCH_PID` gets `unknown` lineage, - which attribution ignores (psmux; with no `/proc` there, its lineage reads - `unknown` anyway). -- **Implement `TerminalMultiplexer` fresh** when the host has no tmux-shaped CLI - at all (e.g. a ConPTY-based window manager). You implement the full contract - directly; `tmux_backend.py` is the reference for what each method must produce. - The reference worked example is the external **herdr adapter** +- **Extend `BaseTmuxBackend`** (`adapters/tmux_base.py`) for a **tmux-family** backend. + The base holds every argv construction and routes every spawn through `_run(argv, *, +check=..., env=...)`. A leaf sets class attributes rather than overriding methods: + `_BINARY` (the binary every spawn, `PATH` probe and client verb uses), `_CLIENT_FLAGS`, + `_ENCODING`, `_ERRORS`, and the session/window-gone stderr markers. It overrides `_run` + only to wrap the spawn (psmux binds its registry root there). A non-POSIX leaf also + overrides the shell-dialect hooks that `new_window` and `new_parked_window` compose from + (`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`, `_window_launch`, and + the `_EXIT_CAPTURE` / `_ECHO` / `_PARK` fragments), plus `pipe_pane`, whose POSIX + `cat >>` sink has no hook. Never edit `tmux_base.py` or its POSIX leaf `tmux_backend.py` + for a new leaf. + + The base `_window_launch` is dialect-neutral (`-e` flags plus the raw command). The POSIX + `TmuxMultiplexer` leaf adds a `/bin/sh -c` prelude that exports its pid as + `BMAD_LOOP_LAUNCH_PID` for the relays' hook lineage, then execs `$SHELL -c `. A + leaf that does not set `BMAD_LOOP_LAUNCH_PID` (psmux does not) gets `unknown` lineage, + which attribution ignores. + +- **Implement `TerminalMultiplexer` fresh** when the host has no tmux-shaped CLI. You + implement the full contract; `tmux_base.py` shows what each method must produce. The + worked example is the herdr adapter ([pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr), - `src/bmad_loop_adapter_herdr/backend.py`) — a cross-platform, agent-aware - workspace manager whose object model (workspace/tab/pane) and CLI are a - different binary family entirely, so it subclasses nothing and maps the whole - contract onto herdr verbs: a bmad-loop session is a herdr **workspace** (label - == session name), a window is a **tab** (its `root_pane.pane_id` is the native - window id), and the launched command runs via a typed `exec ` so - process-exit stays tmux-identical window death. Where herdr has no analogue - for a contract method — options, `pipe_pane`, the parked-window return hop, - detach — it emulates or degrades honestly (a JSON sidecar for options, a - polling tee for `pipe_pane`, a per-window return file for the parked trailer, - a no-op detach); that **degradation ledger** is the module docstring, and it - is the template for what "implement fresh" costs in practice. (The - operator-facing view of those degradations — what a herdr _user_ notices and - does — is - [the adapter's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md).) - -`available()` gates whether the backend is usable on the current host (e.g. its -binary is on PATH); the optional `version()` feeds `bmad-loop mux`, the diagnostic -dump, and the validate preflight (seam 4). - -**`version()` returns one bounded line.** Every one of those consumers renders it -inline — a table row whose width sets every other row's, a finding message, a -scalar `--json` field — so a binary whose `--version` prints several lines (psmux -prints a `tmux X.Y.Z` compatibility line plus its own) folds them in the backend, -and a very long single line breaks the same surfaces a newline does. Use -`fold_version()` from `adapters/multiplexer.py`: it joins the non-blank lines with -`"; "` **in order**, caps the result at `VERSION_MAX_CHARS`, and returns `None` — -the "no version" sentinel, never `""` — for an all-blank probe. Order is -load-bearing wherever something parses the string: psmux's version gate anchors at -the first segment, and the cap only ever cuts the tail. Core applies the same fold -defensively at each consumer, so breaking the promise cannot split a `mux` row — -but fold at the source, since only the backend knows which line identifies it. + `src/bmad_loop_adapter_herdr/backend.py`): a bmad-loop session is a herdr **workspace**, + a window is a **tab** (its root pane id is the native window id), and the command runs via + `exec ` so process exit closes the tab. Where herdr has no analogue (options, + `pipe_pane`, the parked-window return hop, detach) it emulates or degrades, and its + module docstring lists each case. + +`available()` gates whether the backend is usable on this host; `version()` feeds +`bmad-loop mux`, the diagnostic dump and the validate preflight. + +**`version()` returns one bounded line.** Each consumer renders it inline (a table row, a +finding message, a scalar `--json` field). Fold multi-line output with `fold_version()` from +`adapters/multiplexer.py`: it joins non-blank lines with `"; "` in order, caps the result at +`VERSION_MAX_CHARS`, and returns `None` (never `""`) for a blank probe. Put the identifying +version in the first segment; a version gate parses from the front, and the cap cuts the +tail. ### Availability discriminators (same-platform backends) -Selection consults `available()`, so it must be a **cheap, side-effect-free -probe** — PATH lookups, plus at most one bounded version query when usability -genuinely depends on the installed version — and `factory()` must be a plain -constructor: `detect_multiplexers()` instantiates every registered backend just -to list it. When two backends claim the same platform, their `available()` -probes should be **pairwise discriminating** — otherwise both report usable and -only the platform default / registration order separates them in listings and -selection. The bundled psmux backend discriminates by construction: it drives -psmux's distinctly-named binary (`_BINARY = "psmux"`), so it never claims some -other tmux-family install that owns the `tmux` name. Its probe also -version-gates — psmux 3.3.8 or newer is required -([why](multiplexer-backends.md#psmux-native-windows-experimental)), so an old or unidentifiable -version reads as unavailable (`psmux -V` keeps the `tmux X.Y.Z` output format -deliberately): +Selection calls `available()`, so it must be a **cheap, side-effect-free probe**: `PATH` +lookups plus at most one bounded version query when usability depends on the installed +version. psmux is the example: it requires `psmux` and `pwsh` on `PATH` and a minimum psmux +version ([requirements](multiplexer-backends.md#psmux-native-windows-experimental)), and +caches the verdict per instance. -```python -class PsmuxMultiplexer(BaseTmuxBackend): - _BINARY = "psmux" +When two backends claim the same platform, their `available()` probes should be **pairwise +discriminating**; otherwise both report usable and only the platform default or +registration order separates them. psmux discriminates by construction: it drives its own +`psmux` binary, so it never claims another install that owns the `tmux` name. A +hypothetical sibling that owns `tmux` on Windows would discriminate explicitly: - def available(self) -> bool: - if not all(shutil.which(exe) for exe in ("psmux", "pwsh")): - return False - reported = re.match(r"tmux (\d+)\.(\d+)(?:\.(\d+))?", self.version() or "") - return bool(reported) and tuple(int(part or 0) for part in reported.groups()) > (3, 3, 7) - -# a sibling that owns the `tmux` name (e.g. a tmux-windows port) discriminates -# against psmux explicitly: +```python class WindowsTmuxMultiplexer(BaseTmuxBackend): def available(self) -> bool: return shutil.which("tmux") is not None and shutil.which("psmux") is None ``` -Do **not** inherit `BaseTmuxBackend.available()` (a bare `which` on `_BINARY`) -for a same-platform sibling that shares a binary name: selection would still -break the tie via the platform default, but `bmad-loop mux` and the validate -preflight would list both as available when only one actually drives the -installed binary. A host with an ambiguous install resolves it explicitly: +Do not inherit `BaseTmuxBackend.available()` (a bare `which` on `_BINARY`) for a +same-platform sibling that shares a binary name: `bmad-loop mux` and the preflight would +list both as available. An operator with an ambiguous install resolves it with `bmad-loop mux set `. -A backend from a **different binary family** sidesteps this problem entirely. -The external herdr adapter probes `shutil.which("herdr")` — a distinct binary -that no tmux-family backend claims — so it is **pairwise-discriminating by -construction**: it can never report available on a host where only tmux is -installed, and vice versa, without any explicit tie-break. That `available()` -must stay a pure PATH lookup (it is called by `detect_multiplexers()` on every -listing) — the herdr adapter in particular **never** probes or starts its -background server from `available()`, `version()`, or the constructor; server -autostart is lazy, confined to the mutating operations that actually need it. +A backend from a different binary family, such as herdr, discriminates by construction. Its +`available()` must stay a pure `PATH` lookup; never start a server from `available()`, +`version()` or the constructor. ### Registry namespaces (only if your transport has one) -Some multiplexers address sessions through a **registry**: a directory of -per-session addressing files that every verb resolves a target through. psmux is -one — `PSMUX_DATA_DIR`, a `.port`/`.key` set per session. tmux is not: a server -is a socket, and there is no root a caller could be pointed at. - -The distinction matters because a registry is a **namespace, not a filter**. A -session in registry A is not merely hidden from a verb aimed at registry B; it is -unaddressable from it, and the transport usually reports that as an ordinary "no -such session" rather than an error. Two processes that disagree about the root -therefore disagree about which sessions exist — and the verbs that carry that -disagreement (`has_session`, `list_window_ids`) are exactly the ones whose seam -contract says to degrade quietly. - -If your transport namespaces, four rules: - -- **Derive the root from the project**, never from the run, never from the - launching shell, and never from anything a driven session can write - (`policy.toml` and the project tree are both session-writable). bmad-loop's is - `runs.mux_registry_root` — `//_mux`, reusing - `runs.project_tag` so two spellings of one project cannot key two registries. - The derivation belongs in `runs`, beside the state root, not in the backend. -- **Bind it once, ahead of every spawn.** `cli._configure_mux` exports it before - dispatch — the last point that still knows the project and the first that - precedes every verb. A create-call-only injection is worse than doing nothing. - An ambient value needs a real question answered, not a flag. A multiplexer - hands every pane child the server's environment, so an ambient value there was - inherited rather than typed — and a process cannot tell an inherited one from a - typed one, nor a value typed in one shell from one a profile exports into every - shell. Those want opposite answers, so do not try to decide between them: derive - unconditionally, override what you find, and report that you did. A derived root - is a pure function of (project, state root), so every process agrees without - anything having to travel between them, which is the property worth protecting. -- **Carry `new_window`'s env inside the command you launch**, not in the - environment you hope the pane inherits. A multiplexer is free not to hand a - pane child the server's environment (psmux's `PSMUX_BARE_ENV=1` mode clears - it to a 14-name allowlist — a mode bmad-loop declares unsupported and warns - about, precisely because the _other_ windows ride inheritance); an env dict a - caller passed explicitly must survive regardless, and an in-command transport - is the one that does. `_window_launch` is the dialect hook that owns each - family dialect's answer; the base one passes bare `-e` flags, and a leaf - whose shell wraps the command (psmux) overrides it alongside `_shell_wrap`. -- **Answer `has_registry_namespace()`, `registry_root()` and - `legacy_registries()`.** The first tells cleanup your transport namespaces - sessions at all, so a registry with no root in force reads as the shared - default it is rather than as proof of ownership. The second lets - `bmad-loop mux` disclose the root, so an operator's own client is not silently - looking at an empty registry. The third hands cleanup instances bound to roots - your sessions may predate; each must be an independent instance, never a global - environment swap, because the sweep runs on a TUI worker thread beside other - threads issuing ordinary verbs. - -All three default to "no namespace" on `TerminalMultiplexer`, so a transport -without one inherits the right answers and writes no code. - -**Deep contract →** [adapter authoring guide: the transport contract for a backend -author](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). +Some multiplexers address sessions through a **registry**: a directory of per-session +addressing files every verb resolves a target through. psmux is one (`PSMUX_DATA_DIR`, one +`.port`/`.key`/`.sid`/`.pid` set per session). tmux is not: a server is a socket. + +A registry is a **namespace, not a filter**. A session in registry A is unaddressable from +registry B, and the transport usually reports that as an ordinary "no such session". Two +processes that disagree about the root disagree about which sessions exist. If your +transport namespaces, four rules: + +- **Derive the root from the project**, never from the run, the launching shell, or anything + a driven session can write (`policy.toml` and the project tree are both session-writable). + bmad-loop's derivation is `runs.mux_registry_root`, keyed on `runs.project_tag`; it belongs + in `runs`, beside the state root, not in the backend. +- **Bind it once, ahead of every spawn.** `cli._configure_mux` exports it before dispatch. + Derive by default and override an ambient value, reporting that you did; honouring an + ambient value is an explicit operator opt-in + ([`honor_ambient_psmux_data_dir`](multiplexer-backends.md#using-your-own-psmux_data_dir)). +- **Carry `new_window`'s env inside the command you launch**, not in the environment the + pane inherits. A multiplexer may clear a pane's inherited environment (psmux's + `PSMUX_BARE_ENV`). `_window_launch` is the dialect hook that owns this. +- **Answer `has_registry_namespace()`, `registry_root()` and `legacy_registries()`.** The + first tells cleanup the transport namespaces at all, so a registry with no root in force + reads as the shared default rather than as proof of ownership. The second lets + `bmad-loop mux` disclose the root. The third hands cleanup instances bound to older roots; + each must be an independent instance, never a global environment swap, because the sweep + runs on a TUI worker thread beside other verbs. + +All three default to "no namespace", so a transport without one writes no code. --- ## Seam 2 — process lifecycle (`ProcessHost`) -**Contract:** `ProcessHost` (`src/bmad_loop/process_host.py`) — the four pid -operations the orchestrator needs (`runs.stop_run`, the TUI liveness column), plus -the hook interpreter (seam 3). On POSIX these are `os.kill` calls; on Windows -`taskkill` / psutil. `WindowsProcessHost` already ships (unexercised until a -Windows backend lands). +**Contract:** `ProcessHost` (`src/bmad_loop/process_host.py`): the pid operations behind +`runs.stop_run` and the TUI liveness column, plus the hook interpreter (seam 3). +`PosixProcessHost` uses `os.kill`; `WindowsProcessHost` uses `taskkill` and psutil. Both are +registered in `_load_builtin_hosts`. **Register** like the multiplexer: ```python from bmad_loop.process_host import register_process_host -register_process_host("windows", lambda platform: platform == "win32", WindowsProcessHost) +register_process_host("myhost", lambda platform: platform == "myos", MyProcessHost) ``` -`get_process_host()` selects by the same rule as `get_multiplexer()`; -`BMAD_LOOP_PROCESS_HOST` forces one by name; POSIX is the default fallback. +`get_process_host()` picks the host named by `BMAD_LOOP_PROCESS_HOST`, otherwise the first +registered host whose `matches(sys.platform)` is true, otherwise POSIX. A forced name that +matches nothing raises. **Implement:** -- `terminate(pid)` — politely stop it (POSIX `SIGTERM` / Windows `taskkill`). Raise - the `OSError` family (`ProcessLookupError` / `PermissionError`) so callers keep - their "already gone / not ours" handling. This is the polite fast path, not the - stop guarantee: `bmad-loop stop` also lodges a hard `stop-request.json` the engine - reads itself, so a port whose `terminate` cannot actually be delivered still stops - runs (#319). -- `force_kill(pid)` — escalation when `terminate` is ignored (POSIX `SIGKILL` / - Windows `taskkill /F /T`). Only ever called once identity is confirmed. -- `is_alive(pid)` — read-only liveness probe, no signal sent. -- `identity(pid) -> float | None` — the **PID-reuse guard**: a value that stays - constant for the life of `pid` but changes if the pid is reused (Linux reads - `/proc//stat` start-time; elsewhere psutil's `create_time()`). Return - `None` where the platform can't provide one — callers then **refuse to - force-kill** rather than risk an unrelated process that inherited the pid. -- `hook_interpreter()` — seam 3, below. -- `unsafe_shell_chars(path)` — the shell metacharacters `shell_quote()` leaves - exposed in `path`; `init` and `validate` warn on them (DW-346). The default - `()` suits hosts that always quote; override it alongside `shell_quote()` - when the host's quoting is conditional (Windows' list2cmdline quotes only on - whitespace). +- `terminate(pid)`: polite stop (POSIX `SIGTERM`, Windows `taskkill`). The contract raises + the `OSError` family (`ProcessLookupError` / `PermissionError`) so callers keep their + "already gone / not ours" handling. `bmad-loop stop` also writes a `stop-request.json` the + engine reads, so an undeliverable `terminate` still stops runs. +- `force_kill(pid)`: escalation when `terminate` is ignored (POSIX `SIGKILL`, Windows + `taskkill /F /T`). Called only once identity is confirmed. +- `is_alive(pid)`: read-only liveness probe, no signal sent. +- `identity(pid) -> float | None`: the **PID-reuse guard**, a value constant for the life of + `pid` that changes if the pid is reused (Linux reads `/proc//stat` start time; + elsewhere psutil's `create_time()`). Return `None` where the platform cannot provide one; + callers then refuse to force-kill. +- `hook_interpreter()`: seam 3, below. + +Defaults you may override: `descendants(pid)` (pid → identity snapshot of the process tree, +never raises; psutil by default), `shell_quote(arg)` (default `shlex.quote`), and +`unsafe_shell_chars(path)` (the shell metacharacters `shell_quote()` leaves exposed; +`init` and `validate` warn on them). Override `unsafe_shell_chars` alongside `shell_quote` +when quoting is conditional (Windows' `list2cmdline` quotes only on whitespace). --- ## Seam 3 — hook interpreter -`ProcessHost.hook_interpreter()` supplies an absolute, host-quoted -`sys.executable` path for temporary Python hook scripts, such as the live probe -capture hook. Both POSIX and Windows use that interpreter directly. `init` and -worktree provisioning register the installed `bmad-loop relay ` console -script by absolute path instead of invoking a Python script in the workspace. -A new OS overrides the quoting behavior on its `ProcessHost` as needed. +`ProcessHost.hook_interpreter()` supplies an absolute, host-quoted `sys.executable` path for +temporary Python hook scripts, such as the live probe's capture hook. `init` and worktree +provisioning register the installed `bmad-loop relay ` console script by absolute +path instead of invoking a Python script in the workspace. A new OS overrides the quoting +on its `ProcessHost` as needed. --- ## Seam 4 — validate preflight -`platform_preflight(project)` (`src/bmad_loop/runsetup.py`, imported into `cli.py` as -`_platform_preflight` and called from `cmd_validate`) -asks the selected multiplexer for its `available()` / `version()` and names the -selected process host. A new OS therefore surfaces its readiness in `bmad-loop -validate` **by registering** (seams 1–2) — not by adding a `win32` block to -`validate`. The process host is named in the output so a misselection (e.g. the -Windows host picked on Linux) is visible at a glance. +`runsetup.platform_preflight(project)`, called from `cmd_validate`, asks the selected +multiplexer for `available()` / `version()`, reports the selection reason and any external +load or probe faults, and names the selected process host. A new OS surfaces its readiness +in `bmad-loop validate` **by registering** (seams 1–2), not by adding a platform block to +`validate`. There is no new code to write for this seam. -There is no new code to write for this seam — it reads seams 1 and 2. - -The one `sys.platform` branch that does live here is not a port seam and is not a -precedent for one: the `host.win32-on-wsl-path` check (#332) reports that the _interpreter -itself_ is the wrong build for the shell that launched it — a native-Windows -`bmad-loop` reached from a WSL prompt. No registration can express that, because -every seam is correctly selected for the interpreter that is running; what is wrong -is which interpreter the operator got. Readiness questions still register. +The one `sys.platform` branch here, `host.win32-on-wsl-path`, is not a port seam: it warns +when a native-Windows interpreter is working on a WSL distro path, which no registration +can express. --- ## Helper scripts (plugins) -Plugin **helper scripts** (e.g. the bundled Unity plugin's `unity_setup.py` / -`unity_teardown.py`) are spawned under the orchestrator's own interpreter via -`sys.executable`, **not** a PATH-resolved `python3`. The practical consequence: a -bundled helper script may `import bmad_loop` — so for pid lifecycle it should -**use the seam** rather than re-implement kill/liveness behind its own -`sys.platform` guards: +Plugin helper scripts (for example the bundled Unity plugin's `unity_setup.py` / +`unity_teardown.py`) run under the orchestrator's own interpreter via `sys.executable`, not a +`PATH`-resolved `python3`. A bundled helper may therefore `import bmad_loop`, and for pid +lifecycle it should use the seam rather than its own `sys.platform` guards: ```python from bmad_loop.process_host import get_process_host host = get_process_host() +ident = host.identity(pid) host.terminate(pid) -if host.is_alive(pid): +if host.is_alive(pid) and ident is not None and host.identity(pid) == ident: host.force_kill(pid) ``` -**Worked example:** `data/plugins/unity/unity_teardown.py` now _delegates_ its -SIGTERM→SIGKILL sweep of leaked Editor / MCP-server processes to -`get_process_host()` instead of calling `os.kill` / `signal.SIGKILL` itself — so it -gains Windows behavior for free when a Windows host registers. (It still does its -own worktree-bound process _discovery_ via `/proc` with a psutil fallback, because -discovery has no seam — see the next section.) +`data/plugins/unity/unity_teardown.py` routes its terminate/force-kill sweep through +`get_process_host()`. Process discovery has no seam, so it scans `/proc` on Linux and falls +back to psutil elsewhere. -> Out-of-tree plugin scripts distributed outside this repo can't assume `bmad_loop` -> is importable in every install; the import path is reliable for **bundled** -> scripts spawned under `sys.executable`. +> Out-of-tree plugin scripts can't assume `bmad_loop` is importable in every install; the +> import is reliable only for bundled scripts spawned under `sys.executable`. --- ## The portability guard -`tests/test_portability_guard.py` AST-scans `src/bmad_loop` and fails CI if a new -hard POSIX dependency creeps in outside an allowlist — a `["tmux", …]` argv outside -the tmux backend, a bare `os.kill(pid, 0)` outside the liveness helpers, an -unguarded `signal.SIGKILL`, a hardcoded `/tmp` / `/proc` / `/dev/null`, -`start_new_session=True`, or `shell=True`. When you add a seam, route the OS call -**through** it rather than widening an allowlist; the few sanctioned exceptions -(the quarantine files, the platform-guarded discovery helpers) carry a -`# portability:` ack on the line. - -Things **without** a seam still need a hand-guarded fallback behind a -`sys.platform` branch with that ack: `cp --reflink` / CoW copies, symlinks, -`/proc` scanning, `/tmp`, and `start_new_session`. Keep the Linux fast path -byte-identical; the new-OS branch can be best-effort until exercised. -Hook-event lineage (DW-507) is one such `/proc` reader with no seam: both relays -walk the parent chain through Linux `/proc` only, so on any other OS every event -is tagged `unknown` and attribution keeps only the #767 rules. +`tests/test_portability_guard.py` AST-scans `src/bmad_loop` and fails CI when a hard POSIX +dependency appears outside an allowlist: a `["tmux", …]` argv outside the tmux backend, a +bare `os.kill(pid, 0)` outside the liveness helpers, any `os.kill(` outside +`process_host.py`, an unguarded `signal.SIGKILL`, a hardcoded `/tmp` / `/proc` / +`/dev/null`, `start_new_session=True`, or `shell=True`. When you add a seam, route the OS +call through it rather than widening an allowlist. Allowlisted lines carry a +`# portability:` ack; the tmux quarantine files (`tmux_base.py`, `tmux_backend.py`) are +exempt from the tmux-argv rule only; every other rule still applies to them. + +Things **without** a seam need a hand-guarded fallback behind a `sys.platform` branch with +that ack: `cp --reflink` / CoW copies, symlinks, `/proc` scanning, `/tmp`, and +`start_new_session`. Keep the Linux fast path byte-identical; the new-OS branch may be +best-effort. Hook-event lineage is one such `/proc` reader: both relays (`events.py`, +`data/bmad_loop_hook.py`) walk the parent chain through `/proc`, so on an OS without it +every event is tagged `unknown` and attribution ignores lineage. --- ## Path predicates — the guard family a port must not relax -`src/bmad_loop/platform_util.py` carries four predicates that every "this config -value must be a path inside the project" guard is built from. They are -**platform-independent by construction**: each answers the same way on every host, -because a config file must not mean different things depending on where the run +`src/bmad_loop/platform_util.py` carries four predicates that every "this config value must +be a path inside the project" guard is built from. They are **platform-independent by +construction**: a config file must not mean different things depending on where the run happens. -- `is_absolute_path(value)` — rooted or drive-qualified in _either_ flavour: - `/etc/passwd`, `C:\x`, `\\server\share`, and the drive-_relative_ `C:foo`. -- `has_parent_ref(value)` — a `..` segment in either flavour. -- `names_tree_root(value)` — names the tree itself rather than anything inside it: - `""`, `"."`, and the all-periods-and-spaces spellings Win32 trims to nothing - (`". "`, `"..."`, `" "`). -- `names_win32_alias(value)` — the determinism member. The other three refuse a value - that _escapes_ the tree; this one refuses a value that stays inside it and still - names a **different** path on Windows: a reserved device basename (`NUL`, - `aux.json`, `CON .txt`), a component whose trailing periods and spaces Win32 - strips (`.claude/skills.`), or a component of _nothing but_ periods and spaces - sitting beside a real one (`sub/...` — Win32 empties it and the value addresses - `sub`). - -What a porter needs to know: +- `is_absolute_path(value)`: rooted or drive-qualified in _either_ flavour: `/etc/passwd`, + `C:\x`, `\\server\share`, and the drive-relative `C:foo`. +- `has_parent_ref(value)`: a `..` segment in either flavour. +- `names_tree_root(value)`: names the tree itself: `""`, `"."`, and the + all-periods-and-spaces spellings Win32 trims to nothing (`". "`, `"..."`, `" "`). +- `names_win32_alias(value)`: a value that stays inside the tree but names a **different** + path on Windows: a reserved device basename (`NUL`, `aux.json`, `CON .txt`), a component + whose trailing periods and spaces Win32 strips (`.claude/skills.`), or a + periods-and-spaces component beside a real one (`sub/...`). + +Rules for a porter: - **Do not make any of them consult `sys.platform`.** A value refused here is refused - everywhere, deliberately — `skill_tree = "NUL"` is rejected on Linux for the same - reason `C:\secrets` is. The alternative turns a `seed_files` entry into a - build-number question, since Windows 11 narrowed the device rule and Windows 10 did - not. -- **They refuse disjoint spelling classes, and that is load-bearing.** - `names_tree_root` carves out `..` for `has_parent_ref`; `names_win32_alias` carves - out a _value_ made entirely of period/space components for `names_tree_root` (a - single such component beside a real one stays its own — it aliases its parent, not - the root) and the `.`/`..` components for their owners. Those carve-outs are what - let each predicate be ablated on its own — a "simplification" that merges them - costs the suite its ability to say which rule fired. -- **`_is_reserved_basename` is a _segment_ predicate**, blind to `sub/NUL`: it splits - on the first dot of the whole string. Apply it per component, after splitting on - both separators, or it silently answers False. -- **`safe_segment` is not one of them.** It maps `/` to `_`, so it sanitizes a single - name (a run id, a sweep bundle name) and must never be applied to a multi-segment - config path. -- **Their Win32 half is cited, not measured** — CI's legs are Linux and nothing here - calls a Win32 API. A native-Windows port is the first chance to measure it; the - docstrings carry their sources (Microsoft, Wine's ntdll path conformance tests, - Project Zero) so a disagreement can be settled rather than argued. -- `src/bmad_loop/data/plugins/unity/unity_seed_assets.py` re-implements all four by - hand: it is deployed into a consumer project, is stdlib-only, and is excluded from - pyright. Its only drift guard is the parity suite in - `tests/test_unity_scene_guard.py`. Mirror any change there too. + everywhere: `skill_tree = "NUL"` is rejected on Linux for the same reason `C:\secrets` is. +- **They refuse disjoint spelling classes.** Each predicate carves out the others' cases, so + each can be ablated on its own. Do not merge them. +- **`_is_reserved_basename` is a _segment_ predicate**, blind to `sub/NUL`. Apply it per + component, after splitting on both separators. +- **`safe_segment` is not one of them.** It maps `/` to `_` to sanitize a single name (a run + id, a sweep bundle name); never apply it to a multi-segment config path. +- **Their Win32 half is cited, not measured.** The docstrings carry their sources so a + disagreement measured on a real Windows host can be settled. +- `src/bmad_loop/data/plugins/unity/unity_seed_assets.py` re-implements all four by hand + (it is deployed into a consumer project, stdlib-only, and excluded from pyright). Its only + drift guard is the parity suite in `tests/test_unity_scene_guard.py`; mirror any change + there. --- ## Testing a port -Both `get_multiplexer()` and `get_process_host()` are `lru_cache`d, and selection -keys off `sys.platform`. To exercise a not-yet-default backend on your dev box: +`get_multiplexer()` and `get_process_host()` are `lru_cache`d, and selection keys off +`sys.platform`. To force a registered backend or host on a dev box: -| To force… | Set | Then clear the cache | -| -------------- | -------------------------------- | -------------------------------- | -| a multiplexer | `BMAD_LOOP_MUX_BACKEND=psmux` | `get_multiplexer.cache_clear()` | -| a process host | `BMAD_LOOP_PROCESS_HOST=windows` | `get_process_host.cache_clear()` | +| To force… | Set | Then clear the cache | +| -------------- | ------------------------------- | -------------------------------- | +| a multiplexer | `BMAD_LOOP_MUX_BACKEND=` | `get_multiplexer.cache_clear()` | +| a process host | `BMAD_LOOP_PROCESS_HOST=` | `get_process_host.cache_clear()` | -The env var picks the registered backend by `name`; `cache_clear()` is required -because the first call memoizes the selection for the process. To make a -multiplexer choice stick across invocations instead, persist it with -`bmad-loop mux set ` (writes `[mux] backend` into the machine-local -policy.toml; the env var still outranks it, and `configure_multiplexer` / -`register_multiplexer` clear the cache themselves). +`cache_clear()` is required because the first call memoizes the selection for the process. +`register_multiplexer`, `register_process_host` and `configure_multiplexer` clear the cache +themselves. To make a multiplexer choice persist, use +[`bmad-loop mux set `](multiplexer-backends.md#which-backend-is-running-and-how-to-switch). --- -## What a native-Windows port costs, end to end - -Concretely, a native-Windows port is: - -1. `PsmuxMultiplexer(BaseTmuxBackend)` (or a fresh `TerminalMultiplexer`) + - its `register_multiplexer("psmux", …)`. -2. `WindowsProcessHost` — **already shipped** — needs only its registration, which - is **already present** in `_load_builtin_hosts`. Its `hook_interpreter()` - (an absolute, host-quoted interpreter path) is in place too. -3. A CI runner on Windows to exercise the above. +## Reference port: native Windows -No edits to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`, -`cli.py`'s `validate`, or the POSIX seam bodies. The remaining design questions -(what hosts the windows, how attach/detach and the parked exit-status window map -without a POSIX shell, the Windows-Unity cache-path follow-up) are tracked in +The native-Windows port is `PsmuxMultiplexer` (`adapters/psmux_backend.py`) on +`BaseTmuxBackend`, plus `WindowsProcessHost` (`process_host.py`), both registered in their +`_load_builtin_*` loaders. Its registry namespace adds `runs`/`cli` support for the +per-project registry root. The Windows CI legs and the psmux live gate +(`tests/test_psmux_live.py`) exercise it. Remaining work is on [the roadmap](ROADMAP.md#native-windows-multiplexer-backend). diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 125abb68f..600419563 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3140,11 +3140,12 @@ def live_session_may_be_ours( so it reads as untagged and the refusal stands — by then the listing has already established that a session is live. - Both reads are caught explicitly because the seam permits a raise: only - `pipe_pane` and `kill_session` are contractually best-effort, so an - out-of-tree backend raises :class:`MultiplexerError` here where the bundled - one returns empty (docs/adapter-authoring-guide.md). The listing is checked - first, so the tag query only runs on a name collision. + Both reads are caught explicitly because the seam permits a raise: neither + `list_sessions` nor `session_options` is contractually best-effort, so an + out-of-tree backend may raise :class:`MultiplexerError` here where the bundled + one returns empty (docs/adapter-authoring-guide.md, "The transport contract + for a backend author"). The listing is checked first, so the tag query only + runs on a name collision. A stronger shape was built and withdrawn: a proof discipline (block unless the transport *proves* the session absent) fell to four consecutive reviews, From e18f85c6711b058551587b88969060d7db151a74 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 02:54:13 -0700 Subject: [PATCH 7/9] docs(tui,testing): rewrite the TUI guide and condense the testing guide tui-guide.md: keep the layout -> keys -> actions -> troubleshooting structure and the key table (adds the escape/unpin-log binding); drop tracker tags, changelog residue and journal session-crumb detail; replace the hand-maintained settings table with a link to the generated configuration reference; link sweep-guide and escalations-guide for decision and resolve semantics. A verify workflow (5 agents) checked every section against src/bmad_loop/tui/; its 23 findings are fixed (stopped/crashed statuses, awaiting-operator glyphs, banner wording, 256 KB log window, launch-guard order, skip ends the decision walk, resolve prompt, plugin grant states, stale troubleshooting message). testing.md: one-line quality-guard rows (plus the docs hygiene guard), tracker refs and history stripped, a "Premise before outcome" section, the real fixture inventory and pyright strict list. Guard motivations missing from test docstrings moved into them (check-id registry, skill-drift, probe hook, release, reap-wall scope, extra-less CLI), correcting docstrings that overclaimed. --- docs/testing.md | 651 +++++++-------- docs/tui-guide.md | 1196 +++++++++++---------------- src/bmad_loop/checks.py | 9 +- src/bmad_loop/tui/screens/modals.py | 4 +- tests/test_cli.py | 6 +- tests/test_conftest.py | 4 +- tests/test_entry_point.py | 8 +- tests/test_hook_script.py | 4 +- tests/test_module_skills_sync.py | 7 +- tests/test_probe_hook.py | 4 +- tests/test_release.py | 5 +- 11 files changed, 771 insertions(+), 1127 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 32349dfcc..3eba796c5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,7 +2,7 @@ How bmad-loop is tested: the layer model, the doctrines behind the suite's shape, and the policies a new test is expected to follow. [AGENTS.md](../AGENTS.md) owns the terse testing -invariants agents must not violate; this document is the strategy behind them — why each rule +invariants agents must not violate; this document is the strategy behind them: why each rule exists, where a new test belongs, and which absences are decisions rather than gaps. When the two disagree, fix this document first and re-derive the terse rule (see [Doc-sync rules](#policy-quick-reference-and-doc-sync-rules)). @@ -12,68 +12,46 @@ and every negative assertion proven by ablation.** ## The suite at a glance -- Flat `tests/` — roughly 85 `test_*.py` files mirroring `src/bmad_loop` modules by name, - ~13,000 collected tests. No package nesting, one `tests/conftest.py`, one data file - (`tests/fixtures/stories.yaml`, the dogfooded stories-mode contract from BMAD-METHOD - PR #2549). +- Flat `tests/`: `test_*.py` files mirroring `src/bmad_loop` modules by name, no package + nesting, one `tests/conftest.py`, and one data file (`tests/fixtures/stories.yaml`, the + stories-mode contract). - Configuration is four settings under `[tool.pytest.ini_options]` in `pyproject.toml`: `testpaths = ["tests"]`, `asyncio_mode = "auto"`, `addopts = "--dist loadgroup"`, and - `filterwarnings` (next bullet). The `addopts` line is a _scheduling_ declaration and nothing - else (DW-95): it selects xdist's loadgroup scheduler so the `xdist_group` marks on the - real-tmux E2Es actually bite, because under the default `load` scheduler xdist ignores those - marks silently. It lives here rather than in the CI workflow so a local `-n logical` run - schedules exactly the way CI does, and `--dist` is accepted with or without `-n`. It does - make pytest-xdist a hard prerequisite for every pytest invocation — without it a run aborts - before collection with `unrecognized arguments: --dist`, naming neither xdist nor this - setting — but xdist is already a `dev`-group dependency, so only a `pytest` from outside the - uv environment hits that. There is still no marker registry. -- **Deprecations are errors** (DW-370): `filterwarnings` carries `error::DeprecationWarning` - and `error::PendingDeprecationWarning`, so a new deprecation — first-party, a dependency's, - or one a new Python leg introduces — fails the test that triggers it instead of adding a - line to a warnings summary nobody reads while the matrix stays green. pytest's own - `PytestDeprecationWarning` is a `DeprecationWarning` subclass and errors too. A test that - _expects_ one says so with `pytest.warns(DeprecationWarning)` (the legacy `[engine]` policy - table in `test_policy.py` is the example). Triage found no third-party offender on the - Linux py3.11–3.14 legs, so there are no ignore entries today. A dependency warning we cannot - fix gets a targeted entry appended after the two `error::` lines (the last matching entry - wins) — `ignore::DeprecationWarning:` — never a blanket category - ignore. The filter governs only the pytest process: subprocesses a test spawns get the - interpreter default. The pin is `test_pyproject_filterwarnings_turns_deprecations_into_errors` - (`test_conftest.py`), which checks both that the entry is loaded and that a warning emitted - in a test raises. -- `uv run pytest -q` runs everything runnable on the host; `-n logical` (pytest-xdist) is - supported because every test builds its state from per-test fixtures (the template repo is - per-worker). Prefer `logical` over `auto`: `psutil` is installed on every platform here - (the `non-linux` extra carries no environment marker, and CI syncs `--all-extras`), so - xdist's `auto` resolves to the _physical_ core count — half the vCPUs on the hosted - runners. CI passes `-n logical` in both test jobs for that reason. A test that fails only - under xdist load is a flake, which is a bug. Live/E2E modules skip themselves when their - host requirements are absent — selection is in-file, never in config. -- Only builtin pytest marks appear — `parametrize`, `skipif`, `usefixtures`, and - `xfail(strict=True)` only to pin a known defect (none today, see - [Ablation records](#ablation-records)) — plus `xdist_group`, which pytest-xdist registers itself. **No custom markers, deliberately**: - a marker registry is a second selection mechanism that can drift from the tests it - classifies. The filename suffix and the module-level `skipif` encode the same fact where the - reader already is. `xdist_group` needs no registry entry because pytest-xdist registers the - marker itself, so it cannot drift out of sync with a registry this suite maintains. It IS - selectable — `pytest -m xdist_group tests/test_generic_tmux.py` collects 8 of 261 — but the - suite never selects on it: what it classifies is _scheduling_, not a test category anything - chooses by, and no run, workflow or doc keys off it. It appears on exactly one population, - the real-tmux E2Es, all of which take it from the single `real_mux_e2e` alias in - `tests/conftest.py`; a guard (below) refuses any that do not. + `filterwarnings`. +- **`--dist loadgroup` is a scheduling declaration.** It makes the `xdist_group` marks on the + real-tmux E2Es bite; under xdist's default `load` scheduler those marks are silently ignored. + It lives in `addopts` so a local `-n logical` run schedules exactly as CI does. It makes + pytest-xdist (a `dev`-group dependency) a prerequisite: a `pytest` from outside the uv + environment aborts with `unrecognized arguments: --dist`. +- **Deprecations are errors.** `filterwarnings` carries `error::DeprecationWarning` and + `error::PendingDeprecationWarning`, so a new deprecation fails the test that triggers it. A + test that expects one uses `pytest.warns(DeprecationWarning)`. A dependency warning we cannot + fix gets a targeted `ignore::DeprecationWarning:` entry after the two + `error::` lines, never a blanket category ignore. The filter covers only the pytest process. +- `uv run pytest -q` runs everything runnable on the host. `-n logical` (pytest-xdist) is + supported because every test builds its state from per-test fixtures. Prefer `logical` over + `auto`: with `psutil` installed, `auto` resolves to the physical core count, half the vCPUs + on hosted runners. A test that fails only under xdist load is a flake, which is a bug. + Live/E2E modules skip themselves when their host requirements are absent. +- **No custom markers, deliberately.** Only builtin marks appear (`parametrize`, `skipif`, + `usefixtures`, `xfail(strict=True)` to pin a known defect) plus `xdist_group`, which + pytest-xdist registers itself. A marker registry is a second selection mechanism that can + drift from the tests it classifies; the filename suffix and the module-level `skipif` encode + the same fact. `xdist_group` classifies scheduling, not a test category, and appears only on + the real-tmux E2Es, all through the single `real_mux_e2e` alias in `tests/conftest.py`. ## Layers: where a test belongs New behavior lands with a test at the **lowest layer that can catch its regression**. The layers, from cheapest to most expensive: -| Layer | What it is | Machinery | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **L1 — pure core** | Unit tests over the pyright-strict seven: `model`, `statemachine`, `policy`, `documents`, `machine`, `checks`, `sanitize`. No process, git, or mux I/O; fixtures limited to `tmp_path`/`monkeypatch`. | Plain asserts; exhaustive `parametrize` grids | -| **L2 — seam** | Tests that drive one injection seam with a scripted double. | `MockAdapter`, TOML adapter profiles, the `force_tmux_backend` fixture, the injectable `make_adapters` and engine-class parameters of `runsetup.compose_run` (`engine_cls`, `stories_engine_cls`), `compose_sweep` (`sweep_engine_cls`), and `compose_resume` (all three) | -| **L3 — sandbox integration** | Engine/CLI flows over a real git repo, no multiplexer. | The `project` fixture (a copied template repo) plus the conftest effect helpers | -| **L4 — real-mux integration** | A real tmux server drives a scripted shell script standing in for the CLI. | The `HAVE_TMUX`-gated tests in `tests/test_generic_tmux.py` | -| **L5 — live gates** | Real external binaries, still zero tokens. | `tests/test_stories_e2e.py`, `tests/test_opencode_live.py`, `tests/test_psmux_live.py` | +| Layer | What it is | Machinery | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **L1 — pure core** | Unit tests over the pyright-strict modules: `model`, `statemachine`, `policy`, `documents`, `machine`, `checks`, `sanitize`, `plugins/digest`, `plugins/grants`. No process, git, or mux I/O; fixtures limited to `tmp_path`/`monkeypatch`. | Plain asserts; exhaustive `parametrize` grids | +| **L2 — seam** | Tests that drive one injection seam with a scripted double. | `MockAdapter`, TOML adapter profiles, the `force_tmux_backend` fixture, the injectable `make_adapters` and engine-class parameters of `runsetup.compose_run` (`engine_cls`, `stories_engine_cls`), `compose_sweep` (`sweep_engine_cls`), and `compose_resume` (all three) | +| **L3 — sandbox integration** | Engine/CLI flows over a real git repo, no multiplexer. | The `project` fixture (a copied template repo) plus the conftest effect helpers | +| **L4 — real-mux integration** | A real tmux server drives a scripted shell script standing in for the CLI. | The `HAVE_TMUX`-gated tests in `tests/test_generic_tmux.py` | +| **L5 — live gates** | Real external binaries, still zero tokens. | `tests/test_stories_e2e.py`, `tests/test_opencode_live.py`, `tests/test_psmux_live.py` | Placement rules: @@ -82,193 +60,164 @@ Placement rules: `plugins/loader.py` → `test_plugin_loader.py`), and adapter internals are covered through their transport suites. Cross-module flows live with the module that owns the behavior under assertion (engine flows in `test_engine.py`, CLI contract in `test_cli.py`). A module without - a mirror file is a smell the gap register tracks — five top-level modules currently lack one - (see the register's mirror-gap rows; #545 tracks the sharpest, `machine.py`). + a mirror file is a smell; see [Gaps and deviations](#gaps-and-deviations). - **Suffix convention is the selection mechanism.** `*_e2e.py` and `*_live.py` name the modules - with host requirements; each carries a module-level - `pytestmark = pytest.mark.skipif(...)` naming its requirement (`HAVE_TMUX`, `HAVE_OPENCODE`, - `HAVE_PSMUX`; the opencode module adds an `importorskip` on httpx, and its `HAVE_OPENCODE` is - probe-backed rather than `which`-backed — conftest's `opencode_runs()` runs the binary's - `--version`, so a resolvable-but-dead shim skips instead of failing, #294). Ordinary runs collect - them and skip them; a capable host runs them with no extra flags. Do not add a marker, an env-var opt-in, or a separate pytest invocation for - these — the filename plus the in-file gate is the whole mechanism. + with host requirements; each carries a module-level `pytestmark = pytest.mark.skipif(...)` + naming its requirement (`HAVE_TMUX`, `HAVE_OPENCODE`, `HAVE_PSMUX`). `HAVE_OPENCODE` runs the + binary's `--version`, so a resolvable but dead shim skips instead of failing. Ordinary runs + collect and skip them; a capable host runs them with no extra flags. Do not add a marker, an + env-var opt-in, or a separate pytest invocation for these. - **Prefer a lower layer over a broader one.** If a defect is expressible as a pure-core case, do not pin it in a sandbox flow: L3+ tests are slower, and their failures triangulate worse. - The inverse also holds — a seam contract (say, adapter timeout instrumentation) cannot be + The inverse also holds: a seam contract (say, adapter timeout instrumentation) cannot be proven by an L1 test and belongs at L2 with a parity twin (see [Duplicate tests and contract parity](#duplicate-tests-and-contract-parity)). ## Fixtures and helpers -`tests/conftest.py` holds **fixtures for lifecycle and isolation only — currently exactly -five** — and everything else is a plain function or constant a test imports by name: - -- `project` — the workhorse: a disposable copy of a session-scoped template repo - (`_project_template`), BMAD-shaped artifact dirs plus an initial commit. Never hand-roll a - temp repo and never touch the template — a mutation there poisons every later test in the - worker; the copy exists so per-test git init/fsync cost is paid once per xdist worker. -- `force_tmux_backend` — pins `BMAD_LOOP_MUX_BACKEND=tmux` and clears the selection cache on - both ends, so tests asserting tmux argv through the mux seam cannot be hijacked by an - installed external backend that happens to match the platform. -- `_project_template` — session-scoped, built once per xdist worker, reached only through - `project`. -- `_isolate_ambient_git_ignores` — session-scoped and **autouse**: it wraps every test, not - just sandbox ones, shadowing the developer's global gitignore sources (`GIT_CONFIG_GLOBAL`, - `XDG_CONFIG_HOME`). Without it, a dev box that globally ignores `.claude/` makes shield - tests measure the wrong thing while passing. -- `_isolate_state_root` — **autouse** and function-scoped: points `BMAD_LOOP_STATE_DIR` at a - per-test temp dir. `runs.state_root()` does not merely read the user-scoped state location, - it mkdirs into it, so without this every test that builds an adapter would litter the - developer's (or the runner's) real state directory with one tree per run id, on a path no - fixture cleans up. - -Everything else is a **plain helper — a function or constant** (`from conftest import -write_spec, dev_effect, machine_json, ...`). The rule is deliberate: a fixture is ambient — its consumers are invisible -at the call site and to grep — so it is reserved for setup/teardown that must wrap the test. -Construction and simulation (`write_spec`, `write_sprint`, `dev_effect`, `review_effect`, -`escalated_run`, ...) are explicit imports, so each test names its dependencies. Local fixtures -inside a single test file are fine for file-scoped concerns. - -Two helpers carry suite-wide contracts and are worth knowing before writing any CLI or engine -test: - -- `machine_json(argv, capsys, *, rc=0, err_contains=None)` — runs a `--json` command and parses +`tests/conftest.py` holds **fixtures for lifecycle and isolation only**; everything else is a +plain function or constant a test imports by name. The fixtures: + +- **Sandboxes.** `project` is the workhorse: a disposable copy of a session-scoped template + repo (`_project_template`, built once per xdist worker) with BMAD-shaped artifact dirs and an + initial commit. Never hand-roll a temp repo and never touch the template: a mutation there + poisons every later test in the worker. `project_tree` is the same file tree with no Git, + opted into only by tests whose code path never consults a repository. +- **Backend pins.** `force_tmux_backend` and `force_psmux_backend` pin the transport backend + by name and clear the selection cache, so tests asserting backend argv cannot be hijacked by + the host's default or an installed external backend. +- **Autouse isolation.** `_isolate_ambient_git_ignores` (session-scoped) shadows the + developer's global gitignore sources (`GIT_CONFIG_GLOBAL`, `XDG_CONFIG_HOME`), so a dev box + that globally ignores `.claude/` cannot make shield tests measure the wrong thing. + `_isolate_state_root` points `BMAD_LOOP_STATE_DIR` at a per-test directory (allocated by + `_state_root_allocator`), because `runs.state_root()` creates directories in the real state + location. `_isolate_mux_registry` keeps the psmux registry root and the inside-a-pane marker + out of the cross-test environment. +- **Per-test helpers that need teardown.** `emulate_windows_newlines` makes `Path.write_text` + translate newlines as Windows does, so a "no CRLF" assertion can fail on Linux. + `reap_leftovers` force-kills pids a failing row may have leaked. + +Everything else is a **plain helper** (`from conftest import write_spec, dev_effect, +machine_json, ...`). A fixture is ambient, invisible at the call site and to grep, so it is +reserved for setup and teardown that must wrap the test. Construction and simulation helpers +are explicit imports, so each test names its dependencies. Local fixtures inside a single test +file are fine for file-scoped concerns. + +Two helpers carry suite-wide contracts: + +- `machine_json(argv, capsys, *, rc=0, err_contains=None)` runs a `--json` command and parses the **whole** of stdout. Parsing the full stream is itself the assertion that nothing but the - document was printed (the `machine.py` purity contract); the default `err == ""` is the strict - form, and `err_contains` is an opt-in to a different assertion, never a waiver. -- `dev_effect` / `review_effect` and their bundle twins — simulate what a real skill session - does to disk (spec frontmatter flips, code edits) and, just as deliberately, what it does - **not** do: `dev_effect` never writes sprint-status — the orchestrator is the single - sprint-status writer at dev time, and a test double that helpfully did both would mask a - regression. (`review_effect` does advance the board when it finalizes, modeling what real - review sessions do and the orchestrator re-verifies; the bundle twins write none, because - bundles have no sprint-status entry.) + document was printed (the `machine.py` purity contract); `err == ""` is the default, and + `err_contains` opts into a different assertion, never a waiver. +- `dev_effect` / `review_effect` and their bundle twins simulate what a real skill session does + to disk, and just as deliberately what it does **not** do: `dev_effect` never writes + sprint-status, because the orchestrator is the single sprint-status writer at dev time. + `review_effect` advances the board when it finalizes, as real review sessions do; the bundle + twins write none, because bundles have no sprint-status entry. Divergent-root tests choose a topology for the distinction they need to prove: - **Default** (`project`) keeps `project == repo_root`. Use it for ordinary sandbox behavior, never for an assertion that claims to distinguish those roots. -- **Sibling** places `repo_root` beside or otherwise outside the BMAD project. Lower-level seam - rows may hand-build this shape (`test_verify.py::_repo_root_override`); caller/config coverage - writes it with `write_repo_root_override` and reloads it through `load_paths`. Use it to prove - that code commands run in the configured root while artifact reads stay in the project. - Artifact-derived excludes correctly collapse to `()` from the disjoint code tree; a non-empty - project-relative spelling may still match nothing there, so this shape cannot grade pathspec - selection by outcome alone. -- **Nested monorepo** (`nested_repo_root_paths`) puts the BMAD project at `/app`, writes - and commits `_bmad/bmm/config.yaml`, and returns `load_paths(app)`. Use it when both right- - and wrong-root pathspecs must be non-empty and separable: the correct value carries `app/`, - while the wrong value can select a plausible outer-tree decoy. The helper accepts alias - spellings but returns canonical paths and commits every seed/config file, so fixture residue - cannot masquerade as session work. +- **Sibling** places `repo_root` outside the BMAD project (`write_repo_root_override`, reloaded + through `load_paths`). Use it to prove that code commands run in the configured root while + artifact reads stay in the project. It cannot grade pathspec selection by outcome alone. +- **Nested monorepo** (`nested_repo_root_paths`) puts the BMAD project at `/app`. Use it + when both right- and wrong-root pathspecs must be non-empty and separable. - **Isolated worktree** adds a third live root. Use a real relative command against a marker - created only in the mounted unit worktree; assert the main checkout lacks it, the command - record passed, and classification received the same mounted cwd. Keep the marker under a - gitignored generated-state path so it cannot merge back as proof of work. - -Every divergent-root row guards its premise before its outcome: compare resolved roots, assert -the nested parent relation when nesting matters, and for cwd tests plant/probe both directions -(`plant_root_markers`, `REPO_ROOT_MARKER_CMD`, `PROJECT_MARKER_CMD`). A positive marker identifies -the intended root; the opposite-root marker rules out the tempting alternative. When a wrong -root could still name a real path, create that decoy and assert the selected value rather than -asserting only that some path is absent or a gate refused. - -**`MockAdapter` is production code** — `src/bmad_loop/adapters/mock.py`, shipped in the wheel, -scripted with a list of `SessionResult`s or `callable(spec) -> SessionResult` effects. It is -not reachable from configuration (no `mock` profile exists; `runsetup.make_adapters` builds -only real CLI adapters — generic-over-mux, or the opencode HTTP pair for hookless profiles), -and it exercises **the generic adapter path only** — a caveat to -respect, not to fix: adapter-specific behavior needs an L2 profile test or an L4/L5 gate. It -also deliberately carries no `profile` attribute; the profile-less shape _is_ the None-tree -fallback path, and `attach_profile` in conftest opts in where the resolved skill name is under -test. + created only in the mounted unit worktree, and assert the main checkout lacks it. + +**`MockAdapter` is production code** (`src/bmad_loop/adapters/mock.py`, shipped in the wheel), +scripted with a list of `SessionResult`s or `callable(spec) -> SessionResult` effects. No +configuration reaches it, and it exercises **the generic adapter path only**: adapter-specific +behavior needs an L2 profile test or an L4/L5 gate. It carries no `profile` attribute on +purpose; `attach_profile` in conftest opts in where the resolved skill name is under test. + +## Premise before outcome + +A test asserts its **premise before its outcome**: before checking the result, it proves the +setup is the situation the test claims to exercise. A negative outcome ("nothing was +published", "the gate refused") passes for every reason the value could be absent, including +a setup that never reached the code under test. + +- Assert the planted state is really on disk (the entry is `done`, the cache is valid, the + tree starts clean) before acting on it. +- For divergent-root tests, compare resolved roots, assert the nesting when it matters, and + plant markers in both directions (`plant_root_markers`, `REPO_ROOT_MARKER_CMD`, + `PROJECT_MARKER_CMD`): one identifies the intended root, the other rules out the tempting + alternative. +- When a wrong answer could still name a real path, create that decoy and assert the selected + value, rather than asserting only that some path is absent. ## Quality guards (meta-tests) -A slice of the suite tests the **repo** rather than the product. The inventory: - -| Guard | Where | Enforces | -| ----------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Portability guard | `tests/test_portability_guard.py` | One shared AST scan over every `src/bmad_loop/**/*.py` (data scripts included), carrying fourteen guards: tmux argv lists (a `"tmux"` head, or a name or attribute the module binds to `"tmux"`, which catches the backend's own `[self._BINARY, ...]` spelling) only in the two backend files; sequence-form git argvs (list or tuple, literal or named constant) only as `_run_git`'s argv argument in `verify.py`, with string-form git spawns refused everywhere, `verify.py` included; no bare `/tmp`-class POSIX paths; no unguarded `SIGKILL` (the attribute under any `signal` alias or through `Signals`, a from-import, or a no-default `getattr`); `os.kill(pid, 0)` probes only in `process_host.py`; any `os.kill` at all only there too (a second, distinct guard); `start_new_session` only in the detach helpers; `shell=True` only in its one sanctioned file (`childrun.py`, the stop-aware runner behind verify commands and declarative hooks); `BMAD_LOOP_*` env reads only through the `envvars.py` registry, a plugin's own variable family, or the session-protocol vars the two stand-alone hook relays read back; a persisted `spec_file` / `dispatched_spec_file` resolved with a bare `Path(...)` only in the four files that run inside the tree the value was recorded against; `verify_commands_outcome` called only from `verify._verify_review_commands`; its classifier half `verify_command_results_outcome` called only from `verify.verify_commands_outcome` or `Engine._verify_commands_with_results` (a separate guard, because fencing the wrapper alone still lets a gate compose run+classify by hand and pick its own root — #695); text-mode `open` / `io.open` / `Path.open` / `read_text` / `write_text` only with an explicit non-`None` encoding (binary modes and module-level `os.open`-style functions exempt; Windows decodes with the locale codepage until Python 3.15, PEP 686), the same detector also run over `scripts/release.py` and `scripts/sync_version.py`; plus a scanned-file-count floor so a broken scan root cannot pass vacuously | -| Kind & refusal inventories | `tests/test_portability_guard.py` | Enumerate-vs-declare gates riding the same AST scan, so a new entry on an enumerable surface is a PR-time decision instead of review-pass archaeology: every literal journal kind is a declared `JOURNAL_KINDS` row, via a dedicated literal-kind emit reading the inventoried channels — kind-only writes, the `kind=` literals or parameter defaults that reach a declared dynamic-kind position, and, when the positional slot is empty, a `kind=` keyword or a readable `**` splat on the write itself; unreadable kinds in keyword/splat channels over an empty direct-write slot and in declared dynamic caller channels emit the unresolved sentinel; occupied unreadable direct-write slots retain their literalness finding without keyword inventory; at a declared dynamic-kind position that finding is waived and the direct-write inventory intentionally remains silent, and each such position additionally declares HOW MANY `journalkind` findings it holds (write sites without a positional string-literal kind, including an empty slot with a keyword literal), graded against the tree in both directions so a write added or removed inside an ALREADY-declared position reddens instead of merely falsifying a comment (the waiver is per position, so the declaredness gate passes on such a write by construction); and the f-string family stays out of the kind inventory, governed by the literalness test (which still refuses a keyword-spelled kind outside a declared position) and, since DW-151, by an identity axis alongside the count: a declared position's minted kind SPELLINGS are read off the AST — the f-string's literal parts expanded through the same-function `for` bindings that supply its interpolations — and graded against `JOURNAL_DYNAMIC_KIND_SPELLINGS` over the union of declared and measured positions, so a respelling reddens where the count cannot see it; an interpolation the resolver refuses mints the unresolved sentinel, which no row may declare; every journal kind carrying a `patch` field is a `JOURNAL_PATCH_KINDS` row, since `diagnostics` drops that field by NAME and a new kind would otherwise inherit the drop undecided; every `_refuse_*`/`_reject_*` helper definition is a `REFUSAL_HELPER_DEFS` row; and the #414-family isolation-refusal call sites (`bmadconfig.worktree_isolation_conflict` plus its CLI wrapper `_reject_isolation_conflict`; since DW-379 the refusal covers only a project outside `repo_root`) match `ISOLATION_CONFLICT_CALLERS` with multiplicity, so `cmd_resolve`'s legitimate second call cannot absorb a third. Since DW-150 the same count axis rides the SPLAT holes: `JOURNAL_SPLAT_ALLOW` declares how many UNRESOLVED `**` KEYWORD ARGUMENTS each position holds — never write calls and never call sites, so a single `append(kind, **a, **b)` counts 2, the finer of the two readings chosen deliberately because a second splat must redden whether it arrives as a new call or as a second `**` inside an existing one — and the field inventory it lets through moved to the sibling `JOURNAL_SPLAT_FIELDS` (key sets held equal by their own row), graded over the union of declared and measured positions so an undeclared hole, a stale row and an unresolved argument added or removed inside an ALREADY-declared position all redden — the shape a set comparison was blind to, since a second splat dropped beside the first was waived on arrival and its field names escaped the inventory; the offender filter reports the same over-count at the offending LINES while the producer row reports the NUMBERS — a deliberate overlap on the undeclared and over-count directions, taken because a filter over findings structurally cannot see a position that LOST an unresolved argument, and recorded in both docstrings rather than left to be rediscovered. And since DW-152 the BARE function name every journal position key uses is enforceable rather than assumed: each journal write emits its enclosing DEF identity (name plus `def` lineno, off the resolver's existing node map), and `test_journal_writers_do_not_share_a_bare_name` reddens the day one module holds two same-named journal-writing functions, whose declarations would otherwise aggregate into one row — qualifying the keys to `class.method` stays deliberately deferred, with the bound stated on both position tables. What it establishes is exactly a pair of WRITERS, which is what the four POSITION tables need, and NOT full name safety for the fifth table, `JOURNAL_FORWARDERS`: that row makes `_is_journal_write` read any call to that NAME in that FILE as a journal write, so a same-named twin that never touches the journal still routes its callers' keywords into the field inventory — and being a non-writer it emits nothing, so this guard cannot see it; closing that would mean widening the classifier, which DW-152 does not ask for. All assertions are exact-set or exact-Counter in both directions — a removed or renamed entry reddens its stale row too — and each new detector emit carries must-flag and must-stay-silent probe rows through `_scan_source` | -| Settings-schema sync | `tests/test_settings_schema.py` | `src/bmad_loop/data/settings/core.toml` stays in lockstep with `policy.py` by reflection, in both directions: every spec maps to a live dataclass field with a matching default wherever one is baked in, every policy field is reachable from exactly one spec (or listed in the explicit `HIDDEN` set), and every `*Policy` dataclass is consciously classified | -| Exit-code allocation | `tests/test_entry_point.py` | `ExitCode` is pinned literally (OK=0, FAILURE=1, USAGE=2, INTERRUPTED=130) **and closed**: the enum's value set equals exactly those four, so codes 3–129/131+ cannot be allocated quietly | -| Extra-less core CLI | `tests/test_entry_point.py` | A fresh interpreter with `pyte`/`rich`/`textual`/`tomlkit` blocked at `find_spec` — the blocker **raises** rather than returning None, so the dev venv's installed copies cannot make it pass vacuously, and an `import pyte` floor proves it bites — imports `bmad_loop.cli` and `bmad_loop.settings_schema`, runs `list` to rc 0, and asserts `tui` degrades to the `bmad-loop[tui]` hint instead of a traceback. Every test job installs `--all-extras`, which is why #650 shipped broken for 23 releases; CI's isolated wheel `list` run is the same floor at install level | -| State-machine table | `tests/test_statemachine.py` | Every `Phase` has a transition row; `TERMINAL_PHASES` (model.py) equals the table's dead ends — a cross-module parity nothing else links; an N×N `parametrize` grid drives every pair (legal pairs land, illegal pairs raise and leave the phase untouched); the awaiting-operator reachability rule is additionally stated independently, because the N² grid reads its expectation out of the table under test | -| Check-id registry | `checks.py` + `tests/test_cli.py` | `ValidationReport.add` asserts its id is in `VALIDATE_CHECKS` at every **executed** call site, and an end-to-end test unions the ids a real passing **and** failing `validate --json` emit and asserts them registered. Both mechanisms are exercised-path enforcement — there is no static call-site scan, so an id on a branch neither reaches can still ship unregistered and raises `AssertionError` only when that branch first executes; a new check site therefore lands together with a test that reaches it | -| Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | -| Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | -| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | -| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | -| Real-mux xdist grouping | `tests/test_conftest.py` | Every real-tmux E2E is in exactly the shared `real_mux_e2e` group, `pyproject.toml`'s `addopts` selects `--dist loadgroup`, and the scheduler actually honours the mark — three halves that are each silently inert alone, so each is asserted (DW-95). An AST scan over every `tests/test_*.py` finds each `test_*` gated by a `skipif` whose `reason` mentions "tmux" (decorator, class decorator, or module-level `pytestmark`; single mark or list; inline or bound to a module-level/conftest-imported alias) and requires the set of `xdist_group` names on it to EQUAL `{REAL_MUX_XDIST_GROUP}` — so a private group of one, and a second group mark xdist would merge into a composite name, both fail. Keyed on the reason string because `HAVE_TMUX` reaches the AST as a bare boolean, which also keeps `test_psmux_live.py` (its own non-xdist CI invocation) and `test_opencode_live.py` out; a non-literal reason is a recorded blind spot with its own row. Split scan (`_scan_source`) with must-flag rows per gating form and must-stay-silent rows per lookalike, exact per-module gated-def counts against a vacuous pass (equality, so an added def fails the guard just as a deleted one does — re-pin the inventory for intentional changes; DW-173), a live-object row pinning the alias to the group name the scan trusts, and — because every other row reads only source text — a subprocess row running a synthetic module under real xdist, asserting the group lands on one worker with the `@group` node-id suffix under `loadgroup` and no suffix under `load` | -| Tmux SessionSpec + reap walls | `tests/test_conftest.py` | Real-tmux generic `SessionSpec` waits must use the imported `REAL_MUX_HANG_CEILING_S`; the two named deliberate timeout tests must retain literal `6.0` (DW-95). The shared ceiling is pinned at `90.0`. The source detector checks only direct `SessionSpec` constructor keywords, including missing timeouts, with expected-function/site coverage and synthetic accepted/rejected probes. Later replacements and binding/dataflow changes are outside this focused guard. A second scanner (DW-108) extends the same doctrine to `tests/test_stories_e2e.py`, the one real-tmux module the SessionSpec scan excludes: every `time.monotonic() + ` reap deadline in a tmux-gated test there must spell the conftest-imported ceiling (either import form), graded with its own named expected-site inventory — which is what fails a scan that found nothing, since most stories E2Es hold no deadline — plus accepted/rejected probe rows through the same function. The `assert time.monotonic() < deadline` poll lines are comparisons, not additions, and fall outside without special-casing. A third scanner (DW-174) grades that same module's MODULE-LEVEL AST for the `detach_ack_ceiling_s=` splice the detached-writer fake hands to bash: the fragment must be followed by `int()` under either import form, with a plain literal carrying the fragment counting as both a site and an offender — the hardcoded-`90` case, which renders byte-identically to the splice and so slips past that module's own rendered-text asserts. It too carries a named expected-site inventory (keyed by the assigned constant) that fails a scan finding nothing, plus accepted/rejected probe rows and must-stay-silent lookalikes; scanning module level only is what keeps `_run_detach_gate`'s deliberately varying in-def budget out of scope without an exception list. Stories budgets that are NOT scanned by any of the three guards retain their existing scope: the subprocess `timeout=` walls, the `end["teardown_s"] < 15.0` teardown-gap assertion, and the `time.time() - hb["ts"] < 120` heartbeat-freshness assertion. | -| Deprecation posture | `tests/test_conftest.py` | `pyproject.toml`'s `filterwarnings` carries `error::DeprecationWarning` and `error::PendingDeprecationWarning`, **and** a deprecation emitted inside a test actually raises — the second half catches a later blanket `ignore` that leaves the entries present but inert (DW-370). | +A slice of the suite tests the **repo** rather than the product. Each guard's test docstrings +carry its full rationale and blind spots. + +| Guard | Test file | What it prevents | +| ----------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Portability guard | `tests/test_portability_guard.py` | tmux argv, git spawns, POSIX-only calls, `BMAD_LOOP_*` env reads, spec-path anchoring and encoding-less text I/O escaping their sanctioned seams | +| Kind & refusal inventories | `tests/test_portability_guard.py` | an undeclared journal kind, `patch` carrier, splat hole, `_refuse_*`/`_reject_*` helper or isolation-refusal call site landing without a PR-time decision | +| Settings-schema sync | `tests/test_settings_schema.py` | `core.toml` drifting from the `policy.py` dataclasses: a spec without a live field, a mismatched default, or an unclassified field | +| Exit-code allocation | `tests/test_entry_point.py` | renumbering an `ExitCode` or quietly allocating one outside {0, 1, 2, 130} | +| Extra-less core CLI | `tests/test_entry_point.py` | the core CLI crashing without the `[tui]` packages; `tui` must degrade to an install hint instead of a traceback | +| State-machine table | `tests/test_statemachine.py` | a `Phase` without a transition row, terminal phases out of step with the table's dead ends, or an illegal move that lands | +| Check-id registry | `checks.py` + `tests/test_cli.py` | a `validate` check id emitted on an executed path without being in `VALIDATE_CHECKS` (exercised paths only; no static scan) | +| Skill-drift guard | `tests/test_module_skills_sync.py` | seeded `.claude/skills` and `.agents/skills` forks drifting from the canonical skills (dev boxes only; CI has no forks) | +| Schema-version parity | `tests/test_tui_app.py` | the TUI rendering a newer validate document schema as the old one after a CLI bump | +| Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | the packaged hook relays drifting from their source (wheel-installed runs only), or `init` writing a workspace relay copy | +| Version sync | `tests/test_release.py` + CI | version fields disagreeing, or a release leaving `## [Unreleased]` and its compare link unreopened | +| Real-mux xdist grouping | `tests/test_conftest.py` | real-tmux E2Es scheduled onto concurrent workers: each must carry exactly the `real_mux_e2e` group, honored by `--dist loadgroup` | +| Tmux SessionSpec + reap walls | `tests/test_conftest.py` | load-sensitive literal timeouts in real-tmux waits; they must use `REAL_MUX_HANG_CEILING_S` | +| Deprecation posture | `tests/test_conftest.py` | deprecation warnings passing silently, including through a later blanket `ignore` that leaves the `error::` entries inert | +| Docs hygiene | `tests/test_docs_hygiene.py` | broken relative links and anchors in the docs, and a stale generated policy table in the configuration reference | Rules for adding or touching a guard: - **Fix the source, not the allowlist.** A new finding means routing the call through the seam; widening an allowlist is the last resort and needs the same scrutiny as a production change. - **Exemptions are scoped and acknowledged.** Most file-keyed allowlists (paths, detach, - kill-probe, `shell=True`) require a per-line `# portability:` ack on top; whole-file passes + kill-probe, `shell=True`) also require a per-line `# portability:` ack. Whole-file passes exist only where the file _is_ the sanctioned spot (the tmux backends; `process_host.py` for - `os.kill`), and `verify.py`'s git exemption is narrowed further, to the `_run_git` argv - position. Env-read exemptions are scoped **by variable name or family, never by file** — a - file-wide pass would let a hook read a core knob unnoticed. -- **The detector itself gets executable coverage.** Every detector added since this bar was set - carries a probe matrix — env-read, git-argv, the two verify-composition ones, spec-anchor, - task-artifact, session-task-id, the re-arm call, the journal field / kind / kind-literal / - minted-kind / def-identity family, refusal-def, isolation-call, real-mux group mark, generic-tmux completion wall, - stories-E2E reap deadline, and stories-E2E detach-ceiling splice: the source detectors - (`_scan_source`, `_scan_session_walls`, `_scan_reap_deadlines`, - `_scan_detach_ceiling_splices`) are split so probe fixtures run the same code path as the - real scan, with a must-flag row per claimed access form and a must-stay-silent row per - lookalike. When a new form turns up, add the failing probe row first, then fix the - detector. The green "no findings today" assertion cannot grade a detector — it is equally - green when the scan silently stopped scanning, which is why the matrices exist. The seven - older tripwires (tmux, path, sigkill, killprobe, oskill, detach, shell) predate this bar and - now meet it through one shared `(kind, label, source)` matrix with a per-kind floor. A - further test holds the tmux guard non-vacuous by requiring a finding in the backend's own - file. -- **The guard's boundary is `src/bmad_loop`,** enforced structurally by its scan root rather - than by an allowlist. The deliberate exceptions are the real-mux grouping, completion-wall, - stories reap-deadline and stories detach-ceiling guards, whose subject is the suite's own - scheduling and waits. - There is no production artifact to scan, so they are rooted at `tests/` and live in - `tests/test_conftest.py` rather than - `tests/test_portability_guard.py` precisely so that file's `src/bmad_loop` scan root stays - the structural boundary it is. Tests, `scripts/`, and CI workflows deliberately spawn their own git — - a harness must not depend on the artifact it validates (AGENTS.md states this for git; the - same reasoning covers their env reads). + `os.kill`), and `verify.py`'s git exemption is narrowed to the `_run_git` argv position. + Env-read exemptions are scoped **by variable name or family, never by file**. +- **The detector itself gets executable coverage.** Source detectors are split (`_scan_source` + and its siblings) so probe fixtures run the same code path as the real scan, with a + must-flag row per claimed access form and a must-stay-silent row per lookalike. When a new + form turns up, add the failing probe row first, then fix the detector. A green "no findings + today" assertion cannot grade a detector: it is equally green when the scan silently stopped + scanning. +- **The guard's boundary is `src/bmad_loop`,** enforced by its scan root rather than an + allowlist. Guards whose subject is the suite's own scheduling and waits are rooted at + `tests/` and live in `tests/test_conftest.py`, so the portability guard's scan root stays + the boundary. Tests, `scripts/`, and CI workflows deliberately spawn their own git: a harness + must not depend on the artifact it validates. ## The zero-token invariant **No test, at any layer, ever spends an LLM token.** AGENTS.md states the hard invariant for -the live/E2E gates ("Live/E2E tests must consume zero LLM tokens"); in practice it holds -everywhere, and the mechanism differs per gate — worth knowing before touching any of them: +the live/E2E gates; in practice it holds everywhere, and the mechanism differs per gate: -| Gate | Real component | Zero-token mechanism | Runs where | -| ------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `test_generic_tmux.py` (6 `HAVE_TMUX` test functions, 8 collected) | tmux server | The spawned "CLI" is a tiny shell script written by the test | CI Linux job + any POSIX dev box with tmux | -| `test_stories_e2e.py` | tmux + the real `bmad-loop run/resolve/resume` CLI | Scripted fake `claude` variants (bash, defined as string constants in the test module) wired in as a custom TOML profile (`fakestories`); the fake writes its own SessionStart/Stop hook events, so no `bmad-loop init` and no real CLI exists anywhere in the run | CI Linux job (Linux-only gate: the fakes use GNU coreutils + `setsid`) | -| `test_opencode_live.py` | A real `opencode serve` HTTP server | **Never sends a prompt**: only the spawn/teardown paths are used — nothing that prompts — and the tests touch health/doc/session/event endpoints; the prompt endpoint is asserted against the OpenAPI schema, never called. One test asserts the session's token/cost aggregates are zero | Manual — POSIX box with a **runnable** `opencode` — the gate probes `--version`, so a resolvable-but-dead shim skips (#294) | -| `test_psmux_live.py` | A real psmux on Windows | **Parked windows only**: every parked window runs `pwsh -NoProfile -Command exit 0`, and no coding CLI is ever launched. Includes `test_premise_*` probes with inverted semantics — a red probe means a workaround became droppable — and `test_adopted_*` probes with ordinary semantics — a red probe means psmux regressed a behavior the 3.3.8 floor lets the backend assume | CI Windows job (the job installs psmux) + any Windows dev box with an admitted psmux (3.3.8+) and `pwsh` on PATH | +| Gate | Real component | Zero-token mechanism | Runs where | +| ------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `test_generic_tmux.py` (`HAVE_TMUX`) | tmux server | The spawned "CLI" is a tiny shell script written by the test | CI Linux job + any POSIX dev box with tmux | +| `test_stories_e2e.py` | tmux + the real `bmad-loop run/resolve/resume` CLI | Scripted fake `claude` variants (bash string constants) wired in as a custom TOML profile; the fake writes its own SessionStart/Stop hook events | CI Linux job (the fakes use GNU coreutils + `setsid`) | +| `test_opencode_live.py` | A real `opencode serve` HTTP server | **Never sends a prompt**: only spawn/teardown and the health/doc/session/event endpoints; the prompt endpoint is checked against the OpenAPI schema, never called | Manual: a POSIX box with a runnable `opencode` | +| `test_psmux_live.py` | A real psmux on Windows | **Parked windows only**: every window runs `pwsh -NoProfile -Command exit 0`. `test_premise_*` probes are inverted (red means a workaround became droppable); `test_adopted_*` probes are ordinary | CI Windows job + any Windows dev box with psmux 3.3.8+ and `pwsh` | -Never "fix" a gate to call a real CLI, and never add a completion path that trusts LLM output — +Never "fix" a gate to call a real CLI, and never add a completion path that trusts LLM output: sessions complete only on hook Stop events or window death, and the fakes exercise exactly those paths. -**Manual-gate cadence:** `test_opencode_live.py` is the one gate CI still cannot see. Run it on a -POSIX box with opencode installed before every release, and after any change to the adapter it -covers; a release cut without it is trusting stale evidence. `test_psmux_live.py` left this -category in #662 — the **test-windows-live** job installs psmux and runs the gate on every pull -request and on pushes to `main`/`release/*` (the workflow's own triggers), serially on its own -machine, so its evidence is as fresh as the branch rather than as fresh as someone's memory. +**Manual-gate cadence:** `test_opencode_live.py` is the one gate CI cannot run. Run it on a +POSIX box with opencode installed before every release and after any change to the adapter it +covers. ## Ablation records The rule (AGENTS.md): **for any test asserting "X is refused/absent", delete the gating code -and confirm the test fails before trusting it** — a negative assertion passes for every reason -a value could be absent. The suite records performed ablations in-file, in one house grammar: +and confirm the test fails before trusting it.** The suite records performed ablations +in-file, in one house grammar: - **The canonical form** is a prescriptive, undated sentence in the test's docstring (or a nearby comment), naming the exact mutation and the expected failure: @@ -281,106 +230,87 @@ a value could be absent. The suite records performed ablations in-file, in one h `Ablation guard:`, `Ablations:`, `ablation pin:`, and lettered `ABLATION A1`, `A2`, … where a test carries several. -- **`INVERSE ablation:`** — used when deleting the gate would not reproduce the bug (the gate +- **`INVERSE ablation:`** is used when deleting the gate would not reproduce the bug (the gate is an _absence_, or the bug needs the old behavior restored). The record states the inverse mutation: restore the old code, or add what the rule forbids. -- **Green-ablation records** — a small set records that a mutation deliberately does _not_ - redden this test, naming, where one exists, the test that does pin it. That is a durable - warning against miscounting coverage, not a guard. +- **Green-ablation records** note that a mutation deliberately does _not_ redden this test, + naming the test that does pin it where one exists: a warning against miscounting coverage. Two disciplines around the grammar: -- **Records are evidence, not contracts.** A record certifies the ablation that was run when - the test was written or last audited. Touching the gate re-opens the question: re-run the - ablation, against a `cp` backup of the source (never `git checkout` — it has destroyed - in-flight fixes), and restore byte-identical. Dates, pasted assertion output, and run logs - belong in the **commit message**, not the file — durable facts in docstrings, records in git - history. (Three dated `ABLATION (... 2026-07-25)` comments survive in - `test_opencode_http.py` from before this convention settled; trim them to the undated form - on next touch.) -- **Ablate singly.** Two gates deleted together redden everything and grade neither; the - envvars work proved its rejection gates hold disjoint row sets by ablating them one at a - time — three gates today, four ablations counting the `raw is None` early-out. - -A close cousin: a **known defect is pinned with `xfail(strict=True)`**, its reason naming the -defect (`test_env_fault_patterns.py`'s #194 corpus, the first, and the EscalationModal and -ConfirmResumeModal-warning rows of `test_tui_app.py`'s 39×9 dialog matrix were all promoted -to live assertions when their fixes landed). `strict=True` is the point — -the day the defect is fixed, the test fails and forces the debt note to be removed with it. +- **Records are evidence, not contracts.** A record certifies the ablation run when the test + was written or last audited. Touching the gate re-opens the question: re-run the ablation + against a `cp` backup of the source (never `git checkout`, which can destroy in-flight + fixes), and restore byte-identical. Dates, pasted assertion output, and run logs belong in + the **commit message**, not the file. +- **Ablate singly.** Two gates deleted together redden everything and grade neither. + +A **known defect is pinned with `xfail(strict=True)`**, its reason naming the defect. +`strict=True` is the point: the day the defect is fixed, the test fails and forces the note to +be removed with it. ## Typing and lint - **Staged pyright, deliberate.** `typeCheckingMode = "basic"` over `src/bmad_loop`, with the - strict list covering the pure/leaf seven (`model`, `statemachine`, `policy`, `documents`, - `machine`, `checks`, `sanitize`). Strictness lives where the code is pure; the I/O edges are - not forced over the strict bar. `src/bmad_loop/data` is excluded — data-shipped scripts are - standalone templates outside the import graph (with the untested-script consequence #546 - tracks). + strict list covering the pure/leaf modules (`model`, `statemachine`, `policy`, `documents`, + `machine`, `checks`, `sanitize`, `plugins/digest`, `plugins/grants`). Strictness lives where + the code is pure. `src/bmad_loop/data` + is excluded: data-shipped scripts are standalone templates outside the import graph. - **Three `os` surfaces.** CI also runs pyright with `--pythonplatform Windows` and `Darwin`, whose `os` stubs lack POSIX/Linux-only names (`O_DIRECTORY`, `setxattr`, `sysconf`). Code that needs such a name fetches it with a default-free `getattr` inside its runtime check. - **`tests/` and `scripts/` are outside pyright, deliberately.** Tests get their rigor from - ablation, parity, and the guards — annotating monkeypatch-heavy test code buys noise, not - safety. Do not add them to the include list as a drive-by. -- **Pyright is pinned exactly** (`pyright==1.1.411` in the dev group) — it is a gate, and a - newer pyright adds checks that fail the build on untouched code. Bump the `==` pin - deliberately; an exact pin is not something a lock refresh can move for you. -- **Ruff rule families are pinned** (`E4`, `E7`, `E9`, `F`) to ruff 0.15.17's default set, so - CI (trunk's pinned ruff) and a bare newer ruff share rule families — a newer ruff may still - add rules under those prefixes. **Do not ratchet stricter than CI** - without moving CI first — the `[tool.ruff.lint]` comment in pyproject.toml is the authority. + ablation, parity, and the guards. Do not add them to the include list as a drive-by. +- **Pyright is pinned exactly** in the dev group: it is a gate, and a newer pyright adds checks + that fail the build on untouched code. Bump the `==` pin deliberately. +- **Ruff rule families are pinned** (`E4`, `E7`, `E9`, `F`), so CI (trunk's pinned ruff) and a + bare newer ruff share rule families. **Do not ratchet stricter than CI** without moving CI + first; the `[tool.ruff.lint]` comment in pyproject.toml is the authority. - Formatting is trunk's (black/isort/prettier/markdownlint); `trunk check` before every push is the contract, and the pre-push hook enforces it. ## CI, flakes, and deliberate absences -Eight jobs (`.github/workflows/ci.yml`): **test** (ubuntu, Python 3.11–3.14, tmux installed so -L4 and `stories_e2e` run), three Windows jobs (`PYTHONUTF8=1`; PRs run the 3.11/3.14 boundary -only — Windows failures here have been platform-shaped, not version-shaped — pushes to `main` -and `release/*` run the full spread), **version-sync**, **lint** (trunk, including actionlint -and zizmor over the workflows themselves), **typecheck** (the same pinned pyright a contributor -runs, plus the Windows and Darwin target checks), and **build** (packaging smoke: sdist and wheel, the console script executed from the -installed wheel, and a wheel data-file inventory against `git ls-files` — every other job runs -from the source tree, so packaging breaks were invisible until this job existed). +Eight jobs (`.github/workflows/ci.yml`): + +- **test**: ubuntu, Python 3.11–3.14, tmux installed so L4 and `stories_e2e` run. +- Three Windows jobs (`PYTHONUTF8=1`). Pull requests run the 3.11/3.14 boundary only; pushes + to `main` and `release/*` run the full spread. +- **version-sync**: `scripts/release.py check`. +- **lint**: trunk, including actionlint and zizmor over the workflows themselves. +- **typecheck**: the same pinned pyright a contributor runs, plus the Windows and Darwin + target checks. +- **build**: packaging smoke. Builds the sdist and wheel, runs the console script from the + installed wheel, and checks the wheel's data files against `git ls-files`; every other job + runs from the source tree. The Windows jobs split one leg across machines without dropping a test: - **test-windows-shard** runs the suite minus `test_psmux_live.py` as two shards per Python leg, `--ci-shard=1/2` and `2/2` (`tests/perf_report.py`). A shard receives whole units: a - test function with every parameter variant, or a whole xdist group keyed by its sorted group - names, so a real-mux group can never split. Units are assigned by a SHA-256 digest of the - unit's nodeid before parametrization — never Python's randomized `hash()`, never a file list - — so a new test is assigned automatically and existing tests do not move. A malformed range - or a shard that selects nothing is a usage error; without the option pytest runs the whole - suite. The digest balances test counts, not seconds, so one shard usually runs longer. + test function with every parameter variant, or a whole xdist group, so a real-mux group can + never split. Units are assigned by a SHA-256 digest of the unit's nodeid, never Python's + randomized `hash()` or a file list, so a new test is assigned automatically and existing + tests do not move. The digest balances test counts, not seconds. - **test-windows-live** installs psmux and runs the L5 gate once per leg, serially, on a machine of its own. -- **test-windows** keeps the check name the unsharded job had (`test (windows, py3.x)`) and - is the leg's verdict. It downloads that leg's records and runs - `perf_report.py verify-shards --count 2`: both shards present and green, their selected sets - disjoint, their union equal to the collected set each shard recorded before deselecting - (platform skips included), no function family straddling shards. Then `verify-executed` - requires every live-gate test to have passed; a skip there means the gate did not run. A - failed, cancelled, deadline-stopped or never-uploaded shard or gate fails the leg, and so - does one whose wrapper `runner.json` is missing or never reached `exited`. It judges - from records rather than `needs.*.result`, which aggregates the whole matrix. Artifacts are - run-scoped, so a re-run job that fails before uploading would leave the earlier attempt's - green records in place; `verify-jobs` therefore also requires each of the leg's three jobs - to have concluded `success` in its latest execution (the run's `jobs?filter=latest` listing). +- **test-windows** keeps the check name `test (windows, py3.x)` and is the leg's verdict. + `perf_report.py verify-shards --count 2` requires both shards present and green, their + selections disjoint, and their union equal to the collected set, with no function family + straddling shards. `verify-executed` requires every live-gate test to have passed; a skip + there means the gate did not run. `verify-jobs` requires each of the leg's jobs to have + concluded `success` in its latest execution, so a re-run that fails before uploading cannot + pass on an earlier attempt's records. Sharding buys latency, not work: every shard pays its own runner setup and a full collection. -It stays only while the extra aggregate job-seconds remain within 15% of the unsharded -layout; beyond that, one job per leg is the cheaper answer. The repository has no required -status checks today; the aggregator keeps the old names so adding protection later needs no -renames. +It stays only while the extra aggregate job-seconds remain within 15% of the unsharded layout. **Zero-retry flaky policy.** There is no retry mechanism anywhere: no `--reruns`, no retry -plugin — `pytest-rerunfailures` was removed from the environment precisely because an installed -retry plugin is an invitation to paper over a real flake. **A flake is a bug**: it gets an -issue, a diagnosis, and a deterministic fix — never a rerun loop. The main defense is the -**frozen-clock pattern, mandatory for time-dependent tests**: no unit or seam test sleeps -toward a deadline (the real-process gates use short settle polls, which is different from -waiting out a production timeout). Two sanctioned shapes: +plugin, because an installed retry plugin is an invitation to paper over a real flake. **A +flake is a bug**: it gets a diagnosis and a deterministic fix, never a rerun loop. The main +defense is the **frozen-clock pattern, mandatory for time-dependent tests**: no unit or seam +test sleeps toward a deadline (the real-process gates use short settle polls, which is +different from waiting out a production timeout). Two sanctioned shapes: 1. A scoped `_Clock` shim over a mutable dict, monkeypatched in as the module-under-test's `time` reference; a scripted watcher callback advances the dict past the deadline @@ -390,7 +320,7 @@ waiting out a production timeout). Two sanctioned shapes: takes `clock`/`sleep` callables, and the test's fake `sleep` credits the fake clock (`test_signals.py`). -Deliberate absences — decisions, not gaps: +Deliberate absences, decisions rather than gaps: - **No coverage gate.** A percentage threshold optimizes for the metric, and this suite's riskiest surfaces (guards, ablations, parity) are exactly what line coverage cannot grade. @@ -398,29 +328,22 @@ Deliberate absences — decisions, not gaps: `uv run --with pytest-cov pytest --cov=bmad_loop --cov-report=term -q`. - **No macOS runner.** POSIX portability is held by the seam quarantine plus the portability guard; macOS-specific traps found in the field get targeted unit tests (the psutil - `create_time` trap being the canonical example). A macOS job would mostly re-run the Linux - suite at 10× the queue time. -- **No WSL2 runner.** Hosted runners cannot host WSL2, so run the suite there by hand - (#883), from a clone inside the WSL filesystem: a Windows worktree reached through - `/mnt/c` carries a `C:/` gitdir that WSL git cannot resolve. A stock install differs - from the ubuntu leg in two ways the tests already handle. Ubuntu ships only `python3`, - so a test that spawns an interpreter uses `sys.executable`, never a bare `python`. - uv's standalone CPython has no `os.pidfd_open`, so the pidfd reap rows skip via - `conftest.needs_pidfd`. To run them, build the venv on the distro Python with - `uv venv -p /usr/bin/python3`. -- **No opencode install in CI** — so `test_opencode_live.py` is the last manual gate (table - above), and faking the server would test the fake. psmux is the counter-example rather than - the precedent: it is one zip on a GitHub release, so **test-windows-live** installs it and runs - that gate on every PR and every push to `main`/`release/*` (#662). Chocolatey was tried - first and dropped — its community feed 503'd on both matrix legs, and Chocolatey document - it as unguaranteed and rate-limited per IP, which hosted runners share. + `create_time` trap is the canonical example). +- **No WSL2 runner.** Hosted runners cannot host WSL2, so run the suite there by hand, from a + clone inside the WSL filesystem: a Windows worktree reached through `/mnt/c` carries a `C:/` + gitdir that WSL git cannot resolve. A test that spawns an interpreter uses `sys.executable`, + never a bare `python` (Ubuntu ships only `python3`). uv's standalone CPython has no + `os.pidfd_open`, so the pidfd reap rows skip via `conftest.needs_pidfd`; to run them, build + the venv on the distro Python with `uv venv -p /usr/bin/python3`. +- **No opencode install in CI**, so `test_opencode_live.py` is the last manual gate (see + [The zero-token invariant](#the-zero-token-invariant)); faking the server would test the + fake. psmux, one zip on a GitHub release, is installed by **test-windows-live** instead. ## Runtime, metrics, and performance budgets **Default invocation is serial.** `uv run pytest -q` runs one process: correct everywhere, -slowest everywhere. Parallelism is opt-in with `-n logical` (see -[The suite at a glance](#the-suite-at-a-glance)). Compare like with like: a serial local run -against a four-worker hosted run measures the worker count, not the change. +slowest everywhere. Parallelism is opt-in with `-n logical`. Compare like with like: a serial +local run against a four-worker hosted run measures the worker count, not the change. **The 2-vCPU Windows comparison** (a small VM or WinBoat guest) is two commands, in this order, never alongside another full-suite run on the same guest: @@ -431,10 +354,9 @@ uv run pytest -q -n 2 --ignore=tests/test_psmux_live.py uv run pytest -q tests/test_psmux_live.py ``` -Keep the checkout and `TMP`/`TEMP` on the guest's own disk. A host-shared or network folder -makes every per-test sandbox copy cross the share, which measures the share. The live gate -runs separately and serially for the same reason CI runs it that way: its tests drive real -psmux servers, a single-machine resource rather than per-worker state. +Keep the checkout and `TMP`/`TEMP` on the guest's own disk; a host-shared or network folder +measures the share. The live gate runs separately and serially because its tests drive real +psmux servers, a single-machine resource. **Metrics** (`tests/perf_report.py`, inert unless asked for) answer where the time went, including for a run that never finished: @@ -447,35 +369,31 @@ uv run python tests/perf_report.py summarize The wrapper owns the pytest process lifetime, preserves pytest's exit status, and with `--timeout`/`--deadline-epoch` stops the whole tree (exit 124) while leaving every flushed record behind. Pytest alone takes `--test-metrics-dir=PATH`, spelled with `=`. The summary -keeps two clocks apart: wall-clock elapsed, and _worker-seconds_ summed across xdist workers, -which exceeds elapsed whenever workers overlap. It also reports fixture setup totals, per-file -cost, the cleanup tail after the last report, and tests started but never finished. -`--test-metrics-subprocesses` adds per-test, per-command and per-call-site launch counts -(`git `). It is a targeted diagnostic with real overhead and stays off in CI. Nothing -recorded carries environment values, output or argv beyond a program name and git verb. Metrics -directories are run artifacts: CI uploads them (`test-metrics-*`, 14-day retention, uploaded -even when the step fails or hits its deadline) and they never go into Git. +keeps wall-clock elapsed apart from _worker-seconds_ summed across xdist workers, and reports +fixture setup totals, per-file cost, the cleanup tail, and tests started but never finished. +`--test-metrics-subprocesses` adds per-test, per-command and per-call-site launch counts; it +has real overhead and stays off in CI. Recorded data carries no environment values, output, or +argv beyond a program name and git verb. Metrics directories are run artifacts: CI uploads +them (`test-metrics-*`) and they never go into Git. **Budgets.** Each test job has a hard `timeout-minutes` cap, and its test steps stop a few minutes short of it through the wrapper's deadline, so a wedged run ends as a red step with its -metrics uploaded rather than as a cancelled job with nothing to read. Change a cap and its -deadline arithmetic together (the workflow comments name both). Beyond the caps, the -engineering targets for the Windows legs are an ordinary-suite median of at most ten minutes -and a completed Windows check within fifteen, judged on at least three hosted samples per -boundary Python version. These are acceptance targets, not guarantees; three samples support a -median and a range, never a p95. A red sample is recorded as a result, never rerun into a -green one. +metrics uploaded rather than as a cancelled job. Change a cap and its deadline arithmetic +together (the workflow comments name both). The engineering targets for the Windows legs are +an ordinary-suite median of at most ten minutes and a completed Windows check within fifteen, +judged on at least three hosted samples per boundary Python version. These are targets, not +guarantees. A red sample is recorded as a result, never rerun into a green one. ## TUI testing The TUI suite is split by what can regress, across four files: -| File | Covers | Textual required? | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `test_tui_data.py` | The pure filesystem observation layer — data correctness, no textual involved | No textual — one test _enforces_ that `tui/data.py` imports none — but the file still needs the `tui` extra (`data.py` imports `pyte`/`rich`) | -| `test_tui_launch.py` | Exact tmux/CLI argv built by `tui.launch`, against monkeypatched subprocess (plus real-subprocess sanity checks of the captured path); module-wide `force_tmux_backend` | No | -| `test_tui_app.py` | Coarse Pilot smoke over the dashboard: mount, run-table population and auto-select, pane polling, keybinding → modal → `tui.launch` wiring (monkeypatched — no real tmux) | Yes | -| `test_tui_settings.py` | `PolicyDoc` edit-model semantics plus Pilot tests of the settings screen (minimal diff on save, invalid values blocked) | Yes | +| File | Covers | Textual required? | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `test_tui_data.py` | The pure filesystem observation layer: data correctness | No (one test enforces that `tui/data.py` imports no textual), but needs the `tui` extra | +| `test_tui_launch.py` | Exact tmux/CLI argv built by `tui.launch`, against monkeypatched subprocess; module-wide `force_tmux_backend` | No | +| `test_tui_app.py` | Coarse Pilot smoke over the dashboard: mount, run table, pane polling, keybinding → modal → `tui.launch` wiring (no real tmux) | Yes | +| `test_tui_settings.py` | `PolicyDoc` edit-model semantics plus Pilot tests of the settings screen (minimal diff on save, invalid values blocked) | Yes | Doctrine: @@ -483,63 +401,47 @@ Doctrine: argv assertions in `test_tui_launch.py`; `test_tui_app.py` proves _wiring_, coarsely, with the shared `until(pilot, condition)` poll helper. A fine-grained assertion inside a Pilot test is usually in the wrong file. -- **Without the `tui` extra, the TUI files fail at collection with ImportError — not skip. - Deliberate.** The extra is a dev prerequisite (`uv sync --all-extras` in the docs and in - every CI job that installs the project), so an environment missing it is a broken dev setup - that should fail loudly, not a legitimate configuration that silently runs ~300 fewer tests - (the two Pilot files, and `test_tui_data.py` via its `pyte`/`rich` imports — - `test_cleanup.py` left that set in #650, when the run-inventory reader it - depends on moved to `runs.py`). +- **Without the `tui` extra, the TUI files fail at collection with ImportError, not skip. + Deliberate.** The extra is a dev prerequisite (`uv sync --all-extras`), so an environment + missing it is a broken dev setup that should fail loudly rather than silently run hundreds + fewer tests. - **Snapshot testing is rejected, with revisit triggers.** `pytest-textual-snapshot` is unverified against the textual 8 line this repo pins, and every TUI gap observed so far has - been behavioral (data, argv, wiring, liveness gates) — exactly what snapshots do not catch, - while snapshot churn taxes every layout tweak. Revisit if either changes: the plugin proves - out on textual 8+, or a real regression escapes because it was purely visual - (#547's attention-pane work is the current best candidate to test that claim). + been behavioral (data, argv, wiring, liveness gates), which snapshots do not catch, while + snapshot churn taxes every layout tweak. Revisit if the plugin proves out on textual 8+, or + a real regression escapes because it was purely visual. ## Duplicate tests and contract parity -An AST dedup sweep (name collisions plus normalized-body hashes over every test file, PR #540) -informs this policy: - -- **A true duplicate is consolidated at the mirror file of the function it covers** — the one - the sweep found now lives beside its function, superset assertions kept. +- **A true duplicate is consolidated at the mirror file of the function it covers**, superset + assertions kept. - **Identical names across files are otherwise deliberate contract parity.** The timeout-instrumentation and token-budget blocks in `test_generic_tmux.py` and `test_opencode_http.py` hold identically named tests over two transports: the - `test_generic_tmux.py` headers state the contract — _a behavior change lands in both or - records the divergence_ — and the `test_opencode_http.py` headers point back. A cross-link - must exist on both sides, because a one-way pointer rots; the layered - `test_envvars.py`/`test_engine.py` pair, which carries the full contract sentence in both - files, is the model form. + `test_generic_tmux.py` headers state the contract (_a behavior change lands in both or + records the divergence_) and the `test_opencode_http.py` headers point back. A cross-link + must exist on both sides, because a one-way pointer rots. - **Layered coverage is not duplication.** `test_envvars.py` and `test_engine.py` pin the same - rejection rows at two layers — "what does the parse return" vs "does the policy default - survive" are different claims — and each states its parity with the other. + rejection rows at two layers ("what does the parse return" vs "does the policy default + survive" are different claims), and each states its parity with the other. - **A shared parametrized contract module is rejected as over-abstraction.** Hoisting the - parity blocks into one parametrized-over-transport module would hide the transport-specific + parity blocks into one module parametrized over transport would hide the transport-specific setup that is most of each test's meaning, and the divergence _note_ is the valuable - artifact. Identical bodies under disjoint `parametrize` sets, and one body exercised against - two different shipped scripts, were likewise adjudicated deliberate and left alone. + artifact. ## Gaps and deviations -The honest register. Each row is either tracked or a documented deviation with its rationale. - -| Item | Status | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `machine.py` has no mirror test file; `add_json_flag` uncovered; two contract edges unpinned | #545 | -| `checks.py`, `documents.py`, `fences.py`, `workspace.py` also lack mirror files (`checks` and `documents` being pyright-strict modules); today they are reached only from consumer suites (`test_cli.py`, the conftest harnesses, the engine/worktree files) | Known, untracked — file an issue on next touch if a regression risk surfaces that consumer coverage cannot catch | -| `unity_cleanup.py` (data-shipped Unity script) has zero behavioral tests; plugin dispatch guards unpinned | #546 — test on next touch | -| TUI Attention pane render path (append, truncation reset, toast) untested | #547 | -| "Legal phase transitions live only in `statemachine.py`" is comment-enforced, not guard-enforced: ~14 direct `task.phase =` writes exist outside the state machine, each with a prose ack, inline or in the enclosing docstring | Deviation, accepted — the writes are deliberate resets/latches; a guard would need an ack grammar the comments already provide informally | -| Skill-drift guard is CI-inert (fork trees are gitignored, so absent in CI; the guard skips when a tree is absent) | Documented limitation; drift is caught on dev boxes, which is where forks exist | -| Three dated ablation comments predate the undated house form | Trim on next touch of `test_opencode_http.py` | +The honest register. Each row is a known gap or a documented deviation with its rationale. -Recently closed by the audit's remediation PRs, for context: the suite's one true duplicate -test and its vestigial `engine.py` re-export (#540); the untested `envvars.py` registry, the -unenforced plugin-env-family invariant, and the TUI hard-stop/archive paths (#541, which also -removed the unused retry plugin and fixed the `inf` session-timeout override it found); CI's -unlinted workflows and untested packaging (#543). +| Item | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `machine.py` has no mirror test file | Known gap | +| `checks.py`, `documents.py`, `fences.py`, `workspace.py` lack mirror files and are reached only from consumer suites (`test_cli.py`, the conftest harnesses, the engine/worktree files) | Known gap; add a mirror file when a regression risk surfaces that consumer coverage cannot catch | +| `unity_cleanup.py` (data-shipped Unity script) has no behavioral tests | Known gap; test on next touch | +| TUI Attention pane render path (append, truncation reset, toast) is untested | Known gap | +| "Legal phase transitions live only in `statemachine.py`" is comment-enforced, not guard-enforced: direct `task.phase =` writes outside the state machine each carry a prose ack | Deviation, accepted: the writes are deliberate resets and latches | +| Skill-drift guard is CI-inert (fork trees are gitignored, so absent in CI) | Documented limitation; drift is caught on dev boxes, where forks exist | +| Three dated ablation comments in `test_opencode_http.py` predate the undated house form | Trim on next touch | ## Policy quick-reference and doc-sync rules @@ -549,28 +451,27 @@ The rules a reviewer actually enforces, in one place: only. 2. New behavior lands at the lowest layer that catches its regression; mirror-file rule for placement. -3. Negative assertion ⇒ ablation, singly, against a `cp` backup; record the undated - `Ablation:`/`Ablation target:` sentence in-file, the run evidence in the commit message. -4. Time-dependent test ⇒ frozen clock. No sleeps toward deadlines, no retries, ever. A flake - gets an issue. +3. Negative assertion ⇒ premise before outcome, and ablation, singly, against a `cp` backup; + record the undated `Ablation:`/`Ablation target:` sentence in-file, the run evidence in the + commit message. +4. Time-dependent test ⇒ frozen clock. No sleeps toward deadlines, no retries, ever. A flake is + diagnosed and fixed deterministically. 5. In-process `--json` test expecting a document on stdout ⇒ `machine_json` (whole-stdout - parse is the purity assertion); error paths that owe an **empty** stdout and `--json ---out` runs that validate the written file sit outside it. Process-boundary tests — - `test_entry_point.py`, and `test_stories_e2e.py` where it runs `validate --json` — - spawn a real subprocess and parse its whole stdout themselves; the subprocess _is_ the - test, so never convert one to `cli.main`. + parse is the purity assertion). Error paths that owe an **empty** stdout, and `--json --out` + runs that validate the written file, sit outside it. Process-boundary tests + (`test_entry_point.py`, and `test_stories_e2e.py` where it runs `validate --json`) spawn a + real subprocess and parse its whole stdout themselves; never convert one to `cli.main`. 6. Sandbox test ⇒ `project` fixture; never hand-roll a repo, never touch the template. 7. Cross-transport/cross-layer twins keep identical names and two-way header cross-links. 8. Guard finding ⇒ fix the source; allowlist widening is a reviewed exception with a scoped, - acknowledged entry — and a new detector shape gets its probe row first. + acknowledged entry, and a new detector shape gets its probe row first. 9. New policy field ⇒ `core.toml` entry (the schema-sync guard will insist). New check id ⇒ `VALIDATE_CHECKS`. New env var ⇒ `envvars.py` or the plugin's own family. 10. Suite invariants that break silently are AGENTS.md material; everything else about testing belongs here. -Doc-sync: **edit this file first.** AGENTS.md's Testing section stays terse — a bullet changes -there only when the underlying rule changes, and it links here (one Testing bullet plus a -docs-index row) rather than duplicating. +Doc-sync: **edit this file first.** AGENTS.md's Testing section stays terse: a bullet changes +there only when the underlying rule changes, and it links here rather than duplicating. CONTRIBUTING.md points contributors here from its Tests bullet. Behavior docs ([FEATURES.md](FEATURES.md)) never carry testing policy. When a gap in the register closes, delete its row in the same PR. diff --git a/docs/tui-guide.md b/docs/tui-guide.md index ee5e3489d..81e69ba72 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -9,101 +9,70 @@ README](../README.md#the-tui). ## Installation and launch ```bash -uv sync --extra tui # adds textual + tomlkit + pyte + rich; core stays pyyaml-only +uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" cd /path/to/your/bmad/project bmad-loop tui # or: bmad-loop tui --project /path/to/project ``` -`--project` defaults to the current directory. The selected terminal-multiplexer -backend — tmux by default; external backends install separately, see -[Terminal multiplexer backends](multiplexer-backends.md) — must be on PATH for the -launch/attach keys (`r` `s` `e` `a`); pure observation works without it. (WSL counts as -Linux, so tmux works there unchanged; native Windows awaits a Windows-capable backend.) -`bmad-loop mux` shows which backend is selected and why, and the Settings editor's -`mux.backend` (or the `BMAD_LOOP_MUX_BACKEND` env var) forces the choice per machine. - -**Terminal sizes, as measured.** The modal dialogs — confirmations, the -start/sweep option forms, the escalation, checkpoint, spec and validate viewers -— are the tightest thing the TUI draws. The figures below describe those -**dialogs**, not the dashboard behind them, which simply shows fewer rows as the -window shrinks. **39 columns × 9 rows** is the size at which a dialog shows its -title, a row of body and all of its docked buttons, fully visible — every -dialog included: the spec and validate viewers, the escalation viewer with any -of its hints, and the resume confirmation with its double-drive warning. - -Caller-supplied text cannot move that size. Titles, headers, subtitles and file -paths are docked _outside_ the scrolling body, and their length comes from the -caller — a deferred-work ledger heading, a story title, a `spec:` folder, a -spec path. Each of their lines is held to **one row**, ending in `…` when it is -too long, so a dialog's height depends on how many lines it docks, never on how -long they are. Docked warnings and hints are the TUI's own text and are not -held this way; they are written short enough to fit that size whole. On a -short terminal the resume confirmation's body scrolls rather than push its -warning out. - -What the `…` cuts is not always shown anywhere else. Some of it is: the spec -path is cut from the front so it keeps its file name, and `copy path` copies the -whole path; the deferred-work viewer's scrolling body repeats the full heading, -and its done and legacy markers sit on lines of their own. At narrow widths, -though, a long story title, the end of the validate header's lines — including -its severity tallies and the note that the gates are chained — and the end of a -long dialog title can be cut with no other copy on screen; widen the terminal -to read them. - -Below **80 columns** the spec viewer's action row — `copy path`, the action -verbs and `close` — wraps into two columns instead of clipping its right-most -buttons, and the viewer takes the terminal's full height to fit the second -row. Below about 43 columns the longest label (`Approve & resume`) may lose -its tail to `…`; the button stays whole and still works. - -Below **60 columns or 20 rows** the dialogs degrade to a compact layout rather -than clipping: the dialog clamps to the screen width, the action buttons shrink -and tighten, and vertically the padding, the title margin and the button-row -margin collapse while the buttons render one row tall instead of three. At 60 -columns and 20 rows and above you get the full chrome, unchanged (#281). - -Over a slow or high-latency link (SSH, Tailscale), a 60fps update stream can't -drain in time and partial frames paint over old ones. Launch with -`bmad-loop tui --low-frame-rate` to cap Textual to 15fps and disable -animations (sets `TEXTUAL_FPS` / `TEXTUAL_ANIMATIONS`), or make it permanent -with `[tui] low_frame_rate = true` in `policy.toml` (editable from the settings -screen). An explicit `TEXTUAL_FPS` in the environment still wins. +The `[tui]` extra adds `textual`, `tomlkit`, `pyte` and `rich`; from a source +checkout, `uv sync --extra tui` does the same. `--project` defaults to the +current directory. + +The selected terminal-multiplexer backend (tmux by default; see +[Terminal multiplexer backends](multiplexer-backends.md)) must be available for +the launch and attach keys. Observation works without it. `bmad-loop mux` shows +which backend is selected and why; the settings editor's `mux.backend` or the +`BMAD_LOOP_MUX_BACKEND` environment variable forces the choice per machine. + +Over a slow or high-latency link (SSH, Tailscale), launch with +`bmad-loop tui --low-frame-rate` to cap Textual to 15fps and disable animations, +or set `[tui] low_frame_rate = true` in `policy.toml`. An explicit `TEXTUAL_FPS` +in the environment still wins. + +### Terminal sizes + +The dashboard shows fewer rows as the window shrinks. The modal dialogs +(confirmations, the start forms, and the escalation, checkpoint, spec and +validate viewers) are the tightest thing the TUI draws: + +- **39 columns × 9 rows** is the measured size at which every dialog shows its + title, one row of body, and all of its docked buttons and warnings. +- Titles, headers, subtitles and file paths are held to one row each and end in + `…` when too long, so long caller text never changes a dialog's height. The + spec path is cut from the front so it keeps its file name, and `copy path` + copies the whole path. At narrow widths a long story title or validate header + can be cut with no other copy on screen; widen the terminal to read it. +- Below **80 columns** the spec viewer's action row wraps into two columns and + the viewer takes the terminal's full height. +- Dialogs always clamp to the screen width. Below **60 columns** buttons shrink + to fit their labels. Below **20 rows** padding and margins collapse and + buttons render one row tall, without borders. ## Architecture: observer/launcher, never the engine -The TUI never runs an engine in-process. The two halves: - -- **Launcher** — `r`, `s`, and `e` spawn detached `bmad-loop` processes as - windows of a dedicated tmux session, `bmad-loop-ctl` (on psmux the name - carries a per-project registry suffix — the launch toast names the exact - session; see [multiplexer backends](multiplexer-backends.md)). Windows are named - `run-`, `sweep-`, or `resume-`, run the same Python - interpreter as the TUI (`python -m bmad_loop.cli`, immune to PATH/venv drift - inside tmux), and stay open after exit showing - `[bmad-loop exited — press enter]` so you can inspect failures. - Quitting or crashing the TUI does not touch them. The engine each run drives - lives in a separate `bmad-loop-` session; it is torn down when the run - finishes (unless `[adapter] cleanup_session_on_finish = false`). These parked - `bmad-loop-ctl` windows and any leftover `bmad-loop-` sessions can be - swept with `c` (see [Cleaning up sessions](#cleaning-up-sessions-c)). Each - launch over an existing run records the id of the window it minted in the - run dir (`ctl-window`), so attach/stop follow the run's live window even - while an older same-run-id window is still parked (#482). Attach/stop only - ever reach a window carrying this project's tag: the record breaks ties - among tagged windows but never vouches for an untagged one, so a window - whose tag write failed is left alone until a relaunch tags one (#750). The - same holds when the tag cannot be read at all, and in both cases `x` warns - that the control window was not closed and `a` says why it cannot reach it. -- **Observer** — the dashboard reads only the artifacts the engine writes - atomically into `.bmad-loop/runs//`: `state.json`, `journal.jsonl`, - `logs/.log`, `ATTENTION`, `engine.pid`. It polls the selected run - every second (run list, sprint status, and the deferred-work ledger every 3 - seconds) with stat-gated readers, so unchanged files are never re-parsed. - Runs started from a plain shell show up identically — the TUI has no - privileged channel. - -Fast read-only commands (`validate`, dry runs) are the exception: they are -captured and shown in a scrollable modal instead of spawned in tmux. +The TUI never runs an engine in-process. + +- **Launcher.** `r`, `s`, `e`, `R` and `V` spawn detached `bmad-loop` processes + as windows of a dedicated control session, `bmad-loop-ctl` (on psmux the name + carries a per-project suffix; the launch toast names the exact session). + Windows are named `run-`, `sweep-`, `resume-` and so + on, and run the same Python interpreter as the TUI. A window stays open after + its command exits, showing `[bmad-loop exited — press enter]`, so you + can read failures. Quitting or crashing the TUI does not touch them. The + engine drives its agents in a separate `bmad-loop-` session, torn down + when the run finishes unless `[adapter] cleanup_session_on_finish = false`. + Attach and stop only reach control windows tagged with this project. +- **Observer.** The dashboard reads only the files in + `.bmad-loop/runs//`: `state.json`, `journal.jsonl`, + `logs/.log`, `ATTENTION`, `engine.pid`, and `stop-request.json` + while a stop is pending. It polls the selected run + every second, and the run list, sprint status and deferred-work ledger every 3 + seconds, re-parsing only files that changed. Runs started from a plain shell + show up identically; the TUI has no privileged channel. See the + [run-state reference](reference/run-state.md) for the layout. + +Fast read-only commands (`validate`, dry runs) are captured and shown in a +scrollable modal instead of spawned in a control window. ## Dashboard layout @@ -133,33 +102,27 @@ captured and shown in a scrollable modal instead of spawned in tmux. ### Left column -Three stacked panes; `tab` / `shift+tab` move focus between them, and their -heights (like the sidebar width) are resizable — see [Resizing -panes](#resizing-panes). The sprint and deferred panes read project-level files -written outside the TUI — `sprint-status.yaml`, which the orchestrator writes -while a run is in flight and your own BMAD skill runs generate and edit outside -one, and `deferred-work.md` — so both parse forgivingly: a missing or malformed -file shows a dim placeholder instead of an error, and the pane recovers on the -next poll once the file is readable again. +Three stacked panes. `tab` / `shift+tab` move focus between them, and their +heights and the sidebar width are adjustable (see [Resizing panes](#resizing-panes)). +The sprint and deferred panes read project files that are also edited outside +the TUI, so a missing or malformed file shows a dim placeholder instead of an +error, and the pane recovers on the next poll. #### Run list (top) -One row per run dir under `.bmad-loop/runs/`, oldest first (run ids are -`YYYYMMDD-HHMMSS-` and sort chronologically). Columns: `st` (status -glyph, see below), `run` (the id), `type` (`story` or `sweep`), `note` (a -colored pause-kind badge on a paused run — `plan` / `story` / `spec` / `epic` / -`gate` / `esc`, or `⏹ stop` when a running run has a stop request pending). -When any run is paused awaiting a human the pane's title shows -a global **`⚑ N need attention`** count. On first load the newest run is -auto-selected; arrow keys or mouse select another. A run you just launched is -selected immediately, before its directory even exists. +One row per run under `.bmad-loop/runs/`, oldest first. Columns: `st` (status +glyph, see [Status reference](#status-reference)), `run` (the id), `type` +(`story` or `sweep`), and `note`: a colored pause badge on a paused run (`plan`, +`story`, `spec`, `epic`, `gate`, `esc`, `env`), or `⏹ stop` when a stop request +is pending. When any run is paused waiting for a human, the pane title shows +**`⚑ N need attention`**. The newest run is selected on first load, and a run +you launch is selected immediately, before its directory exists. #### Sprint tree (middle) -Sprint status from `sprint-status.yaml` as one expandable node per epic — -`Epic N · done/total`, fully green with a `✓` once every story is done. -Enter (or click) expands an epic to its stories and retrospective, each with -a status glyph: +`sprint-status.yaml` as one expandable node per epic, `Epic N · done/total`, +green with a `✓` once every story is done. `enter` (or a click) expands an epic +to its stories and retrospective: | Glyph | Status | Color | | ----- | -------------------------- | ------- | @@ -167,20 +130,19 @@ a status glyph: | `▶` | in-progress | cyan | | `◆` | review | magenta | | `○` | ready-for-dev | cyan | +| `⏸` | awaiting-operator | yellow | | `·` | backlog / optional (retro) | dim | | `?` | anything unrecognized | dim | -Expansion state and the cursor survive the 3-second refresh — only labels are -updated in place unless an epic's story set actually changes. +Expansion state and the cursor survive the refresh. #### Stories board (middle, stories mode) -When the selected run is in **stories mode** (`source = "stories"`), the sprint -tree is replaced in place by a flat **stories board** read from that run's -`stories.yaml` + the id-keyed story specs on disk. One row per story: a state -glyph + label, the story `id`, a two-slot checkpoint cell (`S` = `spec_checkpoint`, -`D` = `done_checkpoint`, dim `·` for an unset slot), and the title. The state -column reflects live disk state: +When the selected run is in stories mode (`source = "stories"`), the sprint +tree is replaced by a flat board read from that run's `stories.yaml` and the +story specs on disk. One row per story: state glyph and label, the story `id`, +a two-slot checkpoint cell (`S` = `spec_checkpoint`, `D` = `done_checkpoint`, +dim `·` when unset), and the title. The board re-derives on each poll. | Glyph | State | Color | | ----- | ---------------------------------------- | -------- | @@ -188,712 +150,478 @@ column reflects live disk state: | `▶` | in-progress | cyan | | `◆` | in-review | magenta | | `○` | ready-for-dev | cyan | +| `⏸` | awaiting-operator | yellow | | `◦` | draft | dim | | `·` | pending (no spec on disk yet) | dim | | `✖` | blocked | bold red | | `⚠` | ambiguous / sentinel (`sentinel:`) | bold red | -Selecting a sprint-mode run swaps the sprint tree back. The board re-derives -each poll so it tracks the dev sessions writing story specs. - #### Deferred work (bottom) -Every entry from the `deferred-work.md` ledger, in file order: `DW-` plus -the title, truncated to the pane width. Done entries are green with a `✓`; -open entries are color-coded by the entry's optional `severity:` field — -critical (bold red), high (red), medium (yellow), low (dim), unspecified -(plain). Arrow keys navigate; `enter` opens the full entry body in a -scrollable modal (`escape` closes). +Every entry in the `deferred-work.md` ledger, in file order: its id and title. +Done entries are green with a `✓`; open entries are colored by their optional +`severity:` field: critical (bold red), high (red), medium (yellow), low (dim), +unspecified (plain). `enter` opens the full entry in a scrollable modal. The +pane title also counts decisions waiting to be answered (see +[Answering missed decisions](#answering-missed-decisions-d)). ### Run header (top right) -A one-glance summary of the selected run: id, `[sweep]` tag for sweep runs, -status glyph + word, start timestamp, current epic, and a counts line — -`tasks N · done (green) · deferred (yellow) · escalated (red when nonzero) · -tokens`, where the token figure reads ` tokens ( raw)` — the -cost-weighted total first (cache reads at `limits.cache_read_weight`), the -unweighted one in parentheses. Below the counts, an **agent line** names who is -driving: while a session is open it reads `agent · · ` (the -resolved adapter for the live stage — `model` omitted when the session ran the -CLI profile's default, `role` is the stage `dev` / `review` / `triage`), with a -yellow `· idle ` appended while the session's transcript has sat still past -`limits.dev_stall_grace_s` (#680 — derived from the journal's open -`session-idle`, cleared by its `session-active`; `` is whole minutes, or -`1h05m` above an hour); when no -session is open it falls back to the run's configured adapters, rebuilt from the -run's policy snapshot — `agents ` when dev and review resolve alike, -else `agents dev review `, plus a `triage ` -on sweep runs. A run that predates adapter stamping (no rebuildable snapshot) -shows no agent line at all rather than a fabricated default. An open session whose -identity cannot be derived from a malformed journal entry reads a yellow -`agent unreadable — ` rather than no line, which would look like no session -at all (DW-474). Below that, situational banners: - -- `⏸ paused () — · press e to resume` — gate or escalation - pause; stages are `spec-approval`, `epic-boundary`, `escalation`, - `story-gate`, `environment`. At the `escalation` stage, `e` only skips the - escalated story — press `R` instead to resolve it (see "Resolving an - escalation" below). At the `environment` stage (badge `env`: an - `[environment]` probe failed before a session launch) fix the environment and - press `e` — the resume re-probes, then launches the same session. -- `⏹ graceful stop pending — will stop after the current item` — a graceful - stop was requested (`S`, or `bmad-loop stop --graceful`); the run finishes the - in-flight story/bundle through commit (or, mid-sweep-triage, lets triage - complete and starts no bundles), then finalizes and stops (resumable). The - underlying read is the control file's presence, not its mode, so the same line - shows for as long as a **hard** stop's request sits on disk before the engine - honors it — usually seconds, longer if the session is blocked in a transport - call — and there the current item does not finish. -- `✖ engine gone — run was interrupted · press e to resume` — the recorded +A summary of the selected run: id, a `[sweep]` tag for sweep runs, status, +start time, current epic, and a counts line: tasks, done, deferred, escalated, +`awaiting N` (only when non-zero), and tokens as ` tokens ( raw)`. Weighted totals count cache reads +at `limits.cache_read_weight`. + +The **agent line** names who is driving. While a session is open it reads +`agent · · ` (`model` omitted when the CLI profile's +default is used; `role` is the session's stage, such as `dev`, `review`, +`triage` or `retro`), with a yellow +`· idle ` once the session's transcript has been quiet past +`limits.dev_stall_grace_s`. Between sessions it shows the run's configured +adapters from its policy snapshot. A run with no snapshot shows no agent line; +an unreadable session identity shows `agent unreadable — `. + +Situational banners below that: + +- `⏸ paused [] — `, then `press p to review · e to resume`: the + run stopped at a checkpoint, gate or escalation. Stage labels are + `plan checkpoint`, `story checkpoint`, `spec-approval gate`, `epic gate`, + `story gate`, `escalation` and `environment fault`. At an escalation, `e` + skips the escalated story; press `R` to resolve it instead. At an environment + fault an `[environment]` probe failed; fix the environment and press `e` to + re-probe and continue. +- `⏹ graceful stop pending — will stop after the current item`: a stop request + is on disk. A graceful stop finishes the in-flight item through commit, then + stops (resumable). A hard stop's request shows the same line until the engine + honors it, usually within seconds. +- `✖ engine crashed — see crash.txt · press e to resume`: the engine recorded + a crash; the error follows on the next line. +- `✖ engine gone — run was interrupted · press e to resume`: the recorded engine pid is dead. -- `⚑ decision needed: DW- — / press a to attach and answer` — - an attended sweep is blocked on a human decision (see below). -- `⧗ starting… waiting for the engine to write state.json` — just launched; - if nothing appears within 10 seconds the TUI raises a "launch may have - failed" error toast. -- `⚠ state stale — state.json unreadable (); showing the last good read` - (DW-472) — the header keeps the last `state.json` that parsed, and this line says - it is no longer current: the file stopped parsing, went away, or cannot be - stat'd. It appears only once the same unchanged file has failed on two - consecutive polls, so a read torn by a write in progress never flashes it, and it - clears on the next good parse. A run whose `state.json` never parsed reads - `state unavailable — `. -- `⚠ journal.jsonl …` / `⚠ ATTENTION …` (dim, DW-475) — the poll could not stat or - read that file this tick; the Journal / Attention tab holds what was read before. - The journal tail keeps its position across the fault, so nothing is re-listed or - skipped when it clears. +- `⚠ auto-sweep not run: () — deferred work is untouched`: an + auto-sweep was refused; run `bmad-loop sweep` with a clean worktree. A sweep + that ran shows a dim `auto-sweep ran: ` line. +- `⚑ decision needed: — / press a to attach and answer`: an + attended sweep is waiting on you (see + [Answering a sweep decision](#answering-a-sweep-decision)). +- `⧗ starting… waiting for the engine to write state.json`: just launched. If + nothing appears within 10 seconds, an error toast says the launch may have + failed. +- `⚠ state stale — state.json unreadable (); showing the last good read`: + the file failed to parse on two consecutive polls; the header keeps the last + good read until the file parses again. A run whose `state.json` never parsed + shows `state unavailable — `. +- `⚠ journal.jsonl …` / `⚠ ATTENTION …` (dim): that file could not be read this + poll; its tab keeps what it already showed. ### Task table (middle right) -One row per story (or sweep bundle/triage task) in the selected run: +One row per story (or sweep bundle and triage task) in the selected run: -| Column | Meaning | -| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `story` | story key, or the sweep task id | -| `phase` | `pending` → `dev-running` → `dev-verify` → `review-running` → `review-verify` → `committing` → `done`; terminal alternatives `deferred` / `escalated`; sweep triage shows `triage-running` / `triage-verify` | -| `agent` | the adapter driving this story, `` (`·model` dropped when it's the profile default). The live agent when it's working this row, else the last session that stamped an identity (#153), else `-` (nothing recorded) | -| `dev` | dev attempt counter, `×N` | -| `review` | review cycle counter, `×N` | -| `tokens` | cost-weighted token total for the story — cache reads counted at `limits.cache_read_weight` (default 0.1), the same total the budgets judge. `-` until known; a real `0` (possible with the weight set to 0) is shown as `0`, never `-` | -| `info` | defer reason, or the commit SHA (first 12 chars) once committed | -| `raw` | unweighted token total, cache reads at full price — typically 5–8x the `tokens` column on agentic work | +| Column | Meaning | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `story` | story key, or the sweep task id | +| `phase` | `pending` → `dev-running` → `dev-verify` → `review-running` → `review-verify` → `committing` → `done`, or `deferred` / `escalated` / `awaiting-operator` (committed, human action owed); sweep triage shows `triage-running` / `triage-verify` | +| `agent` | `` of the adapter driving or last driving this row (`·model` dropped for the profile default); `-` if none recorded | +| `dev` | dev attempt counter, `×N` | +| `review` | review cycle counter, `×N` | +| `tokens` | cost-weighted token total (cache reads at `limits.cache_read_weight`), the total budgets judge; `-` until known | +| `info` | defer reason, or the first 12 characters of the commit SHA once committed | +| `raw` | unweighted token total, cache reads at full price | ### Tabs (bottom right) -- **Journal** — every engine decision, live-tailed from `journal.jsonl`. Line - format: `HH:MM:SS field=value …` (long values truncated with `…`). - Kinds are color-coded — see the reference below. Every session lands a - `session-end` unconditionally (status `aborted` when the outcome is - unknowable, even after a teardown that threw); a timed-out one carries - `fired_at`, `teardown_s`, and `expired_clock` (`monotonic` / `wall` / `both` — - `wall` alone fingerprints a host suspend that froze the monotonic clock). - Entries whose usage was read carry `tokens` (raw) and `tokens_weighted` (cache - reads at `limits.cache_read_weight`); both are `null` if the usage read failed - and both are absent on an `aborted` end. `tokens_weighted` is the - end-of-session total, distinct from a tripped session's `budget_weighted` - (the guard's mid-session sample at trip time). The - matching `tasks//` dir holds the forensic breadcrumbs the adapter wrote - while the session ran: `session-lifecycle.jsonl` (timeout-fire — its - `probe_failures` counts a liveness-probe failure streak open at the deadline and - its `foreign_hook_events` the hook events dropped as a nested CLI's —, - budget-guard `budget-tripped` / `over-budget-fired`, kill-escalation — both - of these carry `liveness_unknown`, true when the verdict's probe raised —, - `session-vanished` (the mux no longer reported the session during the run, #489), - `foreign-hook-event-ignored` (a nested coding CLI's hook events dropped by session - attribution, #767 — written once per foreign id, with `hook_event`, - `foreign_session_id`, and `dropped_so_far`, the running count of dropped events; - id-less drops, which only a trusted relay-side lineage `mismatch` makes, share - one crumb with `foreign_session_id: null`, DW-507), - `hook-lineage-untrusted` (once per session when the first `SessionStart` could - not calibrate relay-side lineage, so only the #767 rules apply — `reason` is - `miscalibrated` or `unavailable`, `lineage` the first start's tag, DW-507), - `pinned-session-id-mismatch` (once per session when the first identified - non-`clear`/`compact` `SessionStart` of a pinned launch reports an id other than - the pin — a rejected `mismatch`-tagged nested start is skipped — - `pinned_session_id`, `reported_session_id`, `source`, DW-509), - mux transport faults `liveness-probe-failed` / `liveness-probe-recovered` / - `nudge-send-failed`, observation faults `parked-probe-failed` / - `log-evidence-failed` / `result-json-refused` / `usage-sample-failed` / - `usage-sample-recovered` / `transcript-scan-failed` / `transcript-scan-recovered` / - `spec-identity-unreadable` / `spec-digest-unreadable` / `spec-readback-failed`, - an abandoned post-kill rescue - `post-kill-rescue-abandoned` (liveness unknown or an unreadable artifact), and the - #276 missing-marker forensics `spec-status-transition-observed` / - `frontmatter-unmodified-refused` / `contract-nudge-sent`), - `heartbeat.json` (the wait loop's proof-of-life — - stale under a live session means the orchestrator itself was frozen; on the - generic adapter it also carries `probe_failures`, the running liveness-probe - failure streak, `stall_nudges_failed`, `usage_sample_failures`, the running - budget usage-sample failure streak, and `foreign_hook_events`, the running count - of hook events dropped as a nested CLI's, #767), and - `resultless-stops.jsonl` (each give-up Stop with its verdict: `no-result-json` / - `malformed-result-json`, `no-artifact`, `stat-failed` / `unreadable-spec` (a spec - read fault, with the error), - `ambiguous-frontmatter`, `unmodified-since-launch` — the spec's bytes were - unchanged since review launch, so it is a prior `done` re-opened, not this - session's output (#276) — or `terminal-frontmatter-pending`). A dev session - that ends non-completed also shows the last of these on its `session-end` - journal entry, as `resultless_verdict` / `resultless_detail` (#780). -- **Log** — the active agent session's pane output (`logs/.log`), - ANSI colors preserved, starting with a dim `— .log —` header. The - active task is the last `session-start` without a matching `session-end` - (falling back to the newest log file); the tab switches automatically when - the engine moves to the next session. Only the last 64 KB of a large log is - read on first open. If the scan of the skipped head for a fullscreen - (alt-screen) switch cannot read the file, a yellow `⚠ could not scan this log's -head for a fullscreen switch` note says the pane may show only the final frame - (DW-475). -- **Attention** — the run's `ATTENTION` file (escalations, gate - notifications). New lines after the first poll also fire a warning toast. +- **Journal**: every engine event, live-tailed from `journal.jsonl` as + `HH:MM:SS field=value …`, colored by kind (see + [Status reference](#status-reference)). `enter` on an entry that carries a + log position jumps the **Log** tab to that point and pins it there; `escape` + unpins it and resumes following new output. +- **Log**: the active agent session's pane output (`logs/.log`) with + ANSI colors, under a dim `— .log —` header (suffixed + `(pinned — esc to follow)` after a journal jump). The active task is the last + `session-start` without a matching `session-end` (else the newest log), and + the tab follows the engine to the next session. Only the last 256 KB of a + large log is read on first open. A session that ran a fullscreen + (alt-screen) TUI shows a yellow note that the pane holds only the final + frame, with the full transcript's path when known. +- **Attention**: the run's `ATTENTION` file (escalations, gate notices). New + lines after the first poll also raise a warning toast. + +The per-session files the adapter writes under `tasks//` are listed in +the [run-state reference](reference/run-state.md#task-directory). ### Selecting and copying text -The **Log** and **Attention** panes are click-drag selectable: highlight with -the mouse and press `ctrl+c` to copy the selection, or press `y` to copy the -whole active pane in one keystroke. The other panes (Runs, Sprint, Deferred -Work, the task table, Journal) are interactive widgets and are not drag- -selectable in-app. +The **Log** and **Attention** panes are click-drag selectable: select with the +mouse and press `ctrl+c`, or press `y` to copy the whole active pane. The other +panes are interactive widgets and are not drag-selectable. -To select text in _any_ pane — or when the in-app copy can't reach your system -clipboard — hold your terminal's bypass modifier while dragging to use the -terminal's own selection instead of the TUI's: **Shift** on most Linux -terminals and Windows Terminal, **Option/Alt** on iTerm2/macOS. +To select text in any pane, or when the in-app copy cannot reach your system +clipboard, hold your terminal's bypass modifier while dragging: **Shift** on +most Linux terminals and Windows Terminal, **Option/Alt** on iTerm2/macOS. -`ctrl+c` and `y` copy via OSC 52. Inside tmux that only reaches the system -clipboard when tmux forwards it — set `set -g set-clipboard on` and use an -OSC 52-capable outer terminal. The Shift/Option-drag native selection above is -the reliable fallback when OSC 52 isn't available. +`ctrl+c` and `y` copy via OSC 52. Inside tmux that reaches the system clipboard +only with `set -g set-clipboard on` and an OSC 52-capable outer terminal. ## Status reference -Run status is classified from `state.json` plus a liveness probe: - -| Glyph | Status | Color | Meaning | -| ----- | ----------- | -------- | ----------------------------------------------------------- | -| `▶` | running | green | not finished, not paused, engine pid alive | -| `⏸` | paused | yellow | engine is waiting at a gate or escalation — `e` resumes | -| `✔` | finished | dim | run completed | -| `✖` | interrupted | bold red | engine pid is dead but the run never finished — `e` resumes | -| `?` | unknown | dim | liveness can't be determined, or `state.json` is unreadable | - -Liveness is **local-only**: `engine.pid` is checked with `os.kill(pid, 0)`. -A run driven on another host (shared checkout) always shows `unknown`, never -falsely `interrupted`. Legacy runs without a pid file fall back to probing the -per-run tmux session, which can prove `alive` but never `dead`. - -Journal kinds are styled by substring, first match wins — except the reader-minted -marker below, which is matched by EQUALITY before the substring table runs, so a -producer kind that merely contains its spelling is not restyled: - -| Match | Color | Examples | -| ----------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `journal-line-unreadable` (exact kind, matched first) | red | a journal line that could not be parsed; reader-minted, never written by the engine. Renders `bytes=` and nothing else — deliberately no timestamp (unknowable) and no content from the line (it can carry session text) | -| `escalat`, `failed` | red | `preference-escalation`, `review-verify-failed` | -| `done`, `complete`, `finished` | green | `story-done`, `run-complete` | -| `decision`, `deferred`, `boundary`, `truncated` | yellow | `decision-pending`, `epic-boundary` | -| `start`, `resume` | cyan | `session-start`, `run-resume` | -| anything else | dim | | +Run status comes from `state.json` plus a liveness check of `engine.pid`: + +| Glyph | Status | Color | Meaning | +| ----- | ----------- | ----------- | ---------------------------------------------------------------------------------------- | +| `▶` | running | green | not finished, not paused, engine pid alive | +| `⏸` | paused | yellow | stopped at a checkpoint, gate or escalation; the engine exited. `p` reviews, `e` resumes | +| `⏹` | stopped | bold yellow | stopped deliberately (`x` / `S`); `e` resumes | +| `✖` | crashed | bold red | the engine recorded a crash (see `crash.txt`); `e` resumes | +| `✔` | finished | dim | run completed | +| `✖` | interrupted | bold red | engine pid is dead but the run never finished; `e` resumes | +| `?` | unknown | dim | liveness can't be determined, or `state.json` is unreadable | + +Liveness is local only. A run driven from another host on a shared checkout +shows `unknown`, never `interrupted`. See +[Derived run status](reference/run-state.md#derived-run-status). + +Journal kinds are colored by the first matching substring: + +| Match | Color | Examples | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `journal-line-unreadable` (exact kind) | red | a journal line that could not be parsed; shows only `bytes=` | +| `escalation-resolved`, `escalation-adopted` | green | positive escalation outcomes, matched before `escalat` | +| `escalat`, `failed` | red | `preference-escalation`, `review-verify-failed` | +| `done`, `complete`, `finished` | green | `story-done`, `run-complete` | +| `decision`, `deferred`, `boundary`, `truncated` | yellow | `decision-pending`, `epic-boundary` | +| `start`, `resume` | cyan | `session-start`, `run-resume` | +| anything else | dim | | ## Key bindings | Key | Action | | -------- | --------------------------------------------------------------------------------------------- | | `r` | start a run (modal) | -| `s` | start a sweep (modal) | +| `s` | start a deferred-work sweep (modal) | | `e` | resume the selected paused/interrupted run (confirm modal) | -| `p` | review the selected paused run in the stage-appropriate HITL viewer | -| `R` | resolve a run paused at an escalation (interactive, then re-arm) | -| `V` | re-verify a story's kept work (`resolve --reverify`, pick target) | -| `d` | answer deferred-work decisions past sweeps left unanswered (modal walk) | -| `a` | attach to the selected run's live session or orchestrator window | +| `p` | review the selected paused run in its HITL viewer | +| `R` | resolve a run paused at an escalation, then re-arm | +| `V` | re-verify a story's kept work (pick target) | +| `d` | answer deferred-work decisions sweeps left open (modal walk) | +| `a` | attach to the selected run's live session or window | | `x` | stop the selected live run, abandoning the in-flight item (confirm modal) | -| `S` | graceful stop: finish the in-flight item, then finalize & stop (confirm) | +| `S` | graceful stop: finish the in-flight item, then finalize and stop (confirm modal) | | `D` | delete the selected run's directory (confirm modal) | | `A` | archive the selected run to `.bmad-loop/archive` (confirm modal) | -| `c` | clean up tmux sessions/windows for finished & stopped runs (confirm modal) | +| `c` | clean up multiplexer sessions of finished runs (confirm modal) | | `v` | run `bmad-loop validate`, findings in a modal (`d` toggles detail) | | `g` | settings editor for `.bmad-loop/policy.toml` | -| `M` | toggle theme (light/dark mode) | +| `M` | toggle light/dark mode | | `y` | copy the active Log/Attention pane to the clipboard | -| `ctrl+w` | enter/leave pane **resize mode** (see below) | +| `escape` | unpin the log view and follow new output | +| `ctrl+w` | enter/leave pane resize mode | | `q` | quit (running engines are unaffected) | | `ctrl+p` | command palette: every command above, filtered to what is live on the current screen or modal | -In the settings editor: `ctrl+s` saves, `ctrl+e` expands/collapses all -sections, `escape` goes back without saving. In any modal: `escape` cancels. -These are in the command palette too, listed only inside the editor or modal. +In the settings editor: `ctrl+s` saves, `ctrl+e` expands or collapses all +sections, `escape` goes back without saving. In any modal, `escape` cancels. +The palette lists these only inside the editor or modal. ### Resizing panes -Every pane boundary is adjustable, by **mouse** or **keyboard**, and the sizes -persist per-project to `[tui]` in `policy.toml` (re-applied on the next launch). +Every pane boundary is adjustable by mouse or keyboard. Sizes persist per +project under `[tui]` in `policy.toml` and are re-applied on the next launch. -- **Mouse** — drag any divider bar: the column split between the left sidebar and - the detail pane, the bars between the stacked left-column panes (they double as - the Sprint / Deferred Work section headings), and the bar between the Tasks - table and the Journal/Log/Attention tabs. Bars highlight on hover. -- **Keyboard** — press `ctrl+w` to enter resize mode (the header shows a hint): - `←`/`→` widen or narrow the sidebar, `↑`/`↓` move the active horizontal - boundary, `Tab`/`Shift+Tab` pick which horizontal boundary is active, and - `Esc` (or `Enter`) leaves the mode. Outside resize mode the arrows and `Tab` - keep their usual navigation/focus behavior. +- **Mouse**: drag a divider bar: the split between the sidebar and the detail + pane, the bars between the stacked left-column panes (they double as the + Sprint and Deferred Work headings), and the bar between the task table and the + tabs. Bars highlight on hover. +- **Keyboard**: `ctrl+w` enters resize mode (the header shows a hint). `←`/`→` + resize the sidebar, `↑`/`↓` move the active horizontal boundary, + `tab`/`shift+tab` pick which horizontal boundary is active, and `escape` or + `enter` leaves the mode. -To forget custom sizes, delete the `[tui]` size keys from `policy.toml` (or set -them to `0`) — the panes return to their default proportions. +To return to the default proportions, delete the `[tui]` size keys from +`policy.toml` or set them to `0`. ## Starting runs and sweeps (`r` / `s`) -`r` opens the **start run** modal — all fields optional: - -- **source** — `sprint mode` (walks `sprint-status.yaml`) or `stories mode` (folder+id dispatch), prefilled from `[stories]` -- **spec folder** — stories mode only: the epic spec folder holding `stories.yaml` + `SPEC.md`; feeds a live **schedule preview** that validates the manifest and lists the linear schedule with `[spec/done]` checkpoint markers and each story's live disk state -- **epic** — integer, restrict to one epic (sprint mode); blank = all -- **story key** — restrict to one story; a story id in stories mode; blank = all -- **max stories** — stop after N stories; blank = no limit -- **dry run** — print the plan, spawn nothing (output shown in a modal) +`r` opens the **start run** modal. Every field is optional: -Selecting stories mode with an empty spec folder is refused with a toast; the run's preflight also validates the manifest + `SPEC.md` before spawning. +- **source**: `sprint mode` (walks `sprint-status.yaml`) or `stories mode` + (folder-and-id dispatch), prefilled from `[stories]`. +- **spec folder**: stories mode only. The epic spec folder holding + `stories.yaml` and `SPEC.md`; it drives a live schedule preview that validates + the manifest and lists the schedule with `[spec/done]` checkpoint markers and + each story's state. Stories mode with an empty spec folder is refused. +- **epic**: restrict to one epic (sprint mode). +- **story key**: restrict to one story (a story id in stories mode). +- **max stories**: stop after N stories. +- **dry run**: print the plan without spawning anything; output shows in a modal. `s` opens the **start sweep** modal: -- **unattended (`--no-prompt`)** — skip decision prompts, leave decisions open -- **decisions only** — triage + answer decisions, run no bundles -- **max bundles** — override the policy's `[sweep] max_bundles`; blank = policy default -- **dry run** — list open ledger entries, spawn nothing +- **unattended (`--no-prompt`)**: skip decision prompts and leave decisions open. +- **decisions only**: triage and answer decisions, run no bundles. +- **max bundles**: override `[sweep] max_bundles`. +- **dry run**: list open ledger entries without spawning anything. -Before any real launch the TUI applies the same guard as the CLI: +`r` and `s` open only when the multiplexer backend is available. Before a real +launch the TUI applies the same guard as the CLI: -1. tmux must be on PATH. -2. The git worktree must be clean — otherwise an error toast, no launch. -3. If another run on this project is currently `running`, a confirmation - modal lists it and asks before you "launch anyway" (two engines on one - project may conflict). +1. git must meet the supported version floor. +2. Worktree isolation must not conflict with the configured `repo_root`. +3. The code root's git worktree must be clean (`.bmad-loop/policy.toml` is + exempt). +4. If another run on this project may be live, or the run listing could not be + fully read, a confirmation (`another run may be live`) asks before you + **launch anyway**. -On success a toast names the run id and the `bmad-loop-ctl` session, and the -dashboard selects the new run, showing `⧗ starting…` until `state.json` -appears. +On success a toast names the run id and the control session, and the dashboard +selects the new run. ## Resuming (`e`) -`e` acts on the selected run. It refuses runs that are already finished or -whose state is unreadable. The confirmation modal shows what you are resuming: - -- paused runs: `paused at — ` in yellow; -- non-paused runs: `run is not paused — it looks interrupted` (dim); -- and, in bold red, `engine.pid may still be live — resuming could double-drive -this run` when the original engine still appears to be running. Heed this - one: two engines driving one run dir corrupt each other's state. It can also - mean the pid was recycled by another process — verify before resuming. - -After the multiplexer and engine-liveness checks, confirming an unfinished sweep -checks whether the main checkout's deferred-work ledger can be read. An undecodable ledger or a -permissions/storage read failure shows an error toast with the same repair guidance -as the CLI, and no detached window launches. Repair the ledger by hand (or fix its -permissions or storage), then resume again to keep the in-flight bundle recovery. -The toast also offers `bmad-loop sweep` after committing or stashing changes so the -worktree is clean. Story runs and cases where the probe cannot locate the ledger -retain the existing handoff, as does an absent ledger. The probe does not check an -isolated unit worktree's ledger; an unreadable copy there may still re-pause the run -after handoff until that copy is repaired. - -When these checks pass, confirming spawns `bmad-loop resume ` detached in `bmad-loop-ctl`, -like any other launch. Resume drops any stale `bmad-loop-` session a -stopped or interrupted run left behind and spins up a fresh one, so the run -never re-attaches to a dead session. +`e` resumes the selected unfinished run: paused, stopped, crashed or +interrupted. It refuses finished runs and runs whose state is unreadable. The confirmation shows `paused at — ` for a paused +run, or `run is not paused — it looks interrupted`. + +If the original engine still appears to be running, the confirmation adds, in +bold red, `engine.pid may still be live — resuming could double-drive this run`. +Confirming checks again, and if the engine still looks live nothing launches. +Two engines driving one run corrupt each other's state; the pid may also have +been recycled by another process. Stop the run, or confirm the pid belongs to +another process, before you resume. + +For an unfinished sweep, confirming first checks that the deferred-work ledger +in the main checkout is readable. If it is not, an error toast carries the same +repair guidance as the CLI and nothing launches; repair the ledger, then resume +again. See [When the ledger won't read](sweep-guide.md#when-the-ledger-wont-read). + +Confirming spawns `bmad-loop resume ` in the control session. Resume +replaces any stale `bmad-loop-` session with a fresh one. ## Cleaning up sessions (`c`) -`c` removes leftover tmux artifacts for the current project in one pass, after a -confirmation modal: every `bmad-loop-` agent session whose run has -finished, stopped, or crashed (and any orphan whose run dir is gone), plus the -parked `[bmad-loop exited …]` windows in `bmad-loop-ctl`. Live runs, the window -you triggered the cleanup from, and any session or window belonging to another -project are always spared, so it is safe to press at any time even with other -projects' runs in flight. A toast reports how many sessions and windows were closed. The same -sweep is available from a plain shell as `bmad-loop cleanup` (`--dry-run` to -preview). Runs already tear their own session down on finish unless you set -`[adapter] cleanup_session_on_finish = false`; `c` is for the backlog that -predates that, or that the flag deliberately keeps around. - -`c` only reaps tmux artifacts, not disk. To reclaim run-dir **disk** — worktrees a -mid-flight stop orphaned (each holding a Unity `Library/`), and old run dirs — use -the `bmad-loop clean` CLI command (see [Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk)); -it is intentionally not bound to a TUI key since it deletes/archives run history. +`c` closes leftover multiplexer artifacts for this project after a +confirmation: the agent sessions of runs whose engine is no longer alive +(including orphans whose run directory is gone) and the parked +`[bmad-loop exited …]` windows in the control session. Live runs, the window +you pressed `c` from, and other projects' sessions are always spared. A toast +reports how many sessions and windows closed. The shell equivalent is +`bmad-loop cleanup` (`--dry-run` to preview). + +`c` does not reclaim disk. Use `bmad-loop clean` for run directories and +orphaned worktrees; it has no TUI key. See +[Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk). + +`D` (delete) and `A` (archive) act on one run directory and refuse a live run +with `run is live — stop it first`. ## Resolving an escalation (`R`) -`R` is the escalation-specific counterpart to `e`. It is only offered for a run -paused at the `escalation` stage (otherwise it warns and does nothing) and -refuses a run whose engine is still live. A CRITICAL escalation parks its story -in a terminal `escalated` phase that plain `resume` skips — `R` is how you get -it un-stuck. - -Confirming launches `bmad-loop resolve ` in a `bmad-loop-ctl` window and -**attaches you to it** (the resolve agent is interactive). You converse with the -agent — it is seeded with the escalation detail and the frozen spec — to -disambiguate the spec. When it has recorded a resolution, the same window prompts -`re-arm and resume run ? [y/N]`; answer `y` and it re-arms the story -(`escalated → pending`, spec status reset to `ready-for-dev`) and resumes the run -in place — a clean rebuild against the corrected spec, then on through the rest -of the sprint. Detach (`Ctrl-b d` on tmux; other backends have their own chord — -herdr's is `ctrl+b q`) to return to the dashboard, which observes the -resumed run like any other. Exiting the agent without recording a resolution -leaves the story escalated and the run paused — the safe default. - -`R` does not apply to two pauses that can look like one. A run paused at the -`environment` stage needs no resolve: `R` says so and points at resume (fix the -environment, then `e`). A pause whose story was **deferred** rather than -escalated (a manual-recovery pause after a defer) is not re-armed either: `R` -and `p` open the **re-verify** picker instead (below). +`R` works only on a run paused at the `escalation` stage and refuses a run +whose engine may still be live. A CRITICAL escalation parks its story at +`escalated`, which a plain resume skips; `R` is how you un-stick it. + +1. Confirming launches `bmad-loop resolve ` in a control window and + attaches you to it. +2. Work with the resolve agent, which is seeded with the escalation and the + frozen spec, to fix the spec. +3. When the agent exits, the window asks + `re-arm and resume run ? [y/N]`, after a + `no resolution recorded` note if the agent wrote none. Answer `y` to re-arm + the story and resume the run; any other answer leaves the story escalated + and the run paused. +4. Detach (`ctrl-b d` on tmux) to return to the dashboard. + For every resolve mode, see + [Resolving an escalation](escalations-guide.md#resolving-an-escalation). + +At an `environment` pause, `R` refuses: fix the environment, then resume with +`e`. At a pause whose +story was **deferred** rather than escalated, `R` and `p` open the re-verify +picker (below). ### Re-verifying kept work (`V`) -`V` re-verifies a story's kept work with -[`bmad-loop resolve --reverify`](escalations-guide.md#resolve-reverify). It opens a -small picker listing every target in the selected paused run: the paused story -first when it is DEFERRED, or ESCALATED at an environment-fault site a replay can -clear (marked `(paused story)`), then each DEFERRED worktree unit under any -pause, in run order (marked `(worktree unit)`). Only the paused story is listed -among escalated ones, and only at such a site — any other escalation's remedy is -`R`, which still opens the resolve agent for the escalated paused story too. -Confirming launches `bmad-loop resolve --reverify` in a control window and -attaches, adding `--story ` for every target except the paused story. -Everything binding -happens in that window: the CLI states HEAD and the baseline, warns that -commits and edits made since the pause are squashed into the story's commit, -asks you to confirm, re-checks the engine under the run lock and resumes — and -refuses there what it refuses (a sweep run, a story with no completed dev -result, …). To move on instead, resume with `e`. - -`V` warns and launches nothing on an unpaused run, on a run with nothing to -re-verify, or — checked when you confirm the picker — when the engine may still -be live. When `R` refuses a pause or confirms an escalation -while DEFERRED worktree units sit under it, its notice or confirm names those -units and points at `V`. +`V` runs [`bmad-loop resolve --reverify`](escalations-guide.md#resolve-reverify) +on a paused run. It opens a picker listing every target: the paused story +(marked `(paused story)`) when it is deferred, or escalated at an +environment-fault site a replay can clear, then each deferred worktree unit +(marked `(worktree unit)`). Confirming launches the command in a control window +and attaches. The CLI then shows HEAD and the baseline, asks you to confirm, and +resumes, or refuses what it cannot re-verify. + +`V` launches nothing on an unpaused run, on a run with nothing to re-verify, +or when the engine may still be live. To move on without re-verifying, resume +with `e`. ## Reviewing a paused run (`p`) -`p` opens the **stage-appropriate HITL viewer** for the selected paused run, -dispatched on `RunState.paused_stage`. Every action calls the exact code path the -CLI uses — no duplicated logic — and every viewer is a read-only presentation of -artifacts the engine already wrote. - -- **Plan checkpoint** (`spec_checkpoint`, stories mode) — a read-only viewer of - the planned `ready-for-dev` spec at its id-keyed path (shown prominently, with a - copy-path action). Under worktree isolation the path is anchored on the tree the - run owns, not on the directory the dashboard was launched from, so the viewer and - the replan write both act on the run's own copy rather than the main checkout's - twin. A spec that cannot be read at that path says so explicitly and its actions - are disabled — an unreviewable gate is not approvable. **Approve & resume** resumes straight to implementation; - **Request replan** resets the spec to `draft` and strips its Auto Run Result - (via the same `devcontract` primitives the engine's repair path uses), then - resumes so the next dispatch re-plans. Edit the markdown in your own editor — the - TUI never edits specs. -- **Story checkpoint** (`done_checkpoint`, stories mode) — a summary card for the - just-committed story: id/title, commit subject + short hash, verification - outcome, and cost-weighted + raw token totals. **Continue run** resumes the - schedule; **Stop run** marks the run stopped. -- **Escalation** — the escalation view enriched with story context: the story - entry's title/description (from `stories.yaml`), the blocking condition parsed - from the spec's `## Auto Run Result`, and a sentinel indicator when the matched - spec is a fixed-slug pre-planning-halt sentinel. Both of those answer from the - tree the run owns: under isolation the spec text and the sentinel live in the - unit's mount, and reading either from the launch directory let one modal - contradict itself. A spec that cannot be read reports its blocking condition as - unknown rather than absent — the two are otherwise indistinguishable — and - refuses **Re-arm & resume**, which would flip the spec's frontmatter, strip its - result and re-stamp the baseline on evidence nobody could read. **Resolve** stays - offered: it neither re-arms nor rewrites the spec — it writes the resolver's - `context.json` and starts the repair session, which is what repairs a bad anchor — and - gating it would have left `close` as the modal's only action while the `R` binding - reached the same agent anyway. **Resolve** launches the same interactive agent as `R`; - **Re-arm & resume** (offered once the resolve agent has recorded a resolution) - re-arms and resumes — deleting a sentinel with a preserved copy for a clean - re-dispatch. Both refuse a still-live engine. -- **Spec-approval / epic / story gate** — a spec-approval gate reuses the spec viewer - (view the finalized spec, then **Approve & resume**), so the pre-existing sprint-mode - gate inherits the same richer surface — including the anchored read and the refusal - of **Approve & resume** on a spec that cannot be read. Story-gate and epic-boundary pauses have no - spec to show — a story gate fires before the story is recorded, or on a sweep bundle - whose intent regeneration the ledger refused (its task may carry a spec file, but the - gate is about the ledger, not the spec), an epic boundary has no story at all — so - they open a compact pause-reason viewer instead: the reason names - the blocking entries and the remedy. An `environment` pause opens the same - spec-less viewer, whose reason names the failed probe; **Resume** re-runs - the probes first and re-pauses unchanged while one still fails. **Resume** re-picks a gated sprint story; - for a bundle-regeneration pause, it recovers the same persisted bundle and regenerates - its intent document. Both re-ask the ledger, so an unresolved refusal re-pauses. - -`p` and `R` overlap for an escalation (both reach Resolve); `p` also exposes -Re-arm & resume inline once a resolution exists. Pause badges in the run list and -the run header name the stage so you know which viewer `p` will open. +`p` opens the viewer for the selected run's pause stage. Every action calls the +same code path as the CLI, and every viewer reads files the engine already +wrote. The pause badges in the run list and header name the stage, so you know +which viewer `p` opens. + +- **Plan checkpoint** (`spec_checkpoint`, stories mode): the planned + `ready-for-dev` spec, with a copy-path action. Under worktree isolation the + path is the run's own copy. **Approve & resume** continues to + implementation; **Request replan** resets the spec to `draft` and resumes so + the next dispatch re-plans. Edit the spec in your own editor; the TUI never + edits specs. +- **Story checkpoint** (`done_checkpoint`, stories mode): a summary of the + just-committed story (title, commit, verification outcome, tokens). + **Continue run** resumes; **Stop run** marks the run stopped. +- **Escalation**: the story's title and description, the blocking condition from + the spec's `## Auto Run Result`, and a marker when the spec is a pre-planning + sentinel. **Resolve** launches the same agent as `R`. **Re-arm & resume** is + enabled once a resolution is recorded. It re-drives the story from scratch, + so a recorded restore patch is dropped (the viewer warns); run + `bmad-loop resolve` to apply one. +- **Spec-approval gate**: the spec viewer; **Approve & resume** continues. +- **Story gate, epic boundary, environment**: a pause-reason viewer that names + the blocking entries, or the failed probe, and the remedy. **Resume** re-checks + the condition and re-pauses if it still holds. + +A viewer whose spec cannot be read says so and disables the actions that would +act on it. Every action that resumes refuses a still-live engine. ## Attaching (`a`) and the sweep decision flow `a` picks its target in this order: -1. **Decision-blocked sweep, or no live agent session** → the run's - orchestrator window in `bmad-loop-ctl` (only exists for runs launched from - the TUI). -2. **Live agent session** → the per-run tmux session `bmad-loop-` - where the coding CLI is working. -3. Neither → a warning; there is nothing to attach to (runs started outside - the TUI between sessions, finished runs). - -If the TUI itself is running inside tmux, attach uses `switch-client` — the -TUI keeps running and you switch back with your usual tmux client commands. -Outside tmux, the TUI suspends, runs `tmux attach`, and resumes when you -detach (`ctrl-b d`). External backends follow the same two paths with their own -chords and caveats — on the herdr adapter the detach chord is `ctrl+b q` and one -hand-back is manual; see -[its operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). +1. **A sweep waiting on a decision, or no live agent session**: the run's + orchestrator window in the control session (only runs launched from the TUI + have one). +2. **A live agent session**: the run's `bmad-loop-` session. +3. **Neither**: a warning that there is nothing to attach to. + +Inside tmux, attach switches your client and the TUI keeps running. Outside +tmux, the TUI suspends, attaches, and comes back when you detach (`ctrl-b d`). +Other backends have their own detach chord; see +[Terminal multiplexer backends](multiplexer-backends.md). ### Answering a sweep decision -An attended sweep that reaches a "needs human decision" entry blocks on its -own terminal prompt. The dashboard spots the `decision-pending` journal event -and shows the `⚑ decision needed: DW-` banner plus a one-time warning -toast. Then: - -1. Press `a` — with a decision pending this always targets the sweep's - orchestrator window, where the prompt is waiting. -2. Answer the prompt (build / close / keep-open, with the triage - recommendation shown). -3. Detach with `ctrl-b d`. (On tmux this step is belt-and-suspenders — answering - already hands your terminal back. On backends without a detach verb — e.g. - the herdr adapter — the hand-back cannot be automatic: press its detach - chord, `ctrl+b q` on herdr. Either way the sweep knows it can no longer reach - you and goes unattended for the rest of the run, so detaching first never - leaves it waiting on a prompt you can't see.) - -The banner clears on the next poll after the sweep journals anything further -(the answer is recorded as a `decision:` line in `deferred-work.md`). Sweeps -launched with **unattended** never prompt, so this flow only applies to -attended sweeps. +An attended sweep that reaches an entry needing a human decision waits at its +own terminal prompt. The dashboard shows the `⚑ decision needed` banner and a +one-time warning toast. + +1. Press `a`. With a decision pending it always targets the sweep's window. +2. Answer the prompt: build, close, or keep-open, with the triage + recommendation shown. +3. Once you have answered, the sweep hands your terminal back to the TUI and + runs unattended for the rest of the run. Later decisions wait for `d`. + +The banner clears once the sweep journals its next event. Sweeps launched +**unattended** never prompt. What each answer does is in +[Answering decisions](sweep-guide.md#answering-decisions). ### Answering missed decisions (`d`) -The flow above is for a decision a _live_ attended sweep is blocked on. For -decisions an **unattended** sweep skipped — or an attended one you walked away -from — press `d`. The Deferred Work pane title shows the outstanding count -(`Deferred Work — N to answer (d)`), or `Deferred Work — decisions unreadable (d)` -when they could not be read (no loadable BMAD config, an unreadable ledger, a read -that raised — DW-473); `d` then toasts the fault as an error instead of claiming -nothing is pending. Otherwise `d` walks them one modal at a time -(question, context, and each option with its effect and the triage -recommendation). Each answer is durable: a `close` is applied immediately, and -a `build`/`keep-open` is saved to `.bmad-loop/decisions.json`, so the next sweep -acts on it (build → bundle, keep-open → recorded) without asking again. The -ledger can take no `decision:` line, though — the entry retired by another -writer while the modal was open, or the ledger file gone (DW-198). The modal -says so in a `warning` toast (naming the id, and the store answer where one was -still saved), the walk carries on to the next decision, and that answer is not -counted in the `recorded N decision(s)` summary. A publication refusal is appended -to that warning, or shown in its own `warning` toast when a ledger line landed. -It says an answer that DID land on disk could not be published to git -(DW-209/213): the modal's writer commits only the files that call actually -wrote, and a file that vanished or went unreadable between the write and the -staging is dropped from the commit and named here with its cause. That one does -not change the count, which still depends only on whether a ledger line landed, -and the walk carries on the same way. Skip a -modal to leave that one for later. The same set is available on the CLI via -`bmad-loop decisions` (`--list` to just view). +For decisions an unattended sweep skipped, or a prompt you walked away from, +press `d`. The Deferred Work pane title shows the count, +`Deferred Work — N to answer (d)`, or `Deferred Work — decisions unreadable (d)` +when they cannot be read; `d` then shows the fault instead. + +1. Press `d`. Each decision opens in its own modal: the question, context, and + each option with its effect and the triage recommendation. +2. Pick an option. Skip (or `escape`) ends the walk; that decision and any + after it stay pending for the next `d`. +3. A toast says how many answers were recorded (`recorded N decision(s)`). + Warnings name any answer that could not be written to the ledger or + published. + +The CLI equivalent is `bmad-loop decisions` (`--list` to view only). Where +answers are stored and how the next sweep uses them: +[Answering decisions](sweep-guide.md#answering-decisions). ## Validate (`v`) -Runs `bmad-loop validate --project --json` in the background and -renders the document in a scrollable modal: a verdict header with the -per-severity counts and the queue mode, then one row per check — glyph, the -stable `check` id, and the message. Same preflight as the CLI: config, -sprint-status, git, tmux, CLI binary, hooks. - -The verdict is the document's own `ok`, not the exit code, which cannot tell -"the checks failed" from "the command broke". A check's `detail` — the data it -knew before flattening itself into a sentence, like every detected mux backend -rather than the text mode's `tmux*, psmux (unavailable)` — shows inline for -warnings and problems, and `d` toggles it on for everything. When anything -failed, a footer notes that the gates are **chained**: a policy failure leaves -the later gates emitting nothing at all, so a short findings list after a -failure is not a short list of problems. - -If the document cannot be rendered — a schema newer than this TUI, or output -that is not a document at all — `v` silently re-runs validate in text mode and -shows the combined output in a modal titled `validate — ok` (or `exit `), -exactly as it did before. +`v` runs `bmad-loop validate --json` in the background and shows the result in a +scrollable modal: a verdict header with per-severity counts and the queue mode, +then one row per check (glyph, check id, message). The check ids are listed in +the [CLI reference](reference/cli.md). + +The verdict is the document's own `ok`, not the exit code. A check's `detail` +shows inline for warnings and problems; `d` toggles it on for every row. When +anything failed, a footer notes that the gates are chained: a failed gate stops +the later ones, so a short list after a failure is not the full list of +problems. If the output cannot be rendered as a document, `v` re-runs validate in +text mode and shows its output. ## Settings editor (`g`) -Edits `.bmad-loop/policy.toml` **comment-preservingly** (tomlkit): saving only -rewrites keys you actually changed; everything else — comments, order, -formatting — stays byte-identical. A missing policy file starts from the full -inline-documented template. The note at the top is load-bearing: **running -engines snapshot policy at start — changes apply to new runs and resumes.** -Because a resume re-stamps, editing `limits.cache_read_weight` and resuming -re-weights that run's _whole_ accumulated history, not just the sessions after -the resume — every total is recomputed from raw counts at the current weight. - -The form is grouped by TOML section. Every section starts **collapsed** with a -one-line description in its header, so the grown-large form scans at a glance — -expand only the section you want to edit, or press `ctrl+e` to toggle them all -open/closed at once. Unset keys show their default as a placeholder rather than -a baked-in value; clearing a field deletes the key, restoring default/inherit -behavior. - -| Section.key | Type | Default | Notes | -| ------------------------------------- | ---------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gates.mode` | select | `per-epic` | `none` / `per-epic` / `per-story-spec-approval` | -| `gates.retrospective` | select | `notify` | `never` / `notify` / `auto` | -| `limits.max_review_cycles` | int ≥ 1 | 3 | review loop bound before plateau-defer | -| `limits.max_dev_attempts` | int ≥ 1 | 2 | dev retry budget | -| `limits.artifact_file_max_mb` | int ≥ 1 | 5 | raw-byte cap for each ignored file selected for isolated artifact publication; tracked declarations ride Git and do not count | -| `limits.artifact_payload_max_mb` | int ≥ 1 | 10 | aggregate raw-byte cap across selected ignored publication files, enforced before base64 encoding | -| `limits.max_followup_reviews` | int ≥ 0 | 1 | extra review rounds granted for a finalized pass's own follow-up before it converges + refiles instead of burning a cycle · 0 = never honor one | -| `limits.session_timeout_min` | int ≥ 1 | 90 | per-session wall clock | -| `limits.git_timeout_s` | int ≥ 1 | 120 | bound on any single git subprocess; exceeding it pauses/degrades, never crashes the run — raise on a loaded host or a very large worktree | -| `limits.teardown_grace_s` | int ≥ 0 | 20 | verified teardown: poll a killed session up to this long, then force-kill its pane pids and re-kill · 0 = single unverified best-effort kill | -| `limits.stop_without_result_nudges` | int ≥ 0 | 1 | nudges when a session stops without result.json | -| `limits.dev_stall_grace_s` | int ≥ 0 | 600 | silence grace armed at dev/review launch and re-armed by transport activity or fresh Stop/idle evidence · also the transcript-idle notice threshold (journal `session-idle`/`session-active`, TUI idle age) · 0 = no launch timer (and no idle notice), but a result-less turn end still fails fast | -| `limits.dev_stall_nudges` | int ≥ 0 | 2 | best-effort wake nudges per silent grace before stalling; fresh Stop/idle evidence restores this budget · 0 = stall on grace expiry | -| `limits.dev_stall_nudges_cap` | int ≥ 0 | 6 | total (never-restored) nudge bound per dev/review session — an accepted nudge does not guarantee a wake · 0 = stall on first grace expiry | -| `limits.workflow_stall_nudges_cap` | int ≥ 0 | 3 | same monotonic cap for an injected plugin-workflow session that finished its work but never wrote its completion marker · 0 = stall on first grace expiry | -| `limits.max_tokens_per_story` | int ≥ 1 | 2000000 | advisory cost-weighted cap on a story's cumulative spend, re-checked at every session boundary; one ATTENTION + desktop notice per story on the crossing, nothing terminated | -| `limits.cache_read_weight` | float 0.0–1.0 | 0.1 | cache-read weight in every token total, budgets and displays alike; 1.0 = raw | -| `limits.session_budget_mode` | select | `warn` | mid-session per-session budget guard: `off` (no sampling) / `warn` (one ATTENTION + breadcrumb) / `enforce` (wrap-up nudge, then `over_budget` termination → retry/defer) | -| `limits.max_tokens_per_session` | int ≥ 1 | 4000000 | weighted per-session cap sampled every ~30s mid-session; healthy sessions run ~1–2.5M weighted, so the default trips only true runaways | -| `limits.session_budget_grace_s` | int ≥ 0 | 240 | enforce mode: wrap-up window after the nudge before `over_budget` · 0 = terminate at trip, no nudge | -| `verify.commands` | one per line | (none) | test/lint commands run before commit | -| `verify.stream_capture_kb` | int ≥ 0 | 256 | per-stream cap (KiB) on verifier stdout/stderr retained under the run's `verify/` directory; the tail is kept and the journal records the full size plus a truncation flag · 0 = capture nothing | -| `notify.desktop` | switch | on | desktop notifications | -| `notify.file` | switch | on | ATTENTION file logging | -| `review.enabled` | switch | on | off = skip the separate review session; dev pass runs its review layers inline | -| `review.trigger` | select | `recommended` | `recommended` (run only when the dev pass flags `followup_review_recommended`) / `always`; bounded by `limits.max_review_cycles` | -| `adapter.name` | text | `claude` | CLI profile: `claude` / `codex` / `gemini` / custom | -| `adapter.model` | text | (CLI default) | model override | -| `adapter.effort` | text | (provider default) | reasoning effort, free-form (`high`, `max`, …); sent by `opencode-http` as the per-prompt `variant`, ignored by the tmux CLIs (validate warns) | -| `adapter.extra_args` | override switch + args | profile defaults | see below | -| `adapter.cleanup_session_on_finish` | switch | on | kill the run's tmux session on finish; off keeps it | -| `adapter.dev` / `.review` / `.triage` | text ×3 + args | inherit | per-stage `name` / `model` / `effort` / `extra_args` overrides | -| `sweep.auto` | select | `never` | `never` / `per-epic` / `run-end` | -| `sweep.max_bundles` | int ≥ 1 | 5 | bundles per sweep; triage excess truncated | -| `sweep.max_triage_attempts` | int ≥ 1 | 2 | triage validation retries | -| `sweep.repeat` | switch | off | re-triage after each cycle, continue on new work | -| `sweep.max_cycles` | int ≥ 1 | 5 | cycle cap per sweep run when repeat is on | -| `scm.isolation` | select | `none` | `none` (work in place) / `worktree` (per-unit worktree + merge-back) | -| `scm.branch_per` | select | `story` | worktree mode: branch per `story`, or one shared branch per `run` (forces delete-branch off) | -| `scm.target_branch` | text | (run-start branch) | worktree mode: branch units merge back into (created if missing) | -| `scm.merge_strategy` | select | `merge` | worktree mode: `ff` / `merge` / `squash` | -| `scm.delete_branch` | switch | on | worktree mode: delete the unit branch after a successful merge | -| `scm.keep_failed` | switch | on | keep a failed unit's worktree + branch mounted for inspection | -| `scm.rollback_on_failure` | switch | off | in-place mode (`isolation = none`) only: on = auto-revert a failed attempt's tracked changes + delete the untracked files this run created (its uncommitted work is lost); off = never touch the tree, pause with manual recovery. A resolved escalation's re-drive always auto-recovers regardless. Prefer `isolation = worktree` | -| `scm.preserve_keep` | int ≥ 0 | 20 | `attempt-preserve/*` recovery branches + `attempt-preserve-dirty/*` rollback snapshots + `merge-preflight-preserve/*` merge pre-flight snapshots kept per family at each run start, newest by committer date; the tail is deleted (0 = never prune) | -| `scm.seed_adapter_defaults` | switch | on | worktree mode: seed each loaded adapter's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`…) into the worktree | -| `scm.worktree_seed` | one per line | (none) | worktree mode: extra project-relative gitignored files to seed, on top of the adapter defaults | -| `scm.commit_message_template` | text | (built-in) | story/bundle commit message; `{story_key}` / `{run_id}` / `{story_title}` substituted | -| `scm.failed_diff_max_mb` | int ≥ 1 | 5 | per-file cap (MB) for untracked files in a kept-failed unit's `changes.patch` | -| `scm.failed_diff_unlimited` | switch | off | lift the failed-diff size cap (warns when active) | -| `cleanup.run_retention` | int ≥ 0 | 10 | newest concluded runs `bmad-loop clean` keeps whole; older ones trimmed/archived (0 = keep none by count) | -| `cleanup.retention_days` | int ≥ 0 | 0 | 0 = off; else also keep runs newer than N days regardless of the count above | -| `cleanup.trim_artifacts` | switch | on | drop the heavy `worktrees/` tree from concluded runs; the run still lists in the dashboard | -| `cleanup.archive_old` | switch | on | archive (vs hard-delete) runs past the retention window | -| `cleanup.auto_clean_on_finish` | switch | on | reconcile worktrees leaked by a mid-flight stop at each run/sweep start | -| `cleanup.clean_tmp` | switch | on | let engine plugins clean their `/tmp` scratch on finish (e.g. the Unity MCP server zips) | -| `plugins.unity.editor_mode` | select | `shared` | `shared` (needs `scm.isolation = none`) / `per_worktree` (needs `isolation = worktree`) | -| `plugins.unity.mcp` | select | `ivanmurzak` | Editor MCP the plugin targets: `ivanmurzak` / `coplaydev` | -| `plugins.unity.unity_path` | text | (auto-detect) | explicit Editor binary for a `per_worktree` launch; ignored in shared mode | -| `plugins.unity.ready_timeout_sec` | int ≥ 1 | 600 | readiness-gate budget for the Editor + MCP to come up | -| `plugins.unity.ready_grace_sec` | int ≥ -1 | -1 | pre-probe delay; `-1` = auto (120s cold `per_worktree`, 0s warm `shared`) | -| `tui.low_frame_rate` | switch | off | cap to 15fps + disable animations (slow/SSH links); applies next launch | -| `mux.backend` | text | (auto-select) | force a registered terminal-multiplexer backend by name (see `bmad-loop mux`); machine-specific — policy.toml is gitignored; applies next invocation | - -(`scm.max_parallel` is intentionally **not** exposed in the editor — it stays -inert, clamped to 1, until parallel fan-out is built.) - -The form is **registry-driven**: the core sections above are described by -`bmad_loop/data/settings/core.toml` (presentation only — defaults and options are -referenced from the policy dataclasses, never duplicated), and every **enabled** plugin's -`[[settings]]` are appended automatically under a collapsible section of their own, -persisting to `[plugins.]`. So the `plugins.unity.*` rows above only appear once -`unity` is listed in `[plugins] enabled` — the opt-in layer for game projects that drive a -live Editor through an MCP, off by default. A custom plugin dropped at -`.bmad-loop/plugins//` surfaces once it is granted (a settings-only plugin -needs no grant and always shows). - -**Project plugins: the toggle is the grant** ([#496](https://github.com/bmad-code-org/bmad-loop/issues/496), [#497](https://github.com/bmad-code-org/bmad-loop/issues/497)). In the Plugins section, a -project plugin with an executable surface (hooks, workflows, seeds, or `[python]`) -gets an enable switch that records or drops its **content grant** — the same -out-of-tree sha256 grant `bmad-loop plugins enable` records — never a -`[plugins] enabled` entry (that list is managed only for bundled `[python]` -plugins, and any other name already in it is preserved). Its row shows the grant -status: `granted`, `not granted`, ``changed — run `bmad-loop plugins accept ` ``, -``enabled, not granted — run `bmad-loop plugins enable ` or toggle off ``, -`granted, but no hooks, workflows, [python] or seeds left — …` (a granted plugin -stripped of its surface keeps its switch, on, so toggling it off revokes the grant), -or `unreadable — …`. A grant whose plugin dir is gone has no row; a line atop the -roster names it with `bmad-loop plugins revoke `, and it blocks every save -until revoked or restored. - -- **Toggle on** an ungranted plugin and save: the grant is recorded **after** the - save's validation passes and policy.toml is written. A failed validation writes - nothing. Validation builds the plugin registry with the candidate grant, so a - newly toggled `[python]` project plugin's module **executes during the save** — - that is the human-present moment you enable it. -- **Toggle off** a granted plugin and save: the grant is revoked. A project - plugin still named in `[plugins] enabled` (from before grants) starts on even - with no grant, and toggling it off drops that legacy entry too. -- **An untouched toggle changes nothing.** An unrelated save never re-pins a - plugin whose files changed since its grant. Saves are judged against the - grants as stored, so a changed, stale, missing, unreadable, or - enabled-but-ungranted plugin **blocks every save** with the same message a launch would print, until you - run `bmad-loop plugins accept ` / `enable ` or toggle the plugin - off. Re-pinning a changed plugin is CLI-only. -- If the grant store exists but can't be read, a line atop the roster says so, - and a save that would change a grant is refused rather than overwrite it. If - policy.toml saves but the grant write fails, the screen says - "policy saved, but the plugin grant was not recorded: …" and stays open. - -See [Writing a bmad-loop -plugin](plugin-authoring-guide.md) for the settings schema and trust model, [Writing a Game Engine -plugin](game-engine-plugin-guide.md), and [Writing a plugin for a specific Editor -MCP](game-engine-mcp-guide.md). The Unity plugin's `editor_mode` ↔ `scm.isolation` -coupling is validated on save, so an invalid combo (e.g. `per_worktree` with -`isolation = none`) blocks with an error. - -`extra_args` fields are special: the switch distinguishes "use the profile's -default flags" (off — the key stays absent) from "replace them with exactly -this list" (on — the input is parsed shell-style; an empty list is a valid -override and is not the same as unset). Because an override replaces the -profile's `bypass_args`, it must repeat the permission-bypass flags; -`bmad-loop validate` warns (`policy.bypass-dropped`) when it does not. Under an -`opencode-http` profile the list lands after the adapter-owned `--port`/`--hostname`/ -`--print-logs`, so repeating one is warned too (`policy.extra-args-unservable`). - -`ctrl+s` validates the whole document through the engine's own parser -(`policy.loads()`) before writing; errors land in a red strip above the -buttons and block the save. The write itself is atomic (temp file + -`os.replace`), confined to the project so a symlink planted at `.bmad-loop/` -is refused rather than followed, and refused outright with a `PermissionError` -if the operator has marked `policy.toml` read-only (#593, #597). +`g` edits `.bmad-loop/policy.toml`. + +- **Comment-preserving.** Saving rewrites only the keys you changed; comments, + order and formatting stay as they were. A missing file starts from the + commented template `bmad-loop init` writes. +- **Applies to new runs and resumes.** Running engines snapshot policy at start. + A resume re-stamps the snapshot, so changing `limits.cache_read_weight` and + resuming re-weights the run's whole token history. +- **Grouped by section.** Every section starts collapsed with a one-line + description; `ctrl+e` expands or collapses them all. Unset keys show their + default as a placeholder, and clearing a field deletes the key. +- **Validated on save.** `ctrl+s` parses the whole document with the engine's + own policy parser; errors appear in a red strip above the buttons and block the + save. The write is atomic, and is refused if `policy.toml` is read-only. + +Every key, its type, default and allowed values are in the +[configuration reference](reference/configuration.md#policy-settings). The form +is built from the same schema, so the two always match. + +`extra_args` fields have a switch. Off keeps the profile's default flags (the +key stays absent); on replaces them with exactly the list you type, parsed +shell-style, where an empty list is a valid override. An override replaces the +profile's permission-bypass flags too, so repeat them; `bmad-loop validate` +warns (`policy.bypass-dropped`) when they are missing. + +### Plugins + +Every enabled plugin's settings appear in a section of their own and save to +`[plugins.]`; the Unity settings, for example, appear only once `unity` is +in `[plugins] enabled`. A plugin's settings are validated on save, so an invalid +combination, such as Unity's `per_worktree` with `scm.isolation = "none"`, +blocks the save. + +For a project plugin with an executable surface (hooks, workflows, seeds or +`[python]`), the toggle **is the trust grant**, the same grant +`bmad-loop plugins enable` records: + +- **Toggle on** and save to record the grant, after validation passes and + `policy.toml` is written. A `[python]` plugin's module runs during that save. +- **Toggle off** and save to revoke the grant. +- **An untouched toggle changes nothing.** A plugin that is enabled but not + granted, or whose grant is changed, stale, missing or unreadable, blocks every + save with the same message a launch would print. Fix it with the command that + message names (`bmad-loop plugins enable`, `accept` or `revoke `), or + toggle the plugin off. Each row shows its grant status; a grant whose plugin + is gone has no row, only a status line above the roster. + +See [Writing a bmad-loop plugin](plugin-authoring-guide.md) for the settings +schema and trust model, and the +[Game Engine plugin](game-engine-plugin-guide.md) and +[Editor MCP plugin](game-engine-mcp-guide.md) guides. ## Troubleshooting -| Message | Cause / fix | -| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `multiplexer backend unavailable — launch/attach disabled` | install the selected backend's binary (tmux by default — see `bmad-loop mux`); the dashboard still works read-only | -| `git worktree is not clean — commit or stash first` | the launch guard; commit/stash and retry. `.bmad-loop/policy.toml` is exempt, so saving in the settings editor never blocks a launch | -| `another run is live: ` | a second engine on the same project may conflict — confirm only if you know they won't touch the same stories | -| `launch may have failed — attach to control session ` | no `state.json` within 10 s of launch; attach to the named ctl session to read the error (the window stays open with the exit code) | -| `no run selected` | `e` / `a` need a selected run — the project has no runs yet | -| `could not read past sweeps' decisions: ` | `d` could not read the missed decisions (DW-473) — fix the named config or ledger fault; `bmad-loop decisions` reports the same | -| `state for run is unreadable` | corrupt/missing `state.json`; inspect the run dir | -| `run already finished` | finished runs can't be resumed | -| `nothing to attach: no live agent session … runs started outside the TUI have none` | between sessions there is no agent window, and shell-started runs have no ctl window; wait for the next session or attach manually | -| `cannot suspend here — run manually: tmux attach …` | the terminal can't suspend the TUI; run the printed command in another terminal | -| `engine.pid may still be live — resuming could double-drive this run` | the original engine still runs (or its pid was recycled); attach and check before resuming | -| `policy.toml is not valid TOML: …` | hand-edited file is syntactically broken; fix it in an editor — the settings screen needs a parseable document to start from | -| sprint tree shows `sprint status unavailable` | missing/invalid `_bmad/bmm/config.yaml` or sprint-status.yaml; run `bmad-loop init` / `bmad-sprint-planning` | -| deferred pane shows `deferred ledger unavailable` | missing/unreadable `deferred-work.md`; normal until the first session defers something | -| header shows `state unavailable` | the run dir exists but `state.json` is missing or never parsed; usually transient at launch | - -Degradation is graceful by design: a mid-write or missing file never crashes a -poll — the dashboard keeps the last good state (`?` / `unknown` where it has -none), and catches up on the next tick. +| Message | Cause / fix | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `multiplexer backend unavailable — launch/attach disabled` | install the selected backend (tmux by default; see `bmad-loop mux`); the dashboard still works read-only | +| `git worktree is not clean — commit or stash first` | the launch guard; commit or stash and retry. `.bmad-loop/policy.toml` is exempt | +| `another run may be live` (`live or unknown: `) | two engines on one project may conflict, or the run listing could not be read; **launch anyway** only if they won't touch the same stories | +| `launch may have failed — attach to control session ` | no `state.json` within 10 s of launch; attach to the named session to read the error (the window stays open with the exit code) | +| `no run selected` | the key needs a selected run, and the project has none yet | +| `could not read past sweeps' decisions: ` | `d` could not read the missed decisions; fix the named config or ledger fault (`bmad-loop decisions` reports the same) | +| `state for run is unreadable` | corrupt or missing `state.json`; inspect the run dir | +| `run already finished` | finished runs can't be resumed | +| `an environment pause needs no resolve: …` | `R` at an environment pause; fix the environment, then resume with `e` | +| `run is live — stop it first` | `D` and `A` refuse a live run; stop it with `x` or `S` first | +| `nothing to attach: no live agent session … runs started outside the TUI have none` | between sessions there is no agent window, and shell-started runs have no control window; wait for the next session | +| `cannot suspend here — run manually: tmux attach …` | the terminal can't suspend the TUI; run the printed command in another terminal | +| `engine.pid may still be live — resuming could double-drive this run` | the original engine still runs, or its pid was recycled; check before resuming | +| `policy.toml is not valid TOML: …` | the file is syntactically broken; fix it in an editor, since the settings screen needs a parseable document | +| sprint tree shows `sprint status unavailable` | missing or invalid `_bmad/bmm/config.yaml` or `sprint-status.yaml`; run `bmad-loop init` / `bmad-sprint-planning` | +| deferred pane shows `deferred ledger unavailable` | missing or unreadable `deferred-work.md`; normal until the first session defers something | +| header shows `state unavailable` | `state.json` is missing or never parsed; usually transient at launch | + +A file caught mid-write or missing never crashes a poll: the dashboard keeps +the last good state (`?` / `unknown` where it has none) and catches up on the +next tick. diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index 9281b94f1..af3368e23 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -16,9 +16,12 @@ :data:`VALIDATE_CHECKS` is the registry every id must be in, enforced by an assert in :meth:`ValidationReport.add`. That is what keeps "one printed line = -exactly one Finding" true: a new check site cannot ship without first naming -itself here. The assert only ever fires on a literal an author just wrote, never -on runtime data, so it is a lint with a stack trace rather than a validation. +exactly one Finding" true. The assert only ever fires on a literal an author just +wrote, never on runtime data, so it is a lint with a stack trace rather than a +validation. It and the end-to-end registry test in test_cli.py only see executed +paths — there is no static call-site scan — so an id on a branch neither reaches +ships unregistered and raises only when that branch first runs. A new check site +therefore lands with a test that reaches it. """ # Strict-checked under #245 Stage 2, with one rule relaxed for this file only: diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index 2fa180cd4..268c63351 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -905,8 +905,8 @@ def compose(self) -> ComposeResult: # Every arm stays within 3 rows at 35 columns, the content width of a # 39-column dialog: 2 border + title + 1 body row + hint + 1-row button # row must fit the 8-row (90%-of-9) dialog at the 39x9 pair (DW-414). - # The reasons behind each arm live in the comments and in - # docs/tui-guide.md, not in the hint. + # The reasons behind each arm live in the comments below, not in + # the hint. hint = Text() if self._unreadable: # Precedence over both branches below: they explain when Re-arm diff --git a/tests/test_cli.py b/tests/test_cli.py index 8364da235..4215098aa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12204,9 +12204,9 @@ def test_validate_json_mode_and_spec_folder_reflect_the_flag(project, capsys, sp def test_validate_json_every_emitted_check_is_registered(project, capsys, monkeypatch): - """The `add` assert enforces this per call site; this proves it end-to-end over a - real run, so an id that only ever appears on an unexercised path still has to be - in the registry.""" + """The `add` assert enforces registration per executed call site; this unions the + ids a real passing and failing run emit and asserts them registered. Exercised + paths only: an id on a branch neither run reaches is not seen.""" from bmad_loop.checks import VALIDATE_CHECKS _make_validate_pass(project, monkeypatch, capsys) diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 1699c2318..8c442b5cc 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1209,7 +1209,9 @@ def count, so the `assert time.monotonic() < deadline` poll lines — comparison Unlike the SessionSpec scan this does NOT flag a gated test holding zero sites: most stories E2Es have no poll deadline at all. `_reap_inventory_offenders` is what keeps a scan that found nothing from passing vacuously. Subprocess `timeout=` - budgets in that file are a separate scope and are not inspected here. + budgets, the `teardown_s < 15.0` teardown-gap assertion and the + `time.time() - hb["ts"] < 120` heartbeat-freshness assertion in that file are + separate scopes and are not inspected by any of the three scanners. KNOWN BLIND SPOTS, deliberately not closed — the shape above is matched literally, so every one of these scans CLEAN and would carry a bare budget past the guard: diff --git a/tests/test_entry_point.py b/tests/test_entry_point.py index 6361344f7..f697413e8 100644 --- a/tests/test_entry_point.py +++ b/tests/test_entry_point.py @@ -126,9 +126,11 @@ def test_extra_less_install_core_works_and_tui_hints(tmp_path): """The core CLI surface works with the ``[tui]`` extra absent (#650). A core-only ``uv tool install bmad-loop`` has no pyte/rich/textual/tomlkit, but - every test job installs ``--all-extras`` and the packaging job's isolated wheel - run only calls ``--version`` — which argparse answers before any subcommand code - imports. That blind spot let ``ls`` crash on a missing ``pyte`` for 23 releases. + every test job installs ``--all-extras``, and a wheel run of only ``--version`` + is answered by argparse before any subcommand code imports. That blind spot let + ``ls`` crash on a missing ``pyte`` for 23 releases. The packaging job now also + runs ``bmad-loop list`` from the isolated wheel, the same floor at install level; + this test is the in-suite half that every ``--all-extras`` job would otherwise miss. So: a fresh interpreter (the subprocess *is* the test, per this module's header) installs an import-system blocker for those four packages, proves the blocker bites, then runs the paths a core-only install actually takes. diff --git a/tests/test_hook_script.py b/tests/test_hook_script.py index 6ed7ebcd7..800458368 100644 --- a/tests/test_hook_script.py +++ b/tests/test_hook_script.py @@ -15,8 +15,8 @@ def _relay_module(): """Import the relay by path. It ships as package DATA, not an importable - module (init copies it into the workspace), so the behavioral tests below - drive it as a subprocess — but the reparse-tag branch of `_is_link_like` is + module (init registers the console entry point's `relay` subcommand, not a + workspace copy), so the behavioral tests below drive it as a subprocess — but the reparse-tag branch of `_is_link_like` is reachable only on Windows, and shipping it unexercised is how a capability check silently becomes dead code.""" spec = importlib.util.spec_from_file_location("_bmad_loop_hook_under_test", SCRIPT) diff --git a/tests/test_module_skills_sync.py b/tests/test_module_skills_sync.py index c1a5f04d1..96b8cbad9 100644 --- a/tests/test_module_skills_sync.py +++ b/tests/test_module_skills_sync.py @@ -3,8 +3,11 @@ The forked skills are plain copies (not symlinks) in .claude/skills/ (read by Claude Code) and .agents/skills/ (read by codex/gemini). Edits must flow -src/bmad_loop/data/skills/ -> both trees; this test turns drift into a -CI failure. +src/bmad_loop/data/skills/ -> both trees. + +Documented limitation: CI-inert. Both fork trees are gitignored and absent in CI, +so every parametrization skips there and the guard bites on dev boxes only. The +canonical-existence assertion runs before the skip and is the one CI-live check. """ import filecmp diff --git a/tests/test_probe_hook.py b/tests/test_probe_hook.py index 277dc6fb9..6977f0da5 100644 --- a/tests/test_probe_hook.py +++ b/tests/test_probe_hook.py @@ -77,7 +77,9 @@ def test_tolerates_garbage_stdin(tmp_path): def test_installed_copy_matches_source(tmp_path): - # packaged alongside the real relay; importlib.resources resolves it + """The packaged resource matches the source relay. This only bites in a + wheel-installed run: under an editable install both sides resolve to the same + file.""" from importlib import resources packaged = resources.files("bmad_loop.data").joinpath("bmad_loop_probe_hook.py") diff --git a/tests/test_release.py b/tests/test_release.py index 35723ae38..5ce9d6116 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -2,7 +2,10 @@ These cover the logic that decides versions, parses/extracts CHANGELOG sections, inserts link references, groups commits, and short-circuits a publish when the tag -already exists. Anything touching real git/gh is exercised via monkeypatch. +already exists. Anything touching real git/gh is exercised via monkeypatch. The +`cmd_check` / `cmd_prepare` rows drive the release contract over fixture changelogs +with `sync_version` stubbed; `sync_version.check()` itself is covered in +test_sync_version.py. """ from __future__ import annotations From bf5f7b527f8502d05758828b1761a5886f49d296 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 12 Oct 2026 03:13:00 -0700 Subject: [PATCH 8/9] docs: trim remaining guides and tighten the docs hygiene guard --- AGENTS.md | 8 +- CHANGELOG.md | 4 + docs/README.md | 2 +- docs/ROADMAP.md | 118 ++++------- docs/adr/0001-mux-env-transport.md | 4 +- docs/game-engine-plugin-guide.md | 5 +- docs/plugin-authoring-guide.md | 307 +++++++++++++---------------- docs/reference/cli.md | 28 +-- docs/reference/configuration.md | 6 +- docs/setup-guide.md | 77 ++++---- docs/testing.md | 5 +- tests/test_docs_hygiene.py | 171 +++++++++++++++- 12 files changed, 421 insertions(+), 314 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1cc248a75..ed2f97e64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Things that break silently. Never violate; when in doubt, read the named module' - No LLM calls in the orchestrator control loop. - Sessions complete only on hook Stop events or window death — never on LLM prose. Never add another completion path. Post-session state is re-verified deterministically (`verify.py`). -- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process since #469; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. +- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. - All git subprocess calls in `src/bmad_loop` go through the `_run_git` chokepoint in `verify.py` (timeouts, `LC_ALL=C`) — no bare subprocess git; `tests/test_portability_guard.py` enforces it. Tests, `scripts/`, and CI workflows deliberately spawn their own git: a harness must not depend on the artifact it validates. - Every new policy field needs an entry in `src/bmad_loop/data/settings/core.toml` (a sync test enforces defaults/options match `policy.py`). New core env vars register in `envvars.py`; plugin-owned env-var families stay with their plugin. - Version strings are stamped only by `scripts/sync_version.py` from `src/bmad_loop/__init__.py` — never hand-edit pyproject.toml, module.yaml, marketplace.json, or uv.lock versions. @@ -36,7 +36,10 @@ Things that break silently. Never violate; when in doubt, read the named module' ## Architecture -Two orthogonal seams: **which CLI** (adapter axis: `adapters/base.py` `CodingCLIAdapter`, TOML profiles in `src/bmad_loop/data/profiles/`, user overlay `.bmad-loop/profiles/*.toml` — a new coding CLI is a TOML profile plus `bmad-loop probe-adapter`, not Python; a CLI needing its own adapter **class** registers one in `adapters/registry.py` (`register_adapter`, `bmad_loop.adapters` entry point), selected by the profile's `adapter` field) and **which transport** (mux axis: `adapters/multiplexer.py` `TerminalMultiplexer` registry; selection: `BMAD_LOOP_MUX_BACKEND` env > policy `[mux] backend` > platform default > first available match > fallback — full 5-step precedence in [docs/multiplexer-backends.md](docs/multiplexer-backends.md)). +Two orthogonal seams: + +- **Which CLI** (adapter axis): `adapters/base.py` `CodingCLIAdapter`, TOML profiles in `src/bmad_loop/data/profiles/`, user overlay `.bmad-loop/profiles/*.toml`. A new coding CLI is a TOML profile plus `bmad-loop probe-adapter`, not Python. A CLI needing its own adapter **class** registers one in `adapters/registry.py` (`register_adapter`, `bmad_loop.adapters` entry point), selected by the profile's `adapter` field. +- **Which transport** (mux axis): `adapters/multiplexer.py` `TerminalMultiplexer` registry. Selection: `BMAD_LOOP_MUX_BACKEND` env > policy `[mux] backend` > platform default > first available match > fallback — full 5-step precedence in [docs/multiplexer-backends.md](docs/multiplexer-backends.md). | Module | Role | | ------------------------------------------ | ------------------------------------------------------------------------------------------- | @@ -70,6 +73,7 @@ Two orthogonal seams: **which CLI** (adapter axis: `adapters/base.py` `CodingCLI - Every entry lands under `## [Unreleased]`, and only under the six Keep a Changelog subsections — `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. - A release **promotes** that section: `## [Unreleased]` is renamed to `## [X.Y.Z] — ` and a fresh empty `## [Unreleased]` opens above it. The release never authors a new version section from the git log, and never leaves a populated `Unreleased` behind — `scripts/release.py prepare` refuses both, and `release.py check` (CI job `version-sync`) holds the reopened heading and its `compare/v...HEAD` link. - Never commit session notes, probe records, or run artifacts. Durable facts belong in docstrings; records in git history. +- Docs describe current behavior: no tracker refs or history outside CHANGELOG/ADRs; FEATURES.md is a feature catalog (format in its Overview); `tests/test_docs_hygiene.py` enforces it. - Review non-convergence is evidence about the approach, not just a defect queue — escalate rather than grind. ## Engineering doctrine diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d394b71f..2336efe75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ breaking changes may land in a minor release. with `PYTHONDONTWRITEBYTECODE=1` (#496, #497). - Show a project plugin's grant status in the TUI settings screen; toggling it on records its grant after a successful save, toggling it off revokes it (#496, #497). +- Restructure the docs: FEATURES.md is a feature catalog; configuration, CLI and + run-state reference pages live in docs/reference/ (the settings tables generated from + the schema); sweep and escalation recovery have their own guides; a docs hygiene test + guards links, anchors, tracker refs, block length and the catalog shape. ### Fixed diff --git a/docs/README.md b/docs/README.md index e89ad2ec4..f196751b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,7 +24,7 @@ guides below go deeper, roughly in the order you'll need them. - **[Writing a bmad-loop plugin](plugin-authoring-guide.md)** — the plugin system: `plugin.toml` manifest, hooks, lifecycle stages, settings, the trust model, and workflow injection, with a worked walkthrough. - **[Writing a Game Engine plugin](game-engine-plugin-guide.md)** — the game-engine layer (built on the plugin system): driving a live engine Editor, the `editor_mode` ↔ `[scm] isolation` coupling, a minimal Godot example. - **[Writing a plugin for a specific Editor MCP](game-engine-mcp-guide.md)** — Editor-MCP specifics for the bundled Unity plugin: IvanMurzak vs CoplayDev, readiness probes, `per_worktree` isolation, and the full `BMAD_LOOP_*` env-var reference. -- **[Porting bmad-loop to a new OS](porting-to-a-new-os.md)** — the four OS seams (terminal multiplexer, process lifecycle, hook interpreter, validate preflight), their registries and override env vars, and what a native-Windows port costs end to end. +- **[Porting bmad-loop to a new OS](porting-to-a-new-os.md)** — the four OS seams (terminal multiplexer, process lifecycle, hook interpreter, validate preflight), their registries and override env vars, and native Windows as the reference port. - **[The Test Architect (TEA) plugin](tea-plugin-guide.md)** — the bundled `tea` plugin: installing TEA, the six advisory test-architecture steps it injects across runs and sweeps, the enable/blocking settings, and the escalate-on-gate behavior. ## Project direction diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fbbeebd3d..d1ccbfd16 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,79 +9,27 @@ Status legend: **planned** (agreed, not started) · **in progress** (started; so ## Native Windows multiplexer backend -**Status:** in progress — the platform seams and a native-Windows backend (`psmux`, held by a real-Windows live gate) have **shipped in-tree**; still open are the second tmux-family backend `tmux-windows` (#85), herdr's win32 launch path, and the open questions at the end of this section · **Foundation:** the full platform-seam series landed (multiplexer registry + `BaseTmuxBackend` + `ProcessHost` + hook interpreter + validate preflight, v0.7.6; availability-aware backend selection + `bmad-loop mux`, #87; out-of-tree backend discovery via `bmad_loop.mux_backends` entry points; original seam v0.7.0) · a first non-tmux-family, native-Windows-capable backend — **herdr** — ships end-to-end on POSIX as the external [bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr) (the win32 `agent.start` launch path is tracked there) - -The orchestrator no longer fuses tmux into the engine. All session/window/pane operations -go through a single `TerminalMultiplexer` ABC (`src/bmad_loop/adapters/multiplexer.py`), -obtained from `get_multiplexer()`; the tmux backend — argv + spawn primitive in -`BaseTmuxBackend` (`adapters/tmux_base.py`), POSIX leaf `TmuxMultiplexer` -(`adapters/tmux_backend.py`) — is the **only** place allowed to shell out to `tmux`, and it -quarantines the POSIX `sh -c` parked-window trailer. Backends now self-**register** -(`register_multiplexer`) rather than being hardcoded into `get_multiplexer()`, and selection -(#87) is availability-aware with a dev-choosable persisted pick: `BMAD_LOOP_MUX_BACKEND` env -var → the machine-scoped `[mux] backend` policy key (`bmad-loop mux set `; policy.toml -is gitignored) → the platform default (win32: `psmux`, elsewhere: `tmux`) when installed → -the first registered platform match that is `available()`. The process-lifecycle POSIX-isms moved behind a -matching seam, `ProcessHost` (`src/bmad_loop/process_host.py`): `terminate` / `force_kill` / -`is_alive` / `identity` (a PID-reuse guard) plus `hook_interpreter()` (so hook registration -never branches on platform), registered the same way (`register_process_host`, -`BMAD_LOOP_PROCESS_HOST`); `WindowsProcessHost` already ships. `bmad-loop validate` runs a -`platform_preflight(project)` (`runsetup.py`) that reports the selected backend's readiness and names the process -host — so a new OS surfaces in preflight by registering, not by a `validate` edit. The Unity -plugin's `/proc`/`/tmp`/`cp -a`/symlink primitives degrade off Linux (with `psutil` from the -optional `non-linux` extra) and its pid lifecycle now delegates to `ProcessHost`; everything is -held by a CI portability guard (`tests/test_portability_guard.py`). **WSL already works today** -— it _is_ Linux, so it takes every fast path unchanged; this is purely about a future _native_ -Windows host. - -Three native-Windows candidates now exist, and they are **not stages of one plan**. -Two are **sibling tmux-family backends**: `psmux` has **shipped in-tree** (drives -psmux's tmux-compatible CLI through its own `psmux` binary, via pwsh) while -`tmux-windows` (#85; drives the tmux-windows port) is still in flight — both subclass -`BaseTmuxBackend` and both register for `win32`, which is why selection is -availability-aware with discriminating `available()` probes (psmux → -`which("psmux") and which("pwsh")` plus a version gate requiring psmux 3.3.8 or newer -([why](multiplexer-backends.md#psmux-native-windows-experimental)); tmux-windows → -`which("tmux") and not which("psmux")`) and an explicit `bmad-loop mux set ` tie-break. - -The third — **herdr** — has **shipped** end-to-end on POSIX (engine run path + -TUI-launch surface), and now lives **out-of-tree** as the reference external adapter, -[pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr) -(developed in-tree in #136/#137, extracted pre-release per core-team direction; it -co-installs with bmad-loop and self-registers via the `bmad_loop.mux_backends` -entry-point group). It is a different-binary-family backend: it implements -`TerminalMultiplexer` fresh over herdr's workspace/tab/pane model rather than -subclassing `BaseTmuxBackend`, and probes `which("herdr")`, so it is -pairwise-discriminating against the tmux family by construction (no `mux set` -tie-break needed to tell them apart). It registers with `matches = True` (all -platforms), which keeps the door open for native Windows without disturbing POSIX. On -POSIX, tmux stays the platform default, so herdr activates only on an explicit -`BMAD_LOOP_MUX_BACKEND=herdr` / `bmad-loop mux set herdr`. On **win32**, -`_PLATFORM_DEFAULTS` is **untouched** — psmux remains the declared win32 default — so -herdr is picked only by **first-match** (the fourth precedence step) when installed and -`available()` and no higher-priority backend is: once psmux (or tmux-windows) ships and -registers for win32, the win32 default reasserts and outranks herdr's first-match -automatically, no code change. herdr's `exec` launch is POSIX-only for now; the Windows -launch path (herdr's `agent.start`) is tracked in -[the adapter repo's issues](https://github.com/pbean/bmad-loop-adapter-herdr/issues), -and the Phase-0 characterization must be re-run on a real Windows host before #92 is claimed. - -The seams are designed so each backend slots in as **new files plus one registration line, -with no change to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`, or -`cli.py`'s `validate`** (`WindowsProcessHost` and its hook interpreter are already in place -and registered; herdr proved this end to end — one registration line and one sanctioned -`probe.py` gate fix, zero portability-guard allowlist changes; the extraction then proved -the **fully external** path — the same registration arriving via entry-point discovery, -zero core files). The end-to-end port path — -both build options, the test-override env vars, and exactly what a native-Windows port costs — -is documented in [Porting bmad-loop to a new OS](porting-to-a-new-os.md); the deep transport -contract is in the +**Status:** in progress — the platform seams and the experimental `psmux` backend ship in-tree; a second tmux-family backend and herdr's win32 launch path are still open. + +What ships today is documented elsewhere: backend selection and psmux operation in +[multiplexer backends](multiplexer-backends.md), the seams and the reference native-Windows port +in [Porting bmad-loop to a new OS](porting-to-a-new-os.md), and the per-method obligations in the [adapter authoring guide](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). +WSL is Linux and needs none of this. + +Open work: -**Open questions:** what hosts the windows on native Windows (Windows Terminal panes, a -ConPTY-based manager, a headless process supervisor?); how attach/detach and the parked -exit-status window map without a POSIX shell; and the Windows-Unity cache-path correctness -left as a documented follow-up in Phase 4. +- **`tmux-windows` backend** — a sibling of psmux that drives the tmux-windows port. It would + subclass `BaseTmuxBackend` and register for `win32`; its `available()` probe must stay + discriminating against psmux (`tmux` present, `psmux` absent), with `bmad-loop mux set ` + as the tie-break. +- **herdr on native Windows** — the external + [bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr) backend runs on + POSIX only; its win32 launch path is tracked in that repo. psmux stays the declared win32 + default, so herdr is picked there only by first available match. + +**Open questions:** how attach/detach and the parked exit-status window map without a POSIX shell +on hosts other than psmux, and the Unity plugin's cache-path correctness on native Windows. --- @@ -109,9 +57,12 @@ multiple in-flight units per run. **Status:** planned · **Blocked-by:** retro-item detail isn't standardized yet -The parser now recognizes `epic-{N}-retro-item-{M}-{slug}` keys in `sprint-status.yaml` +The parser recognizes `epic-{N}-retro-item-{M}-{slug}` keys in `sprint-status.yaml` (`src/bmad_loop/sprintstatus.py` → `RetroItem` / `SprintStatus.retro_items`), so the -`sprint-status-unknown-keys` warning no longer fires. They are tracked but **not driven** as work. +`sprint-status-unknown-keys` warning does not fire. They are tracked but **not driven** as work. +Separately, a fresh sweep already files a retrospective's `action_items:` list into the +deferred-work ledger ([Retro action items](FEATURES.md#retro-action-items)); this entry is about +driving the `epic-N-retro-item-*` board keys themselves. The goal is to run actionable (`backlog`) retro items through the dev → review → commit pipeline, the same way deferred-work sweeps already run. @@ -122,7 +73,7 @@ branch, a retro-item intent fed to the `bmad-build-auto` primitive, and `verify` the bundle verifiers. Story runs stay untouched. **Why blocked:** retro-item _detail_ is scattered — some lives in the epic retro-doc Action-Items -table (`epic-N-retro-YYYY-MM-DD.md`), some in `deferred-work.md` (DW-N) entries, some in ad-hoc +table (`epic-N-retro-YYYY-MM-DD.md`), some in `deferred-work.md` entries, some in ad-hoc `spec-*.md` files; only one epic has an `epic-N-action-items.md`. A deterministic key→file map isn't viable, so automation needs an LLM triage step (like sweep's) to locate/extract each item's intent **and** classify out the non-code items (research, docs). **Prerequisite:** standardize where @@ -136,7 +87,7 @@ reliable enough to trust unattended. **Status:** exploring · **Foundation:** experimental opt-in `tea` plugin landed (v0.5.1) The bundled, **experimental** `tea` plugin (`[plugins] enabled = ["tea"]`, see the -[TEA plugin guide](tea-plugin-guide.md)) now wires the BMAD Test Architect (TEA) suite — +[TEA plugin guide](tea-plugin-guide.md)) wires the BMAD Test Architect (TEA) suite — `bmad-testarch-test-design`, `-automate`, `-atdd`, `-nfr`, `-trace`, `-test-review`, and the `bmad-tea` agent — into every run and sweep as **advisory-by-default** quality steps (the three gate steps can be flipped to blocking). It's experimental: the workflows ride the generic @@ -155,14 +106,14 @@ Which testarch skills become orchestrated vs. stay interactive? ## Integrate BMAD GDS game-test items -**Status:** exploring · **Foundation:** opt-in game-engine layer landed (Unity, shared + per_worktree), now riding the general plugin system +**Status:** exploring · **Foundation:** opt-in game-engine layer landed (Unity, shared + per_worktree) on the general plugin system -The opt-in Unity plugin (`docs/FEATURES.md` → "Game-engine projects"), enabled with +The opt-in [Unity plugin](FEATURES.md#unity-plugin), enabled with `[plugins] enabled = ["unity"]`, already lets a Unity project run its dev/sweep cycle against a live Editor MCP in **shared** mode (agent works in place on the operator's open Editor; a readiness gate blocks until the Editor + MCP are up) and in **per_worktree** mode (one managed Editor per worktree, with reflink/CoW `Library` priming, setup/teardown hooks, and MCP-skill seeding via -`seed_globs`). The game-engine layer is no longer bespoke core code — it's a plugin built on the +`seed_globs`). The game-engine layer is a plugin built on the general [plugin system](plugin-authoring-guide.md) (the legacy `[engine]` block is a deprecated compatibility shim, folded onto `[plugins.unity]` at load time). Next steps: batchmode `verify_cmd` and Godot/Unreal plugins on the same `plugin.toml`+scripts shape — the authoring path is documented @@ -180,3 +131,14 @@ implement → test → review loop. **Open questions:** how do GDS workflow artifacts map onto the orchestrator's sprint-status/work-item model? Does GDS need its own run type, or can the test-design/automation integration above generalize to cover it? Depends on the testarch integration landing first. + +--- + +## Cursor CLI profile + +**Status:** blocked · **Blocked-by:** Cursor CLI exposes no token usage + +Cursor CLI's `sessionStart`/`stop` hooks fire, so a profile using the window-death fallback and +`usage_parser = "none"` is feasible. Two gaps remain: token usage is not exposed anywhere (hooks, +JSON output or on-disk chats), so budgets cannot count it, and slash-command expansion of the +initial prompt argument is unverified. diff --git a/docs/adr/0001-mux-env-transport.md b/docs/adr/0001-mux-env-transport.md index 9ede04052..605f7da58 100644 --- a/docs/adr/0001-mux-env-transport.md +++ b/docs/adr/0001-mux-env-transport.md @@ -200,7 +200,7 @@ Adopt **D**, preceded by **B** as the interim. Specify **C** now but **do not sc Nothing below is implemented yet. Each stage describes the change, files and tests of a future pull request. -### Stage 1 (planned): #731 interim warning (no released-signature change) +### Stage 1 (shipped): #731 interim warning (no released-signature change) **Change:** @@ -303,7 +303,7 @@ So "cannot tell" and "tried and failed" stay distinguishable to the caller, per **Out-of-tree compatibility test (must exist):** `StubMux`, which implements only the released abstract set, goes through `_ensure_ctl_session` on both arms. It completes, `inherited_env` returns `None`, and no warning is emitted. -### Stage 2 (planned): state root in parked-window argv (no seam change) +### Stage 2 (implemented): state root in parked-window argv (no seam change) **Change:** diff --git a/docs/game-engine-plugin-guide.md b/docs/game-engine-plugin-guide.md index bbad961cc..d21a5d4ce 100644 --- a/docs/game-engine-plugin-guide.md +++ b/docs/game-engine-plugin-guide.md @@ -335,8 +335,9 @@ untitled scene so **no tracked `.unity` file is open** when the reset rewrites i closing the "changed on disk" dialog's window before it can open. At `post_rollback` it refreshes assets so the Editor drops its stale in-memory copies and re-imports the reverted tree — including after a paused or failed rollback, -where the tree may be only partially reset (the refresh is harmless there). The -first call doubles as a **wedge probe**: if the Editor is already unresponsive it +where the tree may be only partially reset (the refresh is harmless there). + +The first call doubles as a **wedge probe**: if the Editor is already unresponsive it fails fast and the quiesce is skipped, at the cost of one call timeout rather than the whole budget. Every call carries both the CLI's own `--timeout` and a subprocess-level kill, and the plugin hard-kills the whole helper past diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index e70dac3c3..2d771aec4 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -200,7 +200,7 @@ that breaks across machines or between a builtin and a project-local copy. ## Trust & safety -Trust is decided by **origin** and pinned by **content** (#496, #497). A name is +Trust is decided by **origin** and pinned by **content**. A name is not trusted on its own: a folder can be swapped under a name that was approved before. @@ -282,9 +282,9 @@ before. - **Same-user tampering outside the repo.** The store and the snapshots live in the user's state directory. A process running as the same user that sets out to rewrite them can. Out-of-tree moves the trust record out of reach of a session - editing the repo; it is not a sandbox (the same boundary as the #571 config + editing the repo; it is not a sandbox (the same boundary as the run's config digest). -- **Moving the project loses its grants** (#572). They are keyed by the project +- **Moving the project loses its grants**. They are keyed by the project path, so a moved or re-cloned project starts ungranted. This fails closed: re-grant after a move. - **Files a hook names outside `{scripts}`.** A hook `cmd` that runs a script @@ -318,21 +318,25 @@ kills the hook's process tree, and the run stops: the bus journals `plugin-hook-interrupted` (`plugin`, `stage`) instead of `plugin-hook-error`, and `fail_closed` never turns that into a veto, because an interrupted hook has not failed. If the interrupted hook is on `post_run`, the run still finishes. A hook -timeout kills the tree the same way. The runner re-scans the tree under the -hook's root once a second while that root runs, and again every 50 ms while the -root winds down after its signal; at kill time it also takes in each surviving -known process's current children. Only processes still alive with a matching -recorded identity are signalled. A background job whose parent shell exited is -killed if its parent chain was still under the live root at one of those scans, -and a process forked in the root's TERM handler is killed if the root lived one -50 ms re-scan after forking. These cannot be reached: a process that starts and -is reparented away within one scan interval, a TERM handler that forks and exits -at once, a descendant already reparented away from a surviving job once the root -has exited, and a process that starts while the survivors are being reaped. On -macOS without `psutil`, only the root is killed. Hooks that run as part of the -stop's own unwind — `pre_worktree_teardown`/`post_worktree_teardown`, +timeout kills the tree the same way. Hooks that run as part of the stop's own +unwind — `pre_worktree_teardown`/`post_worktree_teardown`, `pre_rollback`/`post_rollback` — are not interrupted and still run to completion. +What the kill reaches: + +- The runner re-scans the tree under the hook's root once a second while that root + runs, and every 50 ms while the root winds down after its signal. At kill time it + also takes in each surviving known process's current children. +- Only processes still alive with a matching recorded identity are signalled. +- A background job whose parent shell exited is killed if its parent chain was still + under the live root at one of those scans. A process forked in the root's TERM + handler is killed if the root lived one 50 ms re-scan after forking. +- Out of reach: a process that starts and is reparented away within one scan + interval, a TERM handler that forks and exits at once, a descendant reparented + away from a surviving job after the root exited, and a process that starts while + the survivors are being reaped. +- On macOS without `psutil`, only the root is killed. + **Versioning.** Every manifest declares `api_version`. The framework supports a set of versions (`SUPPORTED_API`). A **builtin** with an unsupported version is a hard error (a packaging bug we shipped); a **third-party** one is **skipped with a @@ -537,7 +541,7 @@ reset) or an unexpected error escapes it. `ctx.rollback_outcome` It is `None` on every other stage, `pre_rollback` included. A plugin that quiesces in `pre_rollback` can therefore always rely on the matching release; one that does post-reset work (re-installing dependencies, re-applying state) -should gate it on `completed`, since `post_rollback` no longer implies the reset +should gate it on `completed`, since `post_rollback` does not imply the reset finished. Rollback paths that never emit `pre_rollback` (a clean tree, a policy pause with `[scm] rollback_on_failure` off, an owned-spec pause raised before the rollback @@ -551,149 +555,102 @@ starts) emit neither stage. | `pre_dev_session` | before each dev session | `proposed_prompt`, `proposed_env`, veto | | `post_dev_verify` | after dev or repair verification | — | -`post_dev_verify` fires on **both** legs of the dev phase — once after the dev -session's verification, and again after each repair session's — so a handler sees -it **more than once per story**, not once. The two legs share one `attempt` -counter bounded by `[limits] max_dev_attempts`, which is also the bound on how -many times the stage can fire for one story. Write handlers to be idempotent and -to key on the correlation fields below rather than on the story alone. It also -fires on the way to a pause: an attempt whose session reported a CRITICAL -escalation emits before the run stops, on either leg — and so does a dev or repair -leg whose `[environment]` probe preflight failed, with no command results. One -emission falls outside that bound: a `bmad-loop resolve --reverify` replay fires -the stage once more with **no session** — `ctx.session_status` is `None`, -`ctx.result_json` is the latest completed dev session's result, and `attempt` does -not advance — so a handler that assumes a session behind every emission must -check `session_status` first. - -`post_dev_verify` exposes `ctx.command_results`: an immutable tuple of the -per-command `CommandResult` records core just executed. Each has `command`, -`returncode`, the existing merged bounded `output_tail`, separate `stdout` -and `stderr` strings, and `spawn_error` — normally `None`, and set when the child -could not be started at all. The typical cause is the directory it was to run in -(missing, not a directory, or unsearchable), which the message names, but any -spawn-time `OSError` lands here — a missing shell, EMFILE, ENOMEM — so read the -wrapped exception rather than assuming the directory. Such a result carries no -real exit status (`verify.SPAWN_FAULT_RC`, deliberately outside the range a -signal-killed child reports and distinct from the timeout leg's `-1`) and -classifies as an environment fault, which pauses the run. The two stream strings -are intended to be the streams essentially whole -— they are not cut to `[verify] stream_capture_kb`, which bounds only what is -written to disk — but they are not unbounded either: a hard 32 MiB per-stream -ceiling applies, so a pathologically chatty command cannot grow the orchestrator's -peak memory with the number of configured verify commands. When that ceiling cuts -a stream the **tail** is what a plugin receives, and the matching journal record's -`stdout_bytes` / `stderr_bytes` still report what the command emitted, so the cut -is always detectable rather than silent. Ordinary suites never reach it. This is -observation data only: a plugin cannot change the verifier's outcome or the commit -decision. The run's `journal.jsonl` also records -one `verify-command-result` entry per command with run/story/attempt/stage and -verification-sequence correlation — note that `attempt` is the dev/repair counter, so every review -cycle of one attempt shares a single value and only `verification_sequence` tells successive review -passes apart — `output_tail`, `spawn_error`, byte counts, and run-relative `stdout_path` / -`stderr_path` pointers under the run's `verify/` directory; full streams are not -embedded in the journal. `spawn_error` rides the record because the record's -readers are out-of-process and `returncode` alone cannot separate a child that -never started from one that ran. A pass cut short by a hard `bmad-loop stop` (DW-353) -records `interrupted: true` with `returncode` −1001 (`INTERRUPTED_RC`): the command -the stop killed has `output_tail` `interrupted by a hard stop request`, and every later -command, which was never started, still gets a record, with `output_tail` -`not started: an earlier command was interrupted by a hard stop request`. The run -stops right after journalling that pass, so no hook observes it and no decision is -taken on it. The field is absent from every pass that ran through. That store is deliberately separate from `logs/`, which -holds coding-CLI pane captures named after session task ids and is read as such -by the TUI. - -Two more context fields say **which** verification the results came from, because -nothing else on the context can: both the dev leg and the repair leg emit -`post_dev_verify` from `Phase.DEV_VERIFY`, and `ctx.attempt` is one per-story -counter the repair leg continues rather than restarts, so its value orders the -two but names neither. +`post_dev_verify` fires more than once per story. Write handlers to be idempotent and +to key on the correlation fields below, not on the story alone: + +- It fires after the dev session's verification and again after each repair session's. Both + legs share one `attempt` counter bounded by `[limits] max_dev_attempts`. +- It also fires on the way to a pause: after a session that reported a CRITICAL escalation, + and after a dev or repair leg whose `[environment]` probe preflight failed (no command + results). +- A `bmad-loop resolve --reverify` replay fires it once more with **no session**: + `ctx.session_status` is `None`, `ctx.result_json` is the latest completed dev session's + result, and `attempt` does not advance. Check `session_status` before assuming a session. + +`ctx.command_results` is an immutable tuple of the `CommandResult` records core just ran, +one per `[verify] commands` entry: + +| Field | Value | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | the command line | +| `returncode` | exit status; `verify.SPAWN_FAULT_RC` when the child never started, −1 on timeout | +| `output_tail` | merged, bounded tail of both streams | +| `stdout` | the stream essentially whole: not cut to `[verify] stream_capture_kb`, but capped at 32 MiB (the tail is kept; the journal's byte counts show the cut) | +| `stderr` | as `stdout` | +| `spawn_error` | `None`, or why the child could not start (any spawn-time `OSError`, typically a missing or unsearchable directory). Such a result classifies as an environment fault and pauses the run | + +The results are observation only: a plugin cannot change the verifier's outcome or the +commit decision. Treat verifier output as potentially sensitive. + +Two fields say which verification produced the results, since `attempt` orders the dev and +repair legs but names neither: | Field | Value | | --------------------------- | -------------------------------------------------------------------------------------- | | `ctx.verification_stage` | `"dev"` for the initial dev verification, `"fix"` for a repair one, `None` if none ran | | `ctx.verification_sequence` | the story's 1-based ordinal for that pass, or `None` if it recorded nothing | -The review gate publishes the same pair on its own stage, [`post_review_verify`](#review), -always as `"review"` (or `None`). - -Together they are the join key: the `verify-command-result` entries carrying this -`story_key` + `verification_stage` + `verification_sequence` are exactly this -context's results, one per record, ordered by `command_index`. The sequence is -monotonic per story **across a pause/resume** — unlike `attempt`, which a human -re-arm reuses — so it is safe to persist as a correlation id. - -Read `ctx.command_results == ()` together with `verification_stage`; on its own it -is ambiguous: - -- **`verification_stage is None`** — no verify pass ran. Several causes land here - and the empty tuple names none of them: the session did not complete - (`ctx.session_status`), an earlier gate already failed the attempt — the - dev-artifact check or the deferral harvest (`ctx.verify_reason`) — an - `[environment]` probe failed the preflight, so no command was started - (`ctx.verify_reason` names the probe) — or the engine - variant suppressed the pass for this leg (stories mode skips it on a plan-halt - leg, which has no implementation to build). -- **stage set, `verification_sequence is None`** — the pass ran and executed - nothing, because `[verify] commands` is empty. No journal record exists either. -- **stage set, sequence an int** — those commands ran, and each has a matching - journal record. - -What lands on disk is bounded by `[verify] stream_capture_kb` (default 256 KiB per -stream): the **tail** is retained, and the record stays explicit about the cut — -`stdout_bytes` / `stderr_bytes` are what the command emitted, `stdout_captured_bytes` / -`stderr_captured_bytes` how much of that reached disk, and `stdout_truncated` / -`stderr_truncated` their inequality. Both counts are UTF-8 lengths of the decoded -stream, **not** file sizes: the files are written in text mode, so Windows newline -translation makes the file larger there. Set the knob to `0` to retain nothing at -all — no files are written and the pointers are null, but the record still lands -with the full byte counts, because "nothing was retained" and "the command was -silent" are different facts. Retaining is observation and never fails a run: if the -write raises (ENOSPC, a read-only run dir, or a `verify/` directory whose -confinement cannot be established — the store refuses rather than write through -a symlink a session planted), the pointer is null and `capture_error` -carries the reason. A plugin reading these pointers must therefore treat both -`None` and a missing file as normal, and consult `*_truncated` before assuming a -file holds a command's whole output. Treat verifier output as potentially sensitive and store, upload, sign, -or act on it only from an explicitly configured plugin. - -**`post_dev_verify` covers the dev phase only.** `[verify] commands` also run at -the _review_ gate — `verify_review` / `verify_review_stories` / -`verify_review_bundle` end on the same core classifier — and those runs are -journalled (`verification_stage: "review"`, sharing the story's one -`verification_sequence` counter with the dev and fix passes) and published on -their own stage, [`post_review_verify`](#review). They run in `repo_root`, the same root -the dev phase uses (#695); only the gates' own artifact reads — the spec, the -sprint board, the deferred-work ledger — stay project-rooted. Five engine gates reach them: the -converged review pass, the review-budget-exhaustion rescue, the review-timeout -salvage, and both passes inside the skip-review commit path (which runs the gate -again after a repair). The records do not name which of the five ran — the -neighbouring `review-result` / `review-skipped*` / `review-timeout-salvage*` -entries and the sequence ordering say that. `bmad-loop confirm --reverify` runs -the commands too, out of band by construction — the run that parked the story is -finished, so there is no journal to write to and no hook bus to emit on. - -Two consequences a handler has to be written for: - -- **`verify-command-result` entries are a complete census of a RUN's verifier - command invocations, but not of every gate visit or of a project's.** Every - command an in-run dev, fix or review pass executed lands a record. A pass cut - short by a hard stop also records each command it never started, marked - `interrupted: true` with a `not started:` `output_tail`, so the record count of - such a pass is its configured command count, not what actually ran. A pass - with no `[verify] commands` configured executes nothing and therefore records - nothing; `bmad-loop confirm --reverify` stays outside because it runs after the - run that parked the story is over. Count distinct `verification_sequence` - values to derive recorded passes, while preserving that zero-command caveat. -- **One command record is not a pass verdict**, and an earlier red pass is not - evidence the commit was blocked: a failed pass can be followed by a green - review-gate pass and a commit. Group records by `verification_sequence`, then - correlate that group with its surrounding decision event instead of inferring - the decision from one command or from an older pass. The dev and fix legs use - `dev-decision` / `fix-decision`; review passes use the applicable neighbouring - `review-result`, `review-skipped*`, `review-timeout-salvage*`, - `review-budget-committed`, or `review-followup-damped` event described above. +The review gate publishes the same pair on [`post_review_verify`](#review), always as +`"review"` (or `None`). The sequence is monotonic per story across pause/resume (unlike +`attempt`, which a human re-arm reuses), so it is safe to persist as a correlation id. + +Read `ctx.command_results == ()` together with `verification_stage`: + +- **`verification_stage is None`**: no verify pass ran. The session did not complete + (`ctx.session_status`), an earlier gate failed the attempt (`ctx.verify_reason`), an + `[environment]` probe failed the preflight (`ctx.verify_reason` names it), or stories mode + skipped the pass on a plan-halt leg. +- **Stage set, `verification_sequence is None`**: the pass ran with `[verify] commands` empty. + No journal record exists. +- **Stage set, sequence an int**: those commands ran, and each has a journal record. + +#### `verify-command-result` journal records + +The run's `journal.jsonl` records one `verify-command-result` per command. The records with +this context's `story_key` + `verification_stage` + `verification_sequence` are exactly its +results, ordered by `command_index`. Each carries run/story/attempt/stage correlation, +`output_tail`, `spawn_error`, byte counts, and run-relative `stdout_path` / `stderr_path` +pointers under the run's `verify/` directory (not `logs/`, which holds session pane captures). + +- `attempt` is the dev/repair counter, so every review pass of one attempt shares it; only + `verification_sequence` tells review passes apart. +- **Hard stop.** A pass cut short by a hard `bmad-loop stop` records `interrupted: true` with + `returncode` −1001 (`INTERRUPTED_RC`). The killed command's `output_tail` is + `interrupted by a hard stop request`; every later command still gets a record with + `not started: an earlier command was interrupted by a hard stop request`. No hook observes + that pass. The field is absent from passes that ran through. +- **On-disk capture** is bounded by `[verify] stream_capture_kb` (default 256 KiB per stream; + the tail is kept). `stdout_bytes` / `stderr_bytes` are what the command emitted, + `*_captured_bytes` what reached disk, and `*_truncated` their inequality. Counts are UTF-8 + lengths of the decoded stream, not file sizes (Windows newline translation makes files + larger). `0` retains nothing: no files, null pointers, full byte counts. +- **Capture never fails a run.** If the write raises (ENOSPC, read-only run dir, a `verify/` + directory whose confinement cannot be established), the pointer is null and + `capture_error` says why. Treat both `None` and a missing file as normal, and check + `*_truncated` before assuming a file holds the whole output. + +#### Review-gate verification + +**`post_dev_verify` covers the dev phase only.** `[verify] commands` also run at the review +gate, in the same `repo_root` as the dev phase, and are journalled with +`verification_stage: "review"` (sharing the story's `verification_sequence` counter) and +published on [`post_review_verify`](#review). Five engine gates reach them: the converged +review pass, the review-budget-exhaustion rescue, the review-timeout salvage, and both passes +of the skip-review commit path. The records do not name which gate ran; the neighbouring +`review-result` / `review-skipped*` / `review-timeout-salvage*` entries do. +`bmad-loop confirm --reverify` runs the commands out of band, with no journal and no hook bus. + +Two consequences for handlers: + +- **The records are a complete census of a run's in-run verifier invocations**, not of every + gate visit or of the project's. A hard-stopped pass records its full configured command + count; a pass with no commands records nothing; `confirm --reverify` records nothing. + Count distinct `verification_sequence` values to derive recorded passes. +- **One command record is not a pass verdict**, and an earlier red pass does not mean the + commit was blocked. Group records by `verification_sequence`, then read the surrounding + decision event: `dev-decision` / `fix-decision` for the dev and fix legs; `review-result`, + `review-skipped*`, `review-timeout-salvage*`, `review-budget-committed` or + `review-followup-damped` for review passes. ### Review @@ -706,20 +663,23 @@ Two consequences a handler has to be written for: | `post_review_verify` | after each review verify gate | — | | `pre_fix_session` | before a verify-repair session | `proposed_prompt`, `proposed_env`, veto | -`post_review_verify` is the review leg's counterpart of `post_dev_verify`: its own -stage, not a second meaning for that one. It fires once per review verify gate -visit — the converged review pass, the review-budget-exhaustion rescue, the -review-timeout salvage, and each of the (up to two) passes inside the skip-review -commit path — in every run mode (sprint, stories, sweep). A gate visit ended by -an exception (a hard stop, a repair pause, an engine error) is never published. A -story can therefore see it **several times** (once per review cycle that reached -the gate, plus a re-check after a verify-repair), so write handlers to be -idempotent and key on `verification_sequence` when it is set (it is `None` when no -commands ran). It fires after the gate itself has finished — including its own -acceptance bookkeeping (binding the review artifact source, a sweep's ledger -reclose) — and before the engine routes on the outcome (journals -`review-verify-failed`, escalates, repairs, or commits). It is observation only: a -handler cannot change the gate's outcome, the routing, or the journal. +`post_review_verify` is the review leg's counterpart of `post_dev_verify`, as its +own stage. It fires once per review verify gate visit, in every run mode (sprint, +stories, sweep): + +- The gate visits are the converged review pass, the review-budget-exhaustion + rescue, the review-timeout salvage, and each of the (up to two) passes inside the + skip-review commit path. A visit ended by an exception (a hard stop, a repair + pause, an engine error) is never published. +- A story can see it **several times** (once per review cycle that reached the gate, + plus a re-check after a verify-repair). Write handlers to be idempotent and key on + `verification_sequence` when it is set (`None` when no commands ran). +- It fires after the gate has finished, including its acceptance bookkeeping + (binding the review artifact source, a sweep's ledger reclose), and before the + engine routes on the outcome (journals `review-verify-failed`, escalates, repairs, + or commits). +- It is observation only: a handler cannot change the gate's outcome, the routing, + or the journal. The context carries the same payload as `post_dev_verify`: @@ -808,7 +768,7 @@ blocking = false # true: a non-completed session defers the unit prompt rewrite cannot strip them: a **`## Sprint board`** prohibition — the same one dev and review prompts carry, because a workflow session runs while the orchestrator's own `sprint-status.yaml` advance is still uncommitted and - must not be "fixed" or reverted (#437) — and the **`## Completion signal`** + must not be "fixed" or reverted — and the **`## Completion signal`** contract naming the marker file the session must write before ending its turn. Write `prompt` as a self-contained instruction; do not restate either. (Stories mode has no board, so the first section is absent there.) @@ -976,10 +936,11 @@ trust gate. bmad-loop's core is portable Python, and the OS-specific work is quarantined behind seams (the tmux transport, the `ProcessHost` lifecycle, the hook interpreter — see -[Porting bmad-loop to a new OS](porting-to-a-new-os.md)). Plugin **helper scripts** -are spawned under the orchestrator's own interpreter (`sys.executable`), **not** a -PATH-resolved `python3` — so a bundled script may `import bmad_loop` and reach those -seams instead of re-implementing OS primitives. Follow this discipline: +[Porting bmad-loop to a new OS](porting-to-a-new-os.md)). A helper script that an +in-process plugin spawns with `sys.executable` (as the Unity plugin does) runs under the +orchestrator's interpreter, so it may `import bmad_loop` and reach those seams instead of +re-implementing OS primitives. A declarative hook `cmd` runs through the host shell, so a +`python3` there is whatever is on `PATH` and may not see `bmad_loop`. Follow this discipline: - **Use the `ProcessHost` seam for pid lifecycle.** For terminate / force-kill / liveness, call `get_process_host()` rather than guarding `os.kill` / diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d173b1cd7..946d158f3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -255,13 +255,13 @@ Completes a story parked at `awaiting-operator` once the external actions it lis bmad-loop confirm [--project PROJECT] [--list] [--yes] [--reverify] [--json] [story_key] ``` -| Option | Description | -| ------------ | ----------------------------------------------------------------------------------------------------------- | -| `story_key` | Parked story to confirm. | -| `--list` | List parked stories and their owed actions. | -| `--yes` | Skip the per-action acknowledgment prompt. | -| `--reverify` | After the acknowledgments, run the environment probes and `[verify]` commands; any failure blocks (exit 1). | -| `--json` | Emit the parked set as one JSON document. Implies `--list`. | +| Option | Description | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `story_key` | Parked story to confirm. | +| `--list` | List parked stories and their owed actions. | +| `--yes` | Skip the per-action acknowledgment prompt. | +| `--reverify` | After the acknowledgments, run the environment probes, then the `[verify]` commands; any failure blocks (exit 1). Nothing runs when no `[verify] commands` are configured. | +| `--json` | Emit the parked set as one JSON document. Implies `--list`. | - After each action is acknowledged, appends an `## Operator Confirmation` section to the spec, sets the spec and board to `done`, removes the park record, and commits the result (best-effort). Declining any action changes nothing and exits 0. - Exits 2 without a `story_key`, `--list` or `--json`. Exits 1 when the story is not parked in this checkout or its park record disagrees with the committed state. @@ -392,13 +392,13 @@ Reclaims disk from concluded runs according to `[cleanup]`. Scope: [cleanup vs c bmad-loop clean [--project PROJECT] [--dry-run] [--keep RUN_ID] [--retain N] [--hard] [--json] ``` -| Option | Description | -| --------------- | ----------------------------------------------------------------------------------------------------- | -| `--dry-run` | List what would be reclaimed; remove nothing. | -| `--keep RUN_ID` | Never touch this run (full id, exact match). Repeatable. | -| `--retain N` | Keep the newest N concluded runs whole. Overrides `[cleanup] run_retention`. `0` keeps none by count. | -| `--hard` | Delete runs past retention instead of archiving them. | -| `--json` | Emit one JSON document instead of text. | +| Option | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--dry-run` | List what would be reclaimed; remove nothing. | +| `--keep RUN_ID` | Never touch this run (full id, exact match). Repeatable. | +| `--retain N` | Keep the newest N concluded runs out of archive/delete (still trimmed when `[cleanup] trim_artifacts` is on). Overrides `[cleanup] run_retention`. `0` keeps none by count. | +| `--hard` | Delete runs past retention instead of archiving them. | +| `--json` | Emit one JSON document instead of text. | - Exits 0 once the policy loads; per-run faults are warnings on stderr or fields in the JSON document. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index fe4508a80..b93d9153a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,6 +1,10 @@ # Configuration reference -bmad-loop reads its policy from `.bmad-loop/policy.toml` in the project root. `bmad-loop init` writes a commented template there when the file is missing (it never overwrites an existing one) and adds the file to the project's `.gitignore`, so policy is per-machine. Every key is optional: an unset key takes the default listed below, unknown core keys are ignored, and a value of the wrong type or outside its allowed set is rejected with a policy error. The TUI settings screen edits the same file. Each `run` and `sweep` snapshots the policy into its run state when it starts, and `resume` re-stamps that snapshot from the current file, so an edit applies to new runs and to the next resume of an existing one. +bmad-loop reads its policy from `.bmad-loop/policy.toml` in the project root. `bmad-loop init` writes a commented template there when the file is missing (it never overwrites an existing one) and adds the file to the project's `.gitignore`, so policy is per-machine. + +Every key is optional: an unset key takes the default listed below, unknown core keys are ignored, and a value of the wrong type or outside its allowed set is rejected with a policy error. The TUI settings screen edits the same file. + +Each `run` and `sweep` snapshots the policy into its run state when it starts, and `resume` re-stamps that snapshot from the current file, so an edit applies to new runs and to the next resume of an existing one. Where a key can be set in more than one place, the first of these wins: diff --git a/docs/setup-guide.md b/docs/setup-guide.md index b9361635d..8c94b671b 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -10,7 +10,7 @@ their artifacts. Installing the tool is part of setup, not an optional extra. There are two ways the skills land in a project. The orchestrator's wheel **bundles** -the three skills, so the simplest path is **pip + `bmad-loop init`**, which installs them +the three skills, so the simplest path is **`uv tool install` + `bmad-loop init`**, which installs them itself. Alternatively the **BMAD-method installer** copies them. Either way the `/bmad-loop-setup` skill installs the orchestrator tool, picks which coding CLIs to drive, and bootstraps the project. For the one-page summary, @@ -40,26 +40,24 @@ see [Installing and upgrading](../README.md#installing-and-upgrading) in the REA or the `[mux] backend` policy key, or the `BMAD_LOOP_MUX_BACKEND` env var — forces the choice per machine. - **OS** — Linux or macOS. **Windows is supported via WSL**, which _is_ Linux: tmux and - every POSIX path work unchanged there, so no special setup is needed. **Native Windows is - experimental** — the bundled `psmux` backend (a ConPTY tmux re-implementation) drives runs - there and is selected automatically as the win32 default when its prerequisites are present: - the `psmux` and `pwsh` (PowerShell) binaries on `PATH`, with `psmux` 3.3.8 or newer (older - releases read as unavailable — see - [multiplexer backends](multiplexer-backends.md#psmux-native-windows-experimental) for why). It is - not yet at the Linux/macOS/WSL support tier — the remaining native-Windows work (window - hosting, attach/detach, Unity cache paths) is tracked in - [the roadmap](ROADMAP.md#native-windows-multiplexer-backend); the port path is in - [Porting bmad-loop to a new OS](porting-to-a-new-os.md). Stopping a run is not part of - that gap: `bmad-loop stop` lodges its request in a control file the engine reads itself, - at item boundaries and mid-session, so it no longer depends on Windows signal delivery - (#319). Inside WSL, install with the - **Linux** interpreter — a Windows-installed bmad-loop is reachable from the bash prompt - and silently behaves as Windows - ([why](multiplexer-backends.md#psmux-native-windows-experimental)). To check: - `bmad-loop mux` should read `platform default for linux`; if it names `win32` you are - running the Windows build, and `bmad-loop validate` says so too. (A forced choice — - `BMAD_LOOP_MUX_BACKEND` or `[mux] backend` — is reported as the reason _instead_ of the - platform, so clear it before reading that line as a platform check.) + every POSIX path work unchanged there, so no special setup is needed. + - **Native Windows is experimental.** The bundled `psmux` backend (a ConPTY tmux + re-implementation) drives runs there and is selected automatically as the win32 default + when its prerequisites are present: the `psmux` and `pwsh` (PowerShell) binaries on + `PATH`, with `psmux` 3.3.8 or newer (older releases read as unavailable — see + [multiplexer backends](multiplexer-backends.md#psmux-native-windows-experimental) for why). + - It is below the Linux/macOS/WSL support tier; open native-Windows work is in + [the roadmap](ROADMAP.md#native-windows-multiplexer-backend), and the port path is in + [Porting bmad-loop to a new OS](porting-to-a-new-os.md). `bmad-loop stop` works there too: + it lodges its request in a control file the engine reads at item boundaries and + mid-session, with no signal delivery involved. + - **Inside WSL, install with the Linux interpreter.** A Windows-installed bmad-loop is + reachable from the bash prompt and silently behaves as Windows + ([why](multiplexer-backends.md#psmux-native-windows-experimental)). To check: + `bmad-loop mux` should read `platform default for linux`; if it names `win32` you are + running the Windows build, and `bmad-loop validate` says so too. (A forced choice — + `BMAD_LOOP_MUX_BACKEND` or `[mux] backend` — is reported as the reason _instead_ of the + platform, so clear it before reading that line as a platform check.) ## Installed via the BMAD-method installer? (recommended) @@ -84,7 +82,7 @@ claude "/bmad-loop-setup accept all defaults" `uv tool upgrade bmad-loop --reinstall`. 2. Asks **which coding CLI(s)** the orchestrator should drive, then runs `bmad-loop init` to install the `bmad-loop-*` skills + register hooks + write the `.bmad-loop/policy.toml` - template + add gitignore entries (including policy.toml itself — policy is per-machine; repos initialized before this run `git rm --cached .bmad-loop/policy.toml` once if theirs is already committed) (see [Choosing which CLIs to drive](#choosing-which-clis-to-drive) + template + add gitignore entries, including policy.toml itself, which is per-machine (see [Choosing which CLIs to drive](#choosing-which-clis-to-drive) and [Initializing CLIs other than claude](#initializing-clis-other-than-claude)). On an upgrade it passes `--force-skills` so the per-project skill copies are refreshed. 3. Runs `bmad-loop validate` as a preflight (see [Verify](#verify)). @@ -145,9 +143,9 @@ A layer disabled with an empty `instruction`, or replaced in nothing. If the shape cannot be read at all, `validate` falls back to requiring the two v6.10.0 hunters — which a tree carrying `bmad-review` satisfies. -`bmad-review-verification-gap` is no longer required unconditionally (#260): no tagged -release ships it as a standalone skill, and on current sources it is only a thin -forwarder to `bmad-review`. It is required exactly when your installed layers name it. +`bmad-review-verification-gap` is required only when your installed layers name it: no +tagged release ships it as a standalone skill, and on current sources it is a thin +forwarder to `bmad-review`. ### Customizing the review layers @@ -169,7 +167,7 @@ without running the review: convention is ``Invoke the `skill-name` skill``). Both come back as `skills.review-layer-unresolved` warnings, so a customized layer can -never block a run the way the old fixed catalog did. What _is_ a problem: every layer +never block a run. What _is_ a problem: every layer disabled at once (`skills.review-layers-empty`), which makes the primitive HALT blocked with `no active review layers`. @@ -183,14 +181,17 @@ would not carry. `bmad-retrospective` reads session logs as evidence for its process lessons: why a session stalled, what it tried and dropped. It doesn't know where bmad-loop keeps them, so on an epic bmad-loop drove it finds none and skips that analysis. bmad-loop ships no -override for this (DW-387). To point the skill at the run records, create +override for this. To point the skill at the run records, create `_bmad/custom/bmad-retrospective.toml` yourself, or `bmad-retrospective.user.toml` for a personal copy: ```toml [workflow] persistent_facts = [ - "Session logs for bmad-loop-driven stories are the bmad-loop run directories, .bmad-loop/runs// under {project-root}. journal.jsonl is one JSON event per line: a session-start (task_id, role, story_key, adapter, model) and a session-end (task_id, tokens, and a status of completed, stalled, timeout, crashed, over_budget or aborted) per coding-CLI session, joined on task_id since session-end carries no story_key, plus review results and retries, rollbacks, pauses and escalations. logs/.log is that session's raw terminal capture (tmux pane output including escape sequences and repaints; for the hookless opencode profile, a readable transcript), and the journal's log_task / log_pos fields give the log file and byte offset each event lines up with. state.json records the run's stories and their outcome. Find the runs for this epic by grepping journal.jsonl files for its story keys. Runs archived by bmad-loop archive or bmad-loop clean are .bmad-loop/archive/.tar.gz and still hold journal.jsonl, state.json and logs/: search inside them as well. Record a session-log gap only for runs that were deleted.", + "Session logs for bmad-loop-driven stories are the run directories .bmad-loop/runs// under {project-root}. Find this epic's runs by grepping their journal.jsonl files for its story keys.", + "journal.jsonl holds one JSON event per line: a session-start (task_id, role, story_key, adapter, model) and a session-end (task_id, tokens, and a status of completed, stalled, timeout, crashed, over_budget or aborted) per coding-CLI session, joined on task_id since session-end carries no story_key, plus review results, retries, rollbacks, pauses and escalations.", + "logs/.log is that session's raw terminal capture (escape sequences and repaints included; a readable transcript for the hookless opencode profile). The journal's log_task / log_pos fields name the log file and byte offset each event lines up with. state.json records the run's stories and their outcome.", + "Runs archived by bmad-loop archive or bmad-loop clean are .bmad-loop/archive/.tar.gz and still hold journal.jsonl, state.json and logs/: search inside them too. Record a session-log gap only for runs that were deleted.", ] ``` @@ -209,7 +210,7 @@ time or retro the epic before its runs are deleted. ## 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 — worktree isolation needs the project root trusted; see [First-run notes](#first-run-notes)), 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. @@ -239,7 +240,7 @@ untouched — the default is correct. ## Installing the tool and TUI The `[tui]` extra pulls in the Textual dashboard (`textual` + `tomlkit` + `pyte`) so -`bmad-loop tui` works. The core tool needs only `pyyaml`. +`bmad-loop tui` works. The core tool needs only `pyyaml` and `regex` (plus `psutil` on Windows). **Together (recommended):** @@ -353,12 +354,11 @@ them to whoever owns the machine: with `bmad-loop probe-adapter antigravity --workspace ` (exit `0` = trusted). **Known limitation:** entries are never removed when a worktree is torn down, so `trustedWorkspaces` accumulates one entry per unit — prune stale - `.bmad-loop/runs/` paths by hand if the list grows. See - [#169](https://github.com/bmad-code-org/bmad-loop/issues/169) / DW-390. + `.bmad-loop/runs/` paths by hand if the list grows. - **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. -- **opencode** — install the HTTP client extra (`pip install 'bmad-loop[opencode]'`) and +- **opencode** — install the HTTP client extra (`uv tool install "bmad-loop[tui,opencode] @ git+https://github.com/bmad-code-org/bmad-loop.git"`) 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 `provider/model` (e.g. `[adapter] model = "anthropic/claude-haiku-4-5"`). A reasoning @@ -429,12 +429,13 @@ Tear down any leftover worktrees, tmux sessions, and archived runs while the too installed — once `.bmad-loop/` is gone these are unreachable: ```bash -bmad-loop clean --hard --project # delete every concluded run + its worktrees +bmad-loop clean --hard --retain 0 --project # delete every concluded run + its worktrees bmad-loop cleanup --project # kill leftover tmux sessions/windows ``` `clean --hard` permanently deletes runs instead of archiving them (we're removing the tool, so -there's nothing to keep). Running it **first** also matters for the out-of-tree half: each run's +there's nothing to keep), and `--retain 0` puts every concluded run past the retention window +(a `[cleanup] retention_days` you set still keeps newer runs; unset it first). Running it **first** also matters for the out-of-tree half: each run's control-plane directory lives under the user-scoped state root, and `clean` is what collects it (step 2 covers what is left over). See [Reclaiming sessions and disk](reference/run-state.md#reclaiming-sessions-and-disk) and the @@ -451,7 +452,7 @@ rm -rf .bmad-loop/ ``` One piece is **not** under `.bmad-loop/`: each run's hook-event channel lives in a user-scoped -state root outside the project (#494) — `$XDG_STATE_HOME/bmad-loop`, else +state root outside the project — `$XDG_STATE_HOME/bmad-loop`, else `~/.local/state/bmad-loop`; `%LOCALAPPDATA%\bmad-loop\state` on Windows; or whatever `BMAD_LOOP_STATE_DIR` names. Step 1 collected this project's runs from it, leaving an empty per-project directory whose name is a digest of the project path — not something to pick out by @@ -499,8 +500,8 @@ so remove it only when uninstalling the package itself. ### 5. Drop the gitignore lines -`init` appended `.bmad-loop/runs/`, `.bmad-loop/cache/`, and `.bmad-loop/policy.toml` to -`.gitignore`. Remove those three lines (skip any your project relies on for other reasons). If +`init` appended `.bmad-loop/runs/`, `.bmad-loop/cache/`, `.bmad-loop/policy.toml` and `_bmad/render/` to +`.gitignore`. Remove those four lines (skip any your project relies on for other reasons). If you had run `git rm --cached .bmad-loop/policy.toml` to stop sharing the per-machine policy, the file is untracked — re-add it (`git add .bmad-loop/policy.toml`) only if you want it back in version control. diff --git a/docs/testing.md b/docs/testing.md index 3eba796c5..189bf7dee 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,8 +13,9 @@ and every negative assertion proven by ablation.** ## The suite at a glance - Flat `tests/`: `test_*.py` files mirroring `src/bmad_loop` modules by name, no package - nesting, one `tests/conftest.py`, and one data file (`tests/fixtures/stories.yaml`, the - stories-mode contract). + nesting, one `tests/conftest.py`, and a small `tests/fixtures/` data dir (`stories.yaml`, the + stories-mode contract, plus the captured claude trust-prompt pane log that + `test_generic_tmux.py` replays). - Configuration is four settings under `[tool.pytest.ini_options]` in `pyproject.toml`: `testpaths = ["tests"]`, `asyncio_mode = "auto"`, `addopts = "--dist loadgroup"`, and `filterwarnings`. diff --git a/tests/test_docs_hygiene.py b/tests/test_docs_hygiene.py index e62d3e56b..1753fbe32 100644 --- a/tests/test_docs_hygiene.py +++ b/tests/test_docs_hygiene.py @@ -1,4 +1,5 @@ -"""Docs hygiene guard: links resolve, anchors resolve, generated tables are current. +"""Docs hygiene guard: links and anchors resolve, generated tables are current, +and the docs keep to their content policy. The docs are a cross-linked set — README, AGENTS, CONTRIBUTING and everything under ``docs/`` — and nothing else checks that a link still lands when a page is @@ -10,6 +11,9 @@ or ``](page.md#x)`` across pages) is a heading slug or ```` of its target. * ``test_policy_reference_is_current`` — the generated settings tables in ``docs/reference/configuration.md`` match ``core.toml`` (``scripts/gen_policy_reference.py --check``). +* ``test_no_tracker_tags_in_docs`` — no ``DW-`` or ``#`` refs outside ``docs/adr/``. +* ``test_doc_blocks_stay_short`` — paragraphs, list items and table cells stay under a word cap. +* ``test_features_catalog_shape`` — every FEATURES.md entry has a valid Stability and a Description. The corpus is the tracked ``*.md`` files among README.md, CONTRIBUTING.md, AGENTS.md and ``docs/**``. Fenced code blocks and inline code spans are stripped @@ -237,3 +241,168 @@ def test_policy_reference_is_current(): f"gen_policy_reference.py --check exited {proc.returncode}\n" f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" ) + + +# --- content policy: tracker tags, block length, FEATURES shape ------------- + +_TRACKER = re.compile(r"\bDW-\d+\b|(?]*>|[^)\s]*)[^)]*\)") +_AUTOLINK = re.compile(r"<[A-Za-z][A-Za-z0-9+.-]*:[^>\s]*>") +_LIST_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+") +_TABLE_ROW = re.compile(r"^\s*\|") +_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$") +_CELL_SPLIT = re.compile(r"(?") +_GENERATED_BEGIN = "