Ver Fonte

fix(opencode): differentiate tool mapping by host flavor and harden child detection

Current opencode2 builds renamed the model-facing tools (bash→shell,
task→subagent with `agent` instead of `subagent_type`, apply_patch→
patch/patchText) and removed todowrite entirely, so the single v1
mapping injected on v2 hosts taught the model stale tool names.

- export V1_MAPPING/V2_MAPPING and inject the flavor-correct one on
  each path (v1 messages.transform → V1; v2 ctx.session.hook("context")
  → V2, incl. no-todo-tool guidance and sessionID continuation)
- child-session detection now keys on parentID presence (primary
  signal on both flavors) with dual-shape unwrapping preserved; v1
  #2160 behavior unchanged
- mirror surfaces updated: INSTALL.md dual mapping tables,
  README.opencode.md host-flavor notes, test-bootstrap-caching.mjs
  asserts both mappings + drives the v2 context hook end-to-end
- skills: add OpenCode to executing-plans' subagent-capable list,
  accurate OpenCode worktree status (git fallback; TUI dialogs are
  user-side only), generalized live-subagent resume guidance
GoldJohnKing há 1 semana atrás
pai
commit
ca380ba488

+ 14 - 1
.opencode/INSTALL.md

@@ -98,7 +98,9 @@ Then use the installed package path in `opencode.json`:
 
 ### 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`
 - `Subagent (general-purpose):` template → `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -109,6 +111,17 @@ Skills speak in actions ("create a todo", "dispatch a subagent", "read a file").
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 
+**V2 (`opencode2` beta):**
+
+- "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 a file" / "edit a file" / "delete a file" → `patch` (same patch format, via `patchText`)
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+
 ## Getting Help
 
 - Report issues: https://github.com/obra/superpowers/issues

+ 61 - 32
.opencode/plugins/superpowers.js

@@ -45,28 +45,14 @@ const extractAndStripFrontmatter = (content) => {
   return { frontmatter, content: body };
 };
 
-// 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
-
-// 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;
-  }
-
-  const fullContent = fs.readFileSync(skillPath, 'utf8');
-  const { content } = extractAndStripFrontmatter(fullContent);
-
-  const toolMapping = `**Tool Mapping for OpenCode:**
+// Tool mapping injected into the bootstrap, differentiated by host flavor.
+// V1 (OpenCode 1.18.x) and V2 (OpenCode 2.x beta) 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).
+
+// 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:
 - Create or update todos → \`todowrite\`
 - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
@@ -79,7 +65,44 @@ When skills request actions, substitute OpenCode equivalents:
 
 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, grep, glob, webfetch, skill keep their names.
+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 → \`patch\` with \`patchText\` (same patch format)
+- Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`)
+- Search files → \`grep\`, \`glob\`
+- Fetch a URL → \`webfetch\`
+
+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.
 
 **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.**
@@ -87,9 +110,9 @@ You have superpowers.
 ${content}
 
 ${toolMapping}
-</EXTREMELY_IMPORTANT>`;
+</EXTREMELY_IMPORTANT>`);
 
-  return _bootstrapCache;
+  return _bootstrapCache.get(toolMapping);
 };
 
 // --- Task-subagent (child session) detection --------------------------------
@@ -98,10 +121,12 @@ ${toolMapping}
 // 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: OpenCode task
-// sessions are created with a parentID, 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.
+// 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 for life and the injection hook (which fires on every
@@ -115,10 +140,14 @@ const isChildSession = async (fetchSession, sessionID) => {
   let isChild = false;
   try {
     const result = await fetchSession(sessionID);
-    // V1's SDK returns { data: Session }; V2's ctx returns the record itself.
+    // Defensive dual-shape unwrap: fetchers may return the session record
+    // itself (V2 ctx) or an SDK envelope { data: Session } (V1 client). An
+    // envelope never carries parentID at the top level, so if `result` has
+    // one it already IS the session record — never unwrap past it.
     const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
       ? result.data
       : result;
+    // parentID presence is the child-session signal on both flavors.
     isChild = Boolean(session && typeof session === 'object' && session.parentID);
   } catch (err) {
     // Fail open: on lookup errors keep injecting (previous behavior) and do
@@ -163,7 +192,7 @@ export const SuperpowersPlugin = async ({ client, directory }) => {
     // arrays may need injection again, so getBootstrapContent() must not do
     // repeated disk work.
     'experimental.chat.messages.transform': async (_input, output) => {
-      const bootstrap = getBootstrapContent();
+      const bootstrap = getBootstrapContent(V1_MAPPING);
       if (!bootstrap || !output.messages.length) return;
       const firstUser = output.messages.find(m => m.info.role === 'user');
       if (!firstUser || !firstUser.parts.length) return;
@@ -240,7 +269,7 @@ async function setup(ctx) {
   try {
     await ctx.session.hook('context', async (event) => {
       try {
-        const bootstrap = getBootstrapContent();
+        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;

+ 23 - 8
docs/README.opencode.md

@@ -96,18 +96,20 @@ To pin a specific version, use a branch or tag:
 
 ## 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** into the first user message of each conversation, adding superpowers awareness.
-   - **V1:** via `experimental.chat.messages.transform` hook
-   - **V2:** via `ctx.session.hook("context")` — the V2 equivalent (confirmed active at runtime)
+    - **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** into the first user message of each conversation, adding superpowers awareness. The bootstrap includes a tool mapping that is also flavor-specific: V1 sessions get the V1 tool names below, 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)
 
 ### 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`
 - `Subagent (general-purpose):` template → OpenCode's `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -118,7 +120,20 @@ Skills speak in actions rather than naming any one runtime's tools. On OpenCode
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 
-(Verified against the installed OpenCode CLI's tool inventory.)
+**V2 (`opencode2` beta):**
+
+- "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 a file" / "edit a file" / "delete a file" → `patch` with `patchText` (same patch format as V1's `apply_patch`)
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+
+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; `read`, `grep`, `glob`, `webfetch`, and `skill` keep their V1 names.
+
+(V1 list verified against the installed OpenCode 1.18.x CLI's tool inventory; V2 list verified against the V2 source at `dbd9b18`.)
 
 ## Troubleshooting
 

+ 1 - 1
skills/executing-plans/SKILL.md

@@ -11,7 +11,7 @@ Load plan, review critically, execute all tasks, report when complete.
 
 **Announce at start:** "I'm using the executing-plans skill to implement this plan."
 
-**Note:** Tell your human partner that Superpowers works much better with access to subagents (Claude Code, Codex CLI, Codex App, Copilot CLI, and Gemini CLI all qualify; see the per-platform tool refs in `../using-superpowers/references/`). If subagents are available, use superpowers:subagent-driven-development instead of this skill.
+**Note:** Tell your human partner that Superpowers works much better with access to subagents (Claude Code, Codex CLI, Codex App, Copilot CLI, Gemini CLI, and OpenCode all qualify; see the per-platform tool refs in `../using-superpowers/references/`). If subagents are available, use superpowers:subagent-driven-development instead of this skill.
 
 ## The Process
 

+ 5 - 3
skills/subagent-driven-development/SKILL.md

@@ -374,9 +374,11 @@ scoped re-review. Five rounds maximum per task:
 
 **Rounds 1-3 — resume the original implementer.** Send it the open findings
 verbatim. Its context is intact: it knows the task, the code, and its own
-choices. If your harness cannot send another message to a live subagent,
-dispatch a fresh implementer carrying the brief path, the report-file path,
-and the findings — the report file is the persistent memory either way.
+choices. If your harness can continue a live subagent, do that (e.g. OpenCode
+V2: re-invoke the `subagent` tool passing the subagent's `sessionID`; V1:
+re-dispatch `task` with the same context). Otherwise dispatch a fresh
+implementer carrying the brief path, the report-file path, and the findings —
+the report file is the persistent memory either way.
 
 **Rounds 4-5 — dispatch a fresh implementer on a more capable model** (per
 Model Selection), with the brief path, the report-file path, the open

+ 2 - 0
skills/using-git-worktrees/SKILL.md

@@ -56,6 +56,8 @@ Native tools handle directory placement, branch creation, and cleanup automatica
 
 Only proceed to Step 1b if you have no native worktree tool available.
 
+**OpenCode:** there is no agent-side worktree tool on V1 or V2 — use the git worktree fallback (Step 1b). V2's TUI worktree dialogs (new-worktree / move-session keybinds) are user-side only; you cannot invoke them from a session.
+
 ### 1b. Git Worktree Fallback
 
 **Only use this if Step 1a does not apply** — you have no native worktree tool available. Create a worktree manually using git.

+ 119 - 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 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`);
 await transform({}, firstOutput);
 const afterFirst = { existsCount, readCount };
@@ -40,6 +44,11 @@ const secondOutput = makeOutput(`${scenario} bootstrap second step`);
 await transform({}, secondOutput);
 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 = {
   scenario,
   firstBootstrapParts: countBootstrapParts(firstOutput),
@@ -52,12 +61,24 @@ const result = {
   secondReadCount: afterSecond.readCount,
   firstExistsCount: afterFirst.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'
   ? assertPresentBootstrap(result)
   : assertMissingBootstrap(result);
 
+if (scenario === 'present') {
+  failures.push(...assertV2Bootstrap(result));
+}
+failures.push(...mappingFailures);
+
 if (failures.length > 0) {
   console.error(JSON.stringify(result, null, 2));
   for (const failure of failures) {
@@ -144,3 +165,101 @@ function assertMissingBootstrap(result) {
   }
   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',
+    '`patch` with `patchText`',
+    '`shell`',
+    '`read`',
+    '`grep`, `glob`',
+    '`webfetch`',
+  ]) {
+    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;
+}