diff --git a/README.md b/README.md index 7346a65..3765029 100644 --- a/README.md +++ b/README.md @@ -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:` 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`/ diff --git a/docs/NOTES.md b/docs/NOTES.md index 0051714..e4d7aeb 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -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/plugin@2.0.26` types declare no +`assert`: `dist/promise/permission.d.ts` is +`Pick & { hook }`, and the bundled +`@opencode/client@2.0.26` generated permission API has only +`request/saved/create/list/get/reply` — no `assert` anywhere in the package. +The Unity kit targets `@opencode/plugin@2.0.32`, 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`). diff --git a/src/bootstrap-tool.ts b/src/bootstrap-tool.ts new file mode 100644 index 0000000..daafff4 --- /dev/null +++ b/src/bootstrap-tool.ts @@ -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 +} + +interface BootstrapToolEditorLike { + add(tool: { + name: string + description?: string + input: unknown + options?: { codemode?: boolean } + execute: (input: BootstrapToolInput, context: BootstrapToolCallContext) => Promise + }): void +} + +interface BootstrapToolCtxLike { + tool: { + transform(cb: (editor: BootstrapToolEditorLike) => void): unknown + } + skill: { + reload(): Promise + } + /** `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 + readonly save: ReadonlyArray +} + +/** + * 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 { + 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 +} + +/** + * 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 { + 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 diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 1e50746..7fb5ede 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -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(), diff --git a/src/commands.ts b/src/commands.ts index c789e9c..41f7d2c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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:` 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 { 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, + }) + }, + }) }) } diff --git a/src/index.ts b/src/index.ts index 051042d..bace944 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) }, }) diff --git a/src/readiness.ts b/src/readiness.ts index 92307b7..f79ef10 100644 --- a/src/readiness.ts +++ b/src/readiness.ts @@ -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 `_` 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 = 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 `_` 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 = 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 ?? {}), diff --git a/test/integration/bootstrap.test.ts b/test/integration/bootstrap.test.ts new file mode 100644 index 0000000..c31dbc4 --- /dev/null +++ b/test/integration/bootstrap.test.ts @@ -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 +} + +interface SetupCapture { + addedTools: CapturedTool[] + asserted: Array> + reloaded: { count: number } + progressCalls: Array + 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 = + opts?.withAssert === false + ? {} + : { + assert: async (request: Record) => { + 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 }).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; 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).includes(id)).toBe(false) + } + const added: Array = [] + const prompts: Array = [] + 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") + }) +}) diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts index 0bdcc0a..5907fbf 100644 --- a/test/integration/commands.test.ts +++ b/test/integration/commands.test.ts @@ -85,7 +85,7 @@ describe("T09 commands via real host", () => { }, 60_000) test("COMMAND_IDS exact; no shadowing of rea: 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) diff --git a/test/unit/commands.test.ts b/test/unit/commands.test.ts index 810d561..218864e 100644 --- a/test/unit/commands.test.ts +++ b/test/unit/commands.test.ts @@ -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") }) }) diff --git a/test/unit/docs.test.ts b/test/unit/docs.test.ts index 1f4d0f4..a35bc89 100644 --- a/test/unit/docs.test.ts +++ b/test/unit/docs.test.ts @@ -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"`) + }) }) diff --git a/test/unit/options.test.ts b/test/unit/options.test.ts index c866fb0..97059cf 100644 --- a/test/unit/options.test.ts +++ b/test/unit/options.test.ts @@ -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 () => {} }) }, diff --git a/test/unit/readiness.test.ts b/test/unit/readiness.test.ts index 78ce428..119dd66 100644 --- a/test/unit/readiness.test.ts +++ b/test/unit/readiness.test.ts @@ -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 } },