feat: /plan multiselect + end-of-plan question extension

This commit is contained in:
Vignesh Natarajan 2026-01-24 17:12:17 -08:00
parent 07f176b562
commit ed70d596ec
2 changed files with 192 additions and 21 deletions

View File

@ -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 }) }],
};

View File

@ -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": <same>, "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<string, QuestionSpec[]>();
@ -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, {