diff --git a/.github/workflows/sisyphus-agent.yml b/.github/workflows/sisyphus-agent.yml index 82a9bdfc26..e9938fe83d 100644 --- a/.github/workflows/sisyphus-agent.yml +++ b/.github/workflows/sisyphus-agent.yml @@ -284,7 +284,7 @@ jobs: - user.email: sisyphus-dev-ai@users.noreply.github.com PROMPT_EOF ) - jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON" + jq --arg append "$PROMPT_APPEND" '.agents.Musashi.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON" mkdir -p ~/.local/share/opencode echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json diff --git a/.gitignore b/.gitignore index e913cc4be8..4dc8140df0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ yarn.lock test-injection/ notepad.md oauth-success.html + +# Session files +session-*.md diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 0a87c26adb..29cd57100d 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -5110,6 +5110,18 @@ } } }, + "category_skills": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "claude_code": { "type": "object", "properties": { diff --git a/bun.lock b/bun.lock index 0c3c0dfd4f..7d242efe64 100644 --- a/bun.lock +++ b/bun.lock @@ -10,8 +10,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.6.1", "@modelcontextprotocol/sdk": "^1.25.1", - "@opencode-ai/plugin": "^1.1.19", - "@opencode-ai/sdk": "^1.1.19", + "@opencode-ai/plugin": "^1.1.30", + "@opencode-ai/sdk": "1.1.30", "@types/express": "^5.0.6", "@types/turndown": "^5.0.6", "commander": "^14.0.2", @@ -96,9 +96,9 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.19", "", { "dependencies": { "@opencode-ai/sdk": "1.1.19", "zod": "4.1.8" } }, "sha512-Q6qBEjHb/dJMEw4BUqQxEswTMxCCHUpFMMb6jR8HTTs8X/28XRkKt5pHNPA82GU65IlSoPRph+zd8LReBDN53Q=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.30", "", { "dependencies": { "@opencode-ai/sdk": "1.1.30", "zod": "4.1.8" } }, "sha512-xOHVSKMTbH6vjjgDSZUN1V53iC50wfRBkEfVBZmgs7LZtQx1/SzYNFoaQ84feakBO+wcfpoLVVqZHgFqCCsSVw=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.19", "", {}, "sha512-XhZhFuvlLCqDpvNtUEjOsi/wvFj3YCXb1dySp+OONQRMuHlorNYnNa7P2A2ntKuhRdGT1Xt5na0nFzlUyNw+4A=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.30", "", {}, "sha512-toFtWVnc6PCTTYdGQB03W/PYNBZcKpF87il7LTlg8L65Vim7X9rbRk64mM03hf8lU+5Heo6OUhtJV+9VaHYwgA=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], diff --git a/package.json b/package.json index 7b5df0692f..3705f21f83 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.6.1", "@modelcontextprotocol/sdk": "^1.25.1", - "@opencode-ai/plugin": "^1.1.19", - "@opencode-ai/sdk": "^1.1.19", + "@opencode-ai/plugin": "^1.1.30", + "@opencode-ai/sdk": "1.1.30", "@types/express": "^5.0.6", "@types/turndown": "^5.0.6", "commander": "^14.0.2", diff --git a/src/config/schema.ts b/src/config/schema.ts index e6f29e5cb9..855158bdc4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -350,6 +350,8 @@ export const GitMasterConfigSchema = z.object({ include_co_authored_by: z.boolean().default(true), }) +export const CategorySkillsConfigSchema = z.record(z.string(), z.array(z.string())) + export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(), @@ -359,6 +361,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ disabled_commands: z.array(BuiltinCommandNameSchema).optional(), agents: AgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), + category_skills: CategorySkillsConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), comment_checker: CommentCheckerConfigSchema.optional(), @@ -389,6 +392,7 @@ export type RalphLoopConfig = z.infer export type NotificationConfig = z.infer export type CategoryConfig = z.infer export type CategoriesConfig = z.infer +export type CategorySkillsConfig = z.infer export type BuiltinCategoryName = z.infer export type GitMasterConfig = z.infer diff --git a/src/features/builtin-skills/linearis/SKILL.md b/src/features/builtin-skills/linearis/SKILL.md new file mode 100644 index 0000000000..b507be5211 --- /dev/null +++ b/src/features/builtin-skills/linearis/SKILL.md @@ -0,0 +1,45 @@ +--- +name: linearis +description: (opencode - Skill) Linear CLI tool for issue tracking. Use for creating, listing, updating issues, projects, and cycles. Optimized for LLMs with JSON output. +--- + +# Linearis Skill + +Linearis is a high-performance CLI tool for Linear.app that outputs structured JSON data. It is designed for LLM agents. + +## Usage + +Always use `npx linearis` to execute commands. + +### Common Commands + +**Issues** +- List issues: `npx linearis issues list -l 10` +- Read issue: `npx linearis issues read ABC-123` +- Create issue: `npx linearis issues create "Title" --team ABC --description "Desc"` +- Update issue: `npx linearis issues update ABC-123 --status "In Progress"` +- Search: `npx linearis issues search "query" --team ABC` + +**Projects & Cycles** +- List projects: `npx linearis projects list` +- List cycles: `npx linearis cycles list --team ABC` +- Read cycle: `npx linearis cycles read "Sprint 1" --team ABC` + +**Users & Teams** +- List users: `npx linearis users list` +- List teams: `npx linearis teams list` + +### Output Format + +All commands output structured JSON. You can parse this with `jq` or read it directly. + +### Best Practices + +1. **IDs**: Use `ABC-123` format for issues. +2. **Context**: If team/project is ambiguous, the tool will error. Be specific with `--team` or `--project` flags. +3. **Embeds**: `issues read` includes file embeds. Use `npx linearis embeds download ` to fetch them. + +## Authentication + +Authentication is handled via `~/.linear_api_token` or `LINEAR_API_TOKEN` env var. +If authentication fails, ask the user to provide a Linear API token. diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 425941e798..746edc25ca 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -11,7 +11,7 @@ import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from " import type { SkillMcpConfig } from "../skill-mcp-manager/types" import { collectMdFilesRecursive, parseAllowedTools, validateShellConfig } from "./utils" import { preprocessShellCommands, executeShellBlock, substituteShellVariables } from "./shell-preprocessing" -import { discoverSupportingFiles, formatSize } from "./supporting-files" +import { discoverSupportingFiles, formatSize, getLanguageFromExtension } from "./supporting-files" function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) @@ -75,7 +75,7 @@ export async function loadSkillFromPath( const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) const mcpConfig = mcpJsonMcp || frontmatterMcp - const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '') + const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '', new Set(['SKILL.md'])) const mergedContent = subdirFiles.length > 0 ? '\n\n\n\n' + subdirFiles.map(f => f.content).join('\n\n') @@ -84,7 +84,14 @@ export async function loadSkillFromPath( const supportingFiles = await discoverSupportingFiles(resolvedPath) const supportingFilesSection = supportingFiles.length > 0 ? '\n\n' + - supportingFiles.map(f => `${f.relativePath} (${formatSize(f.sizeBytes)})`).join('\n') + + supportingFiles.map(f => { + const header = `${f.relativePath} (${formatSize(f.sizeBytes)})` + if (f.content) { + const lang = getLanguageFromExtension(f.extension) + return `${header}\n\`\`\`${lang}\n${f.content}\n\`\`\`` + } + return header + }).join('\n\n') + '\n\n\n' : '' @@ -211,6 +218,7 @@ export async function loadProjectSkills(): Promise> { // Support both singular (oh-my-opencode convention) and plural (vercel-labs/add-skill convention) + // Plural takes priority (loaded last to override) as it's the newer convention const skillsSingular = join(homedir(), ".config", "opencode", "skill") const skillsPlural = join(homedir(), ".config", "opencode", "skills") const [singular, plural] = await Promise.all([ @@ -222,6 +230,7 @@ export async function loadOpencodeGlobalSkills(): Promise> { // Support both singular (oh-my-opencode convention) and plural (vercel-labs/add-skill convention) + // Plural takes priority (loaded last to override) as it's the newer convention const skillsSingular = join(process.cwd(), ".opencode", "skill") const skillsPlural = join(process.cwd(), ".opencode", "skills") const [singular, plural] = await Promise.all([ @@ -294,22 +303,24 @@ export async function discoverProjectClaudeSkills(): Promise { export async function discoverOpencodeGlobalSkills(): Promise { // Support both singular (oh-my-opencode convention) and plural (vercel-labs/add-skill convention) + // Plural takes priority (loaded first) as it's the newer convention const skillsSingular = join(homedir(), ".config", "opencode", "skill") const skillsPlural = join(homedir(), ".config", "opencode", "skills") const [singular, plural] = await Promise.all([ loadSkillsFromDir(skillsSingular, "opencode"), loadSkillsFromDir(skillsPlural, "opencode"), ]) - return [...singular, ...plural] + return [...plural, ...singular] } export async function discoverOpencodeProjectSkills(): Promise { // Support both singular (oh-my-opencode convention) and plural (vercel-labs/add-skill convention) + // Plural takes priority (loaded first) as it's the newer convention const skillsSingular = join(process.cwd(), ".opencode", "skill") const skillsPlural = join(process.cwd(), ".opencode", "skills") const [singular, plural] = await Promise.all([ loadSkillsFromDir(skillsSingular, "opencode-project"), loadSkillsFromDir(skillsPlural, "opencode-project"), ]) - return [...singular, ...plural] + return [...plural, ...singular] } diff --git a/src/features/opencode-skill-loader/skill-content.test.ts b/src/features/opencode-skill-loader/skill-content.test.ts index fd8c597da7..af3e93156f 100644 --- a/src/features/opencode-skill-loader/skill-content.test.ts +++ b/src/features/opencode-skill-loader/skill-content.test.ts @@ -178,7 +178,7 @@ describe("resolveMultipleSkillsAsync", () => { expect(result.notFound).toEqual([]) const gitMasterContent = result.resolved.get("git-master") expect(gitMasterContent).not.toContain("Ultraworked with") - expect(gitMasterContent).not.toContain("Co-authored-by: Sisyphus") + expect(gitMasterContent).not.toContain("Co-authored-by: Musashi") }) it("should inject watermark when enabled (default)", async () => { @@ -197,8 +197,8 @@ describe("resolveMultipleSkillsAsync", () => { // #then: watermark section is injected expect(result.resolved.size).toBe(1) const gitMasterContent = result.resolved.get("git-master") - expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]") - expect(gitMasterContent).toContain("Co-authored-by: Sisyphus") + expect(gitMasterContent).toContain("Ultraworked with [Musashi]") + expect(gitMasterContent).toContain("Co-authored-by: Musashi") }) it("should inject only footer when co-author is disabled", async () => { @@ -216,8 +216,8 @@ describe("resolveMultipleSkillsAsync", () => { // #then: only footer is injected const gitMasterContent = result.resolved.get("git-master") - expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]") - expect(gitMasterContent).not.toContain("Co-authored-by: Sisyphus") + expect(gitMasterContent).toContain("Ultraworked with [Musashi]") + expect(gitMasterContent).not.toContain("Co-authored-by: Musashi") }) it("should inject watermark by default when no config provided", async () => { @@ -230,8 +230,8 @@ describe("resolveMultipleSkillsAsync", () => { // #then: watermark is injected (default is ON) expect(result.resolved.size).toBe(1) const gitMasterContent = result.resolved.get("git-master") - expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]") - expect(gitMasterContent).toContain("Co-authored-by: Sisyphus") + expect(gitMasterContent).toContain("Ultraworked with [Musashi]") + expect(gitMasterContent).toContain("Co-authored-by: Musashi") }) it("should inject only co-author when footer is disabled", async () => { @@ -249,8 +249,8 @@ describe("resolveMultipleSkillsAsync", () => { // #then: only co-author is injected const gitMasterContent = result.resolved.get("git-master") - expect(gitMasterContent).not.toContain("Ultraworked with [Sisyphus]") - expect(gitMasterContent).toContain("Co-authored-by: Sisyphus") + expect(gitMasterContent).not.toContain("Ultraworked with [Musashi]") + expect(gitMasterContent).toContain("Co-authored-by: Musashi") }) it("should handle empty array", async () => { diff --git a/src/features/opencode-skill-loader/skill-content.ts b/src/features/opencode-skill-loader/skill-content.ts index 23af613424..7e2f53cc9c 100644 --- a/src/features/opencode-skill-loader/skill-content.ts +++ b/src/features/opencode-skill-loader/skill-content.ts @@ -49,10 +49,10 @@ async function getAllSkills(): Promise { } async function extractSkillTemplate(skill: LoadedSkill): Promise { - if (skill.path) { - const content = readFileSync(skill.path, "utf-8") - const { body } = parseFrontmatter(content) - return body.trim() + if (skill.lazyContent) { + const fullTemplate = await skill.lazyContent.load() + const templateMatch = fullTemplate.match(/([\s\S]*?)<\/skill-instruction>/) + return templateMatch ? templateMatch[1].trim() : fullTemplate } return skill.definition.template || "" } diff --git a/src/features/opencode-skill-loader/supporting-files.test.ts b/src/features/opencode-skill-loader/supporting-files.test.ts index 8be319b61e..4cc355c5e5 100644 --- a/src/features/opencode-skill-loader/supporting-files.test.ts +++ b/src/features/opencode-skill-loader/supporting-files.test.ts @@ -62,16 +62,16 @@ describe("supporting-files", () => { expect(files.find((f) => f.relativePath === "package.json")).toBeTruthy() }) - it("limits to 20 files and filters by size", async () => { - // Create 25 small files - for (let i = 0; i < 25; i++) { - await fs.writeFile(join(tmpDir, `file${i}.txt`), "content") - } + it("limits to 25 files and filters by size", async () => { + // Create 30 small files + for (let i = 0; i < 30; i++) { + await fs.writeFile(join(tmpDir, `file${i}.txt`), "content") + } - const files = await discoverSupportingFiles(tmpDir) + const files = await discoverSupportingFiles(tmpDir) - expect(files.length).toBeLessThanOrEqual(20) - }) + expect(files.length).toBeLessThanOrEqual(25) + }) it("filters out files larger than 1MB", async () => { // Create a small file @@ -96,7 +96,7 @@ describe("supporting-files", () => { expect(files.find((f) => f.relativePath === "visible.txt")).toBeTruthy() }) - it("includes file metadata", async () => { + it("includes file metadata and content for small files", async () => { await fs.writeFile(join(tmpDir, "config.json"), '{"key":"value"}') const files = await discoverSupportingFiles(tmpDir) @@ -106,6 +106,7 @@ describe("supporting-files", () => { expect(file.absolutePath).toBe(join(tmpDir, "config.json")) expect(file.extension).toBe(".json") expect(file.sizeBytes).toBeGreaterThan(0) + expect(file.content).toBe('{"key":"value"}') }) }) }) diff --git a/src/features/opencode-skill-loader/supporting-files.ts b/src/features/opencode-skill-loader/supporting-files.ts index 42b5fbe8a5..83e824c440 100644 --- a/src/features/opencode-skill-loader/supporting-files.ts +++ b/src/features/opencode-skill-loader/supporting-files.ts @@ -6,10 +6,13 @@ export interface SupportingFile { absolutePath: string // Full path sizeBytes: number // File size extension: string // ".sh", ".json", etc. + content?: string // File content (only for files < 50KB) } +const INLINE_CONTENT_MAX_SIZE_BYTES = 50 * 1024 + const DISCOVERY_LIMITS = { - MAX_FILES: 20, + MAX_FILES: 25, MAX_FILE_SIZE: 1024 * 1024, // 1MB per file MAX_TOTAL_SIZE: 10 * 1024 * 1024, // 10MB total } as const @@ -22,6 +25,33 @@ export function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB` } +const EXTENSION_TO_LANGUAGE: Record = { + '.ts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.jsx': 'jsx', + '.json': 'json', + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'bash', + '.fish': 'fish', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.go': 'go', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.html': 'html', + '.css': 'css', + '.sql': 'sql', +} + +export function getLanguageFromExtension(extension: string): string { + return EXTENSION_TO_LANGUAGE[extension.toLowerCase()] || '' +} + async function collectNonMdFilesRecursive( dir: string, basePath: string, @@ -42,12 +72,18 @@ async function collectNonMdFilesRecursive( } else if (entry.isFile() && !entry.name.endsWith('.md')) { const stats = await fs.stat(entryPath).catch(() => null) if (stats) { - results.push({ + const supportingFile: SupportingFile = { relativePath, absolutePath: entryPath, sizeBytes: stats.size, extension: extname(entry.name), - }) + } + + if (stats.size <= INLINE_CONTENT_MAX_SIZE_BYTES) { + supportingFile.content = await fs.readFile(entryPath, 'utf-8').catch(() => undefined) + } + + results.push(supportingFile) } } } @@ -59,10 +95,11 @@ async function collectNonMdFilesRecursive( * Algorithm (DETERMINISTIC): * 1. Recursively collect all non-.md, non-hidden files * 2. Sort alphabetically by relativePath - * 3. Apply limits: max 20 files, skip >1MB files, stop at 10MB total + * 3. Apply limits: max 25 files, skip >1MB files, stop at 10MB total + * 4. Inline file contents for files under 50KB * * @param skillDir The skill's resolved directory path - * @returns Array of SupportingFile metadata (no file contents) + * @returns Array of SupportingFile with metadata and content (for files <50KB) */ export async function discoverSupportingFiles(skillDir: string): Promise { const allFiles: SupportingFile[] = [] @@ -79,18 +116,15 @@ export async function discoverSupportingFiles(skillDir: string): Promise= DISCOVERY_LIMITS.MAX_FILES) { - console.warn(`[skill-loader] Supporting files limit reached (${DISCOVERY_LIMITS.MAX_FILES}), skipping remaining ${allFiles.length - result.length} files`) break } if (file.sizeBytes > DISCOVERY_LIMITS.MAX_FILE_SIZE) { - console.warn(`[skill-loader] Skipping large file: ${file.relativePath} (${formatSize(file.sizeBytes)} > 1MB)`) skippedLargeFiles++ continue } if (totalSize + file.sizeBytes > DISCOVERY_LIMITS.MAX_TOTAL_SIZE) { - console.warn(`[skill-loader] Total size limit reached (10MB), stopping discovery`) break } @@ -98,10 +132,6 @@ export async function discoverSupportingFiles(skillDir: string): Promise 0) { - console.warn(`[skill-loader] Skipped ${skippedLargeFiles} files exceeding 1MB size limit`) - } - return result } diff --git a/src/features/opencode-skill-loader/utils.ts b/src/features/opencode-skill-loader/utils.ts index bd15473301..98de8c11b5 100644 --- a/src/features/opencode-skill-loader/utils.ts +++ b/src/features/opencode-skill-loader/utils.ts @@ -2,11 +2,22 @@ import { promises as fs } from "fs" import { join } from "path" import { parseFrontmatter } from "../../shared/frontmatter" +/** + * Recursively collect .md files from a skill directory. + * + * @param dir - Directory to scan + * @param currentDepth - Current recursion depth (0 = root) + * @param maxDepth - Maximum recursion depth (default: 3) + * @param basePath - Base path for relative path construction + * @param skipFiles - Set of filenames to skip at root level (e.g., 'SKILL.md') + * @returns Array of { path, content } for each .md file found + */ export async function collectMdFilesRecursive( dir: string, currentDepth: number, maxDepth: number = 3, - basePath: string = '' + basePath: string = '', + skipFiles: Set = new Set() ): Promise<{ path: string; content: string }[]> { if (currentDepth > maxDepth) return [] @@ -25,15 +36,16 @@ export async function collectMdFilesRecursive( entryPath, currentDepth + 1, maxDepth, - relativePath + relativePath, + skipFiles ) results.push(...subdirFiles) } else if (entry.isFile() && entry.name.endsWith('.md')) { - if (currentDepth > 0) { - const content = await fs.readFile(entryPath, 'utf-8') - const { body } = parseFrontmatter(content) - results.push({ path: relativePath, content: body.trim() }) - } + if (currentDepth === 0 && skipFiles.has(entry.name)) continue + + const content = await fs.readFile(entryPath, 'utf-8') + const { body } = parseFrontmatter(content) + results.push({ path: relativePath, content: body.trim() }) } } diff --git a/src/hooks/prometheus-md-only/constants.ts b/src/hooks/prometheus-md-only/constants.ts index 2ad16fbd94..eda1a7c7ea 100644 --- a/src/hooks/prometheus-md-only/constants.ts +++ b/src/hooks/prometheus-md-only/constants.ts @@ -2,6 +2,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system export const HOOK_NAME = "prometheus-md-only" +// NOTE: "Musashi - boulder" is intentionally NOT included - boulder is execution mode, not planning export const PROMETHEUS_AGENTS = ["Musashi - plan", "Prometheus (Planner)"] export const ALLOWED_EXTENSIONS = [".md"] diff --git a/src/index.ts b/src/index.ts index 7277a6b65a..54a9811f2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { client: ctx.client, directory: ctx.directory, userCategories: pluginConfig.categories, + userCategorySkills: pluginConfig.category_skills, gitMasterConfig: pluginConfig.git_master, sisyphusJuniorModel: pluginConfig.agents?.["Sisyphus-Junior"]?.model ?? pluginConfig.agents?.["J1 - junior"]?.model, }); diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 0df516d3ba..5181a5c43e 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -227,6 +227,27 @@ export const CATEGORY_DESCRIPTIONS: Record = { general: "General purpose tasks", } +export const CATEGORY_SKILLS: Record = { + "visual-engineering": [ + "component-stack", + "shadcn-ui-patterns", + "frontend-ui-ux", + ], + ultrabrain: [ + "effect-ts-expert", + "drizzle-orm", + "hono-api", + ], + artistry: [ + "asset-prompts", + "runware-assets", + ], + quick: [], + "most-capable": [], + writing: [], + general: [], +} + const BUILTIN_CATEGORIES = Object.keys(DEFAULT_CATEGORIES).join(", ") export const DELEGATE_TASK_DESCRIPTION = `Spawn agent task with category-based or direct agent selection. diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 301ff3a592..974811797a 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1060,4 +1060,183 @@ describe("sisyphus-task", () => { expect(resolved!.model).toBe(systemDefaultModel) }) }) + + // ===== CATEGORY_SKILLS AUTO-INJECTION TESTS ===== + describe("CATEGORY_SKILLS", () => { + test("visual-engineering category has frontend skills defined", () => { + // #given + const { CATEGORY_SKILLS } = require("./constants") + + // #when + const skills = CATEGORY_SKILLS["visual-engineering"] + + // #then + expect(skills).toBeDefined() + expect(Array.isArray(skills)).toBe(true) + expect(skills.length).toBeGreaterThan(0) + expect(skills).toContain("component-stack") + }) + + test("ultrabrain category has backend skills defined", () => { + // #given + const { CATEGORY_SKILLS } = require("./constants") + + // #when + const skills = CATEGORY_SKILLS["ultrabrain"] + + // #then + expect(skills).toBeDefined() + expect(Array.isArray(skills)).toBe(true) + }) + + test("quick category has empty skills array (no skills needed)", () => { + // #given + const { CATEGORY_SKILLS } = require("./constants") + + // #when + const skills = CATEGORY_SKILLS["quick"] + + // #then - quick tasks don't need domain skills + expect(skills).toBeDefined() + expect(skills).toEqual([]) + }) + + test("all default categories have CATEGORY_SKILLS entry", () => { + // #given + const { CATEGORY_SKILLS } = require("./constants") + const defaultCategoryNames = Object.keys(DEFAULT_CATEGORIES) + + // #when / #then - every category should have a skills entry (even if empty) + for (const name of defaultCategoryNames) { + expect(CATEGORY_SKILLS[name]).toBeDefined() + expect(Array.isArray(CATEGORY_SKILLS[name])).toBe(true) + } + }) + }) + + describe("getCategorySkills", () => { + test("returns category skills for known category", () => { + // #given + const { getCategorySkills } = require("./tools") + + // #when + const skills = getCategorySkills("visual-engineering") + + // #then + expect(Array.isArray(skills)).toBe(true) + expect(skills.length).toBeGreaterThan(0) + }) + + test("returns empty array for unknown category", () => { + // #given + const { getCategorySkills } = require("./tools") + + // #when + const skills = getCategorySkills("nonexistent-category") + + // #then + expect(skills).toEqual([]) + }) + + test("user can override category skills", () => { + // #given + const { getCategorySkills } = require("./tools") + const userCategorySkills = { + "visual-engineering": ["my-custom-skill", "another-skill"] + } + + // #when + const skills = getCategorySkills("visual-engineering", userCategorySkills) + + // #then - user skills should be used instead of defaults + expect(skills).toEqual(["my-custom-skill", "another-skill"]) + }) + + test("user empty array overrides defaults (opt-out)", () => { + // #given + const { getCategorySkills } = require("./tools") + const userCategorySkills = { + "visual-engineering": [] // User explicitly opts out of default skills + } + + // #when + const skills = getCategorySkills("visual-engineering", userCategorySkills) + + // #then - should return empty array (user opted out) + expect(skills).toEqual([]) + }) + }) + + describe("category skill injection in delegate_task", () => { + test("category skills are auto-injected for visual-engineering category", async () => { + const { createDelegateTask, getCategorySkills } = require("./tools") + let launchedSkills: string[] | undefined + let launchedSkillContent: string | undefined + + const mockManager = { + launch: async (input: any) => { + launchedSkills = input.skills + launchedSkillContent = input.skillContent + return { + id: "task-skills", + sessionID: "session-skills", + description: "Skills task", + agent: "Sisyphus-Junior", + status: "running", + } + }, + } + + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createDelegateTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + await tool.execute( + { + description: "Skills merge test", + prompt: "Do something visual", + category: "visual-engineering", + run_in_background: true, + skills: [], + }, + toolContext + ) + + const categorySkills = getCategorySkills("visual-engineering") + expect(launchedSkills).toBeDefined() + expect(launchedSkills).toEqual(categorySkills) + }) + + test("passed skills are deduplicated with category skills", async () => { + const { getCategorySkills } = require("./tools") + + const categorySkills = getCategorySkills("visual-engineering") + expect(categorySkills.length).toBeGreaterThan(0) + + const existingSkill = categorySkills[0] + const passedSkills = [existingSkill] + + const merged = [...new Set([...categorySkills, ...passedSkills])] + const uniqueSkills = [...new Set(merged)] + expect(merged.length).toBe(uniqueSkills.length) + }) + }) }) diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index d9f881af2a..3b40920cea 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import type { BackgroundManager } from "../../features/background-agent" import type { DelegateTaskArgs } from "./types" import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema" -import { DELEGATE_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" +import { DELEGATE_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_SKILLS } from "./constants" import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content" import { discoverSkills } from "../../features/opencode-skill-loader" @@ -147,13 +147,23 @@ export function resolveCategoryConfig( return { config, promptAppend, model } } +export function getCategorySkills( + categoryName: string, + userCategorySkills?: Record +): string[] { + if (userCategorySkills?.[categoryName] !== undefined) { + return userCategorySkills[categoryName] + } + return CATEGORY_SKILLS[categoryName] ?? [] +} + export interface DelegateTaskToolOptions { manager: BackgroundManager client: OpencodeClient directory: string userCategories?: CategoriesConfig + userCategorySkills?: Record gitMasterConfig?: GitMasterConfig - /** Model override for Sisyphus-Junior agent (used by category delegation) */ sisyphusJuniorModel?: string } @@ -177,7 +187,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und } export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition { - const { manager, client, directory, userCategories, gitMasterConfig, sisyphusJuniorModel } = options + const { manager, client, directory, userCategories, userCategorySkills, gitMasterConfig, sisyphusJuniorModel } = options return tool({ description: DELEGATE_TASK_DESCRIPTION, @@ -203,9 +213,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini } const runInBackground = args.run_in_background === true + const categorySkills = args.category + ? getCategorySkills(args.category, userCategorySkills) + : [] + const mergedSkills = [...new Set([...categorySkills, ...args.skills])] + let skillContent: string | undefined - if (args.skills.length > 0) { - const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig }) + if (mergedSkills.length > 0) { + const { resolved, notFound } = await resolveMultipleSkillsAsync(mergedSkills, { gitMasterConfig }) if (notFound.length > 0) { const allSkills = await discoverSkills({ includeClaudeCodePaths: true }) const available = allSkills.map(s => s.name).join(", ") @@ -529,7 +544,7 @@ ${textContent || "(No text output)"}` parentModel, parentAgent, model: categoryModel, - skills: args.skills.length > 0 ? args.skills : undefined, + skills: mergedSkills.length > 0 ? mergedSkills : undefined, skillContent: systemContent, }) @@ -593,7 +608,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id description: args.description, agent: agentToUse, isBackground: false, - skills: args.skills.length > 0 ? args.skills : undefined, + skills: mergedSkills.length > 0 ? mergedSkills : undefined, modelInfo, }) } diff --git a/src/tools/session-manager/tools.test.ts b/src/tools/session-manager/tools.test.ts index a44f7dbe74..b6168a9770 100644 --- a/src/tools/session-manager/tools.test.ts +++ b/src/tools/session-manager/tools.test.ts @@ -7,6 +7,8 @@ const mockContext: ToolContext = { messageID: "test-message", agent: "test-agent", abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, } describe("session-manager tools", () => { diff --git a/src/tools/skill-mcp/tools.test.ts b/src/tools/skill-mcp/tools.test.ts index a8184fe492..ccb1797d60 100644 --- a/src/tools/skill-mcp/tools.test.ts +++ b/src/tools/skill-mcp/tools.test.ts @@ -23,6 +23,8 @@ const mockContext = { messageID: "msg-1", agent: "test-agent", abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, } describe("skill_mcp tool", () => { diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts index 16a104ad87..7bae749a7d 100644 --- a/src/tools/skill/tools.test.ts +++ b/src/tools/skill/tools.test.ts @@ -40,6 +40,8 @@ const mockContext = { messageID: "msg-1", agent: "test-agent", abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, } describe("skill tool - MCP schema display", () => {