@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.
- package/bin/build-info.mjs +87 -0
- package/bin/cli-frontdoor.mjs +14 -0
- package/bin/red-skills-brain.mjs +17 -22
- package/bin/red-skills-content.mjs +17 -0
- package/bin/red-skills-hook.mjs +45 -0
- package/bin/red-skills-memory.mjs +17 -22
- package/bin/red-skills-redskilled-mcp.mjs +2 -0
- package/bin/red-skills-resource.mjs +21 -0
- package/bin/runtime-compatibility.mjs +17 -0
- package/bin/runtime-paths.mjs +13 -0
- package/dist/code-nav.bundle.min.mjs +1 -1
- package/dist/opencode-host.bundle.min.mjs +1 -1
- package/dist/redskilled-mcp.bundle.min.mjs +36 -36
- package/dist/redskilled.bundle.min.mjs +234 -230
- package/dist/release.bundle.min.mjs +1 -1
- package/dist/rsp-core.bundle.min.mjs +1 -1
- package/dist/rsp.bundle.min.mjs +1 -1
- package/dist/statusline.bundle.min.mjs +1 -1
- package/package.json +9 -4
- package/runtime/hook-routes.json +125 -0
- package/runtime/plugins/brain/scripts/bootstrap.mjs +656 -0
- package/runtime/plugins/brain/scripts/structural-smoke.sh +7 -0
- package/runtime/plugins/dev/hooks/branch-lock-codex.sh +105 -0
- package/runtime/plugins/dev/hooks/code-nav-mcp.sh +63 -0
- package/runtime/plugins/dev/hooks/command-guard.sh +893 -0
- package/runtime/plugins/dev/hooks/ensure-codex-statusline.mjs +144 -0
- package/runtime/plugins/dev/hooks/red-fetch.mjs +38 -0
- package/runtime/plugins/dev/hooks/redskilled-mcp.sh +64 -0
- package/runtime/plugins/dev/hooks/rsp-hook.sh +343 -0
- package/runtime/plugins/dev/hooks/tests/command-guard.test.sh +378 -0
- package/runtime/plugins/dev/hooks/tests/rsp-hook.test.sh +229 -0
- package/runtime/plugins/dev/scripts/memory-bridge.sh +230 -0
- package/runtime/plugins/dev/scripts/structural-smoke.sh +7 -0
- package/runtime/plugins/dev/scripts/tests/memory-bridge.test.sh +555 -0
- package/runtime/plugins/dev/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
- package/runtime/plugins/dev/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
- package/runtime/plugins/dev/skills/engineering/afk/defaults/gradle-pre-worktree.sh +45 -0
- package/runtime/plugins/dev/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
- package/runtime/plugins/dev/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
- package/runtime/plugins/dev/skills/engineering/afk/detectors/cargo.sh +27 -0
- package/runtime/plugins/dev/skills/engineering/afk/detectors/gradle.sh +38 -0
- package/runtime/plugins/dev/skills/engineering/afk/examples/only-mine.sh +32 -0
- package/runtime/plugins/dev/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
- package/runtime/plugins/dev/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
- package/runtime/plugins/dev/skills/engineering/afk/hooks/red-cargo +38 -0
- package/runtime/plugins/dev/skills/engineering/afk/hooks/red-envelope +50 -0
- package/runtime/plugins/dev/skills/engineering/afk/hooks/red-gradle +42 -0
- package/runtime/plugins/dev/skills/engineering/afk/hooks/red-heartbeat +46 -0
- package/runtime/plugins/dev/skills/engineering/afk/hooks/red-validation +79 -0
- package/runtime/plugins/dev/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/runtime/plugins/dev/skills/engineering/red-setup/scripts/install-runtime-shim.sh +181 -0
- package/runtime/plugins/dev/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
- package/runtime/plugins/dev/skills/engineering/wizard/template.sh +211 -0
- package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
- package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +230 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
- package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
- package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
- package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
- package/runtime/plugins/internal/scripts/structural-smoke.sh +7 -0
- package/runtime/plugins/internal/skills/maintainer/create-plugin/scripts/create-plugin.sh +308 -0
- package/runtime/plugins/memory/scripts/bootstrap.mjs +659 -0
- package/runtime/plugins/memory/scripts/structural-smoke.sh +7 -0
- 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
|
+
}
|