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/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()
|