ADR-0016: Stack profile plugin protocol in a2c_core
- Status: Accepted
- Date: 2026-06-22
- Deciders: Michel Gillet
Context
A2C distinguishes stack profiles (technology-stack defaults) from task-body profiles (profiles/task-body/*.yaml — section templates for planning tasks). Task-body profiles are implemented: YAML data loaded and validated by a2c_core.profiles.task_body.
Stack profiles under profiles/ (for example cpp-cmake-conan) are mostly documentation today: expected tree extensions, ADR topics, CI expectations, and prompt hints. Bootstrap copies the profiles/ directory but does not:
- select or apply a stack profile
- scaffold stack-specific files (
pyproject.toml,CMakeLists.txt,conanfile.py, …) - install stack tooling (Conan, editable Python installs, preset configure smoke tests)
- merge stack-specific pre-commit hooks (Ruff, clang-format, …)
- run stack-specific doctor checks
ADR-0015 closes the stack-agnostic adoption gap (standard .pre-commit-config.yaml, .venv/, pre-commit + gitlint hook registration). It explicitly leaves language and build-stack hooks to profiles.
Project config already exposes workflows.default_profile (initial-contracts.md C3; ProfilePointer in C1), and the TUI can edit it — but bootstrap and setup_dev_environment() do not read it.
LibESys-style portfolios repeat three stacks:
| Stack | Typical tooling |
|---|---|
| Python | pyproject.toml, Ruff/mypy, pytest, editable install |
| C++ / CMake / Conan | CMake presets, Conan 2 profiles, ctest |
| Python + C++ extension | Hybrid layout (CMake + Conan native core, Python packaging), SWIG / PySWIG as the LibESys default binding layer; Conan + Python dev env |
A declarative-only profile format (for example a root profile.yaml with command lists) would still require Python to merge pre-commit config, branch on OS, detect existing toolchain state, and compose hybrid profiles. Behavior belongs in a2c_core; repository profiles/ should hold human guides and scaffold templates, not executable configuration.
The python-cpp-extension profile scaffolds and doctors the build and layout contract (native library + importable Python module). The choice of binding technology (SWIG, pybind11, Cython, etc.) is a project ADR — not hard-coded in the plugin protocol. Bundled v1 templates default to SWIG + PySWIG because that is the usual LibESys stack; consumers override via profiles/python-cpp-extension/templates/ after adoption.
Decision
1. Stack profiles are Python plugins in a2c_core
Introduce a stack profile plugin protocol implemented as Python classes under:
src/a2c_core/profiles/stacks/
├── __init__.py # public registry API
├── base.py # StackProfile protocol / ABC
├── registry.py # id → plugin class, load by id
├── python.py # bundled profile (v1)
├── cpp_cmake_conan.py # bundled profile (v1)
└── python_cpp_extension.py # bundled profile (v1; composes python + cpp)
Bundled plugins ship with a2c_core so pip install a2c-core and consumer bootstrap behave consistently without requiring the full method repository checkout.
The protocol is also designed for optional third-party profile packages installed separately (see §3). v1 ships only bundled profiles; the registry must support both sources from the first implementation.
Each plugin must expose:
| Attribute / method | Purpose |
|---|---|
id: str | Stable kebab-case identifier (for example python, cpp-cmake-conan, python-cpp-extension) |
label: str | Human-readable name for CLI/TUI |
scaffold(ctx) | Write stack template files and directories under the target repo (skip existing paths — same semantics as bootstrap _write_new) |
extend_pre_commit(base_yaml: str) -> str | Return merged .pre-commit-config.yaml text; must preserve ADR-0015 baseline hooks |
setup(ctx) -> DevSetupResult-compatible outcome | Stack-specific install/configure steps after base dev setup |
doctor(ctx) -> list[ValidationIssue] | Optional toolchain and layout checks |
compatible_a2c_version: str | None | External packages only — exact A2C semver this plugin targets; None for bundled plugins (implicit lockstep) |
ctx is a shared context object (repository root, resolved Python for .venv/, bootstrap delivery mode, method-assets root) defined in a2c_core.profiles.stacks.base — not re-specified here.
Composition (hybrid stacks) is implemented in Python (delegate to other plugin instances in a defined order), not via YAML extends.
There is no profile.yaml execution schema. Optional small metadata files under profiles/<id>/ (README only, or future non-executable manifests) do not drive setup logic.
2. Repository profiles/ holds assets and documentation
The method-repository tree profiles/<stack-id>/ remains the consumer-visible profile folder (copied or mounted by bootstrap). Each stack profile directory contains:
| Content | Role |
|---|---|
README.md | Adoption guide, ADR topics, CI expectations (as today) |
templates/ | Stub files the plugin copies during scaffold() (for example pyproject.toml.stub, CMakeLists.txt.stub) |
Plugins resolve template paths from:
- Consumer repo —
profiles/<id>/templates/when present (allows local customization after adoption) - Method assets — bundled under
a2c-workflow/profiles/<id>/templates/viamethod_assets_root()
Task-body profiles (profiles/task-body/) are unchanged — YAML data, separate loader, separate concern.
3. Registry and validation
a2c_core.profiles.stacks.registry provides:
list_stack_profiles() -> list[StackProfileInfo]— id, label, origin (bundled|package)get_stack_profile(profile_id: str) -> StackProfile— raises a structured error (A2C_PROFILE_001or equivalent) when unknownresolve_stack_profile(config: A2CConfig) -> StackProfile | None— usesworkflows.default_profile
Two registration sources (planned from v1)
| Source | How it registers | Examples |
|---|---|---|
| Bundled | Explicit registration in a2c_core.profiles.stacks at import time | python, cpp-cmake-conan, python-cpp-extension |
| Installed packages | Setuptools entry points group a2c.stack_profiles — each entry names a StackProfile subclass or factory | Future org-specific packages (for example a2c-profile-libesys-firmware) |
Registry load order:
- Register bundled plugins (built into
a2c_core). - Discover and register entry points from the active Python environment (
importlib.metadata.entry_points). - Duplicate
idacross sources → hard error at registry init (fail fast; no silent override).
External profile packages:
- Must declare a dependency on
a2c-core(same major product line as the installed CLI). - Must implement the same
StackProfileprotocol as bundled plugins. - Must declare the A2C product version they were built for (see A2C version lock below).
- Should ship default
templates/as package data inside the pip distribution;scaffold()resolves templates from the plugin package when consumer-repoprofiles/<id>/templates/is absent. - May ship a companion
profiles/<id>/README.mdin package data or document install separately; method-repoprofiles/copy remains optional for consumers who want docs in-repo.
A2C version lock (external packages only)
Operators commonly maintain many repositories on different A2C versions (ADR-0013). External profile wheels therefore carry two versions:
| Version | Meaning |
|---|---|
| Profile package version | Semver of the profile distribution itself (for example a2c-profile-libesys-firmware 1.2.0) — independent release cadence |
compatible_a2c_version | Exact A2C product semver the profile was implemented and tested against (for example 0.6.3) |
Each external plugin must expose compatible_a2c_version: str on the profile class (exact X.Y.Z, same form as a2c_core.__version__). Packaging should mirror it in pyproject metadata (for example [tool.a2c-profile] compatible-a2c = "0.6.3") for inspection without importing the plugin.
Compatibility rule: at registry load and on every get_stack_profile() / bootstrap / setup-dev / doctor use, compare compatible_a2c_version to the running A2C version (a2c_core.__version__ of the invoked CLI). Comparison is exact equality on normalized semver — not a floating range in v1.
| Situation | Behavior |
|---|---|
compatible_a2c_version == running A2C | Profile is usable — normal scaffold/setup/doctor |
compatible_a2c_version != running A2C | Profile may remain pip-installed and discoverable, but is not usable |
a2c bootstrap --profile <id> with incompatible package | Hard error (A2C_PROFILE_002 or equivalent) — profile steps do not run; message names running A2C, profile package version, and required compatible_a2c_version |
workflows.default_profile points at incompatible package | a2c doctor reports error; setup-dev profile phase errors; other commands that invoke the profile error when they would call setup() / scaffold() / doctor() |
list_stack_profiles() | Includes incompatible packages with compatible: false and reason — so pip list + a2c help explain why a installed profile is inactive |
Bundled profiles ship in lockstep with a2c_core — they inherit the running product version and skip the external version lock (always compatible with the installed CLI build).
Rationale: a profile compiled against A2C 0.6.x protocol and templates must not silently run under 0.7.x (or 0.5.x) where scaffold hooks, pre-commit merger, or adoption semantics may differ. Install-many-versions-on-one-machine is supported; use is gated per invocation.
v1 implementation includes entry-point discovery even when no third-party profile wheels are published yet — avoids a registry rewrite when the first external package ships.
Profile ids from either source are valid for workflows.default_profile and a2c bootstrap --profile.
Out of scope (still): loading profile classes from arbitrary filesystem paths in the consumer repo without a pip-installed package — that remains a future ADR if ever needed.
4. Bootstrap integration
a2c bootstrap gains an optional --profile <id> flag:
- Run existing stack-agnostic bootstrap (ADR-0015): config files, method dirs, base
.pre-commit-config.yaml,setup_dev_environment(). - When
--profileis set (orworkflows.default_profilealready in config on re-bootstrap — profile flag wins):scaffold()— stack templates and directoriesextend_pre_commit()— rewrite.pre-commit-config.yamlif the profile adds hooks (only when bootstrap wrote or would write that file)setup()— stack toolchain steps
- Set
workflows.default_profilein.a2c/config.yamlto the selected id.
When --profile is omitted, behavior stays identical to ADR-0015 (no stack scaffold, no stack setup).
Bootstrap records the selected profile in adoption metadata where appropriate (human-readable note in scaffold output; optional field in .a2c/adoption.yaml in implementation).
5. Pre-commit merge rules
Stack plugins append repo-local and hook-repo entries; they must not remove or reorder mandatory blocks from ADR-0015:
a2c-pre-commit-hooks(check-changelog,check-web-sources)- baseline
pre-commit-hookshygiene - tab removal for Markdown/YAML
- gitlint on
commit-msg
Implementation uses a shared merger in a2c_core (for example services/pre_commit_merge.py) — plugins return hook fragments or full merged YAML through the protocol, not ad hoc string concat in each plugin.
Pin policy for stack hooks (Ruff rev, clang-format rev) is owned by each bundled plugin and updated with a2c_core releases.
6. Setup and failure semantics
Stack setup() runs after base setup_dev_environment() so .venv/ and pre-commit registration exist before Python-stack steps.
Failure semantics match ADR-0015:
- Missing host tools (Conan, CMake, compiler) →
warningValidationIssue, bootstrap continues - Hard misconfiguration (invalid merged pre-commit YAML) →
error, bootstrap fails
Stack setup must not install OS-level toolchains (MSVC, Xcode, system GCC). Plugins may run detect/configure commands (conan profile detect, pip install -e ".[dev]", cmake --preset … smoke tests) and document prerequisites in profile README. For hybrid profiles, swig on PATH is a doctor warning when missing — same non-blocking semantics as Conan/CMake.
scripts/setup-dev.sh / .ps1 invoke base dev setup, then setup() for workflows.default_profile when set.
7. Doctor integration
a2c doctor (and TUI diagnostics that delegate to it) runs stack doctor() when workflows.default_profile is set, after existing repository checks.
Doctor checks are read-only (version probes, expected paths, preset names documented in profile README) — no mutating install.
8. Initial bundled profiles (v1 scope)
Implementation follows this ADR in separate commits. Bundled v1 ids:
| Profile id | Intent |
|---|---|
python | pyproject.toml stub, Ruff pre-commit hook overlay, editable dev install, pytest-oriented doctor |
cpp-cmake-conan | CMake/Conan stubs, Conan detect, preset/layout doctor |
python-cpp-extension | Composes python + cpp-cmake-conan scaffold/setup/doctor with hybrid ordering; default templates use SWIG / PySWIG (.i interface files, generated wrapper sources, extension module layout). Binding choice remains documented in a project adr(build) — templates are swappable, not protocol-fixed |
Additional profiles (monorepo, cross-compile firmware, etc.) add new bundled plugin modules or separate a2c.stack_profiles pip packages — no protocol change.
Binding-specific variants (for example python-cpp-extension with pybind11-oriented stubs) may ship as optional template packs under profiles/python-cpp-extension/templates/ without new profile ids, unless layout or setup order genuinely differs.
9. Explicit non-goals (v1)
- Publishing third-party profile packages to PyPI/GitLab (protocol and discovery only; first external package can follow in a later release)
- Consumer-defined plugins loaded from arbitrary repo paths without
pip install(future ADR if needed) profile.yamlas an executable DSL for setup commands- Replacing project build ADRs — profiles recommend defaults; toolchain pins and layout authority stay in project ADRs
- CI job generation as fully automated GitLab YAML emit — v1 documents CI expectations in profile README; optional template fragments may follow later
Consequences
Positive
- One
a2c bootstrap --profile …path for Python, native, and hybrid LibESys repos - Stack behavior is testable Python (unit tests per plugin, registry, pre-commit merger) — same quality bar as
dev_setup.py - Extensible registry — LibESys-common stacks ship with
a2c-core; specialized stacks can ship as add-on pip packages without forking core - Safe multi-repo versioning — external profiles pin to an A2C release; mismatch is visible and blocks use without blocking pip install
- Clear split:
a2c_coreexecutes;profiles/documents and supplies templates consumers can customize - ADR-0015 baseline stays universal; stack hooks land via a defined extension point
workflows.default_profilebecomes meaningful for bootstrap, setup-dev re-runs, and doctor
Negative
- More code in
a2c_core— bundled plugins must be maintained and versioned with releases - Each A2C bump may require republishing external profile packages with an updated
compatible_a2c_version— intentional friction to avoid cross-version drift - Hybrid and native profiles cannot fully automate environment parity across Windows/Linux/macOS without operator/CI involvement
- Repositories adopted before this ADR need
a2c bootstrap --profile …(config refresh) or manual profile scaffold +scripts/setup-dev - Pre-commit merge logic must be kept correct when ADR-0015 baseline changes (regression tests required)
References
- profiles/README.md — stack vs task-body profiles
- profiles/cpp-cmake-conan/README.md — existing C++ profile guide
- docs/workflow/reuse.md — adoption steps
- docs/architecture/repository-anatomy.md — consumer tree including
profiles/ - docs/architecture/initial-contracts.md — C1
ProfilePointer, C3default_profile - ADR-0013 — adoption record; multi-version repo context
- ADR-0015 — stack-agnostic bootstrap baseline
a2c_core.profiles.task_body— task-body profile loader (separate concern)a2c_core.services.dev_setup— base venv + pre-commit install- ADR-0017 — publishable Python profile (
python-pypi) and forge-neutral release scaffolding
Supersedes
- (none)
Superseded by
- (none)