From 674d442db78afa016bf420bce63428f3292f6097 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Tue, 10 Feb 2026 09:28:51 +0000 Subject: [PATCH 1/2] feat: Add create-expert CLI and create-expert-skill MCP server - apps/create-expert: CLI that wraps startHandler to create/modify expert definitions - apps/create-expert-skill: MCP stdio skill with runExpert tool that spawns `perstack run` subprocess to test-run expert definitions - Export startHandler from perstack package with additionalEnv callback for injecting provider API keys into MCP skill environments without exposing them globally - Fix lazyInit bug in BaseSkillManager: suppress unhandled promise rejection and return empty tools for failed lazy-init skills - Add E2E tests for create-expert Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + apps/create-expert-skill/bin/server.ts | 22 + apps/create-expert-skill/package.json | 44 ++ apps/create-expert-skill/src/index.ts | 7 + .../src/lib/tool-result.ts | 11 + apps/create-expert-skill/src/server.ts | 26 ++ .../src/tools/run-expert.ts | 299 +++++++++++++ apps/create-expert-skill/tsconfig.json | 8 + apps/create-expert-skill/tsup.config.ts | 12 + apps/create-expert/bin/cli.ts | 43 ++ apps/create-expert/package.json | 40 ++ apps/create-expert/perstack.toml | 133 ++++++ apps/create-expert/tsconfig.json | 9 + apps/create-expert/tsup.config.ts | 10 + apps/perstack/package.json | 6 + apps/perstack/src/lib/context.ts | 3 +- apps/perstack/src/start.ts | 416 +++++++++--------- apps/perstack/tsup.config.ts | 1 + apps/runtime/src/skill-manager/base.ts | 14 +- e2e/create-expert/create-expert.test.ts | 145 ++++++ package.json | 1 + pnpm-lock.yaml | 62 +++ 22 files changed, 1112 insertions(+), 201 deletions(-) create mode 100644 apps/create-expert-skill/bin/server.ts create mode 100644 apps/create-expert-skill/package.json create mode 100644 apps/create-expert-skill/src/index.ts create mode 100644 apps/create-expert-skill/src/lib/tool-result.ts create mode 100644 apps/create-expert-skill/src/server.ts create mode 100644 apps/create-expert-skill/src/tools/run-expert.ts create mode 100644 apps/create-expert-skill/tsconfig.json create mode 100644 apps/create-expert-skill/tsup.config.ts create mode 100644 apps/create-expert/bin/cli.ts create mode 100644 apps/create-expert/package.json create mode 100644 apps/create-expert/perstack.toml create mode 100644 apps/create-expert/tsconfig.json create mode 100644 apps/create-expert/tsup.config.ts create mode 100644 e2e/create-expert/create-expert.test.ts diff --git a/.gitignore b/.gitignore index e71da43c..4b1e4500 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ perstack.lock perstack.toml !examples/**/perstack.toml !benchmarks/**/perstack.toml +!apps/create-expert/perstack.toml perstack/ !apps/perstack Thumbs.db diff --git a/apps/create-expert-skill/bin/server.ts b/apps/create-expert-skill/bin/server.ts new file mode 100644 index 00000000..506fd726 --- /dev/null +++ b/apps/create-expert-skill/bin/server.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { Command } from "commander" +import packageJson from "../package.json" with { type: "json" } +import { createCreateExpertSkillServer } from "../src/server.js" + +async function main() { + const program = new Command() + program + .name(packageJson.name) + .description(packageJson.description) + .version(packageJson.version, "-v, --version", "display the version number") + .action(async () => { + const server = createCreateExpertSkillServer() + const transport = new StdioServerTransport() + console.error("Running @perstack/create-expert-skill version", packageJson.version) + await server.connect(transport) + }) + program.parse() +} +main() diff --git a/apps/create-expert-skill/package.json b/apps/create-expert-skill/package.json new file mode 100644 index 00000000..e9bfff5d --- /dev/null +++ b/apps/create-expert-skill/package.json @@ -0,0 +1,44 @@ +{ + "name": "@perstack/create-expert-skill", + "version": "0.0.1", + "description": "MCP skill for testing Perstack expert definitions", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "@perstack/create-expert-skill": "dist/bin/server.js" + }, + "exports": { + ".": "./dist/src/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm run clean && tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", + "commander": "^14.0.2", + "ts-dedent": "^2.2.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.0.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/apps/create-expert-skill/src/index.ts b/apps/create-expert-skill/src/index.ts new file mode 100644 index 00000000..2f44b6df --- /dev/null +++ b/apps/create-expert-skill/src/index.ts @@ -0,0 +1,7 @@ +export { + createCreateExpertSkillServer, + registerAllTools, + SKILL_NAME, + SKILL_VERSION, +} from "./server.js" +export { registerRunExpert, runExpert } from "./tools/run-expert.js" diff --git a/apps/create-expert-skill/src/lib/tool-result.ts b/apps/create-expert-skill/src/lib/tool-result.ts new file mode 100644 index 00000000..6b5d0ab7 --- /dev/null +++ b/apps/create-expert-skill/src/lib/tool-result.ts @@ -0,0 +1,11 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" + +export function successToolResult(result: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(result) }] } +} + +export function errorToolResult(e: Error): CallToolResult { + return { + content: [{ type: "text", text: JSON.stringify({ error: e.name, message: e.message }) }], + } +} diff --git a/apps/create-expert-skill/src/server.ts b/apps/create-expert-skill/src/server.ts new file mode 100644 index 00000000..5321defa --- /dev/null +++ b/apps/create-expert-skill/src/server.ts @@ -0,0 +1,26 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import packageJson from "../package.json" with { type: "json" } +import { registerRunExpert } from "./tools/run-expert.js" + +export const SKILL_NAME = packageJson.name +export const SKILL_VERSION = packageJson.version + +export function registerAllTools(server: McpServer): void { + registerRunExpert(server) +} + +export function createCreateExpertSkillServer(): McpServer { + const server = new McpServer( + { + name: SKILL_NAME, + version: SKILL_VERSION, + }, + { + capabilities: { + tools: {}, + }, + }, + ) + registerAllTools(server) + return server +} diff --git a/apps/create-expert-skill/src/tools/run-expert.ts b/apps/create-expert-skill/src/tools/run-expert.ts new file mode 100644 index 00000000..ca34a74d --- /dev/null +++ b/apps/create-expert-skill/src/tools/run-expert.ts @@ -0,0 +1,299 @@ +import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import path from "node:path" +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { dedent } from "ts-dedent" +import { z } from "zod/v4" +import { errorToolResult, successToolResult } from "../lib/tool-result.js" + +const MAX_CONTENT_LENGTH = 500 + +const PROVIDER_ENV_MAP: Record = { + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_GENERATIVE_AI_API_KEY", + openai: "OPENAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + "azure-openai": "AZURE_API_KEY", + "amazon-bedrock": "AWS_ACCESS_KEY_ID", + "google-vertex": "GOOGLE_APPLICATION_CREDENTIALS", + ollama: undefined, +} + +function truncate(text: string, max = MAX_CONTENT_LENGTH): string { + if (text.length <= max) return text + return `${text.slice(0, max)}... [truncated]` +} + +function resolvePerstackCommand(): { command: string; baseArgs: string[] } { + const monorepoCliPath = path.resolve(process.cwd(), "apps/perstack/bin/cli.ts") + if (existsSync(monorepoCliPath)) { + return { command: "npx", baseArgs: ["tsx", monorepoCliPath] } + } + return { command: "npx", baseArgs: ["-y", "perstack"] } +} + +type Activity = + | { type: "startRun"; query: string } + | { type: "callTools"; tools: { name: string; input: string }[] } + | { type: "resolveToolResults"; results: { name: string; output: string }[] } + | { type: "completeRun"; text: string } + | { type: "error"; message: string } + +interface RunExpertInput { + configPath: string + expertKey: string + query: string + provider: string + model?: string + timeout: number + maxSteps?: number +} + +interface RunExpertOutput { + status: "completed" | "error" | "timeout" + activities: Activity[] + usage: { inputTokens: number; outputTokens: number; totalTokens: number } +} + +function parseEventLine(line: string): Record | null { + try { + const data = JSON.parse(line) + if (data && typeof data.type === "string") { + return data as Record + } + } catch { + // Not a JSON line + } + return null +} + +function extractActivities(events: Record[]): Activity[] { + const activities: Activity[] = [] + + for (const event of events) { + switch (event.type) { + case "startRun": { + const inputMessages = event.inputMessages as Array<{ + type: string + contents?: Array<{ type: string; text?: string }> + }> + const queryParts = (inputMessages ?? []) + .filter((m) => m.type === "userMessage") + .flatMap((m) => m.contents ?? []) + .filter((c) => c.type === "textPart" && c.text) + .map((c) => c.text as string) + activities.push({ + type: "startRun", + query: truncate(queryParts.join(" ") || "[no query text]"), + }) + break + } + case "callTools": { + const toolCalls = event.toolCalls as Array<{ + skillName: string + toolName: string + args: Record + }> + activities.push({ + type: "callTools", + tools: (toolCalls ?? []).map((tc) => ({ + name: `${tc.skillName}/${tc.toolName}`, + input: truncate(JSON.stringify(tc.args)), + })), + }) + break + } + case "resolveToolResults": { + const toolResults = event.toolResults as Array<{ + skillName: string + toolName: string + result: Array<{ type: string; text?: string }> + }> + activities.push({ + type: "resolveToolResults", + results: (toolResults ?? []).map((tr) => ({ + name: `${tr.skillName}/${tr.toolName}`, + output: truncate( + (tr.result ?? []) + .filter((p) => p.type === "textPart" && p.text) + .map((p) => p.text as string) + .join(" ") || "[no text output]", + ), + })), + }) + break + } + case "completeRun": { + activities.push({ + type: "completeRun", + text: truncate((event.text as string) ?? ""), + }) + break + } + case "stopRunByError": { + const error = event.error as { message?: string } | undefined + activities.push({ + type: "error", + message: error?.message ?? "Unknown error", + }) + break + } + } + } + + return activities +} + +export async function runExpert(input: RunExpertInput): Promise { + const { command, baseArgs } = resolvePerstackCommand() + + const args = [ + ...baseArgs, + "run", + "--config", + input.configPath, + "--filter", + "startRun,callTools,resolveToolResults,completeRun,stopRunByError", + "--provider", + input.provider, + ] + + if (input.model) { + args.push("--model", input.model) + } + + if (input.timeout) { + args.push("--timeout", String(input.timeout)) + } + + if (input.maxSteps) { + args.push("--max-steps", String(input.maxSteps)) + } + + args.push(input.expertKey, input.query) + + // Map PROVIDER_API_KEY to the provider-specific env var + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value) env[key] = value + } + const providerEnvKey = PROVIDER_ENV_MAP[input.provider] + if (providerEnvKey && process.env.PROVIDER_API_KEY) { + env[providerEnvKey] = process.env.PROVIDER_API_KEY + } + + return new Promise((resolve) => { + let stdout = "" + let stderr = "" + let timedOut = false + + const proc = spawn(command, args, { + env, + stdio: ["pipe", "pipe", "pipe"], + }) + + const timer = setTimeout(() => { + timedOut = true + proc.kill("SIGTERM") + }, input.timeout + 10_000) + + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + proc.on("close", (code) => { + clearTimeout(timer) + + const events: Record[] = [] + for (const line of stdout.split("\n")) { + const event = parseEventLine(line.trim()) + if (event) events.push(event) + } + + const activities = extractActivities(events) + + // Aggregate usage from completeRun events + const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 } + for (const event of events) { + if (event.type === "completeRun") { + const u = event.usage as + | { inputTokens?: number; outputTokens?: number; totalTokens?: number } + | undefined + if (u) { + usage.inputTokens += u.inputTokens ?? 0 + usage.outputTokens += u.outputTokens ?? 0 + usage.totalTokens += u.totalTokens ?? 0 + } + } + } + + if (timedOut) { + activities.push({ type: "error", message: `Process timed out after ${input.timeout}ms` }) + resolve({ status: "timeout", activities, usage }) + } else if (code !== 0) { + const hasError = activities.some((a) => a.type === "error") + if (!hasError) { + activities.push({ + type: "error", + message: truncate(stderr || `Process exited with code ${code}`), + }) + } + resolve({ status: "error", activities, usage }) + } else { + resolve({ status: "completed", activities, usage }) + } + }) + + proc.on("error", (err) => { + clearTimeout(timer) + resolve({ + status: "error", + activities: [{ type: "error", message: err.message }], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }) + }) + }) +} + +export function registerRunExpert(server: McpServer) { + server.registerTool( + "runExpert", + { + title: "Run Expert", + description: dedent` + Test-run a Perstack expert definition by executing it with a query. + Spawns a perstack run process, captures events, and returns summarized activities. + Use this after creating or modifying an expert in perstack.toml to verify it works. + `, + inputSchema: { + configPath: z.string().describe("Absolute path to the perstack.toml file to test"), + expertKey: z.string().describe("Expert key to run from the config"), + query: z.string().describe("Query/prompt to send to the expert"), + provider: z + .string() + .optional() + .default("anthropic") + .describe("LLM provider name (default: anthropic)"), + model: z.string().optional().describe("Model name (optional, uses config default)"), + timeout: z + .number() + .optional() + .default(120000) + .describe("Timeout in milliseconds (default: 120000)"), + maxSteps: z.number().optional().describe("Maximum steps (optional)"), + }, + }, + async (input: RunExpertInput) => { + try { + return successToolResult(await runExpert(input)) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} diff --git a/apps/create-expert-skill/tsconfig.json b/apps/create-expert-skill/tsconfig.json new file mode 100644 index 00000000..436ddcfe --- /dev/null +++ b/apps/create-expert-skill/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/create-expert-skill/tsup.config.ts b/apps/create-expert-skill/tsup.config.ts new file mode 100644 index 00000000..b04c1685 --- /dev/null +++ b/apps/create-expert-skill/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig, type Options } from "tsup" +import { baseConfig } from "../../tsup.config.js" + +export const createExpertSkillConfig: Options = { + ...baseConfig, + entry: { + "bin/server": "bin/server.ts", + "src/index": "src/index.ts", + }, +} + +export default defineConfig(createExpertSkillConfig) diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts new file mode 100644 index 00000000..5e86010f --- /dev/null +++ b/apps/create-expert/bin/cli.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs" +import { parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" +import { Command } from "commander" +import { startHandler } from "perstack/start" +import TOML from "smol-toml" + +const tomlPath = new URL("../perstack.toml", import.meta.url) +const config = parseWithFriendlyError( + perstackConfigSchema, + TOML.parse(readFileSync(tomlPath, "utf-8")), +) + +const PROVIDER_ENV_MAP: Record = { + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_GENERATIVE_AI_API_KEY", + openai: "OPENAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + "azure-openai": "AZURE_API_KEY", +} + +new Command() + .name("create-expert") + .description("Create and modify Perstack expert definitions") + .argument("", "Description of the expert to create or modify") + .action(async (query: string) => { + await startHandler( + "expert", + query, + {}, + { + perstackConfig: config, + additionalEnv: (env) => { + const provider = config.provider?.providerName ?? "anthropic" + const envKey = PROVIDER_ENV_MAP[provider] + const value = envKey ? env[envKey] : undefined + return value ? { PROVIDER_API_KEY: value } : ({} as Record) + }, + }, + ) + }) + .parse() diff --git a/apps/create-expert/package.json b/apps/create-expert/package.json new file mode 100644 index 00000000..6d0f2362 --- /dev/null +++ b/apps/create-expert/package.json @@ -0,0 +1,40 @@ +{ + "name": "create-expert", + "version": "0.0.12", + "description": "Create and modify Perstack expert definitions", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "bin": { + "create-expert": "bin/cli.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "create-expert": "dist/bin/cli.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm run clean && tsup --config ./tsup.config.ts && cp perstack.toml dist/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@perstack/core": "workspace:*", + "commander": "^14.0.2", + "perstack": "workspace:*", + "smol-toml": "^1.6.0" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.0.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/apps/create-expert/perstack.toml b/apps/create-expert/perstack.toml new file mode 100644 index 00000000..d84c015a --- /dev/null +++ b/apps/create-expert/perstack.toml @@ -0,0 +1,133 @@ +model = "claude-sonnet-4-5" + +[provider] +providerName = "anthropic" + +[experts."expert"] +version = "1.0.0" +description = "Creates and modifies Perstack expert definitions in perstack.toml" +instruction = """ +You are an expert builder for Perstack. Your job is to create and modify expert definitions in perstack.toml files. + +## perstack.toml Schema + +A perstack.toml file defines experts and their configuration. Here is the complete schema: + +```toml +# Optional: default model for all experts +model = "claude-sonnet-4-5" + +# Optional: default provider configuration +[provider] +providerName = "anthropic" # or "openai", "google", etc. + +# Optional: paths to environment files +envPath = [".env", ".env.local"] + +# Optional: global settings +# maxSteps = 100 +# maxRetries = 5 +# timeout = 300000 + +# Expert definitions - each expert is a key under [experts] +[experts."expert-name"] +version = "1.0.0" +description = "A brief description of what this expert does" +instruction = \"\"\" +Detailed instructions for the expert. This is the system prompt that guides the expert's behavior. +\"\"\" +# Optional: delegate to other experts +# delegates = ["other-expert-name"] +# Optional: tags for categorization +# tags = ["tag1", "tag2"] + +# Skills give experts access to tools via MCP servers +[experts."expert-name".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +# Optional: only include specific tools +pick = ["readTextFile", "writeTextFile", "listDirectory", "think", "attemptCompletion"] +# Optional: exclude specific tools (mutually exclusive with pick) +# omit = ["exec"] + +# Custom MCP skill example +# [experts."expert-name".skills."custom-mcp"] +# type = "mcpStdioSkill" +# description = "Description of this skill" +# command = "npx" +# args = ["-y", "some-mcp-server"] +# requiredEnv = ["API_KEY"] +# rule = "Instructions for using this skill" +# lazyInit = false +``` + +## Available @perstack/base Tools + +The `@perstack/base` package provides these tools: +- `readTextFile` - Read text files (with optional line range) +- `writeTextFile` - Write/overwrite text files +- `appendTextFile` - Append text to files +- `editTextFile` - Edit specific lines in files +- `listDirectory` - List directory contents +- `getFileInfo` - Get file/directory metadata +- `createDirectory` - Create directories +- `deleteDirectory` - Delete directories +- `deleteFile` - Delete files +- `moveFile` - Move/rename files +- `readImageFile` - Read image files +- `readPdfFile` - Read PDF files +- `exec` - Execute shell commands +- `think` - Internal reasoning (no side effects) +- `attemptCompletion` - Signal task completion +- `todo` - Manage a todo list +- `clearTodo` - Clear all todos + +## Your Workflow + +1. First, check if a `perstack.toml` already exists in the current directory using `readTextFile` +2. If it exists, read and understand the current configuration +3. Based on the user's request, create or modify the expert definition +4. Write the updated perstack.toml using `writeTextFile` +5. Preserve all existing content when modifying (do not remove existing experts unless asked) +6. After writing, test-run the expert using `runExpert` to verify it works +7. Review the activities: check that expected tools were called and the completion text is reasonable +8. If the test run shows errors or unexpected behavior, fix the perstack.toml and re-test +9. Use `attemptCompletion` when the expert is created and verified + +## Testing with runExpert + +After writing a perstack.toml file, always test-run the expert you created: +- Use the absolute path to the perstack.toml you just wrote as `configPath` (use the current working directory path) +- Use the expert key you defined as `expertKey` +- Choose a simple, realistic query that exercises the expert's core functionality +- Review the activities: check that expected tools were called and the completion text is reasonable +- If the run fails or produces errors, fix the perstack.toml and re-test + +## Important Rules + +- Always produce valid TOML syntax +- Use triple-quoted strings (\"\"\" \"\"\") for multi-line instructions +- Expert keys should be kebab-case (e.g., "my-expert-name") +- Always include `version`, `description`, and `instruction` for each expert +- Always include at least `attemptCompletion` in the skills pick list +- Choose appropriate tools based on what the expert needs to do +- If the expert needs to read/write files, include file operation tools +- If the expert needs to run commands, include `exec` +- Include `think` for experts that need complex reasoning +""" + +[experts."expert".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["readTextFile", "writeTextFile", "listDirectory", "getFileInfo", "think", "attemptCompletion"] + +[experts."expert".skills."create-expert-skill"] +type = "mcpStdioSkill" +description = "Test-run expert definitions to verify they work correctly" +command = "npx" +args = ["tsx", "./apps/create-expert-skill/bin/server.ts"] +requiredEnv = ["PROVIDER_API_KEY"] +lazyInit = true +rule = "After creating or modifying an expert in perstack.toml, use runExpert to test it with a simple query. Review the activities to verify correctness." diff --git a/apps/create-expert/tsconfig.json b/apps/create-expert/tsconfig.json new file mode 100644 index 00000000..6b253e9b --- /dev/null +++ b/apps/create-expert/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/create-expert/tsup.config.ts b/apps/create-expert/tsup.config.ts new file mode 100644 index 00000000..a0d09ce0 --- /dev/null +++ b/apps/create-expert/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup" +import { baseConfig } from "../../tsup.config.js" + +export default defineConfig({ + ...baseConfig, + dts: false, + entry: { + "bin/cli": "bin/cli.ts", + }, +}) diff --git a/apps/perstack/package.json b/apps/perstack/package.json index e0c0c38c..c43262ed 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -5,8 +5,14 @@ "author": "Wintermute Technologies, Inc.", "license": "Apache-2.0", "type": "module", + "exports": { + "./start": "./src/start.ts" + }, "publishConfig": { "access": "public", + "exports": { + "./start": "./dist/src/start.js" + }, "bin": { "perstack": "dist/bin/cli.js" } diff --git a/apps/perstack/src/lib/context.ts b/apps/perstack/src/lib/context.ts index 8f7393e8..8fcc86bb 100644 --- a/apps/perstack/src/lib/context.ts +++ b/apps/perstack/src/lib/context.ts @@ -27,10 +27,11 @@ export type ResolveRunContextInput = { continueJob?: string resumeFrom?: string expertKey?: string + perstackConfig?: PerstackConfig } export async function resolveRunContext(input: ResolveRunContextInput): Promise { - const perstackConfig = await getPerstackConfig(input.configPath) + const perstackConfig = input.perstackConfig ?? (await getPerstackConfig(input.configPath)) let checkpoint: Checkpoint | undefined if (input.resumeFrom) { if (!input.continueJob) { diff --git a/apps/perstack/src/start.ts b/apps/perstack/src/start.ts index 2557764a..1ab07b5c 100644 --- a/apps/perstack/src/start.ts +++ b/apps/perstack/src/start.ts @@ -2,6 +2,7 @@ import { createId } from "@paralleldrive/cuid2" import { defaultMaxRetries, defaultTimeout, + type PerstackConfig, parseWithFriendlyError, startCommandInputSchema, } from "@perstack/core" @@ -31,6 +32,221 @@ import type { CheckpointHistoryItem, JobHistoryItem } from "./tui/types/index.js const CONTINUE_TIMEOUT_MS = 60_000 +export interface StartHandlerOptions { + perstackConfig?: PerstackConfig + additionalEnv?: (env: Record) => Record +} + +export async function startHandler( + expertKey: string | undefined, + query: string | undefined, + options: Record, + handlerOptions?: StartHandlerOptions, +): Promise { + const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) + + try { + // Phase 1: Initialize context + const { perstackConfig, checkpoint, env, providerConfig, model, experts } = + await resolveRunContext({ + configPath: input.options.config, + provider: input.options.provider, + model: input.options.model, + envPath: input.options.envPath, + continue: input.options.continue, + continueJob: input.options.continueJob, + resumeFrom: input.options.resumeFrom, + expertKey: input.expertKey, + perstackConfig: handlerOptions?.perstackConfig, + }) + + if (handlerOptions?.additionalEnv) { + Object.assign(env, handlerOptions.additionalEnv(env)) + } + + const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps + const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries + const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout + + // Prepare expert lists + const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ + key, + name: key, + })) + const recentExperts = getRecentExperts(10) + + // Prepare history jobs (only if browsing is needed) + const showHistory = !input.expertKey && !input.query && !checkpoint + const historyJobs: JobHistoryItem[] = showHistory + ? getAllJobs().map((j) => ({ + jobId: j.id, + status: j.status, + expertKey: j.coordinatorExpertKey, + totalSteps: j.totalSteps, + startedAt: j.startedAt, + finishedAt: j.finishedAt, + })) + : [] + + // Phase 2: Selection - get expert, query, and optional checkpoint + const selection = await renderSelection({ + showHistory, + initialExpertKey: input.expertKey, + initialQuery: input.query, + initialCheckpoint: checkpoint + ? { + id: checkpoint.id, + jobId: checkpoint.jobId, + runId: checkpoint.runId, + stepNumber: checkpoint.stepNumber, + contextWindowUsage: checkpoint.contextWindowUsage ?? 0, + } + : undefined, + configuredExperts, + recentExperts, + historyJobs, + onLoadCheckpoints: async (j: JobHistoryItem): Promise => { + const checkpoints = getCheckpointsWithDetails(j.jobId) + return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) + }, + }) + + // Validate selection + if (!selection.expertKey) { + console.error("Expert key is required") + return + } + if (!selection.query && !selection.checkpoint) { + console.error("Query is required") + return + } + + // Resolve checkpoint if selected from TUI + let currentCheckpoint = selection.checkpoint + ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) + : checkpoint + + if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { + console.error( + `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, + ) + return + } + + // Load lockfile if present + const lockfilePath = findLockfile() + const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined + + // Phase 3: Execution loop + let currentQuery: string | null = selection.query + let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() + // Track if the next query should be treated as an interactive tool result + let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false + + // Track whether this is the first iteration (for historical events display) + // On first iteration, load all events for the job up to the checkpoint + // On subsequent iterations, skip historical events (previous TUI already displayed them) + let isFirstIteration = true + const initialHistoricalEvents: ReturnType | undefined = + currentCheckpoint + ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) + : undefined + + while (currentQuery !== null) { + // Only pass historical events on first iteration + // Subsequent iterations: previous TUI output remains on screen + const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined + + // Generate a new runId for each iteration + const runId = createId() + + // Start execution TUI + const { result: executionResult, eventListener } = renderExecution({ + expertKey: selection.expertKey, + query: currentQuery, + config: { + runtimeVersion, + model, + maxSteps, + maxRetries, + timeout, + contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, + }, + continueTimeoutMs: CONTINUE_TIMEOUT_MS, + historicalEvents, + }) + + // Run the expert + const runResult = await perstackRun( + { + setting: { + jobId: currentJobId, + runId, + expertKey: selection.expertKey, + input: + isNextQueryInteractiveToolResult && currentCheckpoint + ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) + : { text: currentQuery }, + experts, + model, + providerConfig, + reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, + maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, + maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, + timeout: input.options.timeout ?? perstackConfig.timeout, + perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, + perstackApiKey: env.PERSTACK_API_KEY, + perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, + env, + verbose: input.options.verbose, + }, + checkpoint: currentCheckpoint, + }, + { + eventListener, + storeCheckpoint: defaultStoreCheckpoint, + storeEvent: defaultStoreEvent, + retrieveCheckpoint: defaultRetrieveCheckpoint, + storeJob, + retrieveJob, + createJob: createInitialJob, + lockfile, + }, + ) + + // Wait for execution TUI to complete (user input or timeout) + const result = await executionResult + + // Check if user wants to continue + const canContinue = + runResult.status === "completed" || + runResult.status === "stoppedByExceededMaxSteps" || + runResult.status === "stoppedByError" || + runResult.status === "stoppedByInteractiveTool" + + if (result.nextQuery && canContinue) { + currentQuery = result.nextQuery + currentCheckpoint = runResult + currentJobId = runResult.jobId + + // If the run stopped for interactive tool, the next query is an interactive tool result + isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" + + // Mark first iteration as complete (subsequent TUIs won't show historical events) + isFirstIteration = false + } else { + currentQuery = null + } + } + } catch (error) { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(error) + } + } +} + export const startCommand = new Command() .command("start") .description("Start Perstack with interactive TUI") @@ -67,202 +283,4 @@ export const startCommand = new Command() "Resume from a specific checkpoint (requires --continue or --continue-job)", ) .option("-i, --interactive-tool-call-result", "Query is interactive tool call result") - .action(async (expertKey, query, options) => { - const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) - - try { - // Phase 1: Initialize context - const { perstackConfig, checkpoint, env, providerConfig, model, experts } = - await resolveRunContext({ - configPath: input.options.config, - provider: input.options.provider, - model: input.options.model, - envPath: input.options.envPath, - continue: input.options.continue, - continueJob: input.options.continueJob, - resumeFrom: input.options.resumeFrom, - expertKey: input.expertKey, - }) - - const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps - const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries - const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout - - // Prepare expert lists - const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ - key, - name: key, - })) - const recentExperts = getRecentExperts(10) - - // Prepare history jobs (only if browsing is needed) - const showHistory = !input.expertKey && !input.query && !checkpoint - const historyJobs: JobHistoryItem[] = showHistory - ? getAllJobs().map((j) => ({ - jobId: j.id, - status: j.status, - expertKey: j.coordinatorExpertKey, - totalSteps: j.totalSteps, - startedAt: j.startedAt, - finishedAt: j.finishedAt, - })) - : [] - - // Phase 2: Selection - get expert, query, and optional checkpoint - const selection = await renderSelection({ - showHistory, - initialExpertKey: input.expertKey, - initialQuery: input.query, - initialCheckpoint: checkpoint - ? { - id: checkpoint.id, - jobId: checkpoint.jobId, - runId: checkpoint.runId, - stepNumber: checkpoint.stepNumber, - contextWindowUsage: checkpoint.contextWindowUsage ?? 0, - } - : undefined, - configuredExperts, - recentExperts, - historyJobs, - onLoadCheckpoints: async (j: JobHistoryItem): Promise => { - const checkpoints = getCheckpointsWithDetails(j.jobId) - return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) - }, - }) - - // Validate selection - if (!selection.expertKey) { - console.error("Expert key is required") - return - } - if (!selection.query && !selection.checkpoint) { - console.error("Query is required") - return - } - - // Resolve checkpoint if selected from TUI - let currentCheckpoint = selection.checkpoint - ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) - : checkpoint - - if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { - console.error( - `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, - ) - return - } - - // Load lockfile if present - const lockfilePath = findLockfile() - const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined - - // Phase 3: Execution loop - let currentQuery: string | null = selection.query - let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() - // Track if the next query should be treated as an interactive tool result - let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false - - // Track whether this is the first iteration (for historical events display) - // On first iteration, load all events for the job up to the checkpoint - // On subsequent iterations, skip historical events (previous TUI already displayed them) - let isFirstIteration = true - const initialHistoricalEvents: ReturnType | undefined = - currentCheckpoint - ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) - : undefined - - while (currentQuery !== null) { - // Only pass historical events on first iteration - // Subsequent iterations: previous TUI output remains on screen - const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined - - // Generate a new runId for each iteration - const runId = createId() - - // Start execution TUI - const { result: executionResult, eventListener } = renderExecution({ - expertKey: selection.expertKey, - query: currentQuery, - config: { - runtimeVersion, - model, - maxSteps, - maxRetries, - timeout, - contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, - }, - continueTimeoutMs: CONTINUE_TIMEOUT_MS, - historicalEvents, - }) - - // Run the expert - const runResult = await perstackRun( - { - setting: { - jobId: currentJobId, - runId, - expertKey: selection.expertKey, - input: - isNextQueryInteractiveToolResult && currentCheckpoint - ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) - : { text: currentQuery }, - experts, - model, - providerConfig, - reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, - maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, - maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, - timeout: input.options.timeout ?? perstackConfig.timeout, - perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, - perstackApiKey: env.PERSTACK_API_KEY, - perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, - env, - verbose: input.options.verbose, - }, - checkpoint: currentCheckpoint, - }, - { - eventListener, - storeCheckpoint: defaultStoreCheckpoint, - storeEvent: defaultStoreEvent, - retrieveCheckpoint: defaultRetrieveCheckpoint, - storeJob, - retrieveJob, - createJob: createInitialJob, - lockfile, - }, - ) - - // Wait for execution TUI to complete (user input or timeout) - const result = await executionResult - - // Check if user wants to continue - const canContinue = - runResult.status === "completed" || - runResult.status === "stoppedByExceededMaxSteps" || - runResult.status === "stoppedByError" || - runResult.status === "stoppedByInteractiveTool" - - if (result.nextQuery && canContinue) { - currentQuery = result.nextQuery - currentCheckpoint = runResult - currentJobId = runResult.jobId - - // If the run stopped for interactive tool, the next query is an interactive tool result - isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" - - // Mark first iteration as complete (subsequent TUIs won't show historical events) - isFirstIteration = false - } else { - currentQuery = null - } - } - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - } - }) + .action((expertKey, query, options) => startHandler(expertKey, query, options)) diff --git a/apps/perstack/tsup.config.ts b/apps/perstack/tsup.config.ts index ffbdb6ec..72a007a2 100644 --- a/apps/perstack/tsup.config.ts +++ b/apps/perstack/tsup.config.ts @@ -6,6 +6,7 @@ export const cliConfig: Options = { dts: false, entry: { "bin/cli": "bin/cli.ts", + "src/start": "src/start.ts", }, // Bundle private packages that won't be published to npm noExternal: ["@perstack/tui-components"], diff --git a/apps/runtime/src/skill-manager/base.ts b/apps/runtime/src/skill-manager/base.ts index 211dfd9e..4839dbe1 100644 --- a/apps/runtime/src/skill-manager/base.ts +++ b/apps/runtime/src/skill-manager/base.ts @@ -53,6 +53,10 @@ export abstract class BaseSkillManager { this._initializing = undefined throw error } + } else { + // Prevent unhandled promise rejection for lazy-initialized skills. + // The error will be surfaced when getToolDefinitions() or callTool() awaits _initializing. + initPromise.catch(() => {}) } } @@ -73,7 +77,15 @@ export abstract class BaseSkillManager { async getToolDefinitions(): Promise { // If initialization is in progress, wait for it to complete if (!this.isInitialized() && this._initializing) { - await this._initializing + try { + await this._initializing + } catch { + // Lazy-initialized skill failed to init; treat as unavailable + if (this.lazyInit) { + return [] + } + throw new Error(`Skill ${this.name} failed to initialize`) + } } if (!this.isInitialized()) { throw new Error(`Skill ${this.name} is not initialized`) diff --git a/e2e/create-expert/create-expert.test.ts b/e2e/create-expert/create-expert.test.ts new file mode 100644 index 00000000..fdc1cd03 --- /dev/null +++ b/e2e/create-expert/create-expert.test.ts @@ -0,0 +1,145 @@ +import { spawn } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import TOML from "smol-toml" +import { afterEach, describe, expect, it } from "vitest" +import { assertEventSequenceContains } from "../lib/assertions.js" +import { parseEvents } from "../lib/event-parser.js" +import { injectProviderArgs } from "../lib/round-robin.js" + +const LLM_TIMEOUT = 120_000 +const PROJECT_ROOT = path.resolve(process.cwd()) +const CONFIG_PATH = path.join(PROJECT_ROOT, "apps/create-expert/perstack.toml") + +function runCreateExpert( + query: string, + cwd: string, + timeout = LLM_TIMEOUT, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const args = injectProviderArgs(["run", "--config", CONFIG_PATH, "expert", query]) + return new Promise((resolve, reject) => { + let stdout = "" + let stderr = "" + const proc = spawn( + "npx", + ["tsx", path.join(PROJECT_ROOT, "apps/perstack/bin/cli.ts"), ...args], + { + cwd, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + const timer = setTimeout(() => { + proc.kill("SIGTERM") + reject(new Error(`Timeout after ${timeout}ms`)) + }, timeout) + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString() + }) + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString() + }) + proc.on("close", (code) => { + clearTimeout(timer) + resolve({ stdout, stderr, exitCode: code ?? 0 }) + }) + proc.on("error", (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +describe.concurrent("create-expert", () => { + const tempDirs: string[] = [] + + function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "create-expert-")) + tempDirs.push(dir) + return dir + } + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }) + } + tempDirs.length = 0 + }) + + it( + "should create a new perstack.toml", + async () => { + const tempDir = createTempDir() + + const result = await runCreateExpert( + "Create a simple hello-world expert that greets the user", + tempDir, + ) + + expect(result.exitCode).toBe(0) + + const events = parseEvents(result.stdout) + expect(assertEventSequenceContains(events, ["startRun", "completeRun"]).passed).toBe(true) + + // Verify perstack.toml was created + const tomlPath = path.join(tempDir, "perstack.toml") + expect(fs.existsSync(tomlPath)).toBe(true) + + // Verify it's valid TOML with expert definitions + const tomlContent = fs.readFileSync(tomlPath, "utf-8") + const parsed = TOML.parse(tomlContent) + expect(parsed.experts).toBeDefined() + expect(Object.keys(parsed.experts as Record).length).toBeGreaterThanOrEqual( + 1, + ) + }, + LLM_TIMEOUT, + ) + + it( + "should modify an existing perstack.toml", + async () => { + const tempDir = createTempDir() + + // Create an existing perstack.toml with one expert + const existingToml = `model = "claude-sonnet-4-5" + +[provider] +providerName = "anthropic" + +[experts."existing-expert"] +version = "1.0.0" +description = "An existing expert" +instruction = "You are an existing expert." + +[experts."existing-expert".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion"] +` + fs.writeFileSync(path.join(tempDir, "perstack.toml"), existingToml) + + const result = await runCreateExpert("Add a testing expert that runs unit tests", tempDir) + + expect(result.exitCode).toBe(0) + + const events = parseEvents(result.stdout) + expect(assertEventSequenceContains(events, ["startRun", "completeRun"]).passed).toBe(true) + + // Verify perstack.toml was updated + const tomlPath = path.join(tempDir, "perstack.toml") + const tomlContent = fs.readFileSync(tomlPath, "utf-8") + const parsed = TOML.parse(tomlContent) + expect(parsed.experts).toBeDefined() + + const experts = parsed.experts as Record + // Original expert should be preserved + expect(experts["existing-expert"]).toBeDefined() + // New expert should be added + expect(Object.keys(experts).length).toBeGreaterThanOrEqual(2) + }, + LLM_TIMEOUT, + ) +}) diff --git a/package.json b/package.json index 8c5d9166..8b898f75 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "dotenv": "^17.2.3", "jsdom": "^27.4.0", "knip": "5.82.1", + "smol-toml": "^1.6.0", "tsup": "^8.5.1", "tsx": "^4.21.0", "turbo": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e30c7a57..9b75c488 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: knip: specifier: 5.82.1 version: 5.82.1(@types/node@25.0.10)(typescript@5.9.3) + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -94,6 +97,65 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + apps/create-expert: + dependencies: + '@perstack/core': + specifier: workspace:* + version: link:../../packages/core + commander: + specifier: ^14.0.2 + version: 14.0.2 + perstack: + specifier: workspace:* + version: link:../perstack + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.0.10 + version: 25.0.10 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + apps/create-expert-skill: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.25.3 + version: 1.25.3(hono@4.11.1)(zod@4.3.6) + commander: + specifier: ^14.0.2 + version: 14.0.2 + ts-dedent: + specifier: ^2.2.0 + version: 2.2.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.0.10 + version: 25.0.10 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + apps/perstack: dependencies: '@paralleldrive/cuid2': From 0686e68aefa805c2646d1d198e473fb405df167b Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Tue, 10 Feb 2026 09:38:02 +0000 Subject: [PATCH 2/2] chore: add changeset for create-expert and perstack Co-Authored-By: Claude Opus 4.6 --- .changeset/create-expert-skill.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/create-expert-skill.md diff --git a/.changeset/create-expert-skill.md b/.changeset/create-expert-skill.md new file mode 100644 index 00000000..722508ad --- /dev/null +++ b/.changeset/create-expert-skill.md @@ -0,0 +1,6 @@ +--- +"perstack": patch +"create-expert": patch +--- + +Add create-expert CLI and create-expert-skill MCP server for creating and test-running expert definitions