feat(m2): rea_bootstrap_stack tool and rea-bootstrap command

This commit is contained in:
John Smith committed 2026-10-17 20:20:41 -04:00
1 parent b7b0aaa676
commit 2fb3aeb085
13 files changed
+635 -16

No files matched your search

+38 -2
View File
@@ -15,7 +15,7 @@ into OpenCode — on top of an already-registered REA MCP server.
`2.0.26`, `rea-agents` `6.5.0`)
- Plugin id: `rea` (`src/index.ts`: `Plugin.define({ id: "rea", setup ... })`;
setup parses options, then registers skills, agents, readiness + tool catalog
policy, and commands)
policy, the bootstrap tool, and commands)
Machine-checkable docs guard: `test/unit/docs.test.ts`
(build/test rules: `AGENTS.md`).
@@ -143,10 +143,11 @@ Source of truth: `src/commands.ts` (registered via `ctx.command.transform`).
| `/rea` | Switch to the `rea` investigator agent and run a staged investigation (`rea-investigation` skill: scope → gather with evidence → infer only from cited evidence). |
| `/rea-doctor` | Run the REA readiness check by calling the `rea_readiness` tool; report each item and act on the returned remediation. |
| `/rea-report` | Produce the REA evidence report (observations vs inferences vs unknowns, every claim with its Evidence ID), then delegate a check to the `rea-auditor` agent. |
| `/rea-bootstrap` | Install the REA reverse-engineering stack by calling the `rea_bootstrap_stack` tool, then call `rea_readiness` to verify; report what installed. |
REA's six MCP prompts surface as host commands under the `rea:<prompt>` namespace
and are **not** duplicated by this plugin (`COMMAND_IDS` contain no `":"`, so the
three slash commands never shadow them):
four slash commands never shadow them):
- `rea:investigate_feature`
- `rea:compare_application_versions`
@@ -196,6 +197,41 @@ During the same `ctx.tool.transform` pass, `applyReaToolCatalogPolicy`:
Use it via `/rea-doctor`, or call `rea_readiness` directly.
## Bootstrap tool: `rea_bootstrap_stack`
Source of truth: `src/bootstrap-tool.ts` (registered via `ctx.tool.transform` as a
native tool with `codemode: false`, reusing the `src/bootstrap.ts` runner; read-only
MCP-wise — no writes, no config mutation, never `ctx.mcp.transform`). Use it via
`/rea-bootstrap`, or call `rea_bootstrap_stack` directly.
`rea_bootstrap_stack` contract:
- Input: `{ installSkills?, installMemory?, installWorkflows? }` (all booleans,
all default `true`; `additionalProperties: false`).
- `installSkills`: install the four MIT/Apache-2.0 RE skill packs via
`npx skills add` (`p4nda0s/reverse-skills`, `ljagiello/ctf-skills`,
`cyberkaida/reverse-engineering-assistant`, `rehan-remade/universal-modder`).
- `installMemory`: `npm install --global @aerovato/operator-helper` then
`operator-helper install opencode-v2`.
- `installWorkflows`: `opencode plugin add @rphang/opencode-workflows`
(guarded by a version-probe `skip`).
- Permission: `execute` calls `ctx.permission.assert({ ... action:
"rea_bootstrap_stack", resources: [directory], save: ["*"] })` **before**
running anything, so installs ask the user first. Hosts that predate
`permission.assert` (observed: `@opencode/cli` 2.0.26) make the tool refuse
with a descriptive error instead of running unapproved installs; hosts with
the capability (observed: 2.0.32) gate normally.
- Progress: each step is reported through the tool context's `progress`.
- Output: `{ content, metadata: { results } }` where `content` is the formatted
per-step `[OK]` / `[FAILED]` / `[SKIPPED]` summary (plus a `Failure output:`
section when steps fail) and `metadata.results` is the raw `StepResult[]`.
- Workflows failure is non-fatal: if the `OpenCode Workflows plugin` step does
not complete, the bootstrap still finishes and the content notes that bounded
fan-out falls back to REA's own subagent delegation (that plugin fails to
load on newer CLIs; see `docs/NOTES.md` D5).
- After the run, the tool calls `ctx.skill.reload()` so newly installed skills
are visible without a restart.
## What this plugin deliberately does NOT do
- **No MCP registration or config writes.** Setup only calls `agent`/`skill`/`tool`/
+133
View File
@@ -322,3 +322,136 @@ Root-cause chain (all paths inside the scratch cache):
`formatResults` renders `[OK]`/`[FAILED]`/`[SKIPPED]`.
- [x] `bun test --path-ignore-patterns 'review/**' test/unit/bootstrap.test.ts` → all pass (9/9).
- [x] Workflows-load fact verified in `/tmp` scratch and recorded here (D5: fails on 2.0.32, loads on pinned 2.0.15; fallback = REA subagent delegation, build not blocked).
---
# M2 — `rea_bootstrap_stack` tool + `/rea-bootstrap` command discovery log
Date: 2026-10-18 (UTC). Branch: `claude/project-thread-gnv9zf`.
Worktree: `/home/john/Projects/RE/opencode-rea/.worktrees/m0-spike`.
Spec: `docs/IMPLEMENTATION_PROMPT.md` §6 M2 + §4.1 (tool transform editor
shapes, permission assert), §7, §5 layout. CLI pin stays 2.0.26 (no bump).
## M2.1 — `ctx.permission.assert` availability probe (new discrepancy D6)
§4.1/§6-M2 say to call `ctx.permission.assert(...)` before running, citing
`unity.gamedev.kit/src/v2.ts`. But `@opencode/[email protected]` types declare no
`assert`: `dist/promise/permission.d.ts` is
`Pick<PermissionApi, "list" | "get" | "reply"> & { hook }`, and the bundled
`@opencode/[email protected]` generated permission API has only
`request/saved/create/list/get/reply` — no `assert` anywhere in the package.
The Unity kit targets `@opencode/[email protected]`, so the reference may postdate
our pin. Verified empirically with a scratch probe plugin (tool returning
`typeof ctx.permission.assert` + `Object.keys(ctx.permission)`, driven by the
real CLI + fixture model, scratch deleted afterwards):
- 2.0.26 (`node_modules/.bin/opencode`, `opencode v2.0.26`):
`{"typeofAssert":"undefined","permissionKeys":["hook","list","get","reply"]}`
- 2.0.32 (`/tmp/opencode/wf-check/node_modules/.bin/opencode`, `opencode v2.0.32`):
`{"typeofAssert":"function","permissionKeys":["hook","list","get","reply","assert"]}`
## Discrepancy vs Section 4 (new)
- **D6 — `permission.assert` does not exist on the pinned 2.0.26 host; it was
added by 2.0.32.** Calling it unconditionally would throw
`TypeError: not a function` at runtime on our pin. Design (fail-closed per
Agency rules): `src/bootstrap-tool.ts` `readPermissionAssert()` calls the
host capability when present and otherwise throws a descriptive error
refusing to run installs without approval. Nothing proceeds unapproved on
any host. re-box (2.0.32) gates normally. Build not blocked.
## M2.2 — implementation
File placement: new `src/bootstrap-tool.ts` (thin; reuses the M1 runner —
no `node:fs`/`node:path`, no new runtime deps, no `ctx.mcp.transform`).
`src/index.ts` `setup()` registers it after readiness; `src/commands.ts`
gains `/rea-bootstrap`; `src/readiness.ts` + `src/bootstrap.ts` adjusted
(see below). Exports added to `src/index.ts` for tests/docs-guard.
- Tool registration mirrors `gamedev_bootstrap_stack`:
`editor.add({ name: "rea_bootstrap_stack", input: { installSkills?,
installMemory?, installWorkflows? } booleans, additionalProperties: false,
options: { codemode: false } })`. `execute` calls `readPermissionAssert`
with `{ sessionID, agent, source: { type: "tool", messageID, id }, action:
"rea_bootstrap_stack", resources: [directory], save: ["*"] }` **before**
`runBootstrap(...)` with `onStep` wired to `context.progress`, then
`await ctx.skill.reload()`. Returns `{ content, metadata: { results } }`.
- D5 (workflows failure non-fatal): `runBootstrap` already records step
failures instead of throwing; the tool additionally appends an explicit
fallback note via `withWorkflowsFallbackNote()` when the workflows step is
not `ok` ("bounded fan-out falls back to REA's own subagent delegation").
New shared const `WORKFLOWS_STEP_LABEL` in `src/bootstrap.ts` (value
unchanged, M1 unit assertions untouched).
- New-tool fallout in `src/readiness.ts`: the plugin's own
`rea_bootstrap_stack` matches the `rea_` prefix, so without an exclusion it
would inflate the MCP-only `reaToolCount` (T08/T10 hermetic `0` broke: got
1) and a global `codemode` option would clobber its direct mode. Added
`PLUGIN_OWN_TOOL_NAMES = { rea_readiness, rea_bootstrap_stack }` skipping it
in `countReaToolsByMode` and in the `applyReaToolCatalogPolicy` codemode
loop. `test/unit/readiness.test.ts` extended with a bootstrap entry (counts
unchanged).
- `/rea-bootstrap` (`COMMAND_IDS` + definition): prompt tells the agent to
call `rea_bootstrap_stack` (passing requested flags), then `rea_readiness`,
and report per-step results + readiness summary + the workflows-fallback
note when applicable. No agent switch. `test/unit/commands.test.ts` and
`test/integration/commands.test.ts` exact lists updated to the four
commands; `test/unit/options.test.ts` setup stub gains the host-guaranteed
`location` (unity kit relies on `ctx.location.directory` too).
- Docs: `README.md` gains a `/rea-bootstrap` row ("three" → "four") and a
"Bootstrap tool: `rea_bootstrap_stack`" section (flags, permission gate +
D6 host note, workflows non-fatal + D5 note, `codemode: false`,
`{ content, metadata: { results } }`, `ctx.skill.reload()`).
`test/unit/docs.test.ts` extended: `"rea-bootstrap"` in the command-id
loop + a dedicated test asserting README tokens and `src/bootstrap-tool.ts`
markers (`permission.assert`, `ctx.skill.reload`, `runBootstrap`,
`codemode`).
## M2.3 — live gating evidence (real hosts, built plugin, scratch tests deleted)
Zero-step calls (`installSkills/installMemory/installWorkflows: false`, so no
command ever runs) driven by the real CLI + fixture model:
- 2.0.26 + `--auto`: `tool_use` `status: "error"`,
`error: "rea_bootstrap_stack: host permission.assert is unavailable —
refusing to run installs without approval. ..."` — fail-closed, exit 0.
- 2.0.32 + `--auto`: `tool_use` `status: "completed"`,
`metadata: { results: [], ... }`, `output: ""` — host `assert` approved the
call end to end.
- 2.0.32 without `--auto`: `status: "completed"` as well (standalone-run
host policy auto-resolves the programmatic assert; the user-facing denial
path — assert rejects → execute rejects before any install — is proven
deterministically in `test/integration/bootstrap.test.ts` via an injected
rejecting assert, plus the D6 fail-closed path via an assert-less host).
Recorded as observed; host approval policy is not this plugin's logic.
## M2.4 — test evidence
```
$ bun run check # tsc --noEmit -p tsconfig.json → exit 0
$ bun run build # tsc -p tsconfig.build.json → exit 0
$ bun test --path-ignore-patterns 'review/**' test/unit test/integration
126 pass, 0 fail, 2737 expect() calls, 22 files
```
Includes new `test/integration/bootstrap.test.ts` (6 tests: tool listed
after `setup()` with `codemode: false` + closed boolean schema; denial
rejects with exact assert shape `{ action: "rea_bootstrap_stack",
resources: [directory], save: ["*"] }` and no reload; approved all-false run
returns `metadata.results: []` + one `skill.reload()`; assert-less host
refuses via `/permission\.assert/`; D5 note only on non-ok workflows;
`COMMAND_IDS` contains `rea-bootstrap`, no `":"`, command prompt names both
tools).
## M2 acceptance verdict
- [x] `test/integration/bootstrap.test.ts`: after `setup()`,
`ctx.tool.list()` includes `rea_bootstrap_stack`; calling it without
approval is rejected (permission asserted with the exact spec shape);
`COMMAND_IDS` includes `rea-bootstrap` and contains no `":"`.
- [x] `bun run check` passes.
`bun test --path-ignore-patterns 'review/**' test/unit test/integration`
→ all pass (126/126).
- [x] `docs.test.ts` (extended) confirms README documents the new tool and
command (fails without the README section).
- [x] Workflows failure handled non-fatal (D5 note helper + unit-covered;
step failures never throw out of `runBootstrap`).
+150
View File
@@ -0,0 +1,150 @@
import { WORKFLOWS_STEP_LABEL, formatResults, runBootstrap, type BootstrapOptions, type StepResult } from "./bootstrap.js"
/**
* M2 native tool: `rea_bootstrap_stack`. Mirrors the Unity kit's
* `gamedev_bootstrap_stack` (permission-gated, progress-reporting installs,
* skill reload afterwards) for the REA stack. Thin by design: it reuses the
* M1 runner in `src/bootstrap.ts` and adds no runtime dependencies.
*/
export const BOOTSTRAP_TOOL_NAME = "rea_bootstrap_stack"
export const BOOTSTRAP_PERMISSION_ACTION = "rea_bootstrap_stack"
/** JSON-Schema input: three optional booleans, nothing else. */
export const BootstrapToolInputSchema = {
type: "object",
properties: {
installSkills: { type: "boolean", description: "Install the four RE skill packs. Default: true." },
installMemory: { type: "boolean", description: "Install @aerovato/operator-helper and run `operator-helper install opencode-v2`. Default: true." },
installWorkflows: { type: "boolean", description: "Install @rphang/opencode-workflows. Default: true." },
},
additionalProperties: false,
} as const
export interface BootstrapToolInput {
readonly installSkills?: boolean
readonly installMemory?: boolean
readonly installWorkflows?: boolean
}
export interface BootstrapToolCallContext {
readonly sessionID: string
readonly agent?: string
readonly messageID?: string
readonly id?: string
readonly signal?: AbortSignal
readonly progress: (update: { status: string }) => Promise<unknown> | unknown
}
interface BootstrapToolEditorLike {
add(tool: {
name: string
description?: string
input: unknown
options?: { codemode?: boolean }
execute: (input: BootstrapToolInput, context: BootstrapToolCallContext) => Promise<unknown>
}): void
}
interface BootstrapToolCtxLike {
tool: {
transform(cb: (editor: BootstrapToolEditorLike) => void): unknown
}
skill: {
reload(): Promise<unknown>
}
/** `permission` is untyped: `assert` exists on newer hosts only (see D6). */
permission: unknown
location: {
directory: string
}
}
interface PermissionAssertRequest {
readonly sessionID: string
readonly agent?: string
readonly source?: { type: string; messageID?: string; id?: string }
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save: ReadonlyArray<string>
}
/**
* Fail-closed permission gate. Newer hosts (observed: 2.0.32) provide
* `ctx.permission.assert`; the pinned 2.0.26 host does not (see docs/NOTES.md
* D6). When the capability is missing we refuse to run installs rather than
* running them unapproved.
*/
export function readPermissionAssert(permission: unknown): (request: PermissionAssertRequest) => Promise<unknown> {
const assert = (permission as { assert?: unknown } | null | undefined)?.assert
if (typeof assert !== "function") {
throw new Error(
`${BOOTSTRAP_TOOL_NAME}: host permission.assert is unavailable — refusing to run installs without approval. ` +
`Use a host that provides permission.assert (observed on @opencode/cli 2.0.32), or install the stack manually.`,
)
}
return assert as (request: PermissionAssertRequest) => Promise<unknown>
}
/**
* D5: the OpenCode Workflows step is known to fail on hosts the plugin does
* not support (its upstream pins opencode 2.0.15; it fails to load on
* 2.0.32). A non-ok workflows step must not fail the whole bootstrap:
* bounded fan-out falls back to REA's own subagent delegation. Surface that
* fallback next to the formatted results.
*/
export function withWorkflowsFallbackNote(content: string, results: readonly StepResult[]): string {
const workflows = results.find((result) => result.label === WORKFLOWS_STEP_LABEL)
if (workflows && workflows.status !== "ok") {
return (
`${content}\n\nNote: the "${WORKFLOWS_STEP_LABEL}" step did not complete (${workflows.status}); ` +
`bounded fan-out falls back to REA's own subagent delegation (see docs/NOTES.md D5).`
)
}
return content
}
function normalizeInput(input: BootstrapToolInput | undefined): BootstrapOptions {
return {
...(input?.installSkills === undefined ? {} : { installSkills: input.installSkills }),
...(input?.installMemory === undefined ? {} : { installMemory: input.installMemory }),
...(input?.installWorkflows === undefined ? {} : { installWorkflows: input.installWorkflows }),
}
}
/** Register the `rea_bootstrap_stack` native tool via ctx.tool.transform (never ctx.mcp.transform). */
export async function registerBootstrapTool(ctx: BootstrapToolCtxLike): Promise<void> {
const directory = ctx.location.directory
await ctx.tool.transform((tools) => {
tools.add({
name: BOOTSTRAP_TOOL_NAME,
description:
"Install the REA reverse-engineering stack (skill packs, operator-memory, parallel-fan-out workflows). Runs package installs, so it asks the user first.",
input: BootstrapToolInputSchema,
options: { codemode: false },
execute: async (input, context) => {
const args = normalizeInput(input ?? {})
await readPermissionAssert(ctx.permission)({
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
action: BOOTSTRAP_PERMISSION_ACTION,
resources: [directory],
save: ["*"],
})
const results = await runBootstrap(directory, args, {
signal: context.signal,
onStep: (label, index, total) => {
void context.progress({ status: `${index}/${total} ${label}` })
},
})
await ctx.skill.reload()
const content = withWorkflowsFallbackNote(formatResults(results), results)
return { content, metadata: { results } }
},
})
})
}
/** Convention alias: the module exposes one `register` alongside `registerBootstrapTool`. */
export const register = registerBootstrapTool
+4 -1
View File
@@ -27,6 +27,9 @@ export interface StepResult {
detail: string
}
/** Label of the workflows install step (guarded by a version-probe `skip`; see docs/NOTES.md D5). */
export const WORKFLOWS_STEP_LABEL = "OpenCode Workflows plugin"
/** MIT/Apache-2.0 skill packs installed via `npx skills add`. */
export const REA_SKILL_PACKS: ReadonlyArray<{ label: string; repo: string }> = [
{ label: "p4nda0s/reverse-skills", repo: "p4nda0s/reverse-skills" },
@@ -91,7 +94,7 @@ export function reaBootstrapSteps(cwd: string, o: BootstrapOptions = {}): Bootst
}
if (o.installWorkflows ?? true) {
steps.push({
label: "OpenCode Workflows plugin",
label: WORKFLOWS_STEP_LABEL,
command: "opencode",
args: ["plugin", "add", "@rphang/opencode-workflows"],
skip: () => workflowsSkipReason(),
+25 -3
View File
@@ -1,8 +1,9 @@
import type { ReaOptions } from "./options.js"
import { sanitizeServerNamespace } from "./options.js"
import { BOOTSTRAP_TOOL_NAME } from "./bootstrap-tool.js"
import { READINESS_TOOL_NAME } from "./readiness.js"
export const COMMAND_IDS = ["rea", "rea-doctor", "rea-report"] as const
export const COMMAND_IDS = ["rea", "rea-doctor", "rea-report", "rea-bootstrap"] as const
export type CommandId = (typeof COMMAND_IDS)[number]
@@ -22,7 +23,7 @@ export type ReaPromptBaseName = (typeof REA_PROMPT_BASE_NAMES)[number]
export const REA_PROMPT_COMMANDS = REA_PROMPT_BASE_NAMES.map((name) => `rea:${name}` as const)
// Invariant: none of COMMAND_IDS contains ":" and none equals any of
// REA_PROMPT_COMMANDS, so the three slash commands never shadow or duplicate
// REA_PROMPT_COMMANDS, so the four slash commands never shadow or duplicate
// the six `rea:<prompt>` MCP prompt commands.
export interface CommandPromptLike {
@@ -97,7 +98,16 @@ function reaReportText(args: string): string {
return appendArgs(template, args)
}
/** Register the 3 rea slash commands via ctx.command.transform. */
function reaBootstrapText(args: string): string {
const template = [
`Install the REA reverse-engineering stack by calling the ${BOOTSTRAP_TOOL_NAME} tool (passing through any requested installSkills/installMemory/installWorkflows flags), then call the ${READINESS_TOOL_NAME} tool to verify the installation.`,
"Report what installed: the per-step results from the bootstrap tool ([OK] / [FAILED] / [SKIPPED] lines) plus the readiness summary (server status, tool counts, skill availability, version).",
"If the workflows step did not complete, note that bounded fan-out falls back to REA's own subagent delegation.",
].join(" ")
return appendArgs(template, args)
}
/** Register the 4 rea slash commands via ctx.command.transform. */
export async function registerCommands(ctx: CommandCtxLike, options: ReaOptions): Promise<void> {
const readinessTool = READINESS_TOOL_NAME
await ctx.command.transform((editor) => {
@@ -138,6 +148,18 @@ export async function registerCommands(ctx: CommandCtxLike, options: ReaOptions)
})
},
})
editor.add({
name: "rea-bootstrap",
description: "Install the REA reverse-engineering stack, verify with rea_readiness, and report what installed",
execute: async (input) => {
await ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: reaBootstrapText(userText(input)),
delivery: input.delivery,
})
},
})
})
}
+13
View File
@@ -1,10 +1,22 @@
import { Plugin } from "@opencode/plugin"
import { registerAgents } from "./agents.js"
import { registerBootstrapTool } from "./bootstrap-tool.js"
import { registerCommands } from "./commands.js"
import { parseOptions } from "./options.js"
import { registerReadiness } from "./readiness.js"
import { registerSkills } from "./skills.js"
export {
BOOTSTRAP_PERMISSION_ACTION,
BOOTSTRAP_TOOL_NAME,
BootstrapToolInputSchema,
readPermissionAssert,
registerBootstrapTool,
withWorkflowsFallbackNote,
type BootstrapToolCallContext,
type BootstrapToolInput,
} from "./bootstrap-tool.js"
export {
AGENT_IDS,
buildAgentPermissions,
@@ -68,6 +80,7 @@ export default Plugin.define({
await registerSkills(ctx as any, options)
await registerAgents(ctx as any, options)
await registerReadiness(ctx as any, options)
await registerBootstrapTool(ctx as any)
await registerCommands(ctx as any, options)
},
})
+13 -3
View File
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { BOOTSTRAP_TOOL_NAME } from "./bootstrap-tool.js"
import type { ReaOptions } from "./options.js"
import { sanitizeServerNamespace } from "./options.js"
@@ -32,6 +33,15 @@ export type PinnedReaToolName = (typeof PINNED_REA_TOOLS)[number]
export const REA_NAMESPACE_DESCRIPTION =
"REA reverse-engineering analysis, artifact inspection, version comparison, and runtime observation tools."
/**
* Native tools owned by this plugin rather than the MCP server. They share
* the `<sanitized-server>_` prefix, so the rea_ counters and the codemode
* override loop must skip them explicitly (otherwise the hermetic count is
* wrong and a global `codemode` option would clobber the plugin's own
* direct-mode tools).
*/
const PLUGIN_OWN_TOOL_NAMES: ReadonlySet<string> = new Set([READINESS_TOOL_NAME, BOOTSTRAP_TOOL_NAME])
export interface ReadinessOutput {
readonly server: string
readonly status: string
@@ -162,7 +172,7 @@ export interface ReaToolCounts {
export function countReaToolsByMode(toolListResult: unknown, server: string): ReaToolCounts {
// Effective names carry the `<sanitized-server>_` prefix (default server is "rea", so
// this is the rea_ count). Entries expose `id` and/or `name`; check both.
// The plugin's own readiness tool matches the default prefix, so exclude it:
// The plugin's own native tools match the default prefix, so exclude them:
// the count reflects MCP-server tools only (0 when the server is absent).
const prefix = `${sanitizeServerNamespace(server)}_`
const entries: Array<any> = Array.isArray(toolListResult)
@@ -176,7 +186,7 @@ export function countReaToolsByMode(toolListResult: unknown, server: string): Re
for (const entry of entries) {
const id = typeof entry?.id === "string" ? (entry.id as string) : ""
const name = typeof entry?.name === "string" ? (entry.name as string) : ""
if (id === READINESS_TOOL_NAME || name === READINESS_TOOL_NAME) continue
if (PLUGIN_OWN_TOOL_NAMES.has(id) || PLUGIN_OWN_TOOL_NAMES.has(name)) continue
if (id.startsWith(prefix) || name.startsWith(prefix)) {
reaToolCount++
if (entry?.options?.codemode === false) directToolCount++
@@ -248,7 +258,7 @@ export function applyReaToolCatalogPolicy(tools: ToolEditorLike, options: ReaOpt
: typeof entry?.name === "string"
? (entry.name as string)
: ""
if (!id.startsWith(prefix) || id === READINESS_TOOL_NAME || pinnedIds.has(id)) continue
if (!id.startsWith(prefix) || PLUGIN_OWN_TOOL_NAMES.has(id) || pinnedIds.has(id)) continue
tools.update(id, (tool) => {
tool.options = {
...(tool.options ?? {}),
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, test } from "bun:test"
import plugin from "../../src/index.js"
import {
BOOTSTRAP_TOOL_NAME,
BootstrapToolInputSchema,
registerBootstrapTool,
withWorkflowsFallbackNote,
} from "../../src/bootstrap-tool.js"
import { COMMAND_IDS, REA_PROMPT_COMMANDS, registerCommands } from "../../src/commands.js"
const LIVE_NOTE = "live-model behaviour unverified"
interface CapturedTool {
name: string
description?: string
input: unknown
options?: { codemode?: boolean }
execute: (input: any, context: any) => Promise<any>
}
interface SetupCapture {
addedTools: CapturedTool[]
asserted: Array<Record<string, unknown>>
reloaded: { count: number }
progressCalls: Array<unknown>
directory: string
deny: boolean
ctx: any
}
/** Fake host ctx proving registration + permission gating without network (all-flag-false runs zero steps). */
function makeSetupCapture(opts?: { deny?: boolean; withAssert?: boolean; directory?: string }): SetupCapture {
const cap: SetupCapture = {
addedTools: [],
asserted: [],
reloaded: { count: 0 },
progressCalls: [],
directory: opts?.directory ?? "/tmp/rea-m2-bootstrap-ws",
deny: opts?.deny ?? true,
ctx: undefined as any,
}
const permission: Record<string, unknown> =
opts?.withAssert === false
? {}
: {
assert: async (request: Record<string, unknown>) => {
cap.asserted.push(request)
if (cap.deny) throw new Error("permission denied: rea_bootstrap_stack not approved")
},
}
cap.ctx = {
options: {},
location: { directory: cap.directory },
skill: {
transform: async (cb: (editor: any) => void) => {
cb({ add: () => {} })
return { dispose: async () => {} }
},
reload: async () => {
cap.reloaded.count++
},
},
agent: {
transform: async (cb: (editor: any) => void) => {
cb({ update: () => {} })
return { dispose: async () => {} }
},
},
tool: {
transform: async (cb: (editor: any) => void) => {
cb({
add: (tool: CapturedTool) => void cap.addedTools.push(tool),
namespace: () => {},
update: () => {},
list: () => [],
})
return { dispose: async () => {} }
},
list: async () => cap.addedTools.map((tool) => ({ id: tool.name, ...tool })),
},
command: {
transform: async (cb: (editor: any) => void) => {
cb({ add: () => {} })
return { dispose: async () => {} }
},
},
session: { switchAgent: async () => {}, prompt: async () => {} },
mcp: { list: async () => [] },
permission: { hook: async () => ({}), ...permission },
}
return cap
}
function callContext(cap: SetupCapture) {
return {
sessionID: "ses-m2-test",
agent: "rea",
messageID: "msg-m2-test",
id: "call-m2-test",
progress: async (update: unknown) => void cap.progressCalls.push(update),
}
}
// M2 acceptance: after setup(), ctx.tool.list() includes rea_bootstrap_stack;
// calling it without approval is rejected (permission asserted); COMMAND_IDS
// includes rea-bootstrap and contains no ":". No model calls, no network:
// installs run zero steps under all-false flags (live-model unverified).
describe("M2 integration: rea_bootstrap_stack tool + rea-bootstrap command", () => {
test("setup() registers rea_bootstrap_stack; tool.list() includes it with codemode:false and a closed input schema", async () => {
const cap = makeSetupCapture()
const setup = (plugin as unknown as { setup: (ctx: unknown) => Promise<unknown> }).setup
await setup(cap.ctx)
const listed = (await cap.ctx.tool.list()) as Array<{ id: string }>
const ids = listed.map((entry) => entry.id)
console.log(`[${LIVE_NOTE}] M2 tool ids after setup:`, JSON.stringify(ids))
expect(ids).toContain(BOOTSTRAP_TOOL_NAME)
expect(BOOTSTRAP_TOOL_NAME).toBe("rea_bootstrap_stack")
const entry = cap.addedTools.find((tool) => tool.name === BOOTSTRAP_TOOL_NAME)
expect(entry).toBeDefined()
expect(entry?.options).toMatchObject({ codemode: false })
expect(entry?.input).toEqual(BootstrapToolInputSchema)
const schema = entry?.input as { type: string; properties: Record<string, { type: string }>; additionalProperties: boolean }
expect(schema.type).toBe("object")
expect(schema.additionalProperties).toBe(false)
expect(Object.keys(schema.properties).sort()).toEqual(["installMemory", "installSkills", "installWorkflows"])
for (const prop of Object.values(schema.properties)) {
expect(prop.type).toBe("boolean")
}
expect(typeof entry?.description).toBe("string")
expect((entry?.description ?? "").length).toBeGreaterThan(0)
})
test("calling rea_bootstrap_stack without approval is rejected; permission.assert runs first with the exact shape", async () => {
const cap = makeSetupCapture({ deny: true })
await registerBootstrapTool(cap.ctx)
const entry = cap.addedTools.find((tool) => tool.name === BOOTSTRAP_TOOL_NAME)
expect(entry).toBeDefined()
await expect(entry!.execute({}, callContext(cap))).rejects.toThrow(/not approved/)
// Permission was asserted exactly once, BEFORE any install ran.
expect(cap.asserted).toHaveLength(1)
expect(cap.asserted[0]).toMatchObject({
action: "rea_bootstrap_stack",
resources: [cap.directory],
save: ["*"],
})
expect(cap.asserted[0]?.sessionID).toBe("ses-m2-test")
expect(cap.asserted[0]?.agent).toBe("rea")
expect(cap.asserted[0]?.source).toMatchObject({ type: "tool", messageID: "msg-m2-test", id: "call-m2-test" })
// Denial means no installs ran and no skill reload happened.
expect(cap.reloaded.count).toBe(0)
})
test("approved call with all flags false runs zero steps, reports results metadata, and reloads skills", async () => {
const cap = makeSetupCapture({ deny: false })
await registerBootstrapTool(cap.ctx)
const entry = cap.addedTools.find((tool) => tool.name === BOOTSTRAP_TOOL_NAME)
expect(entry).toBeDefined()
const res = await entry!.execute(
{ installSkills: false, installMemory: false, installWorkflows: false },
callContext(cap),
)
expect(cap.asserted).toHaveLength(1)
expect(res?.metadata?.results).toEqual([])
expect(typeof res?.content).toBe("string")
expect(cap.reloaded.count).toBe(1)
console.log(`[${LIVE_NOTE}] M2 approved zero-step content:`, JSON.stringify(res?.content))
})
test("fail-closed: a host without permission.assert refuses instead of running unapproved installs (D6)", async () => {
const cap = makeSetupCapture({ deny: false, withAssert: false })
await registerBootstrapTool(cap.ctx)
const entry = cap.addedTools.find((tool) => tool.name === BOOTSTRAP_TOOL_NAME)
expect(entry).toBeDefined()
await expect(entry!.execute({}, callContext(cap))).rejects.toThrow(/permission\.assert/)
expect(cap.reloaded.count).toBe(0)
})
test("workflows fallback note is appended only when the workflows step did not complete (D5)", () => {
const clean = withWorkflowsFallbackNote("[OK] OpenCode Workflows plugin", [
{ label: "OpenCode Workflows plugin", status: "ok", detail: "done" },
])
expect(clean).not.toContain("falls back")
for (const status of ["failed", "skipped"] as const) {
const noted = withWorkflowsFallbackNote(`[${status === "failed" ? "FAILED" : "SKIPPED"}] OpenCode Workflows plugin`, [
{ label: "OpenCode Workflows plugin", status, detail: "reason" },
])
expect(noted).toContain("falls back to REA's own subagent delegation")
expect(noted).toContain("D5")
}
})
test("COMMAND_IDS includes rea-bootstrap and contains no ':'", async () => {
expect(COMMAND_IDS).toContain("rea-bootstrap")
for (const id of COMMAND_IDS) {
expect(id).not.toContain(":")
expect((REA_PROMPT_COMMANDS as ReadonlyArray<string>).includes(id)).toBe(false)
}
const added: Array<any> = []
const prompts: Array<any> = []
const fakeCtx = {
command: {
transform: async (cb: (editor: any) => void) => {
cb({ add: (d: any) => void added.push(d) })
return { dispose: async () => {} }
},
},
session: { switchAgent: async () => {}, prompt: async (input: any) => void prompts.push(input) },
}
await registerCommands(fakeCtx as any, { server: "rea", runtime: "ask" } as any)
const def = added.find((d) => d.name === "rea-bootstrap")
expect(def).toBeDefined()
expect(typeof def?.description).toBe("string")
expect((def?.description ?? "").length).toBeGreaterThan(0)
await def.execute({ sessionID: "s-bootstrap", prompt: { text: "" }, delivery: undefined })
const text = String(prompts[0]?.text ?? "")
expect(text).toContain("rea_bootstrap_stack")
expect(text).toContain("rea_readiness")
})
})
+2 -2
View File
@@ -85,7 +85,7 @@ describe("T09 commands via real host", () => {
}, 60_000)
test("COMMAND_IDS exact; no shadowing of rea:<prompt> commands", () => {
expect([...COMMAND_IDS]).toEqual(["rea", "rea-doctor", "rea-report"])
expect([...COMMAND_IDS]).toEqual(["rea", "rea-doctor", "rea-report", "rea-bootstrap"])
expect(REA_PROMPT_COMMANDS).toHaveLength(6)
for (const name of COMMAND_IDS) {
expect(name).not.toContain(":")
@@ -111,7 +111,7 @@ async function collectDefinitions(options: { server: string; runtime: "ask" | "a
async function assertDirectRegistration() {
const added = await collectDefinitions({ server: "rea", runtime: "ask" })
expect(added.map((d) => d.name)).toEqual(["rea", "rea-doctor", "rea-report"])
expect(added.map((d) => d.name)).toEqual(["rea", "rea-doctor", "rea-report", "rea-bootstrap"])
for (const d of added) {
expect(typeof d.description).toBe("string")
expect((d.description ?? "").length).toBeGreaterThan(0)
+11 -3
View File
@@ -3,7 +3,7 @@ import { COMMAND_IDS, REA_PROMPT_COMMANDS, registerCommands } from "../../src/co
describe("T09 unit: slash commands registration and execution", () => {
test("COMMAND_IDS and REA_PROMPT_COMMANDS do not overlap", () => {
expect([...COMMAND_IDS]).toEqual(["rea", "rea-doctor", "rea-report"])
expect([...COMMAND_IDS]).toEqual(["rea", "rea-doctor", "rea-report", "rea-bootstrap"])
expect(REA_PROMPT_COMMANDS).toHaveLength(6)
for (const cmd of COMMAND_IDS) {
expect(cmd).not.toContain(":")
@@ -11,7 +11,7 @@ describe("T09 unit: slash commands registration and execution", () => {
}
})
test("registerCommands wires /rea, /rea-doctor, and /rea-report with server context and user args", async () => {
test("registerCommands wires /rea, /rea-doctor, /rea-report, and /rea-bootstrap with server context and user args", async () => {
const added: any[] = []
const sessionCalls: Array<{ method: string; input: any }> = []
const ctx: any = {
@@ -28,7 +28,7 @@ describe("T09 unit: slash commands registration and execution", () => {
}
await registerCommands(ctx, { server: "custom.rea", runtime: "ask" })
expect(added.map((d) => d.name)).toEqual(["rea", "rea-doctor", "rea-report"])
expect(added.map((d) => d.name)).toEqual(["rea", "rea-doctor", "rea-report", "rea-bootstrap"])
// /rea switches to primary rea agent and passes target prompt + sanitized server namespace
const reaCmd = added.find((d) => d.name === "rea")
@@ -55,5 +55,13 @@ describe("T09 unit: slash commands registration and execution", () => {
expect(sessionCalls.some((c) => c.method === "switchAgent")).toBe(false)
expect(sessionCalls[0]?.input?.text).toContain("rea-auditor")
expect(sessionCalls[0]?.input?.text).toContain("focus on TLS pinning")
// /rea-bootstrap tells the agent to call rea_bootstrap_stack then rea_readiness and report
sessionCalls.length = 0
const bootstrapCmd = added.find((d) => d.name === "rea-bootstrap")
await bootstrapCmd.execute({ sessionID: "s4", prompt: { text: "" }, delivery: undefined })
expect(sessionCalls.some((c) => c.method === "switchAgent")).toBe(false)
expect(sessionCalls[0]?.input?.text).toContain("rea_bootstrap_stack")
expect(sessionCalls[0]?.input?.text).toContain("rea_readiness")
})
})
+22 -1
View File
@@ -78,7 +78,7 @@ describe("T15 docs guard (README accuracy)", () => {
test("every command README names exists in src/commands.ts exports", async () => {
const readme = await Bun.file("README.md").text()
const commandsSrc = await Bun.file("src/commands.ts").text()
for (const id of ["rea", "rea-doctor", "rea-report"]) {
for (const id of ["rea", "rea-doctor", "rea-report", "rea-bootstrap"]) {
expect(readme).toContain(id)
expect(commandsSrc).toContain(`"${id}"`)
}
@@ -121,4 +121,25 @@ describe("T15 docs guard (README accuracy)", () => {
// Plugin never registers or edits an MCP server.
expect(readme).toContain("ctx.mcp.transform")
})
test("README documents the rea_bootstrap_stack tool and rea-bootstrap command", async () => {
const readme = await Bun.file("README.md").text()
for (const token of [
"rea_bootstrap_stack",
"rea-bootstrap",
"installSkills",
"installMemory",
"installWorkflows",
]) {
expect(readme).toContain(token)
}
const toolSrc = await Bun.file("src/bootstrap-tool.ts").text()
expect(toolSrc).toContain("rea_bootstrap_stack")
expect(toolSrc).toContain("permission.assert")
expect(toolSrc).toContain("ctx.skill.reload")
expect(toolSrc).toContain("runBootstrap")
expect(toolSrc).toContain("codemode")
const commandsSrc = await Bun.file("src/commands.ts").text()
expect(commandsSrc).toContain(`"rea-bootstrap"`)
})
})
+1
View File
@@ -123,6 +123,7 @@ describe("T04 options boundary: setup wiring", () => {
expect(typeof setup).toBe("function")
const withSkill = (options: unknown) => ({
options,
location: { directory: process.cwd() },
skill: { transform: async () => ({ dispose: async () => {} }) },
agent: { transform: async () => ({ dispose: async () => {} }) },
tool: { transform: async () => ({ dispose: async () => {} }) },
+3 -1
View File
@@ -11,11 +11,13 @@ import {
countReaToolsByMode,
registerReadiness,
} from "../../src/readiness.js"
import { BOOTSTRAP_TOOL_NAME } from "../../src/bootstrap-tool.js"
describe("T08 unit: readiness & Code Mode tool catalog policy", () => {
test("countReaMcpTools and countReaToolsByMode count MCP tools while excluding rea_readiness", () => {
test("countReaMcpTools and countReaToolsByMode count MCP tools while excluding plugin-owned tools", () => {
const tools = [
{ id: READINESS_TOOL_NAME, name: READINESS_TOOL_NAME, options: { codemode: false } },
{ id: BOOTSTRAP_TOOL_NAME, name: BOOTSTRAP_TOOL_NAME, options: { codemode: false } },
{ id: "rea_binary_session", name: "rea_binary_session", options: { codemode: true, pinned: true } },
{ id: "rea_inspect_artifact", name: "rea_inspect_artifact", options: {} },
{ id: "rea_read_native_function", name: "rea_read_native_function", options: { codemode: false } },