@reddb-io/red-skills 4.4.0 → 4.5.0

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. package/bin/build-info.mjs +87 -0
  2. package/bin/cli-frontdoor.mjs +14 -0
  3. package/bin/red-skills-brain.mjs +17 -22
  4. package/bin/red-skills-content.mjs +17 -0
  5. package/bin/red-skills-hook.mjs +45 -0
  6. package/bin/red-skills-memory.mjs +17 -22
  7. package/bin/red-skills-redskilled-mcp.mjs +2 -0
  8. package/bin/red-skills-resource.mjs +21 -0
  9. package/bin/runtime-compatibility.mjs +17 -0
  10. package/bin/runtime-paths.mjs +13 -0
  11. package/dist/code-nav.bundle.min.mjs +1 -1
  12. package/dist/opencode-host.bundle.min.mjs +1 -1
  13. package/dist/redskilled-mcp.bundle.min.mjs +36 -36
  14. package/dist/redskilled.bundle.min.mjs +234 -230
  15. package/dist/release.bundle.min.mjs +1 -1
  16. package/dist/rsp-core.bundle.min.mjs +1 -1
  17. package/dist/rsp.bundle.min.mjs +1 -1
  18. package/dist/statusline.bundle.min.mjs +1 -1
  19. package/package.json +9 -4
  20. package/runtime/hook-routes.json +125 -0
  21. package/runtime/plugins/brain/scripts/bootstrap.mjs +656 -0
  22. package/runtime/plugins/brain/scripts/structural-smoke.sh +7 -0
  23. package/runtime/plugins/dev/hooks/branch-lock-codex.sh +105 -0
  24. package/runtime/plugins/dev/hooks/code-nav-mcp.sh +63 -0
  25. package/runtime/plugins/dev/hooks/command-guard.sh +893 -0
  26. package/runtime/plugins/dev/hooks/ensure-codex-statusline.mjs +144 -0
  27. package/runtime/plugins/dev/hooks/red-fetch.mjs +38 -0
  28. package/runtime/plugins/dev/hooks/redskilled-mcp.sh +64 -0
  29. package/runtime/plugins/dev/hooks/rsp-hook.sh +343 -0
  30. package/runtime/plugins/dev/hooks/tests/command-guard.test.sh +378 -0
  31. package/runtime/plugins/dev/hooks/tests/rsp-hook.test.sh +229 -0
  32. package/runtime/plugins/dev/scripts/memory-bridge.sh +230 -0
  33. package/runtime/plugins/dev/scripts/structural-smoke.sh +7 -0
  34. package/runtime/plugins/dev/scripts/tests/memory-bridge.test.sh +555 -0
  35. package/runtime/plugins/dev/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
  36. package/runtime/plugins/dev/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
  37. package/runtime/plugins/dev/skills/engineering/afk/defaults/gradle-pre-worktree.sh +45 -0
  38. package/runtime/plugins/dev/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
  39. package/runtime/plugins/dev/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
  40. package/runtime/plugins/dev/skills/engineering/afk/detectors/cargo.sh +27 -0
  41. package/runtime/plugins/dev/skills/engineering/afk/detectors/gradle.sh +38 -0
  42. package/runtime/plugins/dev/skills/engineering/afk/examples/only-mine.sh +32 -0
  43. package/runtime/plugins/dev/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
  44. package/runtime/plugins/dev/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
  45. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-cargo +38 -0
  46. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-envelope +50 -0
  47. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-gradle +42 -0
  48. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-heartbeat +46 -0
  49. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-validation +79 -0
  50. package/runtime/plugins/dev/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
  51. package/runtime/plugins/dev/skills/engineering/red-setup/scripts/install-runtime-shim.sh +181 -0
  52. package/runtime/plugins/dev/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
  53. package/runtime/plugins/dev/skills/engineering/wizard/template.sh +211 -0
  54. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
  55. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
  56. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
  57. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
  58. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
  59. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
  60. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
  61. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
  62. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
  63. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
  64. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
  65. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +230 -0
  66. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
  67. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
  68. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
  69. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
  70. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
  71. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
  72. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
  73. package/runtime/plugins/internal/scripts/structural-smoke.sh +7 -0
  74. package/runtime/plugins/internal/skills/maintainer/create-plugin/scripts/create-plugin.sh +308 -0
  75. package/runtime/plugins/memory/scripts/bootstrap.mjs +659 -0
  76. package/runtime/plugins/memory/scripts/structural-smoke.sh +7 -0
  77. package/scripts/check-skills-boundary.mjs +32 -0
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bootstrap.mjs — dependency-free runtime resolver for the Brain plugin.
4
+ *
5
+ * Mirrors the Memory plugin's resolver (ADR 0029): the plugin ships as a
6
+ * marketplace git checkout into the cache; Claude Code / Codex never run a build
7
+ * or install, so the compiled CLI/MCP and their deps cannot live in the checkout.
8
+ * The release publishes two esbuild bundles (`brain-cli.mjs`, `brain-mcp.mjs`,
9
+ * all JS deps inlined) plus a runtime manifest, and the native `red` engine
10
+ * binary is pinned by that manifest and reused from reddb-io/reddb's own
11
+ * releases. This script — invoked by the MCP launcher and lifecycle hooks in
12
+ * place of built output — fetches those artifacts once per plugin version into a
13
+ * version-keyed cache that survives `autoUpdate`, then delegates the call.
14
+ *
15
+ * A repo checkout (where `apps/plugin-brain/dist-bundle/*` or the TS source exists)
16
+ * falls back to the local artifacts when the release fetch fails, so the plugin
17
+ * stays runnable in this repo before its first release.
18
+ *
19
+ * Uses only `node:` builtins because it runs before any dependency exists. On any
20
+ * failure it prints `{}` on stdout (the no-op the hooks expect) and logs an
21
+ * actionable line — never silent. See ADR 0029/0038.
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { createHash } from "node:crypto";
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { mkdir, readFile, rename, writeFile, chmod, appendFile, unlink } from "node:fs/promises";
28
+ import { homedir, platform, arch } from "node:os";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const RED_SKILLS_REPO = "reddb-io/red-skills";
33
+ const PLUGIN = "brain";
34
+ const RUNTIME_MANIFEST = "brain-runtime-manifest.json";
35
+ const CLI_ASSET = "brain-cli.mjs";
36
+
37
+ // ADR 0091: the npm registry is the source of truth for version *discovery*.
38
+ // The brain runtime keeps shipping as pinned GitHub-release assets (it carries a
39
+ // per-platform native `red` binary that cannot live in a platform-independent
40
+ // npm tarball) — that runtime distribution is deliberately NOT redesigned here.
41
+ // ADR 0091 removes the broken hand-rolled sigstore manifest verification and the
42
+ // phantom `releases/download/v1/` self-update channel that 404'd on every boot.
43
+ const NPM_PACKAGE = "@reddb-io/red-skills";
44
+ const NPM_REGISTRY_BASE = process.env.RED_NPM_REGISTRY_BASE || "https://registry.npmjs.org";
45
+
46
+ const HERE = dirname(fileURLToPath(import.meta.url));
47
+ const PLUGIN_ROOT = resolve(HERE, "..");
48
+ // The dev / pre-release local-fallback root. Derived from this launcher's own
49
+ // location by default; overridable (RED_BRAIN_REPO_ROOT) so the launcher tests
50
+ // can point it at an empty dir and simulate an installed copy with no checkout.
51
+ const REPO_ROOT = process.env.RED_BRAIN_REPO_ROOT || resolve(PLUGIN_ROOT, "..", "..");
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Pure helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Map node's platform/arch to the reddb release asset key. null = unsupported. */
58
+ export function platformKey(plat = platform(), architecture = arch()) {
59
+ const os = { linux: "linux", darwin: "macos", win32: "windows" }[plat];
60
+ const cpu = { x64: "x86_64", arm64: "aarch64", arm: "armv7" }[architecture];
61
+ if (!os || !cpu) return null;
62
+ return `${os}-${cpu}`;
63
+ }
64
+
65
+ export function sha256Hex(buf) {
66
+ return createHash("sha256").update(buf).digest("hex");
67
+ }
68
+
69
+ /**
70
+ * Release host. Overridable via RED_BRAIN_RELEASE_BASE so the launcher tests
71
+ * can point every asset URL at a local server — no real network, ever.
72
+ */
73
+ const RELEASE_HOST = process.env.RED_BRAIN_RELEASE_BASE || "https://github.com";
74
+
75
+ /** GitHub release asset URL. */
76
+ export function assetUrl(repo, tag, name) {
77
+ return `${RELEASE_HOST}/${repo}/releases/download/${tag}/${name}`;
78
+ }
79
+
80
+ /**
81
+ * Which invocations may hit the network on a cache miss. Only the resolve/warm
82
+ * points do: the once-per-session SessionStart hook, the long-lived mcp server,
83
+ * and user-invoked CLI commands. Recurring render/hot-path hooks (PostToolUse /
84
+ * Stop / PreCompact) must never block on a synchronous fetch (ADR 0084, the
85
+ * statusline-blanking lesson). On a cold cache they no-op and let SessionStart
86
+ * warm the cache.
87
+ */
88
+ export function mayFetchRuntime(argv) {
89
+ if (argv[0] === "hook") return argv[1] === "SessionStart";
90
+ return true;
91
+ }
92
+
93
+ function normalizeRedAsset(value) {
94
+ if (!value || typeof value !== "object") return null;
95
+ if (typeof value.asset !== "string") return null;
96
+ if (typeof value.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(value.sha256)) return null;
97
+ return { asset: value.asset, sha256: value.sha256.toLowerCase() };
98
+ }
99
+
100
+ // ── In-range self-update (ADR 0084) ──────────────────────────────────────────
101
+ // Mirror of packages/shared/self-update.ts, inlined because the launcher ships
102
+ // dependency-free in the plugin checkout. Keep the copies in lockstep. On an
103
+ // enabled session start the launcher spawns a DETACHED background check for a
104
+ // newer in-range (same-major) runtime and atomically swaps a pointer so the NEXT
105
+ // session serves it; the current session and out-of-range majors are untouched,
106
+ // and resolution (resolveActiveVersion) is a LOCAL read that can never fetch.
107
+
108
+ export function parseSemver(v) {
109
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim());
110
+ return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) } : null;
111
+ }
112
+ export function compareSemver(a, b) {
113
+ const pa = parseSemver(a);
114
+ const pb = parseSemver(b);
115
+ if (!pa || !pb) return 0;
116
+ return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch;
117
+ }
118
+ export function sameMajor(a, b) {
119
+ const pa = parseSemver(a);
120
+ const pb = parseSemver(b);
121
+ return !!pa && !!pb && pa.major === pb.major;
122
+ }
123
+ /** Newest in-range candidate strictly newer than `current`, else null. */
124
+ export function selectInRangeUpdate(installed, current, candidate) {
125
+ if (!candidate || !sameMajor(installed, candidate)) return null;
126
+ if (compareSemver(candidate, current) <= 0) return null;
127
+ return candidate;
128
+ }
129
+ /** npm registry metadata URL for the package (scoped name is `%2F`-escaped). */
130
+ export function registryPackageUrl(pkg = NPM_PACKAGE) {
131
+ return `${NPM_REGISTRY_BASE}/${pkg.replace("/", "%2F")}`;
132
+ }
133
+ /** Newest published SAME-major version from registry metadata JSON, else null. */
134
+ export function newestSameMajorFromRegistry(metadataText, installed) {
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(metadataText);
138
+ } catch {
139
+ return null;
140
+ }
141
+ const versions = parsed && typeof parsed.versions === "object" ? Object.keys(parsed.versions) : [];
142
+ const pi = parseSemver(installed);
143
+ if (!pi) return null;
144
+ let best = null;
145
+ for (const v of versions) {
146
+ const pv = parseSemver(v);
147
+ if (!pv || pv.major !== pi.major) continue;
148
+ if (best === null || compareSemver(v, best) > 0) best = v;
149
+ }
150
+ return best;
151
+ }
152
+ export function pointerFileName(plugin) {
153
+ return `${plugin}-stable.current`;
154
+ }
155
+ export function readPointerVersion(text) {
156
+ const t = String(text).trim();
157
+ try {
158
+ const o = JSON.parse(t);
159
+ if (o && typeof o.version === "string") return o.version.trim();
160
+ } catch {
161
+ /* fall through to a bare-version tolerance */
162
+ }
163
+ return /^\d+\.\d+\.\d+/.test(t) ? t : "";
164
+ }
165
+
166
+ function pointerPath() {
167
+ return join(runtimeRoot(), pointerFileName(PLUGIN));
168
+ }
169
+
170
+ /**
171
+ * The runtime version to serve right now, using only LOCAL reads (no network).
172
+ * Honours the self-update pointer only when it is a real in-range, non-downgrade
173
+ * version whose runtime is actually present; otherwise the installed version.
174
+ */
175
+ async function resolveActiveVersion(installed) {
176
+ if (!parseSemver(installed)) return installed;
177
+ const ptr = pointerPath();
178
+ if (!existsSync(ptr)) return installed;
179
+ let pointed;
180
+ try {
181
+ pointed = readPointerVersion(await readFile(ptr, "utf8"));
182
+ } catch {
183
+ return installed;
184
+ }
185
+ if (!pointed || !sameMajor(installed, pointed) || compareSemver(pointed, installed) < 0) {
186
+ return installed;
187
+ }
188
+ return existsSync(join(runtimeRoot(), pointed, CLI_ASSET)) ? pointed : installed;
189
+ }
190
+
191
+ /**
192
+ * Best-effort background in-range self-update. Never throws: any failure leaves
193
+ * the cache/pointer untouched so the cached runtime keeps serving and the check
194
+ * retries on a later boot. Runs only in the detached `__self-update` child.
195
+ */
196
+ async function backgroundSelfUpdate(installed) {
197
+ if (!parseSemver(installed)) return;
198
+ try {
199
+ const current = await resolveActiveVersion(installed);
200
+ // ADR 0091: discover the newest same-major version from the npm registry
201
+ // (the phantom `releases/download/v1/` channel is gone). The runtime itself
202
+ // still comes from that version's pinned GitHub release.
203
+ const metadataText = (await fetchBuffer(registryPackageUrl())).toString("utf8");
204
+ const candidate = newestSameMajorFromRegistry(metadataText, installed);
205
+ const target = selectInRangeUpdate(installed, current, candidate);
206
+ if (!target) return;
207
+ const targetManifest = await fetchRuntimeManifest(`v${target}`);
208
+ await ensureRuntime(target, { mayFetch: true }); // fetch + checksum-verify the target runtime
209
+ await assertRuntimeCacheMatches(target, targetManifest);
210
+ const ptr = pointerPath();
211
+ const tmp = `${ptr}.${target}.tmp`;
212
+ await mkdir(dirname(ptr), { recursive: true });
213
+ await writeFile(tmp, JSON.stringify({ version: target }));
214
+ await rename(tmp, ptr); // atomic swap: pointer flips last, all-or-nothing
215
+ await logLine(`self-update: swapped to ${target} for next boot`);
216
+ } catch (err) {
217
+ await logLine(`self-update check failed (${err?.message ?? err}); cached runtime keeps serving`);
218
+ }
219
+ }
220
+
221
+ /** Fire-and-forget the detached background self-update; never blocks the caller. */
222
+ function spawnBackgroundSelfUpdate() {
223
+ try {
224
+ const self = process.argv[1];
225
+ if (!self) return;
226
+ const child = spawn(process.execPath, [self, "__self-update"], {
227
+ detached: true,
228
+ stdio: "ignore",
229
+ });
230
+ child.unref();
231
+ } catch {
232
+ /* best-effort */
233
+ }
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // IO
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function runtimeRoot() {
241
+ const base =
242
+ process.env.RED_BRAIN_CACHE_DIR ||
243
+ process.env.XDG_CACHE_HOME ||
244
+ join(homedir(), ".cache");
245
+ return join(base, "reddb-brain");
246
+ }
247
+
248
+ async function pluginVersion() {
249
+ const root =
250
+ process.env.CLAUDE_PLUGIN_ROOT || process.env.CODEX_PLUGIN_ROOT || PLUGIN_ROOT;
251
+ try {
252
+ const pj = JSON.parse(
253
+ await readFile(join(root, ".claude-plugin", "plugin.json"), "utf8"),
254
+ );
255
+ return pj.version;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ async function logLine(msg) {
262
+ try {
263
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
264
+ await appendFile(join(runtimeRoot(), "bootstrap.log"), line).catch(() => {});
265
+ process.stderr.write(`brain bootstrap: ${msg}\n`);
266
+ } catch {
267
+ /* logging must never throw */
268
+ }
269
+ }
270
+
271
+ /** Machine-readable degrade marker path — a `brain doctor` can later read this. */
272
+ export function degradeMarkerPath() {
273
+ return join(runtimeRoot(), "runtime-degraded.json");
274
+ }
275
+
276
+ /**
277
+ * Record an offline / failed-first-fetch degrade as a machine-readable marker
278
+ * the doctor can later report. Best-effort: writing the marker must never throw,
279
+ * so a degrade never becomes a crash.
280
+ */
281
+ async function writeDegradeMarker(version, err, argv) {
282
+ try {
283
+ await mkdir(runtimeRoot(), { recursive: true });
284
+ await writeFile(
285
+ degradeMarkerPath(),
286
+ JSON.stringify(
287
+ {
288
+ schema: "red.brain.runtime-degraded.v1",
289
+ plugin: "brain",
290
+ version: version ?? null,
291
+ reason: String(err?.message ?? err),
292
+ argv,
293
+ at: new Date().toISOString(),
294
+ },
295
+ null,
296
+ 2,
297
+ ),
298
+ );
299
+ } catch {
300
+ /* marker writing must never throw */
301
+ }
302
+ }
303
+
304
+ /** Clear a stale degrade marker once the runtime resolves again. */
305
+ async function clearDegradeMarker() {
306
+ await unlink(degradeMarkerPath()).catch(() => {});
307
+ }
308
+
309
+ async function fetchBuffer(url) {
310
+ const res = await fetch(url, { redirect: "follow" });
311
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
312
+ return Buffer.from(await res.arrayBuffer());
313
+ }
314
+
315
+ async function fetchRuntimeManifest(tag) {
316
+ const raw = await fetchBuffer(assetUrl(RED_SKILLS_REPO, tag, RUNTIME_MANIFEST));
317
+ return JSON.parse(raw.toString("utf8"));
318
+ }
319
+
320
+ async function fetchCheckedAsset(url, expectedSha) {
321
+ const buf = await fetchBuffer(url);
322
+ const got = sha256Hex(buf);
323
+ if (got !== String(expectedSha).toLowerCase()) {
324
+ throw new Error(`checksum mismatch for ${url}: ${got} != ${expectedSha}`);
325
+ }
326
+ return buf;
327
+ }
328
+
329
+ async function writeRuntimeFile(dest, bytes, { mode } = {}) {
330
+ if (existsSync(dest)) {
331
+ const have = sha256Hex(await readFile(dest));
332
+ if (have === sha256Hex(bytes)) {
333
+ if (mode) await chmod(dest, mode);
334
+ return;
335
+ }
336
+ await logLine(`checksum drift at ${dest}, refetching`);
337
+ }
338
+ await mkdir(dirname(dest), { recursive: true });
339
+ await writeFile(dest, bytes);
340
+ if (mode) await chmod(dest, mode);
341
+ }
342
+
343
+ async function assertRuntimeCacheMatches(version, manifest) {
344
+ const dir = join(runtimeRoot(), version);
345
+ const checks = [
346
+ { path: join(dir, "brain-cli.mjs"), sha256: manifest.cli?.sha256, label: "brain cli" },
347
+ ];
348
+ if (manifest.mcp) {
349
+ checks.push({ path: join(dir, "brain-mcp.mjs"), sha256: manifest.mcp.sha256, label: "brain mcp" });
350
+ }
351
+ const key = platformKey();
352
+ const redAsset = key && normalizeRedAsset(manifest.reddb?.assets?.[key]);
353
+ if (!redAsset) throw new Error(`no red binary for platform ${key ?? "unknown"}`);
354
+ checks.push({
355
+ path: join(dir, process.platform === "win32" ? "red.exe" : "red"),
356
+ sha256: redAsset.sha256,
357
+ label: "red binary",
358
+ });
359
+ for (const check of checks) {
360
+ if (!check.sha256) throw new Error(`missing checksum for ${check.label}`);
361
+ const got = sha256Hex(await readFile(check.path));
362
+ if (got !== String(check.sha256).toLowerCase()) {
363
+ throw new Error(`cached ${check.label} checksum mismatch: ${got} != ${check.sha256}`);
364
+ }
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Ensure {brain-cli.mjs, brain-mcp.mjs, red} exist for `version` in the
370
+ * version-keyed cache and return their absolute paths. Returns null when a cold
371
+ * cache is hit on a render/hot path (`mayFetch` false) so the caller can honour
372
+ * the hook no-op contract. Throws (caught by the caller, which falls back to
373
+ * local artifacts) on any fetch failure.
374
+ */
375
+ async function ensureRuntime(version, { mayFetch } = {}) {
376
+ const dir = join(runtimeRoot(), version);
377
+ const cliPath = join(dir, "brain-cli.mjs");
378
+ const mcpPath = join(dir, "brain-mcp.mjs");
379
+ const redPath = join(dir, process.platform === "win32" ? "red.exe" : "red");
380
+
381
+ // Fast path: a complete version-keyed cache is immutable — each asset was
382
+ // sha256-verified before it was written (ensureFile), and a published release
383
+ // version never changes its assets. So when all three already exist, trust them
384
+ // and skip the network entirely. Without this, every SessionStart re-fetched
385
+ // the runtime manifest and re-validated (re-downloading the ~24 MB `red`
386
+ // binary), making session start take ~17 s instead of being instant.
387
+ if (existsSync(cliPath) && existsSync(mcpPath) && existsSync(redPath)) {
388
+ return { cliPath, mcpPath, redPath };
389
+ }
390
+
391
+ // Cold cache on a render/hot path (recurring PostToolUse / Stop / PreCompact
392
+ // hooks): never fetch. Defer to the SessionStart resolve point and no-op this
393
+ // call. `null` tells the caller to honour the hook no-op contract.
394
+ if (!mayFetch) return null;
395
+
396
+ const tag = `v${version}`;
397
+ const manifest = await fetchRuntimeManifest(tag);
398
+
399
+ const cliBytes = await fetchCheckedAsset(
400
+ assetUrl(RED_SKILLS_REPO, tag, manifest.cli.asset),
401
+ manifest.cli.sha256,
402
+ );
403
+ let mcpBytes = null;
404
+ if (manifest.mcp) {
405
+ mcpBytes = await fetchCheckedAsset(
406
+ assetUrl(RED_SKILLS_REPO, tag, manifest.mcp.asset),
407
+ manifest.mcp.sha256,
408
+ );
409
+ }
410
+
411
+ // Native `red` binary (per-platform), reused from reddb-io/reddb releases.
412
+ const key = platformKey();
413
+ const redAsset = key && normalizeRedAsset(manifest.reddb?.assets?.[key]);
414
+ if (!redAsset) throw new Error(`no red binary for platform ${key ?? "unknown"}`);
415
+ const redBytes = await fetchCheckedAsset(
416
+ assetUrl(manifest.reddb.repo, manifest.reddb.tag, redAsset.asset),
417
+ redAsset.sha256,
418
+ );
419
+
420
+ // Every asset has passed its manifest checksum — adopt the runtime.
421
+ await writeRuntimeFile(cliPath, cliBytes);
422
+ if (mcpBytes) await writeRuntimeFile(mcpPath, mcpBytes);
423
+ await writeRuntimeFile(
424
+ redPath,
425
+ redBytes,
426
+ { mode: 0o755 },
427
+ );
428
+
429
+ return { cliPath, mcpPath, redPath };
430
+ }
431
+
432
+ /** Local repo-checkout fallback: built ./dist bundle, else run the TS source via
433
+ * tsx. Returns a spawn spec; `null` when nothing local is runnable. */
434
+ function localCandidate(kind) {
435
+ const file = kind === "mcp" ? "brain-mcp.bundle.min.mjs" : "brain.bundle.min.mjs";
436
+ const source = kind === "mcp" ? "src/mcp-server.ts" : "src/cli.ts";
437
+ const candidates = [
438
+ { command: process.execPath, args: [join(REPO_ROOT, "dist", file)] },
439
+ { command: process.execPath, args: ["--import", "tsx", join(REPO_ROOT, "apps/plugin-brain", source)] },
440
+ ];
441
+ for (const c of candidates) {
442
+ if (existsSync(c.args[c.args.length - 1])) return c;
443
+ }
444
+ return null;
445
+ }
446
+
447
+ function localRedBin() {
448
+ const bin = join(
449
+ REPO_ROOT,
450
+ "node_modules/.pnpm/@reddb-io+sdk@1.7.0/node_modules/@reddb-io/sdk/bin",
451
+ process.platform === "win32" ? "red.exe" : "red",
452
+ );
453
+ return existsSync(bin) ? bin : null;
454
+ }
455
+
456
+ function run(command, args, env) {
457
+ return new Promise((resolve) => {
458
+ const child = spawn(command, args, { stdio: "inherit", env });
459
+ child.on("exit", (code) => resolve(code ?? 0));
460
+ child.on("error", () => resolve(1));
461
+ });
462
+ }
463
+
464
+ // ── Inert MCP server (issue #843) ────────────────────────────────────────────
465
+ // When the plugin is gated off (ADR 0067), the `cli`/`hook` paths no-op by
466
+ // printing `{}` and exiting 0. The `mcp` stdio path cannot do that: exiting
467
+ // closes the pipe before the MCP handshake, which the host reports as
468
+ // `✘ Failed to connect` ("1 error during load"). Instead, speak just enough of
469
+ // the MCP stdio protocol (newline-delimited JSON-RPC 2.0) to complete the
470
+ // handshake and expose zero tools — a valid, empty, inert server. No bundle
471
+ // fetch, no RedDB, no hooks, no tools: the only thing this does is not
472
+ // fake-fail the handshake. Exported for tests.
473
+ export function startInertMcpServer({ name, version } = {}) {
474
+ const serverInfo = { name: name || "inert", version: version || "0.0.0" };
475
+ let buffer = "";
476
+ const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`);
477
+ const handle = (msg) => {
478
+ if (!msg || typeof msg !== "object") return;
479
+ // A request carries an id; a notification (e.g. notifications/initialized)
480
+ // does not — ignore notifications. (id may legitimately be 0.)
481
+ if (msg.id === undefined || msg.id === null) return;
482
+ const { id, method, params } = msg;
483
+ if (method === "initialize") {
484
+ send({
485
+ jsonrpc: "2.0",
486
+ id,
487
+ result: {
488
+ protocolVersion: params?.protocolVersion || "2024-11-05",
489
+ capabilities: { tools: {} },
490
+ serverInfo,
491
+ },
492
+ });
493
+ return;
494
+ }
495
+ if (method === "tools/list") {
496
+ send({ jsonrpc: "2.0", id, result: { tools: [] } });
497
+ return;
498
+ }
499
+ if (method === "ping") {
500
+ send({ jsonrpc: "2.0", id, result: {} });
501
+ return;
502
+ }
503
+ send({
504
+ jsonrpc: "2.0",
505
+ id,
506
+ error: { code: -32601, message: `Method not found: ${method}` },
507
+ });
508
+ };
509
+ process.stdin.setEncoding("utf8");
510
+ process.stdin.on("data", (chunk) => {
511
+ buffer += chunk;
512
+ let idx;
513
+ while ((idx = buffer.indexOf("\n")) >= 0) {
514
+ const line = buffer.slice(0, idx).trim();
515
+ buffer = buffer.slice(idx + 1);
516
+ if (!line) continue;
517
+ try {
518
+ handle(JSON.parse(line));
519
+ } catch {
520
+ /* ignore malformed frames */
521
+ }
522
+ }
523
+ });
524
+ process.stdin.on("end", () => process.exit(0));
525
+ process.stdin.resume();
526
+ }
527
+
528
+ // ── Per-directory plugin gate (ADR 0067) ─────────────────────────────────────
529
+ // Mirror of packages/shared/plugin-gate.ts (pluginEnabledInConfig + walk-up),
530
+ // inlined because the launcher ships dependency-free in the plugin checkout and
531
+ // cannot import the shared module at runtime. Keep the three copies in lockstep.
532
+ function stripInlineComment(value) {
533
+ const hash = value.indexOf(" #");
534
+ return (hash >= 0 ? value.slice(0, hash) : value).trim();
535
+ }
536
+ function flatConfigValue(text, dottedKey) {
537
+ const stack = [];
538
+ for (const rawLine of text.split("\n")) {
539
+ const line = rawLine.replace(/\r$/, "");
540
+ const trimmed = line.trim();
541
+ if (!trimmed || trimmed.startsWith("#")) continue;
542
+ const colon = line.indexOf(":");
543
+ if (colon < 0) continue;
544
+ const indent = line.length - line.trimStart().length;
545
+ const key = line.slice(0, colon).trim();
546
+ if (!key) continue;
547
+ const value = line.slice(colon + 1).trim();
548
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
549
+ stack.push({ indent, key });
550
+ if (value && stack.map((s) => s.key).join(".") === dottedKey) {
551
+ return stripInlineComment(value);
552
+ }
553
+ }
554
+ return undefined;
555
+ }
556
+ function isPluginEnabled(cwd, plugin) {
557
+ let dir = cwd;
558
+ for (let i = 0; i < 16; i++) {
559
+ const candidate = join(dir, ".red", "config.yaml");
560
+ if (existsSync(candidate)) {
561
+ try {
562
+ return flatConfigValue(readFileSync(candidate, "utf8"), `plugins.${plugin}.enabled`) === "true";
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+ const parent = dirname(dir);
568
+ if (parent === dir) break;
569
+ dir = parent;
570
+ }
571
+ return false;
572
+ }
573
+
574
+ async function main() {
575
+ const argv = process.argv.slice(2);
576
+ // Detached in-range self-update worker (ADR 0084): resolve version + gate, run
577
+ // the background check, exit 0. Handled before anything else so it never runs
578
+ // a delegate or the MCP server.
579
+ if (argv[0] === "__self-update") {
580
+ if (isPluginEnabled(process.cwd(), "brain")) {
581
+ await backgroundSelfUpdate(await pluginVersion());
582
+ }
583
+ process.exit(0);
584
+ }
585
+ // Gate FIRST (ADR 0067): if brain is not explicitly enabled in this directory,
586
+ // stay fully inert — no fetch, no local fallback — and honour the hooks' no-op
587
+ // contract (`{}` on stdout, exit 0).
588
+ if (!isPluginEnabled(process.cwd(), "brain")) {
589
+ // The mcp stdio path must complete the handshake (empty server) rather than
590
+ // exit, or the host reports `✘ Failed to connect` (issue #843). cli/hook
591
+ // paths keep the silent no-op exit.
592
+ if (argv[0] === "mcp" || argv[0] === "brain-mcp") {
593
+ startInertMcpServer({ name: "red-brain", version: await pluginVersion() });
594
+ return;
595
+ }
596
+ process.stdout.write("{}");
597
+ process.exit(0);
598
+ }
599
+ const kind = argv[0] === "mcp" || argv[0] === "brain-mcp" ? "mcp" : "cli";
600
+ const extra = kind === "mcp" ? argv.slice(1) : argv;
601
+
602
+ // 1. Primary: fetch the release-published runtime into the version-keyed cache.
603
+ let version = null;
604
+ let fetchErr = null;
605
+ try {
606
+ const installed = await pluginVersion();
607
+ if (!installed) throw new Error("could not resolve plugin version");
608
+ // Serve the in-range version a prior background update swapped in (ADR 0084);
609
+ // LOCAL read only — never fetches, so no render/hook path blocks on network.
610
+ version = await resolveActiveVersion(installed);
611
+ // On session start, kick a DETACHED background in-range self-update for the
612
+ // NEXT boot. Detached + unref'd so it never delays the hook or any surface.
613
+ if (argv[0] === "hook" && argv[1] === "SessionStart") spawnBackgroundSelfUpdate();
614
+ const rt = await ensureRuntime(version, { mayFetch: mayFetchRuntime(argv) });
615
+ if (!rt) {
616
+ // Render/hot-path hook on a cold cache: SessionStart will warm it. No-op.
617
+ process.stdout.write("{}");
618
+ process.exit(0);
619
+ }
620
+ // The runtime resolved: any earlier degrade is stale.
621
+ await clearDegradeMarker();
622
+ const target = kind === "mcp" ? rt.mcpPath : rt.cliPath;
623
+ const code = await run(process.execPath, [target, ...extra], {
624
+ ...process.env,
625
+ REDDB_BIN: rt.redPath,
626
+ });
627
+ process.exit(code);
628
+ } catch (err) {
629
+ fetchErr = err;
630
+ await logLine(`release fetch failed (${err?.message ?? err}); trying local checkout`);
631
+ }
632
+
633
+ // 2. Fallback: a repo checkout's built bundle or TS source (pre-release / dev).
634
+ const local = localCandidate(kind);
635
+ if (local) {
636
+ const env = { ...process.env };
637
+ if (!env.REDDB_BIN) {
638
+ const red = localRedBin();
639
+ if (red) env.REDDB_BIN = red;
640
+ }
641
+ const code = await run(local.command, [...local.args, ...extra], env);
642
+ process.exit(code);
643
+ }
644
+
645
+ // 3. No runtime at all (installed copy, first fetch offline): honour the hook
646
+ // no-op contract and leave a machine-readable marker the doctor can report.
647
+ await writeDegradeMarker(version, fetchErr, argv);
648
+ await logLine("no runnable runtime: release fetch failed and no local bundle/source found");
649
+ process.stdout.write("{}");
650
+ process.exit(0);
651
+ }
652
+
653
+ // Only run when invoked directly (tests may import the pure helpers).
654
+ if (process.argv[1] && process.argv[1].endsWith("bootstrap.mjs")) {
655
+ main();
656
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="$(cd "$(dirname "$0")/../../.." && pwd)"
5
+ cd "$REPO"
6
+
7
+ node scripts/plugin-structural-smoke.mjs plugins/brain