summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-20 20:33:43 +0200
committerPaul Buetow <paul@buetow.org>2026-03-20 20:33:43 +0200
commite66e46fcc27aee1246f40b76fedd87d2138e6d15 (patch)
treebce8f25d8c643971ad65825af586965483b9bc9f
parent8f2e5923b7952f9f1ecb34e049f37f6ec6169647 (diff)
Add Pi plan mode and fresh subagent extensions
-rw-r--r--pi/agent/extensions/fresh-subagent/README.md83
-rw-r--r--pi/agent/extensions/fresh-subagent/index.ts313
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/README.md82
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/index.ts708
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/utils.ts252
5 files changed, 1438 insertions, 0 deletions
diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md
new file mode 100644
index 0000000..c74dd78
--- /dev/null
+++ b/pi/agent/extensions/fresh-subagent/README.md
@@ -0,0 +1,83 @@
+# Fresh Subagent
+
+Minimal fresh-context subagent support for Pi.
+
+## What it does
+
+- registers a `subagent` tool the main agent can call
+- registers a `/subagent <prompt>` command for direct use
+- runs the delegated work in a new `pi --mode json -p --no-session` process
+- defaults to the current session model when one is active
+- returns only the final answer or review result
+
+This is intentionally small. It does not manage agent catalogs, chains, or
+parallel workers. It is meant for one-off delegation with a clean context.
+
+## What it is for
+
+Subagents are generic. The main agent can hand them any focused prompt that
+benefits from a clean context, for example:
+
+- independent code review
+- fresh-context debugging
+- focused codebase research
+- second-opinion architecture checks
+- summarizing a noisy command output or diff
+- validating whether a completed task is actually done
+
+One common use is the `taskwarrior-task-management` review loop:
+
+1. The main agent implements the change
+2. The main agent self-reviews the change
+3. The main agent uses `subagent` for an independent fresh-context review
+4. The main agent fixes findings
+5. Only then does the task move toward completion
+
+## Direct usage
+
+Run a manual fresh-context review:
+
+```text
+/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings.
+```
+
+Run a focused side investigation:
+
+```text
+/subagent Find all code paths that write to the SSH known_hosts file and summarize the risk.
+```
+
+Run a generic delegation:
+
+```text
+/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches.
+```
+
+One-shot CLI usage also works now:
+
+```bash
+pi --model openai/gpt-4.1 --no-session -p '/subagent Say only SUBAGENT_COMMAND_OK'
+```
+
+## Agent usage
+
+Because this is registered as a tool, the main agent can call it itself. A good
+generic pattern is:
+
+```text
+Use the subagent tool for a fresh-context pass on this side task, then return only the useful result.
+```
+
+For review-specific flows:
+
+```text
+First review your own changes. Afterwards, use the subagent tool to perform an independent fresh-context review and then address any findings.
+```
+
+## Notes
+
+- The subagent uses a fresh session via `--no-session`.
+- The subprocess still runs in the same working directory unless you override
+ `cwd`.
+- The extension disables itself inside child subagent processes to avoid
+ accidental recursive registration.
diff --git a/pi/agent/extensions/fresh-subagent/index.ts b/pi/agent/extensions/fresh-subagent/index.ts
new file mode 100644
index 0000000..52fed1b
--- /dev/null
+++ b/pi/agent/extensions/fresh-subagent/index.ts
@@ -0,0 +1,313 @@
+import { spawn } from "node:child_process";
+import type { AgentToolResult, AgentToolResultContent } from "@mariozechner/pi-agent-core";
+import type { Message, TextContent } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+
+const CHILD_ENV_FLAG = "PI_FRESH_SUBAGENT_CHILD";
+
+interface UsageStats {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ cost: number;
+ turns: number;
+}
+
+interface FreshSubagentResult {
+ prompt: string;
+ model?: string;
+ cwd: string;
+ exitCode: number;
+ stopReason?: string;
+ errorMessage?: string;
+ stderr: string;
+ output: string;
+ usage: UsageStats;
+}
+
+function getProviderScopedModel(ctx: ExtensionContext): string | undefined {
+ if (!ctx.model) return undefined;
+ return `${ctx.model.provider}/${ctx.model.id}`;
+}
+
+function getLastAssistantText(messages: Message[]): string {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role !== "assistant") continue;
+ const text = message.content
+ .filter((part): part is TextContent => part.type === "text")
+ .map((part) => part.text)
+ .join("\n")
+ .trim();
+ if (text) return text;
+ }
+ return "";
+}
+
+async function runFreshSubagent(
+ prompt: string,
+ options: {
+ cwd: string;
+ model?: string;
+ tools?: string[];
+ signal?: AbortSignal;
+ onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void;
+ },
+): Promise<FreshSubagentResult> {
+ const args = ["--mode", "json", "-p", "--no-session"];
+ if (options.model) args.push("--model", options.model);
+ if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
+ args.push(prompt);
+
+ const result: FreshSubagentResult = {
+ prompt,
+ model: options.model,
+ cwd: options.cwd,
+ exitCode: 0,
+ stderr: "",
+ output: "",
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: 0,
+ turns: 0,
+ },
+ };
+
+ const messages: Message[] = [];
+
+ const emitUpdate = () => {
+ options.onUpdate?.({
+ content: [{ type: "text", text: result.output || "(running...)" }],
+ details: { ...result },
+ });
+ };
+
+ let wasAborted = false;
+
+ result.exitCode = await new Promise<number>((resolve) => {
+ const proc = spawn("pi", args, {
+ cwd: options.cwd,
+ shell: false,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ [CHILD_ENV_FLAG]: "1",
+ },
+ });
+
+ let buffer = "";
+
+ const processLine = (line: string) => {
+ if (!line.trim()) return;
+
+ let event: any;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ return;
+ }
+
+ if (event.type !== "message_end" || !event.message) return;
+
+ const message = event.message as Message;
+ messages.push(message);
+ result.output = getLastAssistantText(messages);
+
+ if (message.role === "assistant") {
+ result.usage.turns++;
+ const usage = message.usage;
+ if (usage) {
+ result.usage.input += usage.input || 0;
+ result.usage.output += usage.output || 0;
+ result.usage.cacheRead += usage.cacheRead || 0;
+ result.usage.cacheWrite += usage.cacheWrite || 0;
+ result.usage.cost += usage.cost?.total || 0;
+ }
+ if (!result.model && message.model) result.model = message.model;
+ if (message.stopReason) result.stopReason = message.stopReason;
+ if (message.errorMessage) result.errorMessage = message.errorMessage;
+ }
+
+ emitUpdate();
+ };
+
+ proc.stdout.on("data", (data) => {
+ buffer += data.toString();
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+ for (const line of lines) processLine(line);
+ });
+
+ proc.stderr.on("data", (data) => {
+ result.stderr += data.toString();
+ });
+
+ proc.on("close", (code) => {
+ if (buffer.trim()) processLine(buffer);
+ resolve(code ?? 0);
+ });
+
+ proc.on("error", () => {
+ resolve(1);
+ });
+
+ if (options.signal) {
+ const killProc = () => {
+ wasAborted = true;
+ proc.kill("SIGTERM");
+ setTimeout(() => {
+ if (!proc.killed) proc.kill("SIGKILL");
+ }, 5000);
+ };
+
+ if (options.signal.aborted) killProc();
+ else options.signal.addEventListener("abort", killProc, { once: true });
+ }
+ });
+
+ if (wasAborted) {
+ result.stopReason = "aborted";
+ result.errorMessage = "Fresh subagent was aborted";
+ }
+
+ result.output ||= getLastAssistantText(messages);
+ return result;
+}
+
+function renderSubagentSummary(details: FreshSubagentResult, expanded: boolean, theme: any): string {
+ const status =
+ details.exitCode === 0 && details.stopReason !== "error" && details.stopReason !== "aborted"
+ ? theme.fg("success", "✓")
+ : theme.fg("error", "✗");
+ const header = `${status} ${theme.fg("toolTitle", theme.bold("subagent"))}${
+ details.model ? theme.fg("muted", ` ${details.model}`) : ""
+ }`;
+
+ const lines = [header, theme.fg("muted", `cwd: ${details.cwd}`)];
+ if (expanded) {
+ lines.push("", theme.fg("muted", "Prompt:"), details.prompt);
+ lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)"));
+ } else {
+ const preview = details.output ? details.output.split("\n").slice(0, 5).join("\n") : "(no output)";
+ lines.push("", preview);
+ }
+
+ if (details.errorMessage) lines.push("", theme.fg("error", `Error: ${details.errorMessage}`));
+ if (details.stderr.trim()) lines.push("", theme.fg("dim", details.stderr.trim()));
+ return lines.join("\n");
+}
+
+export default function freshSubagentExtension(pi: ExtensionAPI): void {
+ if (process.env[CHILD_ENV_FLAG] === "1") return;
+
+ const params = Type.Object({
+ prompt: Type.String({ description: "Prompt to run in a fresh-context subagent" }),
+ model: Type.Optional(Type.String({ description: "Optional model override. Defaults to the current session model." })),
+ cwd: Type.Optional(Type.String({ description: "Working directory for the subagent process" })),
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist for the subagent process" })),
+ });
+
+ pi.registerTool({
+ name: "subagent",
+ label: "Subagent",
+ description: "Spawn a fresh-context subagent with a prompt and return its final answer.",
+ promptSnippet: "Delegate a self-contained task to a fresh-context subagent and get its result back",
+ promptGuidelines: [
+ "Use this tool for any self-contained side task that benefits from a clean context, such as review, research, summarization, or focused implementation checks.",
+ "Pass a complete prompt with enough context for the subagent to succeed independently, because it starts with a fresh session.",
+ ],
+ parameters: params,
+
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
+ const details = await runFreshSubagent(params.prompt, {
+ cwd: params.cwd ?? ctx.cwd,
+ model: params.model ?? getProviderScopedModel(ctx),
+ tools: params.tools,
+ signal,
+ onUpdate,
+ });
+
+ const content: AgentToolResultContent[] = [{ type: "text", text: details.output || "(no output)" }];
+ const isError = details.exitCode !== 0 || details.stopReason === "error" || details.stopReason === "aborted";
+
+ if (isError) {
+ const text = details.errorMessage || details.stderr || details.output || "Fresh subagent failed.";
+ return {
+ content: [{ type: "text", text }],
+ details,
+ isError: true,
+ };
+ }
+
+ return { content, details };
+ },
+
+ renderCall(args, theme) {
+ const preview = args.prompt.length > 80 ? `${args.prompt.slice(0, 80)}...` : args.prompt;
+ return new Text(
+ `${theme.fg("toolTitle", theme.bold("subagent"))}\n ${theme.fg("dim", preview)}`,
+ 0,
+ 0,
+ );
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const details = result.details as FreshSubagentResult | undefined;
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+ }
+ return new Text(renderSubagentSummary(details, expanded, theme), 0, 0);
+ },
+ });
+
+ pi.registerCommand("subagent", {
+ description: "Run a fresh-context subagent with a prompt",
+ handler: async (args, ctx) => {
+ const prompt = args.trim();
+ if (!prompt) {
+ ctx.ui.notify("Usage: /subagent <prompt>", "warning");
+ return;
+ }
+
+ ctx.ui.setStatus("fresh-subagent", ctx.ui.theme.fg("warning", "subagent: running"));
+
+ try {
+ const details = await runFreshSubagent(prompt, {
+ cwd: ctx.cwd,
+ model: getProviderScopedModel(ctx),
+ });
+
+ if (!ctx.hasUI) {
+ const text = details.output || details.errorMessage || details.stderr || "(no output)";
+ if (text) {
+ process.stdout.write(`${text}\n`);
+ }
+ return;
+ }
+
+ pi.sendMessage(
+ {
+ customType: "fresh-subagent-result",
+ content: details.output || "(no output)",
+ display: true,
+ details,
+ },
+ { triggerTurn: false },
+ );
+ } finally {
+ ctx.ui.setStatus("fresh-subagent", undefined);
+ }
+ },
+ });
+
+ pi.registerMessageRenderer("fresh-subagent-result", (message, { expanded }, theme) => {
+ return new Text(renderSubagentSummary(message.details as FreshSubagentResult, expanded, theme), 0, 0);
+ });
+}
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");
+}
+