diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index fc29f57e6..41dcc0f15 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -320,9 +320,9 @@ Use `--jsonl` when another process needs progress events as they happen. The con | `step_subprocess_failed` | A setup subprocess exited non-zero. | | `step_subprocess_timeout` | A setup subprocess exceeded its timeout. | -Step names are fixed and ordered: `doctor`, `journal`, `install_models`, `skills_user`, `skills_journal`, `wrapper`, `service`. +Step names are fixed and ordered: `doctor`, `journal`, `install_models`, `skills_user`, `skills_journal`, `wrapper`, `service`, `brain`. -Skipped or resumed reasons are fixed: `--skip-models`, `--skip-skills`, `--skip-service`, `prior_run_ok`, `resumed_after_restart`. +Skipped, warning, or resumed reasons are fixed: `--skip-models`, `--skip-brain`, `--skip-models implies --skip-brain`, `--skip-skills`, `--skip-service`, `a provider is already configured`, `local provider unavailable on this host`, `local bootstrap did not start`, `prior_run_ok`, `resumed_after_restart`. The `wrapper` setup step provisions both managed wrappers in-process for source and packaged installs. It backs up a non-owned alias under `/tmp` before @@ -337,7 +337,7 @@ Example stream excerpt for setup readiness: ```jsonl {"event":"setup.started","ts":"2026-05-11T20:00:00Z","version":"0.0.0+source","mode":"non_interactive"} -{"event":"step.started","ts":"2026-05-11T20:00:00Z","step":"doctor","index":1,"total":7} +{"event":"step.started","ts":"2026-05-11T20:00:00Z","step":"doctor","index":1,"total":8} {"event":"doctor.started","ts":"2026-05-11T20:00:00Z","version":"0.0.0+source","port":5015,"feature":""} {"event":"check.completed","ts":"2026-05-11T20:00:01Z","name":"python_version","severity":"blocker","status":"ok","detail":"Python version ok","fix":""} {"event":"doctor.completed","ts":"2026-05-11T20:00:01Z","status":"ok","duration_ms":120,"summary":{"total":10,"failed":0,"warnings":0,"skipped":0}} diff --git a/solstone/think/setup.py b/solstone/think/setup.py index 8ad1005f4..8d1fbf961 100644 --- a/solstone/think/setup.py +++ b/solstone/think/setup.py @@ -35,7 +35,6 @@ from solstone.think.user_config import ( from solstone.think.utils import ensure_journal_config, get_project_root from solstone.think.utils import is_source_checkout as source_checkout -TOTAL_STEPS = 7 MANIFEST_SCHEMA_VERSION = 1 # doctor is examine-only and must complete near-instantly; 30s is a generous # backstop, not a work budget. If doctor approaches it, the cause is a layer @@ -80,6 +79,7 @@ class SetupContext: variant_source: str yes: bool skip_models: bool + skip_brain: bool skip_skills: bool skip_service: bool accept_existing_journal: bool @@ -228,6 +228,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="skip local model installation", ) + parser.add_argument( + "--skip-brain", + action="store_true", + help="skip local brain bootstrap", + ) parser.add_argument( "--skip-skills", action="store_true", @@ -288,6 +293,13 @@ def resolve_context( port_supplied = arg_supplied(raw_argv, "--port") step_timeout_supplied = arg_supplied(raw_argv, "--step-timeout-seconds") variant_supplied = arg_supplied(raw_argv, "--variant") + skip_brain = bool(args.skip_brain or args.skip_models) + if args.skip_brain: + skip_brain_source = "cli:--skip-brain" + elif args.skip_models: + skip_brain_source = "cli:--skip-models" + else: + skip_brain_source = "default" args_resolved: dict[str, object] = { "journal": { @@ -327,6 +339,10 @@ def resolve_context( "value": bool(args.skip_models), "source": "cli" if args.skip_models else "default", }, + "skip_brain": { + "value": skip_brain, + "source": skip_brain_source, + }, "skip_skills": { "value": bool(args.skip_skills), "source": "cli" if args.skip_skills else "default", @@ -365,6 +381,7 @@ def resolve_context( variant_source="cli" if variant_supplied else "default", yes=bool(args.yes), skip_models=bool(args.skip_models), + skip_brain=skip_brain, skip_skills=bool(args.skip_skills), skip_service=bool(args.skip_service), accept_existing_journal=bool(args.accept_existing_journal), @@ -791,6 +808,26 @@ def skills_journal_command(ctx: SetupContext) -> list[str]: ] +def brain_bootstrap_command() -> list[str]: + return [ + sys.executable, + "-m", + "solstone.think.sol_cli", + "call", + "thinking", + "local", + "bootstrap", + ] + + +def brain_skip_reason(ctx: SetupContext) -> str: + resolved = ctx.args_resolved.get("skip_brain") + source = resolved.get("source") if isinstance(resolved, dict) else None + if source == "cli:--skip-models": + return "--skip-models implies --skip-brain" + return "--skip-brain" + + def service_install_command(ctx: SetupContext) -> list[str]: return [ *journal_console_command(), @@ -1343,6 +1380,82 @@ def step_service(ctx: SetupContext, step_index: int) -> StepResult: return step_result("service", "ok", paths, started_at) +def step_brain(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + from solstone.think.journal_config import read_journal_config, write_journal_config + + cfg = read_journal_config(ctx.journal_path) + providers_raw = cfg.get("providers", {}) + providers = providers_raw if isinstance(providers_raw, dict) else {} + generate = providers.get("generate", {}) + cogitate = providers.get("cogitate", {}) + typed_configs = ( + generate if isinstance(generate, dict) else {}, + cogitate if isinstance(cogitate, dict) else {}, + ) + owner_chose_other = any( + type_config.get("provider") is not None + and type_config.get("provider") != "local" + for type_config in typed_configs + ) + if owner_chose_other: + reason = "a provider is already configured" + print_step_skipped(ctx, step_index, "brain", reason) + return step_result("brain", "skipped", [], started_at, reason=reason) + + changed = "providers" not in cfg or providers_raw is not providers + if changed: + cfg["providers"] = providers + for agent_type in ("generate", "cogitate"): + type_config = providers.get(agent_type) + if not isinstance(type_config, dict): + type_config = {} + providers[agent_type] = type_config + changed = True + if type_config.get("provider") != "local": + type_config["provider"] = "local" + changed = True + if changed: + write_journal_config(cfg, ctx.journal_path) + + if ctx.skip_brain: + reason = brain_skip_reason(ctx) + print_step_skipped(ctx, step_index, "brain", reason) + return step_result("brain", "skipped", [], started_at, reason=reason) + + if ctx.skip_service: + reason = "--skip-service" + print_step_skipped(ctx, step_index, "brain", reason) + return step_result("brain", "skipped", [], started_at, reason=reason) + + from solstone.think.check import build_check_report + + verdict = build_check_report() + if verdict.report.overall == "blocked": + reason = "local provider unavailable on this host" + print_step_skipped(ctx, step_index, "brain", reason) + return step_result("brain", "skipped", [], started_at, reason=reason) + + command = brain_bootstrap_command() + print_step_header(ctx, step_index, "brain", command) + result = run_step_subprocess(ctx, command, timeout=ctx.step_timeout_seconds) + if result.returncode == 0 and not result.timed_out: + return step_result("brain", "ok", [], started_at) + + from solstone.think.providers import local_install + + error = subprocess_error("brain", result, timeout=ctx.step_timeout_seconds) + error["fix_hint"] = local_install.install_hint() + return step_result( + "brain", + "warning", + [], + started_at, + error, + reason="local bootstrap did not start", + ) + + CLEAN_UNINSTALL_TOTAL_STEPS = 4 CLEAN_UNINSTALL_STEP_NAMES = ("service", "wrapper", "config", "manifest") @@ -1633,49 +1746,64 @@ def dead_end_journal_is_file(ctx: SetupContext) -> None: ) -def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: - heading = "setup dry-run" if dry_run else "setup plan" - narrate(ctx, f"{heading}:") - narrate(ctx, f" mode: {ctx.mode.value}") - narrate(ctx, f" journal: {ctx.journal_path} ({ctx.journal_source})") - narrate(ctx, f" port: {ctx.port} ({ctx.port_source})") - narrate(ctx, f" variant: {ctx.variant} ({ctx.variant_source})") - timeout_resolved = ctx.args_resolved["step_timeout_seconds"] - timeout_source = timeout_resolved["source"] - narrate( - ctx, f" step_timeout_seconds: {ctx.step_timeout_seconds} ({timeout_source})" - ) - narrate(ctx, f" source checkout: {ctx.is_source_checkout}") - narrate(ctx) - narrate(ctx, f"[step 1/7] {_STEP_NAME[step_doctor]}") +def _print_plan_step_header( + ctx: SetupContext, + step_index: int, + step: Callable[[SetupContext, int], StepResult], +) -> None: + suffix = _plan_step_suffix(ctx, step) + narrate(ctx, f"[step {step_index}/{TOTAL_STEPS}] {_STEP_NAME[step]}{suffix}") + + +def _plan_step_suffix( + ctx: SetupContext, + step: Callable[[SetupContext, int], StepResult], +) -> str: + if step is step_skills_user: + return " - installs the sol skill for claude / codex / gemini" + if step is step_skills_journal: + return ( + " - installs talent skills into " + f"{ctx.journal_path}/.{{claude,agents}}/skills" + ) + return "" + + +def _print_doctor_plan(ctx: SetupContext) -> None: narrate(ctx, f" would run: {format_command(doctor_command(ctx))}") - narrate(ctx, f"[step 2/7] {_STEP_NAME[step_journal]}") + + +def _print_journal_plan(ctx: SetupContext) -> None: narrate(ctx, f" would write: {ctx.config_path}") narrate(ctx, f" would use journal: {ctx.journal_path}") - narrate(ctx, f"[step 3/7] {_STEP_NAME[step_install_models]}") + + +def _print_install_models_plan(ctx: SetupContext) -> None: if ctx.skip_models: narrate(ctx, " skipped: --skip-models") else: narrate(ctx, f" would run: {format_command(install_models_command(ctx))}") - narrate( - ctx, - f"[step 4/7] {_STEP_NAME[step_skills_user]} - installs the sol skill for claude / codex / gemini", - ) + + +def _print_skills_user_plan(ctx: SetupContext) -> None: if ctx.skip_skills: narrate(ctx, " skipped: --skip-skills") else: narrate(ctx, f" would run: {format_command(skills_user_command())}") - narrate( - ctx, - f"[step 5/7] {_STEP_NAME[step_skills_journal]} - installs talent skills into {ctx.journal_path}/.{{claude,agents}}/skills", - ) + + +def _print_skills_journal_plan(ctx: SetupContext) -> None: if ctx.skip_skills: narrate(ctx, " skipped: --skip-skills") else: narrate(ctx, f" would run: {format_command(skills_journal_command(ctx))}") - narrate(ctx, f"[step 6/7] {_STEP_NAME[step_wrapper]}") + + +def _print_wrapper_plan(ctx: SetupContext) -> None: narrate(ctx, " would provision managed sol and journal wrappers in-process") - narrate(ctx, f"[step 7/7] {_STEP_NAME[step_service]}") + + +def _print_service_plan(ctx: SetupContext) -> None: if ctx.skip_service: narrate(ctx, " skipped: --skip-service") else: @@ -1683,6 +1811,41 @@ def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: narrate(ctx, " would call: solstone.think.service._up()") +def _print_brain_plan(ctx: SetupContext) -> None: + narrate(ctx, " would set local provider lane if unset") + if ctx.skip_brain: + narrate(ctx, f" skipped: {brain_skip_reason(ctx)}") + return + if ctx.skip_service: + narrate(ctx, " skipped: --skip-service") + return + narrate(ctx, " would check local provider fit") + narrate(ctx, f" would run: {format_command(brain_bootstrap_command())}") + + +def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: + heading = "setup dry-run" if dry_run else "setup plan" + narrate(ctx, f"{heading}:") + narrate(ctx, f" mode: {ctx.mode.value}") + narrate(ctx, f" journal: {ctx.journal_path} ({ctx.journal_source})") + narrate(ctx, f" port: {ctx.port} ({ctx.port_source})") + narrate(ctx, f" variant: {ctx.variant} ({ctx.variant_source})") + timeout_resolved = ctx.args_resolved["step_timeout_seconds"] + timeout_source = timeout_resolved["source"] + narrate( + ctx, f" step_timeout_seconds: {ctx.step_timeout_seconds} ({timeout_source})" + ) + narrate(ctx, f" source checkout: {ctx.is_source_checkout}") + narrate(ctx) + for step_index, step in enumerate(_STEPS, start=1): + renderer = _PLAN_BODY.get(step) + if renderer is None: + name = _STEP_NAME.get(step, getattr(step, "__name__", repr(step))) + raise RuntimeError(f"missing setup plan body for {name}") + _print_plan_step_header(ctx, step_index, step) + renderer(ctx) + + def print_failure(ctx: SetupContext, result: StepResult) -> None: error = result.error or {} message = error.get("message", "step failed") @@ -1829,6 +1992,7 @@ _STEP_NAME: dict[Callable[[SetupContext, int], StepResult], str] = { step_skills_journal: "skills_journal", step_wrapper: "wrapper", step_service: "service", + step_brain: "brain", } _STEPS: tuple[Callable[[SetupContext, int], StepResult], ...] = ( @@ -1839,9 +2003,27 @@ _STEPS: tuple[Callable[[SetupContext, int], StepResult], ...] = ( step_skills_journal, step_wrapper, step_service, + step_brain, ) +TOTAL_STEPS = len(_STEPS) + + +_PLAN_BODY: dict[ + Callable[[SetupContext, int], StepResult], Callable[[SetupContext], None] +] = { + step_doctor: _print_doctor_plan, + step_journal: _print_journal_plan, + step_install_models: _print_install_models_plan, + step_skills_user: _print_skills_user_plan, + step_skills_journal: _print_skills_journal_plan, + step_wrapper: _print_wrapper_plan, + step_service: _print_service_plan, + step_brain: _print_brain_plan, +} + + _CONTINUE_AFTER_FAILURE: frozenset[str] = frozenset({"skills_user", "skills_journal"}) @@ -1858,6 +2040,8 @@ def command_for_step( return skills_journal_command(ctx) if step is step_service: return service_install_command(ctx) + if step is step_brain: + return brain_bootstrap_command() return None @@ -2022,6 +2206,8 @@ def main(argv: list[str] | None = None) -> int: incompatible.append("--explain") if args.skip_models: incompatible.append("--skip-models") + if args.skip_brain: + incompatible.append("--skip-brain") if args.skip_skills: incompatible.append("--skip-skills") if args.skip_service: diff --git a/solstone/think/setup_events.py b/solstone/think/setup_events.py index 75992bf9f..d5abe3b6c 100644 --- a/solstone/think/setup_events.py +++ b/solstone/think/setup_events.py @@ -45,13 +45,19 @@ STEP_NAMES: tuple[str, ...] = ( "skills_journal", "wrapper", "service", + "brain", ) SKIPPED_REASONS: frozenset[str] = frozenset( { "--skip-models", + "--skip-brain", + "--skip-models implies --skip-brain", "--skip-skills", "--skip-service", + "a provider is already configured", + "local provider unavailable on this host", + "local bootstrap did not start", "prior_run_ok", "resumed_after_restart", } diff --git a/tests/test_setup.py b/tests/test_setup.py index 9cef6c538..a16874928 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -7,7 +7,9 @@ import hashlib import json import subprocess import sys +import time from pathlib import Path +from types import SimpleNamespace from typing import Any from unittest.mock import Mock @@ -17,6 +19,10 @@ from solstone.think import health_cli, install_guard, service, setup from solstone.think.user_config import write_user_config +def fake_check_report(overall: str = "ok") -> SimpleNamespace: + return SimpleNamespace(report=SimpleNamespace(overall=overall)) + + @pytest.fixture(autouse=True) def no_user_path_mutation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( @@ -26,6 +32,13 @@ def no_user_path_mutation(monkeypatch: pytest.MonkeyPatch) -> None: ) +@pytest.fixture(autouse=True) +def fast_brain_check(monkeypatch: pytest.MonkeyPatch) -> None: + from solstone.think import check + + monkeypatch.setattr(check, "build_check_report", lambda: fake_check_report()) + + def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: home = tmp_path / "home" home.mkdir() @@ -175,6 +188,7 @@ STEP_NAMES = [ "skills_journal", "wrapper", "service", + "brain", ] @@ -238,6 +252,18 @@ def expected_service_restart_command() -> list[str]: return [str(Path(sys.executable).parent / "journal"), "service", "restart"] +def expected_brain_bootstrap_command() -> list[str]: + return [ + sys.executable, + "-m", + "solstone.think.sol_cli", + "call", + "thinking", + "local", + "bootstrap", + ] + + def assert_command( calls: list[list[str]], position: int, expected_argv: list[str] ) -> None: @@ -262,6 +288,23 @@ def read_manifest(journal: Path) -> dict[str, Any]: ) +def read_journal_config_file(journal: Path) -> dict[str, Any]: + return json.loads((journal / "config" / "journal.json").read_text(encoding="utf-8")) + + +def provider_lanes(journal: Path) -> tuple[str | None, str | None]: + config = read_journal_config_file(journal) + providers = config.get("providers", {}) + return ( + providers.get("generate", {}).get("provider"), + providers.get("cogitate", {}).get("provider"), + ) + + +def step_by_name(manifest: dict[str, Any], name: str) -> dict[str, Any]: + return next(step for step in manifest["steps"] if step["name"] == name) + + def touch_file(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.touch() @@ -379,6 +422,7 @@ def prior_artifact_paths(journal: Path) -> dict[str, list[Path]]: ], "wrapper": list(install_guard.alias_paths().values()), "service": [service_path] if service_path is not None else [], + "brain": [], } @@ -442,14 +486,17 @@ def test_interactive_happy_path_default_journal( encoding="utf-8" ) == f'journal = "{journal}"\n' manifest = read_manifest(journal) - assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok"]) + assert_step_names_and_statuses( + manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] + ) assert "solstone is running at http://localhost:5015" in capsys.readouterr().out assert_command(calls, 0, expected_doctor_command()) assert_command(calls, 1, expected_install_models_command()) assert_command(calls, 2, expected_skills_user_command()) assert_command(calls, 3, expected_skills_journal_command(journal)) assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 def test_resolve_journal_path_precedence_chain( @@ -509,7 +556,8 @@ def test_interactive_happy_path_journal_override( ) == f'journal = "{journal}"\n' assert read_manifest(journal)["args_resolved"]["journal"]["source"] == "cli" assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 def test_non_interactive_happy_path( @@ -529,9 +577,306 @@ def test_non_interactive_happy_path( assert rc == 0 manifest = read_manifest(journal) assert manifest["completed_at"] is not None - assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok"]) + assert_step_names_and_statuses( + manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] + ) assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 + + +def test_brain_capable_sets_lane_and_triggers_bootstrap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + started = time.monotonic() + rc = setup.main(["--yes", "--journal", str(journal)]) + elapsed = time.monotonic() - started + + assert rc == 0 + assert elapsed < 1 + assert provider_lanes(journal) == ("local", "local") + manifest = read_manifest(journal) + assert step_by_name(manifest, "brain")["status"] == "ok" + assert_command(calls, 5, expected_brain_bootstrap_command()) + + +def test_brain_skip_brain_sets_lane_and_skips( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solstone.think import check + + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + monkeypatch.setattr( + check, + "build_check_report", + lambda: pytest.fail("build_check_report should not run"), + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal), "--skip-brain"]) + + assert rc == 0 + assert provider_lanes(journal) == ("local", "local") + brain_step = step_by_name(read_manifest(journal), "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "--skip-brain" + assert expected_brain_bootstrap_command() not in calls + + +def test_brain_skip_models_derives_skip_brain( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solstone.think import check + + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + monkeypatch.setattr( + check, + "build_check_report", + lambda: pytest.fail("build_check_report should not run"), + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) + + assert rc == 0 + manifest = read_manifest(journal) + assert manifest["args_resolved"]["skip_brain"] == { + "value": True, + "source": "cli:--skip-models", + } + brain_step = step_by_name(manifest, "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "--skip-models implies --skip-brain" + assert provider_lanes(journal) == ("local", "local") + assert expected_brain_bootstrap_command() not in calls + + +def test_brain_skip_service_sets_lane_and_skips( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solstone.think import check + + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + monkeypatch.setattr( + check, + "build_check_report", + lambda: pytest.fail("build_check_report should not run"), + ) + calls = patch_subprocess(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal), "--skip-service"]) + + assert rc == 0 + assert provider_lanes(journal) == ("local", "local") + brain_step = step_by_name(read_manifest(journal), "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "--skip-service" + assert expected_service_install_command() not in calls + assert expected_brain_bootstrap_command() not in calls + + +def test_brain_blocked_verdict_skips_without_trigger( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solstone.think import check + + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + monkeypatch.setattr( + check, "build_check_report", lambda: fake_check_report("blocked") + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + manifest = read_manifest(journal) + assert manifest["completed_at"] is not None + assert provider_lanes(journal) == ("local", "local") + brain_step = step_by_name(manifest, "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "local provider unavailable on this host" + assert expected_brain_bootstrap_command() not in calls + + +def test_brain_trigger_nonzero_warns_but_setup_succeeds( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + commands: list[list[str]] = [] + + def fake_run_step_subprocess( + ctx: setup.SetupContext, + command: list[str], + *, + timeout: float | None = None, + ) -> setup.StepProcessResult: + del ctx, timeout + commands.append(command) + if command == expected_brain_bootstrap_command(): + return setup.StepProcessResult(9, "", "bootstrap unavailable\n", False) + return setup.StepProcessResult(0, "", "", False) + + monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + manifest = read_manifest(journal) + assert manifest["completed_at"] is not None + brain_step = step_by_name(manifest, "brain") + assert brain_step["status"] == "warning" + assert brain_step["reason"] == "local bootstrap did not start" + assert brain_step["error"]["message"] == "bootstrap unavailable" + assert brain_step["error"]["fix_hint"] == "journal install-provider local" + assert expected_brain_bootstrap_command() in commands + + +def test_brain_preserves_existing_provider_lane( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + journal = tmp_path / "journal" + config_path = journal / "config" / "journal.json" + config_path.parent.mkdir(parents=True) + original_config = { + "providers": { + "generate": {"provider": "anthropic"}, + "cogitate": {"provider": "anthropic", "tier": 2}, + } + } + config_path.write_text(json.dumps(original_config), encoding="utf-8") + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + + rc = setup.main(["--yes", "--journal", str(journal), "--accept-existing-journal"]) + + assert rc == 0 + assert read_journal_config_file(journal) == original_config + brain_step = step_by_name(read_manifest(journal), "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "a provider is already configured" + assert expected_brain_bootstrap_command() not in calls + + +def test_brain_dry_run_writes_nothing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + journal = tmp_path / "journal" + + rc = setup.main(["--dry-run", "--journal", str(journal)]) + + assert rc == 0 + assert not (journal / "config" / "journal.json").exists() + + +def test_brain_never_calls_direct_installers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solstone.think import install_provider + from solstone.think.providers import local_install + + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + monkeypatch.setattr( + local_install, + "install_local", + lambda *args, **kwargs: pytest.fail("install_local should not run"), + ) + monkeypatch.setattr( + install_provider, + "main", + lambda *args, **kwargs: pytest.fail("install_provider.main should not run"), + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + assert expected_brain_bootstrap_command() in calls + + +def test_brain_rerun_after_defer_triggers_bootstrap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + first_calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + first_rc = setup.main(["--yes", "--journal", str(journal), "--skip-brain"]) + + assert first_rc == 0 + first_manifest = read_manifest(journal) + first_brain = step_by_name(first_manifest, "brain") + assert first_brain["status"] == "skipped" + assert first_brain["reason"] == "--skip-brain" + assert provider_lanes(journal) == ("local", "local") + assert expected_brain_bootstrap_command() not in first_calls + + second_calls = patch_subprocess(monkeypatch) + second_rc = setup.main(["--yes", "--journal", str(journal)]) + + assert second_rc == 0 + assert provider_lanes(journal) == ("local", "local") + second_brain = step_by_name(read_manifest(journal), "brain") + assert second_brain["status"] == "ok" + assert expected_brain_bootstrap_command() in second_calls @pytest.mark.parametrize("use_journal_flag", [False, True]) @@ -663,6 +1008,33 @@ def test_explain_early_exit( assert "setup plan:" in capsys.readouterr().out +def test_print_plan_step_count_matches_steps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + + rc = setup.main(["--explain"]) + + assert rc == 0 + assert len(setup._STEPS) == setup.TOTAL_STEPS + assert set(setup._STEP_NAME) == set(setup._STEPS) + out = capsys.readouterr().out + headers = [line for line in out.splitlines() if line.startswith("[step ")] + assert len(headers) == setup.TOTAL_STEPS + assert headers == [ + f"[step {index}/{setup.TOTAL_STEPS}] {setup._STEP_NAME[step]}" + + setup._plan_step_suffix( + setup.resolve_context(setup.build_parser().parse_args(["--explain"]), []), + step, + ) + for index, step in enumerate(setup._STEPS, start=1) + ] + + def test_manifest_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: home = patch_home(monkeypatch, tmp_path) patch_source_checkout(monkeypatch, tmp_path) @@ -971,14 +1343,15 @@ def test_partial_completion_runs_remaining_steps( assert rc == 0 manifest = read_manifest(journal) assert_step_names_and_statuses( - manifest, ["skipped", "ok", "ok", "ok", "ok", "ok", "ok"] + manifest, ["skipped", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] ) assert manifest["steps"][0]["reason"] == "prior_run_ok" assert_command(calls, 0, expected_install_models_command()) assert_command(calls, 1, expected_skills_user_command()) assert_command(calls, 2, expected_skills_journal_command(journal)) assert_command(calls, 3, expected_service_install_command()) - assert len(calls) == 4 + assert_command(calls, 4, expected_brain_bootstrap_command()) + assert len(calls) == 5 def test_non_interactive_setup_has_no_port_preflight_dead_end( @@ -1014,7 +1387,8 @@ def test_non_interactive_setup_has_no_port_preflight_dead_end( assert "port_in_use_non_interactive" not in captured.out assert_command(calls, 0, expected_doctor_command()) assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 def test_doctor_timeout_records_failure( @@ -1164,6 +1538,7 @@ def test_clean_uninstall_rejects_jsonl_with_specific_message( ["--dry-run"], ["--explain"], ["--skip-models"], + ["--skip-brain"], ["--skip-skills"], ["--skip-service"], ["--accept-existing-journal"], @@ -1553,12 +1928,14 @@ def test_packaged_install_runs_service_step( assert_command(calls, 1, expected_service_install_command()) assert len(calls) == 2 steps = read_manifest(journal)["steps"] - assert steps[-2]["status"] == "ok" + service_step = next(step for step in steps if step["name"] == "service") + assert service_step["status"] == "ok" for binary, alias in install_guard.alias_paths().items(): assert alias == home / ".local" / "bin" / binary assert_setup_wrapper(alias, binary, journal, bin_dir) - assert steps[-1]["name"] == "service" - assert steps[-1]["status"] == "ok" + brain_step = next(step for step in steps if step["name"] == "brain") + assert brain_step["status"] == "skipped" + assert brain_step["reason"] == "--skip-models implies --skip-brain" def test_packaged_install_absent_provisions_wrappers( @@ -1669,7 +2046,7 @@ def test_wrapper_provisioning_failure_is_non_fatal_warning( captured = capsys.readouterr() manifest = read_manifest(journal) - wrapper_step = manifest["steps"][-2] + wrapper_step = step_by_name(manifest, "wrapper") assert rc == 0 assert wrapper_step["status"] == "warning" assert "~/.local/bin" in wrapper_step["error"]["fix_hint"] @@ -1842,7 +2219,7 @@ def test_step_skills_user_failure_does_not_block_skills_journal( assert expected_skills_journal_command(journal) in commands manifest = read_manifest(journal) assert_step_names_and_statuses( - manifest, ["ok", "ok", "skipped", "failed", "ok", "ok", "ok"] + manifest, ["ok", "ok", "skipped", "failed", "ok", "ok", "ok", "skipped"] ) @@ -1878,7 +2255,7 @@ def test_step_skills_journal_failure_does_not_block_subsequent_steps( assert commands[-1] == expected_service_install_command() manifest = read_manifest(journal) assert_step_names_and_statuses( - manifest, ["ok", "ok", "skipped", "ok", "failed", "ok", "ok"] + manifest, ["ok", "ok", "skipped", "ok", "failed", "ok", "ok", "skipped"] ) @@ -1941,7 +2318,7 @@ def test_resumption_skips_completed_steps( assert rc == 0 assert calls == [] manifest = read_manifest(journal) - assert_step_names_and_statuses(manifest, ["skipped"] * 7) + assert_step_names_and_statuses(manifest, ["skipped"] * 8) assert {step["reason"] for step in manifest["steps"]} == {"prior_run_ok"} @@ -1972,7 +2349,16 @@ def test_resumption_runs_step_when_artifact_missing( manifest = read_manifest(journal) assert_step_names_and_statuses( manifest, - ["skipped", "skipped", "skipped", "skipped", "skipped", "ok", "skipped"], + [ + "skipped", + "skipped", + "skipped", + "skipped", + "skipped", + "ok", + "skipped", + "skipped", + ], ) @@ -1995,7 +2381,7 @@ def test_resumption_wedged_service_restarts( assert health_check.call_count == 2 assert_command(calls, 0, expected_service_restart_command()) assert len(calls) == 1 - service_step = read_manifest(journal)["steps"][-1] + service_step = step_by_name(read_manifest(journal), "service") assert service_step["status"] == "ok" assert service_step["reason"] == "resumed_after_restart" @@ -2070,9 +2456,12 @@ def test_force_skips_resumption( assert_command(calls, 2, expected_skills_user_command()) assert_command(calls, 3, expected_skills_journal_command(journal)) assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 manifest = read_manifest(journal) - assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok"]) + assert_step_names_and_statuses( + manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] + ) assert all(step["reason"] is None for step in manifest["steps"]) @@ -2220,7 +2609,8 @@ def test_invalid_manifest_treated_as_no_prior( assert_command(calls, 2, expected_skills_user_command()) assert_command(calls, 3, expected_skills_journal_command(journal)) assert_command(calls, 4, expected_service_install_command()) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 def test_port_propagates_to_subprocess_argv( @@ -2240,4 +2630,5 @@ def test_port_propagates_to_subprocess_argv( assert rc == 0 assert_command(calls, 0, expected_doctor_command(port=8080)) assert_command(calls, 4, expected_service_install_command(port=8080)) - assert len(calls) == 5 + assert_command(calls, 5, expected_brain_bootstrap_command()) + assert len(calls) == 6 diff --git a/tests/test_setup_jsonl.py b/tests/test_setup_jsonl.py index 9a07dd5af..5c1c6caf3 100644 --- a/tests/test_setup_jsonl.py +++ b/tests/test_setup_jsonl.py @@ -230,7 +230,7 @@ def test_setup_jsonl_step_events_paired( ] assert [event["step"] for event in started] == list(STEP_NAMES) assert [event["step"] for event in terminal] == list(STEP_NAMES) - assert {event["total"] for event in started} == {7} + assert {event["total"] for event in started} == {setup.TOTAL_STEPS} def test_setup_jsonl_forwards_doctor_events_byte_for_byte(