dotbrain 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotbrain/__init__.py +7 -0
- dotbrain/_cli_reference.py +115 -0
- dotbrain/adopter_repos.py +573 -0
- dotbrain/beads.py +511 -0
- dotbrain/bootstrap.py +199 -0
- dotbrain/brainspaces.py +238 -0
- dotbrain/cli.py +660 -0
- dotbrain/config.py +428 -0
- dotbrain/doctor.py +279 -0
- dotbrain/hooks.py +84 -0
- dotbrain/migrate.py +304 -0
- dotbrain/paths.py +139 -0
- dotbrain/resource_loader.py +47 -0
- dotbrain/resources/__init__.py +1 -0
- dotbrain/resources/agents/claude/implementer.md +47 -0
- dotbrain/resources/agents/claude/investigator.md +35 -0
- dotbrain/resources/agents/claude/reviewer.md +38 -0
- dotbrain/resources/agents/claude/verifier.md +35 -0
- dotbrain/resources/agents/codex/implementer.toml +26 -0
- dotbrain/resources/agents/codex/investigator.toml +21 -0
- dotbrain/resources/agents/codex/reviewer.toml +27 -0
- dotbrain/resources/agents/codex/verifier.toml +21 -0
- dotbrain/resources/config.yaml +20 -0
- dotbrain/resources/core.yaml +18 -0
- dotbrain/resources/templates/brain/AGENTS.md +9 -0
- dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
- dotbrain/resources/templates/brain/adr/README.md +8 -0
- dotbrain/resources/templates/brain/designs/README.md +28 -0
- dotbrain/resources/templates/brain/docs/README.md +9 -0
- dotbrain/resources/templates/brain/project.yaml +27 -0
- dotbrain/resources/templates/gitignore +17 -0
- dotbrain/skills.py +264 -0
- dotbrain/subagents.py +252 -0
- dotbrain/updater.py +105 -0
- dotbrain/workflows.py +529 -0
- dotbrain-0.3.4.dist-info/METADATA +21 -0
- dotbrain-0.3.4.dist-info/RECORD +40 -0
- dotbrain-0.3.4.dist-info/WHEEL +4 -0
- dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
- dotbrain-0.3.4.dist-info/licenses/LICENSE +21 -0
dotbrain/bootstrap.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Machine-readiness bootstrap.
|
|
2
|
+
|
|
3
|
+
This module owns machine-global setup: data-root seeding and global runtime links.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from dotbrain import adopter_repos, resource_loader, skills, subagents
|
|
15
|
+
|
|
16
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_run(
|
|
20
|
+
argv: Sequence[str], *, cwd: Path | None = None, check: bool = True
|
|
21
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
list(argv), cwd=cwd, check=check,
|
|
24
|
+
capture_output=True, encoding="utf-8", stdin=subprocess.DEVNULL,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- data-root seeding
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DataRootResult:
|
|
33
|
+
"""What happened during data-root seeding (idempotent)."""
|
|
34
|
+
|
|
35
|
+
created: bool = False
|
|
36
|
+
config_seeded: bool = False
|
|
37
|
+
skills_seeded: bool = False
|
|
38
|
+
agents_seeded: bool = False
|
|
39
|
+
git_initialized: bool = False
|
|
40
|
+
logs: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_root_gitignore(dotbrain_home: Path) -> bool:
|
|
44
|
+
"""Ensure the data-root gitignore matches the packaged template."""
|
|
45
|
+
|
|
46
|
+
path = Path(dotbrain_home) / ".gitignore"
|
|
47
|
+
desired = resource_loader.resource("templates/gitignore").read_text(encoding="utf-8")
|
|
48
|
+
if path.is_file() and path.read_text(encoding="utf-8") == desired:
|
|
49
|
+
return False
|
|
50
|
+
path.write_text(desired, encoding="utf-8", newline="\n")
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_data_root(dotbrain_home: Path, *, run: Runner = _default_run) -> DataRootResult:
|
|
55
|
+
"""Create the data root and seed ``config.yaml`` from the packaged template.
|
|
56
|
+
|
|
57
|
+
Idempotent — if ``config.yaml`` already exists it is left untouched.
|
|
58
|
+
"""
|
|
59
|
+
root = Path(dotbrain_home)
|
|
60
|
+
result = DataRootResult()
|
|
61
|
+
|
|
62
|
+
if not root.exists():
|
|
63
|
+
root.mkdir(parents=True)
|
|
64
|
+
result.created = True
|
|
65
|
+
result.logs.append(f"created data root: {root}")
|
|
66
|
+
|
|
67
|
+
# wire/refresh/unwire all require the data root to be a git checkout — Brain and
|
|
68
|
+
# execution state are versioned there. Seeding the files without initializing the
|
|
69
|
+
# repo left every fresh install failing on the first 'dotbrain wire'.
|
|
70
|
+
if not (root / ".git").exists():
|
|
71
|
+
try:
|
|
72
|
+
run(["git", "init", "--quiet"], cwd=root)
|
|
73
|
+
except FileNotFoundError as exc:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"git is required to initialize the dotbrain data root at {root}; install git"
|
|
76
|
+
) from exc
|
|
77
|
+
except subprocess.CalledProcessError as exc:
|
|
78
|
+
detail = (exc.stderr or exc.stdout or "").strip() or "git init failed"
|
|
79
|
+
raise RuntimeError(f"could not initialize {root} as a git checkout: {detail}") from exc
|
|
80
|
+
result.git_initialized = True
|
|
81
|
+
result.logs.append(f"initialized git checkout: {root}")
|
|
82
|
+
|
|
83
|
+
if ensure_root_gitignore(root):
|
|
84
|
+
result.logs.append(f"seeded .gitignore into {root}")
|
|
85
|
+
|
|
86
|
+
config_dest = root / "config.yaml"
|
|
87
|
+
if not config_dest.exists():
|
|
88
|
+
src = resource_loader.resource("config.yaml")
|
|
89
|
+
if src.is_file():
|
|
90
|
+
config_dest.write_text(
|
|
91
|
+
src.read_text(encoding="utf-8"),
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
newline="\n",
|
|
94
|
+
)
|
|
95
|
+
result.config_seeded = True
|
|
96
|
+
result.logs.append(f"seeded config.yaml into {root}")
|
|
97
|
+
|
|
98
|
+
# Seed the operator skill-link config so there's a clear home to manage
|
|
99
|
+
# global skills. Rendered via the same function reconcile uses, so the
|
|
100
|
+
# seeded file is already normalized.
|
|
101
|
+
skills_dest = root / "skills" / "skills.yaml"
|
|
102
|
+
if not skills_dest.exists():
|
|
103
|
+
skills_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
skills_dest.write_text(
|
|
105
|
+
skills.render_global_config(skills.DEFAULT_TARGETS, ()),
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
newline="\n",
|
|
108
|
+
)
|
|
109
|
+
result.skills_seeded = True
|
|
110
|
+
result.logs.append(f"seeded skills/skills.yaml into {root}")
|
|
111
|
+
|
|
112
|
+
agents_root = root / "agents"
|
|
113
|
+
for subdir, _ext in subagents.RUNTIME_SPEC.values():
|
|
114
|
+
(agents_root / subdir).mkdir(parents=True, exist_ok=True)
|
|
115
|
+
agents_dest = agents_root / "agents.yaml"
|
|
116
|
+
if not agents_dest.exists():
|
|
117
|
+
agents_dest.write_text(
|
|
118
|
+
subagents.render_global_subagents(),
|
|
119
|
+
encoding="utf-8",
|
|
120
|
+
newline="\n",
|
|
121
|
+
)
|
|
122
|
+
result.agents_seeded = True
|
|
123
|
+
result.logs.append(f"seeded agents/agents.yaml into {root}")
|
|
124
|
+
seeded_subagents = subagents.rehydrate_packaged_subagents(root)
|
|
125
|
+
if seeded_subagents:
|
|
126
|
+
result.agents_seeded = True
|
|
127
|
+
result.logs += [
|
|
128
|
+
f"rehydrated {path.relative_to(root).as_posix()} into {root}" for path in seeded_subagents
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class GlobalSkillBootstrapResult:
|
|
136
|
+
logs: list[str] = field(default_factory=list)
|
|
137
|
+
warnings: list[str] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def link_global_skills(
|
|
141
|
+
dotbrain_home: Path, target: str = "all", *, home: Path | None = None
|
|
142
|
+
) -> GlobalSkillBootstrapResult:
|
|
143
|
+
root = Path(dotbrain_home)
|
|
144
|
+
h = Path(home) if home is not None else Path.home()
|
|
145
|
+
config_path = root / "skills" / "skills.yaml"
|
|
146
|
+
result = GlobalSkillBootstrapResult()
|
|
147
|
+
config = skills.reconcile_global_config(config_path)
|
|
148
|
+
skill_paths = config.global_extra
|
|
149
|
+
if target == "all":
|
|
150
|
+
keys = list(config.targets)
|
|
151
|
+
elif target in config.targets:
|
|
152
|
+
keys = [target]
|
|
153
|
+
else:
|
|
154
|
+
result.warnings.append(f"target '{target}' not configured; skipping")
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
for key in keys:
|
|
158
|
+
dest = adopter_repos.expand_path(config.targets[key], home=h)
|
|
159
|
+
link_result = skills.link_into(root, dest, skill_paths, label=key, prune_owned_only=True)
|
|
160
|
+
result.warnings += [f"{warning} (global {key})" for warning in link_result.warnings]
|
|
161
|
+
result.logs += [f"stashed real path aside: {moved}" for moved in link_result.stashed]
|
|
162
|
+
result.logs += [f"pruned stale {pruned}" for pruned in link_result.pruned]
|
|
163
|
+
result.logs.append(f"global: linked {len(link_result.linked)} skill(s) into {dest}")
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def link_global_subagents(
|
|
168
|
+
dotbrain_home: Path, target: str = "all", *, home: Path | None = None
|
|
169
|
+
) -> GlobalSkillBootstrapResult:
|
|
170
|
+
root = Path(dotbrain_home)
|
|
171
|
+
h = Path(home) if home is not None else Path.home()
|
|
172
|
+
result = GlobalSkillBootstrapResult()
|
|
173
|
+
config = subagents.load_global_config(root)
|
|
174
|
+
names = config.global_names
|
|
175
|
+
resolved = {name: subagents._resolve_subagent_files(root, name) for name in names}
|
|
176
|
+
missing = [name for name, runtime_files in resolved.items() if not runtime_files]
|
|
177
|
+
result.warnings += [f"subagent not found: {name}" for name in missing]
|
|
178
|
+
|
|
179
|
+
if target == "all":
|
|
180
|
+
keys = list(config.targets)
|
|
181
|
+
elif target in config.targets:
|
|
182
|
+
keys = [target]
|
|
183
|
+
else:
|
|
184
|
+
result.warnings.append(f"target '{target}' not configured; skipping")
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
for key in keys:
|
|
188
|
+
dest = adopter_repos.expand_path(config.targets[key], home=h)
|
|
189
|
+
files = [runtime_files[key] for name, runtime_files in resolved.items() if key in runtime_files]
|
|
190
|
+
link_result = subagents.link_files_into(
|
|
191
|
+
root,
|
|
192
|
+
dest,
|
|
193
|
+
files,
|
|
194
|
+
label=key,
|
|
195
|
+
)
|
|
196
|
+
result.logs += [f"stashed real path aside: {moved}" for moved in link_result.stashed]
|
|
197
|
+
result.logs += [f"pruned stale {pruned}" for pruned in link_result.pruned]
|
|
198
|
+
result.logs.append(f"global: linked {len(files)} subagent file(s) into {dest}")
|
|
199
|
+
return result
|
dotbrain/brainspaces.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Brainspace lifecycle: Brain seeding, agent-workspace preparation, and offboarding.
|
|
2
|
+
|
|
3
|
+
A Brainspace is a project's private context store under ``brainspaces/<name>/``. This module owns
|
|
4
|
+
its whole lifecycle except the adopter-repo links (``adopter_repos``) and beads setup
|
|
5
|
+
(``wiring``/``beads``):
|
|
6
|
+
|
|
7
|
+
- Brain skeleton seeding from packaged ``templates/brain/`` resources;
|
|
8
|
+
- agent-workspace preparation for selected Claude/Codex assets;
|
|
9
|
+
- offboarding: keep | archive | delete plus the byproduct cleanup that precedes git mv/rm.
|
|
10
|
+
|
|
11
|
+
It depends only on ``paths``. It is intentionally not split into ``brain_seed.py`` /
|
|
12
|
+
``agent_workspaces.py`` yet.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from dotbrain import config, paths, resource_loader
|
|
24
|
+
|
|
25
|
+
# A subprocess seam: same shape as ``subprocess.run`` but easy to fake in tests.
|
|
26
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
27
|
+
|
|
28
|
+
def _default_run(
|
|
29
|
+
argv: Sequence[str], *, cwd: Path | None = None, check: bool = True
|
|
30
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
list(argv), cwd=cwd, check=check, capture_output=True, encoding="utf-8"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- pure helpers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- brain & gitignore
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def seed_brain(brainspace: Path, dotbrain_home: Path) -> None:
|
|
43
|
+
"""Seed a brain skeleton from packaged dotbrain resources.
|
|
44
|
+
|
|
45
|
+
``DOTBRAIN.md`` and ``README.md`` files are dotbrain-owned and overwritten
|
|
46
|
+
so package template changes propagate. All other files, including
|
|
47
|
+
``project.yaml``, are project-owned and are only written when missing.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
brain = Path(brainspace) / ".brain"
|
|
51
|
+
brain.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
if not resource_loader.resource("templates/brain/AGENTS.md").is_file():
|
|
54
|
+
raise FileNotFoundError("package resource templates/brain/AGENTS.md is missing")
|
|
55
|
+
|
|
56
|
+
for rel, src in resource_loader.iter_resource_files("templates/brain"):
|
|
57
|
+
dest = brain / rel
|
|
58
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
if src.name not in ("DOTBRAIN.md", "README.md") and dest.exists():
|
|
60
|
+
continue
|
|
61
|
+
content = src.read_text(encoding="utf-8")
|
|
62
|
+
if dest.is_file() and dest.read_bytes() == content.encode("utf-8"):
|
|
63
|
+
continue
|
|
64
|
+
dest.write_text(
|
|
65
|
+
content,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
newline="\n",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
claude = brain / "CLAUDE.md"
|
|
71
|
+
if not claude.exists():
|
|
72
|
+
claude.symlink_to("AGENTS.md")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------------- agent workspaces
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _hook_command_present(entries: list, command: str) -> bool:
|
|
79
|
+
for entry in entries:
|
|
80
|
+
for hook in entry.get("hooks", []) if isinstance(entry, dict) else []:
|
|
81
|
+
if isinstance(hook, dict) and hook.get("command") == command:
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_json_hook(
|
|
87
|
+
file: Path,
|
|
88
|
+
event: str,
|
|
89
|
+
command: str,
|
|
90
|
+
matcher: str = "",
|
|
91
|
+
status_message: str = "",
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Idempotently merge a hook entry into an agent JSON config (Python port of the jq logic).
|
|
94
|
+
|
|
95
|
+
Keyed on the command string anywhere under ``.hooks[event][].hooks[].command``.
|
|
96
|
+
"""
|
|
97
|
+
file = Path(file)
|
|
98
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
text = file.read_text(encoding="utf-8") if file.is_file() else ""
|
|
100
|
+
data = json.loads(text) if text.strip() else {}
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
data = {}
|
|
103
|
+
|
|
104
|
+
hooks = data.setdefault("hooks", {})
|
|
105
|
+
entries = hooks.setdefault(event, [])
|
|
106
|
+
if _hook_command_present(entries, command):
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
hook: dict = {"type": "command", "command": command}
|
|
110
|
+
if status_message:
|
|
111
|
+
hook["statusMessage"] = status_message
|
|
112
|
+
entry: dict = {"hooks": [hook]}
|
|
113
|
+
if matcher:
|
|
114
|
+
entry["matcher"] = matcher
|
|
115
|
+
entries.append(entry)
|
|
116
|
+
file.write_text(
|
|
117
|
+
json.dumps(data, indent=2) + "\n",
|
|
118
|
+
encoding="utf-8",
|
|
119
|
+
newline="\n",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_KNOWN_AGENT_WORKSPACES = frozenset({"claude", "codex"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def active_agent_workspaces(brainspace: Path, dotbrain_home: Path) -> tuple[str, ...]:
|
|
127
|
+
"""Return the declared, known workspace directory names for a project."""
|
|
128
|
+
agents = config.load_project_agents(dotbrain_home, Path(brainspace).name)
|
|
129
|
+
return tuple(f".{agent}" for agent in agents if agent in _KNOWN_AGENT_WORKSPACES)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_brain_only(brainspace: Path) -> bool:
|
|
133
|
+
"""True when the Brainspace declares no adopter repo."""
|
|
134
|
+
|
|
135
|
+
repo_file = Path(brainspace) / ".repo"
|
|
136
|
+
return (
|
|
137
|
+
repo_file.is_file()
|
|
138
|
+
and repo_file.read_text(encoding="utf-8").strip() == "(brain-only)"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def seed_agent_workspaces(brainspace: Path, dotbrain_home: Path, home: Path | None = None) -> list[str]:
|
|
143
|
+
"""Create declared agent workspaces, but only for a brain-only Brainspace.
|
|
144
|
+
|
|
145
|
+
A repo-backed Brainspace has its workspaces materialized in the code repo, and skill and
|
|
146
|
+
subagent links point from there straight at the dotbrain home. Seeding one here too would
|
|
147
|
+
leave directories nothing reads. A brain-only Brainspace has no repo, so it is the only
|
|
148
|
+
place its links can live.
|
|
149
|
+
|
|
150
|
+
Undeclared agents still warn either way: a typo in ``project.yaml`` should not be silent.
|
|
151
|
+
"""
|
|
152
|
+
brainspace = Path(brainspace)
|
|
153
|
+
warnings: list[str] = []
|
|
154
|
+
brain_only = is_brain_only(brainspace)
|
|
155
|
+
|
|
156
|
+
for agent in config.load_project_agents(dotbrain_home, brainspace.name):
|
|
157
|
+
if agent not in _KNOWN_AGENT_WORKSPACES:
|
|
158
|
+
warnings.append(f"ignored unknown agent workspace in {brainspace / '.brain' / 'project.yaml'}: {agent}")
|
|
159
|
+
continue
|
|
160
|
+
if brain_only:
|
|
161
|
+
(brainspace / f".{agent}").mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
return warnings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------- offboarding
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _strip_brainspace_byproducts(dotbrain_home: Path, project: str, run: Runner) -> None:
|
|
170
|
+
"""Remove the Brainspace's gitignored runtime/wiring litter (beads runtime state,
|
|
171
|
+
.claude/.codex skill symlinks). ``git rm``/``git mv`` only handle tracked files, so
|
|
172
|
+
without this an offboard strands these byproducts on disk. ``-X`` removes *only* ignored
|
|
173
|
+
files, so an uncommitted (untracked) brain is left intact; ``-ff`` clears nested git/dolt dirs."""
|
|
174
|
+
rel = paths.data_dir(dotbrain_home).name
|
|
175
|
+
run(["git", "-C", str(dotbrain_home), "clean", "-ffdXq", "--", f"{rel}/{project}"])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_tracked(dotbrain_home: Path, project: str, run: Runner) -> bool:
|
|
179
|
+
"""True if the Brainspace has any git-tracked files (wire no longer commits, so a
|
|
180
|
+
freshly-wired root is untracked and git rm/mv would fail)."""
|
|
181
|
+
rel = paths.data_dir(dotbrain_home).name
|
|
182
|
+
out = run(["git", "-C", str(dotbrain_home), "ls-files", "--", f"{rel}/{project}"], check=False)
|
|
183
|
+
return bool((out.stdout or "").strip())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def offboard_brainspace(
|
|
187
|
+
dotbrain_home: Path,
|
|
188
|
+
project: str,
|
|
189
|
+
mode: str,
|
|
190
|
+
*,
|
|
191
|
+
dry_run: bool = False,
|
|
192
|
+
run: Runner = _default_run,
|
|
193
|
+
) -> list[str]:
|
|
194
|
+
"""keep | archive | delete the Brainspace. Returns log lines."""
|
|
195
|
+
brainspace = paths.brainspace(dotbrain_home, project)
|
|
196
|
+
rel = paths.data_dir(dotbrain_home).name
|
|
197
|
+
if not brainspace.is_dir():
|
|
198
|
+
return [f"warning: Brainspace {brainspace} not found; nothing to offboard"]
|
|
199
|
+
|
|
200
|
+
if mode == "keep":
|
|
201
|
+
return [f"kept Brainspace: {brainspace} (disconnected; re-wire later with dotbrain wire)"]
|
|
202
|
+
|
|
203
|
+
if mode == "archive":
|
|
204
|
+
if dry_run:
|
|
205
|
+
return [f"would archive Brainspace {rel}/{project} -> {rel}/.archive/{project} "
|
|
206
|
+
"(stripping runtime byproducts first)"]
|
|
207
|
+
_strip_brainspace_byproducts(dotbrain_home, project, run)
|
|
208
|
+
archive_dir = paths.data_dir(dotbrain_home) / ".archive"
|
|
209
|
+
archive_dir.mkdir(exist_ok=True)
|
|
210
|
+
dest = archive_dir / project
|
|
211
|
+
if _is_tracked(dotbrain_home, project, run):
|
|
212
|
+
run(["git", "-C", str(dotbrain_home), "mv",
|
|
213
|
+
f"{rel}/{project}", f"{rel}/.archive/{project}"])
|
|
214
|
+
staged = " (staged)"
|
|
215
|
+
else:
|
|
216
|
+
shutil.move(str(brainspace), str(dest))
|
|
217
|
+
staged = " (uncommitted)"
|
|
218
|
+
return [
|
|
219
|
+
f"archived Brainspace -> {rel}/.archive/{project}{staged}",
|
|
220
|
+
f"suggested commit: chore(brain): archive {project} Brainspace",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
if mode == "delete":
|
|
224
|
+
if dry_run:
|
|
225
|
+
return [f"would remove Brainspace {rel}/{project} (tracked files + runtime byproducts)"]
|
|
226
|
+
_strip_brainspace_byproducts(dotbrain_home, project, run)
|
|
227
|
+
# -f: delete is a deliberate full removal, so force past locally-modified tracked files
|
|
228
|
+
# (e.g. beads backup-state). --ignore-unmatch: a freshly-wired root is untracked.
|
|
229
|
+
run(["git", "-C", str(dotbrain_home), "rm", "-r", "-q", "-f", "--ignore-unmatch",
|
|
230
|
+
f"{rel}/{project}"])
|
|
231
|
+
if brainspace.exists():
|
|
232
|
+
shutil.rmtree(brainspace)
|
|
233
|
+
return [
|
|
234
|
+
f"removed Brainspace {rel}/{project}",
|
|
235
|
+
f"suggested commit: chore(brain): remove {project} Brainspace",
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
raise ValueError(f"unknown offboard mode: {mode!r}")
|