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/doctor.py ADDED
@@ -0,0 +1,279 @@
1
+ """Read-only health check for dotbrain: machine readiness, project wiring, beads drift.
2
+
3
+ Self-contained diagnostic module. No filesystem mutations — pure inspections only.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import subprocess
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from dotbrain import config, paths, resource_loader
15
+ from dotbrain.adopter_repos import is_dotbrain_repo
16
+
17
+ # Same shape as bootstrap.Runner; injected so tests can record without real subprocess calls.
18
+ Runner = Callable[..., subprocess.CompletedProcess[str]]
19
+
20
+
21
+ def _default_run(
22
+ argv: list[str], *, cwd: Path | None = None, check: bool = True, timeout: int | None = None
23
+ ) -> subprocess.CompletedProcess[str]:
24
+ return subprocess.run(
25
+ argv, cwd=cwd, check=check, capture_output=True, encoding="utf-8", timeout=timeout,
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class Finding:
31
+ status: str # "ok", "warn", "error"
32
+ message: str
33
+ suggestion: str = ""
34
+
35
+
36
+ @dataclass
37
+ class DoctorReport:
38
+ machine: list[Finding] = field(default_factory=list)
39
+ projects: dict[str, list[Finding]] = field(default_factory=dict)
40
+
41
+
42
+ # --------------------------------------------------------------------------- machine readiness
43
+
44
+
45
+ def _check_binary(name: str) -> Finding:
46
+ if shutil.which(name):
47
+ return Finding("ok", f"{name} available")
48
+ return Finding("error", f"{name} not found on PATH",
49
+ f"install {name} or add it to PATH")
50
+
51
+
52
+ def _check_dotbrain_config(root: Path) -> Finding:
53
+ try:
54
+ config.load_config(root)
55
+ except Exception as exc:
56
+ return Finding("error", f"config.yaml unreadable: {exc}",
57
+ "check config.yaml syntax")
58
+ return Finding("ok", "config.yaml readable")
59
+
60
+
61
+ def _check_global_skills_config(root: Path) -> Finding:
62
+ path = root / "skills" / "skills.yaml"
63
+ if path.is_file():
64
+ return Finding("ok", "operator skills config present")
65
+ return Finding("ok", "using packaged required-core skills only")
66
+
67
+
68
+ def _check_templates(root: Path) -> Finding:
69
+ if resource_loader.resource("templates/brain/AGENTS.md").is_file():
70
+ return Finding("ok", "package templates/brain present")
71
+ return Finding("error", "package templates/brain missing",
72
+ "installed package is missing bundled templates")
73
+
74
+
75
+ def _check_machine(root: Path, home: Path) -> list[Finding]:
76
+ findings: list[Finding] = [
77
+ _check_binary("bd"),
78
+ _check_dotbrain_config(root),
79
+ _check_global_skills_config(root),
80
+ _check_templates(root),
81
+ ]
82
+ return findings
83
+
84
+
85
+ # --------------------------------------------------------------------------- project wiring
86
+
87
+
88
+ def _check_repo_file(brainspace: Path) -> tuple[Finding | None, Path | None]:
89
+ """Returns (finding_or_None, resolved_repo_path_or_None).
90
+
91
+ finding is None when the repo exists and is valid; the caller proceeds with wiring checks.
92
+ resolved_repo_path is None for brain-only projects or when the target is missing.
93
+ """
94
+ repo_file = brainspace / ".repo"
95
+ if not repo_file.is_file():
96
+ # Undeclared: the Brainspace names neither a repo nor brain-only. Both are
97
+ # resolvable, and only the operator knows which was intended, so name both.
98
+ return (Finding("error", f".repo file missing in {brainspace.name}",
99
+ f"run 'dotbrain wire --repo <repo>' to wire a code repo, "
100
+ f"or 'dotbrain wire --name {brainspace.name} --no-repo' "
101
+ f"to declare it brain-only"),
102
+ None)
103
+ content = repo_file.read_text(encoding="utf-8").strip()
104
+ if content.startswith("(brain-only)"):
105
+ return (Finding("ok", "brain-only project (no code repo)"), None)
106
+ resolved = Path(content).expanduser().resolve() if content else None
107
+ if not resolved or not resolved.is_dir():
108
+ return (Finding("error", f".repo target not found: {content}",
109
+ f"update {brainspace.name}/.repo or run 'dotbrain wire'"),
110
+ None)
111
+ return (None, resolved) # repo exists; wiring checks follow
112
+
113
+
114
+ def _check_brainspace_links(repo: Path, brainspace: Path) -> list[Finding]:
115
+ findings: list[Finding] = []
116
+ active_agents = config.load_project_agents(brainspace.parents[1], brainspace.name)
117
+ for agent in (agent for agent in ("claude", "codex") if agent in active_agents):
118
+ workspace = repo / f".{agent}"
119
+ if not workspace.is_dir() or workspace.is_symlink():
120
+ findings.append(Finding(
121
+ "warn",
122
+ f"{workspace} is not a materialized workspace",
123
+ f"run 'dotbrain refresh --name {brainspace.name}'",
124
+ ))
125
+ for name in paths.BRAINSPACE_LINKS:
126
+ link = repo / name
127
+ if not link.is_symlink():
128
+ findings.append(Finding("warn", f"{repo}/{name} not wired",
129
+ f"run 'dotbrain wire --repo {repo}'"))
130
+ continue
131
+ try:
132
+ link.resolve(strict=True)
133
+ except OSError:
134
+ findings.append(Finding("error", f"{repo}/{name} broken symlink",
135
+ f"run 'dotbrain wire --repo {repo}' to repair"))
136
+ return findings
137
+
138
+
139
+ def _check_repo_excludes(repo: Path) -> list[Finding]:
140
+ findings: list[Finding] = []
141
+ present = paths.exclude_entries(repo)
142
+ for entry in paths.EXCLUDE_ENTRIES:
143
+ if entry not in present:
144
+ findings.append(Finding("warn",
145
+ f"{repo}/.git/info/exclude missing {entry}",
146
+ f"run 'dotbrain wire --repo {repo}' to repair"))
147
+ return findings
148
+
149
+
150
+ # Wire's idempotency marker in append_pointer_to_file: ".brain/AGENTS.md" substring.
151
+ # Doctor mirrors this so its "missing pointer" finding can actually be fixed by dotbrain wire.
152
+ _WIRE_POINTER_MARKER = ".brain/AGENTS.md"
153
+
154
+
155
+ def _check_agent_pointer(repo: Path) -> list[Finding]:
156
+ findings: list[Finding] = []
157
+ for name in ("AGENTS.md", "CLAUDE.md"):
158
+ file = repo / name
159
+ if not file.exists():
160
+ continue
161
+ try:
162
+ target = file.resolve(strict=True) if file.is_symlink() else file
163
+ except OSError:
164
+ continue
165
+ try:
166
+ if _WIRE_POINTER_MARKER not in target.read_text(encoding="utf-8"):
167
+ findings.append(Finding("warn",
168
+ f"{file} missing dotbrain pointer",
169
+ f"run 'dotbrain wire --repo {repo}' to repair"))
170
+ except OSError:
171
+ pass
172
+ return findings
173
+
174
+
175
+ def _check_project_wiring(brainspace: Path, dotbrain_home: Path) -> list[Finding]:
176
+ findings: list[Finding] = []
177
+
178
+ repo_finding, resolved = _check_repo_file(brainspace)
179
+ if repo_finding is not None:
180
+ findings.append(repo_finding)
181
+ if repo_finding.status == "error":
182
+ return findings
183
+ if resolved is None:
184
+ return findings # brain-only or repo target missing
185
+
186
+ # The dotbrain repo itself isn't an adopter — skip wiring checks on it.
187
+ if is_dotbrain_repo(resolved, dotbrain_home):
188
+ return [Finding("ok", "dotbrain repo (not an adopter)")]
189
+
190
+ findings += _check_brainspace_links(resolved, brainspace)
191
+ findings += _check_repo_excludes(resolved)
192
+ if paths.INJECT_ADOPTER_POINTER:
193
+ findings += _check_agent_pointer(resolved)
194
+ return findings
195
+
196
+
197
+ # --------------------------------------------------------------------------- beads state
198
+
199
+
200
+ def _check_beads_state(
201
+ brainspace: Path, name: str, dotbrain_home: Path, *, run: Runner = _default_run,
202
+ ) -> list[Finding]:
203
+ findings: list[Finding] = []
204
+ beads_cfg = config.load_project_config(dotbrain_home, name)
205
+
206
+ if beads_cfg.mode == "none":
207
+ return [Finding("ok", "beads disabled (mode: none)")]
208
+
209
+ beads_dir = brainspace / ".beads"
210
+ if not beads_dir.is_dir() or not (beads_dir / "metadata.json").is_file():
211
+ return [Finding("warn", ".beads not initialized",
212
+ f"run 'dotbrain beads load --name {name}' or 'dotbrain wire --all'")]
213
+
214
+ if beads_cfg.mode == "server":
215
+ try:
216
+ run(["bd", "dolt", "test"], cwd=brainspace, check=True, timeout=15)
217
+ findings.append(Finding("ok", "beads server reachable"))
218
+ except subprocess.CalledProcessError as exc:
219
+ detail = (exc.stderr or exc.stdout or "").strip().splitlines()
220
+ msg = detail[-1] if detail else "bd dolt test failed"
221
+ findings.append(Finding("error", f"beads server unreachable: {msg}",
222
+ "check beads.server.host in dotbrain.yaml"))
223
+ except FileNotFoundError:
224
+ findings.append(Finding("error", "bd not found; skipping beads connectivity check",
225
+ "install bd to check beads server connectivity"))
226
+ except subprocess.TimeoutExpired:
227
+ findings.append(Finding("error", "beads server timed out",
228
+ "check connectivity to beads server"))
229
+ else:
230
+ findings.append(Finding("ok", f"beads mode: {beads_cfg.mode}"))
231
+
232
+ try:
233
+ result = run(["bd", "-C", str(brainspace), "ready"], cwd=brainspace, check=False, timeout=15)
234
+ if result.returncode != 0:
235
+ msg = (result.stderr or "").strip().splitlines()
236
+ snippet = msg[-1] if msg else "bd ready returned non-zero"
237
+ findings.append(Finding("warn", f"bd ready failed: {snippet}",
238
+ "beads tracker may need attention: bd doctor, bd dolt pull"))
239
+ else:
240
+ findings.append(Finding("ok", "bd ready responds"))
241
+ except FileNotFoundError:
242
+ findings.append(Finding("error", "bd not found; skipping beads check"))
243
+
244
+ return findings
245
+
246
+
247
+ # --------------------------------------------------------------------------- orchestration
248
+
249
+
250
+ def run_doctor(
251
+ dotbrain_home: Path, home: Path | None = None, *, run: Runner = _default_run,
252
+ ) -> DoctorReport:
253
+ home = Path(home) if home is not None else Path.home()
254
+ root = dotbrain_home.resolve()
255
+
256
+ report = DoctorReport(
257
+ machine=_check_machine(root, home),
258
+ )
259
+
260
+ brainspaces = paths.brainspaces(root)
261
+ if not brainspaces:
262
+ report.machine.append(Finding("warn", f"no Brainspaces in {paths.data_dir(root).name}/",
263
+ "create a project with 'dotbrain wire'"))
264
+ return report
265
+
266
+ for brainspace in brainspaces:
267
+ name = brainspace.name
268
+ findings: list[Finding] = []
269
+
270
+ wiring_findings = _check_project_wiring(brainspace, root)
271
+ findings += wiring_findings
272
+
273
+ if shutil.which("bd"):
274
+ findings += _check_beads_state(brainspace, name, root, run=run)
275
+
276
+ if findings:
277
+ report.projects[name] = findings
278
+
279
+ return report
dotbrain/hooks.py ADDED
@@ -0,0 +1,84 @@
1
+ """SessionStart hook: assemble a wired repo's Brain context.
2
+
3
+ Pure Python by design. This ran as a packaged bash script until the Windows chain
4
+ (PowerShell -> Python -> Git Bash) proved unreliable: locating a real ``bash`` behind
5
+ Windows' ``System32`` WSL launcher depends on how Git was installed, and when the
6
+ resolver picked the stub the script emitted nothing. Because the hook is fail-open,
7
+ that surfaced as a session with no Brain context and no error at all.
8
+
9
+ Only ``DOTBRAIN.md`` is injected. dotbrain owns that file, so its size is bounded and
10
+ guarded by a test. The project's own ``.brain/AGENTS.md`` is deliberately left out: it
11
+ grows with the project and would eventually cross the payload limit, at which point the
12
+ runtime spills stdout to a file and the model receives none of it — including the
13
+ convention. ``DOTBRAIN.md`` carries a rule telling the agent to read it instead.
14
+
15
+ Output is assembled as bytes and written to ``stdout.buffer`` so the payload is exactly
16
+ what ``cat`` produced: no newline translation on Windows, no re-encoding of Brain files.
17
+
18
+ Beads context lives in project hooks (``bd prime --hook-json`` on Claude Code,
19
+ ``bd codex-hook SessionStart`` on Codex), so this hook does not set ``BEADS_DIR``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import BinaryIO
28
+
29
+ CONVENTION_HEADING = "## dotbrain convention"
30
+
31
+ # Hook stdout over this many bytes is spilled to a file and never reaches the model.
32
+ # Measured on Claude Code: 9,961 bytes delivered, 10,010 did not.
33
+ PAYLOAD_LIMIT = 10_000
34
+
35
+
36
+ def repo_root(cwd: Path | None = None) -> Path | None:
37
+ """Return the enclosing git repo root, or None outside one (and with no git)."""
38
+
39
+ try:
40
+ completed = subprocess.run(
41
+ ["git", "rev-parse", "--show-toplevel"],
42
+ cwd=cwd,
43
+ capture_output=True,
44
+ check=False,
45
+ encoding="utf-8",
46
+ )
47
+ except OSError:
48
+ return None
49
+ if completed.returncode != 0:
50
+ return None
51
+ top = (completed.stdout or "").strip()
52
+ return Path(top) if top else None
53
+
54
+
55
+ def brain_context(cwd: Path | None = None) -> bytes:
56
+ """Build the SessionStart payload for a wired repo; empty bytes when there is no Brain."""
57
+
58
+ root = repo_root(cwd)
59
+ if root is None:
60
+ return b""
61
+ convention = root / ".brain" / "DOTBRAIN.md"
62
+ if not convention.is_file():
63
+ return b""
64
+
65
+ heading = (CONVENTION_HEADING + "\n\n").encode("utf-8")
66
+ return heading + convention.read_bytes() + b"\n\n"
67
+
68
+
69
+ def emit_brain_context(stream: BinaryIO | None = None, cwd: Path | None = None) -> None:
70
+ """Write the payload to a binary stream. Fail-open: never raises, never exits nonzero."""
71
+
72
+ target = stream if stream is not None else sys.stdout.buffer
73
+ try:
74
+ payload = brain_context(cwd)
75
+ except OSError:
76
+ return
77
+ if not payload:
78
+ return
79
+ try:
80
+ target.write(payload)
81
+ target.flush()
82
+ except (OSError, ValueError):
83
+ # stdout closed or EPIPE at hook exit must not surface as a hook failure.
84
+ return
dotbrain/migrate.py ADDED
@@ -0,0 +1,304 @@
1
+ """Migrate a project's beads tracker from embedded Dolt to a remote sql-server.
2
+
3
+ dotbrain adopters start local-by-default: ``dotbrain wire`` initialises beads with an embedded
4
+ Dolt engine. Moving an already-wired brain onto a shared sql-server has to preserve the full
5
+ Dolt commit graph, so this uses ``bd backup`` (Dolt-native, history-preserving) rather than the
6
+ ``bd export`` JSONL path, which drops branches/history.
7
+
8
+ Per-project sequence (all ``bd`` calls run with ``cwd=<brainspace>`` and ``_beads_env(brainspace)``):
9
+
10
+ 1. ``bd stats --json`` -> capture ``summary.total_issues`` (pre_count)
11
+ 2. ``bd backup init <backup-dir>``
12
+ 3. ``bd backup sync`` -> full Dolt backup of the embedded db
13
+ 4. ``bd init --server --external ... --reinit-local --destroy-token DESTROY-<prefix>``
14
+ -> repoint .beads at the server (fresh empty db)
15
+ 5. ``bd backup restore --force <backup-dir>`` -> load full history into the server db
16
+ 6. ``bd dolt test`` -> connection sanity
17
+ 7. ``bd stats --json`` -> post_count; assert it matches pre_count
18
+
19
+ The embedded ``.beads/embeddeddolt`` and the backup dir are kept by default as the rollback path;
20
+ cleanup stays manual until a migration's history depth is verified on the server.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from dotbrain import config, paths
30
+ from dotbrain.beads import (
31
+ Runner,
32
+ _bd_init_args,
33
+ _beads_env,
34
+ _default_run,
35
+ _hide_root_beads,
36
+ _restore_root_beads,
37
+ normalize_server_beads_metadata,
38
+ )
39
+
40
+
41
+ # --------------------------------------------------------------------------- pure helpers
42
+
43
+
44
+ def destroy_token(project: str) -> str:
45
+ """The non-interactive ``bd init`` confirmation token for re-initialising over local data."""
46
+ return f"DESTROY-{project}"
47
+
48
+
49
+ def backup_dir_for(brainspace: Path) -> Path:
50
+ """Backup destination, kept outside tracked project Brainspaces."""
51
+ brainspace = Path(brainspace)
52
+ return brainspace.parent.parent / "backups" / "beads" / brainspace.name
53
+
54
+
55
+ def parse_total_issues(stdout: str) -> int | None:
56
+ """Pull ``summary.total_issues`` out of ``bd stats --json``; None when it can't be parsed."""
57
+ try:
58
+ return int(json.loads(stdout)["summary"]["total_issues"])
59
+ except (ValueError, KeyError, TypeError):
60
+ return None
61
+
62
+
63
+ def beads_mode(brainspace: Path) -> str:
64
+ """Classify a Brainspace's beads backend: ``embedded`` | ``server`` | ``none`` | ``unknown``.
65
+
66
+ Reads ``.beads/metadata.json`` directly (the same file bootstrap inspects); avoids shelling out.
67
+ """
68
+ metadata = Path(brainspace) / ".beads" / "metadata.json"
69
+ if not (Path(brainspace) / ".beads").is_dir():
70
+ return "none"
71
+ if not metadata.is_file():
72
+ return "unknown"
73
+ try:
74
+ data = json.loads(metadata.read_text(encoding="utf-8"))
75
+ except (ValueError, OSError):
76
+ return "unknown"
77
+ mode = str(data.get("dolt_mode", "")).strip()
78
+ if mode == "embedded":
79
+ return "embedded"
80
+ if mode in {"server", "external"}:
81
+ return "server"
82
+ return "unknown"
83
+
84
+
85
+ # --------------------------------------------------------------------------- result type
86
+
87
+
88
+ @dataclass
89
+ class MigrationResult:
90
+ project: str = ""
91
+ brainspace: Path | None = None
92
+ status: str = "" # migrated|migrated-unverified|skipped-server|skipped-no-beads|skipped-unknown|aborted-count-mismatch|dry-run|failed
93
+ pre_count: int | None = None
94
+ post_count: int | None = None
95
+ logs: list[str] = field(default_factory=list)
96
+ warnings: list[str] = field(default_factory=list)
97
+ planned_commands: list[list[str]] = field(default_factory=list)
98
+
99
+
100
+ # --------------------------------------------------------------------------- orchestration
101
+
102
+
103
+ def _migration_argv(
104
+ project: str, database: str, host: str, port: str, user: str, backup: Path
105
+ ) -> list[list[str]]:
106
+ """The ordered bd command sequence for one embedded -> server migration."""
107
+ return [
108
+ ["bd", "stats", "--json"],
109
+ ["bd", "backup", "init", str(backup)],
110
+ ["bd", "backup", "sync"],
111
+ *_backup_server_copy_argv(host, backup),
112
+ _bd_init_args(
113
+ project, "", host, port, user, database or project,
114
+ reinit_local=True, destroy_token=destroy_token(project),
115
+ ),
116
+ ["bd", "backup", "restore", "--force", str(backup)],
117
+ ["bd", "dolt", "test"],
118
+ ["bd", "stats", "--json"],
119
+ ]
120
+
121
+
122
+ # Non-interactive SSH for the backup copy: the migration runs with no TTY, so a
123
+ # first-connect host-key prompt or an unreachable server would hang it forever.
124
+ # BatchMode disables prompts and ConnectTimeout bounds the wait, turning either
125
+ # case into a fast, surfaced error instead of a silent stall.
126
+ _SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]
127
+ _RSYNC_SSH = "ssh " + " ".join(_SSH_OPTS)
128
+
129
+
130
+ def _host_is_local(host: str) -> bool:
131
+ return host in {"", "localhost", "127.0.0.1", "::1"}
132
+
133
+
134
+ def _backup_server_copy_argv(host: str, backup: Path) -> list[list[str]]:
135
+ """Make a local file:// backup visible to a remote external Dolt server."""
136
+ if _host_is_local(host):
137
+ return []
138
+ return [
139
+ ["ssh", *_SSH_OPTS, host, "mkdir", "-p", str(backup.parent)],
140
+ ["rsync", "-a", "--delete", "-e", _RSYNC_SSH, f"{backup}/", f"{host}:{backup}/"],
141
+ ]
142
+
143
+
144
+ def migrate_project(
145
+ *,
146
+ dotbrain_home: Path,
147
+ project: str,
148
+ server_host: str,
149
+ server_port: str = "3307",
150
+ server_user: str = "beads",
151
+ database: str = "",
152
+ dry_run: bool = False,
153
+ run: Runner = _default_run,
154
+ ) -> MigrationResult:
155
+ dotbrain_home = Path(dotbrain_home)
156
+ brainspace = paths.brainspace(dotbrain_home, project)
157
+ result = MigrationResult(project=project, brainspace=brainspace)
158
+
159
+ mode = beads_mode(brainspace)
160
+ if mode == "server":
161
+ result.status = "skipped-server"
162
+ result.logs.append(f"{project}: already on a Dolt server; nothing to migrate")
163
+ return result
164
+ if mode == "none":
165
+ result.status = "skipped-no-beads"
166
+ result.logs.append(f"{project}: no .beads to migrate")
167
+ return result
168
+ if mode == "unknown":
169
+ result.status = "skipped-unknown"
170
+ result.warnings.append(
171
+ f"{project}: could not determine beads mode from metadata.json; skipping to avoid a "
172
+ "destructive re-init"
173
+ )
174
+ return result
175
+
176
+ database = database or project
177
+ backup = backup_dir_for(brainspace)
178
+ commands = _migration_argv(
179
+ project, database, server_host, server_port, server_user, backup
180
+ )
181
+ backup_copy_commands = _backup_server_copy_argv(server_host, backup)
182
+ init_args = commands[3 + len(backup_copy_commands)]
183
+
184
+ if dry_run:
185
+ result.status = "dry-run"
186
+ result.planned_commands = commands
187
+ result.logs.append(f"{project}: dry-run; {len(commands)} bd commands planned")
188
+ return result
189
+
190
+ env = _beads_env(brainspace)
191
+ hidden = _hide_root_beads(brainspace, dotbrain_home)
192
+ try:
193
+ stats_pre = run(["bd", "stats", "--json"], cwd=brainspace, env=env, check=True)
194
+ result.pre_count = parse_total_issues(stats_pre.stdout or "")
195
+
196
+ backup.parent.mkdir(parents=True, exist_ok=True)
197
+ run(["bd", "backup", "init", str(backup)], cwd=brainspace, env=env, check=True)
198
+ run(["bd", "backup", "sync"], cwd=brainspace, env=env, check=True)
199
+ for argv in backup_copy_commands:
200
+ run(argv, cwd=brainspace, env=env, check=True)
201
+ run(init_args, cwd=brainspace, env=env, check=True) # bd init --server ... --reinit-local
202
+ run(["bd", "backup", "restore", "--force", str(backup)], cwd=brainspace, env=env, check=True)
203
+ run(["bd", "dolt", "test"], cwd=brainspace, env=env, check=True)
204
+ # bd writes the legacy dolt_server_port into metadata.json; make the port file primary.
205
+ normalize_server_beads_metadata(brainspace / ".beads", server_port)
206
+
207
+ stats_post = run(["bd", "stats", "--json"], cwd=brainspace, env=env, check=True)
208
+ result.post_count = parse_total_issues(stats_post.stdout or "")
209
+ finally:
210
+ _restore_root_beads(hidden, dotbrain_home)
211
+
212
+ if result.pre_count is None or result.post_count is None:
213
+ result.status = "migrated-unverified"
214
+ result.warnings.append(
215
+ f"{project}: migrated, but could not verify issue count from bd stats; verify history "
216
+ "depth on the server before removing embedded data"
217
+ )
218
+ _record_migrated_backend(dotbrain_home, project, database, result)
219
+ return result
220
+ if result.post_count != result.pre_count:
221
+ result.status = "aborted-count-mismatch"
222
+ result.warnings.append(
223
+ f"{project}: post-restore count {result.post_count} != pre-migration "
224
+ f"{result.pre_count}; embedded data and backup left intact for rollback"
225
+ )
226
+ return result
227
+
228
+ result.status = "migrated"
229
+ result.logs.append(
230
+ f"{project}: migrated to {server_host}:{server_port}/{database} "
231
+ f"({result.post_count} issues); embedded data + backup kept for rollback"
232
+ )
233
+ _record_migrated_backend(dotbrain_home, project, database, result)
234
+ return result
235
+
236
+
237
+ def _record_migrated_backend(
238
+ dotbrain_home: Path, project: str, database: str, result: MigrationResult
239
+ ) -> None:
240
+ """The project is on the server now; its dotbrain.yaml entry must say so."""
241
+ if database != project:
242
+ log = config.record_project_beads(
243
+ dotbrain_home, project, config.ProjectBeads(mode="server", database=database)
244
+ )
245
+ else:
246
+ log = config.remove_project_beads(dotbrain_home, project)
247
+ if log:
248
+ result.logs.append(log)
249
+
250
+
251
+ def _looks_like_ssh_failure(exc: Exception) -> bool:
252
+ """True when the failed command was the ssh/rsync backup copy (a likely host-key issue)."""
253
+ cmd = getattr(exc, "cmd", None)
254
+ return bool(cmd) and cmd[0] in {"ssh", "rsync"}
255
+
256
+
257
+ def safe_migrate_project(**kwargs) -> MigrationResult:
258
+ """``migrate_project`` that turns any subprocess failure into a ``failed`` result.
259
+
260
+ Both the single-project CLI path and the ``--all`` sweep use this so a failed migration
261
+ reports a clean status and an actionable hint instead of crashing with a traceback. Accepts
262
+ the same keyword arguments as ``migrate_project``.
263
+ """
264
+ project = kwargs.get("project", "")
265
+ try:
266
+ return migrate_project(**kwargs)
267
+ except Exception as exc:
268
+ brainspace = paths.brainspace(Path(kwargs["dotbrain_home"]), project) if project else None
269
+ detail = str(exc)
270
+ stderr = (getattr(exc, "stderr", "") or "").strip()
271
+ if stderr:
272
+ detail = f"{detail}: {stderr}"
273
+ warnings = [f"{project}: migration failed: {detail}"]
274
+ if _looks_like_ssh_failure(exc):
275
+ warnings.append(
276
+ f"{project}: the Dolt server's SSH host key may be untrusted; trust it first "
277
+ "(e.g. `ssh-keyscan <host> >> ~/.ssh/known_hosts`) and retry"
278
+ )
279
+ return MigrationResult(project=project, brainspace=brainspace, status="failed", warnings=warnings)
280
+
281
+
282
+ def migrate_all(
283
+ *,
284
+ dotbrain_home: Path,
285
+ server_host: str,
286
+ server_port: str = "3307",
287
+ server_user: str = "beads",
288
+ dry_run: bool = False,
289
+ run: Runner = _default_run,
290
+ ) -> list[MigrationResult]:
291
+ """Migrate every embedded Brainspace; skip server/none ones; never abort the whole sweep."""
292
+ dotbrain_home = Path(dotbrain_home)
293
+ return [
294
+ safe_migrate_project(
295
+ dotbrain_home=dotbrain_home,
296
+ project=brainspace.name,
297
+ server_host=server_host,
298
+ server_port=server_port,
299
+ server_user=server_user,
300
+ dry_run=dry_run,
301
+ run=run,
302
+ )
303
+ for brainspace in paths.brainspaces(dotbrain_home)
304
+ ]