diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index b8a9691a3..d19bd2ede 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Show current status.", textAlias: "/status", }), + defineChatCommand({ + key: "plan", + nativeName: "plan", + description: "Interactive project planning (/plan) with saved artifacts.", + textAlias: "/plan", + acceptsArgs: true, + }), defineChatCommand({ key: "plans", nativeName: "plans", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 4f734ab30..93b0212b6 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -28,6 +28,7 @@ import { } from "./commands-session.js"; import { handlePluginCommand } from "./commands-plugin.js"; import { handlePlansCommand } from "./commands-plans.js"; +import { handlePlanCommand } from "./commands-plan.js"; import type { CommandHandler, CommandHandlerResult, @@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [ handleRestartCommand, handleTtsCommands, handleHelpCommand, + handlePlanCommand, handlePlansCommand, handleCommandsListCommand, handleStatusCommand, diff --git a/src/auto-reply/reply/commands-plan.test.ts b/src/auto-reply/reply/commands-plan.test.ts new file mode 100644 index 000000000..ed00bb908 --- /dev/null +++ b/src/auto-reply/reply/commands-plan.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +// Mock clack prompts to simulate TUI interaction. +const clackHoisted = vi.hoisted(() => { + let selectCalls = 0; + const select = vi.fn(async ({ options, message }: any) => { + selectCalls += 1; + // First: choose a section (pick first). + if (String(message).includes("Choose a section")) { + if (selectCalls > 1) return "__review"; + return options[0].value; + } + // Second: action selection etc. + return options[0].value; + }); + const text = vi.fn(async ({ initialValue }: any) => initialValue ?? ""); + const confirm = vi.fn(async () => true); + const isCancel = vi.fn((v: any) => v === Symbol.for("clack:cancel")); + return { select, text, confirm, isCancel }; +}); + +vi.mock("@clack/prompts", async () => { + return { + confirm: clackHoisted.confirm, + isCancel: clackHoisted.isCancel, + select: clackHoisted.select, + text: clackHoisted.text, + }; +}); + +const hoisted = vi.hoisted(() => { + const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => { + if (String(prompt).includes("Generate a compact questionnaire")) { + return { + payloads: [ + { + text: JSON.stringify({ + goal: "demo", + questions: [ + { + id: "budget", + section: "Constraints", + prompt: "Budget?", + kind: "select", + required: true, + options: ["$", "$$"], + }, + { + id: "deadline", + section: "Timeline", + prompt: "Deadline?", + kind: "text", + }, + ], + }), + }, + ], + }; + } + + return { + payloads: [{ text: JSON.stringify({ ok: true }) }], + }; + }); + + return { runEmbeddedPiAgent }; +}); + +// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form. +vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({ + runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent, +})); +vi.mock("../../../agents/pi-embedded-runner.js", () => ({ + runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent, +})); + +let testWorkspaceDir = os.tmpdir(); + +beforeAll(async () => { + testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plan-")); + await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8"); +}); + +afterAll(async () => { + await fs.rm(testWorkspaceDir, { recursive: true, force: true }); +}); + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "cli", + CommandAuthorized: true, + Provider: "cli", + Surface: "cli", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: testWorkspaceDir, + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "cli", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("/plan TUI", () => { + it("creates a plan directory and writes plan.md + answers.json", async () => { + // Make TTY true for interactive mode. + (process.stdin as any).isTTY = true; + (process.stdout as any).isTTY = true; + + const cfg = { + commands: { text: true }, + agents: { defaults: { model: { primary: "openai/mock-1" } } }, + } as ClawdbotConfig; + + const params = buildParams("/plan plan a trip", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Plan saved"); + + const plansDir = path.join(testWorkspaceDir, "plans"); + const entries = await fs.readdir(plansDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + expect(dirs.length).toBeGreaterThan(0); + + const createdDir = path.join(plansDir, dirs[0]); + const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8"); + const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8")); + + expect(planMd).toContain("# Plan"); + expect(Object.keys(answers).length).toBeGreaterThan(0); + }); +}); diff --git a/src/auto-reply/reply/commands-plan.ts b/src/auto-reply/reply/commands-plan.ts new file mode 100644 index 000000000..18853a3ed --- /dev/null +++ b/src/auto-reply/reply/commands-plan.ts @@ -0,0 +1,350 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { confirm, isCancel, select, text } from "@clack/prompts"; + +import type { ClawdbotPluginApi } from "../../plugins/types.js"; +import type { CommandHandler } from "./commands-types.js"; + +// We import the llm-task tool directly (Clawdbot-native) so standalone TUI runs can +// still use the agent's configured models via the embedded runner. +import { createLlmTaskTool } from "../../../extensions/llm-task/src/llm-task-tool.js"; + +type QuestionKind = "text" | "select" | "multiselect" | "confirm"; + +export type QuestionSpec = { + id: string; + section: string; + prompt: string; + kind: QuestionKind; + required?: boolean; + options?: string[]; + placeholder?: string; +}; + +type QuestionSet = { + title?: string; + goal: string; + questions: QuestionSpec[]; +}; + +const QUESTIONS_SCHEMA = { + type: "object", + properties: { + title: { type: "string" }, + goal: { type: "string" }, + questions: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + section: { type: "string" }, + prompt: { type: "string" }, + kind: { type: "string", enum: ["text", "select", "multiselect", "confirm"] }, + required: { type: "boolean" }, + options: { type: "array", items: { type: "string" } }, + placeholder: { type: "string" }, + }, + required: ["id", "section", "prompt", "kind"], + additionalProperties: false, + }, + }, + }, + required: ["goal", "questions"], + additionalProperties: false, +}; + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +function nowStamp() { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad( + d.getMinutes(), + )}`; +} + +async function safeReadJson(filePath: string): Promise { + try { + const text = await fs.readFile(filePath, "utf-8"); + return JSON.parse(text); + } catch (err: any) { + if (err?.code === "ENOENT") return null; + return null; + } +} + +async function writeJson(filePath: string, value: any) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8"); +} + +async function writeTextFile(filePath: string, textContent: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, textContent, "utf-8"); +} + +function buildLlmQuestionPrompt(goal: string) { + return ( + `You are helping a user plan a project. Generate a compact questionnaire grouped into sections.\n` + + `Return JSON matching the provided schema.\n\n` + + `Requirements:\n` + + `- Ask only high-signal questions. Prefer multiple choice when the user can pick from common options.\n` + + `- Use sections like Goals, Constraints, Inputs, Outputs, Timeline, Risks (as appropriate).\n` + + `- Keep it to ~8-15 questions unless the goal clearly needs more.\n` + + `- Use stable ids (snake_case).\n\n` + + `GOAL:\n${goal}` + ); +} + +function buildPlanMarkdown(goal: string, answers: Record, questions: QuestionSpec[]) { + const byId = new Map(questions.map((q) => [q.id, q] as const)); + const sections = new Map>(); + for (const [id, a] of Object.entries(answers)) { + const q = byId.get(id); + if (!q) continue; + const list = sections.get(q.section) ?? []; + list.push({ q, a }); + sections.set(q.section, list); + } + + const lines: string[] = []; + lines.push(`# Plan\n\n## Goal\n${goal}\n`); + for (const [section, items] of sections) { + lines.push(`## ${section}`); + for (const { q, a } of items) { + const rendered = Array.isArray(a) + ? a.join(", ") + : typeof a === "boolean" + ? a + ? "yes" + : "no" + : String(a); + lines.push(`- **${q.prompt}**: ${rendered}`); + } + lines.push(""); + } + + return lines.join("\n").trim() + "\n"; +} + +function createFakePluginApi(cfg: any, workspaceDir: string): ClawdbotPluginApi { + // Minimal stub for llm-task tool; it only relies on `config` and `pluginConfig`. + return { + id: "local", + name: "local", + source: "internal", + config: cfg, + pluginConfig: {}, + runtime: {} as any, + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + registerTool() {}, + registerHook() {}, + registerHttpHandler() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerCommand() {}, + resolvePath: (input: string) => path.resolve(workspaceDir, input), + on() {}, + } as any; +} + +export const handlePlanCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + if (!params.command.commandBodyNormalized.startsWith("/plan")) return null; + + // Only implement interactive plan builder in the local CLI. + const isInteractiveCli = + params.ctx.CommandSource === "cli" && + Boolean(process.stdin.isTTY) && + Boolean(process.stdout.isTTY); + if (!isInteractiveCli) return null; + + const raw = + params.ctx.BodyForCommands ?? + params.ctx.CommandBody ?? + params.ctx.RawBody ?? + params.ctx.BodyStripped ?? + params.ctx.Body ?? + "/plan"; + + const goal = raw.replace(/^\s*\/plan\s*/i, "").trim(); + if (!goal) { + return { + shouldContinue: false, + reply: { text: "Usage: /plan " }, + }; + } + + const defaultName = `${nowStamp()}-${slugify(goal.split(/\s+/).slice(0, 6).join(" ")) || "plan"}`; + const chosenName = await text({ + message: "Plan name (folder under workspace/plans/)", + initialValue: defaultName, + validate: (v) => (!v?.trim() ? "Name required" : undefined), + }); + if (isCancel(chosenName)) { + return { shouldContinue: false, reply: { text: "Cancelled." } }; + } + + const planName = slugify(String(chosenName).trim()) || defaultName; + const plansDir = path.join(params.workspaceDir, "plans"); + const planDir = path.join(plansDir, planName); + await fs.mkdir(planDir, { recursive: true }); + + const metaPath = path.join(planDir, "meta.json"); + const answersPath = path.join(planDir, "answers.json"); + const planMdPath = path.join(planDir, "plan.md"); + + const existingAnswers = (await safeReadJson(answersPath)) ?? {}; + + await writeJson(metaPath, { + name: planName, + goal, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Ask the LLM to generate the question set. + const api = createFakePluginApi(params.cfg, params.workspaceDir); + const tool = createLlmTaskTool(api); + + const llmResult = await tool.execute("llm-task", { + prompt: buildLlmQuestionPrompt(goal), + input: { goal, existingAnswers }, + schema: QUESTIONS_SCHEMA, + }); + + const questionSet = (llmResult as any).details?.json as QuestionSet; + const questions = Array.isArray(questionSet?.questions) ? questionSet.questions : []; + if (questions.length === 0) { + return { + shouldContinue: false, + reply: { text: "No questions generated (unexpected)." }, + }; + } + + // Group by section. + const sectionOrder: string[] = []; + const bySection = new Map(); + for (const q of questions) { + const section = q.section?.trim() || "General"; + if (!bySection.has(section)) sectionOrder.push(section); + const list = bySection.get(section) ?? []; + list.push(q); + bySection.set(section, list); + } + + const answers: Record = { ...existingAnswers }; + + // Iterate sections with a review loop. + while (true) { + const section = await select({ + message: "Choose a section", + options: [ + ...sectionOrder.map((s) => ({ label: s, value: s })), + { label: "Review + finalize", value: "__review" }, + ], + }); + if (isCancel(section)) { + return { shouldContinue: false, reply: { text: "Cancelled." } }; + } + + if (section === "__review") break; + + const qs = bySection.get(String(section)) ?? []; + for (const q of qs) { + const existing = answers[q.id]; + const required = Boolean(q.required); + + if (q.kind === "confirm") { + const res = await confirm({ message: q.prompt, initialValue: Boolean(existing ?? false) }); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + answers[q.id] = Boolean(res); + } else if (q.kind === "select") { + const opts = (q.options ?? []).map((o) => ({ label: o, value: o })); + if (opts.length === 0) { + const res = await text({ + message: q.prompt, + initialValue: existing ? String(existing) : "", + }); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + const v = String(res).trim(); + if (required && !v) + return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } }; + answers[q.id] = v; + } else { + const res = await select({ message: q.prompt, options: opts }); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + answers[q.id] = res; + } + } else if (q.kind === "multiselect") { + // clack multiselect isn't currently imported here to keep deps minimal in this handler. + // Fallback to freeform comma-separated input. + const res = await text({ + message: `${q.prompt} (comma-separated)`, + initialValue: Array.isArray(existing) ? existing.join(", ") : "", + placeholder: q.placeholder, + }); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + const arr = String(res) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (required && arr.length === 0) { + return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } }; + } + answers[q.id] = arr; + } else { + const res = await text({ + message: q.prompt, + initialValue: existing ? String(existing) : "", + placeholder: q.placeholder, + }); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + const v = String(res).trim(); + if (required && !v) { + return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } }; + } + answers[q.id] = v; + } + + await writeJson(answersPath, answers); + } + } + + const md = buildPlanMarkdown(goal, answers, questions); + await writeTextFile(planMdPath, md); + await writeJson(metaPath, { + name: planName, + goal, + createdAt: (await safeReadJson(metaPath))?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + return { + shouldContinue: false, + reply: { + text: + `✅ Plan saved: ${planName}\n` + + `- ${path.relative(params.workspaceDir, planMdPath)}\n` + + `- ${path.relative(params.workspaceDir, answersPath)}\n\n` + + `Use /plans show ${planName} to view it.`, + }, + }; +};