diff --git a/docs/docs.json b/docs/docs.json index 0952953b0a5..b885c5212dc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -655,6 +655,10 @@ "source": "/templates/SOUL", "destination": "/reference/templates/SOUL" }, + { + "source": "/templates/SOUL.architect", + "destination": "/reference/templates/SOUL.architect" + }, { "source": "/templates/TOOLS", "destination": "/reference/templates/TOOLS" @@ -1233,6 +1237,7 @@ "reference/templates/HEARTBEAT", "reference/templates/IDENTITY", "reference/templates/SOUL", + "reference/templates/SOUL.architect", "reference/templates/TOOLS", "reference/templates/USER" ] diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 6e2869403f5..fb9ee005090 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -26,6 +26,12 @@ cp docs/reference/templates/SOUL.md ~/.openclaw/workspace/SOUL.md cp docs/reference/templates/TOOLS.md ~/.openclaw/workspace/TOOLS.md ``` +Optional: if you want a CEO-style multi-agent software delivery persona, start from the Architect soul template instead: + +```bash +cp docs/reference/templates/SOUL.architect.md ~/.openclaw/workspace/SOUL.md +``` + 3. Optional: if you want the personal assistant skill roster, replace AGENTS.md with this file: ```bash diff --git a/docs/reference/templates/SOUL.architect.md b/docs/reference/templates/SOUL.architect.md new file mode 100644 index 00000000000..339eb8f9a1a --- /dev/null +++ b/docs/reference/templates/SOUL.architect.md @@ -0,0 +1,158 @@ +--- +title: "SOUL.md Template (Architect CEO)" +summary: "System-prompt template for a CEO-style multi-agent software delivery orchestrator" +read_when: + - Building autonomous multi-agent product pipelines + - Defining strict orchestration roles and retry loops +--- + +# SOUL.md - Architect CEO + +You are the **OpenClaw Agent CEO** (Project Architect). + +## Objective + +Take a high-level product request (for example, "Build a CRM for dentists") and orchestrate a 6-agent pipeline that produces a production-ready, secure, and containerized full-stack application. + +## Core Identity + +- You are an orchestrator, not a solo implementer. +- You own state management, context passing, quality gates, and recursive debugging loops. +- You enforce output contracts between agents. +- You do not invent extra features during fixes. + +## Squad (Invoke Sequentially) + +### Agent 1 - Strategist (GPT-4o) + +- Input: User's raw idea. +- Duty: Idea generation and market analysis. +- Output: `concept_brief.json` containing: + - `targetAudience` + - `coreValueProposition` + - `potentialFeatures` + +### Agent 2 - Product Lead (GPT-4 <-> Claude Opus) + +- Input: `concept_brief.json`. +- Duty: Recursive critique and refinement. +- Output: `prd.md` with: + - user stories + - technical constraints + - prioritized feature list + +### Agent 3 - Designer (Gemini 1.5 Pro) + +- Input: `prd.md`. +- Duty: Visual and data planning. +- Output: + - `wireframes.md` (ASCII or structured layout descriptions) + - `data-schema.json` (database models and relationships) + - `design-system.md` (CSS variables and/or Tailwind token spec) + +### Agent 4 - DevOps Architect (Codex/GPT-4) + +- Input: `prd.md` + design artifacts. +- Duty: Infrastructure and project skeleton. +- Output: + - `docker-compose.yml` + - `Dockerfile` + - database initialization scripts + - generated folder structure + +### Agent 5 - Builder (BMAD/Wiggum) + +- Input: infra skeleton + PRD + design artifacts. +- Duty: Implement full-stack app code. +- Constraints: + - Implement feature-by-feature. + - Follow `data-schema.json` strictly. +- Output: fully populated source tree. + +### Agent 6 - Auditor (Codex/GPT-4) + +- Input: source tree from Agent 5. +- Duty: security + quality review. +- Required checks: + - SQL injection + - XSS + - exposed secrets/keys + - logic and lint errors +- Output: `security-report.md` with `PASS` or `FAIL`. + +## Pipeline + +### Phase A - Planning (1-3) + +1. Receive user request. +2. Invoke Agent 1 and save `concept_brief.json`. +3. Invoke Agent 2 and save `prd.md`. +4. Invoke Agent 3 and save design artifacts. +5. Update shared context from all planning outputs. + +### Phase B - Construction (4-5) + +6. Invoke Agent 4 to generate infrastructure. +7. Invoke Agent 5 to implement application code in generated structure. +8. Enforce strict schema compliance with Agent 3 outputs. + +### Phase C - Validation + Recursion (6 + loop) + +9. Invoke Agent 6 for audit. + +Decision gate: + +- If report is `PASS`: + - package the app + - generate `DEPLOY_INSTRUCTIONS.md` + - return `Project Complete.` +- If report is `FAIL` or `ERROR`: + - send exact findings and logs to Agent 5 + - command: "Fix these specific issues. Do not hallucinate new features. Return updated code." + - re-run Agent 6 + - max retries: 5 + - after 5 failed retries: escalate to human + +## Operational State (Required) + +Maintain `state.json` in the project root: + +```json +{ + "project": "", + "currentPhase": "planning|construction|validation", + "currentStep": 1, + "retryCount": 0, + "status": "running|blocked|complete|escalated", + "sharedContext": { + "conceptBriefPath": "concept_brief.json", + "prdPath": "prd.md", + "wireframesPath": "wireframes.md", + "schemaPath": "data-schema.json", + "designSystemPath": "design-system.md" + }, + "artifacts": { + "infraReady": false, + "codeReady": false, + "securityReportPath": "security-report.md", + "deployInstructionsPath": "DEPLOY_INSTRUCTIONS.md" + } +} +``` + +Update this file after every agent handoff and after every retry loop iteration. + +## Tools and Capabilities + +You must actively use: + +- File system read/write for persistent artifacts. +- `state.json` as the single source of orchestration truth. +- Terminal build verification before final audit (for example `npm run build`, test commands, or container checks). + +## Guardrails + +- No feature creep during bugfix loops. +- No skipping the audit gate. +- No completion claim without deploy instructions. +- On uncertainty, surface blockers clearly and escalate with concrete evidence. diff --git a/src/agents/openclaw-tools.architect-pipeline.test.ts b/src/agents/openclaw-tools.architect-pipeline.test.ts new file mode 100644 index 00000000000..71775fa01fa --- /dev/null +++ b/src/agents/openclaw-tools.architect-pipeline.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +describe("architect_pipeline tool", () => { + let workspaceDir: string; + + beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-architect-pipeline-")); + }); + + afterEach(async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("initializes and reads default state", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "architect_pipeline", + ); + if (!tool) { + throw new Error("missing architect_pipeline tool"); + } + + const initResult = await tool.execute("call-1", { + action: "init", + project: "dentist-crm", + }); + expect(initResult.details).toMatchObject({ + action: "init", + state: { + project: "dentist-crm", + currentPhase: "planning", + currentStep: 1, + retryCount: 0, + status: "running", + }, + }); + + const getResult = await tool.execute("call-2", { action: "get" }); + expect(getResult.details).toMatchObject({ + action: "get", + state: { + project: "dentist-crm", + }, + }); + }); + + it("applies decision gate retries and escalates at retry limit", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "architect_pipeline", + ); + if (!tool) { + throw new Error("missing architect_pipeline tool"); + } + + await tool.execute("call-init", { action: "init", project: "crm" }); + + const failResult = await tool.execute("call-fail", { + action: "decision_gate", + report: "FAIL", + findings: "xss in comment renderer", + maxRetries: 2, + }); + expect(failResult.details).toMatchObject({ + status: "running", + retryCount: 1, + nextAction: "send_findings_to_builder_then_reaudit", + }); + + const escalateResult = await tool.execute("call-escalate", { + action: "decision_gate", + report: "ERROR", + findings: "build not reproducible", + maxRetries: 2, + }); + expect(escalateResult.details).toMatchObject({ + status: "escalated", + retryCount: 2, + nextAction: "escalate_to_human", + }); + }); + + it("rejects state paths outside workspace", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "architect_pipeline", + ); + if (!tool) { + throw new Error("missing architect_pipeline tool"); + } + + await expect( + tool.execute("call-bad-path", { + action: "init", + path: path.join("..", "..", "tmp", "state.json"), + }), + ).rejects.toThrow("path must stay within the workspace directory"); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index eed12b72d41..7a52cbd9cc2 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -5,6 +5,7 @@ import type { AnyAgentTool } from "./tools/common.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { resolveSessionAgentId } from "./agent-scope.js"; import { createAgentsListTool } from "./tools/agents-list-tool.js"; +import { createArchitectPipelineTool } from "./tools/architect-pipeline-tool.js"; import { createBrowserTool } from "./tools/browser-tool.js"; import { createCanvasTool } from "./tools/canvas-tool.js"; import { createCronTool } from "./tools/cron-tool.js"; @@ -19,6 +20,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSubagentsTool } from "./tools/subagents-tool.js"; import { createTtsTool } from "./tools/tts-tool.js"; +import { createVentureStudioTool } from "./tools/venture-studio-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; @@ -110,6 +112,8 @@ export function createOpenClawTools(options?: { createCronTool({ agentSessionKey: options?.agentSessionKey, }), + createArchitectPipelineTool({ workspaceDir }), + createVentureStudioTool({ workspaceDir }), ...(messageTool ? [messageTool] : []), createTtsTool({ agentChannel: options?.agentChannel, diff --git a/src/agents/openclaw-tools.venture-studio.test.ts b/src/agents/openclaw-tools.venture-studio.test.ts new file mode 100644 index 00000000000..ca75d9a4055 --- /dev/null +++ b/src/agents/openclaw-tools.venture-studio.test.ts @@ -0,0 +1,160 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +describe("venture_studio tool", () => { + let workspaceDir: string; + + beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-venture-studio-")); + }); + + afterEach(async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("records findings, dedupes duplicates, and generates plan/workflow/spec artifacts", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "venture_studio", + ); + if (!tool) { + throw new Error("missing venture_studio tool"); + } + + await tool.execute("call-init", { action: "init" }); + await tool.execute("call-add-1", { + action: "add_finding", + sourceType: "forum", + sourceUrl: "https://example.com/thread/123", + title: "Missed patient follow-ups", + painPoint: "Small dental clinics miss recall follow-ups and lose recurring revenue", + targetCustomer: "dental clinics", + urgency: "high", + willingnessToPay: "$299/month", + }); + + const dedupeResult = await tool.execute("call-add-2", { + action: "add_finding", + sourceType: "forum", + sourceUrl: "https://example.com/thread/123", + title: "Missed patient follow-ups", + painPoint: "Small dental clinics miss recall follow-ups and lose recurring revenue", + targetCustomer: "dental clinics", + urgency: "high", + willingnessToPay: "$299/month", + }); + expect(dedupeResult.details).toMatchObject({ deduped: true, totalFindings: 1 }); + + const planResult = await tool.execute("call-plan", { + action: "plan_apps", + appCount: 1, + stack: "nextjs-node-postgres", + }); + expect(planResult.details).toMatchObject({ + action: "plan_apps", + totalPlans: 1, + }); + + const details = planResult.details as { + discussionPath?: string; + generatedPlans?: Array<{ + docPath: string; + workflowPath: string; + specPath: string; + id: string; + }>; + }; + if (!details.discussionPath || !details.generatedPlans?.[0]) { + throw new Error("missing generated artifacts"); + } + + await expect(fs.readFile(details.discussionPath, "utf-8")).resolves.toContain( + "Agent roundtable", + ); + await expect(fs.readFile(details.generatedPlans[0].docPath, "utf-8")).resolves.toContain( + "Recommended stack", + ); + await expect(fs.readFile(details.generatedPlans[0].workflowPath, "utf-8")).resolves.toContain( + "go_to_market", + ); + await expect(fs.readFile(details.generatedPlans[0].specPath, "utf-8")).resolves.toContain( + "coreFeatures", + ); + }); + + it("builds a scaffold for a generated plan", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "venture_studio", + ); + if (!tool) { + throw new Error("missing venture_studio tool"); + } + + await tool.execute("call-init", { action: "init" }); + await tool.execute("call-add", { + action: "add_finding", + sourceType: "web", + sourceUrl: "https://example.com/blog", + title: "Manual invoice reconciliation", + painPoint: "Accounting teams lose time reconciling invoices with bank feeds", + targetCustomer: "mid-market finance teams", + urgency: "critical", + }); + + const planResult = (await tool.execute("call-plan", { + action: "plan_apps", + appCount: 1, + stack: "react-fastapi-postgres", + })) as { details?: { generatedPlans?: Array<{ id: string }> } }; + const planId = planResult.details?.generatedPlans?.[0]?.id; + if (!planId) { + throw new Error("missing plan id"); + } + + const scaffoldResult = await tool.execute("call-scaffold", { + action: "build_scaffold", + planId, + }); + expect(scaffoldResult.details).toMatchObject({ action: "build_scaffold", planId }); + + const details = scaffoldResult.details as { scaffoldRoot?: string }; + if (!details.scaffoldRoot) { + throw new Error("missing scaffold root"); + } + + await expect( + fs.readFile(path.join(details.scaffoldRoot, "docker-compose.yml"), "utf-8"), + ).resolves.toContain("services:"); + await expect( + fs.readFile(path.join(details.scaffoldRoot, "backend", "requirements.txt"), "utf-8"), + ).resolves.toContain("fastapi=="); + await expect( + fs.readFile(path.join(details.scaffoldRoot, "frontend", "package.json"), "utf-8"), + ).resolves.toContain("vite"); + await expect( + fs.readFile(path.join(details.scaffoldRoot, "DEPENDENCIES.md"), "utf-8"), + ).resolves.toContain("docker compose up --build"); + await expect( + fs.readFile(path.join(details.scaffoldRoot, "scripts", "dev.ps1"), "utf-8"), + ).resolves.toContain("docker compose up --build"); + await expect( + fs.readFile(path.join(details.scaffoldRoot, "scripts", "dev.cmd"), "utf-8"), + ).resolves.toContain("docker compose up --build"); + }); + + it("rejects operations before init", async () => { + const tool = createOpenClawTools({ workspaceDir }).find( + (candidate) => candidate.name === "venture_studio", + ); + if (!tool) { + throw new Error("missing venture_studio tool"); + } + + await expect(tool.execute("call-list", { action: "list_findings" })).rejects.toThrow( + "Run action=init first", + ); + }); +}); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a2f1409669c..364d1f49b4e 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -248,6 +248,10 @@ export function buildAgentSystemPrompt(params: { subagents: "List, steer, or kill sub-agent runs for this requester session", session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", + architect_pipeline: + "Manage Architect CEO orchestration state and audit decision gates for multi-agent build loops", + venture_studio: + "Capture web/forum pain-point research and generate monetized app plans + workflow documents; pair with web_search/web_fetch for source discovery", image: "Analyze an image with the configured image model", }; @@ -275,6 +279,8 @@ export function buildAgentSystemPrompt(params: { "sessions_send", "subagents", "session_status", + "architect_pipeline", + "venture_studio", "image", ]; diff --git a/src/agents/tools/architect-pipeline-tool.ts b/src/agents/tools/architect-pipeline-tool.ts new file mode 100644 index 00000000000..24450b2a2b6 --- /dev/null +++ b/src/agents/tools/architect-pipeline-tool.ts @@ -0,0 +1,246 @@ +import { Type } from "@sinclair/typebox"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AnyAgentTool } from "./common.js"; +import { optionalStringEnum } from "../schema/typebox.js"; +import { ToolInputError, jsonResult, readNumberParam, readStringParam } from "./common.js"; + +const ARCHITECT_PIPELINE_ACTIONS = ["init", "get", "update", "decision_gate"] as const; +const ARCHITECT_PHASES = ["planning", "construction", "validation"] as const; +const ARCHITECT_STATUSES = ["running", "blocked", "complete", "escalated"] as const; +const ARCHITECT_REPORTS = ["PASS", "FAIL", "ERROR"] as const; + +type ArchitectPipelineAction = (typeof ARCHITECT_PIPELINE_ACTIONS)[number]; +type ArchitectPhase = (typeof ARCHITECT_PHASES)[number]; +type ArchitectStatus = (typeof ARCHITECT_STATUSES)[number]; +type ArchitectReport = (typeof ARCHITECT_REPORTS)[number]; + +type ArchitectState = { + project: string; + currentPhase: ArchitectPhase; + currentStep: number; + retryCount: number; + status: ArchitectStatus; + sharedContext: { + conceptBriefPath: string; + prdPath: string; + wireframesPath: string; + schemaPath: string; + designSystemPath: string; + }; + artifacts: { + infraReady: boolean; + codeReady: boolean; + securityReportPath: string; + deployInstructionsPath: string; + }; + latestAudit?: { + report: ArchitectReport; + findings?: string; + at: string; + retryCount: number; + }; +}; + +const ArchitectPipelineToolSchema = Type.Object({ + action: optionalStringEnum(ARCHITECT_PIPELINE_ACTIONS), + path: Type.Optional(Type.String()), + project: Type.Optional(Type.String()), + currentPhase: optionalStringEnum(ARCHITECT_PHASES), + currentStep: Type.Optional(Type.Number({ minimum: 1 })), + status: optionalStringEnum(ARCHITECT_STATUSES), + retryCount: Type.Optional(Type.Number({ minimum: 0 })), + conceptBriefPath: Type.Optional(Type.String()), + prdPath: Type.Optional(Type.String()), + wireframesPath: Type.Optional(Type.String()), + schemaPath: Type.Optional(Type.String()), + designSystemPath: Type.Optional(Type.String()), + infraReady: Type.Optional(Type.Boolean()), + codeReady: Type.Optional(Type.Boolean()), + securityReportPath: Type.Optional(Type.String()), + deployInstructionsPath: Type.Optional(Type.String()), + report: optionalStringEnum(ARCHITECT_REPORTS), + findings: Type.Optional(Type.String()), + maxRetries: Type.Optional(Type.Number({ minimum: 1 })), +}); + +function defaultState(project = "unnamed-project"): ArchitectState { + return { + project, + currentPhase: "planning", + currentStep: 1, + retryCount: 0, + status: "running", + sharedContext: { + conceptBriefPath: "concept_brief.json", + prdPath: "prd.md", + wireframesPath: "wireframes.md", + schemaPath: "data-schema.json", + designSystemPath: "design-system.md", + }, + artifacts: { + infraReady: false, + codeReady: false, + securityReportPath: "security-report.md", + deployInstructionsPath: "DEPLOY_INSTRUCTIONS.md", + }, + }; +} + +function isWithinWorkspace(candidatePath: string, workspaceDir: string) { + const rel = path.relative(workspaceDir, candidatePath); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function resolveStatePath(params: { workspaceDir: string; rawPath?: string }) { + const workspaceDir = path.resolve(params.workspaceDir); + const rawPath = params.rawPath?.trim(); + const statePath = rawPath + ? path.resolve(workspaceDir, rawPath) + : path.join(workspaceDir, "state.json"); + if (!isWithinWorkspace(statePath, workspaceDir)) { + throw new ToolInputError("path must stay within the workspace directory"); + } + return statePath; +} + +async function readState(statePath: string): Promise { + try { + const raw = await fs.readFile(statePath, "utf-8"); + return JSON.parse(raw) as ArchitectState; + } catch (error) { + const anyErr = error as { code?: string }; + if (anyErr.code === "ENOENT") { + return null; + } + throw error; + } +} + +async function writeState(statePath: string, state: ArchitectState) { + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); +} + +export function createArchitectPipelineTool(options: { workspaceDir: string }): AnyAgentTool { + return { + label: "Architect Pipeline", + name: "architect_pipeline", + description: + "Manage Architect CEO orchestration state.json and enforce audit decision gates for retries/escalation.", + parameters: ArchitectPipelineToolSchema, + execute: async (_callId, input) => { + const params = (input ?? {}) as Record; + const action = (readStringParam(params, "action") ?? "get") as ArchitectPipelineAction; + const statePath = resolveStatePath({ + workspaceDir: options.workspaceDir, + rawPath: readStringParam(params, "path"), + }); + + if (action === "init") { + const project = readStringParam(params, "project") ?? "unnamed-project"; + const state = defaultState(project); + await writeState(statePath, state); + return jsonResult({ action, path: statePath, state }); + } + + const current = await readState(statePath); + if (!current) { + throw new ToolInputError( + `state file not found at ${statePath}. Run action=init first or provide an existing path.`, + ); + } + + if (action === "get") { + return jsonResult({ action, path: statePath, state: current }); + } + + if (action === "update") { + const next: ArchitectState = { + ...current, + project: readStringParam(params, "project") ?? current.project, + currentPhase: + (readStringParam(params, "currentPhase") as ArchitectPhase | undefined) ?? + current.currentPhase, + currentStep: + readNumberParam(params, "currentStep", { integer: true }) ?? current.currentStep, + retryCount: + readNumberParam(params, "retryCount", { integer: true }) ?? current.retryCount, + status: + (readStringParam(params, "status") as ArchitectStatus | undefined) ?? current.status, + sharedContext: { + ...current.sharedContext, + conceptBriefPath: + readStringParam(params, "conceptBriefPath") ?? current.sharedContext.conceptBriefPath, + prdPath: readStringParam(params, "prdPath") ?? current.sharedContext.prdPath, + wireframesPath: + readStringParam(params, "wireframesPath") ?? current.sharedContext.wireframesPath, + schemaPath: readStringParam(params, "schemaPath") ?? current.sharedContext.schemaPath, + designSystemPath: + readStringParam(params, "designSystemPath") ?? current.sharedContext.designSystemPath, + }, + artifacts: { + ...current.artifacts, + infraReady: + typeof params.infraReady === "boolean" + ? params.infraReady + : current.artifacts.infraReady, + codeReady: + typeof params.codeReady === "boolean" + ? params.codeReady + : current.artifacts.codeReady, + securityReportPath: + readStringParam(params, "securityReportPath") ?? current.artifacts.securityReportPath, + deployInstructionsPath: + readStringParam(params, "deployInstructionsPath") ?? + current.artifacts.deployInstructionsPath, + }, + }; + await writeState(statePath, next); + return jsonResult({ action, path: statePath, state: next }); + } + + if (action === "decision_gate") { + const report = ( + readStringParam(params, "report", { required: true }) as ArchitectReport + ).toUpperCase() as ArchitectReport; + const findings = readStringParam(params, "findings"); + const maxRetries = readNumberParam(params, "maxRetries", { integer: true }) ?? 5; + const retryCount = report === "PASS" ? current.retryCount : current.retryCount + 1; + const status: ArchitectStatus = + report === "PASS" ? "complete" : retryCount >= maxRetries ? "escalated" : "running"; + const next: ArchitectState = { + ...current, + currentPhase: report === "PASS" ? "validation" : current.currentPhase, + retryCount, + status, + latestAudit: { + report, + findings, + at: new Date().toISOString(), + retryCount, + }, + }; + await writeState(statePath, next); + const nextAction = + report === "PASS" + ? "package_app_and_generate_deploy_instructions" + : status === "escalated" + ? "escalate_to_human" + : "send_findings_to_builder_then_reaudit"; + return jsonResult({ + action, + path: statePath, + report, + status, + retryCount, + maxRetries, + nextAction, + state: next, + }); + } + + throw new ToolInputError("Unknown action."); + }, + }; +} diff --git a/src/agents/tools/venture-studio-tool.ts b/src/agents/tools/venture-studio-tool.ts new file mode 100644 index 00000000000..a412a6631ab --- /dev/null +++ b/src/agents/tools/venture-studio-tool.ts @@ -0,0 +1,678 @@ +import { Type } from "@sinclair/typebox"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AnyAgentTool } from "./common.js"; +import { optionalStringEnum } from "../schema/typebox.js"; +import { + ToolInputError, + jsonResult, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "./common.js"; + +const VENTURE_ACTIONS = [ + "init", + "add_finding", + "list_findings", + "plan_apps", + "list_plans", + "build_scaffold", +] as const; +const SOURCE_TYPES = ["web", "forum", "other"] as const; +const URGENCY_LEVELS = ["low", "medium", "high", "critical"] as const; +const STACK_OPTIONS = [ + "nextjs-node-postgres", + "react-fastapi-postgres", + "sveltekit-supabase", +] as const; + +type VentureAction = (typeof VENTURE_ACTIONS)[number]; +type SourceType = (typeof SOURCE_TYPES)[number]; +type UrgencyLevel = (typeof URGENCY_LEVELS)[number]; +type StackOption = (typeof STACK_OPTIONS)[number]; + +type ResearchFinding = { + id: string; + sourceType: SourceType; + sourceUrl?: string; + title: string; + painPoint: string; + targetCustomer: string; + urgency: UrgencyLevel; + willingnessToPay?: string; + observedAt: string; +}; + +type AppPlan = { + id: string; + name: string; + problem: string; + users: string; + monetization: string; + billionDollarThesis: string; + stack: StackOption; + workflowPath: string; + docPath: string; + specPath: string; + basedOnFindingIds: string[]; + createdAt: string; +}; + +type VentureStudioState = { + version: 1; + initializedAt: string; + findings: ResearchFinding[]; + plans: AppPlan[]; +}; + +const VentureStudioToolSchema = Type.Object({ + action: optionalStringEnum(VENTURE_ACTIONS), + path: Type.Optional(Type.String()), + outputDir: Type.Optional(Type.String()), + sourceType: optionalStringEnum(SOURCE_TYPES), + sourceUrl: Type.Optional(Type.String()), + title: Type.Optional(Type.String()), + painPoint: Type.Optional(Type.String()), + targetCustomer: Type.Optional(Type.String()), + urgency: optionalStringEnum(URGENCY_LEVELS), + willingnessToPay: Type.Optional(Type.String()), + appName: Type.Optional(Type.String()), + appCount: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), + monetization: Type.Optional(Type.String()), + thesis: Type.Optional(Type.String()), + findingIds: Type.Optional(Type.Array(Type.String())), + planId: Type.Optional(Type.String()), + stack: optionalStringEnum(STACK_OPTIONS), + appRootDir: Type.Optional(Type.String()), +}); + +function toSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 60); +} + +function isWithinWorkspace(candidatePath: string, workspaceDir: string) { + const rel = path.relative(workspaceDir, candidatePath); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function resolveWorkspacePath(params: { + workspaceDir: string; + rawPath?: string; + fallback: string; +}) { + const workspaceDir = path.resolve(params.workspaceDir); + const targetPath = params.rawPath?.trim() + ? path.resolve(workspaceDir, params.rawPath) + : path.join(workspaceDir, params.fallback); + if (!isWithinWorkspace(targetPath, workspaceDir)) { + throw new ToolInputError("path must stay within the workspace directory"); + } + return targetPath; +} + +function urgencyWeight(urgency: UrgencyLevel): number { + if (urgency === "critical") { + return 4; + } + if (urgency === "high") { + return 3; + } + if (urgency === "medium") { + return 2; + } + return 1; +} + +function sortFindingsByOpportunity(findings: ResearchFinding[]): ResearchFinding[] { + return [...findings].toSorted((a, b) => urgencyWeight(b.urgency) - urgencyWeight(a.urgency)); +} + +function defaultState(): VentureStudioState { + return { + version: 1, + initializedAt: new Date().toISOString(), + findings: [], + plans: [], + }; +} + +async function readState(statePath: string): Promise { + try { + const raw = await fs.readFile(statePath, "utf-8"); + return JSON.parse(raw) as VentureStudioState; + } catch (error) { + const anyErr = error as { code?: string }; + if (anyErr.code === "ENOENT") { + return null; + } + throw error; + } +} + +async function writeState(statePath: string, state: VentureStudioState): Promise { + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); +} + +function nextSequenceId(prefix: string, existingIds: string[]): string { + const used = new Set(existingIds); + for (let i = 1; i <= 100_000; i += 1) { + const candidate = `${prefix}-${i}`; + if (!used.has(candidate)) { + return candidate; + } + } + return `${prefix}-${Date.now().toString(36)}`; +} + +async function writePlanArtifacts(params: { + outputDir: string; + planId: string; + appName: string; + problem: string; + users: string; + monetization: string; + thesis: string; + stack: StackOption; + findings: ResearchFinding[]; +}): Promise<{ docPath: string; workflowPath: string; specPath: string }> { + const planDir = path.join(params.outputDir, params.planId); + await fs.mkdir(planDir, { recursive: true }); + + const docPath = path.join(planDir, "PLAN.md"); + const workflowPath = path.join(planDir, "workflow.json"); + const specPath = path.join(planDir, "APP_SPEC.json"); + + const findingsSection = params.findings + .map( + (finding) => + `- ${finding.title} (${finding.sourceType}${finding.sourceUrl ? `: ${finding.sourceUrl}` : ""}) — ${finding.painPoint}`, + ) + .join("\n"); + + const planDoc = `# ${params.appName}\n\n## Problem\n${params.problem}\n\n## Target users\n${params.users}\n\n## Monetization\n${params.monetization}\n\n## Billion-dollar thesis\n${params.thesis}\n\n## Recommended stack\n${params.stack}\n\n## Evidence from research\n${findingsSection || "- No findings attached."}\n\n## Build workflow\n1. Validate demand with 10 customer interviews from identified segment.\n2. Build MVP full-stack app with auth, billing, and analytics.\n3. Launch paid pilot with design partners.\n4. Iterate weekly from support/usage data.\n5. Expand distribution via integrations and channel partners.\n`; + + const workflow = { + stages: [ + { id: "research", goal: "Verify problem urgency and willingness to pay" }, + { id: "product", goal: "Define MVP scope and technical architecture" }, + { id: "build", goal: "Ship production-ready full-stack MVP" }, + { id: "go_to_market", goal: "Acquire first paying customers" }, + { id: "scale", goal: "Expand to enterprise and adjacent markets" }, + ], + generatedAt: new Date().toISOString(), + }; + + const spec = { + appName: params.appName, + problem: params.problem, + users: params.users, + monetization: params.monetization, + stack: params.stack, + coreFeatures: [ + "authentication", + "team workspace", + "automation workflows", + "billing subscriptions", + "usage analytics dashboard", + ], + nonFunctional: ["security", "auditability", "multi-tenant readiness", "cost controls"], + generatedAt: new Date().toISOString(), + }; + + await fs.writeFile(docPath, planDoc, "utf-8"); + await fs.writeFile(workflowPath, `${JSON.stringify(workflow, null, 2)}\n`, "utf-8"); + await fs.writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`, "utf-8"); + + return { docPath, workflowPath, specPath }; +} + +async function writeDiscussionDoc(outputDir: string, newPlans: AppPlan[]): Promise { + const discussionPath = path.join(outputDir, "DISCUSSION.md"); + const lines = [ + "# Venture Studio Discussion", + "", + "## Goal", + "Identify painful, recurring, high-value problems and turn them into monetized full-stack app opportunities.", + "", + "## Candidate plans", + ...newPlans.map( + (plan) => + `- **${plan.name}**: solves "${plan.problem}" for ${plan.users}. Revenue: ${plan.monetization}`, + ), + "", + "## Agent roundtable", + "- Strategist: Is this pain frequent enough to justify an always-on product?", + "- Product Lead: What is the narrow MVP wedge that can be shipped in under 8 weeks?", + "- Designer: Which workflow screens remove the biggest friction first?", + "- DevOps Architect: Which stack minimizes time-to-production and ops risk?", + "- Builder: What implementation milestones de-risk integration and billing early?", + "- Auditor: What security/compliance controls are mandatory before paid rollout?", + "", + "## Decision checklist", + "- Is the pain urgent and frequent?", + "- Can customers justify paying quickly?", + "- Can the first version be shipped in <8 weeks?", + "- Does the wedge support expansion into a large market?", + "", + ]; + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(discussionPath, lines.join("\n"), "utf-8"); + return discussionPath; +} + +async function buildScaffold(params: { + workspaceDir: string; + appRootDirRaw?: string; + plan: AppPlan; +}): Promise<{ scaffoldRoot: string; createdFiles: string[] }> { + const appRootDir = resolveWorkspacePath({ + workspaceDir: params.workspaceDir, + rawPath: params.appRootDirRaw, + fallback: "venture-studio/apps", + }); + const scaffoldRoot = path.join(appRootDir, params.plan.id); + const backendDir = path.join(scaffoldRoot, "backend"); + const frontendDir = path.join(scaffoldRoot, "frontend"); + const dbDir = path.join(scaffoldRoot, "db"); + + await fs.mkdir(backendDir, { recursive: true }); + await fs.mkdir(frontendDir, { recursive: true }); + await fs.mkdir(dbDir, { recursive: true }); + + const isNodeBackend = params.plan.stack !== "react-fastapi-postgres"; + const backendDockerfile = isNodeBackend + ? 'FROM node:22-alpine\nWORKDIR /app\nCOPY package.json package.json\nRUN npm install\nCOPY . .\nEXPOSE 8080\nCMD ["npm","run","dev"]\n' + : 'FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt requirements.txt\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8080\nCMD ["uvicorn","main:app","--host","0.0.0.0","--port","8080"]\n'; + + const backendPackageJson = JSON.stringify( + { + name: "backend", + private: true, + type: "module", + scripts: { + dev: "node server.js", + }, + dependencies: { + cors: "^2.8.5", + express: "^4.21.2", + pg: "^8.13.1", + }, + }, + null, + 2, + ); + + const frontendPackageJsonByStack: Record = { + "nextjs-node-postgres": JSON.stringify( + { + name: "frontend", + private: true, + scripts: { + dev: "next dev -p 3000", + build: "next build", + start: "next start -p 3000", + }, + dependencies: { + next: "^15.0.4", + react: "^18.3.1", + "react-dom": "^18.3.1", + }, + }, + null, + 2, + ), + "react-fastapi-postgres": JSON.stringify( + { + name: "frontend", + private: true, + scripts: { + dev: "vite --host 0.0.0.0 --port 3000", + build: "vite build", + preview: "vite preview --host 0.0.0.0 --port 3000", + }, + dependencies: { + react: "^18.3.1", + "react-dom": "^18.3.1", + }, + devDependencies: { + vite: "^5.4.11", + "@vitejs/plugin-react": "^4.3.3", + }, + }, + null, + 2, + ), + "sveltekit-supabase": JSON.stringify( + { + name: "frontend", + private: true, + scripts: { + dev: "vite dev --host 0.0.0.0 --port 3000", + build: "vite build", + preview: "vite preview --host 0.0.0.0 --port 3000", + }, + dependencies: { + "@sveltejs/kit": "^2.8.3", + "@supabase/supabase-js": "^2.47.4", + svelte: "^5.2.7", + }, + devDependencies: { + vite: "^5.4.11", + }, + }, + null, + 2, + ), + }; + + const frontendEntryByStack: Record = { + "nextjs-node-postgres": { + file: "pages/index.js", + content: + "export default function Home() {\n return

Venture Scaffold

Replace this page with your product UI.

;\n}\n", + }, + "react-fastapi-postgres": { + file: "src/main.jsx", + content: + "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nfunction App() {\n return

Venture Scaffold

Replace with your product UI.

;\n}\ncreateRoot(document.getElementById('root')).render();\n", + }, + "sveltekit-supabase": { + file: "src/routes/+page.svelte", + content: + "

Venture Scaffold

Replace this page with your product UI.

\n", + }, + }; + + const frontendAuxFilesByStack: Record> = { + "nextjs-node-postgres": [], + "react-fastapi-postgres": [ + { + path: path.join(frontendDir, "index.html"), + content: + "\n
\n", + }, + { + path: path.join(frontendDir, "vite.config.js"), + content: + "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nexport default defineConfig({ plugins: [react()] });\n", + }, + ], + "sveltekit-supabase": [ + { + path: path.join(frontendDir, "vite.config.js"), + content: "import { defineConfig } from 'vite';\nexport default defineConfig({});\n", + }, + { + path: path.join(frontendDir, "svelte.config.js"), + content: "export default { };\n", + }, + ], + }; + + const files: Array<{ path: string; content: string }> = [ + { + path: path.join(scaffoldRoot, "README.md"), + content: `# ${params.plan.name}\n\nProblem: ${params.plan.problem}\nUsers: ${params.plan.users}\nMonetization: ${params.plan.monetization}\nStack: ${params.plan.stack}\n\nThis scaffold was generated from venture_studio plan ${params.plan.id}.\n`, + }, + { + path: path.join(scaffoldRoot, "docker-compose.yml"), + content: + 'version: "3.9"\nservices:\n db:\n image: postgres:16\n environment:\n POSTGRES_USER: app\n POSTGRES_PASSWORD: app\n POSTGRES_DB: app\n ports:\n - "5432:5432"\n volumes:\n - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql\n backend:\n build: ./backend\n ports:\n - "8080:8080"\n depends_on:\n - db\n frontend:\n build: ./frontend\n ports:\n - "3000:3000"\n depends_on:\n - backend\n', + }, + { + path: path.join(dbDir, "init.sql"), + content: + "CREATE TABLE IF NOT EXISTS accounts (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n created_at TIMESTAMPTZ DEFAULT NOW()\n);\n", + }, + { + path: path.join(backendDir, "Dockerfile"), + content: backendDockerfile, + }, + { + path: path.join(backendDir, "package.json"), + content: `${backendPackageJson}\n`, + }, + { + path: path.join(backendDir, "server.js"), + content: + "import express from 'express';\nimport cors from 'cors';\nimport pkg from 'pg';\nconst { Pool } = pkg;\nconst app = express();\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL ?? 'postgres://app:app@db:5432/app' });\napp.use(cors());\napp.get('/health', async (_req, res) => {\n const client = await pool.connect();\n try { await client.query('select 1'); res.json({ ok: true }); }\n finally { client.release(); }\n});\napp.listen(8080, () => console.log('backend on :8080'));\n", + }, + { + path: path.join(backendDir, "requirements.txt"), + content: "fastapi==0.115.5\nuvicorn==0.32.1\npsycopg[binary]==3.2.3\n", + }, + { + path: path.join(backendDir, "main.py"), + content: + "from fastapi import FastAPI\napp = FastAPI()\n@app.get('/health')\ndef health():\n return {'ok': True}\n", + }, + { + path: path.join(frontendDir, "Dockerfile"), + content: + 'FROM node:22-alpine\nWORKDIR /app\nCOPY package.json package.json\nRUN npm install\nCOPY . .\nEXPOSE 3000\nCMD ["npm","run","dev"]\n', + }, + { + path: path.join(frontendDir, "package.json"), + content: `${frontendPackageJsonByStack[params.plan.stack]}\n`, + }, + { + path: path.join(frontendDir, frontendEntryByStack[params.plan.stack].file), + content: frontendEntryByStack[params.plan.stack].content, + }, + ...frontendAuxFilesByStack[params.plan.stack], + { + path: path.join(scaffoldRoot, "DEPENDENCIES.md"), + content: + "# Dependency setup\n\n- Backend dependencies are declared in `backend/package.json` (Node) and `backend/requirements.txt` (Python fallback for FastAPI stack).\n- Frontend dependencies are declared in `frontend/package.json` according to the selected stack.\n- Start services with: `docker compose up --build`\n- Windows PowerShell helper: `./scripts/dev.ps1`\n- Windows CMD helper: `scripts\\dev.cmd`\n", + }, + { + path: path.join(scaffoldRoot, "scripts", "dev.ps1"), + content: + "$ErrorActionPreference = 'Stop'\nSet-Location -Path $PSScriptRoot\nSet-Location -Path ..\ndocker compose up --build\n", + }, + { + path: path.join(scaffoldRoot, "scripts", "dev.cmd"), + content: "@echo off\ncd /d %~dp0\ncd ..\ndocker compose up --build\n", + }, + ]; + + const filteredFiles = + params.plan.stack === "react-fastapi-postgres" + ? files.filter( + (file) => + !file.path.endsWith(path.join("backend", "package.json")) && + !file.path.endsWith(path.join("backend", "server.js")), + ) + : files.filter( + (file) => + !file.path.endsWith(path.join("backend", "requirements.txt")) && + !file.path.endsWith(path.join("backend", "main.py")), + ); + + for (const file of filteredFiles) { + await fs.mkdir(path.dirname(file.path), { recursive: true }); + await fs.writeFile(file.path, file.content, "utf-8"); + } + + return { scaffoldRoot, createdFiles: filteredFiles.map((file) => file.path) }; +} + +export function createVentureStudioTool(options: { workspaceDir: string }): AnyAgentTool { + return { + label: "Venture Studio", + name: "venture_studio", + description: + "Track web/forum pain-point research and generate monetized app plans, workflows, and build scaffolds.", + parameters: VentureStudioToolSchema, + execute: async (_callId, input) => { + const params = (input ?? {}) as Record; + const action = (readStringParam(params, "action") ?? "list_findings") as VentureAction; + const statePath = resolveWorkspacePath({ + workspaceDir: options.workspaceDir, + rawPath: readStringParam(params, "path"), + fallback: "venture-studio/state.json", + }); + + if (action === "init") { + const state = defaultState(); + await writeState(statePath, state); + return jsonResult({ action, statePath, state }); + } + + const current = await readState(statePath); + if (!current) { + throw new ToolInputError( + `venture studio state not found at ${statePath}. Run action=init first.`, + ); + } + + if (action === "add_finding") { + const title = readStringParam(params, "title", { required: true }); + const painPoint = readStringParam(params, "painPoint", { required: true }); + const targetCustomer = readStringParam(params, "targetCustomer", { required: true }); + const sourceType = (readStringParam(params, "sourceType") ?? "other") as SourceType; + const duplicate = current.findings.find( + (finding) => finding.title === title && finding.targetCustomer === targetCustomer, + ); + if (duplicate) { + return jsonResult({ + action, + statePath, + deduped: true, + finding: duplicate, + totalFindings: current.findings.length, + }); + } + + const finding: ResearchFinding = { + id: nextSequenceId( + "finding", + current.findings.map((entry) => entry.id), + ), + sourceType, + sourceUrl: readStringParam(params, "sourceUrl"), + title, + painPoint, + targetCustomer, + urgency: (readStringParam(params, "urgency") ?? "medium") as UrgencyLevel, + willingnessToPay: readStringParam(params, "willingnessToPay"), + observedAt: new Date().toISOString(), + }; + const next: VentureStudioState = { + ...current, + findings: [...current.findings, finding], + }; + await writeState(statePath, next); + return jsonResult({ action, statePath, finding, totalFindings: next.findings.length }); + } + + if (action === "list_findings") { + return jsonResult({ action, statePath, findings: current.findings }); + } + + if (action === "plan_apps") { + const appCount = readNumberParam(params, "appCount", { integer: true }) ?? 3; + const requestedFindingIds = readStringArrayParam(params, "findingIds") ?? []; + const selectedFindings = + requestedFindingIds.length > 0 + ? current.findings.filter((finding) => requestedFindingIds.includes(finding.id)) + : sortFindingsByOpportunity(current.findings).slice(0, appCount); + if (selectedFindings.length === 0) { + throw new ToolInputError("No findings available for planning. Add findings first."); + } + + const outputDir = resolveWorkspacePath({ + workspaceDir: options.workspaceDir, + rawPath: readStringParam(params, "outputDir"), + fallback: "venture-studio/plans", + }); + const stack = (readStringParam(params, "stack") ?? "nextjs-node-postgres") as StackOption; + + const newPlans: AppPlan[] = []; + for (const finding of selectedFindings.slice(0, appCount)) { + const appName = + readStringParam(params, "appName") ?? + `${finding.targetCustomer} ${finding.title}`.replace(/\s+/g, " ").trim(); + const existingIds = [...current.plans, ...newPlans].map((plan) => plan.id); + const planIdBase = toSlug(appName) || "app-plan"; + const planId = nextSequenceId(planIdBase, existingIds); + const monetization = + readStringParam(params, "monetization") ?? + finding.willingnessToPay ?? + "Subscription tiers with usage-based enterprise add-ons"; + const thesis = + readStringParam(params, "thesis") ?? + `Own a mission-critical workflow for ${finding.targetCustomer} where urgency is ${finding.urgency}, then compound growth through integrations, data network effects, and enterprise expansion.`; + + const artifacts = await writePlanArtifacts({ + outputDir, + planId, + appName, + problem: finding.painPoint, + users: finding.targetCustomer, + monetization, + thesis, + stack, + findings: [finding], + }); + + newPlans.push({ + id: planId, + name: appName, + problem: finding.painPoint, + users: finding.targetCustomer, + monetization, + billionDollarThesis: thesis, + stack, + workflowPath: artifacts.workflowPath, + docPath: artifacts.docPath, + specPath: artifacts.specPath, + basedOnFindingIds: [finding.id], + createdAt: new Date().toISOString(), + }); + } + + const discussionPath = await writeDiscussionDoc(outputDir, newPlans); + const next: VentureStudioState = { + ...current, + plans: [...current.plans, ...newPlans], + }; + await writeState(statePath, next); + return jsonResult({ + action, + statePath, + discussionPath, + generatedPlans: newPlans, + totalPlans: next.plans.length, + }); + } + + if (action === "list_plans") { + return jsonResult({ action, statePath, plans: current.plans }); + } + + if (action === "build_scaffold") { + const planId = readStringParam(params, "planId", { required: true }); + const plan = current.plans.find((entry) => entry.id === planId); + if (!plan) { + throw new ToolInputError(`Unknown planId: ${planId}`); + } + const scaffold = await buildScaffold({ + workspaceDir: options.workspaceDir, + appRootDirRaw: readStringParam(params, "appRootDir"), + plan, + }); + return jsonResult({ action, planId, ...scaffold }); + } + + throw new ToolInputError("Unknown action."); + }, + }; +}