diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-21 09:56:45 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-21 09:56:45 +0200 |
| commit | 8fdba30d44037a91623c7cf05da7f1e2a298c47e (patch) | |
| tree | f1f863325c6abede8da8a6413cc180618ea06037 /pi/agent/extensions/taskwarrior-plan-mode | |
| parent | ebe3566cefcccd288faa000cfe9bda298542cc5d (diff) | |
import pi.dev stuff
Diffstat (limited to 'pi/agent/extensions/taskwarrior-plan-mode')
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/README.md | 173 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/index.ts | 822 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/utils.ts | 252 |
3 files changed, 1247 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..e75c4d5 --- /dev/null +++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md @@ -0,0 +1,173 @@ +# Taskwarrior Plan Mode + +Taskwarrior-backed planning for Pi. + +This extension keeps planning and execution separate: + +- use `/plan` to enter read-only planning mode +- ask Pi to produce a numbered `Plan:` +- convert the extracted plan into Taskwarrior tasks explicitly +- leave planning mode and continue execution against real tasks + +Taskwarrior remains the source of truth. This extension does not keep a private +todo list. + +## Commands + +- `/plan` + Enter read-only planning mode. The active tool set is reduced to safe + exploration tools. +- `/plan-exit` + Leave planning mode and restore the previous tool set. +- `/plan-create-tasks [sequential|independent]` + Create Taskwarrior tasks from the last extracted `Plan:`. +- `/task-sync [sequential|independent]` + Legacy alias for `/plan-create-tasks`. +- `/task-update <selector> :: <new description>` + Replace a task description. +- `/task-modify <selector> :: <mods>` + Apply raw `ask ... modify ...` arguments to a task. +- `/tasks` + Show started and `+READY` tasks for the current repo. +- `/task-next [run]` + Focus the started task, or start the next `+READY` task. +- `/task-exit` + Leave Taskwarrior focus mode. +- `/task-unfocus` + Alias for `/task-exit`. +- `/work-on-tasks [strategy] [max]` + Kick off the Taskwarrior execution loop aligned to the + `taskwarrior-task-management` workflow. + +## Rules + +- all Taskwarrior operations go through `ask`, never raw `task` +- tasks are scoped to the current git repo through your `ask` wrapper +- use UUIDs for stable references +- planning mode is read-only by design +- the extracted plan is session-local, so `/plan`, the planning prompt, + `/plan-create-tasks`, and `/plan-exit` should happen in the same interactive + or continued Pi session + +## Usage Flows + +### Flow 1: Turn a plan into Taskwarrior tasks + +1. Start Pi in the project. +2. Run: + +```text +/plan +``` + +3. Ask for analysis and a numbered `Plan:`. Example: + +```text +Analyze the current repo and propose a concise Plan: for fixing the SSH bootstrap trust model. +``` + +4. After Pi replies with a `Plan:`, create tasks: + +```text +/plan-create-tasks sequential +``` + +5. Leave planning mode: + +```text +/plan-exit +``` + +Use `sequential` when each step should depend on the previous one. Use +`independent` when the planned tasks can be worked separately. + +### Flow 2: Adjust a task after planning + +Rewrite a task description: + +```text +/task-update uuid:12345678-1234-1234-1234-123456789abc :: Restore SSH host verification during bootstrap +``` + +Apply standard modify arguments: + +```text +/task-modify uuid:12345678-1234-1234-1234-123456789abc :: priority:H +security +``` + +Use Taskwarrior replacement syntax: + +```text +/task-modify uuid:12345678-1234-1234-1234-123456789abc :: /bootstrap/provisioning/ +``` + +### Flow 3: Start executing the real tasks + +See what is active: + +```text +/tasks +``` + +Focus the current task: + +```text +/task-next +``` + +Focus and immediately start execution: + +```text +/task-next run +``` + +Leave focus mode again: + +```text +/task-exit +``` + +Run the full repo task loop: + +```text +/work-on-tasks highest-impact +``` + +### Flow 4: Planning session pattern + +This is the cleanest end-to-end interactive pattern: + +```text +/plan +``` + +```text +Analyze the repo and give me a Plan: for the next implementation slice. +``` + +```text +/plan-create-tasks sequential +``` + +```text +/plan-exit +``` + +```text +/work-on-tasks +``` + +## Notes And Limits + +- Planning mode is read-only by design. +- All Taskwarrior operations still go through `ask`, never raw `task`. +- `ask` must use real Taskwarrior CLI syntax. It is not a natural-language + task assistant and should never be called like `ask taskwarrior-task-management ...`. +- 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. +- Execution mode now treats the focused task as the already-selected starting + point and blocks repeated identical `ask uuid:<current>` lookups until the + agent has moved on to repo inspection, implementation, tests, review, or a + different command. +- Full `/plan` state is not meant to be passed across unrelated one-shot `pi -p` + invocations. Use a real interactive or continued session for planning. 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..0f8b12c --- /dev/null +++ b/pi/agent/extensions/taskwarrior-plan-mode/index.ts @@ -0,0 +1,822 @@ +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 escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeCommandText(command: string): string { + return command.trim().replace(/\s+/g, " "); +} + +function isMutatingAskCommand(command: string): boolean { + return /\b(add|annotate|append|delete|denotate|done|log|modify|prepend|start|stop|undo)\b/.test(command); +} + +function repeatedCurrentTaskLookupKey(command: string, currentTaskUuid?: string): string | undefined { + if (!currentTaskUuid) return undefined; + + const normalized = normalizeCommandText(command); + if (!/^ask(?:\s|$)/.test(normalized)) return undefined; + if (isMutatingAskCommand(normalized)) return undefined; + + const uuidPattern = new RegExp(`(?:^|\\s)["']?uuid:${escapeRegExp(currentTaskUuid)}["']?(?:\\s|$)`); + if (!uuidPattern.test(normalized)) return undefined; + + return normalized; +} + +function malformedAskReason(command: string): string | undefined { + const normalized = normalizeCommandText(command); + if (!/^ask(?:\s|$)/.test(normalized)) return undefined; + + if (/\btaskwarrior-task-management\b/.test(normalized)) { + return "The 'ask' command is only a Taskwarrior CLI wrapper. Do not pass the skill name or natural-language workflow text to it. Use concrete Taskwarrior syntax such as 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate \"note\"', 'ask uuid:<uuid> modify priority:H', or 'ask uuid:<uuid> done'."; + } + + return undefined; +} + +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[] = []; + let executionTaskUuid: string | undefined; + let repeatedTaskLookups = new Set<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) { + executionTaskUuid = undefined; + ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("muted", "task: none")); + ctx.ui.setWidget("task-plan-mode", undefined); + return; + } + + executionTaskUuid = currentTask.uuid; + 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); + executionTaskUuid = undefined; + repeatedTaskLookups.clear(); + 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 exitExecutionMode(ctx: ExtensionContext): Promise<void> { + if (!executionMode) { + ctx.ui.notify("Taskwarrior focus mode is not enabled.", "info"); + return; + } + + executionMode = false; + executionTaskUuid = undefined; + repeatedTaskLookups.clear(); + pi.setActiveTools(normalTools); + persistState(); + await updateStatus(ctx); + ctx.ui.notify("Taskwarrior focus mode disabled.", "info"); + } + + 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); + executionTaskUuid = task.uuid; + repeatedTaskLookups.clear(); + 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-exit", { + description: "Leave Taskwarrior focus mode", + handler: async (_args, ctx) => { + await exitExecutionMode(ctx); + }, + }); + + pi.registerCommand("task-unfocus", { + description: "Alias for /task-exit", + handler: async (_args, ctx) => { + await exitExecutionMode(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 workflow rules below for the current git project. + +Project: ${projectName} +Selection strategy: ${parsed.strategy} +Max tasks: ${maxTasksText} + +Current focused task: +${formatTaskDetails(currentTask)} + +Workflow: +1. Treat the current focused task above as the already-selected starting point for this run. +2. Only use ask to load project-scoped tasks when the current task is missing, blocked, completed, or you are ready to pick the next 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. +- 'ask' is a thin Taskwarrior CLI wrapper, not a natural-language interface and not a skill runner. +- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'. +- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing. +- Scope all work to project:${projectName} +agent tasks only. +- Use UUIDs for all long-lived references. +- Do not repeat the same ask lookup for the current task unless task state may have changed or required information is still missing. +- After one task lookup, move into repo inspection, implementation, testing, review, or annotation before refreshing Taskwarrior again. +- 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 now. Do not re-check Taskwarrior immediately just to confirm the same task again.`, { + 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 (!executionMode) { + if (event.toolName !== "bash") return; + } else if (event.toolName !== "bash") { + repeatedTaskLookups.clear(); + return; + } + + const command = String(event.input.command ?? ""); + const repeatedLookupKey = executionMode ? repeatedCurrentTaskLookupKey(command, executionTaskUuid) : undefined; + if (executionMode && repeatedLookupKey) { + if (repeatedTaskLookups.has(repeatedLookupKey)) { + return { + block: true, + reason: + "Repeated lookup of the same current Taskwarrior task was blocked. Use the task details already in context and move to code inspection, implementation, tests, review, or an annotation before refreshing the same task again.", + }; + } + repeatedTaskLookups.add(repeatedLookupKey); + } else if (executionMode) { + repeatedTaskLookups.clear(); + } + + const malformedAsk = malformedAskReason(command); + if (malformedAsk) { + return { + block: true, + reason: malformedAsk, + }; + } + + 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; + executionTaskUuid = currentTask.uuid; + + return { + message: { + customType: "taskwarrior-execution-mode-context", + content: `[TASKWARRIOR EXECUTION MODE] +Project: ${projectName} + +Use the Taskwarrior workflow rules below: +- Use 'ask ...' for all task operations. Never use raw 'task'. +- 'ask' is only a Taskwarrior CLI wrapper. It does not understand the skill name or natural-language requests. +- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'. +- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing. +- Continue an already-started task before starting a new one. +- Use UUIDs for long-lived references and follow-up commands. +- The current task below is already the selected task for this turn. Do not immediately query the same UUID again unless required details are missing or task state changed. +- After one Taskwarrior lookup, move to repo inspection or implementation work before refreshing Taskwarrior again. +- 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) => { + repeatedTaskLookups.clear(); + if (executionMode) { + await updateStatus(ctx); + } + }); + + pi.on("agent_end", async (event, ctx) => { + repeatedTaskLookups.clear(); + 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(); + } + repeatedTaskLookups.clear(); + + 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"); +} + |
