diff --git a/src/tools/delegate-task/anthropic-categories.ts b/src/tools/delegate-task/anthropic-categories.ts
new file mode 100644
index 000000000..e6b0894e3
--- /dev/null
+++ b/src/tools/delegate-task/anthropic-categories.ts
@@ -0,0 +1,54 @@
+import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
+
+const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `
+You are working on tasks that don't fit specific categories but require moderate effort.
+
+
+BEFORE selecting this category, VERIFY ALL conditions:
+1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
+2. Task requires more than trivial effort but is NOT system-wide
+3. Scope is contained within a few files/modules
+
+If task fits ANY other category, DO NOT select unspecified-low.
+This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
+
+
+
+
+THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
+
+**PROVIDE CLEAR STRUCTURE:**
+1. MUST DO: Enumerate required actions explicitly
+2. MUST NOT DO: State forbidden actions to prevent scope creep
+3. EXPECTED OUTPUT: Define concrete success criteria
+`
+
+const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `
+You are working on tasks that don't fit specific categories but require substantial effort.
+
+
+BEFORE selecting this category, VERIFY ALL conditions:
+1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
+2. Task requires substantial effort across multiple systems/modules
+3. Changes have broad impact or require careful coordination
+4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
+
+If task fits ANY other category, DO NOT select unspecified-high.
+If task is unclassifiable but moderate-effort, use unspecified-low instead.
+
+`
+
+export const ANTHROPIC_CATEGORIES: BuiltinCategoryDefinition[] = [
+ {
+ name: "unspecified-low",
+ config: { model: "anthropic/claude-sonnet-4-6" },
+ description: "Tasks that don't fit other categories, low effort required",
+ promptAppend: UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
+ },
+ {
+ name: "unspecified-high",
+ config: { model: "anthropic/claude-opus-4-6", variant: "max" },
+ description: "Tasks that don't fit other categories, high effort required",
+ promptAppend: UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
+ },
+]
diff --git a/src/tools/delegate-task/builtin-categories.ts b/src/tools/delegate-task/builtin-categories.ts
new file mode 100644
index 000000000..f8da8ecf1
--- /dev/null
+++ b/src/tools/delegate-task/builtin-categories.ts
@@ -0,0 +1,33 @@
+import type { CategoryConfig } from "../../config/schema"
+import { ANTHROPIC_CATEGORIES } from "./anthropic-categories"
+import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
+import { GOOGLE_CATEGORIES } from "./google-categories"
+import { KIMI_CATEGORIES } from "./kimi-categories"
+import { OPENAI_CATEGORIES } from "./openai-categories"
+
+const BUILTIN_CATEGORIES: BuiltinCategoryDefinition[] = [
+ ...GOOGLE_CATEGORIES,
+ ...OPENAI_CATEGORIES,
+ ...ANTHROPIC_CATEGORIES,
+ ...KIMI_CATEGORIES,
+]
+
+function buildCategoryRecord(
+ selector: (definition: BuiltinCategoryDefinition) => TValue
+): Record {
+ return Object.fromEntries(
+ BUILTIN_CATEGORIES.map((definition) => [definition.name, selector(definition)])
+ )
+}
+
+export const DEFAULT_CATEGORIES: Record = buildCategoryRecord(
+ (definition) => definition.config
+)
+
+export const CATEGORY_PROMPT_APPENDS: Record = buildCategoryRecord(
+ (definition) => definition.promptAppend
+)
+
+export const CATEGORY_DESCRIPTIONS: Record = buildCategoryRecord(
+ (definition) => definition.description
+)
diff --git a/src/tools/delegate-task/builtin-category-definition.ts b/src/tools/delegate-task/builtin-category-definition.ts
new file mode 100644
index 000000000..d9c853b63
--- /dev/null
+++ b/src/tools/delegate-task/builtin-category-definition.ts
@@ -0,0 +1,8 @@
+import type { CategoryConfig } from "../../config/schema"
+
+export type BuiltinCategoryDefinition = {
+ name: string
+ config: CategoryConfig
+ description: string
+ promptAppend: string
+}
diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts
index c0cc9ca42..510bcf80d 100644
--- a/src/tools/delegate-task/constants.ts
+++ b/src/tools/delegate-task/constants.ts
@@ -1,308 +1,13 @@
-import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import { truncateDescription } from "../../shared/truncate-description"
-
-export const VISUAL_CATEGORY_PROMPT_APPEND = `
-You are working on VISUAL/UI tasks.
-
-
-## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
-
-**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
-
-**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
-
-### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
-
-**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
-
-1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
- - Design tokens: colors, spacing, typography, shadows, border-radii
- - Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- - Shared/base components: Button, Card, Input, Layout primitives
- - Existing UI patterns: How are pages structured? What spacing grid? What color usage?
-
-2. **READ at minimum 5-10 existing UI components.** Understand:
- - Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- - Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- - Color usage (semantic tokens? Direct hex? Theme references?)
- - Typography scale (heading levels, body, caption — how many? What font stack?)
- - Component composition patterns (slots? children? compound components?)
-
-**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
-
-### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
-
-If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
-
-1. **STOP. Do NOT build the requested UI yet.**
-2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
-3. **Create a minimal design system FIRST:**
- - Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- - Typography scale: heading levels (h1-h4 minimum), body, small, caption
- - Spacing scale: consistent increments (4px or 8px base)
- - Border radii, shadows, transitions — systematic, not random
- - Component primitives: the reusable building blocks
-4. **Commit/save the design system, THEN proceed to Phase 3.**
-
-A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
-
-### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
-
-**NOW and ONLY NOW** — implement the requested visual work:
-
-| Element | CORRECT | WRONG (WILL BE REJECTED) |
-|---------|---------|--------------------------|
-| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
-| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
-| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
-| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
-| Border radius | System token | Random \`border-radius: 6px\` |
-
-**IF the design requires something OUTSIDE the current system:**
-- **Extend the system FIRST** — add the new token/primitive
-- **THEN use the new token** in your component
-- **NEVER one-off override.** That is how design systems die.
-
-### PHASE 4: VERIFY BEFORE CLAIMING DONE
-
-BEFORE reporting visual work as complete, answer these:
-
-- [ ] Does EVERY color reference a design token or CSS variable?
-- [ ] Does EVERY spacing use the system scale?
-- [ ] Does EVERY component follow the existing composition pattern?
-- [ ] Would a designer see CONSISTENCY across old and new components?
-- [ ] Are there ZERO hardcoded magic numbers for visual properties?
-
-**If ANY answer is NO — FIX IT. You are NOT done.**
-
-
-
-
-Design-first mindset (AFTER design system is established):
-- Bold aesthetic choices over safe defaults
-- Unexpected layouts, asymmetry, grid-breaking elements
-- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
-- Cohesive color palettes with sharp accents
-- High-impact animations with staggered reveals
-- Atmosphere: gradient meshes, noise textures, layered transparencies
-
-AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
-
-`
-
-export const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `
-You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
-
-**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
-1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
-2. Your code MUST match the project's existing conventions - blend in seamlessly
-3. Write READABLE code that humans can easily understand - no clever tricks
-4. If unsure about style, explore more files until you find the pattern
-
-Strategic advisor mindset:
-- Bias toward simplicity: least complex solution that fulfills requirements
-- Leverage existing code/patterns over new components
-- Prioritize developer experience and maintainability
-- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
-- Signal when advanced approach warranted
-
-Response format:
-- Bottom line (2-3 sentences)
-- Action plan (numbered steps)
-- Risks and mitigations (if relevant)
-`
-
-export const ARTISTRY_CATEGORY_PROMPT_APPEND = `
-You are working on HIGHLY CREATIVE / ARTISTIC tasks.
-
-Artistic genius mindset:
-- Push far beyond conventional boundaries
-- Explore radical, unconventional directions
-- Surprise and delight: unexpected twists, novel combinations
-- Rich detail and vivid expression
-- Break patterns deliberately when it serves the creative vision
-
-Approach:
-- Generate diverse, bold options first
-- Embrace ambiguity and wild experimentation
-- Balance novelty with coherence
-- This is for tasks requiring exceptional creativity
-`
-
-export const QUICK_CATEGORY_PROMPT_APPEND = `
-You are working on SMALL / QUICK tasks.
-
-Efficient execution mindset:
-- Fast, focused, minimal overhead
-- Get to the point immediately
-- No over-engineering
-- Simple solutions for simple problems
-
-Approach:
-- Minimal viable implementation
-- Skip unnecessary abstractions
-- Direct and concise
-
-
-
-THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
-
-The model executing this task is optimized for speed over depth. Your prompt MUST be:
-
-**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
-1. MUST DO: List every required action as atomic, numbered steps
-2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
-3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
-
-**WHY THIS MATTERS:**
-- Smaller models benefit from explicit guardrails
-- Vague instructions may lead to unpredictable results
-- Implicit expectations may be missed
-**PROMPT STRUCTURE (MANDATORY):**
-\`\`\`
-TASK: [One-sentence goal]
-
-MUST DO:
-1. [Specific action with exact details]
-2. [Another specific action]
-...
-
-MUST NOT DO:
-- [Forbidden action + why]
-- [Another forbidden action]
-...
-
-EXPECTED OUTPUT:
-- [Exact deliverable description]
-- [Success criteria / verification method]
-\`\`\`
-
-If your prompt lacks this structure, REWRITE IT before delegating.
-`
-
-export const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `
-You are working on tasks that don't fit specific categories but require moderate effort.
-
-
-BEFORE selecting this category, VERIFY ALL conditions:
-1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
-2. Task requires more than trivial effort but is NOT system-wide
-3. Scope is contained within a few files/modules
-
-If task fits ANY other category, DO NOT select unspecified-low.
-This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
-
-
-
-
-THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
-
-**PROVIDE CLEAR STRUCTURE:**
-1. MUST DO: Enumerate required actions explicitly
-2. MUST NOT DO: State forbidden actions to prevent scope creep
-3. EXPECTED OUTPUT: Define concrete success criteria
-`
-
-export const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `
-You are working on tasks that don't fit specific categories but require substantial effort.
-
-
-BEFORE selecting this category, VERIFY ALL conditions:
-1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
-2. Task requires substantial effort across multiple systems/modules
-3. Changes have broad impact or require careful coordination
-4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
-
-If task fits ANY other category, DO NOT select unspecified-high.
-If task is unclassifiable but moderate-effort, use unspecified-low instead.
-
-`
-
-export const WRITING_CATEGORY_PROMPT_APPEND = `
-You are working on WRITING / PROSE tasks.
-
-Wordsmith mindset:
-- Clear, flowing prose
-- Appropriate tone and voice
-- Engaging and readable
-- Proper structure and organization
-
-Approach:
-- Understand the audience
-- Draft with care
-- Polish for clarity and impact
-- Documentation, READMEs, articles, technical writing
-
-ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
-- NEVER use em dashes (—) or en dashes (–). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
-- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
-- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
-- Use contractions naturally: "don't" not "do not", "it's" not "it is".
-- Vary sentence length. Don't make every sentence the same length.
-- NEVER start consecutive sentences with the same word.
-- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
-- Write like a human, not a corporate template.
-`
-
-export const DEEP_CATEGORY_PROMPT_APPEND = `
-You are working on GOAL-ORIENTED AUTONOMOUS tasks.
-
-You are NOT an interactive assistant. You are an autonomous problem-solver.
-
-BEFORE making ANY changes:
-1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
-2. Read related files, trace dependencies, understand the full context
-3. Build a complete mental model of the problem space
-4. Do not ask clarifying questions - the goal is already defined
-
-You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
-
-Sub-steps of ONE goal = execute all steps as phases of one atomic task.
-Genuinely independent tasks = flag and refuse, require separate delegations.
-
-Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
-
-Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
-`
-
-
-
-export const DEFAULT_CATEGORIES: Record = {
- "visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
- ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
- deep: { model: "openai/gpt-5.4", variant: "medium" },
- artistry: { model: "google/gemini-3.1-pro", variant: "high" },
- quick: { model: "openai/gpt-5.4-mini" },
- "unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
- "unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
- writing: { model: "kimi-for-coding/k2p5" },
-}
-
-export const CATEGORY_PROMPT_APPENDS: Record = {
- "visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
- ultrabrain: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
- deep: DEEP_CATEGORY_PROMPT_APPEND,
- artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
- quick: QUICK_CATEGORY_PROMPT_APPEND,
- "unspecified-low": UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
- "unspecified-high": UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
- writing: WRITING_CATEGORY_PROMPT_APPEND,
-}
-
-export const CATEGORY_DESCRIPTIONS: Record = {
- "visual-engineering": "Frontend, UI/UX, design, styling, animation",
- ultrabrain: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
- deep: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
- artistry: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
- quick: "Trivial tasks - single file changes, typo fixes, simple modifications",
- "unspecified-low": "Tasks that don't fit other categories, low effort required",
- "unspecified-high": "Tasks that don't fit other categories, high effort required",
- writing: "Documentation, prose, technical writing",
-}
+export {
+ CATEGORY_DESCRIPTIONS,
+ CATEGORY_PROMPT_APPENDS,
+ DEFAULT_CATEGORIES,
+} from "./builtin-categories"
/**
* System prompt prepended to plan agent invocations.
diff --git a/src/tools/delegate-task/google-categories.ts b/src/tools/delegate-task/google-categories.ts
new file mode 100644
index 000000000..53f6cb8ad
--- /dev/null
+++ b/src/tools/delegate-task/google-categories.ts
@@ -0,0 +1,122 @@
+import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
+
+const VISUAL_CATEGORY_PROMPT_APPEND = `
+You are working on VISUAL/UI tasks.
+
+
+## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
+
+**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
+
+**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
+
+### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
+
+**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
+
+1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
+ - Design tokens: colors, spacing, typography, shadows, border-radii
+ - Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
+ - Shared/base components: Button, Card, Input, Layout primitives
+ - Existing UI patterns: How are pages structured? What spacing grid? What color usage?
+
+2. **READ at minimum 5-10 existing UI components.** Understand:
+ - Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
+ - Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
+ - Color usage (semantic tokens? Direct hex? Theme references?)
+ - Typography scale (heading levels, body, caption — how many? What font stack?)
+ - Component composition patterns (slots? children? compound components?)
+
+**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
+
+### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
+
+If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
+
+1. **STOP. Do NOT build the requested UI yet.**
+2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
+3. **Create a minimal design system FIRST:**
+ - Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
+ - Typography scale: heading levels (h1-h4 minimum), body, small, caption
+ - Spacing scale: consistent increments (4px or 8px base)
+ - Border radii, shadows, transitions — systematic, not random
+ - Component primitives: the reusable building blocks
+4. **Commit/save the design system, THEN proceed to Phase 3.**
+
+A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
+
+### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
+
+**NOW and ONLY NOW** — implement the requested visual work:
+
+| Element | CORRECT | WRONG (WILL BE REJECTED) |
+|---------|---------|--------------------------|
+| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
+| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
+| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
+| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
+| Border radius | System token | Random \`border-radius: 6px\` |
+
+**IF the design requires something OUTSIDE the current system:**
+- **Extend the system FIRST** — add the new token/primitive
+- **THEN use the new token** in your component
+- **NEVER one-off override.** That is how design systems die.
+
+### PHASE 4: VERIFY BEFORE CLAIMING DONE
+
+BEFORE reporting visual work as complete, answer these:
+
+- [ ] Does EVERY color reference a design token or CSS variable?
+- [ ] Does EVERY spacing use the system scale?
+- [ ] Does EVERY component follow the existing composition pattern?
+- [ ] Would a designer see CONSISTENCY across old and new components?
+- [ ] Are there ZERO hardcoded magic numbers for visual properties?
+
+**If ANY answer is NO — FIX IT. You are NOT done.**
+
+
+
+
+Design-first mindset (AFTER design system is established):
+- Bold aesthetic choices over safe defaults
+- Unexpected layouts, asymmetry, grid-breaking elements
+- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
+- Cohesive color palettes with sharp accents
+- High-impact animations with staggered reveals
+- Atmosphere: gradient meshes, noise textures, layered transparencies
+
+AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
+
+`
+
+const ARTISTRY_CATEGORY_PROMPT_APPEND = `
+You are working on HIGHLY CREATIVE / ARTISTIC tasks.
+
+Artistic genius mindset:
+- Push far beyond conventional boundaries
+- Explore radical, unconventional directions
+- Surprise and delight: unexpected twists, novel combinations
+- Rich detail and vivid expression
+- Break patterns deliberately when it serves the creative vision
+
+Approach:
+- Generate diverse, bold options first
+- Embrace ambiguity and wild experimentation
+- Balance novelty with coherence
+- This is for tasks requiring exceptional creativity
+`
+
+export const GOOGLE_CATEGORIES: BuiltinCategoryDefinition[] = [
+ {
+ name: "visual-engineering",
+ config: { model: "google/gemini-3.1-pro", variant: "high" },
+ description: "Frontend, UI/UX, design, styling, animation",
+ promptAppend: VISUAL_CATEGORY_PROMPT_APPEND,
+ },
+ {
+ name: "artistry",
+ config: { model: "google/gemini-3.1-pro", variant: "high" },
+ description: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
+ promptAppend: ARTISTRY_CATEGORY_PROMPT_APPEND,
+ },
+]
diff --git a/src/tools/delegate-task/kimi-categories.ts b/src/tools/delegate-task/kimi-categories.ts
new file mode 100644
index 000000000..041a4ae54
--- /dev/null
+++ b/src/tools/delegate-task/kimi-categories.ts
@@ -0,0 +1,36 @@
+import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
+
+const WRITING_CATEGORY_PROMPT_APPEND = `
+You are working on WRITING / PROSE tasks.
+
+Wordsmith mindset:
+- Clear, flowing prose
+- Appropriate tone and voice
+- Engaging and readable
+- Proper structure and organization
+
+Approach:
+- Understand the audience
+- Draft with care
+- Polish for clarity and impact
+- Documentation, READMEs, articles, technical writing
+
+ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
+- NEVER use em dashes (—) or en dashes (–). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
+- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
+- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
+- Use contractions naturally: "don't" not "do not", "it's" not "it is".
+- Vary sentence length. Don't make every sentence the same length.
+- NEVER start consecutive sentences with the same word.
+- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
+- Write like a human, not a corporate template.
+`
+
+export const KIMI_CATEGORIES: BuiltinCategoryDefinition[] = [
+ {
+ name: "writing",
+ config: { model: "kimi-for-coding/k2p5" },
+ description: "Documentation, prose, technical writing",
+ promptAppend: WRITING_CATEGORY_PROMPT_APPEND,
+ },
+]
diff --git a/src/tools/delegate-task/openai-categories.ts b/src/tools/delegate-task/openai-categories.ts
new file mode 100644
index 000000000..028ade55e
--- /dev/null
+++ b/src/tools/delegate-task/openai-categories.ts
@@ -0,0 +1,116 @@
+import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
+
+const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `
+You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
+
+**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
+1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
+2. Your code MUST match the project's existing conventions - blend in seamlessly
+3. Write READABLE code that humans can easily understand - no clever tricks
+4. If unsure about style, explore more files until you find the pattern
+
+Strategic advisor mindset:
+- Bias toward simplicity: least complex solution that fulfills requirements
+- Leverage existing code/patterns over new components
+- Prioritize developer experience and maintainability
+- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
+- Signal when advanced approach warranted
+
+Response format:
+- Bottom line (2-3 sentences)
+- Action plan (numbered steps)
+- Risks and mitigations (if relevant)
+`
+
+const DEEP_CATEGORY_PROMPT_APPEND = `
+You are working on GOAL-ORIENTED AUTONOMOUS tasks.
+
+You are NOT an interactive assistant. You are an autonomous problem-solver.
+
+BEFORE making ANY changes:
+1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
+2. Read related files, trace dependencies, understand the full context
+3. Build a complete mental model of the problem space
+4. Do not ask clarifying questions - the goal is already defined
+
+You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
+
+Sub-steps of ONE goal = execute all steps as phases of one atomic task.
+Genuinely independent tasks = flag and refuse, require separate delegations.
+
+Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
+
+Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
+`
+
+const QUICK_CATEGORY_PROMPT_APPEND = `
+You are working on SMALL / QUICK tasks.
+
+Efficient execution mindset:
+- Fast, focused, minimal overhead
+- Get to the point immediately
+- No over-engineering
+- Simple solutions for simple problems
+
+Approach:
+- Minimal viable implementation
+- Skip unnecessary abstractions
+- Direct and concise
+
+
+
+THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
+
+The model executing this task is optimized for speed over depth. Your prompt MUST be:
+
+**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
+1. MUST DO: List every required action as atomic, numbered steps
+2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
+3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
+
+**WHY THIS MATTERS:**
+- Smaller models benefit from explicit guardrails
+- Vague instructions may lead to unpredictable results
+- Implicit expectations may be missed
+**PROMPT STRUCTURE (MANDATORY):**
+\`\`\`
+TASK: [One-sentence goal]
+
+MUST DO:
+1. [Specific action with exact details]
+2. [Another specific action]
+...
+
+MUST NOT DO:
+- [Forbidden action + why]
+- [Another forbidden action]
+...
+
+EXPECTED OUTPUT:
+- [Exact deliverable description]
+- [Success criteria / verification method]
+\`\`\`
+
+If your prompt lacks this structure, REWRITE IT before delegating.
+`
+
+export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
+ {
+ name: "ultrabrain",
+ config: { model: "openai/gpt-5.4", variant: "xhigh" },
+ description: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
+ promptAppend: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
+ },
+ {
+ name: "deep",
+ config: { model: "openai/gpt-5.4", variant: "medium" },
+ description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
+ promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
+ },
+ {
+ name: "quick",
+ config: { model: "openai/gpt-5.4-mini" },
+ description: "Trivial tasks - single file changes, typo fixes, simple modifications",
+ promptAppend: QUICK_CATEGORY_PROMPT_APPEND,
+ },
+]
diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts
index 0baedf552..88117ca0f 100644
--- a/src/tools/delegate-task/subagent-resolver.ts
+++ b/src/tools/delegate-task/subagent-resolver.ts
@@ -141,8 +141,6 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
}
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
- // Cold cache: resolution was skipped but user explicitly configured a model.
- // Honor the user override directly — don't fall through to hardcoded fallback chain.
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
if (normalized) {
const agentCategoryVariant = agentOverride?.category
@@ -164,8 +162,6 @@ Create the work plan directly - that's your job as the planning agent.`,
normalizedAgentFallbackModels,
defaultProviderID,
)
- // Don't assign hardcoded fallback chain when resolution was skipped (cold cache)
- // — the chain may contain model IDs that don't exist in the provider yet.
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
// Only promote fallback-only settings when resolution actually selected a fallback model.
diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts
index 40585210f..9be6dabae 100644
--- a/src/tools/hashline-edit/edit-operations.test.ts
+++ b/src/tools/hashline-edit/edit-operations.test.ts
@@ -227,10 +227,10 @@ describe("hashline edit operations", () => {
})
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
- //#given — reproduces the 애국가 bug where blank+indented lines collapse
+ //#given, reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
- //#when — replace the range with indented version (blank lines preserved)
+ //#when, replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
@@ -238,7 +238,7 @@ describe("hashline edit operations", () => {
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
- //#then — all 7 lines preserved with indentation, not collapsed to 3
+ //#then, all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
diff --git a/src/tools/hashline-edit/formatter-trigger.test.ts b/src/tools/hashline-edit/formatter-trigger.test.ts
index c631ae079..050c2b2f6 100644
--- a/src/tools/hashline-edit/formatter-trigger.test.ts
+++ b/src/tools/hashline-edit/formatter-trigger.test.ts
@@ -350,10 +350,10 @@ describe("runFormattersForFile", () => {
},
})
- //#when — run for a .go file, but only .ts formatters registered
+ //#when, run for a .go file, but only .ts formatters registered
await runFormattersForFile(client, "/project", "/src/main.go")
- //#then — no error thrown
+ //#then, no error thrown
})
it("runs formatter for matching extension", async () => {
@@ -367,10 +367,10 @@ describe("runFormattersForFile", () => {
},
})
- //#when — echo is a safe no-op command
+ //#when, echo is a safe no-op command
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
- //#then — should complete without error
+ //#then, should complete without error
expect(client.config.get).toHaveBeenCalledTimes(1)
})
})
diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts
index 739def9fa..c3c531bef 100644
--- a/src/tools/hashline-edit/validation.test.ts
+++ b/src/tools/hashline-edit/validation.test.ts
@@ -23,10 +23,10 @@ describe("parseLineRef", () => {
})
it("gives specific hint when literal text is used instead of line number", () => {
- //#given — model sends "LINE#HK" instead of "1#HK"
+ //#given, model sends "LINE#HK" instead of "1#HK"
const ref = "LINE#HK"
- //#when / #then — error should mention that LINE is not a valid number
+ //#when / #then, error should mention that LINE is not a valid number
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
@@ -39,10 +39,10 @@ describe("parseLineRef", () => {
})
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
- //#given — normalizeLineRef extracts 42#VK from LINE42#VK
+ //#given, normalizeLineRef extracts 42#VK from LINE42#VK
const ref = "LINE42#VK"
- //#when / #then — should parse successfully as line 42
+ //#when / #then, should parse successfully as line 42
const result = parseLineRef(ref)
expect(result.line).toBe(42)
expect(result.hash).toBe("VK")
@@ -144,11 +144,11 @@ describe("validateLineRef", () => {
})
it("suggests correct line number when hash matches a file line", () => {
- //#given — model sends LINE#XX where XX is the actual hash for line 1
+ //#given, model sends LINE#XX where XX is the actual hash for line 1
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
- //#when / #then — error should suggest the correct reference
+ //#when / #then, error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
})
})
diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts
index aa9166c16..f09b8fb8b 100644
--- a/src/tools/hashline-edit/validation.ts
+++ b/src/tools/hashline-edit/validation.ts
@@ -48,7 +48,6 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2],
}
}
- // normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts
index 85de82c37..59459cde8 100644
--- a/src/tools/lsp/config.test.ts
+++ b/src/tools/lsp/config.test.ts
@@ -20,8 +20,7 @@ describe("isServerInstalled", () => {
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true })
- } catch (e) {
- // cleanup failed — ignored
+ } catch {
}
if (process.platform === "win32") {
diff --git a/src/tools/lsp/lsp-manager-process-cleanup.ts b/src/tools/lsp/lsp-manager-process-cleanup.ts
index df9f299e9..4bf6b14f7 100644
--- a/src/tools/lsp/lsp-manager-process-cleanup.ts
+++ b/src/tools/lsp/lsp-manager-process-cleanup.ts
@@ -1,3 +1,5 @@
+import { log } from "../../shared/logger"
+
type ManagedClientForCleanup = {
client: {
stop: () => Promise;
@@ -22,23 +24,32 @@ export type LspProcessCleanupHandle = {
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
const handlers: RegisteredHandler[] = [];
- // Synchronous cleanup for 'exit' event (cannot await)
+ const logCleanupError = (phase: string, error: unknown): void => {
+ log(`[lsp-manager-process-cleanup] ${phase}`, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ };
+
const syncCleanup = () => {
for (const [, managed] of options.getClients()) {
try {
- // Fire-and-forget during sync exit - process is terminating
- void managed.client.stop().catch(() => {});
- } catch {}
+ void managed.client.stop().catch((error) => {
+ logCleanupError("stop failed during exit cleanup", error);
+ });
+ } catch (error) {
+ logCleanupError("failed to schedule exit cleanup", error);
+ }
}
options.clearClients();
options.clearCleanupInterval();
};
- // Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise[] = [];
for (const [, managed] of options.getClients()) {
- stopPromises.push(managed.client.stop().catch(() => {}));
+ stopPromises.push(managed.client.stop().catch((error) => {
+ logCleanupError("stop failed during signal cleanup", error);
+ }));
}
await Promise.allSettled(stopPromises);
options.clearClients();
@@ -52,8 +63,9 @@ export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions)
registerHandler("exit", syncCleanup);
- // Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
- const signalCleanup = () => void asyncCleanup().catch(() => {});
+ const signalCleanup = () => void asyncCleanup().catch((error) => {
+ logCleanupError("signal cleanup failed", error);
+ });
registerHandler("SIGINT", signalCleanup);
registerHandler("SIGTERM", signalCleanup);
if (process.platform === "win32") {
diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts
index 358c5c7e5..3f7b769a2 100644
--- a/src/tools/lsp/lsp-process.ts
+++ b/src/tools/lsp/lsp-process.ts
@@ -2,11 +2,9 @@ import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
-// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
function shouldUseNodeSpawn(): boolean {
return process.platform === "win32"
}
-// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
@@ -24,7 +22,6 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
-// Bridges Bun Subprocess and Node.js ChildProcess under a common API
export interface UnifiedProcess {
stdin: { write(chunk: Uint8Array | string): void }
stdout: { getReader(): StreamReader }
diff --git a/src/tools/skill/description-formatter.ts b/src/tools/skill/description-formatter.ts
new file mode 100644
index 000000000..c51c10a23
--- /dev/null
+++ b/src/tools/skill/description-formatter.ts
@@ -0,0 +1,61 @@
+import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
+import { sortByScopePriority } from "./scope-priority"
+import type { SkillInfo } from "./types"
+import type { CommandInfo } from "../slashcommand/types"
+
+function formatSkillCommand(skill: SkillInfo): string {
+ const lines = [
+ " ",
+ ` /${skill.name}`,
+ ` ${skill.description}`,
+ ` ${skill.scope}`,
+ ]
+
+ if (skill.compatibility) {
+ lines.push(` ${skill.compatibility}`)
+ }
+
+ lines.push(" ")
+ return lines.join("\n")
+}
+
+function formatSlashCommand(command: CommandInfo): string {
+ const argumentHint = typeof command.metadata.argumentHint === "string"
+ ? command.metadata.argumentHint.trim()
+ : undefined
+ const lines = [
+ " ",
+ ` /${command.name}`,
+ ` ${command.metadata.description || "(no description)"}`,
+ ` ${command.scope}`,
+ ]
+
+ if (argumentHint) {
+ lines.push(` ${argumentHint}`)
+ }
+
+ lines.push(" ")
+ return lines.join("\n")
+}
+
+export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
+ if (skills.length === 0 && commands.length === 0) {
+ return TOOL_DESCRIPTION_NO_SKILLS
+ }
+
+ const availableItems = [
+ ...sortByScopePriority(skills).map(formatSkillCommand),
+ ...sortByScopePriority(commands).map(formatSlashCommand),
+ ]
+
+ if (availableItems.length === 0) {
+ return TOOL_DESCRIPTION_PREFIX
+ }
+
+ return `${TOOL_DESCRIPTION_PREFIX}
+
+Priority: project > user > opencode > builtin/plugin | Skills listed before commands
+Invoke via: skill(name="item-name") — omit leading slash for commands.
+${availableItems.join("\n")}
+`
+}
diff --git a/src/tools/skill/mcp-capability-formatter.ts b/src/tools/skill/mcp-capability-formatter.ts
new file mode 100644
index 000000000..a7371480f
--- /dev/null
+++ b/src/tools/skill/mcp-capability-formatter.ts
@@ -0,0 +1,96 @@
+import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
+import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
+import type {
+ SkillMcpClientInfo,
+ SkillMcpManager,
+ SkillMcpServerContext,
+} from "../../features/skill-mcp-manager"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export async function formatMcpCapabilities(
+ skill: LoadedSkill,
+ manager: SkillMcpManager,
+ sessionID: string
+): Promise {
+ if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
+ return null
+ }
+
+ const sections: string[] = ["", "## Available MCP Servers", ""]
+
+ for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
+ const info: SkillMcpClientInfo = {
+ serverName,
+ skillName: skill.name,
+ sessionID,
+ }
+ const context: SkillMcpServerContext = {
+ config,
+ skillName: skill.name,
+ }
+
+ sections.push(`### ${serverName}`, "")
+
+ try {
+ const [tools, resources, prompts] = await Promise.all([
+ manager.listTools(info, context).catch(() => []),
+ manager.listResources(info, context).catch(() => []),
+ manager.listPrompts(info, context).catch(() => []),
+ ])
+
+ appendToolSections(sections, tools as Tool[])
+ appendResourceSection(sections, resources as Resource[])
+ appendPromptSection(sections, prompts as Prompt[])
+
+ if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
+ sections.push("*No capabilities discovered*")
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
+ }
+
+ sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
+ }
+
+ return sections.join("\n")
+}
+
+function appendToolSections(sections: string[], tools: Tool[]): void {
+ if (tools.length === 0) {
+ return
+ }
+
+ sections.push("**Tools:**", "")
+
+ for (const toolDefinition of tools) {
+ sections.push(`#### \`${toolDefinition.name}\``)
+ if (toolDefinition.description) {
+ sections.push(toolDefinition.description)
+ }
+ sections.push(
+ "",
+ "**inputSchema:**",
+ "```json",
+ JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
+ "```",
+ ""
+ )
+ }
+}
+
+function appendResourceSection(sections: string[], resources: Resource[]): void {
+ if (resources.length === 0) {
+ return
+ }
+
+ sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
+}
+
+function appendPromptSection(sections: string[], prompts: Prompt[]): void {
+ if (prompts.length === 0) {
+ return
+ }
+
+ sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
+}
diff --git a/src/tools/skill/native-skills.ts b/src/tools/skill/native-skills.ts
new file mode 100644
index 000000000..67bc463b3
--- /dev/null
+++ b/src/tools/skill/native-skills.ts
@@ -0,0 +1,62 @@
+import type { SkillInfo } from "./types"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export type NativeSkillEntry = {
+ name: string
+ description: string
+ location: string
+ content: string
+}
+
+export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
+ return {
+ name: skill.name,
+ description: skill.definition.description || "",
+ location: skill.path,
+ scope: skill.scope,
+ license: skill.license,
+ compatibility: skill.compatibility,
+ metadata: skill.metadata,
+ allowedTools: skill.allowedTools,
+ }
+}
+
+function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
+ return {
+ name: native.name,
+ path: native.location,
+ definition: {
+ name: native.name,
+ description: native.description,
+ template: native.content,
+ },
+ scope: "config",
+ }
+}
+
+export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
+ const knownNames = new Set(skills.map((skill) => skill.name))
+ for (const native of nativeSkills) {
+ if (knownNames.has(native.name)) continue
+ skills.push(nativeSkillToLoadedSkill(native))
+ knownNames.add(native.name)
+ }
+}
+
+export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
+ const knownNames = new Set(skillInfos.map((skill) => skill.name))
+ for (const native of nativeSkills) {
+ if (knownNames.has(native.name)) continue
+ skillInfos.push({
+ name: native.name,
+ description: native.description,
+ location: native.location,
+ scope: "config",
+ })
+ knownNames.add(native.name)
+ }
+}
+
+export function isPromiseLike(value: TValue | Promise): value is Promise {
+ return typeof value === "object" && value !== null && "then" in value
+}
diff --git a/src/tools/skill/scope-priority.ts b/src/tools/skill/scope-priority.ts
new file mode 100644
index 000000000..29364d24d
--- /dev/null
+++ b/src/tools/skill/scope-priority.ts
@@ -0,0 +1,17 @@
+export const SCOPE_PRIORITY: Record = {
+ project: 4,
+ user: 3,
+ opencode: 2,
+ "opencode-project": 2,
+ plugin: 1,
+ config: 1,
+ builtin: 1,
+}
+
+export function sortByScopePriority(items: TItem[]): TItem[] {
+ return [...items].sort((left, right) => {
+ const leftPriority = SCOPE_PRIORITY[left.scope] || 0
+ const rightPriority = SCOPE_PRIORITY[right.scope] || 0
+ return rightPriority - leftPriority
+ })
+}
diff --git a/src/tools/skill/skill-body.ts b/src/tools/skill/skill-body.ts
new file mode 100644
index 000000000..fa05f6c8e
--- /dev/null
+++ b/src/tools/skill/skill-body.ts
@@ -0,0 +1,26 @@
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
+
+const SKILL_INSTRUCTION_PATTERN = /([\s\S]*?)<\/skill-instruction>/
+
+function trimSkillInstruction(template: string): string {
+ const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
+ return templateMatch ? templateMatch[1].trim() : template
+}
+
+export async function extractSkillBody(skill: LoadedSkill): Promise {
+ if (skill.lazyContent) {
+ const fullTemplate = await skill.lazyContent.load()
+ return trimSkillInstruction(fullTemplate)
+ }
+
+ if (skill.scope === "config" && skill.definition.template) {
+ return trimSkillInstruction(skill.definition.template)
+ }
+
+ if (skill.path) {
+ return extractSkillTemplate(skill)
+ }
+
+ return trimSkillInstruction(skill.definition.template || "")
+}
diff --git a/src/tools/skill/skill-matcher.ts b/src/tools/skill/skill-matcher.ts
new file mode 100644
index 000000000..9634d3c3b
--- /dev/null
+++ b/src/tools/skill/skill-matcher.ts
@@ -0,0 +1,40 @@
+import { sortByScopePriority } from "./scope-priority"
+import type { CommandInfo } from "../slashcommand/types"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
+ const normalizedName = requestedName.toLowerCase()
+ const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
+ if (exactMatch) {
+ return exactMatch
+ }
+
+ const shortNameMatches = skills.filter((skill) => {
+ const parts = skill.name.split("/")
+ const shortName = parts[parts.length - 1]
+ return parts.length > 1 && shortName?.toLowerCase() === normalizedName
+ })
+
+ if (shortNameMatches.length === 1) {
+ return shortNameMatches[0]
+ }
+
+ return undefined
+}
+
+export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
+ const normalizedName = requestedName.toLowerCase()
+ return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
+}
+
+export function findPartialMatches(
+ skills: LoadedSkill[],
+ commands: CommandInfo[],
+ requestedName: string
+): string[] {
+ const normalizedName = requestedName.toLowerCase()
+ return [
+ ...skills.map((skill) => skill.name),
+ ...commands.map((command) => `/${command.name}`),
+ ].filter((name) => name.toLowerCase().includes(normalizedName))
+}
diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts
index 5007857ff..bf7ebf451 100644
--- a/src/tools/skill/tools.test.ts
+++ b/src/tools/skill/tools.test.ts
@@ -732,14 +732,14 @@ describe("skill tool - short name resolution", () => {
]
const tool = createSkillTool({ skills: loadedSkills })
- // when / then — should not resolve (ambiguous), should suggest both
+ // when / then, should not resolve (ambiguous), should suggest both
await expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
"not found"
)
})
it("prefers exact match over short name match", async () => {
- // given — "debugging" exists as both exact and as part of a namespace
+ // given, "debugging" exists as both exact and as part of a namespace
const loadedSkills = [
createMockSkill("debugging"),
createMockSkill("superpowers/debugging"),
@@ -749,7 +749,7 @@ describe("skill tool - short name resolution", () => {
// when
const result = await tool.execute({ name: "debugging" }, mockContext)
- // then — should match "debugging" exactly, not "superpowers/debugging"
+ // then, should match "debugging" exactly, not "superpowers/debugging"
expect(result).toContain("## Skill: debugging")
})
})
diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts
index 34d31cb2e..f15e90410 100644
--- a/src/tools/skill/tools.ts
+++ b/src/tools/skill/tools.ts
@@ -1,258 +1,52 @@
import { dirname } from "node:path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
-import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
-import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
+import { TOOL_DESCRIPTION_PREFIX } from "./constants"
+import type { SkillArgs, SkillLoadOptions } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
-import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
+import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
-import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
-import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
-import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
-
-type NativeSkillEntry = {
- name: string
- description: string
- location: string
- content: string
-}
-// Priority: project > user > opencode/opencode-project > builtin/config
-const scopePriority: Record = {
- project: 4,
- user: 3,
- opencode: 2,
- "opencode-project": 2,
- plugin: 1,
- config: 1,
- builtin: 1,
-}
-
-function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
- return {
- name: skill.name,
- description: skill.definition.description || "",
- location: skill.path,
- scope: skill.scope,
- license: skill.license,
- compatibility: skill.compatibility,
- metadata: skill.metadata,
- allowedTools: skill.allowedTools,
- }
-}
-
-function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
- return {
- name: native.name,
- path: native.location,
- definition: {
- name: native.name,
- description: native.description,
- template: native.content,
- },
- scope: "config",
- }
-}
-
-function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
- const knownNames = new Set(skills.map(skill => skill.name))
- for (const native of nativeSkills) {
- if (knownNames.has(native.name)) continue
- skills.push(nativeSkillToLoadedSkill(native))
- knownNames.add(native.name)
- }
-}
-
-function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
- const knownNames = new Set(skillInfos.map(skill => skill.name))
- for (const native of nativeSkills) {
- if (knownNames.has(native.name)) continue
- skillInfos.push({
- name: native.name,
- description: native.description,
- location: native.location,
- scope: "config",
- })
- knownNames.add(native.name)
- }
-}
-
-function isPromiseLike(value: T | Promise): value is Promise {
- return typeof value === "object" && value !== null && "then" in value
-}
-
-function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
- const lines: string[] = []
-
- if (skills.length === 0 && commands.length === 0) {
- return TOOL_DESCRIPTION_NO_SKILLS
- }
-
- // Uses module-level scopePriority for consistent priority ordering
-
- const allItems: string[] = []
-
- // Skills rendered as command items (skills are also slash-invocable)
- if (skills.length > 0) {
- const sortedSkills = [...skills].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA
- })
- sortedSkills.forEach(skill => {
- const parts = [
- " ",
- ` /${skill.name}`,
- ` ${skill.description}`,
- ` ${skill.scope}`,
- ]
- if (skill.compatibility) {
- parts.push(` ${skill.compatibility}`)
- }
- parts.push(" ")
- allItems.push(parts.join("\n"))
- })
- }
-
- // Sort and add commands second (commands after skills)
- if (commands.length > 0) {
- const sortedCommands = [...commands].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA // Higher priority first
- })
- sortedCommands.forEach(cmd => {
- const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
- const parts = [
- " ",
- ` /${cmd.name}`,
- ` ${cmd.metadata.description || "(no description)"}`,
- ` ${cmd.scope}`,
- ]
- if (hint) {
- parts.push(` ${hint.trim()}`)
- }
- parts.push(" ")
- allItems.push(parts.join("\n"))
- })
- }
-
- if (allItems.length > 0) {
- lines.push(`\n\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n`)
- }
-
- return TOOL_DESCRIPTION_PREFIX + lines.join("")
-}
-
-async function extractSkillBody(skill: LoadedSkill): Promise {
- if (skill.lazyContent) {
- const fullTemplate = await skill.lazyContent.load()
- const templateMatch = fullTemplate.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : fullTemplate
- }
-
- if (skill.scope === "config" && skill.definition.template) {
- const templateMatch = skill.definition.template.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : skill.definition.template
- }
-
- if (skill.path) {
- return extractSkillTemplate(skill)
- }
-
- const templateMatch = skill.definition.template?.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
-}
-
-async function formatMcpCapabilities(
- skill: LoadedSkill,
- manager: SkillMcpManager,
- sessionID: string
-): Promise {
- if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
- return null
- }
-
- const sections: string[] = ["", "## Available MCP Servers", ""]
-
- for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
- const info: SkillMcpClientInfo = {
- serverName,
- skillName: skill.name,
- sessionID,
- }
- const context: SkillMcpServerContext = {
- config,
- skillName: skill.name,
- }
-
- sections.push(`### ${serverName}`)
- sections.push("")
-
- try {
- const [tools, resources, prompts] = await Promise.all([
- manager.listTools(info, context).catch(() => []),
- manager.listResources(info, context).catch(() => []),
- manager.listPrompts(info, context).catch(() => []),
- ])
-
- if (tools.length > 0) {
- sections.push("**Tools:**")
- sections.push("")
- for (const t of tools as Tool[]) {
- sections.push(`#### \`${t.name}\``)
- if (t.description) {
- sections.push(t.description)
- }
- sections.push("")
- sections.push("**inputSchema:**")
- sections.push("```json")
- sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
- sections.push("```")
- sections.push("")
- }
- }
- if (resources.length > 0) {
- sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
- }
- if (prompts.length > 0) {
- sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
- }
-
- if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
- sections.push("*No capabilities discovered*")
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error)
- sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
- }
-
- sections.push("")
- sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
- sections.push("")
- }
-
- return sections.join("\n")
-}
+import { formatCombinedDescription } from "./description-formatter"
+import { formatMcpCapabilities } from "./mcp-capability-formatter"
+import {
+ findPartialMatches,
+ matchCommandByName,
+ matchSkillByName,
+} from "./skill-matcher"
+import { extractSkillBody } from "./skill-body"
+import {
+ isPromiseLike,
+ loadedSkillToInfo,
+ mergeNativeSkillInfos,
+ mergeNativeSkills,
+} from "./native-skills"
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
let cachedDescription: string | null = null
const getSkills = async (): Promise => {
clearSkillCache()
- const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
+ const discovered = await getAllSkills({
+ disabledSkills: options?.disabledSkills,
+ browserProvider: options?.browserProvider,
+ })
const allSkills = !options.skills
? discovered
- : [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
+ : [
+ ...discovered,
+ ...options.skills.filter(
+ (skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
+ ),
+ ]
if (options.nativeSkills) {
try {
const nativeAll = await options.nativeSkills.all()
mergeNativeSkills(allSkills, nativeAll)
} catch {
- // Native skill discovery may not be available
}
}
@@ -289,7 +83,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
mergeNativeSkillInfos(skillInfos, nativeAll)
}
} catch {
- // Native skill discovery may not be available
}
}
@@ -323,21 +116,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "")
-
- // Check skills first (exact match, case-insensitive)
- let matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
-
- // Fallback: try matching by short name (basename) for namespaced skills
- // e.g. "systematic-debugging" matches "superpowers/systematic-debugging"
- if (!matchedSkill) {
- const shortNameMatches = skills.filter(s => {
- const parts = s.name.split("/")
- return parts.length > 1 && parts[parts.length - 1].toLowerCase() === requestedName.toLowerCase()
- })
- if (shortNameMatches.length === 1) {
- matchedSkill = shortNameMatches[0]
- }
- }
+ const matchedSkill = matchSkillByName(skills, requestedName)
if (matchedSkill) {
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
@@ -380,27 +159,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return output.join("\n")
}
- // Check commands (exact match, case-insensitive) - sort by priority first
- const sortedCommands = [...commands].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA // Higher priority first
- })
- const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
+ const matchedCommand = matchCommandByName(commands, requestedName)
if (matchedCommand) {
return await formatLoadedCommand(matchedCommand, args.user_message)
}
- // No match found — provide helpful error with partial matches
- const allNames = [
- ...skills.map(s => s.name),
- ...commands.map(c => `/${c.name}`),
- ]
-
- const partialMatches = allNames.filter(n =>
- n.toLowerCase().includes(requestedName.toLowerCase())
- )
+ const partialMatches = findPartialMatches(skills, commands, requestedName)
if (partialMatches.length > 0) {
throw new Error(
@@ -408,7 +173,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
)
}
- const available = allNames.join(", ")
+ const available = [
+ ...skills.map((skill) => skill.name),
+ ...commands.map((command) => `/${command.name}`),
+ ].join(", ")
throw new Error(
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
)
diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts
index d6c87c3df..bf83b732b 100644
--- a/src/tools/task/todo-sync.test.ts
+++ b/src/tools/task/todo-sync.test.ts
@@ -535,7 +535,7 @@ describe("syncAllTasksToTodos", () => {
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
- // then — no duplicates
+ // then, no duplicates
const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
expect(matching.length).toBe(1);
expect(matching[0].status).toBe("in_progress");