From c10b02b7a1d716bca72bae1434fffe8ef585b58a Mon Sep 17 00:00:00 2001 From: miguelmartens Date: Sat, 10 Oct 2026 20:41:44 +0200 Subject: [PATCH] fix: migrate the plugin to the OpenCode V2 plugin API OpenCode V2 rejects the V1 function export with "Plugin must export a default definition with an id and an effect or setup function", so the plugin failed to load after the V2 upgrade. - export a dual definition: `id` plus `setup(ctx)` for V2, `server()` for V1 1.18.29+, with the legacy hooks unchanged in behavior - port registration to command/skill/agent transforms, system prompt injection to the session context hook, deactivation to the prompt hook, and `/caveman ` persistence into the command execute - register skills without frontmatter and upsert the cavecrew subagents, appending an edit deny for the two read-only ones - move the plugin to `.opencode/plugins/caveman/index.mjs`: V2 only discovers local plugins as directories with an index file - update package entry points, the root opencode.json, docs, and tests --- .opencode/plugins/caveman.mjs | 110 -------- .../{ => caveman}/caveman-frontmatter.cjs | 23 +- .opencode/plugins/caveman/index.mjs | 235 ++++++++++++++++++ AGENTS.md | 6 +- CONTRIBUTING.md | 2 +- README.md | 22 +- opencode.json | 2 +- package.json | 6 +- tests/frontmatter.test.js | 21 +- tests/plugin.test.js | 220 +++++++++++++--- 10 files changed, 484 insertions(+), 163 deletions(-) delete mode 100644 .opencode/plugins/caveman.mjs rename .opencode/plugins/{ => caveman}/caveman-frontmatter.cjs (69%) create mode 100644 .opencode/plugins/caveman/index.mjs diff --git a/.opencode/plugins/caveman.mjs b/.opencode/plugins/caveman.mjs deleted file mode 100644 index 58c0c9a..0000000 --- a/.opencode/plugins/caveman.mjs +++ /dev/null @@ -1,110 +0,0 @@ -// caveman — OpenCode plugin. -// -// Injects the caveman ruleset into every chat's system prompt at the active -// level, persists /caveman level switches, answers the natural-language -// deactivation commands, and registers the packaged commands, skills, and -// cavecrew subagents so they work when the package is installed from npm. -// -// OpenCode loads this as a server plugin — add it to your opencode.json: -// { "plugin": ["@miguelmartens/opencode-caveman"] } - -import { createRequire } from 'module'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// The shared helpers are CommonJS; bridge to them from this ES module. -const require = createRequire(import.meta.url); -const { - isDeactivationCommand, - normalizeLevel, - readLevel, - writeLevel, -} = require('../../hooks/caveman-config.cjs'); -const { getLevelInstructions } = require('../../hooks/caveman-instructions.cjs'); -const { parseAgentFile, parseCommandFile } = require('./caveman-frontmatter.cjs'); - -// Read-only by contract; enforce it so a misread prompt cannot edit files. -const READ_ONLY_AGENTS = new Set(['cavecrew-investigator', 'cavecrew-reviewer']); - -export default async ({ client } = {}) => { - const log = (level, message) => { - try { - client && client.app && client.app.log({ body: { service: 'caveman', level, message } }); - } catch (e) {} - }; - - return { - // Register slash commands, the skills directory, and cavecrew subagents. - config: async (config) => { - if (!config.command) config.command = {}; - const commandDir = path.join(__dirname, '..', 'command'); - try { - for (const file of fs.readdirSync(commandDir).filter((f) => f.endsWith('.md'))) { - const parsed = parseCommandFile(path.join(commandDir, file)); - if (parsed && parsed.template) { - config.command[path.basename(file, '.md')] = parsed; - } - } - } catch (e) {} - - config.skills = config.skills || {}; - config.skills.paths = config.skills.paths || []; - const skillsDir = path.resolve(__dirname, '..', '..', 'skills'); - if (!config.skills.paths.includes(skillsDir)) config.skills.paths.push(skillsDir); - - if (!config.agent) config.agent = {}; - const agentDir = path.resolve(__dirname, '..', '..', 'agents'); - try { - for (const file of fs.readdirSync(agentDir).filter((f) => f.endsWith('.md'))) { - const name = path.basename(file, '.md'); - const parsed = parseAgentFile(path.join(agentDir, file)); - if (!parsed || !parsed.prompt) continue; - config.agent[name] = { - description: parsed.description, - mode: 'subagent', - prompt: parsed.prompt, - }; - if (READ_ONLY_AGENTS.has(name)) config.agent[name].permission = { edit: 'deny' }; - } - } catch (e) {} - }, - - // Append the ruleset to the system prompt every turn. - 'experimental.chat.system.transform': async (_input, output) => { - const level = readLevel(); - if (level === 'off') return; - const instructions = getLevelInstructions(level); - if (output.system.length > 0) { - output.system[output.system.length - 1] += '\n\n' + instructions; - } else { - output.system.push(instructions); - } - }, - - // Persist `/caveman ` so the next turn's injection follows it. - 'command.execute.before': async (input) => { - if (!input || input.command !== 'caveman') return; - // No argument means "turn it on": full, matching the upstream command. - const args = String(input.arguments || '').trim(); - const level = args ? normalizeLevel(args.split(/\s+/)[0]) : 'full'; - if (!level) return; - writeLevel(level); - log('info', 'caveman level ' + level); - }, - - // "stop caveman" / "normal mode" as a standalone message turns it off; - // the per-turn injection would otherwise keep caveman on regardless. - 'chat.message': async (_input, output) => { - const text = (output.parts || []) - .filter((part) => part && part.type === 'text' && typeof part.text === 'string') - .map((part) => part.text) - .join('\n'); - if (!isDeactivationCommand(text)) return; - writeLevel('off'); - log('info', 'caveman off'); - }, - }; -}; diff --git a/.opencode/plugins/caveman-frontmatter.cjs b/.opencode/plugins/caveman/caveman-frontmatter.cjs similarity index 69% rename from .opencode/plugins/caveman-frontmatter.cjs rename to .opencode/plugins/caveman/caveman-frontmatter.cjs index cc71812..2783a03 100644 --- a/.opencode/plugins/caveman-frontmatter.cjs +++ b/.opencode/plugins/caveman/caveman-frontmatter.cjs @@ -2,10 +2,10 @@ // caveman frontmatter parser. // -// Kept out of caveman.mjs so the plugin module's only top-level export is the -// plugin function itself. OpenCode's legacy plugin loader treats every -// function exported from a plugin module as a plugin; calling the parser as -// one throws. One plugin-shaped export per module. +// Kept out of index.mjs so the plugin module's only top-level export is the +// plugin definition itself. OpenCode's legacy loader treats every function +// exported from a plugin module as a plugin; calling the parser as one throws. +// One plugin-shaped export per module. const fs = require('fs'); @@ -60,4 +60,17 @@ function parseAgentFile(filePath) { }; } -module.exports = { parseAgentFile, parseCommandFile, parseField, splitFrontmatter }; +function parseSkillFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = splitFrontmatter(content); + // All frontmatter is optional at runtime; keep the body either way, without + // frontmatter, matching how OpenCode loads a discovered SKILL.md. + if (!parsed) return { name: undefined, description: undefined, content: content.trim() }; + return { + name: parseField(parsed.frontmatter, 'name'), + description: parseField(parsed.frontmatter, 'description'), + content: parsed.body, + }; +} + +module.exports = { parseAgentFile, parseCommandFile, parseField, parseSkillFile, splitFrontmatter }; diff --git a/.opencode/plugins/caveman/index.mjs b/.opencode/plugins/caveman/index.mjs new file mode 100644 index 0000000..5a5fed8 --- /dev/null +++ b/.opencode/plugins/caveman/index.mjs @@ -0,0 +1,235 @@ +// caveman — OpenCode plugin. +// +// Injects the caveman ruleset into every chat's system prompt at the active +// level, persists /caveman level switches, answers the natural-language +// deactivation commands, and registers the packaged commands, skills, and +// cavecrew subagents so they work when the package is installed from npm. +// +// One default export serves both OpenCode plugin APIs: V2 validates `id` plus +// `setup(ctx)`; V1 (1.18.29+) calls `server()` for the legacy hooks object. +// Plugin.define is runtime identity, so no @opencode/plugin dependency is +// needed here. + +import { createRequire } from 'module'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// The shared helpers are CommonJS; bridge to them from this ES module. +const require = createRequire(import.meta.url); +const { + isDeactivationCommand, + normalizeLevel, + readLevel, + writeLevel, +} = require('../../../hooks/caveman-config.cjs'); +const { getLevelInstructions } = require('../../../hooks/caveman-instructions.cjs'); +const { parseAgentFile, parseCommandFile, parseSkillFile } = require('./caveman-frontmatter.cjs'); + +// Read-only by contract; enforce it so a misread prompt cannot edit files. +const READ_ONLY_AGENTS = new Set(['cavecrew-investigator', 'cavecrew-reviewer']); + +const COMMAND_DIR = path.resolve(__dirname, '..', '..', 'command'); +const AGENT_DIR = path.resolve(__dirname, '..', '..', '..', 'agents'); +const SKILL_DIR = path.resolve(__dirname, '..', '..', '..', 'skills'); + +function markdownFiles(dir) { + try { + return fs + .readdirSync(dir) + .filter((file) => file.endsWith('.md')) + .map((file) => path.join(dir, file)); + } catch (e) { + return []; + } +} + +function skillFiles() { + try { + return fs.readdirSync(SKILL_DIR).map((entry) => path.join(SKILL_DIR, entry, 'SKILL.md')); + } catch (e) { + return []; + } +} + +// V2 command definitions submit their own prompt, so expand the markdown +// template the way V2 expands file-based commands: $ARGUMENTS in place, or a +// blank-line appended argument string when the template has no placeholder. +function expandTemplate(template, args) { + const text = String(args || '').trim(); + if (template.includes('$ARGUMENTS')) return template.replaceAll('$ARGUMENTS', text); + return text ? template + '\n\n' + text : template; +} + +export default { + id: 'caveman', + + // OpenCode V2: register the domains and hooks through the plugin context. + async setup(ctx) { + // Register the bundled slash commands. V2 runs `execute` instead of a + // markdown template, so each definition submits its own expanded prompt. + await ctx.command.transform((editor) => { + try { + for (const file of markdownFiles(COMMAND_DIR)) { + const parsed = parseCommandFile(file); + if (!parsed || !parsed.template) continue; + const name = path.basename(file, '.md'); + editor.add({ + name, + description: parsed.description, + execute: async ({ sessionID, prompt, delivery }) => { + const args = String(prompt.text || ''); + if (name === 'caveman') { + // No argument means "turn it on": full, matching the upstream command. + const level = args.trim() ? normalizeLevel(args.trim().split(/\s+/)[0]) : 'full'; + if (level) writeLevel(level); + } + await ctx.session.prompt({ + ...prompt, + sessionID, + text: expandTemplate(parsed.template, args), + delivery, + }); + }, + }); + } + } catch (e) {} + }); + + // Register the bundled skills (directory per skill, SKILL.md inside). + await ctx.skill.transform((editor) => { + try { + for (const file of skillFiles()) { + if (!fs.existsSync(file)) continue; + const parsed = parseSkillFile(file); + if (!parsed) continue; + const id = path.basename(path.dirname(file)); + editor.add({ + id, + name: parsed.name || id, + description: parsed.description, + path: file, + content: parsed.content, + }); + } + } catch (e) {} + }); + + // Register the cavecrew subagents. V2 has no agent `add`; `update` upserts + // and seeds the default permissions, so the read-only ones append a deny. + await ctx.agent.transform((editor) => { + try { + for (const file of markdownFiles(AGENT_DIR)) { + const name = path.basename(file, '.md'); + const parsed = parseAgentFile(file); + if (!parsed || !parsed.prompt) continue; + editor.update(name, (agent) => { + agent.name = parsed.name || name; + agent.description = parsed.description; + agent.mode = 'subagent'; + agent.system = parsed.prompt; + if (READ_ONLY_AGENTS.has(name)) { + agent.permissions.push({ action: 'edit', resource: '*', effect: 'deny' }); + } + }); + } + } catch (e) {} + }); + + // Append the ruleset to the system prompt before every agent-loop request. + await ctx.session.hook('context', (event) => { + const level = readLevel(); + if (level === 'off') return; + event.system.push({ type: 'text', text: getLevelInstructions(level) }); + }); + + // "stop caveman" / "normal mode" as a standalone message turns it off; + // the per-turn injection would otherwise keep caveman on regardless. + await ctx.session.hook('prompt', (event) => { + const text = event.prompt && event.prompt.text; + if (!isDeactivationCommand(text)) return; + writeLevel('off'); + }); + }, + + // OpenCode V1 (1.18.29+): the same behavior through the legacy hooks object. + async server(input = {}) { + const client = input && input.client; + const log = (level, message) => { + try { + client && client.app && client.app.log({ body: { service: 'caveman', level, message } }); + } catch (e) {} + }; + + return { + // Register slash commands, the skills directory, and cavecrew subagents. + config: async (config) => { + if (!config.command) config.command = {}; + try { + for (const file of markdownFiles(COMMAND_DIR)) { + const parsed = parseCommandFile(file); + if (parsed && parsed.template) { + config.command[path.basename(file, '.md')] = parsed; + } + } + } catch (e) {} + + config.skills = config.skills || {}; + config.skills.paths = config.skills.paths || []; + if (!config.skills.paths.includes(SKILL_DIR)) config.skills.paths.push(SKILL_DIR); + + if (!config.agent) config.agent = {}; + try { + for (const file of markdownFiles(AGENT_DIR)) { + const name = path.basename(file, '.md'); + const parsed = parseAgentFile(file); + if (!parsed || !parsed.prompt) continue; + config.agent[name] = { + description: parsed.description, + mode: 'subagent', + prompt: parsed.prompt, + }; + if (READ_ONLY_AGENTS.has(name)) config.agent[name].permission = { edit: 'deny' }; + } + } catch (e) {} + }, + + // Append the ruleset to the system prompt every turn. + 'experimental.chat.system.transform': async (_input, output) => { + const level = readLevel(); + if (level === 'off') return; + const instructions = getLevelInstructions(level); + if (output.system.length > 0) { + output.system[output.system.length - 1] += '\n\n' + instructions; + } else { + output.system.push(instructions); + } + }, + + // Persist `/caveman ` so the next turn's injection follows it. + 'command.execute.before': async (input) => { + if (!input || input.command !== 'caveman') return; + // No argument means "turn it on": full, matching the upstream command. + const args = String(input.arguments || '').trim(); + const level = args ? normalizeLevel(args.split(/\s+/)[0]) : 'full'; + if (!level) return; + writeLevel(level); + log('info', 'caveman level ' + level); + }, + + // "stop caveman" / "normal mode" as a standalone message turns it off; + // the per-turn injection would otherwise keep caveman on regardless. + 'chat.message': async (_input, output) => { + const text = (output.parts || []) + .filter((part) => part && part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n'); + if (!isDeactivationCommand(text)) return; + writeLevel('off'); + log('info', 'caveman off'); + }, + }; + }, +}; diff --git a/AGENTS.md b/AGENTS.md index 7080066..48c9be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,12 @@ OpenCode plugin packaging of the Caveman rules (upstream: `JuliusBrussee/caveman - Test: `npm test` (node `--test`, no runtime dependencies). - Format: `npm run format` / `npm run format:check` (Prettier; `skills/`, `agents/`, `NOTICE.md`, `LICENSE` are excluded and must stay byte-identical). - Pre-commit hook: wired automatically by `npm install` (`prepare` script); manual fallback is `git config core.hooksPath .githooks`. It runs `npm test`. -- Local use: opening OpenCode in this repo loads `.opencode/plugins/caveman.mjs` through the root `opencode.json`. +- Local use: opening OpenCode in this repo loads `.opencode/plugins/caveman/index.mjs` (V2 auto-discovery, plus the root `opencode.json` `plugins` entry). ## Rules - `skills/` and `agents/` are vendored from upstream, not authored here. Do not edit them locally. Re-vendor from the pinned upstream commit in NOTICE.md and update the SHA there when syncing. -- Runtime code is `hooks/*.cjs` plus `.opencode/plugins/caveman.mjs`. The `.mjs` bridges to CommonJS with `createRequire` so it loads with no build step. -- Keep exactly one plugin-shaped export in `caveman.mjs`. OpenCode's legacy loader calls every named export of a plugin module as a plugin, so helpers live in `caveman-frontmatter.cjs`. +- Runtime code is `hooks/*.cjs` plus `.opencode/plugins/caveman/index.mjs`. The `.mjs` bridges to CommonJS with `createRequire` so it loads with no build step. +- The default export is dual-shape: V2 validates `id` plus `setup(ctx)`, V1 (1.18.29+) calls `server()` for the legacy hooks. Keep exactly one plugin-shaped export; OpenCode's legacy loader calls every named export of a plugin module as a plugin, so helpers live in `caveman-frontmatter.cjs`. - The injected ruleset stays mode-filtered: only the active level's intensity-table row and worked examples survive the filter. - State file is `~/.config/opencode/.caveman-active` (XDG/`OPENCODE_CONFIG_DIR` aware). Never write state during tests outside a temp dir. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1aa649c..ba94745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ change is dropping the `model:` line from agent frontmatter. - Keep the diff focused; one concern per PR. - `npm test` and `npm run format:check` must pass — CI runs both. -- Runtime code lives in `hooks/*.cjs` and `.opencode/plugins/caveman.mjs`; tests live in `tests/`. +- Runtime code lives in `hooks/*.cjs` and `.opencode/plugins/caveman/index.mjs`; tests live in `tests/`. - Do not add dependencies for what a few lines of Node can do. ## Releases diff --git a/README.md b/README.md index 8cecb38..db1cc9f 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ opencode plugin @miguelmartens/opencode-caveman ``` That installs the package and adds it to your config. Or add it by hand -(global at `~/.config/opencode/opencode.json`, or per project): +(global at `~/.config/opencode/opencode.json`, or per project) — V2 uses the +`plugins` key (`plugin` on V1): ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["@miguelmartens/opencode-caveman"] + "plugins": ["@miguelmartens/opencode-caveman"] } ``` @@ -28,7 +29,7 @@ Or run it from a checkout with no publish step — the repo ships a root `opencode.json` that does exactly this: ```json -{ "plugin": ["./.opencode/plugins/caveman.mjs"] } +{ "plugins": ["./.opencode/plugins/caveman"] } ``` Restart OpenCode after changing config. The mode is active from the next @@ -56,12 +57,15 @@ session; no extra setup. ## How it works -| Hook | Effect | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `config` | Registers the commands, the bundled skills, and the cavecrew subagents | -| `experimental.chat.system.transform` | Injects the level-filtered ruleset into every turn's system prompt — no drift, survives `/clear` and compaction | -| `command.execute.before` | Persists `/caveman ` to `~/.config/opencode/.caveman-active` | -| `chat.message` | Turns caveman off on a standalone `stop caveman` / `normal mode` message | +On OpenCode V2 the plugin uses the plugin context; on V1 (1.18.29+) it uses the +legacy hooks with the same behavior. + +| V2 hook | Effect | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `command` / `skill` / `agent` transforms | Register the commands, the bundled skills, and the cavecrew subagents | +| `session.hook("context")` | Injects the level-filtered ruleset into every turn's system prompt — no drift, survives `/clear` and compaction | +| command `execute` | Persists `/caveman ` to `~/.config/opencode/.caveman-active` | +| `session.hook("prompt")` | Turns caveman off on a standalone `stop caveman` / `normal mode` message | The injected ruleset is the vendored caveman skill filtered to the active level: only that level's intensity-table row and worked examples survive, so diff --git a/opencode.json b/opencode.json index ad70004..d5f5ce0 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["./.opencode/plugins/caveman.mjs"] + "plugins": ["./.opencode/plugins/caveman"] } diff --git a/package.json b/package.json index c0b3eb7..1566e82 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "bugs": { "url": "https://github.com/miguelmartens/opencode-caveman/issues" }, - "main": "./.opencode/plugins/caveman.mjs", + "main": "./.opencode/plugins/caveman/index.mjs", "exports": { - ".": "./.opencode/plugins/caveman.mjs", - "./plugin": "./.opencode/plugins/caveman.mjs" + ".": "./.opencode/plugins/caveman/index.mjs", + "./plugin": "./.opencode/plugins/caveman/index.mjs" }, "files": [ ".opencode/", diff --git a/tests/frontmatter.test.js b/tests/frontmatter.test.js index 1fe9cfd..a2506d6 100644 --- a/tests/frontmatter.test.js +++ b/tests/frontmatter.test.js @@ -10,8 +10,9 @@ const { parseAgentFile, parseCommandFile, parseField, + parseSkillFile, splitFrontmatter, -} = require('../.opencode/plugins/caveman-frontmatter.cjs'); +} = require('../.opencode/plugins/caveman/caveman-frontmatter.cjs'); function tempFile(name, content) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-fm-')); @@ -50,6 +51,24 @@ test('parseAgentFile folds a block-scalar description and keeps the prompt body' assert.equal(parsed.prompt, 'Prompt body.'); }); +test('parseSkillFile keeps the body without frontmatter', () => { + const file = tempFile( + 'SKILL.md', + '---\nname: Caveman Test\ndescription: >\n Folded skill description.\n---\n\n# Body\n\nInstructions.\n', + ); + const parsed = parseSkillFile(file); + assert.equal(parsed.name, 'Caveman Test'); + assert.equal(parsed.description, 'Folded skill description.'); + assert.equal(parsed.content, '# Body\n\nInstructions.'); +}); + +test('parseSkillFile tolerates a missing frontmatter block', () => { + const file = tempFile('SKILL.md', 'Plain body.\n'); + const parsed = parseSkillFile(file); + assert.equal(parsed.name, undefined); + assert.equal(parsed.content, 'Plain body.'); +}); + test('parseField supports the literal block form', () => { const field = parseField('description: |\n line one\n line two\n', 'description'); assert.equal(field, 'line one\nline two'); diff --git a/tests/plugin.test.js b/tests/plugin.test.js index 67de6d5..9717ae4 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -32,22 +32,202 @@ test.afterEach(() => { }); async function loadPlugin() { - const module = await import('../.opencode/plugins/caveman.mjs'); + const module = await import('../.opencode/plugins/caveman/index.mjs'); return module; } -test('the plugin module has exactly one export so the legacy loader stays quiet', async () => { +// Minimal stand-in for the OpenCode V2 plugin context: transforms run their +// callback against recording editors, session hooks are captured, and +// session.prompt records what the command executors submit. +function fakeContext() { + const commands = []; + const skills = []; + const agents = []; + const hooks = {}; + const prompts = []; + + return { + commands, + skills, + agents, + hooks, + prompts, + command: { transform: async (cb) => cb({ add: (definition) => commands.push(definition) }) }, + skill: { transform: async (cb) => cb({ add: (skill) => skills.push(skill) }) }, + agent: { + transform: async (cb) => + cb({ + list: () => agents, + get: (id) => agents.find((agent) => agent.id === id), + update: (id, update) => { + let agent = agents.find((entry) => entry.id === id); + if (!agent) { + // Same upsert base OpenCode seeds for a new agent ID. + agent = { + id, + name: id, + request: { settings: {}, headers: {}, body: {} }, + mode: 'primary', + hidden: false, + permissions: [{ action: '*', resource: '*', effect: 'allow' }], + }; + agents.push(agent); + } + update(agent); + }, + }), + }, + session: { + hook: async (name, callback) => { + hooks[name] = callback; + }, + prompt: async (input) => { + prompts.push(input); + }, + }, + }; +} + +async function setupV2() { + const { default: plugin } = await loadPlugin(); + const ctx = fakeContext(); + await plugin.setup(ctx); + return ctx; +} + +test('the plugin module exports one dual V2/V1 definition', async () => { const module = await loadPlugin(); assert.deepEqual(Object.keys(module), ['default']); - assert.equal(typeof module.default, 'function'); + assert.equal(module.default.id, 'caveman'); + assert.equal(typeof module.default.setup, 'function'); + assert.equal(typeof module.default.server, 'function'); +}); + +test('V2 setup registers commands that persist levels and submit the template', async () => { + const ctx = await setupV2(); + + assert.deepEqual(ctx.commands.map((command) => command.name).sort(), [ + 'caveman', + 'caveman-commit', + 'caveman-help', + 'caveman-review', + ]); + for (const command of ctx.commands) { + assert.ok(command.description.length > 0, command.name + ' has a description'); + assert.equal(typeof command.execute, 'function'); + } + + const caveman = ctx.commands.find((command) => command.name === 'caveman'); + await caveman.execute({ sessionID: 's', prompt: { text: 'ultra' }, delivery: 'steer' }); + assert.equal(readLevel(), 'ultra'); + assert.equal(ctx.prompts.length, 1); + assert.equal(ctx.prompts[0].sessionID, 's'); + assert.equal(ctx.prompts[0].delivery, 'steer'); + assert.match(ctx.prompts[0].text, /^Switch to caveman ultra mode\./); + + await caveman.execute({ sessionID: 's', prompt: { text: 'wenyan' }, delivery: 'steer' }); + assert.equal(readLevel(), 'wenyan-full'); + + await caveman.execute({ sessionID: 's', prompt: { text: '' }, delivery: 'steer' }); + assert.equal(readLevel(), 'full'); + + await caveman.execute({ sessionID: 's', prompt: { text: 'bogus' }, delivery: 'steer' }); + assert.equal(readLevel(), 'full'); + assert.equal(ctx.prompts.length, 4); + + const help = ctx.commands.find((command) => command.name === 'caveman-help'); + await help.execute({ sessionID: 's', prompt: { text: '' }, delivery: 'steer' }); + assert.equal(readLevel(), 'full'); + assert.match(ctx.prompts[4].text, /^Use the caveman-help skill\./); + + // Templates without $ARGUMENTS append non-empty arguments after a blank line. + await help.execute({ sessionID: 's', prompt: { text: 'extra' }, delivery: 'steer' }); + assert.match( + ctx.prompts[5].text, + /card once\. Do not change the caveman level or write state\.\n\nextra$/, + ); +}); + +test('V2 setup registers the bundled skills without frontmatter', async () => { + const ctx = await setupV2(); + + assert.deepEqual(ctx.skills.map((skill) => skill.id).sort(), [ + 'caveman', + 'caveman-commit', + 'caveman-help', + 'caveman-review', + ]); + for (const skill of ctx.skills) { + assert.ok(Array.isArray(skill.content) === false && skill.content.length > 0); + assert.equal(skill.content.startsWith('---'), false, skill.id + ' content has no frontmatter'); + assert.ok(skill.name.length > 0, skill.id + ' has a name'); + assert.ok(skill.description.length > 0, skill.id + ' has a description'); + assert.ok(fs.existsSync(skill.path), skill.id + ' path exists'); + } +}); + +test('V2 setup upserts the cavecrew subagents and denies edit for read-only ones', async () => { + const ctx = await setupV2(); + + assert.deepEqual(ctx.agents.map((agent) => agent.id).sort(), [ + 'cavecrew-builder', + 'cavecrew-investigator', + 'cavecrew-reviewer', + ]); + for (const agent of ctx.agents) { + assert.equal(agent.mode, 'subagent'); + assert.ok(agent.system.length > 0, agent.id + ' has a system prompt'); + assert.ok(agent.description.length > 0, agent.id + ' has a description'); + } + + const deniesEdit = (agent) => + agent.permissions.some( + (rule) => rule.action === 'edit' && rule.resource === '*' && rule.effect === 'deny', + ); + const byId = (id) => ctx.agents.find((agent) => agent.id === id); + assert.ok(deniesEdit(byId('cavecrew-investigator'))); + assert.ok(deniesEdit(byId('cavecrew-reviewer'))); + assert.equal(deniesEdit(byId('cavecrew-builder')), false); +}); + +test('V2 context hook injects the active level and stays silent when off', async () => { + const ctx = await setupV2(); + + writeLevel('lite'); + const lite = { system: [] }; + await ctx.hooks.context(lite); + assert.equal(lite.system.length, 1); + assert.equal(lite.system[0].type, 'text'); + assert.match(lite.system[0].text, /^CAVEMAN MODE ACTIVE — level: lite/); + assert.ok(lite.system[0].text.includes('| **lite** |')); + + writeLevel('off'); + const off = { system: [] }; + await ctx.hooks.context(off); + assert.deepEqual(off.system, []); +}); + +test('V2 prompt hook turns caveman off on a standalone deactivation message', async () => { + const ctx = await setupV2(); + + writeLevel('full'); + await ctx.hooks.prompt({ prompt: { text: 'stop caveman' } }); + assert.equal(readLevel(), 'off'); + + writeLevel('full'); + await ctx.hooks.prompt({ prompt: { text: 'add a normal mode toggle' } }); + assert.equal(readLevel(), 'full'); + + await ctx.hooks.prompt({ prompt: { text: 'normal mode' } }); + assert.equal(readLevel(), 'off'); }); -test('config hook registers commands, skills, and cavecrew subagents', async () => { +test('V1 server returns the legacy hooks object with the same behavior', async () => { const { default: plugin } = await loadPlugin(); - const hooks = await plugin({}); + const hooks = await plugin.server({}); + const config = {}; await hooks.config(config); - for (const name of ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help']) { assert.ok(config.command[name], 'command ' + name + ' registered'); assert.ok(config.command[name].template.length > 0, 'command ' + name + ' has a template'); @@ -70,11 +250,6 @@ test('config hook registers commands, skills, and cavecrew subagents', async () assert.deepEqual(config.agent['cavecrew-investigator'].permission, { edit: 'deny' }); assert.deepEqual(config.agent['cavecrew-reviewer'].permission, { edit: 'deny' }); assert.equal(config.agent['cavecrew-builder'].permission, undefined); -}); - -test('system transform injects the active level and stays silent when off', async () => { - const { default: plugin } = await loadPlugin(); - const hooks = await plugin({}); writeLevel('lite'); const lite = { system: ['base prompt'] }; @@ -87,22 +262,12 @@ test('system transform injects the active level and stays silent when off', asyn const off = { system: ['base prompt'] }; await hooks['experimental.chat.system.transform']({}, off); assert.deepEqual(off.system, ['base prompt']); -}); - -test('system transform pushes a standalone entry when the system prompt is empty', async () => { - const { default: plugin } = await loadPlugin(); - const hooks = await plugin({}); writeLevel('full'); - const output = { system: [] }; - await hooks['experimental.chat.system.transform']({}, output); - assert.equal(output.system.length, 1); - assert.match(output.system[0], /^CAVEMAN MODE ACTIVE — level: full/); -}); - -test('command hook persists levels and ignores other commands', async () => { - const { default: plugin } = await loadPlugin(); - const hooks = await plugin({}); + const empty = { system: [] }; + await hooks['experimental.chat.system.transform']({}, empty); + assert.equal(empty.system.length, 1); + assert.match(empty.system[0], /^CAVEMAN MODE ACTIVE — level: full/); await hooks['command.execute.before']({ command: 'caveman', sessionID: 's', arguments: 'ultra' }); assert.equal(readLevel(), 'ultra'); @@ -125,11 +290,6 @@ test('command hook persists levels and ignores other commands', async () => { await hooks['command.execute.before']({ command: 'caveman', sessionID: 's', arguments: 'off' }); assert.equal(readLevel(), 'off'); -}); - -test('a standalone deactivation message turns caveman off', async () => { - const { default: plugin } = await loadPlugin(); - const hooks = await plugin({}); writeLevel('full'); await hooks['chat.message'](