diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08d9ae..da93a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,9 @@ jobs: - name: Check gs launches what gs sync updates run: bash tests/gs_test.sh + - name: Check Pi model profiles sync + run: python3 tests/sync_pi_model_profiles_test.py + - name: Check Nix Home Manager preflight contracts run: bash scripts/test-nix-preflight.sh diff --git a/README.md b/README.md index 770fd9a..1bdd008 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,15 @@ find ~ -maxdepth 4 -name '*.bak_[0-9]*' -print Si un destino es un directorio real (no symlink), el instalador aborta antes de tocar nada: movelo o borralo y volvé a correr `install.sh`. +## Routing de modelos de Pi (gentle-pi profiles) + +`pi/gentle-ai/profiles.json` versiona los profiles de modelos de gentle-pi (`/gentle:profiles`): qué modelo usa el orquestador y cada subagente. `./install.sh` corre `scripts/sync-pi-model-profiles.py`, que: + +- mezcla esos profiles en `~/.pi/gentle-ai/profiles.json` y `~/.gentle-shell/gentle-ai/profiles.json`. Los profiles del repo reemplazan a los locales con el mismo nombre; los profiles que sólo existen en local se conservan, igual que `current`; +- materializa el profile activo en `agent/settings.json` (orquestador), `agent/subagents.json` y el frontmatter de `agent/agents/*.md`. + +Cada archivo que cambia queda con backup `*.bak-profiles-`. Correrlo dos veces no cambia nada. Para editar el routing, modificá el JSON del repo y corré `python3 scripts/sync-pi-model-profiles.py` (`--dry-run` muestra qué cambiaría). + ## Perfiles visuales opt-in `./install.sh` instala estos assets mediante symlinks individuales. No cambia el theme activo de Pi, Herdr o Ghostty; tampoco modifica Starship. diff --git a/install.sh b/install.sh index f875768..7af96ad 100755 --- a/install.sh +++ b/install.sh @@ -583,8 +583,14 @@ if command -v python3 &>/dev/null; then [[ "$DRY_RUN" == true ]] && pi_appearance_args+=(--dry-run) python3 -I "$DOTFILES/scripts/pi_appearance.py" "${pi_appearance_args[@]}" \ || echo " ! Pi appearance not merged; fix ~/.pi/agent/settings.json and rerun" + # gentle-pi model routing: merge pi/gentle-ai/profiles.json into ~/.pi and + # ~/.gentle-shell and materialize the active profile. + pi_profiles_args=() + [[ "$DRY_RUN" == true ]] && pi_profiles_args+=(--dry-run) + python3 -I "$DOTFILES/scripts/sync-pi-model-profiles.py" "${pi_profiles_args[@]}" \ + || echo " ! Pi model profiles not synced; fix the gentle-ai profile files and rerun" else - echo " ! python3 unavailable; skipped Pi appearance merge" + echo " ! python3 unavailable; skipped Pi appearance merge and model profiles" fi run_or_plan "chmod +x $DOTFILES/claude/statusline.sh" chmod +x "$DOTFILES/claude/statusline.sh" run_or_plan "chmod +x agent-state CLI" chmod +x "$DOTFILES/zsh/scripts/agent-state.sh" diff --git a/pi/gentle-ai/profiles.json b/pi/gentle-ai/profiles.json new file mode 100644 index 0000000..0d1ec7d --- /dev/null +++ b/pi/gentle-ai/profiles.json @@ -0,0 +1,553 @@ +{ + "kind": "gentle-pi.agent_model_profiles", + "version": 1, + "active": "openai", + "profiles": { + "openai": { + "orchestrator": { + "model": "openai/gpt-6.1-sol", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "gentle-ai-explore": { + "model": "openai/gpt-6-luna", + "thinking": "high" + }, + "review-readability": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "gentle-ai-verify": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-validator": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "review-risk": { + "model": "openai/gpt-6-astra", + "thinking": "high" + }, + "review-reliability": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-resilience": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-refuter": { + "model": "openai/gpt-6.1-sol", + "thinking": "xhigh" + }, + "jd-judge-a": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "jd-judge-b": { + "model": "openai/gpt-6-luna", + "thinking": "max" + }, + "cortex-adversarial-tester": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "openai/gpt-6-luna", + "thinking": "low" + } + }, + "nan": { + "orchestrator": { + "model": "nan/glm5.3", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-risk": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-refuter": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-validator": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-reliability": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-resilience": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "review-readability": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "jd-judge-a": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "jd-judge-b": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "gentle-ai-verify": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "gentle-ai-explore": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "cortex-adversarial-tester": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "nan/glm5.3", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "nan/deepseek-v4-flash", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "nan/mimo-v2.6-flash", + "thinking": "low" + } + }, + "openai-nan": { + "orchestrator": { + "model": "openai/gpt-6.1-sol", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "gentle-ai-explore": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "gentle-ai-verify": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "review-readability": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "review-risk": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-reliability": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-resilience": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "review-refuter": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-validator": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "jd-judge-a": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "jd-judge-b": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-adversarial-tester": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "nan/deepseek-v4-flash", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "nan/mimo-v2.6-flash", + "thinking": "low" + } + }, + "claude": { + "orchestrator": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "review-risk": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "jd-judge-a": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "gentle-ai-verify": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "medium" + }, + "review-validator": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "medium" + }, + "review-refuter": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "xhigh" + }, + "gentle-ai-explore": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "high" + }, + "review-readability": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "review-reliability": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "high" + }, + "review-resilience": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "high" + }, + "jd-judge-b": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "high" + }, + "cortex-adversarial-tester": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "low" + } + }, + "claude-nan": { + "orchestrator": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "review-readability": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "gentle-ai-explore": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "review-refuter": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "gentle-ai-verify": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "review-resilience": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "jd-judge-b": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-risk": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-validator": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "jd-judge-a": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-reliability": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-adversarial-tester": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "nan/deepseek-v4-flash", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "nan/mimo-v2.6-flash", + "thinking": "low" + } + }, + "claude-openai": { + "orchestrator": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "gentle-ai-explore": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "review-readability": { + "model": "openai/gpt-6-luna", + "thinking": "medium" + }, + "review-risk": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "jd-judge-a": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "gentle-ai-verify": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-reliability": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-resilience": { + "model": "openai/gpt-6.1-terra", + "thinking": "high" + }, + "review-refuter": { + "model": "openai/gpt-6.1-sol", + "thinking": "xhigh" + }, + "review-validator": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "medium" + }, + "jd-judge-b": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "cortex-adversarial-tester": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "claude-bridge/claude-opus-5-5", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "claude-bridge/claude-sonnet-5-5", + "thinking": "low" + } + }, + "nan-qa-reliability-sol": { + "orchestrator": { + "model": "nan/glm5.3", + "thinking": "low" + }, + "gentle-ai-worker": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "jd-fix-agent": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-risk": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-refuter": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "review-validator": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "review-reliability": { + "model": "openai/gpt-6.1-sol", + "thinking": "high" + }, + "review-resilience": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "review-readability": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "jd-judge-a": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "jd-judge-b": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "gentle-ai-verify": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "gentle-ai-explore": { + "model": "nan/qwen3.8-flash", + "thinking": "medium" + }, + "cortex-adversarial-tester": { + "model": "nan/deepseek-v4-flash", + "thinking": "high" + }, + "cortex-requirements-analyst": { + "model": "nan/glm5.3", + "thinking": "high" + }, + "cortex-evidence-auditor": { + "model": "nan/glm5.3", + "thinking": "medium" + }, + "cortex-test-designer": { + "model": "nan/deepseek-v4-flash", + "thinking": "medium" + }, + "cortex-exploratory-tester": { + "model": "nan/glm5.3-flash", + "thinking": "medium" + }, + "cortex-test-runner": { + "model": "nan/mimo-v2.6-flash", + "thinking": "low" + } + } + } +} diff --git a/scripts/sync-pi-model-profiles.py b/scripts/sync-pi-model-profiles.py new file mode 100755 index 0000000..35003a8 --- /dev/null +++ b/scripts/sync-pi-model-profiles.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Sync the versioned gentle-pi model profiles into each Pi home. + +Source of truth: pi/gentle-ai/profiles.json (the gentle-pi +`agent_model_profiles` store, without the machine-local `current` snapshot). + +For every home (default: ~/.pi and ~/.gentle-shell) that has an `agent/` dir: + 1. Merge the repo profiles into /gentle-ai/profiles.json. Repo profiles + replace same-name local ones; local-only profiles and `current` are kept. + The local `active` profile is kept when it still exists. A home without a + store starts on the empty `current` profile, so its routing is untouched. + 2. Materialize the active profile the way gentle-pi does, so the routing is + live without opening /gentle:profiles: + - orchestrator -> agent/settings.json defaultProvider/defaultModel/defaultThinkingLevel + - each agent -> agent/subagents.json model_profiles. {model, effort} + and agent/agents/.md frontmatter model/thinking + +Every changed file is backed up as .bak-profiles- and written +atomically. Running it twice changes nothing. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +import time +from pathlib import Path + +ORCHESTRATOR = "orchestrator" +STAMP = time.strftime("%Y%m%dT%H%M%S") + + +def load_json(path: Path, default): + if not path.exists(): + return default + return json.loads(path.read_text()) + + +def write_if_changed(path: Path, text: str, dry_run: bool, changes: list[str]) -> None: + old = path.read_text() if path.exists() else None + if old == text: + return + changes.append(str(path)) + if dry_run: + return + path.parent.mkdir(parents=True, exist_ok=True) + if old is not None: + shutil.copy2(path, f"{path}.bak-profiles-{STAMP}") + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text) + if old is not None: + os.chmod(tmp, path.stat().st_mode & 0o777) + os.replace(tmp, path) + + +def dump(data) -> str: + return json.dumps(data, indent=2) + "\n" + + +def merge_store(local: dict, repo: dict) -> dict: + profiles = dict(local.get("profiles", {})) + profiles.update(repo["profiles"]) + active = local.get("active") + if not local: + # A home that never used profiles keeps its current routing: start on + # the empty `current` snapshot and let the person pick a profile. + profiles.setdefault("current", {}) + active = "current" + elif active not in profiles: + active = repo["active"] + return {"kind": repo["kind"], "version": repo["version"], "active": active, "profiles": profiles} + + +def set_frontmatter(text: str, key: str, value: str) -> str: + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not match: + return text + head = match.group(1) + line = re.compile(rf"^{key}: .*$", re.M) + head = line.sub(f"{key}: {value}", head) if line.search(head) else head + return text[: match.start(1)] + head + text[match.end(1) :] + + +def materialize(agent_dir: Path, profile: dict, dry_run: bool, changes: list[str]) -> None: + orch = profile.get(ORCHESTRATOR) + if orch: + settings_path = agent_dir / "settings.json" + settings = load_json(settings_path, {}) + provider, _, model = orch["model"].partition("/") + settings["defaultProvider"] = provider + settings["defaultModel"] = model + if orch.get("thinking"): + settings["defaultThinkingLevel"] = orch["thinking"] + write_if_changed(settings_path, dump(settings), dry_run, changes) + + sub_path = agent_dir / "subagents.json" + sub = load_json(sub_path, {}) + profiles = sub.setdefault("model_profiles", {}) + for agent, entry in profile.items(): + if agent == ORCHESTRATOR: + continue + current = profiles.setdefault(agent, {}) + current["model"] = entry["model"] + if entry.get("thinking"): + current["effort"] = entry["thinking"] + md = agent_dir / "agents" / f"{agent}.md" + if md.exists(): + text = set_frontmatter(md.read_text(), "model", entry["model"]) + if entry.get("thinking"): + text = set_frontmatter(text, "thinking", entry["thinking"]) + write_if_changed(md, text, dry_run, changes) + write_if_changed(sub_path, dump(sub), dry_run, changes) + + +def sync_home(home: Path, repo: dict, dry_run: bool) -> list[str]: + agent_dir = home / "agent" + if not agent_dir.is_dir(): + return [] + changes: list[str] = [] + store_path = home / "gentle-ai" / "profiles.json" + store = merge_store(load_json(store_path, {}), repo) + write_if_changed(store_path, dump(store), dry_run, changes) + active = store["profiles"][store["active"]] + if active: + materialize(agent_dir, active, dry_run, changes) + return changes + + +def main(argv: list[str]) -> int: + root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--source", type=Path, default=root / "pi" / "gentle-ai" / "profiles.json") + parser.add_argument("--home", type=Path, action="append", help="Pi home (repeatable); default ~/.pi and ~/.gentle-shell") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + + repo = json.loads(args.source.read_text()) + homes = args.home or [Path.home() / ".pi", Path.home() / ".gentle-shell"] + for home in homes: + changes = sync_home(home.expanduser(), repo, args.dry_run) + verb = "would change" if args.dry_run else "changed" + print(f"{home}: {verb} {len(changes)} file(s)") + for change in changes: + print(f" {change}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/sync_pi_model_profiles_test.py b/tests/sync_pi_model_profiles_test.py new file mode 100644 index 0000000..cff99cf --- /dev/null +++ b/tests/sync_pi_model_profiles_test.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Tests for scripts/sync-pi-model-profiles.py (run: python3 tests/sync_pi_model_profiles_test.py).""" + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "sync-pi-model-profiles.py" + +REPO = { + "kind": "gentle-pi.agent_model_profiles", + "version": 1, + "active": "openai", + "profiles": { + "openai": { + "orchestrator": {"model": "openai/gpt-6.1-sol", "thinking": "low"}, + "review-risk": {"model": "openai/gpt-6-astra", "thinking": "high"}, + "gentle-ai-verify": {"model": "openai/gpt-6.1-terra", "thinking": "medium"}, + } + }, +} + +AGENT_MD = "---\nname: review-risk\nmodel: openai/gpt-6.1-sol\nthinking: low\ntools: []\n---\nBody model: untouched\n" + + +class SyncPiModelProfilesTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.source = self.base / "profiles.json" + self.source.write_text(json.dumps(REPO)) + self.home = self.base / "home" + agent = self.home / "agent" + (agent / "agents").mkdir(parents=True) + (agent / "settings.json").write_text(json.dumps({"theme": "refactoria", "defaultModel": "gpt-5.5", "defaultProvider": "openai"})) + (agent / "subagents.json").write_text(json.dumps({"default_effort": "high", "model_profiles": {"review-risk": {"model": "openai/gpt-6.1-sol", "effort": "low"}, "kb-feeder": {"model": "openai/gpt-6-luna"}}})) + (agent / "agents" / "review-risk.md").write_text(AGENT_MD) + (self.home / "gentle-ai").mkdir() + (self.home / "gentle-ai" / "profiles.json").write_text(json.dumps({"kind": "gentle-pi.agent_model_profiles", "version": 1, "active": "openai", "profiles": {"current": {}, "local-only": {"orchestrator": {"model": "nan/glm5.3"}}, "openai": {"orchestrator": {"model": "openai/gpt-5.5"}}}})) + + def tearDown(self): + self.tmp.cleanup() + + def run_sync(self, *extra): + return subprocess.run([sys.executable, "-I", str(SCRIPT), "--source", str(self.source), "--home", str(self.home), *extra], capture_output=True, text=True, check=True) + + def read(self, rel): + return (self.home / rel).read_text() + + def test_merges_store_and_keeps_local_profiles(self): + self.run_sync() + store = json.loads(self.read("gentle-ai/profiles.json")) + self.assertEqual(store["profiles"]["openai"], REPO["profiles"]["openai"]) + self.assertIn("local-only", store["profiles"]) + self.assertIn("current", store["profiles"]) + self.assertEqual(store["active"], "openai") + + def test_materializes_active_profile(self): + self.run_sync() + settings = json.loads(self.read("agent/settings.json")) + self.assertEqual((settings["defaultProvider"], settings["defaultModel"], settings["defaultThinkingLevel"]), ("openai", "gpt-6.1-sol", "low")) + self.assertEqual(settings["theme"], "refactoria") + sub = json.loads(self.read("agent/subagents.json")) + self.assertEqual(sub["model_profiles"]["review-risk"], {"model": "openai/gpt-6-astra", "effort": "high"}) + self.assertEqual(sub["model_profiles"]["gentle-ai-verify"], {"model": "openai/gpt-6.1-terra", "effort": "medium"}) + self.assertEqual(sub["model_profiles"]["kb-feeder"], {"model": "openai/gpt-6-luna"}) + self.assertEqual(sub["default_effort"], "high") + md = self.read("agent/agents/review-risk.md") + self.assertIn("model: openai/gpt-6-astra\nthinking: high\n", md) + self.assertTrue(md.endswith("Body model: untouched\n")) + + def test_second_run_is_a_no_op(self): + self.run_sync() + out = self.run_sync().stdout + self.assertIn("changed 0 file(s)", out) + + def test_dry_run_writes_nothing(self): + before = self.read("agent/subagents.json") + out = self.run_sync("--dry-run").stdout + self.assertIn("would change", out) + self.assertEqual(self.read("agent/subagents.json"), before) + self.assertEqual(list(self.home.rglob("*.bak-profiles-*")), []) + + def test_home_without_store_keeps_its_routing(self): + (self.home / "gentle-ai" / "profiles.json").unlink() + before = {rel: self.read(rel) for rel in ("agent/settings.json", "agent/subagents.json", "agent/agents/review-risk.md")} + self.run_sync() + store = json.loads(self.read("gentle-ai/profiles.json")) + self.assertEqual(store["active"], "current") + self.assertEqual(store["profiles"]["current"], {}) + self.assertIn("openai", store["profiles"]) + self.assertEqual({rel: self.read(rel) for rel in before}, before) + + def test_home_without_agent_dir_is_skipped(self): + empty = self.base / "empty" + empty.mkdir() + out = subprocess.run([sys.executable, "-I", str(SCRIPT), "--source", str(self.source), "--home", str(empty)], capture_output=True, text=True, check=True).stdout + self.assertIn("changed 0 file(s)", out) + self.assertFalse((empty / "gentle-ai").exists()) + + +if __name__ == "__main__": + unittest.main()