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/config.py ADDED
@@ -0,0 +1,428 @@
1
+ """Config store: global ``config.yaml`` + per-project ``.brain/project.yaml``.
2
+
3
+ ``config.yaml`` (data root, version 3) holds global infrastructure defaults
4
+ (``beads.server``). ``brainspaces/<name>/.brain/project.yaml`` (version 1) holds
5
+ per-project identity (beads mode, database, remote). The old
6
+ ``dotbrain.yaml`` is read transparently as a fallback when ``config.yaml``
7
+ is absent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from dotbrain import paths
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Data model
22
+ # ---------------------------------------------------------------------------
23
+
24
+ @dataclass
25
+ class BeadsServer:
26
+ """Shared beads sql-server defaults from ``beads.server`` in config.yaml."""
27
+ host: str = ""
28
+ port: str = "3307"
29
+ user: str = "beads"
30
+ ssh_host: str = ""
31
+
32
+
33
+ @dataclass
34
+ class ProjectBeads:
35
+ """Effective per-project beads config from ``.brain/project.yaml``."""
36
+ mode: str = "embedded" # embedded | server | none
37
+ remote: str = ""
38
+ database: str = ""
39
+
40
+
41
+ DEFAULT_PROJECT_AGENTS: tuple[str, ...] = ("claude", "codex")
42
+
43
+
44
+ @dataclass
45
+ class DotbrainConfig:
46
+ """Parsed representation of config.yaml (schema v3)."""
47
+ version: int = 3
48
+ beads_server: BeadsServer = field(default_factory=BeadsServer)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Global config (config.yaml)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _config_path(dotbrain_home: Path) -> Path:
56
+ return Path(dotbrain_home) / "config.yaml"
57
+
58
+
59
+ def _old_config_path(dotbrain_home: Path) -> Path:
60
+ return Path(dotbrain_home) / "dotbrain.yaml"
61
+
62
+
63
+ def load_config(dotbrain_home: Path) -> DotbrainConfig:
64
+ """Load ``config.yaml``; transparently reads old ``dotbrain.yaml`` as fallback."""
65
+ import yaml # deferred: only needed when this function is called
66
+
67
+ path = _config_path(dotbrain_home)
68
+ if not path.is_file():
69
+ old = _old_config_path(dotbrain_home)
70
+ if old.is_file():
71
+ return _parse_old_format(old)
72
+
73
+ if not path.is_file():
74
+ return DotbrainConfig()
75
+
76
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
77
+ server = (data.get("beads") or {}).get("server") or {}
78
+ return DotbrainConfig(
79
+ version=int(data.get("version", 3)),
80
+ beads_server=BeadsServer(
81
+ host=str(server.get("host", "")),
82
+ port=str(server.get("port", "3307")),
83
+ user=str(server.get("user", "beads")),
84
+ ssh_host=str(server.get("ssh_host", "")),
85
+ ),
86
+ )
87
+
88
+
89
+ def _parse_old_format(path: Path) -> DotbrainConfig:
90
+ """Read beads.server from a legacy dotbrain.yaml, ignoring projects: section."""
91
+ import yaml
92
+
93
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
94
+ server = (data.get("beads") or {}).get("server") or {}
95
+ return DotbrainConfig(
96
+ version=3,
97
+ beads_server=BeadsServer(
98
+ host=str(server.get("host", "")),
99
+ port=str(server.get("port", "3307")),
100
+ user=str(server.get("user", "beads")),
101
+ ssh_host=str(server.get("ssh_host", "")),
102
+ ),
103
+ )
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Per-project config (<data-dir>/<name>/.brain/project.yaml)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def _project_config_path(dotbrain_home: Path, name: str) -> Path:
111
+ """Canonical ``.brain/project.yaml`` path."""
112
+ return paths.brainspace(dotbrain_home, name) / ".brain" / "project.yaml"
113
+
114
+
115
+ def default_beads_mode(dotbrain_home: Path) -> str:
116
+ """Effective default beads mode for a project that doesn't set one.
117
+
118
+ Follows infrastructure: a configured shared sql-server (``beads.server.host``
119
+ in config.yaml) makes ``server`` the default, so every wired project uses it;
120
+ otherwise the default is ``embedded``. Projects override per-project by
121
+ setting ``mode`` explicitly in ``.brain/project.yaml``.
122
+ """
123
+ return "server" if load_config(dotbrain_home).beads_server.host else "embedded"
124
+
125
+
126
+ def load_project_config(dotbrain_home: Path, name: str) -> ProjectBeads:
127
+ """Read ``brainspaces/<name>/.brain/project.yaml``, resolving defaults.
128
+
129
+ The default mode follows :func:`default_beads_mode` (server when a shared
130
+ server is configured, else embedded); database defaults to the project name.
131
+ Old ``dotbrain.yaml`` ``projects:<name>`` entries take precedence over a
132
+ default (seeded) ``.brain/project.yaml``.
133
+ """
134
+ import yaml
135
+
136
+ default_mode = default_beads_mode(dotbrain_home)
137
+
138
+ # Read .brain/project.yaml if it exists.
139
+ file_beads: ProjectBeads | None = None
140
+ path = _project_config_path(dotbrain_home, name)
141
+ if path.is_file():
142
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
143
+ beads = data.get("beads") or {}
144
+ file_beads = ProjectBeads(
145
+ mode=str(beads.get("mode", default_mode)),
146
+ remote=str(beads.get("remote", "")),
147
+ database=str(beads.get("database", name)),
148
+ )
149
+
150
+ # Check old dotbrain.yaml — its explicit entries override a default .brain/project.yaml.
151
+ old = _old_config_path(dotbrain_home)
152
+ if old.is_file():
153
+ data: dict[str, Any] = yaml.safe_load(old.read_text(encoding="utf-8")) or {}
154
+ projects = data.get("projects") or {}
155
+ if name in projects:
156
+ entry = (projects[name] or {}).get("beads") or {}
157
+ old_beads = ProjectBeads(
158
+ mode=str(entry.get("mode", default_mode)),
159
+ remote=str(entry.get("remote", "")),
160
+ database=str(entry.get("database", name)),
161
+ )
162
+ # If .brain/project.yaml exists and deviates from defaults, it wins.
163
+ # If .brain/project.yaml is all defaults (seeded template), old format wins.
164
+ if file_beads is not None and _is_beads_deviation(dotbrain_home, name, file_beads):
165
+ return file_beads
166
+ return old_beads
167
+
168
+ if file_beads is not None:
169
+ return file_beads
170
+ return ProjectBeads(mode=default_mode, database=name)
171
+
172
+
173
+ def load_project_skills(dotbrain_home: Path, name: str) -> tuple[str, ...]:
174
+ """Read the operator's per-project skill list from ``.brain/project.yaml`` ``skills:``.
175
+ """
176
+ import yaml
177
+
178
+ from dotbrain import skills
179
+
180
+ path = _project_config_path(dotbrain_home, name)
181
+ if not path.is_file():
182
+ return ()
183
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
184
+ if not isinstance(data, dict):
185
+ return ()
186
+ return skills._clean(data.get("skills"))
187
+
188
+
189
+ def load_project_subagents(dotbrain_home: Path, name: str) -> tuple[str, ...]:
190
+ """Read per-project subagent declarations from ``.brain/project.yaml``."""
191
+ import yaml
192
+
193
+ from dotbrain import skills
194
+
195
+ path = _project_config_path(dotbrain_home, name)
196
+ if not path.is_file():
197
+ return ()
198
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
199
+ if not isinstance(data, dict):
200
+ return ()
201
+ return skills._clean(data.get("subagents"))
202
+
203
+
204
+ def load_project_agents(dotbrain_home: Path, name: str) -> tuple[str, ...]:
205
+ """Read declared agent workspaces from ``brainspaces/<name>/.brain/project.yaml``.
206
+
207
+ Missing ``agents`` preserves legacy behavior by enabling both packaged agent
208
+ workspaces. An explicit empty list disables agent workspace seeding.
209
+ """
210
+ import yaml
211
+
212
+ path = _project_config_path(dotbrain_home, name)
213
+ if not path.is_file():
214
+ return DEFAULT_PROJECT_AGENTS
215
+
216
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
217
+ if not isinstance(data, dict):
218
+ return DEFAULT_PROJECT_AGENTS
219
+
220
+ raw_agents = data.get("agents")
221
+ if raw_agents is None:
222
+ return DEFAULT_PROJECT_AGENTS
223
+
224
+ entries = raw_agents if isinstance(raw_agents, list) else [raw_agents]
225
+ cleaned: list[str] = []
226
+ seen: set[str] = set()
227
+ for entry in entries:
228
+ agent = str(entry).strip().lower()
229
+ if not agent or agent in seen:
230
+ continue
231
+ seen.add(agent)
232
+ cleaned.append(agent)
233
+ return tuple(cleaned)
234
+
235
+
236
+ def write_project_config(dotbrain_home: Path, name: str, beads: ProjectBeads) -> str | None:
237
+ """Write ``brainspaces/<name>/.brain/project.yaml``. Returns a log line or None if unchanged."""
238
+ import yaml
239
+
240
+ path = _project_config_path(dotbrain_home, name)
241
+ existing = load_project_config(dotbrain_home, name)
242
+
243
+ if (existing.mode == beads.mode
244
+ and existing.remote == beads.remote
245
+ and existing.database == (beads.database or name)):
246
+ return None
247
+
248
+ resolved = ProjectBeads(
249
+ mode=beads.mode,
250
+ remote=beads.remote,
251
+ database=beads.database if beads.database != name else "",
252
+ )
253
+
254
+ doc: dict[str, Any] = {"beads": {"mode": resolved.mode}}
255
+ if resolved.remote:
256
+ doc["beads"]["remote"] = resolved.remote
257
+ if resolved.database:
258
+ doc["beads"]["database"] = resolved.database
259
+
260
+ # This rewrite drops comments; carry the operator's per-project skills across
261
+ # so a beads-deviation write never strands them.
262
+ existing_skills = load_project_skills(dotbrain_home, name)
263
+ if existing_skills:
264
+ doc["skills"] = list(existing_skills)
265
+ existing_subagents = load_project_subagents(dotbrain_home, name)
266
+ if existing_subagents:
267
+ doc["subagents"] = list(existing_subagents)
268
+
269
+ path.parent.mkdir(parents=True, exist_ok=True)
270
+ path.write_text(
271
+ yaml.dump(doc, default_flow_style=False, sort_keys=False),
272
+ encoding="utf-8",
273
+ newline="\n",
274
+ )
275
+ return f"wrote beads config for {name} to {path}"
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Public mutation helpers (used by wire / migrate / workflows)
280
+ # ---------------------------------------------------------------------------
281
+
282
+ def record_project_beads(dotbrain_home: Path, name: str, beads: ProjectBeads) -> str | None:
283
+ """Persist a project's beads config in ``.brain/project.yaml``.
284
+
285
+ Only writes when the config deviates from the resolved defaults (mode differs
286
+ from the config-driven default mode, or a custom remote/database). A default
287
+ call never overwrites a manual declaration (e.g. mode: none) that already
288
+ exists. Returns a log line when the file changed, else None.
289
+ """
290
+ if not _is_beads_deviation(dotbrain_home, name, beads):
291
+ return None
292
+ return write_project_config(dotbrain_home, name, beads)
293
+
294
+
295
+ def migrate_legacy_skill_manifest(dotbrain_home: Path, name: str) -> str | None:
296
+ """Fold a legacy ``.brain/agents/skills.yaml`` into ``.brain/project.yaml`` and remove it.
297
+
298
+ The per-project skill list used to live in a tool-managed manifest under
299
+ ``.brain/agents/``; it now lives in ``.brain/project.yaml`` ``skills:``. This folds any
300
+ extras across (without losing them) and deletes the stale file. Idempotent.
301
+ """
302
+ import yaml
303
+
304
+ from dotbrain import skills
305
+
306
+ legacy = paths.brainspace(dotbrain_home, name) / ".brain" / "agents" / "skills.yaml"
307
+ if not legacy.is_file():
308
+ return None
309
+
310
+ data = yaml.safe_load(legacy.read_text(encoding="utf-8")) or {}
311
+ extras: tuple[str, ...] = ()
312
+ if isinstance(data, dict):
313
+ extras = skills._clean(data.get("skills", data.get("extra")))
314
+ if extras:
315
+ _append_project_skills(dotbrain_home, name, extras)
316
+ legacy.unlink()
317
+
318
+ suffix = f" ({len(extras)} skill(s))" if extras else ""
319
+ return f"migrated legacy skills manifest for {name} into .brain/project.yaml{suffix}"
320
+
321
+
322
+ def _append_project_skills(dotbrain_home: Path, name: str, extras: tuple[str, ...]) -> None:
323
+ """Append a ``skills:`` block to ``.brain/project.yaml``, preserving existing content.
324
+
325
+ No-op when the file already declares ``skills:`` (the operator owns it then).
326
+ """
327
+ import re
328
+
329
+ path = _project_config_path(dotbrain_home, name)
330
+ block = "skills:\n" + "".join(f" - {skill}\n" for skill in extras)
331
+ if path.is_file():
332
+ text = path.read_text(encoding="utf-8")
333
+ if re.search(r"(?m)^skills:", text):
334
+ return
335
+ if text and not text.endswith("\n"):
336
+ text += "\n"
337
+ path.write_text(text + "\n" + block, encoding="utf-8", newline="\n")
338
+ return
339
+ header = "# Per-project skills, linked on top of the brain-coupled required core.\n"
340
+ path.parent.mkdir(parents=True, exist_ok=True)
341
+ path.write_text(header + block, encoding="utf-8", newline="\n")
342
+
343
+
344
+ def _is_beads_deviation(dotbrain_home: Path, name: str, beads: ProjectBeads) -> bool:
345
+ return (
346
+ beads.mode != default_beads_mode(dotbrain_home)
347
+ or bool(beads.remote)
348
+ or bool(beads.database and beads.database != name)
349
+ )
350
+
351
+
352
+ def remove_project_beads(dotbrain_home: Path, name: str) -> str | None:
353
+ """Drop a project's per-project config. Returns a log line or None if absent."""
354
+ path = _project_config_path(dotbrain_home, name)
355
+ if path.is_file():
356
+ os.remove(path)
357
+ return f"removed .brain/project.yaml for {name}"
358
+
359
+ # Old-format cleanup: remove from dotbrain.yaml projects section
360
+ old = _old_config_path(dotbrain_home)
361
+ if old.is_file():
362
+ import yaml
363
+ data: dict[str, Any] = yaml.safe_load(old.read_text(encoding="utf-8")) or {}
364
+ projects = data.get("projects")
365
+ if projects and name in projects:
366
+ return _remove_from_old_projects_section(old, name)
367
+
368
+ return None
369
+
370
+
371
+ def _remove_from_old_projects_section(path: Path, name: str) -> str | None:
372
+ """Drop ``projects.<name>`` from a legacy dotbrain.yaml, preserving other text."""
373
+ import yaml
374
+
375
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
376
+ projects: dict[str, Any] = data.get("projects") or {}
377
+ if name not in projects:
378
+ return None
379
+ del projects[name]
380
+
381
+ text = path.read_text(encoding="utf-8")
382
+ kept: list[str] = []
383
+ in_section = False
384
+ in_entry = False
385
+ for line in text.splitlines():
386
+ if in_entry:
387
+ if line and not line[0].isspace():
388
+ in_entry = False
389
+ else:
390
+ continue
391
+ if in_section:
392
+ if line.startswith(f" {name}:"):
393
+ in_entry = True
394
+ continue
395
+ if line and not line[0].isspace():
396
+ in_section = False
397
+
398
+ if line.startswith("projects:"):
399
+ in_section = True
400
+ continue
401
+ kept.append(line)
402
+
403
+ while kept and not kept[-1].strip():
404
+ kept.pop()
405
+ out = "\n".join(kept) + "\n"
406
+ if projects:
407
+ out += "\n" + _render_old_projects_section(projects)
408
+ path.write_text(out, encoding="utf-8", newline="\n")
409
+ return f"removed beads deviation for {name} from dotbrain.yaml"
410
+
411
+
412
+ def _render_old_projects_section(projects: dict[str, Any]) -> str:
413
+ lines = ["projects:"]
414
+ for pname in sorted(projects):
415
+ entry = projects[pname]
416
+ beads = (entry or {}).get("beads") or {}
417
+ lines.append(f" {pname}:")
418
+ lines.append(" beads:")
419
+ mode = beads.get("mode", "embedded")
420
+ if mode != "embedded":
421
+ lines.append(f" mode: {mode}")
422
+ remote = beads.get("remote", "")
423
+ if remote:
424
+ lines.append(f" remote: {remote}")
425
+ database = beads.get("database", "")
426
+ if database:
427
+ lines.append(f" database: {database}")
428
+ return "\n".join(lines) + "\n"