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