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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- 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()
|