diff --git a/.pi/agents/developer.md b/.pi/agents/developer.md index c86272f..758794d 100644 --- a/.pi/agents/developer.md +++ b/.pi/agents/developer.md @@ -1,7 +1,7 @@ --- name: developer description: Implements assigned tasks. -model: openrouter/stepfun/step-3.5-flash:free +model: openrouter/free tools: read,edit,write,bash,grep,find,ls --- diff --git a/.pi/agents/pm.md b/.pi/agents/pm.md index 9d7f743..cbb03cb 100644 --- a/.pi/agents/pm.md +++ b/.pi/agents/pm.md @@ -1,7 +1,7 @@ --- name: pm description: Project manager who plans and delegates tasks in waves. -model: openrouter/stepfun/step-3.5-flash:free +model: openrouter/free tools: read,grep,find,ls --- diff --git a/.pi/agents/verifier.md b/.pi/agents/verifier.md index ae22b30..7945b32 100644 --- a/.pi/agents/verifier.md +++ b/.pi/agents/verifier.md @@ -1,7 +1,7 @@ --- name: verifier description: Reviews developer work and validates requirements. -model: openrouter/stepfun/step-3.5-flash:free +model: openrouter/free tools: read,grep,find,ls,bash --- diff --git a/.pi/extensions/workflow-orchestrator/agents.ts b/.pi/extensions/workflow-orchestrator/agents.ts index 3130c69..595471e 100644 --- a/.pi/extensions/workflow-orchestrator/agents.ts +++ b/.pi/extensions/workflow-orchestrator/agents.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent"; +import { getPackagePiRoot } from "./setup.js"; -export type AgentSource = "user" | "project"; +export type AgentSource = "user" | "project" | "package"; export interface AgentConfig { name: string; @@ -89,8 +90,12 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult { const projectAgentsDir = findNearestProjectAgentsDir(cwd); const userAgents = loadAgentsFromDir(userDir, "user"); const projectAgents = projectAgentsDir ? loadAgentsFromDir(projectAgentsDir, "project") : []; + const packageAgents = projectAgentsDir + ? [] + : loadAgentsFromDir(path.join(getPackagePiRoot(), "agents"), "package"); const agentMap = new Map(); + for (const agent of packageAgents) agentMap.set(agent.name, agent); for (const agent of userAgents) agentMap.set(agent.name, agent); for (const agent of projectAgents) agentMap.set(agent.name, agent); diff --git a/.pi/extensions/workflow-orchestrator/commands.ts b/.pi/extensions/workflow-orchestrator/commands.ts new file mode 100644 index 0000000..30373b0 --- /dev/null +++ b/.pi/extensions/workflow-orchestrator/commands.ts @@ -0,0 +1,127 @@ +import { DEFAULT_WORKFLOW_NAME } from "./setup.js"; +import { normalizeGoal } from "./utils.js"; + +export const WORKFLOW_COMMANDS = new Set([ + "start", + "resume", + "status", + "stop", + "stop-task", + "message", + "expand", + "collapse", + "help", +]); + +export interface ParsedWorkflowStart { + workflowName: string; + goal?: string; + model?: string; +} + +/** Tokenize command arguments while preserving quoted phrases as one token. */ +export function tokenizeWorkflowArgs(args: string): string[] { + const tokens: string[] = []; + let token = ""; + let quote: '"' | "'" | undefined; + let escaped = false; + + const pushToken = () => { + if (token) tokens.push(token); + token = ""; + }; + + for (const char of args) { + if (escaped) { + token += char; + escaped = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + + if (quote) { + if (char === quote) quote = undefined; + else token += char; + continue; + } + + if (char === '"' || (char === "'" && token.length === 0)) { + quote = char; + continue; + } + + if (/\s/.test(char)) pushToken(); + else token += char; + } + + if (escaped) token += "\\"; + pushToken(); + return tokens; +} + +export function isValidWorkflowName(name: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(name) && name.length > 0 && name.length <= 50; +} + +export function extractModelFlag(tokens: string[]): { tokens: string[]; model?: string } { + const stripped: string[] = []; + let model: string | undefined; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === "--model" && i + 1 < tokens.length) { + model = normalizeGoal(tokens[++i]); + continue; + } + if (token === "--model") continue; + stripped.push(token); + } + + return { tokens: stripped, model }; +} + +export function parseWorkflowStartArgs(tokens: string[]): ParsedWorkflowStart | null { + const { tokens: rest, model } = extractModelFlag(tokens.slice(1)); + if (rest.length === 0) return null; + + if (rest.length === 1) { + const token = rest[0]; + if (isValidWorkflowName(token)) { + return { workflowName: token, goal: undefined, model }; + } + return { + workflowName: DEFAULT_WORKFLOW_NAME, + goal: normalizeGoal(token), + model, + }; + } + + if (!isValidWorkflowName(rest[0])) { + return { + workflowName: DEFAULT_WORKFLOW_NAME, + goal: normalizeGoal(rest.join(" ")), + model, + }; + } + + return { + workflowName: rest[0], + goal: normalizeGoal(rest.slice(1).join(" ")), + model, + }; +} + +export function parseWorkflowShorthandGoal(tokens: string[]): { + goal?: string; + model?: string; +} { + const { tokens: stripped, model } = extractModelFlag(tokens); + return { + goal: normalizeGoal(stripped.join(" ")), + model, + }; +} diff --git a/.pi/extensions/workflow-orchestrator/config.ts b/.pi/extensions/workflow-orchestrator/config.ts index 598b494..6162293 100644 --- a/.pi/extensions/workflow-orchestrator/config.ts +++ b/.pi/extensions/workflow-orchestrator/config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; -import * as path from "node:path"; import { Type, type Static } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; +import { resolveWorkflowPath } from "./setup.js"; const TransitionSchema = Type.Object({ when: Type.Object({ @@ -110,10 +110,7 @@ function sanitizeWorkflowName(name: string): string { export function loadWorkflowConfig(cwd: string, name: string): LoadedWorkflow { const safeName = sanitizeWorkflowName(name); - const workflowPath = path.join(cwd, ".pi", "workflows", `${safeName}.workflow.json`); - if (!fs.existsSync(workflowPath)) { - throw new Error(`Workflow not found: ${workflowPath}`); - } + const workflowPath = resolveWorkflowPath(cwd, safeName); const raw = fs.readFileSync(workflowPath, "utf-8"); let parsed: unknown; diff --git a/.pi/extensions/workflow-orchestrator/index.ts b/.pi/extensions/workflow-orchestrator/index.ts index fe1db4e..2fa7559 100644 --- a/.pi/extensions/workflow-orchestrator/index.ts +++ b/.pi/extensions/workflow-orchestrator/index.ts @@ -8,6 +8,12 @@ import type { import { Type } from "@sinclair/typebox"; import { Text } from "@mariozechner/pi-tui"; import { discoverAgents, findAgentByName } from "./agents.js"; +import { + parseWorkflowShorthandGoal, + parseWorkflowStartArgs, + tokenizeWorkflowArgs, + WORKFLOW_COMMANDS, +} from "./commands.js"; import { loadWorkflowConfig, type WorkflowConfig, @@ -16,9 +22,11 @@ import { type WorkflowWave, } from "./config.js"; import { runTaskFlow } from "./engine.js"; +import { pickModel } from "./models.js"; import { setPmWidgetStatus, setTaskListExpanded, updateStatus } from "./render.js"; import { RpcAgent } from "./runner.js"; import { appendState, restoreState, type TaskState, type WorkflowState } from "./state.js"; +import { materializeProjectDefaults, resolveExtensionPaths } from "./setup.js"; import { extractJson, normalizeGoal } from "./utils.js"; interface WorkflowRunHandle { @@ -151,6 +159,23 @@ function sendWorkflowNotice(pi: ExtensionAPI, text: string) { }); } +function workflowErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error.trim()) return error.trim(); + return fallback; +} + +function reportWorkflowError( + pi: ExtensionAPI, + ctx: ExtensionContext, + error: unknown, + fallback = "Workflow failed", +): void { + const message = workflowErrorMessage(error, fallback); + sendWorkflowNotice(pi, `Workflow error: ${message}`); + if (ctx.hasUI) ctx.ui.notify(message, "error"); +} + function createClarificationToken(): string { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } @@ -314,6 +339,7 @@ function ensurePmSessionFile(state: WorkflowState): string { } function resolveAllowedExtensions( + cwd: string, agentName: string, config: WorkflowConfig, state?: WorkflowState, @@ -322,16 +348,18 @@ function resolveAllowedExtensions( // This supports custom agent roles beyond pm/developer/verifier const role = Object.entries(config.agents).find(([, name]) => name === agentName)?.[0]; + let extensions: string[] | undefined; if (role) { - return ( + extensions = state?.allowedExtensionsByAgent?.[role as keyof typeof state.allowedExtensionsByAgent] ?? config.allowedExtensionsByAgent?.[role as keyof typeof config.allowedExtensionsByAgent] ?? state?.allowedExtensions ?? - config.allowedExtensions - ); + config.allowedExtensions; + } else { + extensions = state?.allowedExtensions ?? config.allowedExtensions; } - return state?.allowedExtensions ?? config.allowedExtensions; + return resolveExtensionPaths(cwd, extensions); } function getRunnerKey(taskId: string, stageId: string): string { @@ -363,9 +391,9 @@ function getTaskRunner( cwd: ctx.cwd, sessionFile, systemPrompt: agent.systemPrompt, - model: agent.model, + model: pickModel(agent.model, currentState?.model, ctx.model?.id), tools: agent.tools, - allowedExtensions: resolveAllowedExtensions(agentName, config, currentState), + allowedExtensions: resolveAllowedExtensions(ctx.cwd, agentName, config, currentState), retry: config.agentRetry, }); @@ -739,9 +767,9 @@ function getPmRunner( cwd: ctx.cwd, sessionFile, systemPrompt: pmAgent.systemPrompt, - model: pmAgent.model, + model: pickModel(pmAgent.model, currentState?.model, ctx.model?.id), tools: pmAgent.tools, - allowedExtensions: resolveAllowedExtensions(pmAgent.name, config, currentState), + allowedExtensions: resolveAllowedExtensions(ctx.cwd, pmAgent.name, config, currentState), retry: config.agentRetry, }); return pmRunner; @@ -908,14 +936,26 @@ async function resumeWorkflow(pi: ExtensionAPI, ctx: ExtensionCommandContext): P return; } - const { config } = loadWorkflowConfig(ctx.cwd, currentState.workflowName); - const { agents } = discoverAgents(ctx.cwd); + let config: WorkflowConfig; + let agents: ReturnType["agents"]; + try { + ({ config } = loadWorkflowConfig(ctx.cwd, currentState.workflowName)); + materializeProjectDefaults(ctx.cwd); + ({ agents } = discoverAgents(ctx.cwd)); + } catch (error) { + reportWorkflowError(pi, ctx, error, "Unable to resume workflow"); + return; + } const effectiveConfig: WorkflowConfig = { ...config, goal: currentState.goal }; const abortController = new AbortController(); const runPromise = (async () => { try { - setState(pi, ctx, { ...currentState!, active: true }); + setState(pi, ctx, { + ...currentState!, + active: true, + model: pickModel(currentState?.model, ctx.model?.id), + }); sendWorkflowNotice(pi, "Workflow resumed."); let previousSummary = currentState?.previousSummary ?? ""; @@ -996,10 +1036,8 @@ async function resumeWorkflow(pi: ExtensionAPI, ctx: ExtensionCommandContext): P setState(pi, ctx, finalState); sendWorkflowNotice(pi, "Workflow completed."); if (ctx.hasUI) ctx.ui.notify("Workflow completed", "info"); - } catch (error: any) { - const message = error?.message || "Workflow failed"; - sendWorkflowNotice(pi, `Workflow error: ${message}`); - if (ctx.hasUI) ctx.ui.notify(message, "error"); + } catch (error) { + reportWorkflowError(pi, ctx, error); if (currentState) { setState(pi, ctx, { ...currentState, @@ -1023,14 +1061,25 @@ async function startWorkflow( ctx: ExtensionCommandContext, workflowName: string, goalOverride?: string, + commandModel?: string, ): Promise { if (currentRun) { if (ctx.hasUI) ctx.ui.notify("Workflow already running", "warning"); return; } - const { config } = loadWorkflowConfig(ctx.cwd, workflowName); - const { agents } = discoverAgents(ctx.cwd); + let config: WorkflowConfig; + let agents: ReturnType["agents"]; + let runModel: string | undefined; + try { + ({ config } = loadWorkflowConfig(ctx.cwd, workflowName)); + materializeProjectDefaults(ctx.cwd); + ({ agents } = discoverAgents(ctx.cwd)); + runModel = pickModel(commandModel, ctx.model?.id); + } catch (error) { + reportWorkflowError(pi, ctx, error, "Unable to start workflow"); + return; + } const effectiveConfig: WorkflowConfig = { ...config, goal: goalOverride ?? config.goal, @@ -1051,6 +1100,7 @@ async function startWorkflow( allowedExtensionsByAgent: effectiveConfig.allowedExtensionsByAgent, previousSummary: "", waveSummaries: [], + model: runModel, }; setState(pi, ctx, initialState); sendWorkflowNotice(pi, `Workflow started: ${effectiveConfig.goal}`); @@ -1111,10 +1161,8 @@ async function startWorkflow( setState(pi, ctx, finalState); sendWorkflowNotice(pi, "Workflow completed."); if (ctx.hasUI) ctx.ui.notify("Workflow completed", "info"); - } catch (error: any) { - const message = error?.message || "Workflow failed"; - sendWorkflowNotice(pi, `Workflow error: ${message}`); - if (ctx.hasUI) ctx.ui.notify(message, "error"); + } catch (error) { + reportWorkflowError(pi, ctx, error); if (currentState) { setState(pi, ctx, { ...currentState, @@ -1251,17 +1299,15 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("workflow", { description: "Manage workflow orchestrator", handler: async (args, ctx) => { - const tokens = (args || "").split(/\s+/).filter(Boolean); + const tokens = tokenizeWorkflowArgs(args || ""); const command = tokens[0]; - const name = tokens[1]; - const goalText = normalizeGoal(tokens.slice(2).join(" ")); if (!command || command === "help") { sendWorkflowNotice( pi, [ "Workflow commands:", - " /workflow start [goal]", + ' /workflow "Your goal"', " /workflow resume", " /workflow status", " /workflow stop", @@ -1269,26 +1315,41 @@ export default function (pi: ExtensionAPI) { " /workflow message ", " /workflow expand", " /workflow collapse", - "Example:", - ' /workflow start default "Build a Telegram bot"', ].join("\n"), ); return; } if (command === "start") { - if (!name) { - ctx.ui?.notify("Usage: /workflow start [goal]", "warning"); + const parsed = parseWorkflowStartArgs(tokens); + if (!parsed) { + ctx.ui?.notify('Usage: /workflow start [name] "goal"', "warning"); + return; + } + if (currentState && !currentState.active) { + ctx.ui?.notify("Existing workflow state found. Use /workflow resume.", "warning"); + return; + } + void startWorkflow(pi, ctx, parsed.workflowName, parsed.goal, parsed.model); + return; + } + + if (!WORKFLOW_COMMANDS.has(command)) { + const { goal, model } = parseWorkflowShorthandGoal(tokens); + if (!goal) { + ctx.ui?.notify('Usage: /workflow [--model ] "Your goal"', "warning"); return; } if (currentState && !currentState.active) { ctx.ui?.notify("Existing workflow state found. Use /workflow resume.", "warning"); return; } - void startWorkflow(pi, ctx, name, goalText); + void startWorkflow(pi, ctx, "default", goal, model); return; } + const name = tokens[1]; + if (command === "resume") { void resumeWorkflow(pi, ctx); return; @@ -1391,17 +1452,18 @@ export default function (pi: ExtensionAPI) { label: "Workflow Run", description: "Start a workflow by name (optional goal override).", parameters: Type.Object({ - name: Type.String({ description: "Workflow name" }), + name: Type.Optional(Type.String({ description: "Workflow name (defaults to default)" })), goal: Type.Optional(Type.String({ description: "Optional goal override" })), }), async execute(_toolCallId, params) { const goal = normalizeGoal(params.goal); + const workflowName = params.name?.trim() || "default"; const command = goal - ? `/workflow start ${params.name} "${goal}"` - : `/workflow start ${params.name}`; + ? `/workflow start ${workflowName} ${JSON.stringify(goal)}` + : `/workflow start ${workflowName}`; pi.sendUserMessage(command, { deliverAs: "followUp" }); return { - content: [{ type: "text", text: `Queued workflow start: ${params.name}` }], + content: [{ type: "text", text: `Queued workflow start: ${workflowName}` }], details: {}, }; }, diff --git a/.pi/extensions/workflow-orchestrator/models.ts b/.pi/extensions/workflow-orchestrator/models.ts new file mode 100644 index 0000000..e22e556 --- /dev/null +++ b/.pi/extensions/workflow-orchestrator/models.ts @@ -0,0 +1,8 @@ +/** First non-empty model string wins. */ +export function pickModel(...candidates: Array): string | undefined { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} diff --git a/.pi/extensions/workflow-orchestrator/runner.ts b/.pi/extensions/workflow-orchestrator/runner.ts index 9b43e42..d8aaf6b 100644 --- a/.pi/extensions/workflow-orchestrator/runner.ts +++ b/.pi/extensions/workflow-orchestrator/runner.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Message } from "@mariozechner/pi-ai"; +import { formatSubagentError } from "./utils.js"; export type AgentRunUpdate = | { type: "text_delta"; delta: string } @@ -333,7 +334,13 @@ export async function runAgent(input: AgentRunInput): Promise { /* ignore */ } - return { outputText, messages, stderr: assistantError || stderr, exitCode, toolCalls }; + return { + outputText, + messages, + stderr: formatSubagentError(assistantError || stderr), + exitCode, + toolCalls, + }; } export class RpcAgent { @@ -586,7 +593,7 @@ export class RpcAgent { private handleClose(): void { if (this.currentRun) { - this.currentRun.reject(new Error(this.stderr || "RPC agent terminated")); + this.currentRun.reject(new Error(formatSubagentError(this.stderr || "RPC agent terminated"))); this.currentRun = null; } this.proc = null; diff --git a/.pi/extensions/workflow-orchestrator/setup.ts b/.pi/extensions/workflow-orchestrator/setup.ts new file mode 100644 index 0000000..3213fd2 --- /dev/null +++ b/.pi/extensions/workflow-orchestrator/setup.ts @@ -0,0 +1,91 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DEFAULT_WORKFLOW_NAME = "default"; + +export function getPackagePiRoot(): string { + const extensionDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(extensionDir, "..", ".."); +} + +export function resolveWorkflowPath(cwd: string, name: string): string { + const fileName = `${name}.workflow.json`; + const projectPath = path.join(cwd, ".pi", "workflows", fileName); + if (fs.existsSync(projectPath)) return projectPath; + + const packagePath = path.join(getPackagePiRoot(), "workflows", fileName); + if (fs.existsSync(packagePath)) return packagePath; + + throw new Error(`Workflow not found: ${name}`); +} + +export function resolveExtensionPath(cwd: string, extensionPath: string): string { + const normalized = extensionPath.replace(/^\.\//, ""); + const packageRelative = normalized.replace(/^\.pi[\\/]/, ""); + const packageRoot = getPackagePiRoot(); + const candidates = [ + path.resolve(cwd, extensionPath), + path.resolve(packageRoot, packageRelative), + path.resolve(packageRoot, "extensions", packageRelative), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + return path.resolve(cwd, extensionPath); +} + +export function resolveExtensionPaths( + cwd: string, + extensionPaths: string[] | undefined, +): string[] | undefined { + if (!extensionPaths) return undefined; + return extensionPaths.map((extensionPath) => resolveExtensionPath(cwd, extensionPath)); +} + +function copyTreeMissing(src: string, dest: string): boolean { + if (!fs.existsSync(src)) return false; + + if (!fs.existsSync(dest)) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(src, dest, { recursive: true }); + return true; + } + + let srcStat: fs.Stats; + let destStat: fs.Stats; + try { + srcStat = fs.statSync(src); + destStat = fs.statSync(dest); + } catch { + return false; + } + + if (!srcStat.isDirectory() || !destStat.isDirectory()) return false; + + let copied = false; + for (const entry of fs.readdirSync(src)) { + copied = copyTreeMissing(path.join(src, entry), path.join(dest, entry)) || copied; + } + return copied; +} + +/** Copy editable defaults into the project without overwriting existing files. */ +export function materializeProjectDefaults(cwd: string): string[] { + const packagePiRoot = getPackagePiRoot(); + const created: string[] = []; + const copies = [ + { src: path.join(packagePiRoot, "workflows"), dest: path.join(cwd, ".pi", "workflows") }, + { src: path.join(packagePiRoot, "agents"), dest: path.join(cwd, ".pi", "agents") }, + ]; + + for (const { src, dest } of copies) { + if (copyTreeMissing(src, dest)) { + created.push(path.relative(cwd, dest)); + } + } + + return created; +} diff --git a/.pi/extensions/workflow-orchestrator/state.ts b/.pi/extensions/workflow-orchestrator/state.ts index d9dc3d6..b70e677 100644 --- a/.pi/extensions/workflow-orchestrator/state.ts +++ b/.pi/extensions/workflow-orchestrator/state.ts @@ -37,6 +37,7 @@ export interface WorkflowState { waveSummaries?: string[]; waitingForClarification?: boolean; clarificationToken?: string; + model?: string; } export const STATE_TYPE = "workflow-state"; diff --git a/.pi/extensions/workflow-orchestrator/utils.ts b/.pi/extensions/workflow-orchestrator/utils.ts index de43991..b00debb 100644 --- a/.pi/extensions/workflow-orchestrator/utils.ts +++ b/.pi/extensions/workflow-orchestrator/utils.ts @@ -15,13 +15,30 @@ export function extractJson(text: string): any { */ export function normalizeGoal(goal?: string): string | undefined { if (!goal) return undefined; - const trimmed = goal.trim(); + let trimmed = goal.trim(); if (!trimmed) return undefined; if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { - return trimmed.slice(1, -1).trim(); + trimmed = trimmed.slice(1, -1).trim(); + } else { + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' || first === "'") && !trimmed.slice(1).includes(first)) { + trimmed = trimmed.slice(1).trim(); + } + if ((last === '"' || last === "'") && !trimmed.slice(0, -1).includes(last)) { + trimmed = trimmed.slice(0, -1).trim(); + } + } + return trimmed || undefined; +} + +export function formatSubagentError(message: string): string { + const trimmed = message.trim(); + if (/model is unavailable|model not found|404/i.test(trimmed)) { + return `${trimmed}\nHint: pick a model in Pi (/model) or set model: in .pi/agents/*.md.`; } return trimmed; } diff --git a/AGENTS.md b/AGENTS.md index 955c036..2801d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,11 @@ pi Then: ``` -/workflow start default "Your goal" +/workflow "Your goal" ``` +First `/workflow` run copies editable agent and workflow files into `.pi/`. Edit them to customize, then `/reload`. + Reload extensions: ``` diff --git a/README.md b/README.md index ed10a12..86f2f6a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Customizable PM → Dev → Verifier workflow for **pi** using an extension and pi install git:github.com/hlibr/piorch ``` -This automatically installs the extension and agents to your project. +This installs the extension and bundled defaults. On first run, editable workflow and agent files are copied into your project's `.pi/` directory. ### Configure Models (Optional) @@ -64,7 +64,7 @@ Then run: ```bash pi -/workflow start default "Your goal" +/workflow "Your goal" ``` ### Option 2: Manual Copy @@ -103,7 +103,7 @@ Note: When working from the repo root, the agent will see test files and develop ## Quick Start ```text -/workflow start default "Build a Telegram bot that replies pong to /ping" +/workflow "Build a Telegram bot that replies pong to /ping" ``` You’ll see: @@ -146,6 +146,8 @@ tools: read,edit,write,bash Run `/reload` after changes. +You can also choose the run model with `/workflow --model "Your goal"`. + ### Workflow Configuration Edit `.pi/workflows/default.workflow.json` to customize: @@ -239,7 +241,7 @@ Run `/reload` after changing configuration files. ## Commands -- `/workflow start [goal]` +- `/workflow "goal"` (or `/workflow start [goal]`) - `/workflow resume` (continue from saved state) - `/workflow stop` - `/workflow stop-task ` diff --git a/package.json b/package.json index de6f464..c20cefb 100644 --- a/package.json +++ b/package.json @@ -34,17 +34,17 @@ "@mariozechner/pi-ai": "^0.57.1", "@mariozechner/pi-coding-agent": "^0.57.1", "@mariozechner/pi-tui": "^0.57.1", - "@sinclair/typebox": "^0.34.49", + "@sinclair/typebox": "^0.34.52", "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", + "@types/node": "^25.9.5", + "@typescript-eslint/eslint-plugin": "^8.64.0", + "@typescript-eslint/parser": "^8.64.0", "@vitest/coverage-v8": "^2.1.9", - "eslint": "^9.39.4", + "eslint": "^9.39.5", "eslint-config-prettier": "^9.1.2", - "globals": "^17.6.0", + "globals": "^17.7.0", "markdownlint-cli": "^0.42.0", - "prettier": "^3.8.3", + "prettier": "^3.9.5", "typescript": "^5.9.3", "vitest": "^2.1.9" } diff --git a/tests/agents.test.ts b/tests/agents.test.ts index d4a318e..d200f91 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -251,7 +251,8 @@ Prompt. it("handles non-existent directory", () => { const result = discoverAgents("/non/existent/path"); - expect(result.agents).toHaveLength(0); + expect(result.agents.length).toBeGreaterThan(0); + expect(result.agents.every((agent) => agent.source === "package")).toBe(true); expect(result.projectAgentsDir).toBeNull(); }); @@ -273,7 +274,7 @@ Prompt. `--- name: pm description: PM with model -model: openrouter/stepfun/step-3.5-flash:free +model: openrouter/free --- Prompt. @@ -282,7 +283,7 @@ Prompt. const result = discoverAgents(tempDir); - expect(result.agents[0].model).toBe("openrouter/stepfun/step-3.5-flash:free"); + expect(result.agents[0].model).toBe("openrouter/free"); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index cda80a9..cb05d60 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -80,4 +80,14 @@ describe("normalizeGoal", () => { it("returns unquoted text as-is", () => { expect(normalizeGoal("Build a bot")).toBe("Build a bot"); }); + + it("strips stray quotes from broken tokenization", () => { + expect(normalizeGoal('"build something"')).toBe("build something"); + expect(normalizeGoal('"build')).toBe("build"); + expect(normalizeGoal('something"')).toBe("something"); + }); + + it("preserves quotes that are part of the goal", () => { + expect(normalizeGoal('"foo" bug')).toBe('"foo" bug'); + }); }); diff --git a/tests/models.test.ts b/tests/models.test.ts new file mode 100644 index 0000000..273e4ad --- /dev/null +++ b/tests/models.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pickModel } from "../.pi/extensions/workflow-orchestrator/models.js"; +import { formatSubagentError } from "../.pi/extensions/workflow-orchestrator/utils.js"; + +describe("pickModel", () => { + it("returns the first non-empty candidate", () => { + expect(pickModel(undefined, "google/gemini-2.0-flash")).toBe("google/gemini-2.0-flash"); + expect(pickModel("openrouter/custom", "google/gemini-2.0-flash")).toBe("openrouter/custom"); + expect(pickModel(undefined, undefined)).toBeUndefined(); + }); +}); + +describe("formatSubagentError", () => { + it("adds a short hint for model errors", () => { + const message = formatSubagentError("404: model is unavailable"); + expect(message).toContain("/model"); + }); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts new file mode 100644 index 0000000..8a14d92 --- /dev/null +++ b/tests/setup.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + parseWorkflowShorthandGoal, + parseWorkflowStartArgs, + tokenizeWorkflowArgs, +} from "../.pi/extensions/workflow-orchestrator/commands.js"; +import { + materializeProjectDefaults, + getPackagePiRoot, + resolveExtensionPath, + resolveWorkflowPath, +} from "../.pi/extensions/workflow-orchestrator/setup.js"; +import { loadWorkflowConfig } from "../.pi/extensions/workflow-orchestrator/config.js"; + +describe("setup.ts", () => { + it("resolves package .pi root from extension location", () => { + const root = getPackagePiRoot(); + expect(fs.existsSync(path.join(root, "workflows", "default.workflow.json"))).toBe(true); + expect(fs.existsSync(path.join(root, "agents", "pm.md"))).toBe(true); + }); + + it("resolves default workflow from package when project has no config", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "workflow-setup-test-")); + const workflowPath = resolveWorkflowPath(cwd, "default"); + + expect(workflowPath).toBe(path.join(getPackagePiRoot(), "workflows", "default.workflow.json")); + expect(loadWorkflowConfig(cwd, "default").config.name).toBe("default"); + + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("prefers project workflow over package default", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "workflow-setup-test-")); + const workflowDir = path.join(cwd, ".pi", "workflows"); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync( + path.join(workflowDir, "default.workflow.json"), + JSON.stringify({ + name: "default", + goal: "project override", + agents: { pm: "pm", developer: "developer", verifier: "verifier" }, + waveSource: { type: "static", staticWaves: [{ goal: "g", tasks: [] }] }, + taskFlow: { + stages: [{ id: "develop", agent: "developer", inputTemplate: "x", outputSchema: {} }], + }, + }), + "utf-8", + ); + + expect(resolveWorkflowPath(cwd, "default")).toBe( + path.join(workflowDir, "default.workflow.json"), + ); + expect(loadWorkflowConfig(cwd, "default").config.goal).toBe("project override"); + + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("resolves package extensions from a .pi-prefixed path without basename heuristics", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "workflow-setup-test-")); + const resolved = resolveExtensionPath(cwd, "./.pi/extensions/workflow-pm-tools/index.ts"); + + expect(resolved).toBe( + path.join(getPackagePiRoot(), "extensions", "workflow-pm-tools", "index.ts"), + ); + + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("materializes editable defaults on first use", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "workflow-setup-test-")); + const created = materializeProjectDefaults(cwd); + + expect(created).toContain(".pi/workflows"); + expect(created).toContain(".pi/agents"); + expect(fs.existsSync(path.join(cwd, ".pi", "workflows", "default.workflow.json"))).toBe(true); + expect(fs.existsSync(path.join(cwd, ".pi", "agents", "pm.md"))).toBe(true); + expect(materializeProjectDefaults(cwd)).toEqual([]); + + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("fills missing defaults inside existing project directories", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "workflow-setup-test-")); + fs.mkdirSync(path.join(cwd, ".pi", "workflows"), { recursive: true }); + fs.mkdirSync(path.join(cwd, ".pi", "agents"), { recursive: true }); + + const created = materializeProjectDefaults(cwd); + + expect(created).toEqual(expect.arrayContaining([".pi/workflows", ".pi/agents"])); + expect(fs.existsSync(path.join(cwd, ".pi", "workflows", "default.workflow.json"))).toBe(true); + expect(fs.existsSync(path.join(cwd, ".pi", "agents", "pm.md"))).toBe(true); + + fs.rmSync(cwd, { recursive: true, force: true }); + }); +}); + +describe("commands.ts", () => { + it("parses shorthand start with goal only", () => { + expect(parseWorkflowStartArgs(["start", "Build a bot"])).toEqual({ + workflowName: "default", + goal: "Build a bot", + }); + }); + + it("parses explicit workflow name and goal", () => { + expect(parseWorkflowStartArgs(["start", "custom", "Build a bot"])).toEqual({ + workflowName: "custom", + goal: "Build a bot", + }); + }); + + it("treats broken quoted goals as default workflow goals", () => { + expect(parseWorkflowStartArgs(["start", '"build', 'something"'])).toEqual({ + workflowName: "default", + goal: "build something", + }); + }); + + it("starts a named workflow without a goal override", () => { + expect(parseWorkflowStartArgs(["start", "default"])).toEqual({ + workflowName: "default", + goal: undefined, + }); + }); + + it("parses quoted shorthand goals", () => { + expect(parseWorkflowShorthandGoal(["Build a bot"])).toEqual({ goal: "Build a bot" }); + expect(parseWorkflowShorthandGoal(['"Build a bot"'])).toEqual({ goal: "Build a bot" }); + }); + + it("parses model flag in shorthand goals", () => { + expect( + parseWorkflowShorthandGoal(["--model", "google/gemini-2.0-flash", "Build a bot"]), + ).toEqual({ + model: "google/gemini-2.0-flash", + goal: "Build a bot", + }); + }); + + it("keeps model-like text inside a quoted goal", () => { + expect(tokenizeWorkflowArgs('"support --model locally"')).toEqual(["support --model locally"]); + expect(parseWorkflowShorthandGoal(tokenizeWorkflowArgs('"support --model locally"'))).toEqual({ + goal: "support --model locally", + }); + }); + + it("normalizes a quoted model flag value", () => { + expect( + parseWorkflowShorthandGoal(tokenizeWorkflowArgs('--model "google/gemini-2.0-flash" Build')), + ).toEqual({ + model: "google/gemini-2.0-flash", + goal: "Build", + }); + }); + + it("does not treat a missing model value as a goal", () => { + expect(parseWorkflowShorthandGoal(tokenizeWorkflowArgs("--model"))).toEqual({ + model: undefined, + goal: undefined, + }); + }); +}); diff --git a/tests/workflow-start-clarification.test.ts b/tests/workflow-start-clarification.test.ts index fa3d384..1036b96 100644 --- a/tests/workflow-start-clarification.test.ts +++ b/tests/workflow-start-clarification.test.ts @@ -133,6 +133,10 @@ function createMockContext(branch: any[] = []): ExtensionCommandContext { return { cwd: process.cwd(), hasUI: false, + model: { id: "test/model" }, + modelRegistry: { + getAvailable: () => [{ id: "test/model", provider: "test" }], + }, sessionManager: { getBranch: vi.fn().mockImplementation(() => branch), },