Kaynağa Gözat

Merge pull request #2106 from GoldJohnKing/feat/opencode-v2-support

feat(opencode): support OpenCode 2.0.4+ alongside V1
Drew Ritter 2 gün önce
ebeveyn
işleme
8f5a89afe4

+ 60 - 8
.opencode/INSTALL.md

@@ -6,7 +6,11 @@
 
 
 ## Installation
 ## Installation
 
 
-Add superpowers to the `plugin` array in your `opencode.json` (global or project-level):
+OpenCode V2 requires version 2.0.4 or later.
+
+### OpenCode V1
+
+Use the existing V1 plugin configuration:
 
 
 ```json
 ```json
 {
 {
@@ -14,7 +18,22 @@ Add superpowers to the `plugin` array in your `opencode.json` (global or project
 }
 }
 ```
 ```
 
 
-Restart OpenCode. The plugin installs through OpenCode's plugin manager and
+### OpenCode V2 (2.0.4 or later)
+
+Use the V2 plugin configuration:
+
+```json
+{
+  "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"]
+}
+```
+
+For a local V2 installation, configure the repository directory containing
+`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file
+path. Discovered plugin symlinks remain supported.
+
+Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available
+as an alias. The plugin installs through OpenCode's plugin manager and
 registers all skills.
 registers all skills.
 
 
 Verify by asking: "Tell me about your superpowers"
 Verify by asking: "Tell me about your superpowers"
@@ -55,19 +74,26 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 clear OpenCode's package cache or reinstall the plugin.
 clear OpenCode's package cache or reinstall the plugin.
 
 
-To pin a specific version:
+To pin a specific version, add a tag or commit to the spec (same form for the
+V1 `plugin` key and the V2 `plugins` key):
 
 
 ```json
 ```json
 {
 {
-  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"]
+  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.3.0"]
 }
 }
 ```
 ```
 
 
+On V2, pin a tag or commit that includes OpenCode V2 support; `v6.3.0` and
+earlier releases load only on V1.
+
 ## Troubleshooting
 ## Troubleshooting
 
 
 ### Plugin not loading
 ### Plugin not loading
 
 
-1. Check logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`
+1. Check logs. V1: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`.
+   V2 loads plugins in the background server, so add `--standalone`:
+   `opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers`,
+   or inspect `~/.local/share/opencode/log/opencode.log` filtering for `role=server`.
 2. Verify the plugin line in your `opencode.json`
 2. Verify the plugin line in your `opencode.json`
 3. Make sure you're running a recent version of OpenCode
 3. Make sure you're running a recent version of OpenCode
 
 
@@ -83,11 +109,23 @@ package:
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 ```
 ```
 
 
-Then use the installed package path in `opencode.json`:
+Then use the absolute path of the installed package in `opencode.json` for your
+OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a
+package name, not a local directory.
+
+**V1:**
 
 
 ```json
 ```json
 {
 {
-  "plugin": ["~/.config/opencode/node_modules/superpowers"]
+  "plugin": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
+}
+```
+
+**V2 (2.0.4 or later):**
+
+```json
+{
+  "plugins": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
 }
 }
 ```
 ```
 
 
@@ -98,7 +136,9 @@ Then use the installed package path in `opencode.json`:
 
 
 ### Tool mapping
 ### Tool mapping
 
 
-Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). On OpenCode these resolve to:
+Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). The plugin injects a flavor-specific mapping — check your OpenCode version:
+
+**V1 (`opencode` 1.x):**
 
 
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - `Subagent (general-purpose):` template → `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
 - `Subagent (general-purpose):` template → `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -109,6 +149,18 @@ Skills speak in actions ("create a todo", "dispatch a subagent", "read a file").
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 - "Fetch a URL" → `webfetch`
 
 
+**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):**
+
+- "Create a todo" → V2 has no todo tool; track the plan in a markdown file instead
+- `Subagent (general-purpose):` template → `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent
+- "Invoke a skill" → OpenCode's native `skill` tool
+- "Read a file" → `read`
+- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+- "Search the web" → `websearch`
+
 ## Getting Help
 ## Getting Help
 
 
 - Report issues: https://github.com/obra/superpowers/issues
 - Report issues: https://github.com/obra/superpowers/issues

+ 303 - 59
.opencode/plugins/superpowers.js

@@ -1,79 +1,80 @@
 /**
 /**
  * Superpowers plugin for OpenCode.ai
  * Superpowers plugin for OpenCode.ai
  *
  *
- * Injects superpowers bootstrap context via message transform.
- * Auto-registers skills directory via config hook (no symlinks needed).
+ * Dual-compatible with OpenCode V1 and V2.
+ *
+ * V1 (opencode): loaded via named export SuperpowersPlugin — provides config
+ * hook for skills registration and experimental.chat.messages.transform for
+ * bootstrap injection.
+ *
+ * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor.
+ * setup() registers skills natively via ctx.skill.transform(), and injects
+ * bootstrap context via ctx.session.hook("context").
+ *
+ * No external dependencies — pure JavaScript works in both V1 and V2 without
+ * installing @opencode-ai/plugin or effect.
  */
  */
 
 
 import path from 'path';
 import path from 'path';
 import fs from 'fs';
 import fs from 'fs';
-import os from 'os';
 import { fileURLToPath } from 'url';
 import { fileURLToPath } from 'url';
 
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 
-// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
+// Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform)
+const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
+
+// Simple frontmatter extraction (avoid dependency on skills-core for
+// bootstrap). Handles plain `key: value` lines, quoted values (including
+// quotes that close on an indented continuation line), YAML block scalar
+// markers (`>`, `|`) with indented continuation lines, and CRLF line
+// endings. Not a full YAML parser — nested maps flatten into their parent
+// key's value, which is fine for the name/description fields consumed here.
 const extractAndStripFrontmatter = (content) => {
 const extractAndStripFrontmatter = (content) => {
-  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
+  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
   if (!match) return { frontmatter: {}, content };
   if (!match) return { frontmatter: {}, content };
 
 
   const frontmatterStr = match[1];
   const frontmatterStr = match[1];
   const body = match[2];
   const body = match[2];
   const frontmatter = {};
   const frontmatter = {};
+  let lastKey = null;
 
 
-  for (const line of frontmatterStr.split('\n')) {
+  for (const rawLine of frontmatterStr.split('\n')) {
+    const line = rawLine.replace(/\r$/, '');
     const colonIdx = line.indexOf(':');
     const colonIdx = line.indexOf(':');
-    if (colonIdx > 0) {
+    if (colonIdx > 0 && !/^\s/.test(line)) {
       const key = line.slice(0, colonIdx).trim();
       const key = line.slice(0, colonIdx).trim();
-      const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
-      frontmatter[key] = value;
+      const value = line.slice(colonIdx + 1).trim();
+      // Block scalar markers (>, |, optionally with +/- chomping) carry no
+      // value themselves; the indented lines that follow do.
+      frontmatter[key] = /^(>[+-]?|\|[+-]?)$/.test(value) ? '' : value;
+      lastKey = key;
+    } else if (lastKey !== null && line.trim() !== '') {
+      // Continuation of a multi-line value: append rather than drop so long
+      // descriptions survive parsing. Newlines collapse to spaces — good
+      // enough for the single-line name/description fields consumed here.
+      frontmatter[lastKey] = `${frontmatter[lastKey]} ${line.trim()}`.trim();
     }
     }
   }
   }
 
 
-  return { frontmatter, content: body };
-};
-
-// Normalize a path: trim whitespace, expand ~, resolve to absolute
-const normalizePath = (p, homeDir) => {
-  if (!p || typeof p !== 'string') return null;
-  let normalized = p.trim();
-  if (!normalized) return null;
-  if (normalized.startsWith('~/')) {
-    normalized = path.join(homeDir, normalized.slice(2));
-  } else if (normalized === '~') {
-    normalized = homeDir;
+  // A quoted value may close on a continuation line, so unquote only once
+  // the value is fully assembled: strip exactly one matching surrounding
+  // pair and leave unbalanced quotes alone.
+  for (const key of Object.keys(frontmatter)) {
+    frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, '$2');
   }
   }
-  return path.resolve(normalized);
-};
-
-// Module-level cache for bootstrap content.
-// The SKILL.md file does not change during a session, so reading + parsing it
-// once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
-// every agent step.  See #1202 for the full analysis.
-let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
 
 
-export const SuperpowersPlugin = async ({ client, directory }) => {
-  const homeDir = os.homedir();
-  const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
-  const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
-  const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
-
-  // Helper to generate bootstrap content (cached after first call)
-  const getBootstrapContent = () => {
-    // Return cached result on subsequent calls
-    if (_bootstrapCache !== undefined) return _bootstrapCache;
-
-    // Try to load using-superpowers skill
-    const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
-    if (!fs.existsSync(skillPath)) {
-      _bootstrapCache = null;
-      return null;
-    }
+  return { frontmatter, content: body };
+};
 
 
-    const fullContent = fs.readFileSync(skillPath, 'utf8');
-    const { content } = extractAndStripFrontmatter(fullContent);
+// Tool mapping injected into the bootstrap, differentiated by host flavor.
+// V1 (OpenCode 1.18.x) and V2 (OpenCode 2.0.4/2.0.7) expose different built-in
+// tools, so each flavor's injection path picks its own constant below.
+// Exported for tests (tests/opencode/test-bootstrap-caching.mjs).
 
 
-    const toolMapping = `**Tool Mapping for OpenCode:**
+// V1 built-ins: todowrite, task (subagent_type), skill, read, apply_patch,
+// bash, grep, glob, webfetch.
+export const V1_MAPPING = `**Tool Mapping for OpenCode:**
 When skills request actions, substitute OpenCode equivalents:
 When skills request actions, substitute OpenCode equivalents:
 - Create or update todos → \`todowrite\`
 - Create or update todos → \`todowrite\`
 - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
 - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
@@ -86,7 +87,47 @@ When skills request actions, substitute OpenCode equivalents:
 
 
 Use OpenCode's native \`skill\` tool to list and load skills.`;
 Use OpenCode's native \`skill\` tool to list and load skills.`;
 
 
-    _bootstrapCache = `<EXTREMELY_IMPORTANT>
+// V2 built-ins: no todo tool at all; task → subagent (agent name in 'agent',
+// continuation via sessionID); apply_patch → patch (patchText, same patch
+// format); bash → shell. read, write, edit, grep, glob, webfetch, websearch,
+// and skill all exist under those names (verified against the 2.0.4 and 2.0.7
+// host contracts).
+export const V2_MAPPING = `**Tool Mapping for OpenCode:**
+When skills request actions, substitute OpenCode equivalents:
+- Create or update todos → OpenCode v2 has no todo tool; track the plan in a markdown file (or the harness's plan facility) instead
+- \`Subagent (general-purpose):\` → \`subagent\` with \`agent: "general"\` (give it \`description\` and \`prompt\`, optionally \`background\`; pass \`sessionID\` to continue a previous subagent)
+- Invoke a skill → OpenCode's native \`skill\` tool
+- Read files → \`read\`
+- Create, edit, or delete files → use \`patch\` with \`patchText\` when available; otherwise use \`write\` to create or overwrite files, \`edit\` for targeted changes, and \`shell\` for deletion
+- Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`)
+- Search files → \`grep\`, \`glob\`
+- Fetch a URL → \`webfetch\`
+- Search the web → \`websearch\`
+
+Use OpenCode's native \`skill\` tool to list and load skills.`;
+
+// Module-level cache for bootstrap content, keyed by tool mapping (host
+// flavor). The SKILL.md file does not change during a session, so reading +
+// parsing it once eliminates redundant fs.existsSync + fs.readFileSync +
+// regex work on every agent step.  See #1202 for the full analysis.
+const _bootstrapCache = new Map(); // mapping -> bootstrap (null = file missing)
+
+// Helper to generate bootstrap content (cached after first call per mapping)
+const getBootstrapContent = (toolMapping) => {
+  // Return cached result on subsequent calls
+  if (_bootstrapCache.has(toolMapping)) return _bootstrapCache.get(toolMapping);
+
+  // Try to load using-superpowers skill
+  const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
+  if (!fs.existsSync(skillPath)) {
+    _bootstrapCache.set(toolMapping, null);
+    return null;
+  }
+
+  const fullContent = fs.readFileSync(skillPath, 'utf8');
+  const { content } = extractAndStripFrontmatter(fullContent);
+
+  _bootstrapCache.set(toolMapping, `<EXTREMELY_IMPORTANT>
 You have superpowers.
 You have superpowers.
 
 
 **IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.**
 **IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.**
@@ -94,17 +135,96 @@ You have superpowers.
 ${content}
 ${content}
 
 
 ${toolMapping}
 ${toolMapping}
-</EXTREMELY_IMPORTANT>`;
+</EXTREMELY_IMPORTANT>`);
 
 
-    return _bootstrapCache;
-  };
+  return _bootstrapCache.get(toolMapping);
+};
+
+// --- Task-subagent (child session) detection --------------------------------
+//
+// #2160: the bootstrap drives controller workflows (brainstorming, planning,
+// approval cycles). Injecting it into task subagent sessions makes workers
+// restart design/approval cycles for work the parent already authorised; the
+// <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
+// is not reliable. Detect child sessions structurally instead: a parentID on
+// the session is the child signal on both flavors (task sessions are created
+// with one; top-level sessions simply lack the field), so when the session
+// carrying the message has a parentID we skip bootstrap injection. Skills
+// stay registered for every session — workers keep explicit access to
+// execution skills.
+
+// sessionID -> is-child decision. parentID never changes for a session, so
+// the result is cached until eviction and the injection hook (which fires on
+// every agent step) pays only one client roundtrip per session. The V2
+// service process is long-lived and sessions accumulate over weeks, so the
+// cache is bounded: when full, drop the oldest quarter (Map iterates keys in
+// insertion order). An evicted session merely pays one extra lookup if seen
+// again.
+const CHILD_SESSION_CACHE_MAX = 512;
+const _childSessionCache = new Map();
+
+const _cacheChildSession = (sessionID, isChild) => {
+  if (_childSessionCache.size >= CHILD_SESSION_CACHE_MAX) {
+    let toDrop = Math.ceil(CHILD_SESSION_CACHE_MAX / 4);
+    for (const key of _childSessionCache.keys()) {
+      if (toDrop-- <= 0) break;
+      _childSessionCache.delete(key);
+    }
+  }
+  _childSessionCache.set(sessionID, isChild);
+};
+
+const isChildSession = async (fetchSession, sessionID) => {
+  if (!sessionID) return false; // unknown session: keep current behavior
+  if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
 
 
+  let isChild = false;
+  try {
+    const result = await fetchSession(sessionID);
+    // V1 returns a successful SDK envelope while V2 returns a direct session
+    // record. Validate both shapes before classifying or caching the result;
+    // resolved SDK errors must follow the same fail-open path as rejections.
+    if (!result || typeof result !== 'object' || Array.isArray(result)) {
+      throw new Error('Session lookup returned no usable record');
+    }
+    if (result.error != null || result.response?.ok === false) {
+      throw new Error('Session lookup was unsuccessful');
+    }
+    const session = 'data' in result ? result.data : result;
+    if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== sessionID) {
+      throw new Error('Session lookup returned an invalid session identity');
+    }
+    if (session.parentID !== undefined &&
+        (typeof session.parentID !== 'string' || session.parentID.length === 0)) {
+      throw new Error('Session lookup returned an invalid parent identity');
+    }
+    isChild = session.parentID !== undefined;
+  } catch (err) {
+    // Fail open: on lookup errors keep injecting (previous behavior) and do
+    // not cache, so a transient failure can recover on the next step.
+    console.error('[superpowers] session lookup failed, treating session as top-level:', err);
+    return false;
+  }
+  _cacheChildSession(sessionID, isChild);
+  return isChild;
+};
+
+/**
+ * V1 Plugin Function (named export + default.server)
+ *
+ * Used by V1 (OpenCode 1.x): discovered via named export scanning.
+ * Provides: config hook (V1 skills registration) + bootstrap injection
+ * (experimental.chat.messages.transform).
+ */
+export const SuperpowersPlugin = async ({ client, directory }) => {
   return {
   return {
     // Inject skills path into live config so OpenCode discovers superpowers skills
     // Inject skills path into live config so OpenCode discovers superpowers skills
     // without requiring manual symlinks or config file edits.
     // without requiring manual symlinks or config file edits.
-    // This works because Config.get() returns a cached singleton — modifications
-    // here are visible when skills are lazily discovered later.
     config: async (config) => {
     config: async (config) => {
+      // V2: skills is a flat array — skip, setup() handles V2 skill registration
+      if (Array.isArray(config.skills)) return;
+
+      // V1: skills is { paths: [...] }
       config.skills = config.skills || {};
       config.skills = config.skills || {};
       config.skills.paths = config.skills.paths || [];
       config.skills.paths = config.skills.paths || [];
       if (!config.skills.paths.includes(superpowersSkillsDir)) {
       if (!config.skills.paths.includes(superpowersSkillsDir)) {
@@ -112,7 +232,7 @@ ${toolMapping}
       }
       }
     },
     },
 
 
-    // Inject bootstrap into the first user message of each session.
+    // Inject bootstrap into the first user message of each top-level session.
     // Using a user message instead of a system message avoids:
     // Using a user message instead of a system message avoids:
     //   1. Token bloat from system messages repeated every turn (#750)
     //   1. Token bloat from system messages repeated every turn (#750)
     //   2. Multiple system messages breaking Qwen and other models (#894)
     //   2. Multiple system messages breaking Qwen and other models (#894)
@@ -122,18 +242,142 @@ ${toolMapping}
     // arrays may need injection again, so getBootstrapContent() must not do
     // arrays may need injection again, so getBootstrapContent() must not do
     // repeated disk work.
     // repeated disk work.
     'experimental.chat.messages.transform': async (_input, output) => {
     'experimental.chat.messages.transform': async (_input, output) => {
-      const bootstrap = getBootstrapContent();
+      const bootstrap = getBootstrapContent(V1_MAPPING);
       if (!bootstrap || !output.messages.length) return;
       if (!bootstrap || !output.messages.length) return;
       const firstUser = output.messages.find(m => m.info.role === 'user');
       const firstUser = output.messages.find(m => m.info.role === 'user');
       if (!firstUser || !firstUser.parts.length) return;
       if (!firstUser || !firstUser.parts.length) return;
 
 
       // Guard: skip if first user message already contains bootstrap.
       // Guard: skip if first user message already contains bootstrap.
-      // This prevents double injection when OpenCode passes an already
-      // transformed in-memory message array through the hook again.
       if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
       if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
 
 
+      // #2160: never restart the controller workflow inside task subagent
+      // (child) sessions. V1 passes no input to this hook (verified in the
+      // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
+      // from the message record itself.
+      if (client && await isChildSession(
+        (id) => client.session.get({ path: { id } }),
+        firstUser.info.sessionID,
+      )) return;
+
       const ref = firstUser.parts[0];
       const ref = firstUser.parts[0];
       firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
       firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
     }
     }
   };
   };
 };
 };
+
+/**
+ * V2 Setup Function (default.setup)
+ *
+ * Called by V2 PluginSupervisor (packages/core/src/plugin/supervisor.ts).
+ * Performs two things:
+ *
+ * 1. Registers every skills/<name>/SKILL.md as a native Skill.Info object
+ *    via ctx.skill.transform((draft) => draft.add(info)).
+ *    V2 removed the old draft.source() directory registration; the draft API
+ *    is now { list, add, update, remove } where add() decodes plain objects
+ *    against the host's Skill.Info schema (OpenCode 2.0.4 contract):
+ *    { id, name, description?, autoinvoke?, path, content }. The file field
+ *    is `path` — renamed from `location` in upstream commit 199aabe9e2,
+ *    first released in v2.0.4.
+ *    See packages/core/src/plugin/skill.ts and packages/schema/src/skill.ts.
+ * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
+ *    equivalent of V1's experimental.chat.messages.transform.
+ */
+async function setup(ctx) {
+  // V1 (observed on opencode 1.18.18) also invokes default.setup, but with a
+  // V1-shaped ctx that lacks the skill/session domains. Detect it and return
+  // quietly — V1 is served entirely by the SuperpowersPlugin named export.
+  if (!ctx || !ctx.skill || typeof ctx.skill.transform !== 'function' || !ctx.session || typeof ctx.session.hook !== 'function') {
+    return;
+  }
+
+  // 1. Register skills (one transform; one draft.add per skill)
+  try {
+    const skills = [];
+    if (fs.existsSync(superpowersSkillsDir)) {
+      for (const entry of fs.readdirSync(superpowersSkillsDir, { withFileTypes: true })) {
+        if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
+        const skillPath = path.join(superpowersSkillsDir, entry.name, 'SKILL.md');
+        if (!fs.existsSync(skillPath)) continue;
+        const { frontmatter, content } = extractAndStripFrontmatter(fs.readFileSync(skillPath, 'utf8'));
+        skills.push({
+          id: entry.name,
+          name: frontmatter.name || entry.name,
+          ...(frontmatter.description ? { description: frontmatter.description } : {}),
+          // Skill.Info renamed its required file field `location` -> `path`
+          // in OpenCode v2.0.4 (upstream commit 199aabe9e2).
+          path: skillPath,
+          content,
+        });
+      }
+    }
+    await ctx.skill.transform((draft) => {
+      // draft.add() decodes against the host's Skill.Info schema and throws
+      // synchronously on a mismatch. A throw escaping this callback is what
+      // the host escalates into an asynchronous hard-disable of the entire
+      // plugin ("Plugin disabled after skill.transform failed") — the
+      // try/catch around ctx.skill.transform never sees it, and the
+      // bootstrap hook is torn down as collateral. Contain failures per
+      // skill so one rejected payload skips that skill instead of killing
+      // skills AND bootstrap.
+      for (const skill of skills) {
+        try {
+          draft.add(skill);
+        } catch (err) {
+          console.error(`[superpowers] skill "${skill.id}" rejected by host, skipping:`, err);
+        }
+      }
+    });
+  } catch (err) {
+    // Never break plugin activation: one failing plugin takes down the whole
+    // V2 generation (including provider/catalog plugins => no models in TUI).
+    console.error('[superpowers] skill registration failed:', err);
+  }
+
+  // 2. Inject bootstrap into first user message via V2 session context hook
+  try {
+    await ctx.session.hook('context', async (event) => {
+      try {
+        const bootstrap = getBootstrapContent(V2_MAPPING);
+        if (!bootstrap || !event.messages || !event.messages.length) return;
+        const firstUser = event.messages.find(m => m.role === 'user');
+        if (firstUser && (!firstUser.content || !firstUser.content.length)) return;
+        if (firstUser?.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
+
+        // #2160: the context event carries the sessionID directly. Skip the
+        // controller bootstrap when this prompt belongs to a task subagent
+        // (child) session. Skills registered above stay available to workers.
+        if (typeof ctx.session.get === 'function' && await isChildSession(
+          (id) => ctx.session.get({ sessionID: id }),
+          event.sessionID,
+        )) return;
+
+        // Native compaction can leave only an opaque checkpoint. Keep it
+        // intact and append the transient bootstrap as a user message.
+        if (firstUser) {
+          firstUser.content.unshift({ type: 'text', text: bootstrap });
+        } else {
+          event.messages.push({ role: 'user', content: [{ type: 'text', text: bootstrap }] });
+        }
+      } catch (err) {
+        // Never let hook callback errors break the request pipeline.
+        console.error('[superpowers] context hook failed:', err);
+      }
+    });
+  } catch (err) {
+    console.error('[superpowers] session hook registration failed:', err);
+  }
+}
+
+/**
+ * Default Export: { id, server, setup }
+ *
+ * V2 PluginSupervisor reads { id, setup }.
+ * V1 reads named export SuperpowersPlugin.
+ * server() is exported for V1 compatibility.
+ */
+export default {
+  id: 'superpowers',
+  server: SuperpowersPlugin,
+  setup,
+};

+ 104 - 22
docs/README.opencode.md

@@ -4,7 +4,11 @@ Complete guide for using Superpowers with [OpenCode.ai](https://opencode.ai).
 
 
 ## Installation
 ## Installation
 
 
-Add superpowers to the `plugin` array in your `opencode.json` (global or project-level):
+OpenCode V2 requires version 2.0.4 or later.
+
+### OpenCode V1
+
+Use the existing V1 plugin configuration:
 
 
 ```json
 ```json
 {
 {
@@ -12,15 +16,27 @@ Add superpowers to the `plugin` array in your `opencode.json` (global or project
 }
 }
 ```
 ```
 
 
-Restart OpenCode. The plugin installs through OpenCode's plugin manager and
+### OpenCode V2 (2.0.4 or later)
+
+Use the V2 plugin configuration:
+
+```json
+{
+  "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"]
+}
+```
+
+For a local V2 installation, configure the repository directory containing
+`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file
+path. Discovered plugin symlinks remain supported.
+
+Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available
+as an alias. The plugin installs through OpenCode's plugin manager and
 registers all skills.
 registers all skills.
 
 
 Verify by asking: "Tell me about your superpowers"
 Verify by asking: "Tell me about your superpowers"
 
 
-OpenCode uses its own plugin install. If you also use Claude Code, Codex, or
-another harness, install Superpowers separately for each one.
-
-### Migrating from the old symlink-based install
+### Migrating from the old symlink-based install (V1)
 
 
 If you previously installed superpowers using `git clone` and symlinks, remove the old setup:
 If you previously installed superpowers using `git clone` and symlinks, remove the old setup:
 
 
@@ -78,7 +94,10 @@ description: Use when [condition] - [what it does]
 
 
 Create project-specific skills in `.opencode/skills/` within your project.
 Create project-specific skills in `.opencode/skills/` within your project.
 
 
-**Skill Priority:** Project skills > Personal skills > Superpowers skills
+**V2 Skill Priority:** Project skills > Personal skills > Superpowers skills. On
+tested V1 1.18.31, bundled Superpowers skills take precedence when a personal
+or project skill has the same name; use distinct names for personal and project
+skills. This behavior is unchanged by the migration.
 
 
 ## Updating
 ## Updating
 
 
@@ -87,24 +106,46 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 clear OpenCode's package cache or reinstall the plugin.
 clear OpenCode's package cache or reinstall the plugin.
 
 
-To pin a specific version, use a branch or tag:
+To pin a specific version, add a tag or commit to the spec (same form for the
+V1 `plugin` key and the V2 `plugins` key):
 
 
 ```json
 ```json
 {
 {
-  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"]
+  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.3.0"]
 }
 }
 ```
 ```
 
 
+On V2, pin a tag or commit that includes OpenCode V2 support; `v6.3.0` and
+earlier releases load only on V1.
+
 ## How It Works
 ## How It Works
 
 
-The plugin does two things:
+The plugin does two things, using host-flavor-specific APIs:
+
+1. **Registers the skills directory** so OpenCode discovers all superpowers skills without symlinks or manual config.
+    - **V1:** via the `config` hook, injecting into `config.skills.paths`
+    - **V2:** via the `setup()` function using `ctx.skill.transform()` (V2 native API, confirmed active at runtime)
+2. **Injects bootstrap context** with a flavor-specific tool mapping: V1 sessions get the V1 tool names below, and V2 sessions get the V2 names.
+    - **V1:** via `experimental.chat.messages.transform` hook
+    - **V2:** via `ctx.session.hook("context")` — the V2 equivalent (confirmed active at runtime)
 
 
-1. **Injects bootstrap context** via the `experimental.chat.messages.transform` hook, adding superpowers awareness to every conversation.
-2. **Registers the skills directory** via the `config` hook, so OpenCode discovers all superpowers skills without symlinks or manual config.
+Controller sessions receive the using-superpowers bootstrap in transient model
+context. Delegated child sessions keep access to native skills but do not receive
+the controller bootstrap. A manual fork without a parent session keeps controller
+behavior. When V2 native compaction retains earlier user messages (the default
+`compaction.keep.tokens` budget), the bootstrap goes into the first retained user
+message ahead of the checkpoint, as in an uncompacted session. When compaction
+removes all user messages, the plugin appends a transient bootstrap message after
+the checkpoint. Saved history is unchanged either way.
+
+If session lookup fails, the plugin keeps bootstrap for that request and retries
+on the next request. Failed lookups are not cached as controller decisions.
 
 
 ### Tool Mapping
 ### Tool Mapping
 
 
-Skills speak in actions rather than naming any one runtime's tools. On OpenCode these resolve to:
+Skills speak in actions rather than naming any one runtime's tools. The bootstrap maps them to the tools your OpenCode flavor actually exposes.
+
+**V1 (`opencode` 1.x):**
 
 
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - `Subagent (general-purpose):` template → OpenCode's `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
 - `Subagent (general-purpose):` template → OpenCode's `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -115,15 +156,43 @@ Skills speak in actions rather than naming any one runtime's tools. On OpenCode
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 - "Fetch a URL" → `webfetch`
 
 
-(Verified against the installed OpenCode CLI's tool inventory.)
+**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):**
+
+- "Create a todo" → V2 has no todo tool of any kind; the mapping tells the model to track the plan in a markdown file (or the harness's plan facility) instead
+- `Subagent (general-purpose):` template → OpenCode's `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent
+- "Invoke a skill" → OpenCode's native `skill` tool
+- "Read a file" → `read`
+- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+- "Search the web" → `websearch`
+
+In short, V2 renamed `task` → `subagent` (the agent name moved from `subagent_type` to `agent`, and continuation happens by re-invoking with `sessionID`), `apply_patch` → `patch`, and `bash` → `shell`, and it dropped the todo tool entirely. The available mutation tools depend on the selected model: `patch` is available for selected GPT model IDs, while other models use `write` and `edit`.
+
+(V1 list verified against the installed OpenCode 1.18.x CLI's tool inventory; V2 list verified against the OpenCode 2.0.4 and 2.0.7 host contracts.)
 
 
 ## Troubleshooting
 ## Troubleshooting
 
 
 ### Plugin not loading
 ### Plugin not loading
 
 
-1. Check OpenCode logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`
-2. Verify the plugin line in your `opencode.json` is correct
-3. Make sure you're running a recent version of OpenCode
+**V1:** Check OpenCode logs:
+
+```
+opencode run --print-logs "hello" 2>&1 | grep -i superpowers
+```
+
+**V2:** Plugins load in the background server, whose logs `--print-logs` only
+shows with `--standalone`:
+
+```
+opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers
+```
+
+Or inspect `~/.local/share/opencode/log/opencode.log`, filtering for `role=server`.
+
+Also verify the plugin path in your `opencode.json` is correct and that you're
+running a recent version of OpenCode.
 
 
 ### Windows install issues
 ### Windows install issues
 
 
@@ -137,11 +206,23 @@ package:
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 ```
 ```
 
 
-Then use the installed package path in `opencode.json`:
+Then use the absolute path of the installed package in `opencode.json` for your
+OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a
+package name, not a local directory.
+
+**V1:**
+
+```json
+{
+  "plugin": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
+}
+```
+
+**V2 (2.0.4 or later):**
 
 
 ```json
 ```json
 {
 {
-  "plugin": ["~/.config/opencode/node_modules/superpowers"]
+  "plugins": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
 }
 }
 ```
 ```
 
 
@@ -153,11 +234,12 @@ Then use the installed package path in `opencode.json`:
 
 
 ### Bootstrap not appearing
 ### Bootstrap not appearing
 
 
-1. Check OpenCode version supports `experimental.chat.messages.transform` hook
-2. Restart OpenCode after config changes
+- **V1:** Check OpenCode version supports `experimental.chat.messages.transform` hook. Restart OpenCode after config changes.
+- **V2:** The plugin uses `ctx.session.hook("context")` for bootstrap injection. Verify the plugin loaded via `opencode api get /api/plugin`. Restart with `opencode service restart` after config changes. The `opencode2` command may be available as an alias.
 
 
 ## Getting Help
 ## Getting Help
 
 
 - Report issues: https://github.com/obra/superpowers/issues
 - Report issues: https://github.com/obra/superpowers/issues
 - Main documentation: https://github.com/obra/superpowers
 - Main documentation: https://github.com/obra/superpowers
-- OpenCode docs: https://opencode.ai/docs/
+- OpenCode V2 docs: https://opencode.ai/v2/docs/
+- OpenCode V1 docs: https://opencode.ai/docs/

+ 1 - 1
docs/porting-to-a-new-harness.md

@@ -802,7 +802,7 @@ Use this as the live index; when in doubt, read the files, not this table.
 | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | — |
 | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | — |
 | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
 | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
 | Kimi Code | `.kimi-plugin/plugin.json` | manifest `sessionStart.skill` loads `using-superpowers` | inline `skillInstructions` in manifest | `tests/kimi/` | marketplace or `/plugins install` GitHub URL |
 | Kimi Code | `.kimi-plugin/plugin.json` | manifest `sessionStart.skill` loads `using-superpowers` | inline `skillInstructions` in manifest | `tests/kimi/` | marketplace or `/plugins install` GitHub URL |
-| OpenCode | `.opencode/plugins/superpowers.js` (declared via root `package.json` `main`) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` plugin git URL |
+| OpenCode | `.opencode/plugins/superpowers.js` (root `package.json` `main` for package installs; root `index.js` re-export for the V2 directory form) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` (V1) / `session.hook("context")` (V2) injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` `plugin` (V1) / `plugins` (V2) git URL |
 | pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields |
 | pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields |
 
 
 ## Appendix B — Gotchas that have bitten porters
 ## Appendix B — Gotchas that have bitten porters

+ 9 - 0
index.js

@@ -0,0 +1,9 @@
+// Root entrypoint for OpenCode v2 directory-form plugin registration.
+//
+// OpenCode V2 hosts (2.0.4 or later) require config plugin entries to be directories
+// with an index entrypoint (`index.js`) and reject bare file paths
+// ("configured plugin path must be a directory"). npm/git package installs
+// resolve via package.json `main`; this file only serves the directory form,
+// an absolute path such as `"plugins": ["/path/to/superpowers"]` (`~` is not
+// expanded).
+export { default } from "./.opencode/plugins/superpowers.js";

+ 1 - 0
scripts/sync-to-codex-plugin.sh

@@ -69,6 +69,7 @@ EXCLUDES=(
   "/GEMINI.md"
   "/GEMINI.md"
   "/RELEASE-NOTES.md"
   "/RELEASE-NOTES.md"
   "/gemini-extension.json"
   "/gemini-extension.json"
+  "/index.js"
   "/package.json"
   "/package.json"
 
 
   # Directories not shipped by canonical Codex plugins
   # Directories not shipped by canonical Codex plugins

+ 6 - 0
tests/codex-plugin-sync/test-sync-to-codex-plugin.sh

@@ -194,6 +194,10 @@ write_upstream_fixture() {
   "name": "fixture-upstream",
   "name": "fixture-upstream",
   "version": "$PACKAGE_VERSION"
   "version": "$PACKAGE_VERSION"
 }
 }
+EOF
+
+    cat > "$repo/index.js" <<'EOF'
+export { default } from "./.opencode/plugins/superpowers.js";
 EOF
 EOF
 
 
     cat > "$repo/.gitignore" <<'EOF'
     cat > "$repo/.gitignore" <<'EOF'
@@ -303,6 +307,7 @@ EOF
         hooks/run-hook.cmd \
         hooks/run-hook.cmd \
         hooks/session-start \
         hooks/session-start \
         hooks/session-start-codex \
         hooks/session-start-codex \
+        index.js \
         package.json \
         package.json \
         scripts/sync-to-codex-plugin.sh \
         scripts/sync-to-codex-plugin.sh \
         skills/example/SKILL.md
         skills/example/SKILL.md
@@ -664,6 +669,7 @@ main() {
     assert_not_contains "$preview_section" "evals/" "Preview excludes eval harness"
     assert_not_contains "$preview_section" "evals/" "Preview excludes eval harness"
     assert_not_contains "$preview_section" ".gitmodules" "Preview excludes repo submodule metadata"
     assert_not_contains "$preview_section" ".gitmodules" "Preview excludes repo submodule metadata"
     assert_not_contains "$preview_section" ".pre-commit-config.yaml" "Preview excludes repo pre-commit config"
     assert_not_contains "$preview_section" ".pre-commit-config.yaml" "Preview excludes repo pre-commit config"
+    assert_not_contains "$preview_section" "index.js" "Preview excludes OpenCode root entrypoint"
     assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
     assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
     assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
     assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
     assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"
     assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"

+ 4 - 0
tests/opencode/run-tests.sh

@@ -45,6 +45,8 @@ while [[ $# -gt 0 ]]; do
             echo "Tests:"
             echo "Tests:"
             echo "  test-plugin-loading.sh  Verify plugin installation and structure"
             echo "  test-plugin-loading.sh  Verify plugin installation and structure"
             echo "  test-bootstrap-caching.sh  Verify bootstrap content caching"
             echo "  test-bootstrap-caching.sh  Verify bootstrap content caching"
+            echo "  test-session-bootstrap.sh  Verify session classification and lookup recovery"
+            echo "  test-skill-registration.sh  Verify V2 skill registration contract (2.0.4 path field)"
             echo "  test-tools.sh           Test use_skill and find_skills tools (integration)"
             echo "  test-tools.sh           Test use_skill and find_skills tools (integration)"
             echo "  test-priority.sh        Test skill priority resolution (integration)"
             echo "  test-priority.sh        Test skill priority resolution (integration)"
             exit 0
             exit 0
@@ -61,6 +63,8 @@ done
 tests=(
 tests=(
     "test-plugin-loading.sh"
     "test-plugin-loading.sh"
     "test-bootstrap-caching.sh"
     "test-bootstrap-caching.sh"
+    "test-session-bootstrap.sh"
+    "test-skill-registration.sh"
 )
 )
 
 
 # Integration tests (require OpenCode)
 # Integration tests (require OpenCode)

+ 122 - 0
tests/opencode/test-bootstrap-caching.mjs

@@ -32,6 +32,10 @@ const mod = await import(pathToFileURL(pluginPath).href);
 const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
 const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
 const transform = plugin['experimental.chat.messages.transform'];
 const transform = plugin['experimental.chat.messages.transform'];
 
 
+// Mapping constants are flavor-specific (#opencode-v2): V1 keeps the 1.18.x
+// tool names, V2 teaches the renamed tools. Assert both directly.
+const mappingFailures = assertMappingConstants(mod);
+
 const firstOutput = makeOutput(`${scenario} bootstrap first step`);
 const firstOutput = makeOutput(`${scenario} bootstrap first step`);
 await transform({}, firstOutput);
 await transform({}, firstOutput);
 const afterFirst = { existsCount, readCount };
 const afterFirst = { existsCount, readCount };
@@ -40,6 +44,11 @@ const secondOutput = makeOutput(`${scenario} bootstrap second step`);
 await transform({}, secondOutput);
 await transform({}, secondOutput);
 const afterSecond = { existsCount, readCount };
 const afterSecond = { existsCount, readCount };
 
 
+// Exercise the V2 path (setup() + ctx.session.hook("context")) with a mock
+// ctx so the V2_MAPPING wiring is verified, not just the constant. Run after
+// the V1 count snapshots: setup() reads SKILL.md files during registration.
+const v2Result = await runV2ContextHook(mod);
+
 const result = {
 const result = {
   scenario,
   scenario,
   firstBootstrapParts: countBootstrapParts(firstOutput),
   firstBootstrapParts: countBootstrapParts(firstOutput),
@@ -52,12 +61,24 @@ const result = {
   secondReadCount: afterSecond.readCount,
   secondReadCount: afterSecond.readCount,
   firstExistsCount: afterFirst.existsCount,
   firstExistsCount: afterFirst.existsCount,
   secondExistsCount: afterSecond.existsCount,
   secondExistsCount: afterSecond.existsCount,
+  v2BootstrapParts: v2Result.bootstrapParts,
+  mapsV2SubagentTool: v2Result.text.includes('`subagent` with `agent: "general"`'),
+  mapsV2SessionIDContinuation: v2Result.text.includes('`sessionID` to continue a previous subagent'),
+  mapsV2NoTodoTool: v2Result.text.includes('no todo tool'),
+  mapsV2MutationToPatch: v2Result.text.includes('`patch` with `patchText`'),
+  mapsV2Shell: v2Result.text.includes('`shell`'),
+  staleV1ToolsInV2: v2Result.text.includes('`apply_patch`') || v2Result.text.includes('`todowrite`') || v2Result.text.includes('`subagent_type`'),
 };
 };
 
 
 const failures = scenario === 'present'
 const failures = scenario === 'present'
   ? assertPresentBootstrap(result)
   ? assertPresentBootstrap(result)
   : assertMissingBootstrap(result);
   : assertMissingBootstrap(result);
 
 
+if (scenario === 'present') {
+  failures.push(...assertV2Bootstrap(result));
+}
+failures.push(...mappingFailures);
+
 if (failures.length > 0) {
 if (failures.length > 0) {
   console.error(JSON.stringify(result, null, 2));
   console.error(JSON.stringify(result, null, 2));
   for (const failure of failures) {
   for (const failure of failures) {
@@ -144,3 +165,104 @@ function assertMissingBootstrap(result) {
   }
   }
   return failures;
   return failures;
 }
 }
+
+function assertMappingConstants(mod) {
+  const failures = [];
+  if (typeof mod.V1_MAPPING !== 'string' || typeof mod.V2_MAPPING !== 'string') {
+    failures.push('expected plugin to export V1_MAPPING and V2_MAPPING string constants');
+    return failures;
+  }
+  for (const needle of ['`todowrite`', '`task` with `subagent_type: "general"`', '`apply_patch`', '`bash`']) {
+    if (!mod.V1_MAPPING.includes(needle)) {
+      failures.push(`expected V1_MAPPING to keep the 1.18.x tool name ${needle}`);
+    }
+  }
+  for (const needle of [
+    '`subagent` with `agent: "general"`',
+    '`sessionID` to continue a previous subagent',
+    'no todo tool',
+    '`write`',
+    '`edit`',
+    '`patch` with `patchText`',
+    '`shell`',
+    '`read`',
+    '`grep`, `glob`',
+    '`webfetch`',
+    '`websearch`',
+  ]) {
+    if (!mod.V2_MAPPING.includes(needle)) {
+      failures.push(`expected V2_MAPPING to teach the V2 tool ${needle}`);
+    }
+  }
+  for (const stale of ['`todowrite`', '`task` with', '`apply_patch`', '`bash`']) {
+    if (mod.V2_MAPPING.includes(stale)) {
+      failures.push(`expected V2_MAPPING not to teach the V1-only tool name ${stale}`);
+    }
+  }
+  return failures;
+}
+
+// Drive setup() with a mock V2 ctx and fire the captured "context" hook on a
+// top-level (parentID-less) session. Returns the injected-part count and the
+// injected bootstrap text ('' when nothing was injected).
+async function runV2ContextHook(mod) {
+  let contextHook = null;
+  const ctx = {
+    skill: {
+      transform: async (fn) => {
+        fn({ add: () => {} });
+      },
+    },
+    session: {
+      hook: async (name, cb) => {
+        if (name === 'context') contextHook = cb;
+      },
+      get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID
+    },
+  };
+  try {
+    await mod.default.setup(ctx);
+  } catch (err) {
+    console.error('[test] V2 setup() threw:', err);
+    return { bootstrapParts: 0, text: '' };
+  }
+  if (typeof contextHook !== 'function') {
+    return { bootstrapParts: 0, text: '' };
+  }
+  const event = {
+    sessionID: 'sess-v2-top',
+    messages: [{ role: 'user', content: [{ type: 'text', text: 'v2 bootstrap step' }] }],
+  };
+  await contextHook(event);
+  const parts = event.messages[0].content.filter(
+    (part) => part.type === 'text' && part.text.includes('EXTREMELY_IMPORTANT')
+  );
+  return { bootstrapParts: parts.length, text: parts[0]?.text || '' };
+}
+
+function assertV2Bootstrap(result) {
+  const failures = [];
+  if (result.v2BootstrapParts !== 1) {
+    failures.push(`expected V2 context hook to inject one bootstrap part, got ${result.v2BootstrapParts}`);
+    return failures;
+  }
+  if (!result.mapsV2SubagentTool) {
+    failures.push('expected V2 bootstrap to map general-purpose subagents to subagent with agent');
+  }
+  if (!result.mapsV2SessionIDContinuation) {
+    failures.push('expected V2 bootstrap to teach sessionID continuation for subagents');
+  }
+  if (!result.mapsV2NoTodoTool) {
+    failures.push('expected V2 bootstrap to state that V2 has no todo tool');
+  }
+  if (!result.mapsV2MutationToPatch) {
+    failures.push('expected V2 bootstrap to map file mutation to patch with patchText');
+  }
+  if (!result.mapsV2Shell) {
+    failures.push('expected V2 bootstrap to map shell commands to the shell tool');
+  }
+  if (result.staleV1ToolsInV2) {
+    failures.push('expected V2 bootstrap not to teach V1-only tool names (apply_patch/todowrite/subagent_type)');
+  }
+  return failures;
+}

+ 224 - 0
tests/opencode/test-session-bootstrap.mjs

@@ -0,0 +1,224 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+const [, , inputPath] = process.argv;
+assert.ok(inputPath, 'pass the plugin module path');
+const pluginURL = pathToFileURL(fs.realpathSync(inputPath));
+const marker = '<EXTREMELY_IMPORTANT>\nYou have superpowers.';
+let generation = 0;
+
+function reply(flavor, session) {
+  return flavor === 'v1' ? { data: session } : session;
+}
+
+function makeEvent(flavor, sessionID) {
+  const text = { type: 'text', text: 'Execute the assigned task' };
+  return {
+    sessionID,
+    messages: [flavor === 'v1'
+      ? { info: { role: 'user', sessionID }, parts: [text] }
+      : { role: 'user', content: [text] }],
+  };
+}
+
+function bootstrapCount(event) {
+  return event.messages.flatMap((message) => message.parts ?? message.content ?? []).filter(
+    (part) => part.type === 'text' && part.text.startsWith(marker)
+  ).length;
+}
+
+async function makeHarness(flavor, fetchSession) {
+  const mod = await import(`${pluginURL.href}?session-test=${++generation}`);
+  const lookups = [];
+  const registered = [];
+  const get = async (id) => {
+    lookups.push(id);
+    return fetchSession(id, lookups.length);
+  };
+  let invoke;
+  if (flavor === 'v1') {
+    const hooks = await mod.SuperpowersPlugin({
+      client: { session: { get: ({ path: { id } }) => get(id) } },
+      directory: '.',
+    });
+    invoke = (event) => hooks['experimental.chat.messages.transform']({}, event);
+  } else {
+    await mod.default.setup({
+      skill: { transform: async (transform) => transform({ add: (skill) => registered.push(skill) }) },
+      session: {
+        get: ({ sessionID }) => get(sessionID),
+        hook: async (name, callback) => { if (name === 'context') invoke = callback; },
+      },
+    });
+  }
+  assert.equal(typeof invoke, 'function');
+  return { invoke, lookups, registered };
+}
+
+for (const flavor of ['v1', 'v2']) {
+  for (const [kind, extra, expected] of [
+    ['root', {}, 1],
+    ['child', { parentID: 'parent' }, 0],
+    ['fork', { fork: { sessionID: 'origin' } }, 1],
+  ]) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, () => reply(flavor, { id, ...extra }));
+    const event = makeEvent(flavor, id);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: first request`);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: repeated event`);
+    const fresh = makeEvent(flavor, id);
+    await h.invoke(fresh);
+    assert.equal(bootstrapCount(fresh), expected, `${id}: fresh request`);
+    assert.deepEqual(h.lookups, [id], `${id}: cache successful classification`);
+    if (flavor === 'v2' && kind === 'child') {
+      assert.ok(h.registered.some((skill) => skill.id === 'brainstorming'));
+    }
+  }
+
+  const failures = [
+    ['throws', () => { throw new Error('temporary lookup failure'); }],
+    ['missing', () => undefined],
+    ['null', () => null],
+    ['empty', () => reply(flavor, {})],
+    ['wrong-id', () => reply(flavor, { id: 'different-session' })],
+    ['invalid-parent', (id) => reply(flavor, { id, parentID: 42 })],
+  ];
+  if (flavor === 'v1') {
+    failures.push(['resolved-http-error', () => ({
+      data: undefined,
+      error: { name: 'UnknownError', data: { message: 'temporary 503' } },
+      response: { ok: false, status: 503 },
+    })]);
+  }
+  for (const [kind, firstResult] of failures) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, (sessionID, call) => call === 1
+      ? firstResult(sessionID)
+      : reply(flavor, { id: sessionID, parentID: 'parent' }));
+    const counts = [];
+    for (let step = 0; step < 2; step++) {
+      const event = makeEvent(flavor, id);
+      await h.invoke(event);
+      counts.push(bootstrapCount(event));
+    }
+    assert.deepEqual(counts, [1, 0], `${id}: recover on the next request`);
+    assert.deepEqual(h.lookups, [id, id], `${id}: never cache the failure`);
+  }
+
+  const isolated = await makeHarness(flavor, (id) => reply(flavor,
+    id === 'child-session' ? { id, parentID: 'parent' } : { id }));
+  for (const [id, expected] of [['root-session', 1], ['child-session', 0], ['root-session', 1], ['child-session', 0]]) {
+    const event = makeEvent(flavor, id);
+    await isolated.invoke(event);
+    assert.equal(bootstrapCount(event), expected);
+  }
+  assert.deepEqual(isolated.lookups, ['root-session', 'child-session']);
+
+  const bounded = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  for (let index = 0; index <= 512; index++) {
+    const event = makeEvent(flavor, `eviction-${index}`);
+    await bounded.invoke(event);
+    assert.equal(bootstrapCount(event), 0);
+  }
+  const evicted = makeEvent(flavor, 'eviction-0');
+  await bounded.invoke(evicted);
+  assert.equal(bootstrapCount(evicted), 0);
+  assert.equal(bounded.lookups.filter((id) => id === 'eviction-0').length, 2);
+
+  const restarted = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  const afterRestart = makeEvent(flavor, 'eviction-0');
+  await restarted.invoke(afterRestart);
+  assert.equal(bootstrapCount(afterRestart), 0);
+  assert.deepEqual(restarted.lookups, ['eviction-0']);
+
+  const unknown = await makeHarness(flavor, () => { throw new Error('must not look up a missing ID'); });
+  const noID = makeEvent(flavor, undefined);
+  await unknown.invoke(noID);
+  assert.equal(bootstrapCount(noID), 1);
+  assert.deepEqual(unknown.lookups, []);
+}
+
+function compactedEvent(sessionID) {
+  return {
+    sessionID,
+    system: [],
+    messages: [{
+      role: 'assistant',
+      content: [{ type: 'compaction', provider: 'fixture', encrypted: 'opaque-checkpoint' }],
+    }],
+  };
+}
+
+const compactedRoot = await makeHarness('v2', (id) => ({ id }));
+const rootEvent = compactedEvent('compacted-root');
+const checkpoint = structuredClone(rootEvent.messages[0]);
+await compactedRoot.invoke(rootEvent);
+assert.equal(bootstrapCount(rootEvent), 1);
+assert.deepEqual(rootEvent.messages[0], checkpoint);
+assert.equal(rootEvent.messages.length, 2);
+assert.equal(rootEvent.messages[1].role, 'user');
+assert.deepEqual(rootEvent.system, []);
+await compactedRoot.invoke(rootEvent);
+assert.equal(bootstrapCount(rootEvent), 1);
+assert.equal(rootEvent.messages.length, 2);
+const freshRootEvent = compactedEvent('compacted-root');
+await compactedRoot.invoke(freshRootEvent);
+assert.equal(bootstrapCount(freshRootEvent), 1);
+assert.deepEqual(compactedRoot.lookups, ['compacted-root']);
+
+const compactedChild = await makeHarness('v2', (id) => ({ id, parentID: 'parent' }));
+const childEvent = compactedEvent('compacted-child');
+const originalChild = structuredClone(childEvent);
+await compactedChild.invoke(childEvent);
+assert.equal(bootstrapCount(childEvent), 0);
+assert.deepEqual(childEvent, originalChild);
+assert.deepEqual(compactedChild.lookups, ['compacted-child']);
+
+const retryChild = await makeHarness('v2', (id, call) => {
+  if (call === 1) throw new Error('temporary lookup failure');
+  return { id, parentID: 'parent' };
+});
+const unknownChild = compactedEvent('retry-compacted-child');
+await retryChild.invoke(unknownChild);
+assert.equal(bootstrapCount(unknownChild), 1);
+const recoveredChild = compactedEvent('retry-compacted-child');
+await retryChild.invoke(recoveredChild);
+assert.equal(bootstrapCount(recoveredChild), 0);
+assert.equal(recoveredChild.messages.length, 1);
+assert.equal(retryChild.lookups.length, 2);
+
+const newPromptAfterCheckpoint = compactedEvent('new-prompt-after-checkpoint-root');
+newPromptAfterCheckpoint.messages.push({ role: 'user', content: [{ type: 'text', text: 'Continue' }] });
+await compactedRoot.invoke(newPromptAfterCheckpoint);
+assert.equal(bootstrapCount(newPromptAfterCheckpoint), 1);
+assert.equal(newPromptAfterCheckpoint.messages.length, 2);
+assert.equal(newPromptAfterCheckpoint.messages[1].content.length, 2);
+
+const retainedUser = compactedEvent('retained-user-root');
+retainedUser.messages.unshift({ role: 'user', content: [{ type: 'text', text: 'Keep going' }] });
+const retainedCheckpoint = structuredClone(retainedUser.messages[1]);
+await compactedRoot.invoke(retainedUser);
+assert.equal(bootstrapCount(retainedUser), 1);
+assert.equal(retainedUser.messages.length, 2);
+assert.equal(retainedUser.messages[0].content.length, 2);
+assert.ok(retainedUser.messages[0].content[0].text.startsWith(marker));
+assert.equal(retainedUser.messages[0].content[1].text, 'Keep going');
+assert.deepEqual(retainedUser.messages[1], retainedCheckpoint);
+await compactedRoot.invoke(retainedUser);
+assert.equal(bootstrapCount(retainedUser), 1);
+assert.equal(retainedUser.messages.length, 2);
+
+const retainedUserChild = compactedEvent('retained-user-child');
+retainedUserChild.messages.unshift({ role: 'user', content: [{ type: 'text', text: 'Keep going' }] });
+const originalRetainedUserChild = structuredClone(retainedUserChild);
+await compactedChild.invoke(retainedUserChild);
+assert.equal(bootstrapCount(retainedUserChild), 0);
+assert.deepEqual(retainedUserChild, originalRetainedUserChild);
+const empty = { sessionID: 'empty', messages: [] };
+await compactedRoot.invoke(empty);
+assert.deepEqual(empty.messages, []);
+
+console.log('Session classification, recovery and cache lifetime passed');

+ 4 - 0
tests/opencode/test-session-bootstrap.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+node "$SCRIPT_DIR/test-session-bootstrap.mjs" "$SCRIPT_DIR/../../.opencode/plugins/superpowers.js"

+ 205 - 0
tests/opencode/test-skill-registration.mjs

@@ -0,0 +1,205 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { pathToFileURL } from 'url';
+
+// Verifies the V2 skill registration payload matches OpenCode 2.0.4's
+// Skill.Info contract (packages/schema/src/skill.ts):
+//   { id, name, description?, autoinvoke?, path, content }
+// Upstream commit 199aabe9e2 (first released in v2.0.4) renamed the required
+// file field `location` -> `path`. A wrong field name makes draft.add()
+// throw inside the host's transform rebuild, which asynchronously disables
+// the whole plugin ("Plugin disabled after skill.transform failed") and
+// takes the bootstrap hook down with it — see PR #2106 review by 80avin.
+
+const [, , inputPath] = process.argv;
+
+if (!inputPath) {
+  console.error('Usage: node test-skill-registration.mjs PLUGIN_PATH');
+  process.exit(2);
+}
+
+const pluginPath = fs.realpathSync(inputPath);
+const skillsDir = path.resolve(path.dirname(pluginPath), '../../skills');
+const mod = await import(pathToFileURL(pluginPath).href);
+
+const failures = [];
+
+// --- Run 1: passive capture of every draft.add payload -------------------
+const added = [];
+await mod.default.setup(makeCtx({ add: (skill) => added.push(skill) }));
+
+const expectedIds = fs.existsSync(skillsDir)
+  ? fs.readdirSync(skillsDir, { withFileTypes: true })
+      .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
+      .filter((e) => fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
+      .map((e) => e.name)
+      .sort()
+  : [];
+
+if (added.length === 0) {
+  failures.push('expected setup() to register at least one skill via draft.add()');
+}
+if (JSON.stringify(added.map((s) => s.id).sort()) !== JSON.stringify(expectedIds)) {
+  failures.push(`expected draft.add() ids to match skills dir contents, got ${JSON.stringify(added.map((s) => s.id))}`);
+}
+
+for (const skill of added) {
+  if (typeof skill.path !== 'string' || !path.isAbsolute(skill.path)) {
+    failures.push(`skill "${skill.id}": expected required absolute Skill.Info field "path", got ${JSON.stringify(skill.path)}`);
+  } else if (skill.path !== path.join(skillsDir, skill.id, 'SKILL.md')) {
+    failures.push(`skill "${skill.id}": expected path ${path.join(skillsDir, skill.id, 'SKILL.md')}, got ${skill.path}`);
+  } else if (!fs.existsSync(skill.path)) {
+    failures.push(`skill "${skill.id}": path does not exist on disk: ${skill.path}`);
+  }
+  // Stale 2.0.3-era fields must not leak into the payload: the host strips
+  // unknown keys, but keeping them would silently mask a future regression
+  // to a schema that no longer accepts `path`.
+  if ('location' in skill) {
+    failures.push(`skill "${skill.id}": payload still carries the pre-2.0.4 field "location"`);
+  }
+  if ('slash' in skill) {
+    failures.push(`skill "${skill.id}": payload carries "slash", removed from Skill.Info in 2.0.4`);
+  }
+  if (typeof skill.id !== 'string' || skill.id.length === 0) failures.push(`skill payload missing non-empty "id"`);
+  if (typeof skill.name !== 'string' || skill.name.length === 0) failures.push(`skill "${skill.id}" missing non-empty "name"`);
+  if (typeof skill.content !== 'string' || !skill.content.trim()) failures.push(`skill "${skill.id}" missing non-empty "content"`);
+  if ('description' in skill && typeof skill.description !== 'string') {
+    failures.push(`skill "${skill.id}": "description" must be a string when present`);
+  } else if ('description' in skill && /["']$/.test(skill.description)) {
+    failures.push(`skill "${skill.id}": description ends with a dangling quote: ${JSON.stringify(skill.description)}`);
+  }
+  if (typeof skill.content === 'string' && skill.content.startsWith('---')) {
+    failures.push(`skill "${skill.id}": content still starts with the frontmatter delimiter`);
+  }
+}
+
+// --- Run 2: hostile draft.add must not abort the remaining registrations --
+// The real host swallows a throw escaping the transform callback and then
+// hard-disables the plugin asynchronously. Locally we can only observe the
+// synchronous half of that contract: when draft.add() rejects one skill, the
+// plugin must keep registering the rest instead of aborting the loop.
+const hostileId = added.length > 1 ? added[Math.floor(added.length / 2)].id : null;
+const survived = [];
+let setupThrew = null;
+let survivingContextHook;
+try {
+  await mod.default.setup(makeCtx({
+    add: (skill) => {
+      if (skill.id === hostileId) throw new Error('Simulated Skill.Info decode failure');
+      survived.push(skill.id);
+    },
+    onHook: (name, callback) => {
+      if (name === 'context') survivingContextHook = callback;
+    },
+  }));
+} catch (err) {
+  setupThrew = err;
+}
+if (setupThrew) {
+  failures.push(`expected setup() to contain draft.add() failures, but it threw: ${setupThrew.message}`);
+} else if (hostileId) {
+  const expectedSurvivors = added.map((s) => s.id).filter((id) => id !== hostileId);
+  if (JSON.stringify(survived.sort()) !== JSON.stringify(expectedSurvivors.sort())) {
+    failures.push(`expected all non-rejected skills to still register when one draft.add() throws, got ${JSON.stringify(survived)}`);
+  }
+}
+if (typeof survivingContextHook !== 'function') {
+  failures.push('expected bootstrap hook to survive a rejected skill');
+} else {
+  const event = {
+    sessionID: 'registration-survival-root',
+    messages: [{ role: 'user', content: [{ type: 'text', text: 'Continue' }] }],
+  };
+  await survivingContextHook(event);
+  const count = event.messages.flatMap((message) => message.content).filter(
+    (part) => part.type === 'text' && part.text.startsWith('<EXTREMELY_IMPORTANT>\nYou have superpowers.')
+  ).length;
+  if (count !== 1) failures.push(`expected surviving bootstrap once, got ${count}`);
+}
+
+// --- Run 3: quoted and multi-line frontmatter values ---------------------
+// The description is what the host shows in its skill list. A quoted value
+// that wraps onto indented continuation lines must register as one unquoted
+// line, so exercise each layout against a synthetic install: a copy of the
+// plugin next to fixture skills, laid out like a real package root.
+const frontmatterFixtures = {
+  'multi-line-double': {
+    frontmatter: 'description: "Use when foo happens\n  and bar continues\n  and baz ends"',
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'multi-line-single': {
+    frontmatter: "description: 'Use when foo happens\n  and bar continues\n  and baz ends'",
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'single-line-quoted': {
+    frontmatter: 'description: "Plain quoted"',
+    expected: 'Plain quoted',
+  },
+  'block-scalar': {
+    frontmatter: 'description: >\n  Folded line one\n  line two',
+    expected: 'Folded line one line two',
+  },
+};
+const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superpowers-frontmatter-'));
+try {
+  const fixturePlugin = path.join(fixtureRoot, '.opencode', 'plugins', 'superpowers.js');
+  fs.mkdirSync(path.dirname(fixturePlugin), { recursive: true });
+  fs.copyFileSync(pluginPath, fixturePlugin);
+  for (const [id, { frontmatter }] of Object.entries(frontmatterFixtures)) {
+    const skillDir = path.join(fixtureRoot, 'skills', id);
+    fs.mkdirSync(skillDir, { recursive: true });
+    fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\nname: ${id}\n${frontmatter}\n---\n# Title\n\nBody.\n`);
+  }
+  const fixtureMod = await import(pathToFileURL(fixturePlugin).href);
+  const fixtureAdded = [];
+  await fixtureMod.default.setup(makeCtx({ add: (skill) => fixtureAdded.push(skill) }));
+  for (const [id, { expected }] of Object.entries(frontmatterFixtures)) {
+    const skill = fixtureAdded.find((s) => s.id === id);
+    if (!skill) {
+      failures.push(`fixture "${id}": expected setup() to register it`);
+      continue;
+    }
+    if (skill.description !== expected) {
+      failures.push(`fixture "${id}": expected description ${JSON.stringify(expected)}, got ${JSON.stringify(skill.description)}`);
+    }
+    if (skill.content.startsWith('---')) {
+      failures.push(`fixture "${id}": content still starts with the frontmatter delimiter`);
+    }
+  }
+} finally {
+  fs.rmSync(fixtureRoot, { recursive: true, force: true });
+}
+
+const result = {
+  registered: added.length,
+  ids: added.map((s) => s.id),
+  allPathsValid: added.every((s) => s.path === path.join(skillsDir, s.id, 'SKILL.md') && fs.existsSync(s.path)),
+  staleLocationField: added.some((s) => 'location' in s),
+  hostileRejectedId: hostileId,
+  survivedHostileAdd: JSON.stringify(survived.sort()) === JSON.stringify(added.map((s) => s.id).filter((id) => id !== hostileId).sort()),
+};
+
+if (failures.length > 0) {
+  console.error(JSON.stringify(result, null, 2));
+  for (const failure of failures) {
+    console.error(`FAIL: ${failure}`);
+  }
+  process.exit(1);
+}
+
+console.log(JSON.stringify(result, null, 2));
+
+function makeCtx({ add, onHook = () => {} }) {
+  return {
+    skill: {
+      transform: async (fn) => {
+        await fn({ list: () => [], get: () => undefined, add, update: () => {}, remove: () => {} });
+      },
+    },
+    session: {
+      hook: async (name, callback) => onHook(name, callback),
+      get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID
+    },
+  };
+}

+ 22 - 0
tests/opencode/test-skill-registration.sh

@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Test: V2 Skill Registration Contract (#2106 review)
+# Verifies setup() registers skills matching OpenCode 2.0.4's Skill.Info
+# schema (path field, no stale location/slash) and contains per-skill
+# draft.add() failures instead of aborting registration.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== Test: V2 Skill Registration Contract ==="
+
+source "$SCRIPT_DIR/setup.sh"
+trap cleanup_test_env EXIT
+
+node "$SCRIPT_DIR/test-skill-registration.mjs" "$SUPERPOWERS_PLUGIN_FILE"
+node "$SCRIPT_DIR/test-skill-registration.mjs" "$OPENCODE_CONFIG_DIR/plugins/superpowers.js"
+
+echo "  [PASS] Skill payloads match the 2.0.4 Skill.Info contract"
+echo "  [PASS] A rejected draft.add() skips one skill without aborting the rest"
+echo "  [PASS] Quoted and multi-line frontmatter values register unquoted"
+echo ""
+echo "=== All skill registration tests passed ==="