Skip to content

feat(adapters): coding-CLI adapter registry (Seam A) — out-of-tree adapters with zero core edits - #239

Merged
pbean merged 6 commits into
mainfrom
feat/adapter-registry
Aug 12, 2026
Merged

feat(adapters): coding-CLI adapter registry (Seam A) — out-of-tree adapters with zero core edits#239
pbean merged 6 commits into
mainfrom
feat/adapter-registry

Conversation

@pbean

@pbean pbean commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What & why

The transport axis has long been extensible out-of-tree (register_multiplexer + the bmad_loop.mux_backends entry-point group). The coding-CLI adapter axis had no equivalent: a CLI needing a new adapter class forced a name-branch in the run bootstrap (if profile.hookless: …) and a hardcoded valid-kinds set. This PR lands the missing registry so a new adapter family — and its selecting profile — plugs in with zero core .py edits, exactly like a transport backend.

This is Session 1 (Seam A) of the PR #226 review plan. No Hermes-specific code, no hook-scope changes, no relay changes.

Rebased as a redesign. Main absorbed none of Seam A, but the integration surface moved ~640 commits: the dispatch now lives in runsetup.make_adapters (not cli._make_adapters), the config digest gained the #461 profiles= pin, and _parse_profile grew invariants. The branch was re-cut onto main rather than merged — see Rebase deltas below for what changed versus the original cut.

Design

Mirrors adapters/multiplexer.py, with two deliberate asymmetries (documented in the module):

  • No lru_cache/cache_clear — adapters are built per run in make_adapters (its own by_cfg cache), so there is nothing to cache.
  • No configure_*/matches(platform)/platform defaults — an adapter kind is selected by the profile.adapter field alone, not a policy knob or sys.platform predicate.

Two dataclasses:

  • AdapterKind(name, needs_mux, load)load is a lazy thunk, so registration/validation/listing never import a heavy adapter module (nor an optional dep like httpx).
  • AdapterBuilder(plain, dev, construct_error) — the plain class, the _DevSynthesisMixin-composed dev class, and the family's construction-failure type(s) (() = none; (OpencodeServerError,) for HTTP).

Changes

  • New adapters/registry.py: register_adapter (first-wins so builtins can't be shadowed), builtin loader (generic needs_mux=True, opencode-http needs_mux=False), the bmad_loop.adapters entry-point scan degrading into _EXTERNAL_ERRORS (never raises), get_adapter_kind (fail loud, naming known kinds), known_adapter_kinds, external_adapter_errors, detect_adapters.
  • runsetup.make_adapters rewritten to a registry lookup keyed on profile.adapter; the shared mux is resolved + usability-checked only when kind.needs_mux; the sole hookless selection branch is gone; the synthesizes/paths axis stays a documented cross-family pipeline contract; construct_errorSystemExit. cli.py keeps only its re-export seam.
  • config_digest pins adapter — it supersedes hookless as the field selecting the argv builder, so a session rewriting it mid-run moves the pin the auto-sweep gates on instead of swapping the launch shape underneath it.
  • adapters/profile.py: CLIProfile.adapter (default "generic"), parsed but not membership-validated (validity is enforced against the live registry). opencode.toml migrated to adapter = "opencode-http". load_profiles gains a bmad_loop.profiles entry-point scan (precedence packaged < entry-point < project), degrade-not-crash into external_profile_errors(). Value-level invariants extracted into _validate_profile, run by both routes into the profile map.
  • bmad-loop adapters command + cmd_validate adapter.kind finding (via known_adapter_kinds(), never a hardcoded set) + external-error surfaces; three new check ids in checks.VALIDATE_CHECKS. adapter.httpx re-keyed on the adapter kind.
  • Docs: the out-of-tree registration recipe (two entry points) in the adapter-authoring guide, plus AGENTS.md, docs/README.md, docs/FEATURES.md, CHANGELOG.

Rebase deltas (versus the original cut)

  1. Dispatch re-sited into runsetup.make_adapters (runsetup.py), preserving the init vendors the hook relay into the agent-writable workspace, turning a file-write primitive into unattended persistent code execution #461 profiles is not None path so a digest-gated caller still launches the bytes it validated — the kind is read off the pinned profile, not a second read. cli.py:86's re-import seam is kept (existing monkeypatch.setattr(cli, "_make_adapters", …) tests still bite; a test pins the identity so a future merge can't resurrect a dead duplicate).
  2. adapter joins the config_digest launch payload, and the docstring's union-completeness rule is re-derived for open-ended external builders: the reads stay a closed set (the adapter's kwargs) but Policy is wider than the hashed launch surface, and the hashed adapter bounds the gap to fields of the kind already launched.
  3. cmd_validate block re-anchored after main's init vendors the hook relay into the agent-writable workspace, turning a file-write primitive into unattended persistent code execution #461 relay-stat block; adapter.httpx re-keyed on the adapter kind rather than hookless — httpx is the opencode family's extra, so a hookless profile driven by another kind no longer FAILs with a remedy that installs the wrong package.
  4. Entry-point profiles are validated against the same invariants _parse_profile enforces — dialect set, env_fault_patterns regex compilation, path containment. Value-level checks were extracted into _validate_profile, called by both routes, so a Python package cannot install a state the TOML parser refuses. The sharp case: an invalid env-fault regex otherwise trades a load-time error for a silent never-match inside a session's fault classification. Scope is semantic, not type-level, and the boundary is stated in the docstring.
  5. A malformed adapter TOML value funnels into ProfileError per provision_worktree's exclude patterns are repo-wide and permanent: new files under .claude/skills silently stop being staged in the main checkout #384 instead of str() coercion (str(["x"]) would carry "['x']" to get_adapter_kind and name that as the unknown kind).
  6. Docs re-integrated into the rewritten (probe-adapter era) guide, with two corrections: the herdr reference is a transport backend, not an adapter-class package, and policy's [adapter] name (picks the profile) is disambiguated from the profile's adapter (picks the kind).
  7. CHANGELOG entry under Unreleased (Added + three Changed notes).
  8. Fresh CI + a re-run zero-core-edit proof against the re-sited seam.

Guardrails honored

  • Validity of profile.adapter enforced via the registry (known_adapter_kinds), never a hardcoded set. (The adapter.httpx check names one bundled kind — a fact about one family's optional dep, not a valid-kinds set — via a registry constant, pinned by a test.)
  • registry.py imports adapter classes only inside lazy load thunks.
  • profile.py does not check adapter membership at parse time (shape only).
  • opencode-http migrated to a registered builtin, with a regression pin that its dispatch is unchanged.

Tests

  • New tests/test_adapter_registry.py (30 tests): registration, builtins-first-wins, unknown-kind fail-loud, broken-external degrades/recorded, one-bad-package-doesn't-hide-the-rest, real *.dist-info discovery, detect_adapters, the by_cfg (cfg, synthesizes) cache, both directions of the needs_mux gate, construct_errorSystemExit and that an undeclared failure is not swallowed, the cli._make_adapters alias identity, the digest pin, the httpx re-key, the profiles= path deciding the kind, and opencode-http dispatching unchanged.
  • tests/test_profile.py: entry-point discovery, plus a 15-row parity table proving an entry-point profile is held to every invariant a TOML profile is, a whole-batch rejection test, and a valid-profile control.
  • Ablations run for the entry-point validation (16 rows redden), the digest adapter pin, and the httpx re-key. The stale "hookless selects the adapter" docstrings in test_cli.py / test_runsetup.py are retexted.

Verification

  • Full uv run pytest -q -n auto: 5197 passed, 49 skipped, 5 xfailed, 0 failed.
  • uv run pyright: 0 errors. trunk check --all: no issues.
  • Zero-core-edit proof, re-run against the re-sited seam: a scratch out-of-tree package (real *.dist-info, both entry-point groups) on PYTHONPATHbmad-loop adapters lists it (scratch external no scratch), and both runsetup.make_adapters and cli._make_adapters select it for all three roles with no multiplexer resolved, with no edits to any core .py.

Summary by CodeRabbit

  • New Features

    • Added extensible coding-CLI adapters and profiles through external packages.
    • Added bmad-loop adapters to list available adapters, requirements, associated profiles, and loading issues.
    • Profiles can explicitly select adapter types, including built-in generic and OpenCode HTTP adapters.
  • Bug Fixes

    • Improved validation for adapter configuration, dependencies, malformed profiles, and external loading failures.
    • Configuration consistency now includes the selected adapter type.
    • Preserved compatibility for existing hookless profiles.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 49940b10-7ef3-46e3-8ef4-7bf3e078b859

📥 Commits

Reviewing files that changed from the base of the PR and between a5a0fcd and 3b0dded.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/adapter-authoring-guide.md
  • src/bmad_loop/adapters/profile.py
  • src/bmad_loop/adapters/registry.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/runsetup.py
  • tests/test_adapter_registry.py
  • tests/test_profile.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/bmad_loop/runsetup.py
  • CHANGELOG.md
  • src/bmad_loop/adapters/registry.py
  • docs/adapter-authoring-guide.md

Walkthrough

The change adds registry-based coding-CLI adapters, external profile loading, adapter-aware launch setup, validation, and the bmad-loop adapters command. The bundled opencode-http profile now selects its registered adapter explicitly.

Changes

Coding-CLI adapter extension

Layer / File(s) Summary
Profile contract and external profile loading
src/bmad_loop/adapters/profile.py, tests/test_profile.py
CLIProfile now includes an adapter field. TOML and external profiles use shared validation, precedence rules, and failure diagnostics.
Adapter registry and extension contract
src/bmad_loop/adapters/registry.py, docs/adapter-authoring-guide.md, AGENTS.md, docs/README.md, tests/test_adapter_registry.py
Built-in and external adapter kinds register through lazy builders and bmad_loop.adapters. The documentation describes adapter classes, profiles, loading, precedence, and failure reporting.
Digest and launch integration
src/bmad_loop/runsetup.py, src/bmad_loop/data/profiles/opencode.toml, tests/test_adapter_registry.py, tests/test_runsetup.py
The digest records profile.adapter. Launch setup resolves adapter kinds, selects plain or dev classes, applies multiplexer requirements, and reports declared construction errors.
Validation and adapter discovery commands
src/bmad_loop/cli.py, src/bmad_loop/checks.py, docs/FEATURES.md, CHANGELOG.md, tests/test_adapter_registry.py, tests/test_cli.py
Validation checks adapter registration and external loading. HTTP and model-format checks use adapter kind. The adapters command lists registered kinds, profiles, transport requirements, and load failures.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: ⚪ Minimal · up to 3b0dd

The PR adds out-of-tree adapter selection and profile registration without any supplied actionable correctness or merge-blocking risk; it is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant ProfileLoader
  participant AdapterRegistry
  participant RunSetup
  participant CLI
  ProfileLoader->>AdapterRegistry: discover external adapter registrations
  ProfileLoader-->>RunSetup: provide profile.adapter
  RunSetup->>AdapterRegistry: resolve adapter kind and load builder
  AdapterRegistry-->>RunSetup: return adapter classes and mux requirement
  RunSetup->>CLI: construct selected adapter
  CLI-->>RunSetup: report validation or construction error
Loading

Suggested reviewers: dracic, jackmcintyre

Poem

I’m a rabbit with adapters neat,
Profiles choose each coding seat.
Builders load when they are called,
Broken packages are recorded.
The registry lists each kind in sight—
And opencode-http selects right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the new coding-CLI adapter registry and its out-of-tree adapter support, which are the main changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/adapter-registry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pbean

pbean commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Triage 2026-08-08 (self-note): still needed — profile.py:254-266 landed the project overlay but no entry-point scan (bmad_loop.profiles / bmad_loop.adapters) exists in-tree. Re-siting required before revival: the target moved, cli._make_adaptersrunsetup.make_adapters (cli.py:86 re-imports; hookless branch at runsetup.py:403). Track order: this (Seam A) → re-cut #226 as profile + dialect → PR #238 relay primitive. Merging any of the three out of order re-conflicts the others.

The transport axis has long been extensible out-of-tree (register_multiplexer +
the bmad_loop.mux_backends entry-point group); the CLI adapter axis had no such
seam, so a CLI needing a new adapter *class* forced a name-branch in the run
bootstrap and a hardcoded valid-kinds set. This lands the missing registry so a
new adapter family — and its selecting profile — plugs in with zero core .py
edits, exactly like a transport backend.

Rebased as a redesign: main moved the integration surface after the original cut.

- New adapters/registry.py: AdapterKind(name, needs_mux, load-thunk) +
  AdapterBuilder(plain, dev, construct_error), register_adapter (first-wins),
  builtin loader (GENERIC, OPENCODE_HTTP), the bmad_loop.adapters entry-point
  scan degrading into _EXTERNAL_ERRORS (never raises), get_adapter_kind (fail
  loud), known_adapter_kinds, detect_adapters. Deliberately NO lru_cache/
  cache_clear and NO configure_*/platform machinery — adapters are built per-run
  and selected by profile.adapter data (documented in the module).

- The dispatch lands in runsetup.make_adapters, not cli._make_adapters: main
  moved that function, and cli.py:86 now re-exports it. The registry lookup
  replaces the `if profile.hookless` selection branch there, and the #461
  `profiles is not None` path is preserved — the kind is read off the profile
  the caller pinned, so a digest-gated caller still launches the bytes it
  validated. cli.py keeps only the re-export seam; no duplicate factory.

- config_digest pins `adapter`. It supersedes `hookless` as the field selecting
  the argv builder, so a driven session rewriting it mid-run now moves the pin
  the auto-sweep gates on. `hookless` stays (it still reshapes what the opencode
  builder emits). The docstring's union-completeness rule is re-derived for
  open-ended external builders: the reads stay a closed set (the adapter's
  kwargs) but Policy is wider than the hashed launch surface, and the hashed
  `adapter` bounds the gap to fields of the kind already launched.

- profile.py: CLIProfile.adapter (default "generic"); opencode migrated to
  adapter = "opencode-http". Value-level invariants extracted into
  _validate_profile, which BOTH routes into the profile map now run — so a
  bmad_loop.profiles entry point can no longer install a state the TOML parser
  refuses (the sharp case: an invalid env_fault_patterns regex, which otherwise
  trades a load-time error for a silent never-match at classification time).
  Scope is semantic, not type-level; the boundary is stated in the docstring.
  A malformed `adapter` value funnels into ProfileError per #384 rather than
  being str()-coerced.

- cmd_validate: the adapter.kind / adapter.external / adapter.external-profile
  block is anchored after main's #461 relay-stat block, and adapter.httpx is
  re-keyed on the adapter KIND rather than hooklessness — httpx is the opencode
  family's extra, so a hookless profile driven by another kind no longer FAILs
  with a remedy that installs the wrong package. Three ids registered in
  checks.VALIDATE_CHECKS.

- `bmad-loop adapters` lists the registered kinds and which profiles select
  them, naming a dangling kind reference and any failed out-of-tree package.

- Docs: the out-of-tree recipe re-integrated into the rewritten (probe-adapter
  era) adapter-authoring guide, with the herdr reference corrected (it is a
  transport backend, not an adapter class) and the two `adapter` keys
  disambiguated; AGENTS.md adapter-axis sentence, docs/README.md,
  docs/FEATURES.md (seam claim, no-Python claim, command reference), CHANGELOG.

- Tests: tests/test_adapter_registry.py covers registration, builtins-first-wins,
  unknown-kind fail-loud, external degradation/isolation, real dist-info
  discovery, the (cfg, synthesizes) cache, both directions of the needs_mux gate,
  construct_error->SystemExit (and that an UNdeclared failure is not swallowed),
  the cli alias identity, the digest pin, the httpx re-key, and the
  opencode-http dispatch-unchanged regression pin. Profile entry-point discovery
  plus a 15-row parity table proving an entry-point profile is held to the
  parser's invariants. Ablations run for the entry-point validation, the digest
  pin and the httpx re-key; the stale `hookless`-selects-the-adapter docstrings
  in test_cli.py / test_runsetup.py are retexted.
@pbean
pbean force-pushed the feat/adapter-registry branch from eccdf09 to a5a0fcd Compare August 12, 2026 20:50
@pbean
pbean marked this pull request as ready for review August 12, 2026 20:50
@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5a0fcd7a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return _EXTERNAL_PROFILES
for ep in eps:
try:
provider = ep.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load built-in adapters before profile entry points

When a package follows the documented pattern of defining both entry points in one module, loading its profile provider here also executes its register_adapter calls before _load_builtin_adapters() has run. If it accidentally registers a bundled name such as generic, the registry's first-registration-wins setdefault preserves the external implementation and all default profiles are redirected to it, defeating the stated guarantee that bundled kinds cannot be shadowed. Ensure built-ins are registered before importing any external profile provider.

Useful? React with 👍 / 👎.

Comment thread src/bmad_loop/runsetup.py Outdated
Comment on lines +455 to +459
try:
kind = get_adapter_kind(profile.adapter)
except AdapterError as e:
raise SystemExit(f"error: profile {profile.name!r}: {e}") from e
builder = kind.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate adapter kinds on the dry-run path

When a selected profile names an unknown adapter kind, a real run reaches this lookup and aborts, but cmd_run --dry-run returns earlier through _dry_run and never resolves the registry. It therefore exits successfully and prints a plausible invocation for a configuration that cannot run; for a registered external family, that invocation is also still synthesized using the generic-versus-hookless assumptions rather than the selected builder. The dry-run path should resolve the kind and use an adapter-specific rendering contract before reporting success.

Useful? React with 👍 / 👎.

Comment thread src/bmad_loop/runsetup.py Outdated
kind = get_adapter_kind(profile.adapter)
except AdapterError as e:
raise SystemExit(f"error: profile {profile.name!r}: {e}") from e
builder = kind.load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Translate failures from lazy adapter loaders

When an external adapter's documented lazy _load imports a missing optional dependency or otherwise fails, the exception is raised at this line before the construct_error handling below, producing a raw traceback after compose_run has already persisted the run state and PID. Neither validate nor bmad-loop adapters detects this because both intentionally avoid invoking the thunk, so run construction needs to convert loader failures into a typed, clean startup error rather than leaving a partially initialized run.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
tests/test_profile.py (1)

462-508: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clear the scan state in the fixture setup, not only inside arm.

arm resets _EXTERNALS_LOADED, _EXTERNAL_PROFILES and _PROFILE_LOAD_ERRORS. Every current test calls arm, so isolation holds today. A future test that requests profile_scan and asserts external_profile_errors() == {} without calling arm would read whatever a previous test left in the module globals, and would pass for the wrong reason.

Moving the three resets to fixture setup keeps arm responsible only for installing the fake entry points.

♻️ Proposed refactor
     saved_loaded = profile_mod._EXTERNALS_LOADED
     saved_profiles = dict(profile_mod._EXTERNAL_PROFILES)
     saved_errors = dict(profile_mod._PROFILE_LOAD_ERRORS)
+    profile_mod._EXTERNALS_LOADED = False
+    profile_mod._EXTERNAL_PROFILES.clear()
+    profile_mod._PROFILE_LOAD_ERRORS.clear()
 
     def arm(*eps, scan_error=None):
@@
         monkeypatch.setattr(profile_mod.importlib.metadata, "entry_points", fake_entry_points)
-        profile_mod._EXTERNALS_LOADED = False
-        profile_mod._EXTERNAL_PROFILES.clear()
-        profile_mod._PROFILE_LOAD_ERRORS.clear()
+        profile_mod._EXTERNALS_LOADED = False  # re-arm after an earlier load in the same test
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_profile.py` around lines 462 - 508, Move the initial resets of
profile_mod._EXTERNALS_LOADED, _EXTERNAL_PROFILES, and _PROFILE_LOAD_ERRORS from
arm into the profile_scan fixture setup before yielding. Keep the corresponding
saved-state restoration in teardown, and leave arm responsible only for
installing fake entry points and any per-call setup required by its arguments.
src/bmad_loop/adapters/profile.py (1)

407-412: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider sorting the entry points so first-registration-wins is reproducible.

setdefault at Line 412 makes the first provider win a name collision. importlib.metadata.entry_points yields providers in metadata discovery order, which depends on the installed distributions and sys.path order. Two hosts with the same packages installed can therefore resolve a colliding profile name differently, and the recorded reason names no conflict.

Sorting by entry-point name makes the winner a property of the names rather than of the install order.

♻️ Proposed refactor
-    for ep in eps:
+    for ep in sorted(eps, key=lambda ep: ep.name):

The bundled adapter registry has the same shape (register_adapter uses setdefault), but there builtins register before the scan, so a bundled name is never shadowed. Profiles have no such ordering guarantee between two external providers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/adapters/profile.py` around lines 407 - 412, Sort the entry
points deterministically by their names before iterating in the external profile
registration flow, so the existing setdefault call in the loop around
_coerce_profiles and _EXTERNAL_PROFILES consistently gives the same provider
precedence for name collisions. Preserve the current provider loading and
profile coercion behavior.
src/bmad_loop/adapters/registry.py (1)

181-205: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm that a failed external adapter import cannot leave a partially registered kind.

ep.load() imports a third-party module. That module can call register_adapter for kind A, then raise while registering kind B. The loader records the failure for the entry point, but kind A stays registered and selectable. Selection then succeeds for a package the operator was told failed to load.

This matches the documented degrade-not-crash contract, so it may be intended. Confirm the intent, and if partial registration is acceptable, state it in the docstring so a reader does not assume a failed entry point registered nothing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/adapters/registry.py` around lines 181 - 205, Clarify the
_load_external_adapters docstring that if ep.load() fails after registering some
adapter kinds, those partial registrations remain available and selectable while
the entry point failure is recorded. Preserve the existing
degrade-without-raising behavior and do not add rollback unless the intended
contract is to reject partial registration.
src/bmad_loop/runsetup.py (1)

455-459: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Convert adapter-thunk ImportError into a startup error.

AdapterKind.load() imports adapter classes and optional dependencies. Catch ImportError and raise SystemExit with profile and adapter context. Keep unrelated exceptions uncaught so adapter bugs still produce tracebacks. The bundled opencode_http loader remains safe because it imports httpx lazily.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/runsetup.py` around lines 455 - 459, Update the adapter loading
flow around kind.load() to catch ImportError and raise SystemExit with both
profile and adapter context, preserving the existing startup-error format. Keep
AdapterError handling unchanged and allow unrelated exceptions from
AdapterKind.load() to propagate normally.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/adapters/profile.py`:
- Around line 222-226: Update the shared adapter validation in the profile
validator to strip whitespace before checking whether adapter is non-empty,
matching the existing name and binary validation behavior. Ensure
whitespace-only adapter values are rejected for entry-point profiles while
preserving non-empty adapter values after normalization.

In `@src/bmad_loop/cli.py`:
- Around line 567-572: Import load_profiles alongside the existing profile
helpers, then invoke it unconditionally before iterating
external_profile_errors() so the bmad_loop.profiles entry-point scan runs even
when policy parsing prevents get_profile from executing. Preserve the existing
warning report and error details.

In `@tests/test_adapter_registry.py`:
- Line 603: Update the unpacking assignment in the test using
scan_adapter_registry to rename the unused registry variable to _registry,
matching the existing convention while preserving arm usage.

---

Nitpick comments:
In `@src/bmad_loop/adapters/profile.py`:
- Around line 407-412: Sort the entry points deterministically by their names
before iterating in the external profile registration flow, so the existing
setdefault call in the loop around _coerce_profiles and _EXTERNAL_PROFILES
consistently gives the same provider precedence for name collisions. Preserve
the current provider loading and profile coercion behavior.

In `@src/bmad_loop/adapters/registry.py`:
- Around line 181-205: Clarify the _load_external_adapters docstring that if
ep.load() fails after registering some adapter kinds, those partial
registrations remain available and selectable while the entry point failure is
recorded. Preserve the existing degrade-without-raising behavior and do not add
rollback unless the intended contract is to reject partial registration.

In `@src/bmad_loop/runsetup.py`:
- Around line 455-459: Update the adapter loading flow around kind.load() to
catch ImportError and raise SystemExit with both profile and adapter context,
preserving the existing startup-error format. Keep AdapterError handling
unchanged and allow unrelated exceptions from AdapterKind.load() to propagate
normally.

In `@tests/test_profile.py`:
- Around line 462-508: Move the initial resets of profile_mod._EXTERNALS_LOADED,
_EXTERNAL_PROFILES, and _PROFILE_LOAD_ERRORS from arm into the profile_scan
fixture setup before yielding. Keep the corresponding saved-state restoration in
teardown, and leave arm responsible only for installing fake entry points and
any per-call setup required by its arguments.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 008d673f-f959-4678-9035-5adc7ff0bf18

📥 Commits

Reviewing files that changed from the base of the PR and between 3e4ddce and a5a0fcd.

📒 Files selected for processing (15)
  • AGENTS.md
  • CHANGELOG.md
  • docs/FEATURES.md
  • docs/README.md
  • docs/adapter-authoring-guide.md
  • src/bmad_loop/adapters/profile.py
  • src/bmad_loop/adapters/registry.py
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/profiles/opencode.toml
  • src/bmad_loop/runsetup.py
  • tests/test_adapter_registry.py
  • tests/test_cli.py
  • tests/test_profile.py
  • tests/test_runsetup.py

Comment thread src/bmad_loop/adapters/profile.py
Comment thread src/bmad_loop/cli.py
Comment on lines +567 to +572
for ep_name, reason in sorted(external_profile_errors().items()):
report.warn(
"adapter.external-profile",
f"external profile '{ep_name}' failed to load: {reason}",
{"entry_point": ep_name, "error": reason},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

external_profile_errors() can report nothing when policy.toml fails to load.

The bmad_loop.profiles entry-point scan runs inside load_profiles, which validate reaches only through get_profile at line 316. That call sits inside the try block whose except policy_mod.PolicyError fires at line 321. If the policy fails to parse, role_names is never computed, get_profile never runs, the profile scan never happens, and this loop iterates an empty mapping. A broken installed profile package is then absent from the report for a reason unrelated to that package.

The adapter half does not have this gap, because known_adapter_kinds() at line 545 runs the adapter scan unconditionally. Trigger the profile scan the same way.

🛡️ Proposed fix
+    # Force the `bmad_loop.profiles` entry-point scan even when policy failed to
+    # load: otherwise a broken profile package goes unreported for a reason that
+    # has nothing to do with that package. `load_profiles` is idempotent.
+    try:
+        load_profiles(project)
+    except ProfileError:
+        pass  # a malformed overlay is already reported as `adapter.profile` above
     for ep_name, reason in sorted(external_profile_errors().items()):

This needs load_profiles added to the import at line 299.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for ep_name, reason in sorted(external_profile_errors().items()):
report.warn(
"adapter.external-profile",
f"external profile '{ep_name}' failed to load: {reason}",
{"entry_point": ep_name, "error": reason},
)
# Force the `bmad_loop.profiles` entry-point scan even when policy failed to
# load: otherwise a broken profile package goes unreported for a reason that
# has nothing to do with that package. `load_profiles` is idempotent.
try:
load_profiles(project)
except ProfileError:
pass # a malformed overlay is already reported as `adapter.profile` above
for ep_name, reason in sorted(external_profile_errors().items()):
report.warn(
"adapter.external-profile",
f"external profile '{ep_name}' failed to load: {reason}",
{"entry_point": ep_name, "error": reason},
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/cli.py` around lines 567 - 572, Import load_profiles alongside
the existing profile helpers, then invoke it unconditionally before iterating
external_profile_errors() so the bmad_loop.profiles entry-point scan runs even
when policy parsing prevents get_profile from executing. Preserve the existing
warning report and error details.

):
"""`bmad-loop adapters` renders the kind table and names a failed out-of-tree
package — the one place an operator looks when an installed adapter is missing."""
registry, arm = scan_adapter_registry

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Prefix the unused unpacked variable with an underscore.

Ruff reports RUF059 here: registry is never used in this test. Line 689 already uses _registry for the same unpacking shape, so the convention exists in this file.

🧹 Proposed fix
-    registry, arm = scan_adapter_registry
+    _registry, arm = scan_adapter_registry
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
registry, arm = scan_adapter_registry
_registry, arm = scan_adapter_registry
🧰 Tools
🪛 Ruff (0.16.1)

[warning] 603-603: Unpacked variable registry is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_adapter_registry.py` at line 603, Update the unpacking assignment
in the test using scan_adapter_registry to rename the unused registry variable
to _registry, matching the existing convention while preserving arm usage.

Source: Linters/SAST tools

…back-compat, loader escalation

Round 1 of the review gate on the adapter-registry seam (codex ×3, CodeRabbit
×3 + 4 nitpicks, plus an independent pass).

- register_adapter seeds the builtins itself, so a bundled kind cannot be
  shadowed however early an external import lands. The documented packaging
  layout puts both entry points in ONE module, so the bmad_loop.profiles scan
  imports it — long before any kind is resolved — and setdefault kept the
  external under the bundled name. Proven with a real *.dist-info repro.
- A profile predating the `adapter` field keeps its old dispatch: absent key +
  dialect "none" resolves to opencode-http, not the generic tmux adapter. A
  project overlay copied from the packaged opencode profile would otherwise
  have waited out session_timeout_min for a hook it never registers, with
  validate green (every check that would catch it also keys on hookless).
- A failing lazy load thunk becomes a clean SystemExit naming the profile and
  kind. ImportError only — a missing dependency is a loader's declared failure;
  anything else is a bug in that package and still surfaces as a traceback,
  matching the construct_error rule.
- The dry-run honesty banner reports an unregistered adapter kind, which a
  preview otherwise renders straight past.
- external_profile_errors/external_adapter_errors perform their own scan
  instead of depending on a neighbouring call — a PolicyError used to abort
  before the profiles scan ever ran, reporting a broken package as absent.
- _validate_profile strips `adapter`, closing the last TOML-vs-entry-point
  divergence; both entry-point scans visit in name order so first-wins is a
  fact about the packages, not about sys.path.

Every fix lands with an ablation-verified test.
@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17012b8399

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +96 to +99
# `validate` finding, both against the live registry. A hookless HTTP profile
# (hooks.dialect = "none") MUST set this to its HTTP adapter kind — the
# transport (hookless) and the driving class are now decoupled axes.
adapter: str = "generic"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Key model validation on the adapter kind

Now that adapter and hooks.dialect are independent, cmd_validate still applies OpenCode's provider/model rule whenever prof.hookless is true. Consequently, an out-of-tree hookless adapter using ordinary bare model names receives a misleading OpenCode warning, while an opencode-http profile with a non-none hook dialect misses the warning and can silently fall back to its default model. Gate this check on prof.adapter == adapter_registry.OPENCODE_HTTP, as the adjacent httpx check already does.

AGENTS.md reference: AGENTS.md:L38-L38

Useful? React with 👍 / 👎.

Round 2 of the review gate (codex, one P2). `policy.model-qualified` is
`adapter.httpx`'s sibling and needed the same re-keying this PR already
gave httpx: "provider/model" is the opencode SERVER's config-file
spelling, a fact about one adapter class, not about whether a profile
registers hooks. Those were one question only while `hookless` selected
the builder.

Keyed on `hookless` the check is wrong in both directions. It warns an
out-of-tree hookless family whose server takes bare model names, naming
an opencode convention that family does not use; and it stays silent for
an `opencode-http` profile carrying a hook dialect — legal once the axes
decoupled, and exactly where a bare name does fall back to the server
default. A test pins each direction, and the two pre-existing
model-qualified tests are the control: the packaged opencode profile is
both hookless and `opencode-http`, so the common case is untouched.

Swept the rest of the `hookless` call sites — validate's hook-config
skip, `_register_hooks`, the worktree hook shield, the config digest,
`probe-adapter` — and they are all genuinely about transport. This was
the only site keyed on the wrong axis.

Also states, at the `adapter` field itself, that the two profile routes
bind its "hookless MUST name an HTTP kind" rule differently: a TOML
profile omitting the key keeps the old dialect dispatch, while a
provider constructing the dataclass takes the default verbatim.
@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98f0bb7fef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +238 to +239
if not profile.adapter.strip():
raise fail("adapter must be a non-empty string naming an adapter kind")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject hookless profiles that select the generic adapter

When an entry-point provider leaves the new adapter field at its generic default while using HookSpec("none", ...), or TOML explicitly combines those values, this validation accepts the profile and validate reports both the kind and hooklessness as OK. The generic adapter relies on a Stop hook, while initialization skips hook registration for this profile, so an interactive CLI remains open until the session timeout rather than completing; reject the generic/none pairing while continuing to allow hookless profiles for custom kinds.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

…bution

Round 3 of the review gate (codex P2, plus two independent passes that
reached the same defect from the other side).

Refuse a hookless profile that selects `generic`. That adapter injects
into a tmux window and completes on a Stop hook; `dialect = "none"` means
none is ever registered, so the pair only ever waits out
`session_timeout_min` against a CLI that never exits — with `validate`
green, since every check it would trip keys on `hookless` too. The rule
lands in `_validate_profile` because that is the one point BOTH routes
pass through, and both could reach the pair: a TOML file naming it
outright, and an entry-point provider that builds a hookless HookSpec
while leaving `adapter` at its dataclass default. The absent-key TOML
case was already steered away by `_legacy_adapter_default`, which is
precisely why the Python route needed saying out loud — it has no
absent-key to detect, so the two routes disagreed about identical
profile content. Naming one bundled kind is a fact about that adapter's
completion contract, the same latitude the httpx check takes; hookless
on any other kind stays legal.

Order both entry-point scans by (name, distribution). Sorting on the
name alone did not deliver what its docstring claimed:
`entry_points(group=...)` does not dedup across distributions, so two
packages advertising the same name come back as two entries, and
`sorted` is stable — the tie fell straight back to `sys.path` order. That
tie is the whole case the sort exists for, since a package conventionally
names its entry point after the kind it registers. Verified against real
`*.dist-info` metadata on 3.11 and 3.13: with a name-only key the winner
flips with `sys.path` order; with the distribution in the key it does
not. The test doubles gain a `dist` to match.

The shadowing test's provider moves off `generic`/hookless — that pairing
was incidental to what it proves, and is now refused.
@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…ld reference

The `adapter` row claimed independence from `hooks.dialect` outright. That
is now two qualifications short: the absent-key back-compat carve-out it
already described, and the explicit `generic`/`none` pairing refused at
load. Also says the part a provider needs: hookless profiles must set
`adapter` rather than inherit the default.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90a7ca930d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +238 to +239
if not profile.adapter.strip():
raise fail("adapter must be a non-empty string naming an adapter kind")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize entry-point adapter names before accepting them

When a bmad_loop.profiles provider returns an otherwise valid profile with surrounding whitespace in adapter (for example, " acme " while acme is registered), this check validates the stripped copy but installs the original frozen value. Unlike the TOML path, which stores raw_adapter.strip(), the entry-point profile is then reported as an unknown kind and every real run refuses it, without recording the provider as malformed. Normalize the value before installation or reject noncanonical whitespace at this boundary.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

…rovider

Round 4 of the review gate (codex P2). The round-1 `.strip()` closed half
of a divergence and left the other half open: `_validate_profile` tests a
STRIPPED copy, but the frozen original is what gets installed. The TOML
route canonicalizes exactly three fields before construction, so `" acme "`
is content `_parse_profile` cannot produce — and every consumer keys on the
exact string. The profile lands under a map key `--cli acme` never finds, a
`binary` `shutil.which` never resolves, and an `adapter` `get_adapter_kind`
reports as an unknown kind, while the provider that shipped it is recorded
as perfectly fine.

Codex flagged `adapter`; `name` and `binary` are the same miss and are
closed with it, since `_parse_profile` strips all three. `name` is the worse
of the two it did not name: the profile is filed under a key nothing
resolves, so the package looks installed and absent at once.

Refused rather than normalized. This function validates and does not
rewrite — rebuilding a frozen dataclass here would leave the caller holding
the original anyway — and refusing is the louder half: the provider is
dropped WITH a reason naming the field, which is what the recorded-degrade
contract owes an operator. Ordered after the emptiness tests so `"   "`
still reads as empty rather than as non-canonical, which keeps the existing
whitespace-only row's message intact.
@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 3b0dded5bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pbean

pbean commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant