diff --git a/src/auto-reply/reply/commands-plan.test.ts b/src/auto-reply/reply/commands-plan.test.ts index ed00bb908..dfd4e9f6f 100644 --- a/src/auto-reply/reply/commands-plan.test.ts +++ b/src/auto-reply/reply/commands-plan.test.ts @@ -14,18 +14,21 @@ 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")) { + // First time: choose first section, second time: review. if (selectCalls > 1) return "__review"; return options[0].value; } - // Second: action selection etc. return options[0].value; }); + const multiselect = vi.fn(async ({ options }: any) => { + // pick all + return options.map((o: any) => o.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 }; + return { select, multiselect, text, confirm, isCancel }; }); vi.mock("@clack/prompts", async () => { @@ -33,12 +36,16 @@ vi.mock("@clack/prompts", async () => { confirm: clackHoisted.confirm, isCancel: clackHoisted.isCancel, select: clackHoisted.select, + multiselect: clackHoisted.multiselect, text: clackHoisted.text, }; }); const hoisted = vi.hoisted(() => { + let calls = 0; const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => { + calls += 1; + if (String(prompt).includes("Generate a compact questionnaire")) { return { payloads: [ @@ -67,6 +74,29 @@ const hoisted = vi.hoisted(() => { }; } + // Extend prompt should return one multiselect question. + if (String(prompt).includes("propose any missing high-signal questions")) { + return { + payloads: [ + { + text: JSON.stringify({ + goal: "demo", + questions: [ + { + id: "transport", + section: "Constraints", + prompt: "Preferred transport?", + kind: "multiselect", + required: true, + options: ["Car", "Plane"], + }, + ], + }), + }, + ], + }; + } + return { payloads: [{ text: JSON.stringify({ ok: true }) }], }; diff --git a/src/auto-reply/reply/commands-plan.ts b/src/auto-reply/reply/commands-plan.ts index 18853a3ed..ec8e8417a 100644 --- a/src/auto-reply/reply/commands-plan.ts +++ b/src/auto-reply/reply/commands-plan.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { confirm, isCancel, select, text } from "@clack/prompts"; +import { confirm, isCancel, select, text, multiselect } from "@clack/prompts"; import type { ClawdbotPluginApi } from "../../plugins/types.js"; import type { CommandHandler } from "./commands-types.js"; @@ -99,7 +99,21 @@ function buildLlmQuestionPrompt(goal: string) { `- 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` + + `- Use stable ids (snake_case).\n` + + `- Use multiselect when multiple options may apply.\n\n` + + `GOAL:\n${goal}` + ); +} + +function buildLlmExtendPrompt(goal: string) { + return ( + `You are helping a user plan a project.\n` + + `Given the current answers and existing question ids, propose any missing high-signal questions.\n` + + `Return JSON matching the provided schema.\n\n` + + `Rules:\n` + + `- If nothing important is missing, return {"goal": , "questions": []}.\n` + + `- Do not repeat existing question ids.\n` + + `- Keep it short: up to 5 additional questions.\n\n` + `GOAL:\n${goal}` ); } @@ -231,7 +245,9 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand }); const questionSet = (llmResult as any).details?.json as QuestionSet; - const questions = Array.isArray(questionSet?.questions) ? questionSet.questions : []; + const questions: QuestionSpec[] = Array.isArray(questionSet?.questions) + ? questionSet.questions + : []; if (questions.length === 0) { return { shouldContinue: false, @@ -239,6 +255,9 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand }; } + const questionsPath = path.join(planDir, "questions.json"); + await writeJson(questionsPath, { goal, questions }); + // Group by section. const sectionOrder: string[] = []; const bySection = new Map(); @@ -294,22 +313,35 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand 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}` } }; + const opts = (q.options ?? []).map((o) => ({ label: o, value: o })); + if (opts.length === 0) { + const res = await text({ + message: q.prompt, + 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 multiselect({ + message: q.prompt, + options: opts, + required: required ? true : false, + } as any); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + const arr = Array.isArray(res) ? res : []; + if (required && arr.length === 0) { + return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } }; + } + answers[q.id] = arr; } - answers[q.id] = arr; } else { const res = await text({ message: q.prompt, @@ -328,6 +360,115 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand } } + // One-time dynamic extension at the end. + const existingIds = new Set(questions.map((q) => q.id)); + const extend = await tool.execute("llm-task", { + prompt: buildLlmExtendPrompt(goal), + input: { + goal, + answers, + existingQuestionIds: Array.from(existingIds), + }, + schema: QUESTIONS_SCHEMA, + }); + + const extendSet = (extend as any).details?.json as QuestionSet; + const extraQuestions: QuestionSpec[] = Array.isArray(extendSet?.questions) + ? extendSet.questions + : []; + const filteredExtras = extraQuestions.filter((q) => q && q.id && !existingIds.has(q.id)); + + if (filteredExtras.length > 0) { + const ok = await confirm({ + message: `I have ${filteredExtras.length} more question(s) to tighten the plan. Add them?`, + }); + if (!isCancel(ok) && ok === true) { + for (const q of filteredExtras) { + const section = q.section?.trim() || "General"; + if (!bySection.has(section)) { + sectionOrder.push(section); + bySection.set(section, []); + } + bySection.get(section)!.push(q); + questions.push(q); + existingIds.add(q.id); + } + + // Ask only the newly added questions (once). + for (const q of filteredExtras) { + 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") { + const opts = (q.options ?? []).map((o) => ({ label: o, value: o })); + if (opts.length === 0) { + const res = await text({ + message: q.prompt, + initialValue: Array.isArray(existing) ? existing.join(", ") : "", + }); + 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 multiselect({ + message: q.prompt, + options: opts, + required: required ? true : false, + } as any); + if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } }; + const arr = Array.isArray(res) ? res : []; + 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) : "", + }); + 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); + } + + await writeJson(questionsPath, { goal, questions }); + } + } + const md = buildPlanMarkdown(goal, answers, questions); await writeTextFile(planMdPath, md); await writeJson(metaPath, {