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/update.py ADDED
@@ -0,0 +1,743 @@
1
+ """``brain update`` (ADR-0005 Ruling 3, UP-01/UP-02) — the self-executing
2
+ refresh: marketplace refresh -> downgrade-safe CLI-plugin reinstall -> engine
3
+ venv refresh -> dist/ rebuild -> workspace re-stage -> ``brain doctor`` verify,
4
+ with one before->after version table and one pass/fail.
5
+
6
+ This module RUNS the operations; it does not print instructions for a human
7
+ to copy-paste (that was the old ``/brainiac-update`` skill's failure mode —
8
+ ADR-0005 Ruling 3 amends ADR-0004 Ruling 5's "print exactly that instruction"
9
+ migration prose into an automatic clean reinstall).
10
+
11
+ Every external effect (subprocess call, filesystem mutation) goes through the
12
+ injectable ``runner`` so tests exercise the real decision logic against
13
+ fixtures/fakes with zero live-machine or plugin-store mutation — required for
14
+ this session's unattended, non-destructive contract.
15
+
16
+ Preflight capability probe (HARDEN:consensus-HIGH): before any destructive
17
+ `claude plugin` call, confirm the CLI surface this module depends on
18
+ (marketplace update / list / uninstall / install) actually exists. Never
19
+ drive an unversioned CLI blind.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any, Callable, Optional
30
+
31
+ from .doctor import (
32
+ CHANNEL_EDITABLE,
33
+ CHANNEL_PIP_USER,
34
+ CHANNEL_PIPX,
35
+ CHANNEL_PYPI_UV,
36
+ _compare,
37
+ _ssot_version,
38
+ check_installed_cli_plugins,
39
+ detect_install_channel,
40
+ render_human,
41
+ run_doctor,
42
+ )
43
+
44
+ # --------------------------------------------------------------------------
45
+ # Runner abstraction — the ONLY place subprocess.run is called from this
46
+ # module. Tests inject a fake that records calls instead of executing them
47
+ # (dry-run-by-construction), so the version-compare/downgrade-decision logic
48
+ # is exercised without ever touching a real plugin store or venv.
49
+ # --------------------------------------------------------------------------
50
+
51
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
52
+
53
+
54
+ def _default_runner(cmd: list[str], **kwargs: Any) -> "subprocess.CompletedProcess[str]":
55
+ kwargs.setdefault("capture_output", True)
56
+ kwargs.setdefault("text", True)
57
+ kwargs.setdefault("timeout", 120)
58
+ return subprocess.run(cmd, **kwargs)
59
+
60
+
61
+ # --------------------------------------------------------------------------
62
+ # Preflight capability probe (HARDEN:consensus-HIGH)
63
+ # --------------------------------------------------------------------------
64
+
65
+ REQUIRED_SUBCOMMANDS = ("marketplace", "list", "uninstall", "install", "update")
66
+
67
+
68
+ def probe_cli_capability(run: Runner = _default_runner) -> dict:
69
+ """Confirm the ``claude plugin`` CLI surface this module drives exists
70
+ before any destructive call. Never assert the surface from memory —
71
+ parse ``claude plugin --help`` and require every subcommand this module
72
+ calls to be present. Blocks (does not raise) on mismatch: callers must
73
+ check ``ok`` and stop, printing the manual fallback commands.
74
+ """
75
+ claude_bin = shutil.which("claude")
76
+ if claude_bin is None:
77
+ return {
78
+ "ok": False,
79
+ "reason": "`claude` CLI not found on PATH",
80
+ "manual_commands": [
81
+ "install/repair the Claude Code CLI, then re-run `brain update`",
82
+ ],
83
+ }
84
+ try:
85
+ out = run([claude_bin, "plugin", "--help"])
86
+ except Exception as exc:
87
+ return {
88
+ "ok": False,
89
+ "reason": f"`claude plugin --help` failed: {type(exc).__name__}: {exc}",
90
+ "manual_commands": [
91
+ "claude plugin marketplace update <name>",
92
+ "claude plugin list",
93
+ "claude plugin uninstall <plugin>@<marketplace>",
94
+ "claude plugin install <plugin>@<marketplace>",
95
+ "claude plugin update <plugin>@<marketplace>",
96
+ ],
97
+ }
98
+ text = ((out.stdout or "") + "\n" + (out.stderr or "")).lower()
99
+ missing = [c for c in REQUIRED_SUBCOMMANDS if c not in text]
100
+ if missing:
101
+ return {
102
+ "ok": False,
103
+ "reason": f"`claude plugin --help` is missing expected subcommand(s): {missing} "
104
+ "(gh#69626 pruning / gh#40153 non-atomic auto-update are cited real risks "
105
+ "of this surface moving under us) — refusing to drive it blind",
106
+ "manual_commands": [
107
+ "claude plugin marketplace update <name>",
108
+ "claude plugin list",
109
+ "claude plugin uninstall <plugin>@<marketplace>",
110
+ "claude plugin install <plugin>@<marketplace>",
111
+ "claude plugin update <plugin>@<marketplace>",
112
+ ],
113
+ }
114
+ return {"ok": True, "reason": "claude plugin CLI surface confirmed", "manual_commands": []}
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # Step: marketplace refresh (ALWAYS FIRST — kills the stale-cache no-op)
119
+ # --------------------------------------------------------------------------
120
+
121
+ def refresh_marketplace(marketplace_name: str, run: Runner = _default_runner) -> dict:
122
+ claude_bin = shutil.which("claude") or "claude"
123
+ try:
124
+ out = run([claude_bin, "plugin", "marketplace", "update", marketplace_name])
125
+ except Exception as exc:
126
+ return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}
127
+ ok = out.returncode == 0
128
+ return {"ok": ok, "detail": (out.stdout or out.stderr or "").strip()}
129
+
130
+
131
+ # --------------------------------------------------------------------------
132
+ # Downgrade-safe reinstall decision (pure logic — no subprocess) + execution
133
+ # --------------------------------------------------------------------------
134
+
135
+ def decide_plugin_action(installed: Optional[str], marketplace: Optional[str]) -> str:
136
+ """Pure decision function (fixture-testable, ADR-0005 Ruling 3):
137
+
138
+ - installed is None -> "install" (never installed)
139
+ - marketplace is None -> "skip" (nothing to compare against)
140
+ - installed > marketplace -> "reinstall" (reconciliation downgrade,
141
+ uninstall+clean install)
142
+ - installed < marketplace -> "update" (normal forward update)
143
+ - installed == marketplace -> "skip" (already current)
144
+ """
145
+ if installed is None:
146
+ return "install"
147
+ if marketplace is None:
148
+ return "skip"
149
+ cmp_ = _compare(installed, marketplace)
150
+ if cmp_ == 0:
151
+ return "skip"
152
+ return "reinstall" if cmp_ > 0 else "update"
153
+
154
+
155
+ def apply_plugin_action(
156
+ action: str, plugin_name: str, marketplace_name: str, run: Runner = _default_runner,
157
+ ) -> dict:
158
+ """Execute the decided action. ROLLBACK-safe ordering (HARDEN:claude-MEDIUM):
159
+ for "reinstall", uninstall ONLY after we've confirmed we're about to run
160
+ install right after in the same call — if install fails after a
161
+ successful uninstall, this returns ok=False with the plugin left absent,
162
+ and the caller (run_update) surfaces that as a partial/mixed-version
163
+ state rather than claiming success.
164
+ """
165
+ claude_bin = shutil.which("claude") or "claude"
166
+ spec = f"{plugin_name}@{marketplace_name}"
167
+ if action == "skip":
168
+ return {"action": "skip", "ok": True, "detail": "already current"}
169
+
170
+ if action == "install":
171
+ out = run([claude_bin, "plugin", "install", spec])
172
+ ok = out.returncode == 0
173
+ return {"action": "install", "ok": ok, "detail": (out.stdout or out.stderr or "").strip()}
174
+
175
+ if action == "update":
176
+ # `claude plugin install` on an already-installed plugin is a no-op
177
+ # (prints "already installed", does NOT upgrade) — `claude plugin
178
+ # update` is the subcommand that actually moves the installed
179
+ # version forward. Verified live against v0.10.0's `claude plugin`
180
+ # CLI: install no-ops, update reports "updated from X to Y".
181
+ out = run([claude_bin, "plugin", "update", spec])
182
+ ok = out.returncode == 0
183
+ return {"action": "update", "ok": ok, "detail": (out.stdout or out.stderr or "").strip()}
184
+
185
+ if action == "reinstall":
186
+ uninstall_out = run([claude_bin, "plugin", "uninstall", spec])
187
+ if uninstall_out.returncode != 0:
188
+ # Uninstall itself failed: plugin is presumably still in its
189
+ # original (installed>marketplace) state. Not worse than before.
190
+ return {
191
+ "action": "reinstall", "ok": False, "stage": "uninstall",
192
+ "detail": (uninstall_out.stdout or uninstall_out.stderr or "").strip(),
193
+ }
194
+ install_out = run([claude_bin, "plugin", "install", spec])
195
+ if install_out.returncode != 0:
196
+ # WORST case (HARDEN:claude-MEDIUM): uninstall succeeded, install
197
+ # failed -> plugin is now ABSENT, strictly worse than the
198
+ # downgraded start. Report this explicitly so the caller can
199
+ # print the exact recovery command rather than a green report.
200
+ return {
201
+ "action": "reinstall", "ok": False, "stage": "install",
202
+ "detail": (install_out.stdout or install_out.stderr or "").strip(),
203
+ "half_applied": True,
204
+ "recovery_command": f"claude plugin install {spec}",
205
+ }
206
+ return {
207
+ "action": "reinstall", "ok": True,
208
+ "detail": (install_out.stdout or install_out.stderr or "").strip(),
209
+ }
210
+
211
+ raise ValueError(f"unknown plugin action: {action!r}")
212
+
213
+
214
+ # --------------------------------------------------------------------------
215
+ # Engine venv refresh — resolves the engine source path from the workspace
216
+ # registry / explicit override, NEVER a hardcoded ~/brainiac (HARDEN:codex-MEDIUM).
217
+ # --------------------------------------------------------------------------
218
+
219
+ def resolve_engine_source(
220
+ *, explicit: Optional[str] = None, repo_root: Optional[Path] = None,
221
+ ) -> Path:
222
+ """Resolve the engine checkout to `pip install -e` against, in order:
223
+
224
+ 1. an explicit override (config / CLI flag / $BRAINIAC_ENGINE_SRC),
225
+ 2. the repo root this module itself ships from (the canonical checkout
226
+ when `brain update` is invoked from within it),
227
+ 3. `$HOME/brainiac` ONLY as a last-resort convention, never assumed blind.
228
+ """
229
+ if explicit:
230
+ return Path(explicit).expanduser().resolve()
231
+ if os.environ.get("BRAINIAC_ENGINE_SRC"):
232
+ return Path(os.environ["BRAINIAC_ENGINE_SRC"]).expanduser().resolve()
233
+ if repo_root is not None:
234
+ return repo_root.resolve()
235
+ inferred = Path(__file__).resolve().parent.parent.parent
236
+ if (inferred / "pyproject.toml").exists():
237
+ return inferred
238
+ return Path.home() / "brainiac"
239
+
240
+
241
+ def refresh_engine_venv(
242
+ engine_src: Path, brainiac_home: Path, run: Runner = _default_runner,
243
+ ) -> dict:
244
+ """Channel-aware refresh (PYP-04): detect which of the four channels the
245
+ host is actually on — the legacy editable dev checkout
246
+ (``~/.brainiac/venv``, pre-PyPI / --dev / offline) or one of the three
247
+ PyPI channels `install.sh`/`install.ps1` try in order (uv tool / pipx /
248
+ pip --user) — and run THAT channel's own upgrade command. A PyPI-first
249
+ default install never creates ``~/.brainiac/venv``, so unconditionally
250
+ running `pip install -e` there (the pre-S07 behavior) would silently
251
+ no-op forever on a real PyPI install; detecting the live channel via the
252
+ PATH-resolved `brain` binary is what makes `/brainiac-update` self-heal
253
+ the right thing regardless of how the engine was installed.
254
+ """
255
+ legacy_venv_dir = brainiac_home / "venv"
256
+ legacy_bin = legacy_venv_dir / "bin" / "brain"
257
+ which_brain = shutil.which("brain")
258
+ brain_bin: Optional[Path] = legacy_bin if legacy_bin.exists() else (
259
+ Path(which_brain) if which_brain else None
260
+ )
261
+ channel = detect_install_channel(brain_bin) if brain_bin else CHANNEL_EDITABLE
262
+
263
+ def _read_version(bin_path: Optional[Path]) -> str:
264
+ if not bin_path or not Path(bin_path).exists():
265
+ return "unknown"
266
+ try:
267
+ out = run([str(bin_path), "--version"])
268
+ return (out.stdout or out.stderr or "unknown").strip() or "unknown"
269
+ except Exception:
270
+ return "unknown"
271
+
272
+ old_version = _read_version(brain_bin)
273
+
274
+ if channel == CHANNEL_PYPI_UV:
275
+ install_out = run(["uv", "tool", "upgrade", "brainiac-cli"])
276
+ elif channel == CHANNEL_PIPX:
277
+ install_out = run(["pipx", "upgrade", "brainiac-cli"])
278
+ elif channel == CHANNEL_PIP_USER:
279
+ install_out = run([sys.executable, "-m", "pip", "install", "--user",
280
+ "--upgrade", "brainiac-cli[mcp]"])
281
+ else: # editable-checkout — the pre-PyPI / --dev / offline path, unchanged
282
+ if not (legacy_venv_dir / "bin" / "pip").exists():
283
+ run(["python3", "-m", "venv", str(legacy_venv_dir)])
284
+ pip = legacy_venv_dir / "bin" / "pip"
285
+ install_out = run([str(pip), "install", "--upgrade", "-e", f"{engine_src}[mcp]"])
286
+ brain_bin = legacy_bin
287
+
288
+ ok = install_out.returncode == 0
289
+ new_version = _read_version(brain_bin) if ok else old_version
290
+ return {
291
+ "ok": ok,
292
+ "old_version": old_version,
293
+ "new_version": new_version,
294
+ "detail": (install_out.stdout or install_out.stderr or "").strip(),
295
+ "channel": channel,
296
+ }
297
+
298
+
299
+ # --------------------------------------------------------------------------
300
+ # dist/ rebuild — `pip install -e` (engine venv refresh, above) refreshes the
301
+ # installed package/venv but NEVER regenerates the gitignored dist/COMPAT +
302
+ # dist/cowork-skills/*.skill artifacts that restage_workspaces' cowork-vm leg
303
+ # copies (tools/package_clients.py is the only thing that builds those).
304
+ # Skipping this is the exact bug observed live twice (0.10.2->0.10.3 and
305
+ # 0.10.3->0.10.4): restage_workspaces silently staged one-build-stale .skill
306
+ # bundles and `brain doctor` flagged "Staged skill bundles stale" / "dist/COMPAT
307
+ # stale" until a human ran the packager by hand and re-ran `brain update`.
308
+ # --------------------------------------------------------------------------
309
+
310
+ def rebuild_dist(engine_src: Path, run: Runner = _default_runner) -> dict:
311
+ packager = engine_src / "tools" / "package_clients.py"
312
+ try:
313
+ out = run([sys.executable, str(packager)], cwd=str(engine_src))
314
+ except Exception as exc:
315
+ return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}
316
+ ok = out.returncode == 0
317
+ return {"ok": ok, "detail": (out.stdout or out.stderr or "").strip()}
318
+
319
+
320
+ # --------------------------------------------------------------------------
321
+ # Workspace re-stage — thin wrapper delegating to workspace_registry; never
322
+ # reimplements its locking/schema. For target "cowork-vm" this re-runs the
323
+ # (a) engine-source and (d) skill-bundle legs of
324
+ # tools/cowork_workspace_install.sh directly in Python (not by shelling into
325
+ # the whole script, which also re-rebuilds the host index and re-scaffolds
326
+ # the overlay every run — steps the `brain sync --publish` call right after
327
+ # already covers for a routine update). Doing the copy in Python keeps it
328
+ # testable with plain tmp-path fixtures instead of a real bash subprocess.
329
+ # --------------------------------------------------------------------------
330
+
331
+ _VERSION_STAMP_RE = re.compile(r'(?m)^__version__ = "([^"]+)"$')
332
+
333
+
334
+ def _read_version_stamp(stamp_path: Path) -> Optional[str]:
335
+ if not stamp_path.exists():
336
+ return None
337
+ m = _VERSION_STAMP_RE.search(stamp_path.read_text(encoding="utf-8"))
338
+ return m.group(1) if m else None
339
+
340
+
341
+ def stage_engine_and_skills(engine_src: Path, workspace_path: str) -> dict:
342
+ """(a)+(d) legs of cowork_workspace_install.sh for one cowork-vm
343
+ workspace: re-copy the engine source into ``<vault>/.brain/engine/brain/``
344
+ and refresh the ``.skill`` bundles into ``<vault>/.brain/skills/`` from
345
+ whatever ``dist/cowork-skills/*.skill`` currently ships in ``engine_src``
346
+ (tools/release.py already runs tools/package_clients.py before every cut,
347
+ so that dir is the SSOT-version build by the time `brain update` runs).
348
+
349
+ Returns a dict with the SSOT/staged versions and skill count so the
350
+ caller can assert-and-fail rather than silently report ok.
351
+ """
352
+ brain_dir = Path(workspace_path) / ".brain"
353
+
354
+ # (a) engine source — replace wholesale, drop __pycache__ (mirrors the
355
+ # script's `rm -rf` + `cp -R` + pycache cleanup).
356
+ engine_dir = brain_dir / "engine"
357
+ if engine_dir.exists():
358
+ shutil.rmtree(engine_dir)
359
+ engine_dir.mkdir(parents=True, exist_ok=True)
360
+ shutil.copytree(engine_src / "src" / "brain", engine_dir / "brain")
361
+ for cache_dir in (engine_dir / "brain").rglob("__pycache__"):
362
+ shutil.rmtree(cache_dir, ignore_errors=True)
363
+
364
+ # (d) skill bundles (cw-02) — refresh from the current dist build.
365
+ skills_src_dir = engine_src / "dist" / "cowork-skills"
366
+ skills_dst_dir = brain_dir / "skills"
367
+ skills_dst_dir.mkdir(parents=True, exist_ok=True)
368
+ zips = sorted(skills_src_dir.glob("*.skill")) if skills_src_dir.is_dir() else []
369
+ for z in zips:
370
+ shutil.copyfile(z, skills_dst_dir / z.name)
371
+
372
+ # (c/c2/c3) offline semantic stack (DV-04) — the re-stage previously staged
373
+ # ONLY engine+skills, so a `brain update` shipped the fixed engine but left
374
+ # the VM without vendored tokenizers/sqlite-vec and with a stale shim/prompt:
375
+ # semantic search silently stayed on the hash fallback. Stage all three from
376
+ # the SAME shared helper the installer uses, so update == install.
377
+ vendor_status: dict[str, str] = {}
378
+ try:
379
+ import sys as _sys
380
+ _sys.path.insert(0, str(engine_src / "tools"))
381
+ import vendor_semantic_deps as _vsd # type: ignore
382
+
383
+ _vsd.write_shim(brain_dir) # vendored-deps-aware shim
384
+ vendor_status = _vsd.stage_vendor(brain_dir) # tokenizers + sqlite-vec per arch
385
+ except Exception as exc: # advisory — a networkless host degrades to lexical
386
+ vendor_status = {"error": f"{type(exc).__name__}: {exc}"}
387
+ # session prompt — the instruction the Cowork agent follows each session.
388
+ prompt_src = engine_src / "docs" / "install" / "cowork-session-prompt.md"
389
+ if prompt_src.exists():
390
+ routines_dst = brain_dir / "routines"
391
+ routines_dst.mkdir(parents=True, exist_ok=True)
392
+ shutil.copyfile(prompt_src, routines_dst / "cowork-session-prompt.md")
393
+
394
+ # conventions contract — the AGENTS.md the session prompt points the agent at
395
+ # (cowork_workspace_install.sh leg, line ~218). The re-stage previously
396
+ # SKIPPED it, so an AGENTS.md change (e.g. the retrieval-discipline block)
397
+ # never reached Cowork on `brain update`: the doctor's staged-version check
398
+ # reads the engine stamp and is blind to a stale contract. Copy it so
399
+ # update == install for the contract too.
400
+ agents_src = engine_src / "AGENTS.md"
401
+ if agents_src.exists():
402
+ shutil.copyfile(agents_src, brain_dir / "AGENTS.md")
403
+
404
+ ssot = _ssot_version(engine_src)
405
+ staged = _read_version_stamp(engine_dir / "brain" / "_version.py")
406
+ return {
407
+ "ssot_version": ssot,
408
+ "staged_version": staged,
409
+ "version_ok": ssot is not None and staged == ssot,
410
+ "skills_shipped": len(zips),
411
+ "skills_src_dir": str(skills_src_dir),
412
+ "vendor_status": vendor_status,
413
+ }
414
+
415
+
416
+ def restage_workspaces(
417
+ engine_src: Path, brainiac_home: Path, run: Runner = _default_runner,
418
+ ) -> list[dict]:
419
+ import sys as _sys
420
+
421
+ _sys.path.insert(0, str(engine_src / "tools"))
422
+ import workspace_registry as _wr # type: ignore
423
+
424
+ results = []
425
+ brain_bin = brainiac_home / "venv" / "bin" / "brain"
426
+ for entry in _wr.list_entries():
427
+ vault_path = entry.get("vault_path", "")
428
+ workspace_path = entry.get("workspace_path", "")
429
+ target = entry.get("target")
430
+ import platform
431
+ import socket
432
+
433
+ # Gate on arch ONLY (BUG A, pre-existing since v0.10.0): macOS
434
+ # `socket.gethostname()` is UNSTABLE — it flips between the mDNS
435
+ # `.local` name and the DHCP-assigned name for the SAME machine
436
+ # (verified live: an entry staged as `oldhost.local`
437
+ # read back as `Mac.lan` on an unchanged host), so a hostname-based
438
+ # gate silently skips the user's own workspace and the re-stage below
439
+ # never runs. arch is stable and still protects against a genuinely
440
+ # different (stale, other-machine) entry; the folder-exists check
441
+ # right below is the second layer of defense for a same-arch entry
442
+ # that's actually a different machine (its path won't resolve here).
443
+ if entry.get("arch") != platform.machine():
444
+ results.append({"workspace_path": workspace_path, "target": target,
445
+ "status": "skipped", "reason": "different arch"})
446
+ continue
447
+ if entry.get("host") != socket.gethostname():
448
+ # Self-heal rather than skip: rewrite the entry's host to the
449
+ # current hostname so the registry stops drifting every time
450
+ # macOS flips which name it hands back.
451
+ _wr.upsert_entry(vault_path=vault_path, workspace_path=workspace_path,
452
+ target=target, model_dir=entry.get("model_dir"))
453
+ if not Path(vault_path or workspace_path).exists():
454
+ results.append({"workspace_path": workspace_path, "target": target,
455
+ "status": "skipped", "reason": "folder missing"})
456
+ continue
457
+
458
+ if target == "host":
459
+ # Engine venv refresh already covers this leg (Step 3); only the
460
+ # nightly task remains and that's a separate, per-vault script
461
+ # (scripts/install-brief-mac.sh) — not re-implemented here.
462
+ results.append({"workspace_path": workspace_path, "target": target,
463
+ "status": "ok", "reason": "engine refresh covers host leg"})
464
+ elif target == "cowork-vm":
465
+ # BUG B: the Cowork VM reads `<vault_path>/.brain` (this is what
466
+ # `tools/cowork_workspace_install.sh` stages into as `$VAULT`) —
467
+ # `workspace_path` is the PARENT checkout dir, whose own `.brain`
468
+ # (if any) is the unrelated HOST stage. Staging into
469
+ # `workspace_path` re-stages the host copy (already current) and
470
+ # reads its version back as "ok" while the real VM engine at
471
+ # `vault_path/.brain/engine` never moves. Must stage vault_path.
472
+ try:
473
+ stage_info = stage_engine_and_skills(engine_src, vault_path)
474
+ except OSError as exc:
475
+ results.append({"workspace_path": workspace_path, "target": target,
476
+ "status": "failed",
477
+ "reason": f"engine/skill re-stage failed: {type(exc).__name__}: {exc}"})
478
+ continue
479
+ # No-silent-no-op (DV-01, ADR-0005 Ruling 1): a re-stage that
480
+ # lands a missing/mismatched _version.py must never report ok —
481
+ # that's exactly the defect this fix exists to close (staged
482
+ # engine stayed at a stale pre-0.10.0 copy while `brain update`
483
+ # reported "ok" off the index sync alone).
484
+ if not stage_info["version_ok"]:
485
+ results.append({"workspace_path": workspace_path, "target": target,
486
+ "status": "failed",
487
+ "reason": f"staged engine version {stage_info['staged_version']!r} != "
488
+ f"SSOT {stage_info['ssot_version']!r} after re-stage — "
489
+ "engine copy landed a missing/stale _version.py stamp"})
490
+ continue
491
+ if stage_info["skills_shipped"] == 0:
492
+ results.append({"workspace_path": workspace_path, "target": target,
493
+ "status": "failed",
494
+ "reason": f"no .skill bundles found in {stage_info['skills_src_dir']} "
495
+ "to refresh — run tools/package_clients.py in the checkout"})
496
+ continue
497
+ sync_out = run([str(brain_bin), "sync", "--publish"],
498
+ env={**os.environ, "BRAIN_VAULT": vault_path})
499
+ ok = sync_out.returncode == 0
500
+ results.append({"workspace_path": workspace_path, "target": target,
501
+ "status": "ok" if ok else "failed",
502
+ "reason": (sync_out.stdout or sync_out.stderr or "").strip()})
503
+ if ok:
504
+ _wr.touch_refreshed(vault_path=vault_path, workspace_path=workspace_path, target=target)
505
+ else:
506
+ results.append({"workspace_path": workspace_path, "target": target,
507
+ "status": "skipped", "reason": f"unknown target {target!r}"})
508
+ return results
509
+
510
+
511
+ # --------------------------------------------------------------------------
512
+ # Before -> after version table
513
+ # --------------------------------------------------------------------------
514
+
515
+ def render_before_after(table: list[dict]) -> str:
516
+ lines = ["Before -> after version table", "-" * 32]
517
+ surface_w = max((len(r["surface"]) for r in table), default=8) + 2
518
+ for r in table:
519
+ lines.append(f"{r['surface']:<{surface_w}}{r['before']:<16} -> {r['after']}")
520
+ return "\n".join(lines)
521
+
522
+
523
+ # --------------------------------------------------------------------------
524
+ # UP-02 — the single top-level orchestrating entry point
525
+ # --------------------------------------------------------------------------
526
+
527
+ def run_update(
528
+ *,
529
+ marketplace_name: str = "brainiac",
530
+ engine_src: Optional[str] = None,
531
+ brainiac_home: Optional[Path] = None,
532
+ claude_home: Optional[Path] = None,
533
+ run: Runner = _default_runner,
534
+ skip_capability_probe: bool = False,
535
+ dry_run: bool = False,
536
+ ) -> dict[str, Any]:
537
+ """Chain, in order: capability probe -> marketplace refresh -> per-plugin
538
+ downgrade-safe reinstall -> engine venv refresh -> dist/ rebuild ->
539
+ workspace re-stage -> `brain doctor` verify. One pass/fail, one
540
+ before->after table.
541
+
542
+ ``dry_run=True`` runs every read/decision step for real but skips every
543
+ mutating call (marketplace update, plugin install/uninstall, pip install,
544
+ workspace re-stage) — used by the evidence transcript in this session so
545
+ no live plugin store or venv is touched.
546
+ """
547
+ brainiac_home = brainiac_home or Path(os.environ.get("BRAINIAC_HOME", Path.home() / ".brainiac"))
548
+ claude_home = claude_home or (Path.home() / ".claude")
549
+ resolved_engine_src = resolve_engine_source(explicit=engine_src)
550
+
551
+ result: dict[str, Any] = {
552
+ "ok": False,
553
+ "steps": {},
554
+ "before_after": [],
555
+ "residual_human_steps": [
556
+ "Desktop/Cowork plugin store: no external CLI — this run only DETECTS "
557
+ "staleness (see the manual-required rows in `brain doctor`); to UPDATE, "
558
+ "in a Cowork session use /skill-creator to repackage + Save-and-Replace "
559
+ "the stale skill(s) — /brainiac-update is host-only and refuses in Cowork "
560
+ "— then re-run `brain doctor` on the host to confirm it took.",
561
+ ],
562
+ }
563
+
564
+ # Step 0 — preflight capability probe (HARDEN:consensus-HIGH)
565
+ if skip_capability_probe:
566
+ probe = {"ok": True, "reason": "probe skipped by caller", "manual_commands": []}
567
+ else:
568
+ probe = probe_cli_capability(run=run)
569
+ result["steps"]["capability_probe"] = probe
570
+ if not probe["ok"]:
571
+ result["notes"] = ("BLOCKED before any destructive call: " + probe["reason"] +
572
+ ". Manual commands: " + "; ".join(probe["manual_commands"]))
573
+ return result
574
+
575
+ # Step 1 — marketplace refresh FIRST (structurally kills the stale-cache
576
+ # no-op — HARDEN:codex-HIGH). This MUST run before `before_doctor` below:
577
+ # `brain doctor`'s "Installed CLI plugin" rows read the marketplace's
578
+ # on-disk checkout (the exact cache this refresh updates), so computing
579
+ # `before_doctor` first would make the reinstall decision at Step 2 compare
580
+ # against pre-refresh marketplace data — the stale-cache no-op this whole
581
+ # ordering exists to kill (GV-02).
582
+ if dry_run:
583
+ mkt_refresh = {"ok": True, "detail": "[dry-run] claude plugin marketplace update "
584
+ f"{marketplace_name} (skipped)"}
585
+ else:
586
+ mkt_refresh = refresh_marketplace(marketplace_name, run=run)
587
+ result["steps"]["marketplace_refresh"] = mkt_refresh
588
+ if not mkt_refresh["ok"]:
589
+ result["notes"] = f"marketplace refresh failed: {mkt_refresh['detail']} — stopping before any plugin mutation."
590
+ return result
591
+
592
+ # `before_doctor` is captured AFTER the marketplace refresh, so the
593
+ # plugin-action decision below and the final before->after table both
594
+ # reflect a fresh marketplace read, never a stale pre-refresh snapshot.
595
+ before_doctor = run_doctor(brainiac_home=brainiac_home, claude_home=claude_home)
596
+ before_table = {r["surface"]: r["detail"] for r in before_doctor["rows"]}
597
+ result["steps"]["doctor_before"] = render_human(before_doctor)
598
+
599
+ # Step 2 — per-plugin downgrade-safe reinstall decision + apply.
600
+ plugin_rows = [r for r in before_doctor["rows"] if r["surface"].startswith("Installed CLI plugin")]
601
+ plugin_actions = []
602
+ half_applied = []
603
+ failed_updates = []
604
+ for row in plugin_rows:
605
+ raw = row.get("raw") or {}
606
+ installed = raw.get("installed")
607
+ marketplace = raw.get("marketplace")
608
+ pname = row["surface"].split("(", 1)[1].rstrip(")")
609
+ action = decide_plugin_action(installed, marketplace)
610
+ if dry_run:
611
+ applied = {"action": action, "ok": True, "detail": f"[dry-run] would {action}"}
612
+ else:
613
+ applied = apply_plugin_action(action, pname, marketplace_name, run=run)
614
+ # No-op detection (the CLI's `plugin install` on an already-
615
+ # installed plugin silently no-ops instead of upgrading — the
616
+ # exact bug this session fixes). Re-read the installed version
617
+ # after the action and assert it actually moved to marketplace;
618
+ # a no-op must never report ok:true.
619
+ if action == "update" and applied.get("ok"):
620
+ refreshed = check_installed_cli_plugins(claude_home, "", marketplace_name)
621
+ after_row = next((r for r in refreshed if r["surface"] == row["surface"]), None)
622
+ after_version = (after_row or {}).get("raw", {}).get("installed")
623
+ if after_version == installed:
624
+ applied = {
625
+ **applied,
626
+ "ok": False,
627
+ "detail": (
628
+ f"plugin {pname} still at {installed} after update — the claude "
629
+ "plugin CLI no-op'd; run `/plugin update "
630
+ f"{pname}@{marketplace_name}` manually and restart"
631
+ ),
632
+ }
633
+ plugin_actions.append({"plugin": pname, "installed_before": installed,
634
+ "marketplace": marketplace, **applied})
635
+ if applied.get("half_applied"):
636
+ half_applied.append(applied)
637
+ elif action == "update" and not applied.get("ok"):
638
+ failed_updates.append(applied)
639
+ result["steps"]["plugin_reinstall"] = plugin_actions
640
+ if half_applied:
641
+ result["notes"] = ("update INCOMPLETE — surfaces at mixed versions: a reinstall "
642
+ "half-applied (uninstall ok, install failed). Recovery: " +
643
+ "; ".join(h["recovery_command"] for h in half_applied))
644
+ result["steps"]["doctor_after"] = render_human(run_doctor(brainiac_home=brainiac_home, claude_home=claude_home))
645
+ return result
646
+ if failed_updates:
647
+ result["notes"] = ("update INCOMPLETE — plugin update no-op'd: " +
648
+ "; ".join(f["detail"] for f in failed_updates))
649
+ result["steps"]["doctor_after"] = render_human(run_doctor(brainiac_home=brainiac_home, claude_home=claude_home))
650
+ return result
651
+
652
+ # Step 3 — engine venv refresh.
653
+ if dry_run:
654
+ engine_result = {"ok": True, "old_version": before_table.get("Host engine venv", "unknown"),
655
+ "new_version": "[dry-run] not executed", "detail": "[dry-run] pip -e skipped"}
656
+ else:
657
+ engine_result = refresh_engine_venv(resolved_engine_src, brainiac_home, run=run)
658
+ result["steps"]["engine_refresh"] = engine_result
659
+ if not engine_result["ok"] and not dry_run:
660
+ result["notes"] = f"engine venv refresh failed: {engine_result['detail']} — stopping before workspace re-stage."
661
+ return result
662
+
663
+ # Step 3.5 — rebuild dist/ (COMPAT + cowork-skills bundles) from the
664
+ # freshly-installed engine, BEFORE staging workspaces below reads it.
665
+ # PYP-04: dist_rebuild and workspace re-stage both need a REAL checkout
666
+ # (tools/package_clients.py, dist/cowork-skills/) — a PyPI-first host
667
+ # install has none by default, and only Cowork workspaces need either
668
+ # step. Gate on the checkout actually existing so a host-only PyPI
669
+ # install's `brain update` never fails on a step it doesn't need.
670
+ engine_src_available = (resolved_engine_src / "pyproject.toml").exists()
671
+ no_checkout_detail = (
672
+ f"skipped — no local checkout at {resolved_engine_src} (a PyPI-first install "
673
+ "has none by default; only needed to re-stage Cowork workspaces — clone "
674
+ "https://github.com/Autopsias/brainiac.git and pass --engine-src, or set "
675
+ "$BRAINIAC_ENGINE_SRC, if you use Cowork)"
676
+ )
677
+ if dry_run:
678
+ dist_rebuild = {"ok": True, "detail": "[dry-run] tools/package_clients.py (skipped)"}
679
+ elif not engine_src_available:
680
+ dist_rebuild = {"ok": True, "skipped": True, "detail": no_checkout_detail}
681
+ else:
682
+ dist_rebuild = rebuild_dist(resolved_engine_src, run=run)
683
+ result["steps"]["dist_rebuild"] = dist_rebuild
684
+ if not dist_rebuild["ok"] and not dry_run:
685
+ result["notes"] = f"dist rebuild failed: {dist_rebuild['detail']} — stopping before workspace re-stage."
686
+ return result
687
+
688
+ # Step 4 — re-stage every registered workspace.
689
+ if dry_run:
690
+ workspace_results: list[dict] = [{"workspace_path": "[dry-run]", "target": "n/a",
691
+ "status": "skipped", "reason": "dry-run: no re-stage executed"}]
692
+ elif not engine_src_available:
693
+ workspace_results = [{"workspace_path": "n/a", "target": "n/a", "status": "skipped",
694
+ "reason": no_checkout_detail}]
695
+ else:
696
+ workspace_results = restage_workspaces(resolved_engine_src, brainiac_home, run=run)
697
+ result["steps"]["workspace_restage"] = workspace_results
698
+
699
+ # Step 5 — brain doctor verify (final pass/fail).
700
+ after_doctor = run_doctor(brainiac_home=brainiac_home, claude_home=claude_home)
701
+ result["steps"]["doctor_after"] = render_human(after_doctor)
702
+
703
+ # Before -> after table across the surfaces that actually changed intent.
704
+ table = []
705
+ for surface in sorted(set(before_table) | {r["surface"] for r in after_doctor["rows"]}):
706
+ after_val = next((r["detail"] for r in after_doctor["rows"] if r["surface"] == surface), "n/a")
707
+ table.append({"surface": surface, "before": before_table.get(surface, "n/a"), "after": after_val})
708
+ result["before_after"] = table
709
+ result["before_after_rendered"] = render_before_after(table)
710
+
711
+ result["ok"] = after_doctor["ok"]
712
+ result["notes"] = ("update complete, all required surfaces current" if after_doctor["ok"]
713
+ else f"update ran to completion but {after_doctor['stale_count']} required "
714
+ "surface(s) still stale — see brain doctor output above")
715
+ return result
716
+
717
+
718
+ def _demo() -> None:
719
+ """ponytail self-check: decision function + dry-run orchestration never
720
+ crash and never call a real subprocess in dry-run mode."""
721
+
722
+ assert decide_plugin_action(None, "1.0.0") == "install"
723
+ assert decide_plugin_action("1.0.0", None) == "skip"
724
+ assert decide_plugin_action("1.1.0", "0.9.1") == "reinstall"
725
+ assert decide_plugin_action("0.9.0", "0.9.1") == "update"
726
+ assert decide_plugin_action("0.9.1", "0.9.1") == "skip"
727
+
728
+ calls = []
729
+
730
+ def fake_run(cmd, **kwargs):
731
+ calls.append(cmd)
732
+ import subprocess as sp
733
+
734
+ return sp.CompletedProcess(cmd, 0, stdout="ok", stderr="")
735
+
736
+ report = run_update(run=fake_run, dry_run=True, skip_capability_probe=True)
737
+ assert "ok" in report
738
+ assert calls == [], "dry_run must never invoke the injected runner for mutating steps"
739
+ print("OK: update self-check passed")
740
+
741
+
742
+ if __name__ == "__main__":
743
+ _demo()