brainiac-cli 0.16.0__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 (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/doctor.py ADDED
@@ -0,0 +1,1161 @@
1
+ """``brain doctor`` (ADR-0005 Ruling 2, DV-02) — READ-ONLY health + version
2
+ table across every Brainiac surface.
3
+
4
+ Pure inspection: this module never writes a file, never calls a subprocess
5
+ that mutates state, and never reaches the network beyond a local
6
+ ``git rev-list`` against the already-cloned marketplace checkout (no fetch) —
7
+ EXCEPT the OPT-IN "PyPI registry drift" row (``run_doctor(registry_fetch=...)``
8
+ / ``brain doctor --check-registry``), which is skipped by default (``None``)
9
+ and even then only ever calls an injected fetcher, never touches the network
10
+ directly in a fixture test. Safe to run anywhere, any number of times.
11
+
12
+ Status classes (ADR-0005 Ruling 2): every row gets exactly one of
13
+ ``current | stale | unmanaged | manual-required | not-detectable | unknown``.
14
+ Only **scriptable REQUIRED** surfaces gate the process exit code — the
15
+ Desktop/Cowork plugin-skill store (surface 11) is always ``manual-required``
16
+ and never fails the run, otherwise `brain update`/CI could never go green
17
+ while an unscriptable surface stays stale.
18
+
19
+ Role-aware VM leg (2026-07-07 addendum, see docs/adr/0005-update-versioning-ux.md):
20
+ ``run_doctor()`` above assumes a full host checkout (pyproject SSOT, ~/.brainiac
21
+ venv, ~/.claude plugins, tools/workspace_registry.py). None of that exists on
22
+ the Cowork VM's staged zero-install copy — ``run_doctor_vm()`` covers the
23
+ surfaces the VM CAN see (engine stamp, skill bundles, snapshot, model cache,
24
+ maintain heartbeat) and lists the rest as not-detectable host-only surfaces,
25
+ never a crash and never a fake-green.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ from pathlib import Path
35
+ from typing import Any, Callable, Optional
36
+
37
+ CURRENT = "current"
38
+ STALE = "stale"
39
+ UNMANAGED = "unmanaged"
40
+ MANUAL_REQUIRED = "manual-required"
41
+ NOT_DETECTABLE = "not-detectable"
42
+ UNKNOWN = "unknown"
43
+
44
+ # Surfaces whose `stale`/`unknown` verdict gates the process exit code
45
+ # (ADR-0005 Ruling 2: "Only scriptable REQUIRED surfaces may hard-fail").
46
+ _GATING_STATUSES = {STALE, UNKNOWN}
47
+
48
+
49
+ def _version_key(v: str):
50
+ """``packaging.version.Version`` when available, else an integer-tuple
51
+ fallback over the leading ``X.Y.Z`` digits (same semantics on the
52
+ constrained semver shape this codebase uses — never a naive string
53
+ compare, which fails at 0.9.1 -> 0.10.0). ponytail: no hard dependency —
54
+ packaging is a transitive install today, not a declared one."""
55
+ try:
56
+ from packaging.version import Version
57
+
58
+ return Version(v)
59
+ except Exception:
60
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)", v)
61
+ if m:
62
+ return tuple(int(x) for x in m.groups())
63
+ return (0, 0, 0)
64
+
65
+
66
+ def _compare(a: str, b: str) -> int:
67
+ """-1 / 0 / 1 for a<b / a==b / a>b, tolerant of non-semver strings."""
68
+ ka, kb = _version_key(a), _version_key(b)
69
+ try:
70
+ if ka < kb:
71
+ return -1
72
+ if ka > kb:
73
+ return 1
74
+ return 0
75
+ except TypeError: # mixed Version/tuple types after a parse failure
76
+ sa, sb = str(a), str(b)
77
+ return -1 if sa < sb else (1 if sa > sb else 0)
78
+
79
+
80
+ def _row(
81
+ surface: str,
82
+ status: str,
83
+ detail: str,
84
+ *,
85
+ remediation: Optional[str] = None,
86
+ raw: Optional[dict] = None,
87
+ ) -> dict:
88
+ return {
89
+ "surface": surface,
90
+ "status": status,
91
+ "detail": detail,
92
+ "remediation": remediation,
93
+ "raw": raw or {},
94
+ }
95
+
96
+
97
+ def _read_json(path: Path) -> Optional[dict]:
98
+ try:
99
+ return json.loads(path.read_text(encoding="utf-8"))
100
+ except Exception:
101
+ return None
102
+
103
+
104
+ # --------------------------------------------------------------------------
105
+ # Surface 1 — Version SSOT (pyproject.toml)
106
+ # --------------------------------------------------------------------------
107
+
108
+ def _ssot_version(repo_root: Path) -> Optional[str]:
109
+ pyproject = repo_root / "pyproject.toml"
110
+ try:
111
+ text = pyproject.read_text(encoding="utf-8")
112
+ except Exception:
113
+ return None
114
+ m = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
115
+ return m.group(1) if m else None
116
+
117
+
118
+ # --------------------------------------------------------------------------
119
+ # Surface 2 — committed src/brain/_version.py stamp
120
+ # --------------------------------------------------------------------------
121
+
122
+ def check_committed_stamp(repo_root: Path, ssot: str) -> dict:
123
+ stamp_path = repo_root / "src" / "brain" / "_version.py"
124
+ if not stamp_path.exists():
125
+ return _row("Committed stamp (src/brain/_version.py)", UNKNOWN,
126
+ "stamp file missing",
127
+ remediation="python tools/package_clients.py")
128
+ text = stamp_path.read_text(encoding="utf-8")
129
+ m = re.search(r'(?m)^__version__ = "([^"]+)"$', text)
130
+ if not m:
131
+ return _row("Committed stamp (src/brain/_version.py)", UNKNOWN,
132
+ "no __version__ line found",
133
+ remediation="python tools/package_clients.py")
134
+ stamped = m.group(1)
135
+ if stamped == ssot:
136
+ return _row("Committed stamp (src/brain/_version.py)", CURRENT,
137
+ f"{stamped} == SSOT {ssot}", raw={"stamped": stamped})
138
+ return _row("Committed stamp (src/brain/_version.py)", STALE,
139
+ f"{stamped} != SSOT {ssot}",
140
+ remediation="python tools/package_clients.py",
141
+ raw={"stamped": stamped})
142
+
143
+
144
+ # --------------------------------------------------------------------------
145
+ # Install channel detection (PYP-04c). Post-S07, a host install may land via
146
+ # any of four channels — the legacy editable dev checkout (`~/.brainiac/venv`,
147
+ # pre-PyPI / --dev / offline), or one of the three PyPI channels `install.sh`/
148
+ # `install.ps1` try in order: `uv tool install`, `pipx install`,
149
+ # `pip install --user`. Detection is a pure, offline path-substring heuristic
150
+ # (ponytail: good enough for a doctor hint, never a hard guarantee) so it
151
+ # stays fully unit-testable with a fabricated Path — no real PATH probing
152
+ # inside the pure function itself; callers resolve the live `brain` binary
153
+ # and pass it in.
154
+ # --------------------------------------------------------------------------
155
+
156
+ CHANNEL_EDITABLE = "editable-checkout"
157
+ CHANNEL_PYPI_UV = "pypi-uv"
158
+ CHANNEL_PIPX = "pipx"
159
+ CHANNEL_PIP_USER = "pip-user"
160
+ CHANNEL_UNKNOWN = "unknown"
161
+
162
+ # The command that moves each channel's installed version forward — the
163
+ # PACKAGE name (brainiac-cli), never the bare `uvx <pypi-name>` form (that
164
+ # fails when the console command, `brain`, differs from the distribution
165
+ # name). Editable-checkout has no single command (checkout-path-dependent);
166
+ # `/brainiac-update` resolves it.
167
+ _CHANNEL_UPGRADE_CMD = {
168
+ CHANNEL_PYPI_UV: "uv tool upgrade brainiac-cli",
169
+ CHANNEL_PIPX: "pipx upgrade brainiac-cli",
170
+ CHANNEL_PIP_USER: "python3 -m pip install --user --upgrade 'brainiac-cli[mcp]'",
171
+ CHANNEL_EDITABLE: "git pull in the checkout, then: pip install --upgrade -e '<checkout>[mcp]'",
172
+ }
173
+
174
+
175
+ def detect_install_channel(brain_bin: Optional[Path]) -> str:
176
+ """Best-effort, offline channel classification from a resolved `brain`
177
+ executable path. Pure function — no PATH/network probing here."""
178
+ if brain_bin is None:
179
+ return CHANNEL_UNKNOWN
180
+ p = str(brain_bin)
181
+ if re.search(r"\.brainiac[/\\]+venv", p):
182
+ return CHANNEL_EDITABLE
183
+ if re.search(r"[/\\]uv[/\\]tools[/\\]", p) or re.search(r"[/\\]uv[/\\]bin[/\\]", p):
184
+ return CHANNEL_PYPI_UV
185
+ if "pipx" in p:
186
+ return CHANNEL_PIPX
187
+ return CHANNEL_PIP_USER
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # Surface 3 — host engine install (channel-aware: editable dev checkout OR
192
+ # one of the three PyPI channels — PYP-04). ``resolved_brain`` is the live
193
+ # PATH-resolved `brain` binary, passed in by ``run_doctor()`` (never resolved
194
+ # inside this pure-ish function, so tests stay deterministic regardless of
195
+ # what's on the live PATH — see test_host_venv_* in tests/test_doctor.py).
196
+ # --------------------------------------------------------------------------
197
+
198
+ def check_host_venv(brainiac_home: Path, ssot: str, resolved_brain: Optional[Path] = None) -> dict:
199
+ legacy_bin = brainiac_home / "venv" / "bin" / "brain"
200
+ brain_bin = legacy_bin if legacy_bin.exists() else resolved_brain
201
+ if brain_bin is None or not Path(brain_bin).exists():
202
+ return _row("Host engine venv", NOT_DETECTABLE,
203
+ f"no `brain` found (legacy venv {legacy_bin}, or on PATH)",
204
+ remediation="/brainiac-install")
205
+ channel = detect_install_channel(Path(brain_bin))
206
+ try:
207
+ out = subprocess.run(
208
+ [str(brain_bin), "--version"], capture_output=True, text=True, timeout=15,
209
+ )
210
+ except Exception as exc:
211
+ return _row("Host engine venv", UNKNOWN, f"{type(exc).__name__}: {exc}",
212
+ raw={"channel": channel})
213
+ text = (out.stdout or out.stderr or "").strip()
214
+ m = re.search(r"(\d+\.\d+\.\d+\S*)", text)
215
+ installed = m.group(1) if m else text
216
+ if not installed:
217
+ return _row("Host engine venv", UNKNOWN, "empty --version output", raw={"channel": channel})
218
+ if installed == ssot:
219
+ return _row("Host engine venv", CURRENT, f"{installed} == SSOT {ssot} (channel: {channel})",
220
+ raw={"installed": installed, "channel": channel})
221
+ return _row("Host engine venv", STALE, f"installed {installed} != SSOT {ssot} (channel: {channel})",
222
+ remediation="/brainiac-update", raw={"installed": installed, "channel": channel})
223
+
224
+
225
+ # --------------------------------------------------------------------------
226
+ # Surface 4 — dist/COMPAT
227
+ # --------------------------------------------------------------------------
228
+
229
+ def check_dist_compat(repo_root: Path, ssot: str) -> dict:
230
+ compat_path = repo_root / "dist" / "COMPAT"
231
+ if not compat_path.exists():
232
+ return _row("dist/COMPAT marker", NOT_DETECTABLE,
233
+ "dist/COMPAT not found (never packaged here)",
234
+ remediation="python tools/package_clients.py")
235
+ marker = compat_path.read_text(encoding="utf-8").strip()
236
+ if marker == ssot:
237
+ return _row("dist/COMPAT marker", CURRENT, f"{marker} == SSOT {ssot}",
238
+ raw={"marker": marker})
239
+ # dist/COMPAT is gitignored (Context, ADR-0005) — a `git pull` never
240
+ # refreshes it, so `stale` here means "regenerate", not "investigate".
241
+ return _row("dist/COMPAT marker", STALE, f"{marker} != SSOT {ssot} (gitignored — regenerate)",
242
+ remediation="python tools/package_clients.py", raw={"marker": marker})
243
+
244
+
245
+ # --------------------------------------------------------------------------
246
+ # Surface 5 — CLI plugin manifests (plugins/*/.claude-plugin/plugin.json)
247
+ # --------------------------------------------------------------------------
248
+
249
+ PLUGIN_NAMES = ("brainiac-manager", "brainiac-kernel", "brainiac-extras")
250
+
251
+
252
+ def check_plugin_manifests(repo_root: Path, ssot: str) -> list[dict]:
253
+ rows = []
254
+ for pname in PLUGIN_NAMES:
255
+ pjson = repo_root / "plugins" / pname / ".claude-plugin" / "plugin.json"
256
+ surface = f"Plugin manifest ({pname})"
257
+ data = _read_json(pjson)
258
+ if data is None:
259
+ rows.append(_row(surface, NOT_DETECTABLE, f"{pjson} missing/unparseable",
260
+ remediation="python tools/package_clients.py"))
261
+ continue
262
+ pv = data.get("version")
263
+ if pv == ssot:
264
+ rows.append(_row(surface, CURRENT, f"{pv} == SSOT {ssot}", raw={"version": pv}))
265
+ else:
266
+ rows.append(_row(surface, STALE, f"{pv} != SSOT {ssot}",
267
+ remediation="python tools/package_clients.py", raw={"version": pv}))
268
+ return rows
269
+
270
+
271
+ # --------------------------------------------------------------------------
272
+ # Surface 7 — installed Claude Code CLI plugins (best-effort, manual-required
273
+ # only when literally not locatable; otherwise scriptable-best-effort per
274
+ # Ruling 2 row 7 — stale/current are still meaningful here since the
275
+ # marketplace + installed_plugins.json are both local files, no network).
276
+ # --------------------------------------------------------------------------
277
+
278
+ def check_installed_cli_plugins(
279
+ claude_home: Path, ssot: str, marketplace_name: str = "brainiac",
280
+ ) -> list[dict]:
281
+ rows = []
282
+ marketplace_dir = claude_home / "plugins" / "marketplaces" / marketplace_name
283
+ for pname in PLUGIN_NAMES:
284
+ surface = f"Installed CLI plugin ({pname})"
285
+ mkt_json = marketplace_dir / "plugins" / pname / ".claude-plugin" / "plugin.json"
286
+ mkt_data = _read_json(mkt_json)
287
+ if mkt_data is None:
288
+ rows.append(_row(surface, NOT_DETECTABLE,
289
+ f"marketplace copy not found at {mkt_json}",
290
+ remediation="/plugin marketplace add Autopsias/brainiac"))
291
+ continue
292
+ mkt_version = mkt_data.get("version")
293
+ installed_json = claude_home / "plugins" / "installed_plugins.json"
294
+ installed_data = _read_json(installed_json) or {}
295
+ plugin_entries = (installed_data.get("plugins") or {}).get(f"{pname}@{marketplace_name}")
296
+ if not plugin_entries:
297
+ rows.append(_row(surface, NOT_DETECTABLE,
298
+ f"not installed (marketplace has {mkt_version})",
299
+ remediation=f"/plugin install {pname}@{marketplace_name}"))
300
+ continue
301
+ # installed_plugins.json version field is a cache-dir label, not always
302
+ # semver (it can be a git sha for github-sourced plugins) — read the
303
+ # REAL version from the plugin.json at the recorded installPath, the
304
+ # same on-disk contract as the marketplace copy.
305
+ entry = plugin_entries[0] if isinstance(plugin_entries, list) else plugin_entries
306
+ install_path = entry.get("installPath") if isinstance(entry, dict) else None
307
+ installed_version = None
308
+ if install_path:
309
+ installed_pjson = _read_json(Path(install_path) / ".claude-plugin" / "plugin.json")
310
+ if installed_pjson:
311
+ installed_version = installed_pjson.get("version")
312
+ if installed_version is None:
313
+ rows.append(_row(surface, UNKNOWN,
314
+ f"installed but version unreadable at {install_path}"))
315
+ continue
316
+ cmp_ = _compare(installed_version, mkt_version or "")
317
+ if cmp_ == 0:
318
+ rows.append(_row(surface, CURRENT, f"installed {installed_version} == marketplace {mkt_version}",
319
+ raw={"installed": installed_version, "marketplace": mkt_version}))
320
+ elif cmp_ < 0:
321
+ rows.append(_row(surface, STALE, f"installed {installed_version} < marketplace {mkt_version}",
322
+ remediation=f"/plugin update {pname}@{marketplace_name}",
323
+ raw={"installed": installed_version, "marketplace": mkt_version}))
324
+ else:
325
+ # Downgrade condition (Ruling 3 / ADR-0004 Ruling 5): installed >
326
+ # marketplace, e.g. a stale 1.x line meeting a reconciled 0.9.x.
327
+ # Report the RAW triple; never assert "regression" — the human/
328
+ # update-skill interprets it (blindspot hardening).
329
+ rows.append(_row(surface, STALE,
330
+ f"installed {installed_version} > marketplace {mkt_version} "
331
+ "(reconciliation downgrade — see ADR-0004 Ruling 5 / ADR-0005 Ruling 3)",
332
+ remediation=f"/plugin uninstall {pname}@{marketplace_name} "
333
+ f"&& /plugin install {pname}@{marketplace_name}",
334
+ raw={"installed": installed_version, "marketplace": mkt_version}))
335
+ return rows
336
+
337
+
338
+ # --------------------------------------------------------------------------
339
+ # Surface — stale-name plugin/marketplace install (NAM-03). Anyone who
340
+ # installed before the profile-a-marketplace/profile-a-kernel/profile-a-extras
341
+ # -> brainiac/brainiac-kernel/brainiac-extras rename has old names registered
342
+ # in known_marketplaces.json / installed_plugins.json. This is a
343
+ # plugin-INDEPENDENT surface (pure Python reading Claude Code's own state
344
+ # files) — it survives even if the plugin surface itself is broken, so the
345
+ # fix instructions here are the recovery-of-last-resort, not just a nicety.
346
+ # --------------------------------------------------------------------------
347
+
348
+ OLD_MARKETPLACE_NAME = "profile-a-marketplace"
349
+ OLD_TO_NEW_PLUGIN_NAMES = {
350
+ "profile-a-kernel": "brainiac-kernel",
351
+ "profile-a-extras": "brainiac-extras",
352
+ }
353
+ # The verbatim 2-command recovery (also in README's Updating section + the
354
+ # CHANGELOG rename entry) — add-new-before-remove-old, never the reverse.
355
+ STALE_NAME_RECOVERY = (
356
+ "claude plugin marketplace add Autopsias/brainiac && "
357
+ "claude plugin install brainiac-manager@brainiac # then run /brainiac-update "
358
+ "to finish migrating off the old names"
359
+ )
360
+
361
+
362
+ def check_stale_name_plugins(claude_home: Path) -> list[dict]:
363
+ rows = []
364
+ surface = "Stale-name plugin/marketplace install"
365
+ # known_marketplaces.json is a FLAT dict keyed by marketplace name
366
+ # (verified on-machine 2026-07-11: {"<name>": {"source": ..., "installLocation": ...}, ...}
367
+ # — no "marketplaces" wrapper key).
368
+ known_mkt = _read_json(claude_home / "plugins" / "known_marketplaces.json") or {}
369
+ installed = _read_json(claude_home / "plugins" / "installed_plugins.json") or {}
370
+ has_old_marketplace = bool(
371
+ isinstance(known_mkt, dict) and OLD_MARKETPLACE_NAME in known_mkt
372
+ )
373
+ old_plugin_specs = [
374
+ spec for spec in (installed.get("plugins") or {})
375
+ if isinstance(spec, str) and spec.split("@", 1)[0] in OLD_TO_NEW_PLUGIN_NAMES
376
+ ]
377
+ if not has_old_marketplace and not old_plugin_specs:
378
+ rows.append(_row(surface, NOT_DETECTABLE,
379
+ "no old-name marketplace or plugin registrations found"))
380
+ return rows
381
+ found = []
382
+ if has_old_marketplace:
383
+ found.append(f"marketplace '{OLD_MARKETPLACE_NAME}'")
384
+ found.extend(f"plugin '{spec}'" for spec in old_plugin_specs)
385
+ rows.append(_row(surface, STALE,
386
+ f"old-name registration(s) found: {', '.join(found)}",
387
+ remediation=STALE_NAME_RECOVERY,
388
+ raw={"old_marketplace": has_old_marketplace, "old_plugins": old_plugin_specs}))
389
+ return rows
390
+
391
+
392
+ # --------------------------------------------------------------------------
393
+ # Surface — Desktop MCP registration collision (SUI-03 hardening addendum).
394
+ # A user can end up with BOTH the .mcpb-installed extension (Settings ->
395
+ # Extensions -> Advanced -> Install Extension, or double-click) AND a
396
+ # claude_desktop_config.json `mcpServers` stanza (written by `brain connect
397
+ # --client claude-desktop`) registering brainiac's MCP server at once —
398
+ # Desktop does not reconcile the two, so this is a "pick ONE" hygiene
399
+ # warning, not a version-staleness problem: UNMANAGED, never gates the exit
400
+ # code (ADR-0005 Ruling 2 — only STALE/UNKNOWN gate).
401
+ #
402
+ # Ground-truthed on-machine 2026-07-11 (S10 recon): Desktop's
403
+ # ``extensions-installations.json`` is ``{"extensions": {<id>: {"manifest":
404
+ # {"name": ..., ...}, ...}}}`` — matched on ``manifest.name`` rather than the
405
+ # ``id`` key, since built-in directory extensions use an ``ant.dir.*`` id
406
+ # scheme that a locally side-loaded .mcpb won't share.
407
+ # --------------------------------------------------------------------------
408
+
409
+ def check_mcpb_desktop_collision(
410
+ app_support_dir: Path, config_path: Optional[Path] = None, name: str = "brainiac",
411
+ ) -> dict:
412
+ surface = "Desktop MCP registration (mcpb vs claude_desktop_config.json)"
413
+ if config_path is None:
414
+ # Reuse the SAME platform-aware resolver connect.py's own
415
+ # `brain connect --client claude-desktop` writer uses (win32/darwin/
416
+ # linux) instead of hardcoding the macOS path here too — a second,
417
+ # divergent macOS-only guess is exactly how this check went blind on
418
+ # Windows (%APPDATA%\Claude) despite the .mcpb itself supporting
419
+ # win32 (manifest.json compatibility.platforms).
420
+ from . import connect
421
+
422
+ config_path = connect.claude_desktop_config_path()
423
+ config_data = _read_json(config_path) or {}
424
+ config_present = bool(
425
+ isinstance(config_data, dict) and (config_data.get("mcpServers") or {}).get(name)
426
+ )
427
+
428
+ installations = _read_json(app_support_dir / "extensions-installations.json") or {}
429
+ exts = installations.get("extensions") if isinstance(installations, dict) else None
430
+ mcpb_present = False
431
+ if isinstance(exts, dict):
432
+ for entry in exts.values():
433
+ manifest = (entry or {}).get("manifest") or {}
434
+ if manifest.get("name") == name:
435
+ mcpb_present = True
436
+ break
437
+
438
+ if config_present and mcpb_present:
439
+ return _row(
440
+ surface, UNMANAGED,
441
+ f"BOTH registered: claude_desktop_config.json mcpServers.{name} AND "
442
+ "a .mcpb extension — Claude Desktop does not reconcile them, pick ONE",
443
+ remediation=(
444
+ "remove ONE: `brain connect --client claude-desktop --remove` "
445
+ f"(drops the config.json stanza) OR Claude Desktop -> Settings -> "
446
+ f"Extensions -> {name} -> Remove (drops the .mcpb)"),
447
+ raw={"config_present": config_present, "mcpb_present": mcpb_present})
448
+ if not app_support_dir.exists():
449
+ return _row(surface, NOT_DETECTABLE,
450
+ f"{app_support_dir} not found — Claude Desktop not installed here")
451
+ return _row(surface, CURRENT,
452
+ f"no collision (config.json entry: {config_present}, .mcpb extension: {mcpb_present})",
453
+ raw={"config_present": config_present, "mcpb_present": mcpb_present})
454
+
455
+
456
+ # --------------------------------------------------------------------------
457
+ # Surface 11 — Desktop / Cowork plugin-skill store (best-effort, ALWAYS
458
+ # manual-required, NEVER gates the exit code — ADR-0005 Ruling 2/4).
459
+ # --------------------------------------------------------------------------
460
+
461
+ def check_desktop_plugin_store(
462
+ app_support_dir: Path, ssot: str, plugin_dir_names: tuple[str, ...] = PLUGIN_NAMES,
463
+ ) -> list[dict]:
464
+ """Best-effort read of
465
+ ``.../local-agent-mode-sessions/<uuid>/<uuid>/rpm/plugin_*/.claude-plugin/plugin.json``.
466
+
467
+ The path carries a per-session UUID with no stable pointer to "the live
468
+ one" from outside that session, so this picks the most-recently-modified
469
+ candidate plugin.json per plugin name and labels the row accordingly
470
+ (HARDEN:consensus). If nothing is found at all it's `manual-required`
471
+ (not scriptable from here); if multiple candidates tie or none can be
472
+ confidently chosen it reports 'unknown (N candidate sessions)' rather than
473
+ inventing a version.
474
+ """
475
+ sessions_root = app_support_dir / "local-agent-mode-sessions"
476
+ rows = []
477
+ for pname in plugin_dir_names:
478
+ surface = f"Desktop/Cowork plugin store ({pname})"
479
+ if not sessions_root.exists():
480
+ rows.append(_row(surface, MANUAL_REQUIRED,
481
+ "no local-agent-mode-sessions dir found — best-effort, "
482
+ "verify manually in the Cowork/Desktop client",
483
+ remediation="Open Cowork/Desktop -> Plugins -> check for update"))
484
+ continue
485
+ candidates: list[tuple[float, Path]] = []
486
+ try:
487
+ for pjson in sessions_root.glob("*/*/rpm/plugin_*/.claude-plugin/plugin.json"):
488
+ data = _read_json(pjson)
489
+ if data and data.get("name") == pname:
490
+ candidates.append((pjson.stat().st_mtime, pjson))
491
+ except Exception:
492
+ candidates = []
493
+ if not candidates:
494
+ rows.append(_row(surface, MANUAL_REQUIRED,
495
+ "not found in any session dir — best-effort, "
496
+ "verify manually in the Cowork/Desktop client",
497
+ remediation="Open Cowork/Desktop -> Plugins -> check for update"))
498
+ continue
499
+ candidates.sort(key=lambda t: t[0], reverse=True)
500
+ newest_mtime, newest_path = candidates[0]
501
+ data = _read_json(newest_path) or {}
502
+ version = data.get("version")
503
+ if version is None:
504
+ rows.append(_row(surface, UNKNOWN, f"unknown ({len(candidates)} candidate sessions, unparseable)"))
505
+ continue
506
+ import datetime
507
+
508
+ mtime_str = datetime.datetime.fromtimestamp(newest_mtime).isoformat(timespec="seconds")
509
+ detail = (f"best-effort, last-seen (mtime {mtime_str}): version {version} "
510
+ f"(SSOT {ssot}); {len(candidates)} candidate session(s) found")
511
+ # Always manual-required (Ruling 2/4): never gates the exit code, no
512
+ # matter what the version comparison says. The remediation text still
513
+ # differentiates stale-vs-current so it points at the real fix: the
514
+ # CLI only DETECTS this surface (it structurally cannot invoke a Claude
515
+ # slash-command skill); in a Cowork session /skill-creator is what
516
+ # repackages + presents the skill for Save-and-Replace. /brainiac-update
517
+ # is host-only (refuses --role vm) so it is NOT the Cowork fix.
518
+ if _compare(str(version), ssot) < 0:
519
+ remediation = ("in a Cowork session use /skill-creator to repackage + "
520
+ "Save-and-Replace the stale skill(s); re-run brain doctor on "
521
+ "the host to confirm it took")
522
+ else:
523
+ remediation = "looks current — no action needed"
524
+ rows.append(_row(surface, MANUAL_REQUIRED, detail, remediation=remediation,
525
+ raw={"version": version, "candidates": len(candidates),
526
+ "newest_mtime": mtime_str}))
527
+ return rows
528
+
529
+
530
+ # --------------------------------------------------------------------------
531
+ # Surface 8 — staged Cowork workspaces (tools/workspace_registry.py entries)
532
+ # --------------------------------------------------------------------------
533
+
534
+ def _cowork_vault_dir(entry: dict) -> str:
535
+ """The dir a cowork-vm entry's `.brain` actually lives under: the
536
+ registry's ``vault_path`` — the same field ``cowork_workspace_install.sh``
537
+ treats as ``$VAULT`` and the Cowork VM reads. ``workspace_path`` is the
538
+ PARENT checkout dir; its own `.brain` (if any) is the unrelated host
539
+ stage — reading it here is exactly the false-green bug (a stale
540
+ cowork-vm engine at `vault_path/.brain` hid behind a current
541
+ `workspace_path/.brain`). Falls back to ``workspace_path`` only if
542
+ ``vault_path`` is absent (malformed/legacy entry)."""
543
+ return entry.get("vault_path") or entry.get("workspace_path", "")
544
+
545
+
546
+ def check_staged_workspaces(registry_entries: list[dict], ssot: str) -> list[dict]:
547
+ rows = []
548
+ for entry in registry_entries:
549
+ if entry.get("target") == "host":
550
+ continue # host entries ARE the checkout; surfaces 1-4 already cover it
551
+ vault_dir = _cowork_vault_dir(entry)
552
+ surface = f"Staged workspace ({vault_dir})"
553
+ stamp_path = Path(vault_dir) / ".brain" / "engine" / "brain" / "_version.py"
554
+ if not stamp_path.exists():
555
+ rows.append(_row(surface, NOT_DETECTABLE,
556
+ f"{stamp_path} not found — workspace may be gone or never staged",
557
+ remediation="/brainiac-cowork-setup"))
558
+ continue
559
+ text = stamp_path.read_text(encoding="utf-8")
560
+ m = re.search(r'(?m)^__version__ = "([^"]+)"$', text)
561
+ if not m:
562
+ rows.append(_row(surface, UNKNOWN, f"{stamp_path}: no __version__ line"))
563
+ continue
564
+ staged = m.group(1)
565
+ if staged == ssot:
566
+ rows.append(_row(surface, CURRENT, f"staged {staged} == SSOT {ssot}",
567
+ raw={"staged": staged}))
568
+ else:
569
+ rows.append(_row(surface, STALE, f"staged {staged} != SSOT {ssot}",
570
+ remediation="/brainiac-update", raw={"staged": staged}))
571
+ return rows
572
+
573
+
574
+ # --------------------------------------------------------------------------
575
+ # Surface — staged Cowork skill bundles (cw-02): the .brain/skills/*.skill
576
+ # zips landed by cowork_workspace_install.sh each carry a VERSION file
577
+ # (tools/package_clients.py build_cowork_zips). A separate row from the
578
+ # engine stamp above so a version-matched engine with a stale/missing skill
579
+ # bundle is still visible (best-effort — reads whichever zip is alphabetically
580
+ # first; every zip in one install pass is written from the same SSOT, so one
581
+ # representative sample is enough to catch drift).
582
+ # --------------------------------------------------------------------------
583
+
584
+ def check_staged_skill_bundles(registry_entries: list[dict], ssot: str) -> list[dict]:
585
+ import zipfile
586
+
587
+ rows = []
588
+ for entry in registry_entries:
589
+ if entry.get("target") == "host":
590
+ continue
591
+ vault_dir = _cowork_vault_dir(entry)
592
+ surface = f"Staged skill bundles ({vault_dir})"
593
+ skills_dir = Path(vault_dir) / ".brain" / "skills"
594
+ if not skills_dir.is_dir():
595
+ rows.append(_row(surface, NOT_DETECTABLE,
596
+ f"{skills_dir} not found — workspace may be gone or never staged",
597
+ remediation="tools/cowork_workspace_install.sh"))
598
+ continue
599
+ zips = sorted(skills_dir.glob("*.skill"))
600
+ if not zips:
601
+ rows.append(_row(surface, NOT_DETECTABLE, f"no .skill bundles found in {skills_dir}",
602
+ remediation="tools/cowork_workspace_install.sh"))
603
+ continue
604
+ sample = zips[0]
605
+ try:
606
+ with zipfile.ZipFile(sample) as zf:
607
+ version_member = f"{sample.stem}/VERSION"
608
+ if version_member not in zf.namelist():
609
+ rows.append(_row(surface, UNKNOWN,
610
+ f"{sample.name}: no VERSION marker (pre-cw-02 bundle?)",
611
+ remediation="tools/cowork_workspace_install.sh"))
612
+ continue
613
+ staged = zf.read(version_member).decode("utf-8").strip()
614
+ except (OSError, zipfile.BadZipFile) as exc:
615
+ rows.append(_row(surface, UNKNOWN, f"{sample.name}: unreadable ({exc})"))
616
+ continue
617
+ if staged == ssot:
618
+ rows.append(_row(surface, CURRENT, f"staged {staged} == SSOT {ssot} (sample: {sample.name})",
619
+ raw={"staged": staged}))
620
+ else:
621
+ rows.append(_row(surface, STALE, f"staged {staged} != SSOT {ssot} (sample: {sample.name})",
622
+ remediation="tools/cowork_workspace_install.sh (re-stage engine + skills)",
623
+ raw={"staged": staged}))
624
+ return rows
625
+
626
+
627
+ # --------------------------------------------------------------------------
628
+ # Surface 10 — index / snapshot schema (per staged workspace, if a snapshot
629
+ # dir exists there) — separate row from the version stamp so a version-match
630
+ # with a schema skew is still visible.
631
+ # --------------------------------------------------------------------------
632
+
633
+ def check_workspace_schema(registry_entries: list[dict], binary_schema_version: int) -> list[dict]:
634
+ rows = []
635
+ for entry in registry_entries:
636
+ if entry.get("target") == "host":
637
+ continue
638
+ vault_dir = _cowork_vault_dir(entry)
639
+ snap_meta = Path(vault_dir) / ".brain" / "snapshot" / "snapshot.manifest.json"
640
+ surface = f"Snapshot schema ({vault_dir})"
641
+ data = _read_json(snap_meta)
642
+ if data is None:
643
+ rows.append(_row(surface, NOT_DETECTABLE, f"{snap_meta} not found"))
644
+ continue
645
+ stored = data.get("schema_version")
646
+ try:
647
+ stored_int = int(stored)
648
+ except (TypeError, ValueError):
649
+ rows.append(_row(surface, UNKNOWN, f"schema_version unreadable: {stored!r}"))
650
+ continue
651
+ if stored_int == binary_schema_version:
652
+ rows.append(_row(surface, CURRENT, f"schema {stored_int} == binary {binary_schema_version}",
653
+ raw={"schema_version": stored_int}))
654
+ elif stored_int > binary_schema_version:
655
+ rows.append(_row(surface, STALE,
656
+ f"snapshot schema {stored_int} > binary {binary_schema_version} "
657
+ "(binary is OLDER than the snapshot — refresh the engine, don't rebuild down)",
658
+ remediation="/brainiac-update", raw={"schema_version": stored_int}))
659
+ else:
660
+ rows.append(_row(surface, STALE,
661
+ f"snapshot schema {stored_int} < binary {binary_schema_version} (stale snapshot)",
662
+ remediation="brain snapshot (on the host, then re-stage)",
663
+ raw={"schema_version": stored_int}))
664
+ return rows
665
+
666
+
667
+ # --------------------------------------------------------------------------
668
+ # Surface — marketplace CACHE freshness (local git rev-list only, no fetch).
669
+ # Deliberately separate from "published-marketplace freshness" per hardening:
670
+ # a local checkout that hasn't been refreshed must never be reported CURRENT
671
+ # just because it matches its own stale HEAD.
672
+ # --------------------------------------------------------------------------
673
+
674
+ def check_marketplace_cache(marketplace_dir: Path) -> dict:
675
+ surface = "Marketplace cache freshness"
676
+ if not (marketplace_dir / ".git").exists():
677
+ return _row(surface, NOT_DETECTABLE, f"{marketplace_dir} is not a git checkout")
678
+ try:
679
+ head = subprocess.run(
680
+ ["git", "-C", str(marketplace_dir), "rev-parse", "HEAD"],
681
+ capture_output=True, text=True, timeout=15,
682
+ ).stdout.strip()
683
+ upstream = subprocess.run(
684
+ ["git", "-C", str(marketplace_dir), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
685
+ capture_output=True, text=True, timeout=15,
686
+ ).stdout.strip()
687
+ if not upstream:
688
+ return _row(surface, NOT_DETECTABLE, "no upstream tracking branch configured", raw={"head": head})
689
+ behind = subprocess.run(
690
+ ["git", "-C", str(marketplace_dir), "rev-list", "--count", f"HEAD..{upstream}"],
691
+ capture_output=True, text=True, timeout=15,
692
+ )
693
+ if behind.returncode != 0:
694
+ return _row(surface, UNKNOWN, behind.stderr.strip() or "git rev-list failed")
695
+ count = int(behind.stdout.strip() or "0")
696
+ except Exception as exc:
697
+ return _row(surface, UNKNOWN, f"{type(exc).__name__}: {exc}")
698
+ # HARDEN:codex-HIGH — this is LOCAL cache state only (no fetch was run),
699
+ # so "0 commits behind the last-known origin ref" is NOT the same claim as
700
+ # "current vs what's actually published". Never collapse the two.
701
+ if count == 0:
702
+ return _row(surface, CURRENT,
703
+ "0 commits behind local cache of origin — cache not refreshed this run; "
704
+ "run `brain update`/`git fetch` to compare against published",
705
+ raw={"commits_behind_cache": 0})
706
+ return _row(surface, STALE,
707
+ f"{count} commit(s) behind local cache of origin (cache not refreshed — "
708
+ "run `brain update` to pull and compare against published)",
709
+ remediation="git -C <marketplace-dir> pull # or: /brainiac-update",
710
+ raw={"commits_behind_cache": count})
711
+
712
+
713
+ # --------------------------------------------------------------------------
714
+ # Registry-drift visibility (PyPI publish addendum). OPT-IN ONLY (see
715
+ # run_doctor's ``registry_fetch`` param) — this is the one surface allowed to
716
+ # touch the network, and even then only via an injected fetcher, a single
717
+ # cached HTTPS metadata read, never by default and never inside a fixture
718
+ # test. Compares three numbers: the repo's latest git release tag, the
719
+ # locally-installed engine version, and the latest published PyPI version.
720
+ # Degrades to NOT_DETECTABLE/UNKNOWN silently offline — never raises, never
721
+ # gates (informational: a human decides whether "marketplace ahead of
722
+ # published engine" matters right now).
723
+ # --------------------------------------------------------------------------
724
+
725
+ def _latest_git_tag(repo_root: Path) -> Optional[str]:
726
+ try:
727
+ out = subprocess.run(
728
+ ["git", "-C", str(repo_root), "tag", "--list", "v*", "--sort=-v:refname"],
729
+ capture_output=True, text=True, timeout=10,
730
+ )
731
+ except Exception:
732
+ return None
733
+ for line in (out.stdout or "").splitlines():
734
+ line = line.strip()
735
+ if line:
736
+ return line.lstrip("v")
737
+ return None
738
+
739
+
740
+ def fetch_pypi_latest_version(dist_name: str = "brainiac-cli", timeout: float = 3.0) -> Optional[str]:
741
+ """Real HTTPS fetcher — the one function in this module allowed to reach
742
+ the network, and only ever called when a caller explicitly opts in
743
+ (``brain doctor --check-registry``). Any failure (offline, DNS, 404
744
+ pre-publish) degrades to ``None``, never an exception."""
745
+ import urllib.request
746
+
747
+ url = f"https://pypi.org/pypi/{dist_name}/json"
748
+ try:
749
+ with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - fixed https host
750
+ data = json.loads(resp.read().decode("utf-8"))
751
+ return data.get("info", {}).get("version")
752
+ except Exception:
753
+ return None
754
+
755
+
756
+ def check_pypi_registry_drift(
757
+ repo_root: Path, installed_version: str, *, fetch: Callable[[], Optional[dict]],
758
+ ) -> dict:
759
+ """``fetch`` returns a dict ``{"pypi_version": str|None}`` (or None on
760
+ total failure) — injected so this stays testable without a live network
761
+ call; ``brain doctor --check-registry`` wires up a real fetcher built on
762
+ ``fetch_pypi_latest_version``."""
763
+ surface = "PyPI registry drift"
764
+ repo_tag = _latest_git_tag(repo_root)
765
+ try:
766
+ result = fetch() or {}
767
+ except Exception as exc:
768
+ return _row(surface, NOT_DETECTABLE, f"fetch failed: {type(exc).__name__}: {exc}",
769
+ raw={"repo_tag": repo_tag, "installed": installed_version})
770
+ pypi_version = result.get("pypi_version")
771
+ if pypi_version is None:
772
+ return _row(surface, NOT_DETECTABLE,
773
+ "no PyPI metadata (offline, or brainiac-cli not yet published — "
774
+ "use the clone/dev install until it is)",
775
+ raw={"repo_tag": repo_tag, "installed": installed_version})
776
+ detail = (f"repo tag {repo_tag or 'none'} / installed {installed_version} / "
777
+ f"PyPI latest {pypi_version}")
778
+ if _compare(repo_tag or "0.0.0", pypi_version) > 0 or _compare(installed_version, pypi_version) > 0:
779
+ return _row(surface, UNMANAGED,
780
+ f"{detail} — marketplace/skills are AHEAD of the published PyPI engine; "
781
+ "do not publish clean-room export docs referencing an unpublished version",
782
+ raw={"repo_tag": repo_tag, "installed": installed_version, "pypi": pypi_version})
783
+ return _row(surface, CURRENT if installed_version == pypi_version else UNMANAGED, detail,
784
+ raw={"repo_tag": repo_tag, "installed": installed_version, "pypi": pypi_version})
785
+
786
+
787
+ # --------------------------------------------------------------------------
788
+ # VM leg (role-aware doctor, 2026-07-07 addendum to ADR-0005 Ruling 2) — the
789
+ # Cowork VM only ever sees the staged zero-install copy
790
+ # (cowork_workspace_install.sh: src/brain -> .brain/engine/brain, plus
791
+ # .brain/{skills,snapshot,model,maintain-state.json}). None of the HOST-only
792
+ # surfaces above (venv, pyproject SSOT, ~/.claude plugins, marketplace clone,
793
+ # Desktop store, tools/workspace_registry.py) exist there. These checks read
794
+ # ONLY what the staged workspace itself carries.
795
+ # --------------------------------------------------------------------------
796
+
797
+ def looks_like_vm_stage(repo_root: Optional[Path] = None) -> bool:
798
+ """True when this engine copy structurally lacks the host-only inputs
799
+ (no ``tools/workspace_registry.py`` companion script, no ``pyproject.toml``
800
+ SSOT) — i.e. it is a staged zero-install copy, even when role wasn't
801
+ explicitly passed. The staged VM shim (``.brain/brain``) runs
802
+ ``python3 -m brain.cli "$@"`` directly and does not set ``$BRAIN_ROLE``, so
803
+ this structural fallback is what keeps a role-less VM invocation from
804
+ hitting the host-only code path."""
805
+ root = repo_root or Path(__file__).resolve().parent.parent.parent
806
+ return not (root / "tools" / "workspace_registry.py").exists() and _ssot_version(root) is None
807
+
808
+
809
+ def _read_version_stamp(path: Path) -> Optional[str]:
810
+ try:
811
+ text = path.read_text(encoding="utf-8")
812
+ except Exception:
813
+ return None
814
+ m = re.search(r'(?m)^__version__ = "([^"]+)"$', text)
815
+ return m.group(1) if m else None
816
+
817
+
818
+ def check_vm_engine_stamp(engine_version: str) -> dict:
819
+ surface = "Engine version (this staged copy)"
820
+ if engine_version.startswith("0.0.0"):
821
+ return _row(surface, STALE, f"brain.__version__ reads {engine_version!r} — stale/pre-stamp stage",
822
+ remediation="re-stage from the host: tools/cowork_workspace_install.sh",
823
+ raw={"version": engine_version})
824
+ return _row(surface, CURRENT, f"brain {engine_version}", raw={"version": engine_version})
825
+
826
+
827
+ def check_vm_snapshot(vault: Path) -> dict:
828
+ from . import config
829
+ from .snapshot import snapshot_status
830
+
831
+ surface = "Snapshot (read-only, .brain/snapshot)"
832
+ snap_dir = config.snapshot_dir(vault)
833
+ st = snapshot_status(snap_dir)
834
+ if st.get("snapshot") != "present":
835
+ return _row(surface, NOT_DETECTABLE, f"no snapshot published at {snap_dir}",
836
+ remediation="publish a snapshot on the host (`brain snapshot`) and re-sync the VM mount")
837
+ age_s = st.get("age_seconds") or 0.0
838
+ detail = (f"gen {st.get('generation')} age {st.get('age_human')} "
839
+ f"({st.get('notes')} notes / {st.get('chunks')} chunks)")
840
+ if age_s > 48 * 3600:
841
+ return _row(surface, STALE, f"{detail} — older than 48h",
842
+ remediation="publish a fresh snapshot on the host (`brain snapshot`) and re-sync the VM mount",
843
+ raw=st)
844
+ return _row(surface, CURRENT, detail, raw=st)
845
+
846
+
847
+ def check_vm_model_cache(vault: Path) -> dict:
848
+ from . import config
849
+
850
+ surface = "Model cache (.brain/model)"
851
+ model_dir = Path(os.environ.get("BRAIN_MODEL_CACHE") or (config.brain_runtime_dir(vault) / "model"))
852
+ if not model_dir.is_dir() or not any(model_dir.iterdir()):
853
+ return _row(surface, STALE,
854
+ f"{model_dir} missing/empty — the VM has no HF egress, so semantic search "
855
+ "silently falls back to hash embeddings without this",
856
+ remediation="re-stage from the host: tools/cowork_workspace_install.sh")
857
+ n_files = sum(1 for p in model_dir.rglob("*") if p.is_file())
858
+ return _row(surface, CURRENT, f"{model_dir} present ({n_files} file(s))")
859
+
860
+
861
+ def check_embedder_liveness() -> dict:
862
+ """Probe whether the LIVE runtime can produce real semantic embeddings, or
863
+ would silently degrade to the non-semantic HashEmbedder (DV-03, 2026-07-09).
864
+
865
+ This is the one health surface older `brain doctor` was structurally blind
866
+ to: version / schema / staging / model-files can ALL read green while
867
+ `search` returns random results because onnxruntime isn't importable in the
868
+ interpreter that actually runs `brain` (the exact Cowork-VM failure that
869
+ lost a retrieval eval to a hash fallback). Note this is distinct from
870
+ ``check_vm_model_cache`` — the model files can be present on disk yet the
871
+ runtime still unable to load them. Reads only process/env state: no model
872
+ load, no side effects, safe in the read-only doctor."""
873
+ from .embed import probe_auto_embedder
874
+
875
+ surface = "Semantic embedder (live runtime)"
876
+ state, backend = probe_auto_embedder()
877
+ if state == "real":
878
+ return _row(surface, CURRENT, f"real semantic embedder available ({backend})",
879
+ raw={"state": state, "backend": backend})
880
+ if state == "explicit-hash":
881
+ # Deliberate offline/test choice — never gates, never alarms.
882
+ return _row(surface, UNMANAGED,
883
+ "hash embedder selected explicitly ($BRAIN_EMBEDDER=hash) — "
884
+ "retrieval is non-semantic BY CHOICE, not a fault",
885
+ raw={"state": state, "backend": backend})
886
+ # implicit-hash — the silent random-search failure. Gate the exit code.
887
+ return _row(surface, STALE,
888
+ "NO real semantic embedder — the auto-path would fall back to the "
889
+ "non-semantic HashEmbedder, so `search` ranks with RANDOM vectors "
890
+ "against a real-model index",
891
+ remediation="install onnxruntime + tokenizers into the interpreter that "
892
+ "runs `brain` (the 'corporate' extras), or invoke the "
893
+ "onnxruntime-bundled frozen binary; then re-run `brain status`",
894
+ raw={"state": state, "backend": backend})
895
+
896
+
897
+ def check_vm_maintain_heartbeat(vault: Path) -> dict:
898
+ """VM-readable mirror of ``BrainCore._maintain_heartbeat_summary`` (the VM
899
+ can read the heartbeat file even though only the host ever runs
900
+ ``brain maintain``)."""
901
+ import datetime as _dt
902
+
903
+ from . import config
904
+
905
+ surface = "Maintain heartbeat (.brain/maintain-state.json)"
906
+ state = _read_json(config.maintain_state_path(vault))
907
+ if not state:
908
+ return _row(surface, NOT_DETECTABLE,
909
+ "no maintain-state.json yet — brain maintain (host-only ritual) has not run")
910
+ today = _dt.date.today()
911
+ stale, repeated = [], []
912
+ for branch, entry in state.items():
913
+ if not isinstance(entry, dict):
914
+ continue
915
+ last_run = entry.get("last_run")
916
+ age_hours: Optional[float] = None
917
+ if last_run:
918
+ try:
919
+ age_hours = (today - _dt.date.fromisoformat(last_run)).days * 24
920
+ except ValueError:
921
+ age_hours = None
922
+ if branch == "daily" and (entry.get("failed") or (age_hours is not None and age_hours > 48)):
923
+ stale.append(branch)
924
+ if int(entry.get("consecutive_failures", 0) or 0) >= 2:
925
+ repeated.append(branch)
926
+ if stale:
927
+ return _row(surface, STALE, f"stale branch(es): {stale}",
928
+ remediation="brain maintain runs host-side only — check the host's nightly scheduler")
929
+ if repeated:
930
+ return _row(surface, STALE, f"repeated-failure branch(es): {repeated}",
931
+ remediation="check the host's nightly maintenance logs")
932
+ return _row(surface, CURRENT, f"{len(state)} branch(es) tracked, none stale/repeatedly-failing")
933
+
934
+
935
+ # Host-only surfaces the VM leg structurally cannot check (never gate, never
936
+ # claimed as checked — ADR-0005 Ruling 2/4: a NOT_DETECTABLE row here, not a
937
+ # fake-green or a crash).
938
+ _HOST_ONLY_SURFACES = (
939
+ "Host engine venv (~/.brainiac/venv)",
940
+ "Version SSOT / dist/COMPAT (pyproject.toml, dist/)",
941
+ "Installed CLI plugins (~/.claude/plugins)",
942
+ "Marketplace cache freshness (~/.claude/plugins/marketplaces)",
943
+ "Desktop/Cowork plugin-skill store",
944
+ "Workspace registry (tools/workspace_registry.py)",
945
+ )
946
+
947
+
948
+ def run_doctor_vm(vault: Optional[str | os.PathLike[str]] = None) -> dict[str, Any]:
949
+ """Role-aware doctor for the Cowork VM leg — read-only, derived entirely
950
+ from what the staged workspace itself carries. Never raises: every
951
+ host-only import this needs is already isolated behind ``check_vm_*``
952
+ helpers that only touch the vault's own ``.brain/`` tree."""
953
+ from . import __version__ as engine_version
954
+ from . import config
955
+ from .index import SCHEMA_VERSION
956
+
957
+ vault_path = config.vault_root(vault)
958
+ entries = [{"vault_path": str(vault_path), "target": "vm"}]
959
+
960
+ rows: list[dict] = [check_vm_engine_stamp(engine_version)]
961
+ rows.extend(check_staged_skill_bundles(entries, engine_version))
962
+ rows.extend(check_workspace_schema(entries, SCHEMA_VERSION))
963
+ rows.append(check_vm_snapshot(vault_path))
964
+ rows.append(check_vm_model_cache(vault_path))
965
+ rows.append(check_embedder_liveness()) # DV-03: model files present ≠ embedder loads
966
+ rows.append(check_vm_maintain_heartbeat(vault_path))
967
+ rows.extend(_row(s, NOT_DETECTABLE,
968
+ "requires `brain doctor` on the host Mac — not checkable from this staged VM copy")
969
+ for s in _HOST_ONLY_SURFACES)
970
+
971
+ gating_stale = [r for r in rows if r["status"] in _GATING_STATUSES]
972
+ return {
973
+ "role": "vm",
974
+ "ssot_version": engine_version,
975
+ "rows": rows,
976
+ "ok": len(gating_stale) == 0,
977
+ "stale_count": len(gating_stale),
978
+ }
979
+
980
+
981
+ # --------------------------------------------------------------------------
982
+ # Orchestration
983
+ # --------------------------------------------------------------------------
984
+
985
+ def run_doctor(
986
+ *,
987
+ repo_root: Optional[Path] = None,
988
+ brainiac_home: Optional[Path] = None,
989
+ claude_home: Optional[Path] = None,
990
+ app_support_dir: Optional[Path] = None,
991
+ registry_entries: Optional[list[dict]] = None,
992
+ marketplace_dir: Optional[Path] = None,
993
+ marketplace_name: str = "brainiac",
994
+ registry_fetch: Optional[Callable[[], Optional[dict]]] = None,
995
+ ) -> dict[str, Any]:
996
+ """Run every ADR-0005 Ruling 2 surface check and return a report dict.
997
+
998
+ All path-ish parameters default to the real machine locations but accept
999
+ overrides — tests pass fixture directories so this NEVER needs the live
1000
+ machine to be exercised.
1001
+
1002
+ ``registry_fetch`` is OPT-IN (default ``None`` = the row is skipped
1003
+ entirely, exactly the pre-S07 row set — zero risk to every existing
1004
+ fixture test and zero network in the default/test path). Pass a callable
1005
+ (see ``fetch_pypi_latest_version``) to add the "PyPI registry drift" row;
1006
+ ``brain doctor --check-registry`` wires up the real HTTPS fetcher.
1007
+ """
1008
+ from . import __version__ as _unused # noqa: F401 (import proves module loads)
1009
+ from .index import SCHEMA_VERSION
1010
+
1011
+ repo_root = repo_root or Path(__file__).resolve().parent.parent.parent
1012
+ brainiac_home = brainiac_home or Path(os.environ.get("BRAINIAC_HOME", Path.home() / ".brainiac"))
1013
+ claude_home = claude_home or (Path.home() / ".claude")
1014
+ if app_support_dir is None:
1015
+ # Platform-aware (win32/darwin/linux) — same resolver connect.py's
1016
+ # `brain connect --client claude-desktop` writer uses, instead of a
1017
+ # second macOS-only guess here. Desktop's own per-user data dir is
1018
+ # the parent of claude_desktop_config.json on every platform (Electron
1019
+ # userData convention: %APPDATA%\Claude on Windows, ~/Library/
1020
+ # Application Support/Claude on macOS) — this also fixes the
1021
+ # extensions-installations.json lookup (check_mcpb_desktop_collision /
1022
+ # check_desktop_plugin_store both key off this same dir).
1023
+ from . import connect
1024
+
1025
+ app_support_dir = connect.claude_desktop_config_path().parent
1026
+ marketplace_dir = marketplace_dir or (claude_home / "plugins" / "marketplaces" / marketplace_name)
1027
+
1028
+ registry_unavailable = False
1029
+ if registry_entries is None:
1030
+ import sys as _sys
1031
+
1032
+ try:
1033
+ _sys.path.insert(0, str(repo_root / "tools"))
1034
+ import workspace_registry as _wr
1035
+
1036
+ registry_entries = _wr.list_entries()
1037
+ except Exception:
1038
+ # HARDEN: `tools/workspace_registry.py` is a host-only companion
1039
+ # script — never part of the staged zero-install engine
1040
+ # (cowork_workspace_install.sh copies only src/brain). A staged
1041
+ # VM copy invoking `brain doctor` with role=host (e.g. the shim
1042
+ # doesn't set $BRAIN_ROLE) must degrade to "can't check this
1043
+ # surface", never crash with a raw ModuleNotFoundError.
1044
+ registry_entries = []
1045
+ registry_unavailable = True
1046
+
1047
+ ssot = _ssot_version(repo_root)
1048
+ rows: list[dict] = []
1049
+
1050
+ if ssot is None:
1051
+ rows.append(_row("Version SSOT (pyproject.toml)", UNKNOWN, "no version found in pyproject.toml"))
1052
+ ssot = "0.0.0" # keeps downstream comparisons from crashing; every row above is UNKNOWN/errored
1053
+ else:
1054
+ rows.append(_row("Version SSOT (pyproject.toml)", CURRENT, ssot, raw={"version": ssot}))
1055
+
1056
+ resolved_brain = shutil.which("brain")
1057
+ rows.append(check_committed_stamp(repo_root, ssot))
1058
+ rows.append(check_host_venv(brainiac_home, ssot,
1059
+ resolved_brain=Path(resolved_brain) if resolved_brain else None))
1060
+ rows.append(check_embedder_liveness()) # DV-03: the host also builds/queries
1061
+ rows.append(check_dist_compat(repo_root, ssot))
1062
+ rows.extend(check_plugin_manifests(repo_root, ssot))
1063
+ rows.extend(check_installed_cli_plugins(claude_home, ssot, marketplace_name))
1064
+ rows.extend(check_stale_name_plugins(claude_home))
1065
+ if registry_unavailable:
1066
+ rows.append(_row(
1067
+ "Workspace registry (tools/workspace_registry.py)", NOT_DETECTABLE,
1068
+ "unavailable in this checkout — looks like a staged zero-install VM "
1069
+ "copy (tools/ is host-only, never staged); staged-workspace/skill-bundle "
1070
+ "rows below are skipped here",
1071
+ remediation="run `brain doctor --role vm` for the VM-appropriate surfaces, "
1072
+ "or run this on the full host checkout"))
1073
+ rows.extend(check_staged_workspaces(registry_entries, ssot))
1074
+ rows.extend(check_staged_skill_bundles(registry_entries, ssot))
1075
+ rows.extend(check_workspace_schema(registry_entries, SCHEMA_VERSION))
1076
+ rows.append(check_marketplace_cache(marketplace_dir))
1077
+ if registry_fetch is not None:
1078
+ rows.append(check_pypi_registry_drift(repo_root, ssot, fetch=registry_fetch))
1079
+ rows.append(check_mcpb_desktop_collision(app_support_dir))
1080
+ # Surface 11 — always LAST, always manual-required, never gates.
1081
+ rows.extend(check_desktop_plugin_store(app_support_dir, ssot))
1082
+
1083
+ gating_stale = [r for r in rows if r["status"] in _GATING_STATUSES]
1084
+ ok = len(gating_stale) == 0
1085
+
1086
+ return {
1087
+ "ssot_version": ssot,
1088
+ "rows": rows,
1089
+ "ok": ok,
1090
+ "stale_count": len(gating_stale),
1091
+ }
1092
+
1093
+
1094
+ _STATUS_ICON = {
1095
+ CURRENT: "✅", # ✅
1096
+ STALE: "⚠️", # ⚠️
1097
+ UNKNOWN: "⚠️",
1098
+ UNMANAGED: "ℹ️", # ℹ️
1099
+ MANUAL_REQUIRED: "\U0001f6e0️", # 🛠️
1100
+ NOT_DETECTABLE: "➖", # ➖
1101
+ }
1102
+
1103
+
1104
+ def render_human(report: dict[str, Any]) -> str:
1105
+ lines = [f"brain doctor — SSOT version {report['ssot_version']}", ""]
1106
+ surface_w = max((len(r["surface"]) for r in report["rows"]), default=8) + 2
1107
+ status_w = 16
1108
+ for r in report["rows"]:
1109
+ icon = _STATUS_ICON.get(r["status"], "?")
1110
+ line = f"{icon} {r['surface']:<{surface_w}}{r['status']:<{status_w}}{r['detail']}"
1111
+ lines.append(line)
1112
+ if r.get("remediation"):
1113
+ lines.append(f" -> fix: {r['remediation']}")
1114
+ lines.append("")
1115
+ if report["ok"]:
1116
+ lines.append(f"OK: all required surfaces current ({len(report['rows'])} checked)")
1117
+ else:
1118
+ lines.append(f"STALE: {report['stale_count']} required surface(s) need attention (see -> fix above)")
1119
+ return "\n".join(lines)
1120
+
1121
+
1122
+ def _demo() -> None:
1123
+ """ponytail self-check: an all-fixture run classifies every row and the
1124
+ exit-code gate only counts stale/unknown scriptable rows, never the
1125
+ always-manual-required Desktop row."""
1126
+ import tempfile
1127
+
1128
+ with tempfile.TemporaryDirectory() as td:
1129
+ root = Path(td)
1130
+ (root / "pyproject.toml").write_text('version = "1.2.3"\n', encoding="utf-8")
1131
+ brain_dir = root / "src" / "brain"
1132
+ brain_dir.mkdir(parents=True)
1133
+ (brain_dir / "_version.py").write_text('__version__ = "1.2.3"\n', encoding="utf-8")
1134
+ for pname in PLUGIN_NAMES:
1135
+ pdir = root / "plugins" / pname / ".claude-plugin"
1136
+ pdir.mkdir(parents=True)
1137
+ (pdir / "plugin.json").write_text(json.dumps({"name": pname, "version": "1.2.3"}), encoding="utf-8")
1138
+ claude_home = root / "claude_home"
1139
+ app_support = root / "app_support"
1140
+ report = run_doctor(
1141
+ repo_root=root, brainiac_home=root / "brainiac_home",
1142
+ claude_home=claude_home, app_support_dir=app_support,
1143
+ registry_entries=[],
1144
+ marketplace_dir=claude_home / "plugins" / "marketplaces" / "brainiac",
1145
+ )
1146
+ assert report["ssot_version"] == "1.2.3"
1147
+ stamp_row = next(r for r in report["rows"] if "Committed stamp" in r["surface"])
1148
+ assert stamp_row["status"] == CURRENT
1149
+ desktop_rows = [r for r in report["rows"] if "Desktop/Cowork" in r["surface"]]
1150
+ assert all(r["status"] == MANUAL_REQUIRED for r in desktop_rows)
1151
+ # Manual-required rows never gate the exit code even though found.
1152
+ assert report["stale_count"] == sum(
1153
+ 1 for r in report["rows"] if r["status"] in _GATING_STATUSES
1154
+ )
1155
+ text = render_human(report)
1156
+ assert "brain doctor" in text
1157
+ print("OK: doctor self-check passed")
1158
+
1159
+
1160
+ if __name__ == "__main__":
1161
+ _demo()