diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-20 20:33:43 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-20 20:33:43 +0200 |
| commit | e66e46fcc27aee1246f40b76fedd87d2138e6d15 (patch) | |
| tree | bce8f25d8c643971ad65825af586965483b9bc9f /pi/agent/extensions/taskwarrior-plan-mode | |
| parent | 8f2e5923b7952f9f1ecb34e049f37f6ec6169647 (diff) | |
Add Pi plan mode and fresh subagent extensions
Diffstat (limited to 'pi/agent/extensions/taskwarrior-plan-mode')
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/README.md | 82 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/index.ts | 708 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/utils.ts | 252 |
3 files changed, 1042 insertions, 0 deletions
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/README.md b/pi/agent/extensions/taskwarrior-plan-mode/README.md new file mode 100644 index 0000000..2613b12 --- /dev/null +++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md @@ -0,0 +1,82 @@ +# Taskwarrior Plan Mode + +Custom Pi plan mode built on the official `plan-mode` example, but using +Taskwarrior as the actual task source of truth through the +`taskwarrior-task-management` workflow. + +## What it changes + +- `/plan` enters read-only planning mode +- `/plan-exit` leaves planning mode and restores normal tools +- blocks raw `task` and requires `ask ...` +- injects current project Taskwarrior context into planning turns +- extracts `Plan:` sections into actionable steps +- `/plan-create-tasks [sequential|independent]` turns the last extracted plan + into real Taskwarrior tasks +- `/task-sync [sequential|independent]` remains as a legacy alias +- `/task-update <selector> :: <new description>` replaces a task description +- `/task-modify <selector> :: <mods>` runs raw `ask ... modify ...` arguments +- `/task-next [run]` focuses the started task, or starts the next `+READY` task +- `/tasks` shows the current started and READY tasks for the repo +- `/work-on-tasks [strategy] [max]` kicks off the project task loop using the + Taskwarrior skill semantics + +## Task semantics + +This extension is aligned to the `taskwarrior-task-management` skill: + +- `ask ...` only, never raw `task` +- project-scoped by current git repo +- continue started task first +- use UUIDs for stable references +- do not mark a task done until implementation, tests, and commit are complete +- self-review first, then run an independent fresh-context subagent review if + the `subagent` tool is available + +## Core workflow + +1. Run `/plan` +2. Ask Pi to analyze the repo and produce a numbered `Plan:` +3. After the plan is extracted, run `/plan-create-tasks sequential` +4. If needed, adjust tasks with `/task-update` or `/task-modify` +5. Run `/plan-exit` + +Planning mode is intentionally read-only. The extension no longer auto-prompts +you to create tasks after planning; task creation is explicit. + +The extracted plan is session-local. Use `/plan`, your planning prompt, +`/plan-create-tasks`, and `/plan-exit` within the same interactive or continued +Pi session. + +## Examples + +Create tasks from the last plan: + +```text +/plan-create-tasks sequential +``` + +Rewrite a task description: + +```text +/task-update uuid:12345678-1234-1234-1234-123456789abc :: Restore SSH host verification during bootstrap +``` + +Apply raw Taskwarrior modify arguments: + +```text +/task-modify uuid:12345678-1234-1234-1234-123456789abc :: priority:H +security +``` + +In-place description replacement with Taskwarrior syntax: + +```text +/task-modify uuid:12345678-1234-1234-1234-123456789abc :: /bootstrap/provisioning/ +``` + +## Notes + +- Planning mode is read-only by design. +- All Taskwarrior operations still go through `ask`, never raw `task`. +- Execution mode injects the current Taskwarrior task back into the agent prompt + so the model works against the real task rather than an in-memory checklist. diff --git a/pi/agent/extensions/taskwarrior-plan-mode/index.ts b/pi/agent/extensions/taskwarrior-plan-mode/index.ts new file mode 100644 index 0000000..6fbfac3 --- /dev/null +++ b/pi/agent/extensions/taskwarrior-plan-mode/index.ts @@ -0,0 +1,708 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Key } from "@mariozechner/pi-tui"; +import { + containsRawTaskCommand, + dedupePlanItems, + extractPlanItems, + formatTaskDetails, + formatTaskLine, + isSafePlanCommand, + normalizeTaskText, + parseCreatedTaskId, + parseUuidList, + stripAnsi, + type PlanItem, + type TaskwarriorTask, +} from "./utils.js"; + +const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; +const STATE_TYPE = "taskwarrior-plan-mode"; + +interface PlanModeState { + enabled: boolean; + executing: boolean; + planItems: PlanItem[]; + createdTaskUuids: string[]; + normalTools: string[]; +} + +interface WorkOnTasksArgs { + strategy: string; + maxTasks?: number; +} + +function parseSelectorAndPayload(rawArgs: string): { selector: string; payload: string } | undefined { + const separator = rawArgs.indexOf("::"); + if (separator === -1) return undefined; + + const selector = rawArgs.slice(0, separator).trim(); + const payload = rawArgs.slice(separator + 2).trim(); + if (!selector || !payload) return undefined; + + return { selector, payload }; +} + +function splitShellWords(input: string): string[] { + const words: string[] = []; + const pattern = /"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|(\S+)/g; + + for (const match of input.matchAll(pattern)) { + const value = match[1] ?? match[2] ?? match[3]; + if (!value) continue; + words.push(value.replace(/\\(["'\\])/g, "$1")); + } + + return words; +} + +function isAssistantMessage(message: AgentMessage): message is AssistantMessage { + return message.role === "assistant" && Array.isArray(message.content); +} + +function getTextContent(message: AssistantMessage): string { + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function parseWorkOnTasksArgs(rawArgs: string): WorkOnTasksArgs { + const parts = rawArgs + .trim() + .split(/\s+/) + .filter(Boolean); + + let maxTasks: number | undefined; + if (parts.length > 0 && /^\d+$/.test(parts[parts.length - 1] ?? "")) { + const parsed = Number(parts.pop()); + if (Number.isFinite(parsed) && parsed > 0) { + maxTasks = parsed; + } + } + + return { + strategy: parts.join(" ") || "highest-impact", + maxTasks, + }; +} + +export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void { + let planModeEnabled = false; + let executionMode = false; + let planItems: PlanItem[] = []; + let createdTaskUuids: string[] = []; + let normalTools: string[] = []; + + pi.registerFlag("plan", { + description: "Start in Taskwarrior plan mode (read-only exploration)", + type: "boolean", + default: false, + }); + + async function runCommand( + command: string, + args: string[], + ctx: ExtensionContext, + signal?: AbortSignal, + ): Promise<{ stdout: string; stderr: string; code: number }> { + const result = await pi.exec(command, args, { + cwd: ctx.cwd, + signal, + timeout: 30_000, + }); + return { + stdout: stripAnsi(result.stdout ?? ""), + stderr: stripAnsi(result.stderr ?? ""), + code: result.code, + }; + } + + async function runAsk( + args: string[], + ctx: ExtensionContext, + signal?: AbortSignal, + ): Promise<{ stdout: string; stderr: string; code: number }> { + return runCommand("ask", args, ctx, signal); + } + + async function getProjectName(ctx: ExtensionContext): Promise<string> { + const command = + 'basename -s .git "$(git remote get-url origin 2>/dev/null)" 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"'; + const result = await runCommand("bash", ["-lc", command], ctx); + return result.stdout.trim() || "unknown"; + } + + async function loadTasks(args: string[], ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> { + const result = await runAsk(args, ctx, signal); + if (result.code !== 0 || !result.stdout.trim()) return []; + + try { + const parsed = JSON.parse(result.stdout) as TaskwarriorTask[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + async function getStartedTasks(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> { + return loadTasks(["start.any:", "export"], ctx, signal); + } + + async function getReadyTasks(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> { + const tasks = await loadTasks(["+READY", "sort:priority-,urgency-", "limit:10", "export"], ctx, signal); + return tasks.filter((task) => !task.start); + } + + async function getTaskByUuid(uuid: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask | undefined> { + const tasks = await loadTasks([`uuid:${uuid}`, "export"], ctx, signal); + return tasks[0]; + } + + async function getCurrentTask(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask | undefined> { + const started = await getStartedTasks(ctx, signal); + if (started.length > 0) { + return started[0]; + } + + const ready = await getReadyTasks(ctx, signal); + return ready[0]; + } + + async function annotateTask(uuid: string, note: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<void> { + await runAsk([`uuid:${uuid}`, "annotate", note], ctx, signal); + } + + async function startTask(uuid: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<void> { + await runAsk([`uuid:${uuid}`, "start"], ctx, signal); + } + + async function createTask( + description: string, + ctx: ExtensionContext, + options?: { dependsOn?: string; annotation?: string; signal?: AbortSignal }, + ): Promise<string | undefined> { + const args = ["add"]; + if (options?.dependsOn) args.push(`depends:${options.dependsOn}`); + args.push(description); + + const result = await runAsk(args, ctx, options?.signal); + if (result.code !== 0) return undefined; + + const createdId = parseCreatedTaskId(result.stdout); + if (!createdId) return undefined; + + const uuidResult = await runAsk([String(createdId), "_uuid"], ctx, options?.signal); + const uuid = parseUuidList(uuidResult.stdout)[0]; + if (uuid && options?.annotation) { + await annotateTask(uuid, options.annotation, ctx, options.signal); + } + return uuid; + } + + async function syncPlanToTaskwarrior( + mode: "sequential" | "independent", + ctx: ExtensionContext, + signal?: AbortSignal, + ): Promise<{ created: string[]; reused: string[] }> { + const existingTasks = await loadTasks(["status:pending", "export"], ctx, signal); + const existingByDescription = new Map<string, TaskwarriorTask>(); + for (const task of existingTasks) { + existingByDescription.set(normalizeTaskText(task.description), task); + } + + const created: string[] = []; + const reused: string[] = []; + let previousUuid: string | undefined; + + for (const item of planItems) { + const key = normalizeTaskText(item.text); + const existing = existingByDescription.get(key); + if (existing) { + item.uuid = existing.uuid; + reused.push(existing.uuid); + if (mode === "sequential") previousUuid = existing.uuid; + continue; + } + + const annotation = `Pi plan mode step ${item.step}`; + const uuid = await createTask(item.text, ctx, { + dependsOn: mode === "sequential" ? previousUuid : undefined, + annotation, + signal, + }); + + if (!uuid) continue; + + item.uuid = uuid; + created.push(uuid); + existingByDescription.set(key, { + uuid, + description: item.text, + status: "pending", + }); + if (mode === "sequential") previousUuid = uuid; + } + + createdTaskUuids = dedupePlanItems(planItems) + .map((item) => item.uuid) + .filter((uuid): uuid is string => Boolean(uuid)); + persistState(); + return { created, reused }; + } + + async function buildTaskOverview(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> { + const projectName = await getProjectName(ctx); + const started = await getStartedTasks(ctx, signal); + const ready = await getReadyTasks(ctx, signal); + + const lines = [`Project: ${projectName}`]; + + if (started.length > 0) { + lines.push("", "Started tasks:"); + for (const task of started.slice(0, 5)) { + lines.push(`- ${formatTaskLine(task)} (${task.uuid})`); + } + } else { + lines.push("", "Started tasks: none"); + } + + if (ready.length > 0) { + lines.push("", "Next READY tasks:"); + for (const task of ready.slice(0, 5)) { + lines.push(`- ${formatTaskLine(task)} (${task.uuid})`); + } + } else { + lines.push("", "Next READY tasks: none"); + } + + return lines.join("\n"); + } + + function persistState(): void { + pi.appendEntry<PlanModeState>(STATE_TYPE, { + enabled: planModeEnabled, + executing: executionMode, + planItems, + createdTaskUuids, + normalTools, + }); + } + + async function updateStatus(ctx: ExtensionContext): Promise<void> { + if (planModeEnabled) { + ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("warning", "⏸ tw-plan")); + ctx.ui.setWidget("task-plan-mode", undefined); + return; + } + + if (!executionMode) { + ctx.ui.setStatus("task-plan-mode", undefined); + ctx.ui.setWidget("task-plan-mode", undefined); + return; + } + + const currentTask = await getCurrentTask(ctx); + if (!currentTask) { + ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("muted", "task: none")); + ctx.ui.setWidget("task-plan-mode", undefined); + return; + } + + ctx.ui.setStatus( + "task-plan-mode", + ctx.ui.theme.fg("accent", `task ${currentTask.priority ?? "-"} ${currentTask.id ?? "?"}`), + ); + ctx.ui.setWidget("task-plan-mode", [ + ctx.ui.theme.fg("accent", "Taskwarrior focus"), + `${currentTask.start ? "▶" : "○"} ${currentTask.description}`, + `${ctx.ui.theme.fg("muted", "uuid")} ${currentTask.uuid}`, + ]); + } + + async function setPlanModeEnabled(enabled: boolean, ctx: ExtensionContext): Promise<void> { + if (enabled === planModeEnabled) return; + + planModeEnabled = enabled; + executionMode = false; + + if (enabled) { + normalTools = pi.getActiveTools(); + pi.setActiveTools(PLAN_MODE_TOOLS); + ctx.ui.notify(`Taskwarrior plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); + } else { + pi.setActiveTools(normalTools); + ctx.ui.notify("Taskwarrior plan mode disabled. Restored previous tools."); + } + + persistState(); + await updateStatus(ctx); + } + + async function enterPlanMode(ctx: ExtensionContext): Promise<void> { + if (planModeEnabled) { + ctx.ui.notify("Taskwarrior plan mode is already enabled.", "info"); + return; + } + await setPlanModeEnabled(true, ctx); + } + + async function exitPlanMode(ctx: ExtensionContext): Promise<void> { + if (!planModeEnabled) { + ctx.ui.notify("Taskwarrior plan mode is not enabled.", "info"); + return; + } + await setPlanModeEnabled(false, ctx); + } + + async function createTasksFromPlan( + mode: "sequential" | "independent", + ctx: ExtensionContext, + ): Promise<void> { + if (planItems.length === 0) { + ctx.ui.notify("No extracted plan available. Enable /plan and generate a plan first.", "warning"); + return; + } + + const { created, reused } = await syncPlanToTaskwarrior(mode, ctx); + ctx.ui.notify( + `Task sync complete. Created ${created.length}, reused ${reused.length} existing task(s).`, + "info", + ); + await updateStatus(ctx); + } + + async function replaceTaskDescription(selector: string, description: string, ctx: ExtensionContext): Promise<void> { + const result = await runAsk([selector, "modify", description], ctx); + if (result.code !== 0) { + ctx.ui.notify(result.stderr || result.stdout || "Task update failed.", "error"); + return; + } + + ctx.ui.notify(result.stdout.trim() || "Task description updated.", "info"); + } + + async function modifyTask(selector: string, modsText: string, ctx: ExtensionContext): Promise<void> { + const mods = splitShellWords(modsText); + if (mods.length === 0) { + ctx.ui.notify("No modify arguments provided.", "warning"); + return; + } + + const result = await runAsk([selector, "modify", ...mods], ctx); + if (result.code !== 0) { + ctx.ui.notify(result.stderr || result.stdout || "Task modify failed.", "error"); + return; + } + + ctx.ui.notify(result.stdout.trim() || "Task modified.", "info"); + } + + async function focusCurrentTask(runNow: boolean, ctx: ExtensionContext): Promise<void> { + const started = await getStartedTasks(ctx); + let task = started[0]; + + if (!task) { + const ready = await getReadyTasks(ctx); + task = ready[0]; + if (!task) { + ctx.ui.notify("No started or READY Taskwarrior task found for this project.", "warning"); + return; + } + await startTask(task.uuid, ctx); + task = await getTaskByUuid(task.uuid, ctx); + } + + if (!task) { + ctx.ui.notify("Could not resolve the active Taskwarrior task.", "error"); + return; + } + + executionMode = true; + planModeEnabled = false; + pi.setActiveTools(normalTools); + persistState(); + await updateStatus(ctx); + + const projectName = await getProjectName(ctx); + ctx.ui.notify(`Focused task ${task.id ?? "?"}: ${task.description}`, "info"); + + if (runNow) { + pi.sendUserMessage( + `Work on the current Taskwarrior task for project ${projectName}. Use ask for all task operations. Current task UUID: ${task.uuid}.`, + ); + } + } + + pi.registerCommand("plan", { + description: "Enter Taskwarrior plan mode (read-only exploration)", + handler: async (_args, ctx) => enterPlanMode(ctx), + }); + + pi.registerCommand("plan-exit", { + description: "Leave Taskwarrior plan mode and restore normal tools", + handler: async (_args, ctx) => exitPlanMode(ctx), + }); + + pi.registerCommand("tasks", { + description: "Show started and READY Taskwarrior tasks for this project", + handler: async (_args, ctx) => { + ctx.ui.notify(await buildTaskOverview(ctx), "info"); + }, + }); + + pi.registerCommand("plan-create-tasks", { + description: "Create Taskwarrior tasks from the last extracted plan", + handler: async (args, ctx) => { + const mode = args.trim().toLowerCase() === "independent" ? "independent" : "sequential"; + await createTasksFromPlan(mode, ctx); + }, + }); + + pi.registerCommand("task-sync", { + description: "Legacy alias for /plan-create-tasks", + handler: async (args, ctx) => { + const mode = args.trim().toLowerCase() === "independent" ? "independent" : "sequential"; + await createTasksFromPlan(mode, ctx); + }, + }); + + pi.registerCommand("task-next", { + description: "Focus the started task, or start the next READY task", + handler: async (args, ctx) => { + await focusCurrentTask(args.trim().toLowerCase() === "run", ctx); + }, + }); + + pi.registerCommand("task-update", { + description: "Replace a task description: /task-update <selector> :: <new description>", + handler: async (args, ctx) => { + const parsed = parseSelectorAndPayload(args); + if (!parsed) { + ctx.ui.notify("Usage: /task-update <selector> :: <new description>", "warning"); + return; + } + await replaceTaskDescription(parsed.selector, parsed.payload, ctx); + }, + }); + + pi.registerCommand("task-modify", { + description: "Run ask modify args: /task-modify <selector> :: <mods>", + handler: async (args, ctx) => { + const parsed = parseSelectorAndPayload(args); + if (!parsed) { + ctx.ui.notify("Usage: /task-modify <selector> :: <mods>", "warning"); + return; + } + await modifyTask(parsed.selector, parsed.payload, ctx); + }, + }); + + pi.registerCommand("work-on-tasks", { + description: "Run the Taskwarrior task workflow for this repo", + handler: async (args, ctx) => { + const parsed = parseWorkOnTasksArgs(args); + await focusCurrentTask(false, ctx); + + const currentTask = await getCurrentTask(ctx); + if (!currentTask) { + ctx.ui.notify("No started or READY Taskwarrior task found for this project.", "warning"); + return; + } + + const projectName = await getProjectName(ctx); + const maxTasksText = parsed.maxTasks ? String(parsed.maxTasks) : "none"; + + pi.sendUserMessage(`Use the taskwarrior-task-management workflow for the current git project. + +Project: ${projectName} +Selection strategy: ${parsed.strategy} +Max tasks: ${maxTasksText} + +Current focused task: +${formatTaskDetails(currentTask)} + +Workflow: +1. Load project-scoped tasks using ask only. +2. Continue already-started tasks first. Only if none are started, use the next READY task. +3. Use priority first, then urgency, as the stable ordering rule. Use the requested selection strategy only as a tie-breaker or framing hint. +4. Start and execute the chosen task. +5. Annotate meaningful implementation progress back to Taskwarrior using UUID selectors. +6. Self-review your own changes before any completion step. +7. After self-review, if the subagent tool is available, use it to run an independent fresh-context review of the completed changes. +8. Address all review findings, repeat the independent review if needed, and only then commit all changes. +9. Mark the task complete only when implementation, tests, self-review, independent subagent review, and required fixes are complete. +10. Immediately return to started tasks, then READY tasks, and continue until there are no actionable tasks, max_tasks is reached, or a hard blocker is encountered. +11. If blocked, annotate the blocker to the task and stop. + +Rules: +- Never use raw task; always use ask. +- Scope all work to project:${projectName} +agent tasks only. +- Use UUIDs for all long-lived references. +- Do not ask the user to choose a task unless there is a real ambiguity or risk. +- Keep working autonomously until the workflow reaches a stop condition. + +Begin with the current focused task unless a higher-priority started task appears when you re-check Taskwarrior.`, { + deliverAs: ctx.isIdle() ? undefined : "steer", + }); + }, + }); + + pi.registerShortcut(Key.ctrlAlt("p"), { + description: "Toggle Taskwarrior plan mode", + handler: async (ctx) => togglePlanMode(ctx), + }); + + pi.on("tool_call", async (event) => { + if (event.toolName !== "bash") return; + + const command = String(event.input.command ?? ""); + if (containsRawTaskCommand(command)) { + return { + block: true, + reason: "Use 'ask ...' for all Taskwarrior operations. Raw 'task' is blocked by taskwarrior-plan-mode.", + }; + } + + if (planModeEnabled && !isSafePlanCommand(command)) { + return { + block: true, + reason: `Taskwarrior plan mode blocks mutating shell commands.\nCommand: ${command}`, + }; + } + }); + + pi.on("context", async (event) => { + return { + messages: event.messages.filter((message) => { + const candidate = message as AgentMessage & { customType?: string }; + if (!planModeEnabled && candidate.customType === "taskwarrior-plan-mode-context") return false; + if (!executionMode && candidate.customType === "taskwarrior-execution-mode-context") return false; + return true; + }), + }; + }); + + pi.on("before_agent_start", async (_event, ctx) => { + const projectName = await getProjectName(ctx); + + if (planModeEnabled) { + const overview = await buildTaskOverview(ctx); + return { + message: { + customType: "taskwarrior-plan-mode-context", + content: `[TASKWARRIOR PLAN MODE ACTIVE] +You are in read-only planning mode for project ${projectName}. + +Rules: +- Use only read, bash, grep, find, and ls. +- For Taskwarrior operations, always use 'ask ...'. Never use raw 'task'. +- Read existing started tasks first; if none, inspect the next READY tasks. +- Do not modify files or create Taskwarrior tasks yourself while planning. +- Avoid duplicating tasks that already exist. + +Current Taskwarrior overview: +${overview} + +Create a concise numbered plan under a "Plan:" header. Each step must be a single actionable task suitable for Taskwarrior: + +Plan: +1. First actionable task +2. Second actionable task +3. Third actionable task`, + display: false, + }, + }; + } + + if (executionMode) { + const currentTask = await getCurrentTask(ctx); + if (!currentTask) return; + + return { + message: { + customType: "taskwarrior-execution-mode-context", + content: `[TASKWARRIOR EXECUTION MODE] +Project: ${projectName} + +Use the taskwarrior-task-management skill semantics: +- Use 'ask ...' for all task operations. Never use raw 'task'. +- Continue an already-started task before starting a new one. +- Use UUIDs for long-lived references and follow-up commands. +- Do not mark a task done until implementation, tests, and commit are complete. +- Annotate meaningful progress back to the task with 'ask uuid:<uuid> annotate ...' when appropriate. +- Self-review first, then if the subagent tool is available use it for an independent fresh-context review before the task is marked done. + +Current task: +${formatTaskDetails(currentTask)}`, + display: false, + }, + }; + } + }); + + pi.on("turn_end", async (_event, ctx) => { + if (executionMode) { + await updateStatus(ctx); + } + }); + + pi.on("agent_end", async (event, ctx) => { + if (executionMode) { + await updateStatus(ctx); + return; + } + + if (!planModeEnabled) return; + + const lastAssistant = [...event.messages].reverse().find(isAssistantMessage); + if (!lastAssistant) return; + + planItems = dedupePlanItems(extractPlanItems(getTextContent(lastAssistant))); + persistState(); + + if (planItems.length === 0) return; + + const todoListText = planItems.map((item) => `${item.step}. ${item.text}`).join("\n"); + pi.sendMessage( + { + customType: "taskwarrior-plan-items", + content: `**Extracted Taskwarrior plan (${planItems.length} steps):**\n\n${todoListText}`, + display: true, + }, + { triggerTurn: false }, + ); + + if (ctx.hasUI) { + ctx.ui.notify("Plan extracted. Run /plan-create-tasks or /plan-exit when ready.", "info"); + } + }); + + pi.on("session_start", async (_event, ctx) => { + if (pi.getFlag("plan") === true) { + planModeEnabled = true; + } + + const entries = ctx.sessionManager.getEntries(); + const planStateEntry = entries + .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_TYPE) + .pop() as { data?: PlanModeState } | undefined; + + if (planStateEntry?.data) { + planModeEnabled = planStateEntry.data.enabled ?? planModeEnabled; + executionMode = planStateEntry.data.executing ?? executionMode; + planItems = planStateEntry.data.planItems ?? planItems; + createdTaskUuids = planStateEntry.data.createdTaskUuids ?? createdTaskUuids; + normalTools = planStateEntry.data.normalTools?.length ? planStateEntry.data.normalTools : normalTools; + } else { + normalTools = pi.getActiveTools(); + } + + if (planModeEnabled) { + pi.setActiveTools(PLAN_MODE_TOOLS); + } + + await updateStatus(ctx); + }); +} diff --git a/pi/agent/extensions/taskwarrior-plan-mode/utils.ts b/pi/agent/extensions/taskwarrior-plan-mode/utils.ts new file mode 100644 index 0000000..cfaba15 --- /dev/null +++ b/pi/agent/extensions/taskwarrior-plan-mode/utils.ts @@ -0,0 +1,252 @@ +export interface PlanItem { + step: number; + text: string; + uuid?: string; +} + +export interface TaskwarriorAnnotation { + entry?: string; + description: string; +} + +export interface TaskwarriorTask { + id?: number; + uuid: string; + description: string; + status?: string; + priority?: string; + start?: string; + project?: string; + urgency?: number; + depends?: string[]; + annotations?: TaskwarriorAnnotation[]; +} + +const ANSI_PATTERN = + // biome-ignore lint/suspicious/noControlCharactersInRegex: strips terminal escape sequences from command output + /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; + +const DESTRUCTIVE_PATTERNS = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])>(?!>)/, + />>/, + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +const SAFE_PATTERNS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, +]; + +const MUTATING_TASK_PATTERNS = [ + /\badd\b/i, + /\bannotate\b/i, + /\bappend\b/i, + /\bdelete\b/i, + /\bdenotate\b/i, + /\bdone\b/i, + /\bduplicate\b/i, + /\bedit\b/i, + /\bimport\b/i, + /\blog\b/i, + /\bmodify\b/i, + /\bprepend\b/i, + /\bpurge\b/i, + /\bstart\b/i, + /\bstop\b/i, + /\bsynchronize\b/i, + /\bundo\b/i, +]; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ""); +} + +export function containsRawTaskCommand(command: string): boolean { + return /(^|[;&|]\s*)task\b/.test(command); +} + +export function isSafeAskCommand(command: string): boolean { + const trimmed = command.trim(); + if (!trimmed.startsWith("ask ")) return false; + if (containsRawTaskCommand(trimmed)) return false; + if (/[;&]/.test(trimmed) || /(^|[^|])\|([^|]|$)/.test(trimmed)) return false; + return !MUTATING_TASK_PATTERNS.some((pattern) => pattern.test(trimmed)); +} + +export function isSafePlanCommand(command: string): boolean { + if (containsRawTaskCommand(command)) return false; + if (isSafeAskCommand(command)) return true; + + const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command)); + const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command)); + return !isDestructive && isSafe; +} + +export function cleanPlanStep(text: string): string { + return text + .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[[^\]]+\]\([^)]+\)/g, "$1") + .replace(/\s+/g, " ") + .trim() + .replace(/[.;:]+$/, ""); +} + +export function normalizeTaskText(text: string): string { + return cleanPlanStep(text).toLowerCase(); +} + +export function extractPlanItems(message: string): PlanItem[] { + const items: PlanItem[] = []; + const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i); + if (!headerMatch) return items; + + const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length); + const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm; + + for (const match of planSection.matchAll(numberedPattern)) { + const cleaned = cleanPlanStep(match[2] ?? ""); + if (cleaned.length < 4) continue; + if (cleaned.startsWith("-") || cleaned.startsWith("/")) continue; + items.push({ + step: items.length + 1, + text: cleaned.slice(0, 240), + }); + } + + return dedupePlanItems(items); +} + +export function dedupePlanItems(items: PlanItem[]): PlanItem[] { + const seen = new Set<string>(); + const deduped: PlanItem[] = []; + + for (const item of items) { + const key = normalizeTaskText(item.text); + if (!key || seen.has(key)) continue; + seen.add(key); + deduped.push({ + step: deduped.length + 1, + text: item.text, + uuid: item.uuid, + }); + } + + return deduped; +} + +export function parseUuidList(text: string): string[] { + return stripAnsi(text) + .match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) + ?.map((value) => value.toLowerCase()) ?? []; +} + +export function parseCreatedTaskId(text: string): number | undefined { + const match = stripAnsi(text).match(/Created task (\d+)/i); + return match ? Number(match[1]) : undefined; +} + +export function formatTaskLine(task: TaskwarriorTask): string { + const bits = [ + task.priority ? `[${task.priority}]` : undefined, + task.start ? "started" : "ready", + task.description, + ]; + return bits.filter(Boolean).join(" "); +} + +export function formatTaskDetails(task: TaskwarriorTask): string { + const annotations = (task.annotations ?? []) + .map((annotation) => `- ${annotation.description}`) + .join("\n"); + + const lines = [ + `UUID: ${task.uuid}`, + `Description: ${task.description}`, + task.priority ? `Priority: ${task.priority}` : undefined, + task.status ? `Status: ${task.status}` : undefined, + task.start ? "Active: yes" : "Active: no", + annotations ? `Annotations:\n${annotations}` : undefined, + ]; + + return lines.filter(Boolean).join("\n"); +} + |
