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.
Files changed (40) hide show
  1. dotbrain/__init__.py +7 -0
  2. dotbrain/_cli_reference.py +115 -0
  3. dotbrain/adopter_repos.py +573 -0
  4. dotbrain/beads.py +511 -0
  5. dotbrain/bootstrap.py +199 -0
  6. dotbrain/brainspaces.py +238 -0
  7. dotbrain/cli.py +660 -0
  8. dotbrain/config.py +428 -0
  9. dotbrain/doctor.py +279 -0
  10. dotbrain/hooks.py +84 -0
  11. dotbrain/migrate.py +304 -0
  12. dotbrain/paths.py +139 -0
  13. dotbrain/resource_loader.py +47 -0
  14. dotbrain/resources/__init__.py +1 -0
  15. dotbrain/resources/agents/claude/implementer.md +47 -0
  16. dotbrain/resources/agents/claude/investigator.md +35 -0
  17. dotbrain/resources/agents/claude/reviewer.md +38 -0
  18. dotbrain/resources/agents/claude/verifier.md +35 -0
  19. dotbrain/resources/agents/codex/implementer.toml +26 -0
  20. dotbrain/resources/agents/codex/investigator.toml +21 -0
  21. dotbrain/resources/agents/codex/reviewer.toml +27 -0
  22. dotbrain/resources/agents/codex/verifier.toml +21 -0
  23. dotbrain/resources/config.yaml +20 -0
  24. dotbrain/resources/core.yaml +18 -0
  25. dotbrain/resources/templates/brain/AGENTS.md +9 -0
  26. dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
  27. dotbrain/resources/templates/brain/adr/README.md +8 -0
  28. dotbrain/resources/templates/brain/designs/README.md +28 -0
  29. dotbrain/resources/templates/brain/docs/README.md +9 -0
  30. dotbrain/resources/templates/brain/project.yaml +27 -0
  31. dotbrain/resources/templates/gitignore +17 -0
  32. dotbrain/skills.py +264 -0
  33. dotbrain/subagents.py +252 -0
  34. dotbrain/updater.py +105 -0
  35. dotbrain/workflows.py +529 -0
  36. dotbrain-0.3.4.dist-info/METADATA +21 -0
  37. dotbrain-0.3.4.dist-info/RECORD +40 -0
  38. dotbrain-0.3.4.dist-info/WHEEL +4 -0
  39. dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
  40. 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
@@ -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}")