Goal: add brigade setup as a top-level command that installs all four manifest-backed native components (GraphTrail, graphtrail-mcp, MiseLedger, sessionfind) into the user-local Brigade managed bin with SHA-256 and byte-size verification, digest-keyed caching, offline cache hits, dry-run reporting, one-step rollback, and post-install smoke that invokes only absolute managed paths.
Architecture: the bundled templates/components/manifest-v1.json shipped inside the installed brigade-cli release is the sole manifest source. Setup rejects a brigade_version mismatch between the running Brigade and the bundled manifest. A new component_install module downloads and verifies every asset before mutating managed binaries, materializes executables via temp-sibling os.replace, runs smoke only on absolute managed paths, and commits installed.json only after smoke passes. installed.previous.json stores one prior state and rotates only when manifest revision or component digests change. Rollback swaps current and prior state using verified cache entries. brigade add and its Cargo/Go/npm fallbacks remain unchanged. PATH is never mutated automatically.
Out of scope: #356 doctor/version reporting and #357 Windows porting beyond the existing component_paths Windows behavior.
Key tech: Python standard library (urllib.request, hashlib, os.replace, stat), existing component_manifest, component_paths, and localio helpers.
Add brigade setup as a first-class top-level command with flags --dry-run, --offline, and --rollback. A dedicated component_install module owns download, verify, cache, materialize, smoke, state, and rollback. brigade add stays station-oriented with unchanged Cargo/Go/npm fallbacks.
Tradeoffs: matches the stated “top-level brigade setup” requirement, keeps install semantics separate from station wiring, and limits blast radius to new files plus manifest publication.
brigade add <station> checks the bundled manifest for matching tools and downloads pinned natives when assets exist; otherwise runs existing install_args.
Tradeoffs: reuses a familiar verb but mixes two install models (toolchain source builds vs pinned binaries), makes dry-run/rollback harder because add prints wiring guidance and is station-scoped, and couples manifest publication to station names.
Expose brigade components install, status, and rollback backed by the same engine as Alternative A.
Tradeoffs: clearer future growth surface, but does not satisfy the top-level brigade setup requirement without an alias and adds CLI surface area before Phase 1 needs it.
Recommendation: Alternative A. Register setup in COMMAND_GROUPS under “Stations and tools”. The component_install module can later back a components group if reporting (#356) needs it.
| Decision | Basis | Label |
|---|---|---|
Top-level brigade setup instead of extending brigade add |
Issue #355 and parent #352 require top-level setup | stated-constraint |
Bundled installed-release manifest is authoritative; reject brigade_version mismatch |
Prevents applying pins from a different Brigade build than the one running setup | stated-constraint |
Publish graphtrail and graphtrail-mcp v0.4.0 assets at commit 64fcd2f9ec37f33e286708845a92e6cfa4abf3bb with release evidence sizes/digests |
Release assets exist; manifest must carry full platform matrix before setup can install GraphTrail tools | evidence |
| Install all four components by default | Phase 1 acceptance requires a clean machine to complete setup without per-component flags | stated-constraint |
Flags limited to --dry-run, --offline, --rollback |
Approved interface; no --component, no PATH flags |
stated-constraint |
| No automatic PATH mutation | PATH edits are fragile and platform-specific; doctor and docs can reference managed absolute paths | judgment |
Keep brigade add Cargo/Go/npm fallbacks unchanged |
Compatibility window and existing managed.py install_args remain the alternate install path |
evidence |
Cache at cached_asset_path(cache_root, sha256, asset_name) |
component_paths.cached_asset_path already encodes digest/name layout |
evidence |
| Verify byte size and SHA-256 before caching, materializing, or executing | Requirement and existing localio.file_sha256 pattern |
stated-constraint |
| Online: replace bad cache only after a verified temp download; offline: fail on bad cache | Prevents clobbering a recoverable cache with a partial download while keeping offline strict | stated-constraint |
| Download and verify all assets before changing any managed bin | Atomic failure domain: no half-upgraded bin directory | stated-constraint |
Materialize via temp sibling + os.replace with executable bit; restore prior bytes on later failure |
Matches localio.write_text_atomic swap semantics for binaries |
evidence |
| Post-install smoke uses only absolute managed paths | Acceptance criterion: smoke must not accidentally pass via PATH | stated-constraint |
Commit installed.json only after smoke passes |
State must reflect a verified, runnable install | stated-constraint |
installed.previous.json holds one prior state; rotate only on manifest/digest change; idempotent repeat does not rotate |
Rollback and repeat-install requirements | stated-constraint |
Tests inject temp roots; never use live network or real $HOME |
CI determinism and contributor safety | stated-constraint |
Commit: 64fcd2f9ec37f33e286708845a92e6cfa4abf3bb. Release tag: v0.4.0. Repository: escoffier-labs/graphtrail.
Download URL pattern: https://github.com/escoffier-labs/graphtrail/releases/download/v0.4.0/<asset_name>.
| Platform | asset_name | byte_size | sha256 |
|---|---|---|---|
| darwin-amd64 | graphtrail-darwin-amd64 | 11695172 | eb6768be11d26a9d82c1bcecd503a9fdc7c883bda3bd627dea9ccb04fd31379c |
| darwin-arm64 | graphtrail-darwin-arm64 | 11426112 | 20534dbbb84f134a5a892520fd5a695d2520c2e040733b5535e991c611c99686 |
| linux-amd64 | graphtrail-linux-amd64 | 12802256 | e78c73a80a2eadbe297066739044e2c5fcdd187c8219198f453bd004bf9c9a55 |
| linux-arm64 | graphtrail-linux-arm64 | 12424560 | 7f961a4f018e4b12b9dcf5227a7cdc20fa7cdd9a723d96ec0336e254adf33c07 |
| windows-amd64 | graphtrail-windows-amd64.exe | 10753536 | 1a9adc002c81661d2b0838e642f1c9db2671a2808c93e6b4499cfc2b33d6ea22 |
| Platform | asset_name | byte_size | sha256 |
|---|---|---|---|
| darwin-amd64 | graphtrail-mcp-darwin-amd64 | 11004028 | d8d605a522d3894c36a2f30e94cdcc99b87c34d05e53b01f4c47018eaebaf93f |
| darwin-arm64 | graphtrail-mcp-darwin-arm64 | 10766928 | 649abe87a3e40415d1933db493c94f1049d84577a996df7ba9c27c4b3c4cf597 |
| linux-amd64 | graphtrail-mcp-linux-amd64 | 11981944 | 66606e4e394973766e1e91d3b0b78b26bd9077076cc85e62b9d0faf9780e7154 |
| linux-arm64 | graphtrail-mcp-linux-arm64 | 11609512 | 0bf17d38d44c5fc4f3132e17078a2db74e92da9bcb27a40c3d8345056523a651 |
| windows-amd64 | graphtrail-mcp-windows-amd64.exe | 10020864 | ec642c7e736fff1673c3d7e06d10eb02c9782008d8f783ab2b40699e69a35b08 |
MiseLedger and sessionfind remain pinned at v0.6.0 per the current bundled manifest; setup installs them through the same engine.
Path: component_paths.installed_state_path(data_root) → <data_root>/brigade/installed.json.
Prior path: component_paths.installed_previous_state_path(data_root) → <data_root>/brigade/installed.previous.json.
{
"schema_version": 1,
"brigade_version": "0.23.0",
"manifest_revision": "2026-07-18",
"platform": "linux-amd64",
"installed_at": "2026-07-19T06:00:00.000000+00:00",
"components": {
"graphtrail": {
"component_revision": "64fcd2f9ec37f33e286708845a92e6cfa4abf3bb",
"asset_name": "graphtrail-linux-amd64",
"byte_size": 12802256,
"sha256": "e78c73a80a2eadbe297066739044e2c5fcdd187c8219198f453bd004bf9c9a55",
"download_url": "https://github.com/escoffier-labs/graphtrail/releases/download/v0.4.0/graphtrail-linux-amd64",
"executable": "/tmp/home/.local/share/brigade/bin/graphtrail"
}
}
}installed.previous.json uses the same schema. Rotation copies the current file to installed.previous.json only when manifest_revision or any component sha256 changes after a successful install. Idempotent repeat installs that verify matching cache and managed bytes leave installed.previous.json untouched.
- Load bundled manifest via
component_manifest.load(). - Compare
manifest.brigade_versiontobrigade.__version__; exit non-zero on mismatch. - Resolve host platform via
component_manifest.platform_key(). - For each component in
KNOWN_COMPONENT_IDS, resolve asset withcomponent_manifest.resolve_asset. - Dry-run (
--dry-run): print brigade version, manifest revision, platform, and for each component: component revision, asset name, byte size, sha256, download URL, cache path, managed executable path, and planned actions (verify-cache,download,materialize,smoke). Write nothing. Exit 0. - Rollback (
--rollback): requireinstalled.previous.json. Verify every prior component cache entry (size + sha256). Swap managed executables from verified cache, swapinstalled.json↔installed.previous.jsonvia atomic writes. Run smoke on restored managed paths. Exit non-zero on verification or smoke failure. - Install (default): for each asset, ensure cache file at
cached_asset_pathmatchesbyte_sizeandsha256. Online: download to temp sibling, verify, thenos.replaceinto cache (replacing a bad cache only after verified temp). Offline (--offline): require valid cache; fail if cache missing or corrupt. After all caches verified, snapshot current managed executables (if any) for restore-on-failure. Materialize each component tomanaged_executable_pathvia temp sibling +os.replace+ executable bit (0o755on Unix). Run smoke suite on absolute managed paths. On any materialize or smoke failure, restore snapshotted executables. On success, rotateinstalled.previous.jsonwhen manifest revision or digests changed, then writeinstalled.json.
| Component | Command | Success |
|---|---|---|
| graphtrail | <managed>/graphtrail --version |
exit 0, non-empty stdout |
| graphtrail-mcp | <managed>/graphtrail-mcp JSON-RPC initialize on stdin |
valid JSON-RPC response on stdout |
| miseledger | <managed>/miseledger version |
exit 0 |
| sessionfind | <managed>/sessionfind --help |
exit 0; command-syntax lines beginning with sessionfind read from stdout, or legacy text containing usage read from stdout or stderr |
| File | Role |
|---|---|
src/brigade/component_paths.py |
Add installed_previous_state_path, managed_bin_dir |
src/brigade/component_state.py |
Load, validate, render, rotate installed state v1 |
src/brigade/component_install.py |
Download, verify, cache, materialize, smoke, orchestration |
src/brigade/cli/setup.py |
brigade setup argparse registration and dispatch |
src/brigade/cli/__init__.py |
Register _setup_group after _add_group |
src/brigade/cli/_common.py |
Add setup to COMMAND_GROUPS (“Stations and tools”) |
src/brigade/component_manifest.py |
Remove graphtrail IDs from UNPUBLISHED_COMPONENT_IDS after manifest publication |
src/brigade/templates/components/manifest-v1.json |
Publish graphtrail v0.4.0 assets; bump manifest_revision |
tests/test_component_install.py |
Engine and orchestration tests (no network) |
tests/test_cli_setup.py |
CLI wiring and flag tests |
tests/test_component_manifest.py |
Extend bundled-manifest assertions for graphtrail v0.4.0 assets |
tests/test_component_paths.py |
Assert installed_previous_state_path |
tests/test_cli_help.py |
Unchanged gate; fails if setup missing from COMMAND_GROUPS |
# src/brigade/component_state.py
SCHEMA_VERSION = 1
@dataclass(frozen=True)
class InstalledComponentRecord:
component_revision: str
asset_name: str
byte_size: int
sha256: str
download_url: str
executable: str
@dataclass(frozen=True)
class InstalledState:
schema_version: int
brigade_version: str
manifest_revision: str
platform: str
installed_at: str
components: dict[str, InstalledComponentRecord]
def load_installed_state(path: Path) -> InstalledState | None
def render_installed_state(state: InstalledState) -> dict[str, object]
def state_digest_map(state: InstalledState) -> dict[str, str]
def should_rotate_previous(current: InstalledState | None, next_state: InstalledState) -> bool
# src/brigade/component_install.py
@dataclass(frozen=True)
class SetupPlanAction:
component_id: str
action: str # verify-cache | download | materialize | smoke
cache_path: str
managed_path: str
asset_name: str
byte_size: int
sha256: str
download_url: str
component_revision: str
@dataclass(frozen=True)
class SetupRoots:
data_root: str
cache_root: str
env: Mapping[str, str]
class ComponentInstallError(RuntimeError)
def resolve_roots(*, env: Mapping[str, str] | None = None, system: str | None = None) -> SetupRoots
def build_setup_plan(manifest: ComponentManifest, *, platform: str, roots: SetupRoots) -> list[SetupPlanAction]
def verify_cached_asset(path: Path, *, byte_size: int, sha256: str) -> None
def fetch_asset_to_cache(asset: ComponentAsset, *, cache_path: Path, offline: bool, opener: Callable[..., object] | None = None) -> Path
def materialize_executable(*, cache_path: Path, managed_path: Path) -> None
def run_post_install_smoke(managed_paths: Mapping[str, str], *, runner: Callable[..., subprocess.CompletedProcess] | None = None) -> None
def setup_native_components(*, dry_run: bool = False, offline: bool = False, rollback: bool = False, env: Mapping[str, str] | None = None, opener: Callable[..., object] | None = None, runner: Callable[..., subprocess.CompletedProcess] | None = None) -> intAdd tests/component_install_helpers.py:
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import brigade
from brigade import component_manifest
GRAPHTRAIL_SHA = "64fcd2f9ec37f33e286708845a92e6cfa4abf3bb"
GRAPHTRAIL_BASE = "https://github.com/escoffier-labs/graphtrail/releases/download/v0.4.0/"
FIXTURE_REPOSITORY = "example/components"
def linux_env(root: Path) -> dict[str, str]:
home = root / "home"
home.mkdir(parents=True, exist_ok=True)
return {
"HOME": str(home),
"XDG_DATA_HOME": str(root / "xdg-data"),
"XDG_CACHE_HOME": str(root / "xdg-cache"),
}
def smoke_stub_script(name: str) -> str:
if name == "graphtrail":
return f'#!/usr/bin/env python3\nimport sys\nif sys.argv[1:] == ["--version"]:\n print("graphtrail test 0.4.0")\n raise SystemExit(0)\nraise SystemExit(1)\n'
if name == "graphtrail-mcp":
return (
'#!/usr/bin/env python3\nimport json, sys\n'
'req = json.load(sys.stdin)\n'
'assert req.get("method") == "initialize"\n'
'print(json.dumps({"jsonrpc": "2.0", "id": req.get("id"), "result": {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "graphtrail-mcp", "version": "0.4.0"}}}))\n'
)
if name == "miseledger":
return '#!/usr/bin/env python3\nimport sys\nif sys.argv[1:] == ["version"]:\n print("miseledger test 0.6.0")\n raise SystemExit(0)\nraise SystemExit(1)\n'
if name == "sessionfind":
return '#!/usr/bin/env python3\nimport sys\nif sys.argv[1:] == ["--help"]:\n print("sessionfind list [--source KIND] ...")\n print("sessionfind search <query> ...")\n print("sessionfind <query> ...")\n raise SystemExit(0)\nraise SystemExit(1)\n'
raise ValueError(name)
def fixture_payload(component_id: str, *, platform: str = "linux-amd64") -> tuple[bytes, int, str]:
"""Return (payload_bytes, byte_size, sha256) for runnable smoke-stub fixture bytes."""
lines = smoke_stub_script(component_id).splitlines(keepends=True)
if lines and lines[0].startswith("#!"):
lines.insert(1, f"# platform: {platform}\n")
else:
lines.insert(0, f"# platform: {platform}\n")
body = "".join(lines).encode("utf-8")
digest = hashlib.sha256(body).hexdigest()
return body, len(body), digest
def fixture_asset_name(component_id: str, *, platform: str) -> str:
base = f"{component_id}-{platform}"
if platform == "windows-amd64":
return f"{base}.exe"
return base
def test_component_revision(component_id: str) -> str:
if component_id in ("graphtrail", "graphtrail-mcp"):
return GRAPHTRAIL_SHA
return "fixture-revision"
def write_verified_cache(cache_path: Path, *, payload: bytes) -> None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(payload)
cache_path.chmod(0o755)
def test_manifest_asset(component_id: str, *, platform: str = "linux-amd64") -> component_manifest.ComponentAsset:
_, byte_size, sha256 = fixture_payload(component_id, platform=platform)
asset_name = fixture_asset_name(component_id, platform=platform)
return component_manifest.ComponentAsset(
asset_name=asset_name,
byte_size=byte_size,
sha256=sha256,
download_url=f"https://example.invalid/components/{asset_name}",
)
def write_test_manifest(path: Path, *, brigade_version: str) -> component_manifest.ComponentManifest:
"""Write a manifest whose digests match fixture_payload bytes for offline engine tests."""
components: dict[str, object] = {}
for component_id in component_manifest.KNOWN_COMPONENT_IDS:
assets: dict[str, object] = {}
for platform in component_manifest.SUPPORTED_PLATFORMS:
_, byte_size, sha256 = fixture_payload(component_id, platform=platform)
asset = test_manifest_asset(component_id, platform=platform)
assert asset.byte_size == byte_size
assert asset.sha256 == sha256
assets[platform] = {
"asset_name": asset.asset_name,
"byte_size": byte_size,
"sha256": sha256,
"download_url": asset.download_url,
}
components[component_id] = {
"component_revision": test_component_revision(component_id),
"source": {"repository": FIXTURE_REPOSITORY, "release_tag": "fixture"},
"executable": component_id,
"assets": assets,
}
path.write_text(
json.dumps(
{
"schema_version": 1,
"brigade_version": brigade_version,
"manifest_revision": "fixture",
"supported_platforms": list(component_manifest.SUPPORTED_PLATFORMS),
"components": components,
}
)
)
return component_manifest.load(path)
def all_fixture_payloads(*, platform: str = "linux-amd64") -> dict[str, bytes]:
payloads: dict[str, bytes] = {}
for component_id in component_manifest.KNOWN_COMPONENT_IDS:
payload, _, _ = fixture_payload(component_id, platform=platform)
asset = test_manifest_asset(component_id, platform=platform)
payloads[asset.download_url] = payload
return payloads
class FakeOpener:
def __init__(self, payloads: dict[str, bytes]):
self.payloads = payloads
self.calls: list[str] = []
def __call__(self, url: str, *args, **kwargs):
self.calls.append(url)
from io import BytesIO
from urllib.error import HTTPError
if url not in self.payloads:
raise HTTPError(url, 404, "not found", hdrs=None, fp=BytesIO(b""))
return BytesIO(self.payloads[url])Engine tests load write_test_manifest(...) from a temp path (or monkeypatch component_manifest.load) so every component publishes the full five-platform matrix, source.repository stays an owner/name pair, and every asset byte_size and sha256 is derived from executable fixture_payload stub bytes at test time. Full-install tests materialize those same bytes so post-install smoke exercises real stub behavior. Never synthesize bytes to match an arbitrary digest.
Each task follows RED → GREEN through Brigade verify, then a small conventional commit. Verification command template:
brigade work verify run --target . \
--command ".venv/bin/python -m pytest <test-target> -q" \
--capture brigade-workFiles: src/brigade/templates/components/manifest-v1.json, src/brigade/component_manifest.py, tests/test_component_manifest.py.
- Update manifest: set
manifest_revisionto2026-07-19, add full graphtrail and graphtrail-mcp asset matrices from the release evidence table withsource.release_tagv0.4.0andcomponent_revision64fcd2f9ec37f33e286708845a92e6cfa4abf3bb. - Remove
graphtrailandgraphtrail-mcpfromUNPUBLISHED_COMPONENT_IDS. - Add failing bundled-manifest tests:
def test_bundled_manifest_pins_graphtrail_v040_assets():
manifest = component_manifest.load()
asset = manifest.components["graphtrail"].assets["linux-amd64"]
assert asset.byte_size == 12802256
assert asset.sha256 == "e78c73a80a2eadbe297066739044e2c5fcdd187c8219198f453bd004bf9c9a55"
assert manifest.components["graphtrail"].source.release_tag == "v0.4.0"
def test_bundled_manifest_pins_graphtrail_mcp_v040_assets():
manifest = component_manifest.load()
asset = manifest.components["graphtrail-mcp"].assets["linux-amd64"]
assert asset.byte_size == 11981944
assert asset.sha256 == "66606e4e394973766e1e91d3b0b78b26bd9077076cc85e62b9d0faf9780e7154"- RED:
brigade work verify run --target . --command ".venv/bin/python -m pytest tests/test_component_manifest.py::test_bundled_manifest_pins_graphtrail_v040_assets tests/test_component_manifest.py::test_bundled_manifest_pins_graphtrail_mcp_v040_assets -q" --capture brigade-work - GREEN after manifest +
UNPUBLISHED_COMPONENT_IDSupdate. - Commit:
chore(components): publish graphtrail v0.4.0 manifest assets.
Files: src/brigade/component_paths.py, src/brigade/component_state.py, tests/test_component_paths.py, tests/test_component_state.py.
- Add
installed_previous_state_path(data_root_path: str) -> str. - Add failing path test:
def test_installed_previous_state_path_uses_brigade_subdir():
env = {"HOME": "/home/alice"}
data = component_paths.data_root(env=env, system="linux")
path = component_paths.installed_previous_state_path(data)
assert path.endswith("brigade/installed.previous.json")- Add failing state tests:
def test_render_installed_state_round_trips_required_fields():
state = InstalledState(
schema_version=1,
brigade_version="0.23.0",
manifest_revision="2026-07-19",
platform="linux-amd64",
installed_at="2026-07-19T06:00:00+00:00",
components={
"miseledger": InstalledComponentRecord(
component_revision="v0.6.0",
asset_name="miseledger-linux-amd64",
byte_size=16441315,
sha256="246893c8c39318f774fc7a06338b5a8e87bf84661b1951251b2c0c971e9a7a6c",
download_url="https://github.com/escoffier-labs/miseledger/releases/download/v0.6.0/miseledger-linux-amd64",
executable="/tmp/xdg-data/brigade/bin/miseledger",
)
},
)
payload = render_installed_state(state)
assert payload["schema_version"] == 1
assert payload["installed_at"] == "2026-07-19T06:00:00+00:00"
assert payload["components"]["miseledger"]["executable"].endswith("/brigade/bin/miseledger")
def test_should_rotate_previous_when_digest_changes():
current = _sample_state(sha256="a" * 64)
nxt = _sample_state(sha256="b" * 64)
assert should_rotate_previous(current, nxt) is True
def test_should_not_rotate_previous_on_identical_digest_map():
current = _sample_state(sha256="a" * 64)
nxt = _sample_state(sha256="a" * 64, manifest_revision=current.manifest_revision)
assert should_rotate_previous(current, nxt) is False- RED → implement → GREEN on
tests/test_component_paths.py tests/test_component_state.py. - Commit:
feat(components): add installed state schema v1.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing tests:
def test_verify_cached_asset_rejects_size_mismatch(tmp_path):
path = tmp_path / "asset"
path.write_bytes(b"short")
with pytest.raises(ComponentInstallError, match="byte_size"):
verify_cached_asset(path, byte_size=10, sha256="a" * 64)
def test_verify_cached_asset_rejects_digest_mismatch(tmp_path):
path = tmp_path / "asset"
path.write_bytes(b"0123456789")
with pytest.raises(ComponentInstallError, match="sha256"):
verify_cached_asset(path, byte_size=10, sha256="b" * 64)
def test_setup_rejects_brigade_version_mismatch(tmp_path, monkeypatch):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version="9.9.9")
monkeypatch.setattr("brigade.__version__", "0.23.0", raising=False)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
rc = setup_native_components(env=env)
assert rc == 1- Implement
verify_cached_assetusingpath.stat().st_sizeandlocalio.file_sha256. - Implement manifest
brigade_versioncheck at start ofsetup_native_components. - RED → GREEN on focused tests.
- Commit:
feat(components): verify cache bytes and brigade version.
Files: src/brigade/component_install.py, tests/test_component_install.py, tests/component_install_helpers.py.
- Add failing tests:
def test_fetch_asset_to_cache_writes_verified_bytes(tmp_path):
asset = test_manifest_asset("miseledger", platform="linux-amd64")
payload, byte_size, sha256 = fixture_payload("miseledger", platform="linux-amd64")
assert asset.byte_size == byte_size
assert asset.sha256 == sha256
cache_path = tmp_path / "cache" / sha256 / asset.asset_name
opener = FakeOpener({asset.download_url: payload})
result = fetch_asset_to_cache(asset, cache_path=cache_path, offline=False, opener=opener)
assert result == cache_path
verify_cached_asset(cache_path, byte_size=byte_size, sha256=sha256)
def test_fetch_asset_offline_fails_when_cache_corrupt(tmp_path):
asset = test_manifest_asset("miseledger", platform="linux-amd64")
cache_path = tmp_path / "cache" / asset.sha256 / asset.asset_name
cache_path.parent.mkdir(parents=True)
cache_path.write_bytes(b"bad")
with pytest.raises(ComponentInstallError, match="offline"):
fetch_asset_to_cache(asset, cache_path=cache_path, offline=True)
def test_fetch_asset_online_replaces_bad_cache_only_after_verified_download(tmp_path):
asset = test_manifest_asset("miseledger", platform="linux-amd64")
payload, byte_size, sha256 = fixture_payload("miseledger", platform="linux-amd64")
cache_path = tmp_path / "cache" / sha256 / asset.asset_name
cache_path.parent.mkdir(parents=True)
cache_path.write_bytes(b"stale")
opener = FakeOpener({asset.download_url: payload})
fetch_asset_to_cache(asset, cache_path=cache_path, offline=False, opener=opener)
verify_cached_asset(cache_path, byte_size=byte_size, sha256=sha256)- Implement temp-sibling download, verify,
os.replaceinto cache; offline raises when cache invalid; online never replaces until verified temp passes. - RED → GREEN.
- Commit:
feat(components): download assets into digest cache.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing tests:
def test_materialize_executable_sets_mode_and_replaces(tmp_path):
cache_path = tmp_path / "cache.bin"
managed_path = tmp_path / "bin" / "tool"
cache_path.write_bytes(b"#!/bin/sh\n")
materialize_executable(cache_path=cache_path, managed_path=managed_path)
assert managed_path.read_bytes() == cache_path.read_bytes()
assert oct(managed_path.stat().st_mode & 0o777) == oct(0o755)
def test_install_restores_prior_executable_when_smoke_fails(tmp_path, monkeypatch):
env = linux_env(tmp_path)
roots = resolve_roots(env=env, system="linux")
prior = Path(roots.data_root) / "brigade" / "bin" / "miseledger"
prior.parent.mkdir(parents=True)
prior.write_bytes(b"OLD")
prior.chmod(0o755)
monkeypatch.setattr(component_install, "run_post_install_smoke", _raise_smoke_failure)
with pytest.raises(ComponentInstallError):
_install_with_stub_assets(env=env, opener=_all_good_opener(env))
assert prior.read_bytes() == b"OLD"- Implement snapshot/restore around materialize loop.
- RED → GREEN.
- Commit:
feat(components): materialize managed executables safely.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing test:
import subprocess
def test_run_post_install_smoke_invokes_absolute_paths_only(tmp_path):
managed = {}
for name in ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"):
path = tmp_path / name
path.write_bytes(fixture_payload(name, platform="linux-amd64")[0])
path.chmod(0o755)
managed[name] = str(path)
calls: list[list[str]] = []
def runner(argv, **kwargs):
calls.append(list(argv))
return subprocess.run(argv, **kwargs)
run_post_install_smoke(managed, runner=runner)
assert {cmd[0] for cmd in calls} == set(managed.values())- Implement smoke runner:
subprocess.runwith absolute argv[0], JSON-RPC stdin for graphtrail-mcp, accept exit 0 for sessionfind --help. Injectrunnerrecords argv then delegates to realsubprocess.runso MCP JSON and sessionfind exit 0 are exercised. - RED → GREEN.
- Commit:
feat(components): add post-install smoke suite.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing tests:
def test_build_setup_plan_lists_all_four_components(tmp_path, monkeypatch):
manifest_path = tmp_path / "manifest-v1.json"
manifest = write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
roots = resolve_roots(env=linux_env(tmp_path), system="linux")
plan = build_setup_plan(manifest, platform="linux-amd64", roots=roots)
assert {action.component_id for action in plan} == set(component_manifest.KNOWN_COMPONENT_IDS)
def test_setup_dry_run_writes_nothing(tmp_path, monkeypatch, capsys):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
rc = setup_native_components(dry_run=True, env=env)
out = capsys.readouterr().out
assert rc == 0
assert "miseledger-linux-amd64" in out
assert "download" in out
assert not (Path(env["XDG_DATA_HOME"]) / "brigade" / "installed.json").exists()- Implement
build_setup_planand dry-run reporting incomponent_installonly; no CLI files in this task. - RED → GREEN on
tests/test_component_install.pyfocused tests. - Commit:
feat(components): add setup dry-run planning.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing tests:
def test_setup_install_writes_state_after_smoke(tmp_path, monkeypatch):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
payloads = all_fixture_payloads(platform="linux-amd64")
rc = setup_native_components(env=env, opener=FakeOpener(payloads))
assert rc == 0
state_path = Path(component_paths.installed_state_path(resolve_roots(env=env).data_root))
state = json.loads(state_path.read_text())
assert state["schema_version"] == 1
assert set(state["components"]) == set(component_manifest.KNOWN_COMPONENT_IDS)
def test_repeat_setup_is_idempotent_and_skips_previous_rotation(tmp_path, monkeypatch):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
payloads = all_fixture_payloads(platform="linux-amd64")
opener = FakeOpener(payloads)
assert setup_native_components(env=env, opener=opener) == 0
roots = resolve_roots(env=env)
previous_path = Path(component_paths.installed_previous_state_path(roots.data_root))
previous_before = previous_path.read_text() if previous_path.is_file() else None
assert setup_native_components(env=env, opener=opener) == 0
if previous_before is not None:
assert previous_path.read_text() == previous_before- Implement full orchestration: download all → materialize all → smoke → rotate/write state.
- RED → GREEN.
- Commit:
feat(components): install pinned native components.
Files: src/brigade/component_install.py, tests/test_component_install.py.
- Add failing tests:
def test_setup_rollback_restores_previous_manifest(tmp_path):
env = linux_env(tmp_path)
_seed_installed_pair(env, revision_a="2026-07-18", revision_b="2026-07-19")
rc = setup_native_components(rollback=True, env=env)
assert rc == 0
state = json.loads(Path(component_paths.installed_state_path(resolve_roots(env=env).data_root)).read_text())
assert state["manifest_revision"] == "2026-07-18"
def test_setup_rollback_fails_when_prior_cache_missing(tmp_path):
env = linux_env(tmp_path)
_seed_installed_pair(env, revision_a="2026-07-18", revision_b="2026-07-19", drop_prior_cache=True)
assert setup_native_components(rollback=True, env=env) == 1- Implement rollback swap and smoke on restored paths.
- RED → GREEN.
- Commit:
feat(components): add setup rollback.
Files: src/brigade/cli/setup.py, src/brigade/cli/__init__.py, src/brigade/cli/_common.py, tests/test_cli_setup.py, tests/test_cli_help.py.
- Create
src/brigade/cli/setup.pyand register parser:
def register(sub: argparse._SubParsersAction) -> None:
p_setup = sub.add_parser("setup", help="Install pinned native Brigade components.")
p_setup.add_argument("--dry-run", action="store_true", help="Report planned actions without writing.")
p_setup.add_argument("--offline", action="store_true", help="Use verified cache only; fail if missing.")
p_setup.add_argument("--rollback", action="store_true", help="Restore the previous installed manifest.")
p_setup.set_defaults(func=dispatch)- Add
setuptoCOMMAND_GROUPSand_setup_group.register(sub)after_add_group.register(sub). - Add failing CLI dispatch tests:
def test_cli_setup_dry_run_flag(tmp_path, monkeypatch):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert cli.main(["setup", "--dry-run"]) == 0
def test_cli_setup_offline_flag(tmp_path, monkeypatch):
env = linux_env(tmp_path)
manifest_path = tmp_path / "manifest-v1.json"
write_test_manifest(manifest_path, brigade_version=brigade.__version__)
monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert cli.main(["setup", "--offline"]) == 1
def test_cli_setup_rollback_flag(tmp_path, monkeypatch):
env = linux_env(tmp_path)
_seed_installed_pair(env, revision_a="2026-07-18", revision_b="2026-07-19")
for key, value in env.items():
monkeypatch.setenv(key, value)
assert cli.main(["setup", "--rollback"]) == 0- RED:
tests/test_cli_help.py::test_command_groups_cover_every_command_exactly_oncefails until registration complete. - GREEN after wiring.
- Commit:
feat(cli): register brigade setup command.
- Run full local gate:
./scripts/verify- Run final Brigade verify:
brigade work verify run --target . \
--command "./scripts/verify" \
--capture brigade-work- Capture outcome:
brigade outcome capture brigade-work --run-id latest --kind skill- Content guard: ensure
git pushpre-push hook passes (no leaked hostnames, tokens, or personal paths in new files). - Independent Opus review: dispatch
reviewskill on the branch diff before opening the PR. - Open PR with test plan checklist mirroring Tasks 0–9; confirm CI
content-guard,repo-metadata,install-from-source, andquickstart-smokejobs pass on the PR branch. - Memory handoff:
.claude/memory-handoffs/<date>-issue-355-pinned-setup.mdrecording manifest publication, rollback semantics, and smoke absolute-path requirement.
- Three scouting alternatives documented with recommendation.
- Sous-mode decisions carry evidence, stated-constraint, or judgment labels.
- GraphTrail v0.4.0 asset table matches release evidence byte-for-byte.
- Every behavior maps to a file, test name, RED/GREEN verify command, and commit message.
-
#356reporting and#357porting explicitly excluded. - No automatic PATH mutation; Cargo/Go fallbacks unchanged.
- Installed state uses
installed_atconsistently in schema, signatures, and tests. - Offline engine tests derive digests from executable
fixture_payloadstubs; no reverse SHA-256 synthesis. -
write_test_manifestpublishes the full five-platform matrix withowner/namesource.repositoryvalues. - Task 6 is engine-only dry-run planning; Task 9 owns all CLI wiring and flag dispatch tests.
- Tests require temp roots and stubbed network; no live GitHub or real home directory.
Run before committing this document (scan body only; exclude this section):
python -c "from pathlib import Path; import re, sys; text=Path('docs/phase-355-pinned-component-setup.md').read_text(); body=text.split('## Placeholder Scan', 1)[0]; sys.exit(1 if re.search(r'TODO|TBD|FIXME|XXX', body) else 0)"