feat: /plan multiselect + end-of-plan question extension
This commit is contained in:
parent
07f176b562
commit
ed70d596ec
@ -14,18 +14,21 @@ const clackHoisted = vi.hoisted(() => {
|
|||||||
let selectCalls = 0;
|
let selectCalls = 0;
|
||||||
const select = vi.fn(async ({ options, message }: any) => {
|
const select = vi.fn(async ({ options, message }: any) => {
|
||||||
selectCalls += 1;
|
selectCalls += 1;
|
||||||
// First: choose a section (pick first).
|
|
||||||
if (String(message).includes("Choose a section")) {
|
if (String(message).includes("Choose a section")) {
|
||||||
|
// First time: choose first section, second time: review.
|
||||||
if (selectCalls > 1) return "__review";
|
if (selectCalls > 1) return "__review";
|
||||||
return options[0].value;
|
return options[0].value;
|
||||||
}
|
}
|
||||||
// Second: action selection etc.
|
|
||||||
return options[0].value;
|
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 text = vi.fn(async ({ initialValue }: any) => initialValue ?? "");
|
||||||
const confirm = vi.fn(async () => true);
|
const confirm = vi.fn(async () => true);
|
||||||
const isCancel = vi.fn((v: any) => v === Symbol.for("clack:cancel"));
|
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 () => {
|
vi.mock("@clack/prompts", async () => {
|
||||||
@ -33,12 +36,16 @@ vi.mock("@clack/prompts", async () => {
|
|||||||
confirm: clackHoisted.confirm,
|
confirm: clackHoisted.confirm,
|
||||||
isCancel: clackHoisted.isCancel,
|
isCancel: clackHoisted.isCancel,
|
||||||
select: clackHoisted.select,
|
select: clackHoisted.select,
|
||||||
|
multiselect: clackHoisted.multiselect,
|
||||||
text: clackHoisted.text,
|
text: clackHoisted.text,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
|
let calls = 0;
|
||||||
const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => {
|
const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => {
|
||||||
|
calls += 1;
|
||||||
|
|
||||||
if (String(prompt).includes("Generate a compact questionnaire")) {
|
if (String(prompt).includes("Generate a compact questionnaire")) {
|
||||||
return {
|
return {
|
||||||
payloads: [
|
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 {
|
return {
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { ClawdbotPluginApi } from "../../plugins/types.js";
|
||||||
import type { CommandHandler } from "./commands-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` +
|
`- 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` +
|
`- 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` +
|
`- 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}`
|
`GOAL:\n${goal}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -231,7 +245,9 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand
|
|||||||
});
|
});
|
||||||
|
|
||||||
const questionSet = (llmResult as any).details?.json as QuestionSet;
|
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) {
|
if (questions.length === 0) {
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
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.
|
// Group by section.
|
||||||
const sectionOrder: string[] = [];
|
const sectionOrder: string[] = [];
|
||||||
const bySection = new Map<string, QuestionSpec[]>();
|
const bySection = new Map<string, QuestionSpec[]>();
|
||||||
@ -294,22 +313,35 @@ export const handlePlanCommand: CommandHandler = async (params, allowTextCommand
|
|||||||
answers[q.id] = res;
|
answers[q.id] = res;
|
||||||
}
|
}
|
||||||
} else if (q.kind === "multiselect") {
|
} else if (q.kind === "multiselect") {
|
||||||
// clack multiselect isn't currently imported here to keep deps minimal in this handler.
|
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
||||||
// Fallback to freeform comma-separated input.
|
if (opts.length === 0) {
|
||||||
const res = await text({
|
const res = await text({
|
||||||
message: `${q.prompt} (comma-separated)`,
|
message: q.prompt,
|
||||||
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
|
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
|
||||||
placeholder: q.placeholder,
|
placeholder: q.placeholder,
|
||||||
});
|
});
|
||||||
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
const arr = String(res)
|
const arr = String(res)
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (required && arr.length === 0) {
|
if (required && arr.length === 0) {
|
||||||
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
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 {
|
} else {
|
||||||
const res = await text({
|
const res = await text({
|
||||||
message: q.prompt,
|
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);
|
const md = buildPlanMarkdown(goal, answers, questions);
|
||||||
await writeTextFile(planMdPath, md);
|
await writeTextFile(planMdPath, md);
|
||||||
await writeJson(metaPath, {
|
await writeJson(metaPath, {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user