Forráskód Böngészése

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 1 hete
szülő
commit
ca380ba488

+ 14 - 1
.opencode/INSTALL.md

@@ -98,7 +98,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 +111,17 @@ 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 (`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
 ## Getting Help
 
 
 - Report issues: https://github.com/obra/superpowers/issues
 - 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 };
   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:
 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"\`
@@ -79,7 +65,44 @@ 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, 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.
 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.**
@@ -87,9 +110,9 @@ You have superpowers.
 ${content}
 ${content}
 
 
 ${toolMapping}
 ${toolMapping}
-</EXTREMELY_IMPORTANT>`;
+</EXTREMELY_IMPORTANT>`);
 
 
-  return _bootstrapCache;
+  return _bootstrapCache.get(toolMapping);
 };
 };
 
 
 // --- Task-subagent (child session) detection --------------------------------
 // --- Task-subagent (child session) detection --------------------------------
@@ -98,10 +121,12 @@ ${toolMapping}
 // approval cycles). Injecting it into task subagent sessions makes workers
 // approval cycles). Injecting it into task subagent sessions makes workers
 // restart design/approval cycles for work the parent already authorised; the
 // restart design/approval cycles for work the parent already authorised; the
 // <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
 // <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
 // 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
 // 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;
   let isChild = false;
   try {
   try {
     const result = await fetchSession(sessionID);
     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)
     const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
       ? result.data
       ? result.data
       : result;
       : result;
+    // parentID presence is the child-session signal on both flavors.
     isChild = Boolean(session && typeof session === 'object' && session.parentID);
     isChild = Boolean(session && typeof session === 'object' && session.parentID);
   } catch (err) {
   } catch (err) {
     // Fail open: on lookup errors keep injecting (previous behavior) and do
     // 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
     // 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;
@@ -240,7 +269,7 @@ async function setup(ctx) {
   try {
   try {
     await ctx.session.hook('context', async (event) => {
     await ctx.session.hook('context', async (event) => {
       try {
       try {
-        const bootstrap = getBootstrapContent();
+        const bootstrap = getBootstrapContent(V2_MAPPING);
         if (!bootstrap || !event.messages || !event.messages.length) return;
         if (!bootstrap || !event.messages || !event.messages.length) return;
         const firstUser = event.messages.find(m => m.role === 'user');
         const firstUser = event.messages.find(m => m.role === 'user');
         if (!firstUser || !firstUser.content || !firstUser.content.length) return;
         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
 ## 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.
 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
 ### 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)
@@ -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`
 - "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 (`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
 ## 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."
 **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
 ## 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
 **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
 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
 **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
 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.
 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
 ### 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.
 **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 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,101 @@ 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',
+    '`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;
+}