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 | |
| parent | 8f2e5923b7952f9f1ecb34e049f37f6ec6169647 (diff) | |
Add Pi plan mode and fresh subagent extensions
| -rw-r--r-- | pi/agent/extensions/fresh-subagent/README.md | 83 | ||||
| -rw-r--r-- | pi/agent/extensions/fresh-subagent/index.ts | 313 | ||||
| -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 |
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"); +} + |
