Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ breaking changes may land in a minor release.

### Added

- **`bmad-loop validate --json` (#205).** A stable, schema-versioned JSON document of the
preflight: the `ok` verdict, the queue `mode`/`spec_folder`, per-severity `counts`, and every
check as a flat emission-ordered finding. Each finding carries a **stable `check` id** —
`hooks.registered`, `adapter.binary`, `skills.base-incomplete`, … — so CI can branch on a
particular failing gate instead of matching remediation prose, which is the part most likely
to be reworded. `detail` keeps what each check knew before it flattened itself into a
sentence: `mux.backends-detected` keeps every detected backend row rather than the text's
`tmux*, psmux (unavailable)` soup, whose trailing `*` a consumer had to parse to learn which
backend was selected, and `skills.base-incomplete` keeps `missing_markers` as a list rather
than a `", "`-joined string. **A failing check emits the whole document and still exits 1** —
the nonzero code is the verdict being reported, not a failure to produce one, so the existing
`bmad-loop validate || exit 1` in CI is untouched. `machine.py` gains the clause that makes
this well-defined: parse non-empty stdout whatever the exit code, and read the verdict from
the document's own `ok`, which unlike `rc` separates "the checks failed" from "the command
broke". The text output is byte-for-byte unchanged.

- **`bmad-loop clean --json` / `bmad-loop cleanup --json` (#204).** Stable, schema-versioned
JSON documents (one per command — they are separate contracts) reporting what a reclaim
removed, or under `--dry-run` would remove: for `clean` the worktree paths, trimmed,
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,14 @@ Each run drives its agents inside a dedicated tmux session, `bmad-loop-<run-id>`

`bmad-loop status --json` is the **supported machine-readable surface**: stdout becomes a single JSON document (nothing else is printed), so `bmad-loop status --json | jq '.tasks[].phase'` just works. The document 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 and `paused_stage`/`paused_reason`/`paused_story_key`), 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`, and a `tokens` object with the four raw counters plus derived `raw` and `weighted`. Everything is computed from the run's `state.json` alone — the weight comes from the persisted policy snapshot, so the numbers match what the run actually enforced. On error (no runs, unknown ref) stdout stays empty and the exit code is 1. The human-readable text output, by contrast, is **best-effort and not a stable interface** — parse the JSON, not the text.

### 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.

## 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/`:
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
### Command reference

- `bmad-loop init` — install skills, hooks, policy, gitignore.
- `bmad-loop validate` — preflight all prerequisites.
- `bmad-loop validate` — preflight all prerequisites. `--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 mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set <name>` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md).
- `bmad-loop run` — drive the dev → review → verify → commit loop.
- `bmad-loop sweep` — triage + execute open deferred-work entries.
Expand All @@ -199,4 +199,4 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

### 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, and anything breaking bumps the version. Errors never produce a partial or error document: the message goes to stderr, stdout stays empty, and the exit code is nonzero — so stdout is always either one complete valid document or empty. The contract is codified in `src/bmad_loop/machine.py` and holds for every command that takes the flag — currently `status`, `list`, `decisions`, `clean`, `cleanup`, `diagnose` and `probe-adapter` — with no exception ([#195](https://github.com/bmad-code-org/bmad-loop/issues/195)). On `diagnose` and `probe-adapter`, whose default output is a human-readable report, `--json` replaces that report rather than appending to it; with `--out FILE` the document goes to the file, stdout stays empty, and the confirmation goes to stderr.
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, and anything breaking bumps the version. Errors never produce a partial or error document: the message goes to stderr, stdout stays empty, and the exit code is nonzero — so stdout is always either one complete valid document or empty. An _error_ means the command could not do its job; a command whose job is to report a **verdict** is a different thing, and exits nonzero to carry the answer while still owing you the document (`validate --json` is the case). So the rule is positive rather than inferred from the exit status: **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". The contract is codified in `src/bmad_loop/machine.py` and holds for every command that takes the flag — currently `status`, `list`, `decisions`, `validate`, `clean`, `cleanup`, `diagnose` and `probe-adapter` — with no exception ([#195](https://github.com/bmad-code-org/bmad-loop/issues/195)). On `diagnose` and `probe-adapter`, whose default output is a human-readable report, `--json` replaces that report rather than appending to it; with `--out FILE` the document goes to the file, stdout stays empty, and the confirmation goes to stderr.
143 changes: 143 additions & 0 deletions src/bmad_loop/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Findings — the structured form of what ``bmad-loop validate`` reports.

``validate`` accumulated two lists of prose strings, printed one as `` ok:`` and
the other as ``FAIL:``. That made the *outcome* addressable (the exit code) but
not the *checks*: a script wanting to branch on "hooks aren't registered" had to
match remediation sentences, which are the part most likely to be reworded. A
:class:`Finding` pairs each printed line with a stable ``check`` id, so the id is
the matchable identity and the message stays free to change.

This lives in its own module rather than in ``cli`` or ``machine``:

- ``cli`` imports ``install``, and ``install`` must *return* Findings — putting
the type in ``cli`` would make that a cycle.
- ``machine`` is deliberately narrow: transport for the ``--json`` contract, zero
domain knowledge. Severity is a concept no other ``--json`` command has.

: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.
"""

from __future__ import annotations

import sys
from dataclasses import dataclass, field
from typing import Literal

Severity = Literal["ok", "warning", "problem"]

# The id names the *gate*, not the outcome — so the same id carries the ok and
# the problem for one check ("adapter.binary" is both "codex found" and "codex
# not found on PATH"). Ids are split only where the two outcomes are genuinely
# different findings with different detail (skills.base vs base-missing vs
# base-incomplete). Scheme: <area>.<check>.
VALIDATE_CHECKS: frozenset[str] = frozenset(
{
"bmad-config",
"policy",
"policy.model-qualified",
"adapter.profile",
"adapter.binary",
"adapter.hookless",
"adapter.httpx",
"queue.sprint-status",
"queue.sprint-status-unknown-keys",
"queue.stories-manifest",
"git.worktree-clean",
"git.probe",
"hooks.config-parse",
"hooks.registered",
"mux.backend",
"mux.preflight",
"mux.backends-detected",
"mux.selection",
"mux.external-backend",
"host.process",
"skills.base",
"skills.base-missing",
"skills.base-incomplete",
"skills.stories-dispatch",
"skills.stories-dispatch-missing",
"skills.stories-dispatch-stale",
}
)


@dataclass(frozen=True)
class Finding:
"""One check's outcome: a stable ``check`` id, a severity, the human line.

``message`` is the exact prose the text mode prints (minus the severity
prefix) and is **not** contracted — several are a bare ``str(e)`` from the
config/policy/profile/sprint-status exceptions, so the wording moves with
those modules. ``detail`` carries what the check knew before it flattened
itself into that sentence, keyed however suits the check.
"""

check: str
severity: Severity
message: str
detail: dict | None = None


@dataclass
class ValidationReport:
"""The findings of one ``validate`` pass, in emission order.

Emission order is the order the gates ran, and it is preserved across
severities — :meth:`render` filters, it never re-sorts, so the text output is
byte-identical to the two-list form it replaced (all stdout lines in append
order, then all stderr lines in append order).
"""

findings: list[Finding] = field(default_factory=list)

def add(self, check: str, severity: Severity, message: str, detail: dict | None = None) -> None:
assert check in VALIDATE_CHECKS, f"unregistered check id: {check!r}"
self.findings.append(Finding(check, severity, message, detail))

def ok(self, check: str, message: str, detail: dict | None = None) -> None:
self.add(check, "ok", message, detail)

def warn(self, check: str, message: str, detail: dict | None = None) -> None:
self.add(check, "warning", message, detail)

def fail(self, check: str, message: str, detail: dict | None = None) -> None:
self.add(check, "problem", message, detail)

def extend(self, findings: list[Finding]) -> None:
for finding in findings:
self.add(finding.check, finding.severity, finding.message, finding.detail)

@property
def passed(self) -> bool:
"""True when nothing failed. Warnings do not clear it and do not set it —
this is exactly the validate exit code (0 when True, 1 when False)."""
return not any(f.severity == "problem" for f in self.findings)

def counts(self) -> dict[str, int]:
return {
severity: sum(1 for f in self.findings if f.severity == severity)
for severity in ("ok", "warning", "problem")
}

def render(self) -> None:
"""Print the human-readable form: notes to stdout, then problems to stderr.

The doubled space in the warning line (`` ok: warning: ...``) is the
shipped output, not a typo to tidy: the warning sites stored
``" warning: " + msg`` into the same list the `` ok: `` printer walked.
Tidying it would be a silent text change — the validate tests
substring-match on a lowercased stream and would not notice.
"""
for finding in self.findings:
if finding.severity == "ok":
print(f" ok: {finding.message}")
elif finding.severity == "warning":
print(f" ok: warning: {finding.message}")
for finding in self.findings:
if finding.severity == "problem":
print(f"FAIL: {finding.message}", file=sys.stderr)
Loading