Compare commits

...

7 Commits

Author SHA1 Message Date
Shakker
d57c9ce9f1
Merge branch 'main' into feat/plan-mode 2026-01-26 18:00:53 +00:00
Vignesh Natarajan
b6f9c31d05 test: expand /plan TUI coverage 2026-01-24 17:19:10 -08:00
Vignesh Natarajan
ed70d596ec feat: /plan multiselect + end-of-plan question extension 2026-01-24 17:12:17 -08:00
Vignesh Natarajan
07f176b562 feat: interactive /plan questionnaire in CLI 2026-01-24 16:54:30 -08:00
Vignesh Natarajan
ecf4b1a527 feat: /plans interactive picker in CLI 2026-01-24 16:40:24 -08:00
Vignesh Natarajan
e8287571e2 feat: add /plans command to manage planning artifacts 2026-01-24 16:35:32 -08:00
Vignesh Natarajan
1179c3a88f feat: add /plan planning-mode instructions 2026-01-24 16:31:47 -08:00
8 changed files with 1230 additions and 0 deletions

View File

@ -310,6 +310,16 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("/status shows Reasoning"); expect(prompt).toContain("/status shows Reasoning");
}); });
it("includes /plan mode instructions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
});
expect(prompt).toContain("## Plan Mode (/plan)");
expect(prompt).toContain("If the user message starts with /plan");
expect(prompt).toContain("plans/<timestamp>-<slug>/");
});
it("builds runtime line with agent and channel details", () => { it("builds runtime line with agent and channel details", () => {
const line = buildRuntimeLine( const line = buildRuntimeLine(
{ {

View File

@ -558,6 +558,27 @@ export function buildAgentSystemPrompt(params: {
); );
} }
// Plan mode is conversational but stateful: compile a messy goal into a structured artifact.
if (!isMinimal) {
lines.push(
"## Plan Mode (/plan)",
"If the user message starts with /plan, enter *planning mode*.",
"- Treat everything after /plan as the goal.",
"- Ask targeted follow-up questions (one at a time) to remove ambiguity.",
"- Persist state in the workspace so planning can resume without losing context.",
"",
"Artifacts:",
"- Create a directory plans/<timestamp>-<slug>/ in the workspace.",
"- Maintain plans/<id>/answers.json (incremental) and plans/<id>/plan.md (human-readable).",
"- Optionally create plans/<id>/plan.json (structured) when helpful.",
"",
"Rules:",
"- Be token-efficient: do not restate the entire plan on every turn; ask the next question.",
"- When enough information is collected, produce a crisp plan with milestones + next actions.",
"",
);
}
lines.push( lines.push(
"## Runtime", "## Runtime",
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),

View File

@ -157,6 +157,20 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Show current status.", description: "Show current status.",
textAlias: "/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",
description: "List/show/rename saved /plan artifacts.",
textAlias: "/plans",
acceptsArgs: true,
}),
defineChatCommand({ defineChatCommand({
key: "allowlist", key: "allowlist",
description: "List/add/remove allowlist entries.", description: "List/add/remove allowlist entries.",

View File

@ -27,6 +27,8 @@ import {
handleUsageCommand, handleUsageCommand,
} from "./commands-session.js"; } from "./commands-session.js";
import { handlePluginCommand } from "./commands-plugin.js"; import { handlePluginCommand } from "./commands-plugin.js";
import { handlePlansCommand } from "./commands-plans.js";
import { handlePlanCommand } from "./commands-plan.js";
import type { import type {
CommandHandler, CommandHandler,
CommandHandlerResult, CommandHandlerResult,
@ -43,6 +45,8 @@ const HANDLERS: CommandHandler[] = [
handleRestartCommand, handleRestartCommand,
handleTtsCommands, handleTtsCommands,
handleHelpCommand, handleHelpCommand,
handlePlanCommand,
handlePlansCommand,
handleCommandsListCommand, handleCommandsListCommand,
handleStatusCommand, handleStatusCommand,
handleAllowlistCommand, handleAllowlistCommand,

View File

@ -0,0 +1,396 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, 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";
const CANCEL = Symbol.for("clack:cancel");
const hoisted = vi.hoisted(() => {
const state = {
selectQueue: [] as any[],
textQueue: [] as any[],
confirmQueue: [] as any[],
multiselectQueue: [] as any[],
// Embedded runner control
baseQuestions: [
{
id: "budget",
section: "Constraints",
prompt: "Budget?",
kind: "select",
required: true,
options: ["$", "$$"],
},
{
id: "deadline",
section: "Timeline",
prompt: "Deadline?",
kind: "text",
},
] as any[],
extraQuestions: [
{
id: "transport",
section: "Constraints",
prompt: "Preferred transport?",
kind: "multiselect",
required: true,
options: ["Car", "Plane"],
},
] as any[],
};
function resetQueues() {
state.selectQueue = [];
state.textQueue = [];
state.confirmQueue = [];
state.multiselectQueue = [];
}
const clack = {
select: vi.fn(async ({ options, message }: any) => {
const queued = state.selectQueue.shift();
if (queued !== undefined) return queued;
// Default behavior: choose first section once, then review.
if (String(message).includes("Choose a section")) {
// If we already chose a section once, go to review.
const already = (clack.select as any)._chosenOnce === true;
(clack.select as any)._chosenOnce = true;
if (already) return "__review";
return options[0].value;
}
return options[0].value;
}),
text: vi.fn(async ({ initialValue }: any) => {
const queued = state.textQueue.shift();
if (queued !== undefined) return queued;
return initialValue ?? "";
}),
confirm: vi.fn(async () => {
const queued = state.confirmQueue.shift();
if (queued !== undefined) return queued;
return true;
}),
multiselect: vi.fn(async ({ options }: any) => {
const queued = state.multiselectQueue.shift();
if (queued !== undefined) return queued;
return options.map((o: any) => o.value);
}),
isCancel: vi.fn((v: any) => v === CANCEL),
};
const embedded = {
runEmbeddedPiAgent: vi.fn(async ({ prompt }: any) => {
if (String(prompt).includes("Generate a compact questionnaire")) {
return {
payloads: [
{
text: JSON.stringify({
goal: "demo",
questions: state.baseQuestions,
}),
},
],
};
}
if (String(prompt).includes("propose any missing high-signal questions")) {
return {
payloads: [
{
text: JSON.stringify({
goal: "demo",
questions: state.extraQuestions,
}),
},
],
};
}
return {
payloads: [{ text: JSON.stringify({ ok: true }) }],
};
}),
};
return { state, resetQueues, clack, embedded };
});
// Mock clack prompts to simulate TUI interaction.
vi.mock("@clack/prompts", async () => {
return {
confirm: hoisted.clack.confirm,
isCancel: hoisted.clack.isCancel,
select: hoisted.clack.select,
multiselect: hoisted.clack.multiselect,
text: hoisted.clack.text,
};
});
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
}));
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
runEmbeddedPiAgent: hoisted.embedded.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 });
});
beforeEach(() => {
hoisted.resetQueues();
(hoisted.clack.select as any)._chosenOnce = false;
// defaults
hoisted.state.baseQuestions = [
{
id: "budget",
section: "Constraints",
prompt: "Budget?",
kind: "select",
required: true,
options: ["$", "$$"],
},
{
id: "deadline",
section: "Timeline",
prompt: "Deadline?",
kind: "text",
},
];
hoisted.state.extraQuestions = [
{
id: "transport",
section: "Constraints",
prompt: "Preferred transport?",
kind: "multiselect",
required: true,
options: ["Car", "Plane"],
},
];
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
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,
};
}
async function getLatestPlanDir() {
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);
// Sort for determinism (timestamp prefix in name)
dirs.sort();
return path.join(plansDir, dirs[dirs.length - 1]);
}
describe("/plan TUI", () => {
it("creates a plan directory and writes plan.md + answers.json + questions.json", async () => {
(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 result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Plan saved");
const createdDir = await getLatestPlanDir();
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"));
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
expect(planMd).toContain("# Plan");
expect(Object.keys(answers).length).toBeGreaterThan(0);
expect(questions.questions.length).toBeGreaterThan(0);
});
it("supports one-time extension at the end and uses multiselect for extra questions", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Ensure we accept the extra questions and multiselect returns both.
hoisted.state.confirmQueue.push(true);
hoisted.state.multiselectQueue.push(["Car", "Plane"]);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
const createdDir = await getLatestPlanDir();
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
expect(questions.questions.some((q: any) => q.id === "transport")).toBe(true);
expect(Array.isArray(answers.transport)).toBe(true);
expect(answers.transport).toEqual(["Car", "Plane"]);
expect(hoisted.clack.multiselect).toHaveBeenCalled();
});
it("does not ask extension confirm when extension returns no extra questions", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
hoisted.state.extraQuestions = [];
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
// confirm might still be used for other prompts; but in this template it shouldn't.
// We assert it was not called with the extension message.
const confirmCalls = (hoisted.clack.confirm as any).mock.calls as any[];
const extensionCall = confirmCalls.find((c) =>
String(c?.[0]?.message ?? "").includes("tighter the plan"),
);
expect(extensionCall).toBeUndefined();
});
it("dedupes extension questions by id", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Extension repeats an existing id.
hoisted.state.extraQuestions = [
{
id: "budget",
section: "Constraints",
prompt: "Budget again?",
kind: "select",
required: true,
options: ["$"],
},
];
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
const createdDir = await getLatestPlanDir();
const questions = JSON.parse(
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
);
const ids = questions.questions.map((q: any) => q.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it("cancels cleanly if plan name prompt is cancelled", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
hoisted.state.textQueue.push(CANCEL);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Cancelled");
});
it("errors on required multiselect when user selects nothing", async () => {
(process.stdin as any).isTTY = true;
(process.stdout as any).isTTY = true;
// Force extension with a required multiselect and accept it.
hoisted.state.confirmQueue.push(true);
hoisted.state.multiselectQueue.push([]);
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Missing required answer");
});
it("does not run TUI handler when not in a TTY", async () => {
(process.stdin as any).isTTY = false;
(process.stdout as any).isTTY = false;
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "openai/mock-1" } } },
} as ClawdbotConfig;
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
expect(result.shouldContinue).toBe(true);
});
});

View File

@ -0,0 +1,491 @@
import fs from "node:fs/promises";
import path from "node:path";
import { confirm, isCancel, select, text, multiselect } 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<any | null> {
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` +
`- 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}`
);
}
function buildPlanMarkdown(goal: string, answers: Record<string, any>, questions: QuestionSpec[]) {
const byId = new Map(questions.map((q) => [q.id, q] as const));
const sections = new Map<string, Array<{ q: QuestionSpec; a: any }>>();
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 <goal>" },
};
}
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: QuestionSpec[] = Array.isArray(questionSet?.questions)
? questionSet.questions
: [];
if (questions.length === 0) {
return {
shouldContinue: false,
reply: { text: "No questions generated (unexpected)." },
};
}
const questionsPath = path.join(planDir, "questions.json");
await writeJson(questionsPath, { goal, questions });
// Group by section.
const sectionOrder: string[] = [];
const bySection = new Map<string, QuestionSpec[]>();
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<string, any> = { ...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") {
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;
}
} 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);
}
}
// 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, {
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.`,
},
};
};

View File

@ -0,0 +1,256 @@
import fs from "node:fs/promises";
import path from "node:path";
import { confirm, isCancel, select, text } from "@clack/prompts";
import type { CommandHandler } from "./commands-types.js";
type PlansArgs = {
action: "list" | "show" | "rename" | "trash" | "help";
a?: string;
b?: string;
};
function parsePlansArgs(raw: string): PlansArgs {
const trimmed = raw.trim();
const parts = trimmed.split(/\s+/).filter(Boolean);
const cmd = parts[0]?.toLowerCase();
if (cmd !== "/plans") return { action: "help" };
const sub = parts[1]?.toLowerCase();
if (!sub || sub === "list") return { action: "list" };
if (sub === "help") return { action: "help" };
if (sub === "show") return { action: "show", a: parts[2] };
if (sub === "rename") return { action: "rename", a: parts[2], b: parts[3] };
if (sub === "trash" || sub === "delete" || sub === "rm") return { action: "trash", a: parts[2] };
// Default: treat unknown as help.
return { action: "help" };
}
async function listPlanDirs(plansDir: string): Promise<string[]> {
try {
const entries = await fs.readdir(plansDir, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => !name.startsWith("."))
.sort();
} catch (err: any) {
if (err?.code === "ENOENT") return [];
throw err;
}
}
async function readFirstExisting(paths: string[]): Promise<string | null> {
for (const p of paths) {
try {
const text = await fs.readFile(p, "utf-8");
return text;
} catch (err: any) {
if (err?.code === "ENOENT") continue;
throw err;
}
}
return null;
}
export const handlePlansCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
// Match on normalized command body
if (!params.command.commandBodyNormalized.startsWith("/plans")) return null;
const raw =
params.ctx.BodyForCommands ??
params.ctx.CommandBody ??
params.ctx.RawBody ??
params.ctx.BodyStripped ??
params.ctx.Body ??
"/plans";
const args = parsePlansArgs(raw);
const plansDir = path.join(params.workspaceDir, "plans");
const isInteractiveCli =
params.ctx.CommandSource === "cli" &&
Boolean(process.stdin.isTTY) &&
Boolean(process.stdout.isTTY);
if (args.action === "help") {
return {
shouldContinue: false,
reply: {
text:
"Usage:\n" +
" /plans list\n" +
" /plans show <name>\n" +
" /plans rename <old> <new>\n" +
" /plans trash <name>\n\n" +
"TUI:\n" +
" /plans (with no args) opens an interactive picker in the CLI.\n\n" +
"Notes:\n" +
"- Plans are stored under workspace/plans/.\n" +
"- Use /plan <goal> to start a new plan; /plans helps manage saved plan folders.",
},
};
}
if (args.action === "list") {
const names = await listPlanDirs(plansDir);
if (names.length === 0) {
return {
shouldContinue: false,
reply: { text: "No plans found yet. Create one with: /plan <goal>" },
};
}
// Interactive picker only when running in the CLI TUI.
// For chat channels (Discord/Telegram/etc.), return plain text.
const wantsInteractive =
isInteractiveCli && (raw.trim() === "/plans" || raw.trim() === "/plans list");
if (wantsInteractive) {
const selected = await select({
message: "Select a plan",
options: names
.slice()
.reverse()
.map((n) => ({ label: n, value: n })),
});
if (isCancel(selected)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
const planName = String(selected);
const action = await select({
message: `Action for ${planName}`,
options: [
{ label: "Show", value: "show" },
{ label: "Rename", value: "rename" },
{ label: "Trash", value: "trash" },
],
});
if (isCancel(action)) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
if (action === "show") {
const planDir = path.join(plansDir, planName);
const planMd = await readFirstExisting([
path.join(planDir, "plan.md"),
path.join(planDir, "README.md"),
]);
if (!planMd) {
return {
shouldContinue: false,
reply: { text: `No plan.md found for '${planName}'. Looked in: ${planDir}` },
};
}
const trimmed = planMd.trim();
const preview =
trimmed.length > 6000 ? `${trimmed.slice(0, 6000)}\n\n…(truncated)` : trimmed;
return { shouldContinue: false, reply: { text: `Plan: ${planName}\n\n${preview}` } };
}
if (action === "rename") {
const next = await text({
message: `Rename '${planName}' to:`,
initialValue: planName,
validate: (v) => (!v?.trim() ? "Name required" : undefined),
});
if (isCancel(next)) return { shouldContinue: false, reply: { text: "Cancelled." } };
const toName = String(next).trim();
if (toName && toName !== planName) {
await fs.rename(path.join(plansDir, planName), path.join(plansDir, toName));
}
return {
shouldContinue: false,
reply: { text: `Renamed plan '${planName}' → '${toName}'.` },
};
}
if (action === "trash") {
const ok = await confirm({ message: `Move '${planName}' to trash?` });
if (isCancel(ok) || ok === false) {
return { shouldContinue: false, reply: { text: "Cancelled." } };
}
const fromDir = path.join(plansDir, planName);
const trashDir = path.join(plansDir, ".trash");
await fs.mkdir(trashDir, { recursive: true });
const stamped = `${planName}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
const toDir = path.join(trashDir, stamped);
await fs.rename(fromDir, toDir);
return { shouldContinue: false, reply: { text: `Moved plan '${planName}' to trash.` } };
}
}
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (args.action === "show") {
const name = String(args.a ?? "").trim();
if (!name) {
return { shouldContinue: false, reply: { text: "Usage: /plans show <name>" } };
}
const planDir = path.join(plansDir, name);
const planMd = await readFirstExisting([
path.join(planDir, "plan.md"),
path.join(planDir, "README.md"),
]);
if (!planMd) {
return {
shouldContinue: false,
reply: { text: `No plan.md found for '${name}'. Looked in: ${planDir}` },
};
}
const trimmed = planMd.trim();
const preview = trimmed.length > 1800 ? `${trimmed.slice(0, 1800)}\n\n…(truncated)` : trimmed;
return {
shouldContinue: false,
reply: { text: `Plan: ${name}\n\n${preview}` },
};
}
if (args.action === "rename") {
const from = String(args.a ?? "").trim();
const to = String(args.b ?? "").trim();
if (!from || !to) {
return { shouldContinue: false, reply: { text: "Usage: /plans rename <old> <new>" } };
}
const fromDir = path.join(plansDir, from);
const toDir = path.join(plansDir, to);
await fs.mkdir(plansDir, { recursive: true });
await fs.rename(fromDir, toDir);
return {
shouldContinue: false,
reply: { text: `Renamed plan '${from}' → '${to}'.` },
};
}
if (args.action === "trash") {
const name = String(args.a ?? "").trim();
if (!name) {
return { shouldContinue: false, reply: { text: "Usage: /plans trash <name>" } };
}
const fromDir = path.join(plansDir, name);
const trashDir = path.join(plansDir, ".trash");
await fs.mkdir(trashDir, { recursive: true });
const stamped = `${name}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
const toDir = path.join(trashDir, stamped);
await fs.rename(fromDir, toDir);
return {
shouldContinue: false,
reply: { text: `Moved plan '${name}' to trash.` },
};
}
return null;
};

View File

@ -132,6 +132,44 @@ describe("handleCommands gating", () => {
}); });
}); });
describe("/plans", () => {
it("lists plan directories", async () => {
const cfg = {
commands: { text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const plansDir = path.join(testWorkspaceDir, "plans");
await fs.mkdir(path.join(plansDir, "2026-01-24-trip"), { recursive: true });
await fs.writeFile(
path.join(plansDir, "2026-01-24-trip", "plan.md"),
"# Trip Plan\n\nDo stuff\n",
"utf-8",
);
const params = buildParams("/plans list", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2026-01-24-trip");
});
it("shows plan.md", async () => {
const cfg = {
commands: { text: true },
whatsapp: { allowFrom: ["*"] },
} as ClawdbotConfig;
const plansDir = path.join(testWorkspaceDir, "plans");
await fs.mkdir(path.join(plansDir, "demo"), { recursive: true });
await fs.writeFile(path.join(plansDir, "demo", "plan.md"), "Hello plan", "utf-8");
const params = buildParams("/plans show demo", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Hello plan");
});
});
describe("handleCommands bash alias", () => { describe("handleCommands bash alias", () => {
it("routes !poll through the /bash handler", async () => { it("routes !poll through the /bash handler", async () => {
resetBashChatCommandForTests(); resetBashChatCommandForTests();