diff --git a/AGENTS.md b/AGENTS.md index 4966c6d74..9f594a0a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_ANNOTATE_HISTORY` | Set to `0` / `false` to disable per-file version history in annotate mode (no copies of annotated files are written to the data dir; the annotate version diff is unavailable). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "annotateHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's `agent` CLI for review jobs — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. | +| `PLANNOTATOR_TODO_PROVIDER` | Set to `off` / `0` / `false` / `disabled` to stop mirroring the approved plan checklist into an editable todo provider during execution. Default: enabled, which syncs only when a provider is detected (currently pi-todos: detected when its todo directory exists — `/.pi/todos` by default, or wherever `PI_TODO_PATH` redirects it when set). The mirror is additive — the progress widget is unaffected either way — and sync is one-way, so provider-side edits never feed back into plan execution. Can also be set via `~/.plannotator/config.json` (`{ "todoProvider": "off" }`); the env var takes precedence. | | `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. | | `PLANNOTATOR_DATA_DIR` | Override the base data directory. Supports `~` expansion. Default: `~/.plannotator`. When unset, an existing `~/.plannotator` always wins; if it doesn't exist and `$XDG_DATA_HOME` is set to an absolute path, `$XDG_DATA_HOME/plannotator` is used; otherwise `~/.plannotator` (the XDG spec's implicit `~/.local/share` default is deliberately not applied). All data (plans, history, drafts, config, hooks, sessions, debug logs, IPC registry) is stored under this directory. | | `PLANNOTATOR_FILE_BROWSER_MAX_FILES` | File-discovery limit: regular files inspected by CLI markdown/folder resolution and startup code-file warming, supported files returned by the file browser, and directories scanned during multi-repo workspace discovery (symlinks may point outside the workspace, so the budget — not the root — bounds that walk). Must be a positive integer; invalid, zero, or negative values use the default of `5000`. | diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index d6fdca39f..19a03dd17 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -24,6 +24,7 @@ All Plannotator environment variables and their defaults. | `PLANNOTATOR_SHARE_URL` | `https://share.plannotator.ai` | Base URL for share links. Set this when self-hosting the share portal. | | `PLANNOTATOR_DATA_DIR` | `~/.plannotator` | Override the base data directory. Supports `~` expansion. All data (plans, history, drafts, config, hooks, sessions) is stored under this directory.* When unset, an existing `~/.plannotator` is always used; if it doesn't exist and `$XDG_DATA_HOME` is set to an absolute path, `$XDG_DATA_HOME/plannotator` is used; otherwise `~/.plannotator`. (The XDG spec's implicit `~/.local/share` default is deliberately not applied — only an explicitly-set `$XDG_DATA_HOME` moves the directory.) | | `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | `345600` | OpenCode only. `submit_plan` wait timeout in seconds. Set `0` to disable timeout. | +| `PLANNOTATOR_TODO_PROVIDER` | auto | Pi/oh-my-pi only. Set to `off` (or `0` / `false` / `disabled`) to stop mirroring the approved plan checklist into an editable todo provider during execution. When enabled, Plannotator syncs the checklist only if a provider is detected — currently [pi-todos](https://github.com/mitsuhiko/agent-stuff), detected by its todo directory existing (`.pi/todos` by default, or wherever `PI_TODO_PATH` redirects it when set). The mirror is additive: the progress widget behaves the same either way, and sync is one-way, so edits made in `/todos` never feed back into plan execution. Can also be set via `~/.plannotator/config.json` (`{ "todoProvider": "off" }`); the env var takes precedence. | \* If you use the VS Code extension, make sure `PLANNOTATOR_DATA_DIR` is visible to both your terminal and VS Code. On macOS, apps launched from the Dock don't inherit shell env vars — launch VS Code from the terminal (`code .`) or set the variable via `launchctl setenv`. diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 8c9fdd8df..12bf5c2f0 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -17,7 +17,7 @@ */ import { existsSync, readFileSync, statSync } from "node:fs"; -import { basename, resolve } from "node:path"; +import { basename, relative, resolve } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { Type } from "@earendil-works/pi-ai"; import type { @@ -46,6 +46,7 @@ import { type PlannotatorPlanApprovedEvent, registerPlannotatorEventListeners, } from "./plannotator-events.ts"; +import { resolveTodoProvider, type TodoProvider } from "./todo-providers/index.ts"; import { findAssistantMessageByEntryId, getAssistantMessageText, @@ -263,6 +264,10 @@ export default function plannotator(pi: ExtensionAPI): void { let phaseAddedTools: string[] = []; let plannotatorConfig = {}; let justApprovedPlan = false; + /** Resolved once per execution phase; undefined means widget-only. */ + let todoProvider: TodoProvider | undefined; + /** Latch: no provider found, or one sync failed. Cleared on return to idle. */ + let todoProviderDisabled = false; pi.on("session_start", (_event, ctx) => { currentPiSession.update(ctx); @@ -323,6 +328,46 @@ export default function plannotator(pi: ExtensionAPI): void { } } + /** + * Mirror the checklist into an editable todo provider, when one is present. + * + * Additive by design: the progress widget above stays exactly as it was. + * pi-todos renders its list on demand in `/todos` and has no live surface, + * so replacing the widget with it would trade a visible tracker for files + * behind a keystroke. Failures are swallowed after one notification — + * a todo mirror must never break plan execution. Runs even when the + * checklist is empty so a resubmitted-empty plan still reconciles + * (closing todos it used to own) instead of leaving them orphaned. + */ + async function syncTodoProvider(ctx: ExtensionContext): Promise { + if (todoProviderDisabled) return; + if (phase !== "executing" || !lastSubmittedPath) return; + if (!todoProvider) { + todoProvider = resolveTodoProvider(loadConfig(), { + cwd: ctx.cwd, + sessionId: ctx.sessionManager.getSessionId(), + }); + if (!todoProvider) { + todoProviderDisabled = true; + return; + } + } + // Tag on the cwd-relative path: it is stable across machines and reads + // cleanly in the /todos detail view, which renders raw tags. + const planId = relative(ctx.cwd, resolve(ctx.cwd, lastSubmittedPath)) || lastSubmittedPath; + try { + await todoProvider.sync(checklistItems, planId); + } catch (error) { + todoProviderDisabled = true; + ctx.ui.notify( + `Plannotator: ${todoProvider.name} sync failed, continuing with the progress widget only. ${ + error instanceof Error ? error.message : String(error) + }`, + "warning", + ); + } + } + function captureSavedState(ctx: ExtensionContext): void { savedState = { model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined, @@ -414,6 +459,7 @@ export default function plannotator(pi: ExtensionAPI): void { updateStatus(ctx); updateWidget(ctx); + await syncTodoProvider(ctx); } async function enterPlanning(ctx: ExtensionContext): Promise { @@ -441,6 +487,10 @@ export default function plannotator(pi: ExtensionAPI): void { phase = "idle"; checklistItems = []; lastSubmittedPath = null; + // Re-detect for the next plan: a provider that appeared (or a transient + // write failure) should not be decided once for the whole session. + todoProvider = undefined; + todoProviderDisabled = false; releaseAddedPhaseTools(); await restoreSavedState(ctx); @@ -1294,6 +1344,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re if (markCompletedSteps(text, checklistItems) > 0) { updateStatus(ctx); updateWidget(ctx); + await syncTodoProvider(ctx); } persistState(); }); diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 0120c44bc..c5723c376 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -39,6 +39,7 @@ "config.ts", "plannotator.json", "server/", + "todo-providers/", "generated/", "README.md", "plannotator.html", diff --git a/apps/pi-extension/startup.test.ts b/apps/pi-extension/startup.test.ts index 15907ca13..3607d1ea9 100644 --- a/apps/pi-extension/startup.test.ts +++ b/apps/pi-extension/startup.test.ts @@ -56,12 +56,13 @@ describe("Pi extension startup boundary", () => { expect(browser.startMarkdownAnnotationSession).toBeFunction(); }); - test("ships the lazy runtime in the npm package", () => { + test("ships the lazy runtime and todo providers in the npm package", () => { const manifest = JSON.parse( readFileSync(join(extensionDirectory, "package.json"), "utf-8"), ) as { files?: unknown }; expect(Array.isArray(manifest.files)).toBe(true); expect(manifest.files).toContain("plannotator-browser-runtime.ts"); + expect(manifest.files).toContain("todo-providers/"); }); }); diff --git a/apps/pi-extension/todo-provider-sync.test.ts b/apps/pi-extension/todo-provider-sync.test.ts new file mode 100644 index 000000000..0095b124e --- /dev/null +++ b/apps/pi-extension/todo-provider-sync.test.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import plannotator from "./index.ts"; + +/** + * Extension-level coverage for the todo-provider mirror: proves the plan + * lifecycle actually drives a provider, which the provider's own unit tests + * (todo-providers/pi-todos.test.ts) cannot show. + * + * The Pi host stub mirrors the one in external-execution.test.ts, trimmed to + * what a `--plan` session needs to boot, auto-approve (hasUI: false), finish a + * turn, and toggle back to idle. + */ +const tempDirs: string[] = []; +let originalPiTodoPath: string | undefined; +let originalTodoProviderEnv: string | undefined; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +/** Restore an env var to its pre-test value instead of deleting ambient state. */ +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +beforeEach(() => { + // The extension resolves a real provider through the real env-sensitive + // detection code (resolveTodoProvider / detectPiTodos). Snapshot and clear + // PI_TODO_PATH and PLANNOTATOR_TODO_PROVIDER so an ambient value on the + // host — or leaked from another test file sharing this process — can + // never redirect detection or writes outside the temp dirs below. + originalPiTodoPath = process.env.PI_TODO_PATH; + originalTodoProviderEnv = process.env.PLANNOTATOR_TODO_PROVIDER; + delete process.env.PI_TODO_PATH; + delete process.env.PLANNOTATOR_TODO_PROVIDER; +}); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + restoreEnv("PI_TODO_PATH", originalPiTodoPath); + restoreEnv("PLANNOTATOR_TODO_PROVIDER", originalTodoProviderEnv); +}); + +function createHarness(cwd: string) { + const commands = new Map Promise }>(); + const tools = new Map Promise }>(); + const handlers = new Map unknown>>(); + const notifications: string[] = []; + const widgets: Array = []; + const state = { activeTools: ["read", "bash", "edit", "write"], thinkingLevel: "medium" }; + + const pi = { + events: { on: () => undefined, emit: () => undefined }, + on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => { + const eventHandlers = handlers.get(event) ?? []; + eventHandlers.push(handler); + handlers.set(event, eventHandlers); + }, + registerFlag: () => undefined, + registerShortcut: () => undefined, + registerCommand: ( + name: string, + command: { handler: (args: string, ctx: unknown) => Promise }, + ) => commands.set(name, command), + registerTool: (tool: { name: string; execute: (...args: unknown[]) => Promise }) => + tools.set(tool.name, tool), + getFlag: () => true, + getActiveTools: () => [...state.activeTools], + setActiveTools: (next: string[]) => { + state.activeTools = [...next]; + }, + getThinkingLevel: () => state.thinkingLevel, + setThinkingLevel: (level: string) => { + state.thinkingLevel = level; + }, + setModel: async () => true, + appendEntry: () => undefined, + sendMessage: () => undefined, + sendUserMessage: () => undefined, + }; + + const ctx = { + cwd, + hasUI: false, + isIdle: () => true, + model: { provider: "test", id: "original-model" }, + modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, + sessionManager: { + getEntries: () => [], + getSessionId: () => "test-session", + getSessionFile: () => "session.json", + getSessionName: () => undefined, + }, + ui: { + notify: (message: string) => notifications.push(message), + setStatus: () => undefined, + setWidget: (_key: string, content: string[] | undefined) => widgets.push(content), + theme: { + fg: (_color: string, text: string) => text, + strikethrough: (text: string) => text, + }, + }, + }; + + return { + ctx, + commands, + notifications, + widgets, + async startSession(): Promise { + plannotator(pi as never); + for (const handler of handlers.get("session_start") ?? []) { + await handler({ reason: "startup" }, ctx); + } + }, + submitPlan(filePath: string) { + return tools + .get("plannotator_submit_plan")! + .execute("call-1", { filePath }, undefined, undefined, ctx); + }, + async endTurn(text: string): Promise { + for (const handler of handlers.get("turn_end") ?? []) { + await handler({ message: { role: "assistant", content: [{ type: "text", text }] } }, ctx); + } + }, + /** The `/plannotator` toggle is the shared exit back to idle. */ + async toggle(): Promise { + await commands.get("plannotator")?.handler("", ctx); + }, + }; +} + +const PLAN_CONTENT = "# Plan\n\n- [ ] First step\n- [ ] Second step\n"; + +function readTodos( + todosDir: string, +): Array<{ title: string; status: string; tags: string[]; assigned_to_session?: string }> { + if (!existsSync(todosDir)) return []; + return readdirSync(todosDir) + .filter((entry) => entry.endsWith(".md")) + .map((entry) => { + const content = readFileSync(join(todosDir, entry), "utf8"); + const end = content.indexOf("\n}"); + return JSON.parse(content.slice(0, end + 2)) as { + title: string; + status: string; + tags: string[]; + assigned_to_session?: string; + }; + }); +} + +describe("plan execution mirrors into a detected todo provider", () => { + test("approving a plan writes the checklist to .pi/todos", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + const todosDir = join(cwd, ".pi", "todos"); + mkdirSync(todosDir, { recursive: true }); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + + const harness = createHarness(cwd); + await harness.startSession(); + await harness.submitPlan("PLAN.md"); + + const todos = readTodos(todosDir); + expect(todos.map((todo) => todo.title).sort()).toEqual(["1. First step", "2. Second step"]); + for (const todo of todos) { + expect(todo.status).toBe("open"); + expect(todo.tags).toContain("plannotator:plan:PLAN.md"); + // TodoProviderEnv carries the session id (ctx.sessionManager.getSessionId()), + // never the session file path — the harness mock returns a different + // string for each, so this fails if the wiring regresses. + expect(todo.assigned_to_session).toBe("test-session"); + } + }); + + test("a DONE marker closes the matching todo", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + const todosDir = join(cwd, ".pi", "todos"); + mkdirSync(todosDir, { recursive: true }); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + + const harness = createHarness(cwd); + await harness.startSession(); + await harness.submitPlan("PLAN.md"); + await harness.endTurn("Finished the first bit. [DONE:1]"); + + const byTitle = new Map(readTodos(todosDir).map((todo) => [todo.title, todo])); + expect(byTitle.get("1. First step")?.status).toBe("done"); + expect(byTitle.get("2. Second step")?.status).toBe("open"); + }); + + test("keeps the progress widget even while mirroring", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + const todosDir = join(cwd, ".pi", "todos"); + mkdirSync(todosDir, { recursive: true }); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + + const harness = createHarness(cwd); + await harness.startSession(); + await harness.submitPlan("PLAN.md"); + + // The mirror is additive: the tracker still renders both steps... + const rendered = harness.widgets.filter((content): content is string[] => Array.isArray(content)); + expect(rendered.at(-1)).toHaveLength(2); + + // ...and the provider actually received the same two steps, not just + // an empty or partial mirror running alongside an unaffected widget. + const todos = readTodos(todosDir); + expect(todos.map((todo) => todo.title).sort()).toEqual(["1. First step", "2. Second step"]); + for (const todo of todos) expect(todo.status).toBe("open"); + }); + + test("stays widget-only when no provider is present", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + + const harness = createHarness(cwd); + await harness.startSession(); + await harness.submitPlan("PLAN.md"); + await harness.endTurn("Done with one. [DONE:1]"); + + // No .pi/todos to detect, so nothing is created and nothing warns. + expect(existsSync(join(cwd, ".pi", "todos"))).toBe(false); + expect(harness.notifications.filter((note) => note.includes("sync failed"))).toEqual([]); + const rendered = harness.widgets.filter((content): content is string[] => Array.isArray(content)); + expect(rendered.at(-1)).toHaveLength(2); + }); + + test("re-detects a provider that appears after the first plan", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + const todosDir = join(cwd, ".pi", "todos"); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + writeFileSync(join(cwd, "SECOND.md"), "# Plan\n\n- [ ] Later step\n"); + + const harness = createHarness(cwd); + await harness.startSession(); + // First plan runs with no provider installed. + await harness.submitPlan("PLAN.md"); + expect(readTodos(todosDir)).toEqual([]); + + // Back to idle, provider installed, second plan approved. + await harness.toggle(); + mkdirSync(todosDir, { recursive: true }); + await harness.toggle(); + await harness.submitPlan("SECOND.md"); + + expect(readTodos(todosDir).map((todo) => todo.title)).toEqual(["1. Later step"]); + }); + + test("a sync failure notifies once and latches while the widget keeps updating", async () => { + const cwd = makeTempDir("plannotator-todo-sync-"); + const todosDir = join(cwd, ".pi", "todos"); + mkdirSync(todosDir, { recursive: true }); + writeFileSync(join(cwd, "PLAN.md"), PLAN_CONTENT); + + const harness = createHarness(cwd); + await harness.startSession(); + await harness.submitPlan("PLAN.md"); + // The provider is healthy for the initial sync: a real directory. + expect(readTodos(todosDir)).toHaveLength(2); + + // Break the provider without chmod: replace the todos directory with a + // plain file, so the next sync's `fs.mkdir(todosDir, { recursive: true })` + // throws ENOTDIR/EEXIST instead of silently no-oping or needing + // permissions this process might not even be able to drop. + rmSync(todosDir, { recursive: true, force: true }); + writeFileSync(todosDir, "not a directory anymore"); + + await harness.endTurn("Finished the first bit. [DONE:1]"); + await harness.endTurn("Finished the second bit. [DONE:2]"); + + const failureNotices = harness.notifications.filter((note) => note.includes("sync failed")); + expect(failureNotices).toHaveLength(1); + + // The widget is wired independently of provider health: execution + // keeps rendering progress after the provider latches disabled. + const rendered = harness.widgets.filter((content): content is string[] => Array.isArray(content)); + expect(rendered.at(-1)).toHaveLength(2); + }); +}); diff --git a/apps/pi-extension/todo-providers/index.ts b/apps/pi-extension/todo-providers/index.ts new file mode 100644 index 000000000..bee436e20 --- /dev/null +++ b/apps/pi-extension/todo-providers/index.ts @@ -0,0 +1,27 @@ +import { type PlannotatorConfig, resolveTodoProviderEnabled } from "../generated/config.ts"; +import { createPiTodosProvider, detectPiTodos } from "./pi-todos.ts"; +import type { TodoProvider, TodoProviderEnv } from "./types.ts"; + +export type { TodoProvider, TodoProviderEnv } from "./types.ts"; +export { detectPiTodos, resolveTodoDir } from "./pi-todos.ts"; + +/** + * Pick a todo provider for this session, or undefined to stay widget-only. + * + * Only pi-todos is implemented today. oh-my-pi's native todo panel is the + * obvious second provider, but as of omp 17.1.5 the panel repaint is gated to + * the built-in `todo` tool (`modes/controllers/event-controller.ts`, plus the + * same guard in `session/agent-session.ts`), and the session handed to an + * extension-registered tool is a read-only projection without the todo + * accessors. A third-party extension therefore cannot repaint that panel; when + * upstream opens it up, an omp provider drops in here behind the same + * interface with no caller changes. + */ +export function resolveTodoProvider( + config: PlannotatorConfig, + env: TodoProviderEnv, +): TodoProvider | undefined { + if (!resolveTodoProviderEnabled(config)) return undefined; + if (!detectPiTodos(env.cwd)) return undefined; + return createPiTodosProvider(env); +} diff --git a/apps/pi-extension/todo-providers/pi-todos.test.ts b/apps/pi-extension/todo-providers/pi-todos.test.ts new file mode 100644 index 000000000..f0a3b7cbf --- /dev/null +++ b/apps/pi-extension/todo-providers/pi-todos.test.ts @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ChecklistItem } from "../generated/checklist.ts"; +import { createPiTodosProvider, detectPiTodos } from "./pi-todos.ts"; +import { resolveTodoProvider } from "./index.ts"; + +/** + * ── Format oracle ──────────────────────────────────────────────────────────── + * + * pi-todos exposes no importable API, so to prove we write files it can read we + * re-derive its reader here and parse our own output with it. + * + * This is an INDEPENDENT implementation written from the format documented in + * mitsuhiko/agent-stuff `extensions/todos.ts` @ a3f8ab11 — deliberately not a + * copy, both to keep this repo's `MIT OR Apache-2.0` grant clean (agent-stuff + * is Apache-2.0) and because a different strategy makes it a real cross-check + * instead of the same code twice. Upstream scans braces to find the end of the + * leading JSON object (`findJsonObjectEnd`, todos.ts:840-880); this walks + * candidate `}` positions and takes the first that parses. + * + * Contract being pinned, per todos.ts: + * - leading JSON object, optional blank line, markdown body (header, :1-19) + * - `["closed", "done"]` count as closed (`isTodoClosed`, :179-181) + * - sort: closed last, then assigned-first, then `created_at` ascending + * (`sortTodos`, :189-199) + * + * If upstream changes any of that, these tests fail — that failure is the point. + */ +interface OracleFrontMatter { + id: string; + title: string; + tags: string[]; + status: string; + created_at: string; + assigned_to_session?: string; +} + +/** Take the leading JSON object as the first `}`-terminated prefix that parses. */ +function oracleSplit(content: string): { frontMatter: unknown; body: string } { + if (!content.startsWith("{")) return { frontMatter: null, body: content }; + for (let i = content.indexOf("}"); i !== -1; i = content.indexOf("}", i + 1)) { + try { + return { + frontMatter: JSON.parse(content.slice(0, i + 1)) as unknown, + body: content.slice(i + 1).replace(/^\r?\n+/, ""), + }; + } catch { + // Not a complete object yet — a brace inside a string or a nested + // object. Try the next candidate. + } + } + return { frontMatter: null, body: content }; +} + +function oracleParse(content: string, idFallback: string): OracleFrontMatter { + const { frontMatter } = oracleSplit(content); + const raw = (frontMatter ?? {}) as Partial; + return { + id: typeof raw.id === "string" && raw.id ? raw.id : idFallback, + title: typeof raw.title === "string" ? raw.title : "", + tags: Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === "string") : [], + status: typeof raw.status === "string" && raw.status ? raw.status : "open", + created_at: typeof raw.created_at === "string" ? raw.created_at : "", + assigned_to_session: + typeof raw.assigned_to_session === "string" && raw.assigned_to_session.trim() + ? raw.assigned_to_session + : undefined, + }; +} + +function oracleIsTodoClosed(status: string): boolean { + return ["closed", "done"].includes(status.toLowerCase()); +} + +function oracleSortTodos(todos: OracleFrontMatter[]): OracleFrontMatter[] { + const rank = (todo: OracleFrontMatter): number => { + if (oracleIsTodoClosed(todo.status)) return 2; + return todo.assigned_to_session ? 0 : 1; + }; + return [...todos].sort( + (a, b) => rank(a) - rank(b) || (a.created_at || "").localeCompare(b.created_at || ""), + ); +} + +/** Read every todo in a dir through the oracle. */ +async function readViaOracle(todosDir: string): Promise { + const entries = await fs.readdir(todosDir); + const todos: OracleFrontMatter[] = []; + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + const content = await fs.readFile(path.join(todosDir, entry), "utf8"); + todos.push(oracleParse(content, entry.slice(0, -3))); + } + return todos; +} + +// ── Fixture ────────────────────────────────────────────────────────────────── + +const PLAN_ID = "PLAN.md"; + +function checklist(...done: number[]): ChecklistItem[] { + return [ + { step: 1, text: "Add the provider interface", completed: done.includes(1) }, + { step: 2, text: "Implement pi-todos", completed: done.includes(2) }, + { step: 3, text: "Wire the extension", completed: done.includes(3) }, + ]; +} + +let cwd: string; +let todosDir: string; +let originalPiTodoPath: string | undefined; +let originalTodoProviderEnv: string | undefined; + +/** Restore an env var to its pre-test value instead of deleting ambient state. */ +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +beforeEach(async () => { + // Snapshot and clear so an ambient PI_TODO_PATH / PLANNOTATOR_TODO_PROVIDER + // on the host — or leaked from another test file sharing this process — + // can never redirect detection or writes outside the temp dir below. + originalPiTodoPath = process.env.PI_TODO_PATH; + originalTodoProviderEnv = process.env.PLANNOTATOR_TODO_PROVIDER; + delete process.env.PI_TODO_PATH; + delete process.env.PLANNOTATOR_TODO_PROVIDER; + cwd = await fs.mkdtemp(path.join(os.tmpdir(), "plannotator-pi-todos-")); + todosDir = path.join(cwd, ".pi", "todos"); + await fs.mkdir(todosDir, { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }); + restoreEnv("PI_TODO_PATH", originalPiTodoPath); + restoreEnv("PLANNOTATOR_TODO_PROVIDER", originalTodoProviderEnv); +}); + +describe("pi-todos provider", () => { + test("writes files the upstream parser reads back intact", async () => { + await createPiTodosProvider({ cwd, sessionId: "session-1" }).sync(checklist(), PLAN_ID); + + // Upstream's directory scan derives a todo's id from its FILENAME, not + // from any "id" field inside the JSON front matter — check the actual + // on-disk names directly instead of routing through the oracle's id + // fallback, which would accept a mismatched filename as long as the + // JSON carried a matching "id". + const filenames = (await fs.readdir(todosDir)).filter((entry) => entry.endsWith(".md")); + expect(filenames).toHaveLength(3); + for (const filename of filenames) expect(filename).toMatch(/^[a-f0-9]{8}\.md$/); + expect(new Set(filenames).size).toBe(3); + + const todos = await readViaOracle(todosDir); + expect(todos).toHaveLength(3); + for (const todo of todos) { + // A format break shows up here: the oracle falls back to defaults + // (empty title, empty created_at) when it cannot parse our JSON. + expect(todo.title).not.toBe(""); + expect(todo.created_at).not.toBe(""); + expect(todo.tags).toContain("plannotator"); + expect(todo.tags).toContain(`plannotator:plan:${PLAN_ID}`); + expect(todo.status).toBe("open"); + expect(oracleIsTodoClosed(todo.status)).toBe(false); + } + }); + + test("keeps plan order under the upstream sort", async () => { + await createPiTodosProvider({ cwd }).sync(checklist(), PLAN_ID); + + const sorted = oracleSortTodos(await readViaOracle(todosDir)); + expect(sorted.map((todo) => todo.title)).toEqual([ + "1. Add the provider interface", + "2. Implement pi-todos", + "3. Wire the extension", + ]); + }); + + test("is idempotent across repeated syncs", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), PLAN_ID); + const first = await readViaOracle(todosDir); + await provider.sync(checklist(), PLAN_ID); + await provider.sync(checklist(), PLAN_ID); + const third = await readViaOracle(todosDir); + + expect(third).toHaveLength(3); + expect(third.map((todo) => todo.id).sort()).toEqual(first.map((todo) => todo.id).sort()); + }); + + test("serializes overlapping syncs so a race can't duplicate todos", async () => { + const provider = createPiTodosProvider({ cwd }); + // Neither call is awaited before the other starts. Without per-instance + // serialization both would read the same empty `readOwnedTodos()` + // snapshot and each mint a fresh random id per step, duplicating every + // todo. Asserting on the converged final state (not which call "wins") + // keeps this deterministic instead of racy: it only holds if the + // second call is queued fully behind the first. + const first = provider.sync(checklist(), PLAN_ID); + const second = provider.sync(checklist(1, 2, 3), PLAN_ID); + await Promise.all([first, second]); + + const todos = await readViaOracle(todosDir); + expect(todos).toHaveLength(3); + for (const todo of todos) expect(oracleIsTodoClosed(todo.status)).toBe(true); + }); + + test("reflects DONE markers as closed and clears the session assignment", async () => { + const provider = createPiTodosProvider({ cwd, sessionId: "session-1" }); + await provider.sync(checklist(), PLAN_ID); + await provider.sync(checklist(1, 2), PLAN_ID); + + const byTitle = new Map((await readViaOracle(todosDir)).map((todo) => [todo.title, todo])); + expect(oracleIsTodoClosed(byTitle.get("1. Add the provider interface")!.status)).toBe(true); + expect(oracleIsTodoClosed(byTitle.get("2. Implement pi-todos")!.status)).toBe(true); + expect(oracleIsTodoClosed(byTitle.get("3. Wire the extension")!.status)).toBe(false); + // Closed todos drop their assignment, matching upstream, so they sort + // below live work instead of above it. + expect(byTitle.get("1. Add the provider interface")!.assigned_to_session).toBeUndefined(); + expect(byTitle.get("3. Wire the extension")!.assigned_to_session).toBe("session-1"); + }); + + test("sorts completed steps below open ones", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), PLAN_ID); + await provider.sync(checklist(1), PLAN_ID); + + const sorted = oracleSortTodos(await readViaOracle(todosDir)); + expect(sorted.at(-1)?.title).toBe("1. Add the provider interface"); + }); + + test("closes steps dropped from an edited plan", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), PLAN_ID); + await provider.sync([{ step: 1, text: "Add the provider interface", completed: false }], PLAN_ID); + + const byTitle = new Map((await readViaOracle(todosDir)).map((todo) => [todo.title, todo])); + expect(oracleIsTodoClosed(byTitle.get("1. Add the provider interface")!.status)).toBe(false); + expect(oracleIsTodoClosed(byTitle.get("2. Implement pi-todos")!.status)).toBe(true); + expect(oracleIsTodoClosed(byTitle.get("3. Wire the extension")!.status)).toBe(true); + }); + + test("leaves another plan's todos alone", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), "other-plan.md"); + await provider.sync(checklist(), PLAN_ID); + + const todos = await readViaOracle(todosDir); + expect(todos).toHaveLength(6); + expect(todos.filter((todo) => todo.tags.includes(`plannotator:plan:${PLAN_ID}`))).toHaveLength(3); + expect( + todos.filter((todo) => todo.tags.includes("plannotator:plan:other-plan.md")), + ).toHaveLength(3); + }); + + test("skips a locked todo instead of stealing the lock", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), PLAN_ID); + + const target = (await readViaOracle(todosDir)).find( + (todo) => todo.title === "2. Implement pi-todos", + )!; + const lockPath = path.join(todosDir, `${target.id}.lock`); + await fs.writeFile(lockPath, JSON.stringify({ id: target.id, pid: 1 }), "utf8"); + + await provider.sync(checklist(2), PLAN_ID); + + const after = (await readViaOracle(todosDir)).find((todo) => todo.id === target.id)!; + // Locked, so the DONE reflection is deferred rather than applied. + expect(oracleIsTodoClosed(after.status)).toBe(false); + // The foreign lock survives untouched. + expect(existsSync(lockPath)).toBe(true); + }); + + test("releases its own locks", async () => { + await createPiTodosProvider({ cwd }).sync(checklist(), PLAN_ID); + const leftover = (await fs.readdir(todosDir)).filter((entry) => entry.endsWith(".lock")); + expect(leftover).toEqual([]); + }); + + test("no-ops on an empty checklist", async () => { + await createPiTodosProvider({ cwd }).sync([], PLAN_ID); + expect(await fs.readdir(todosDir)).toEqual([]); + }); + + test("closes previously owned todos when the checklist goes empty", async () => { + const provider = createPiTodosProvider({ cwd }); + await provider.sync(checklist(), PLAN_ID); + await provider.sync([], PLAN_ID); + + // Reconciliation, not deletion: the now-ownerless todos stay on disk, + // closed, so pi-todos' own GC reaps them instead of this provider + // unlinking work a user might still want to read. + const todos = await readViaOracle(todosDir); + expect(todos).toHaveLength(3); + for (const todo of todos) expect(oracleIsTodoClosed(todo.status)).toBe(true); + }); +}); + +describe("pi-todos detection", () => { + test("detects an existing .pi/todos directory", () => { + expect(detectPiTodos(cwd)).toBe(true); + }); + + test("reports absent when there is no todo directory", async () => { + const bare = await fs.mkdtemp(path.join(os.tmpdir(), "plannotator-bare-")); + try { + expect(detectPiTodos(bare)).toBe(false); + } finally { + await fs.rm(bare, { recursive: true, force: true }); + } + }); + + test("honours PI_TODO_PATH", async () => { + const custom = await fs.mkdtemp(path.join(os.tmpdir(), "plannotator-custom-")); + const bare = await fs.mkdtemp(path.join(os.tmpdir(), "plannotator-bare-")); + try { + process.env.PI_TODO_PATH = custom; + expect(detectPiTodos(bare)).toBe(true); + + await createPiTodosProvider({ cwd: bare }).sync(checklist(), PLAN_ID); + expect((await fs.readdir(custom)).filter((entry) => entry.endsWith(".md"))).toHaveLength(3); + } finally { + await fs.rm(custom, { recursive: true, force: true }); + await fs.rm(bare, { recursive: true, force: true }); + } + }); +}); + +describe("provider resolution", () => { + test("returns the pi-todos provider when detected", () => { + expect(resolveTodoProvider({}, { cwd })?.name).toBe("pi-todos"); + }); + + test("returns nothing when todoProvider is off", () => { + expect(resolveTodoProvider({ todoProvider: "off" }, { cwd })).toBeUndefined(); + }); + + test("returns nothing when no provider is present", async () => { + const bare = await fs.mkdtemp(path.join(os.tmpdir(), "plannotator-bare-")); + try { + expect(resolveTodoProvider({}, { cwd: bare })).toBeUndefined(); + } finally { + await fs.rm(bare, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/pi-extension/todo-providers/pi-todos.ts b/apps/pi-extension/todo-providers/pi-todos.ts new file mode 100644 index 000000000..b4354f1e5 --- /dev/null +++ b/apps/pi-extension/todo-providers/pi-todos.ts @@ -0,0 +1,303 @@ +/** + * pi-todos provider. + * + * pi-todos (mitsuhiko/agent-stuff `extensions/todos.ts`) stores each todo as a + * standalone markdown file with a JSON front-matter block. It exposes no + * programmatic API to other extensions, so the files ARE the integration + * surface. Everything below mirrors that on-disk contract as verified against + * upstream commit a3f8ab11 (2026-05-07): + * + * - directory: $PI_TODO_PATH, else /.pi/todos + * - todo file: <8 lowercase hex>.md + * - lock file: <8 lowercase hex>.lock, created with O_EXCL, 30 min TTL + * - body: JSON.stringify(frontMatter, null, 2), then "\n", then optional + * markdown body separated by a blank line + * - status: free-form string; "closed" and "done" (case-insensitive) count as + * closed, anything else is open. New todos default to "open". + * - a closed todo drops its `assigned_to_session`, which also keeps it from + * sorting above open work in the `/todos` list. + * + * `pi-todos.test.ts` re-derives that reader independently and round-trips our + * output through it, so a drift in either direction fails loudly. + */ +import crypto from "node:crypto"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import path from "node:path"; +import type { ChecklistItem } from "../generated/checklist.ts"; +import type { TodoProvider, TodoProviderEnv } from "./types.ts"; + +const TODO_DIR_NAME = path.join(".pi", "todos"); +const TODO_PATH_ENV = "PI_TODO_PATH"; + +/** Tag marking every todo this provider owns. */ +const OWNER_TAG = "plannotator"; +const STEP_TAG_PATTERN = /^plannotator:step:(\d+)$/; + +interface PiTodoFrontMatter { + id: string; + title: string; + tags: string[]; + status: string; + created_at: string; + assigned_to_session?: string; +} + +interface OwnedTodo { + id: string; + frontMatter: PiTodoFrontMatter; + body: string; +} + +/** Resolve the todo directory the way pi-todos itself resolves it. */ +export function resolveTodoDir(cwd: string): string { + const fromEnv = process.env[TODO_PATH_ENV]?.trim(); + if (fromEnv) return path.resolve(cwd, fromEnv); + return path.join(cwd, TODO_DIR_NAME); +} + +/** + * True when pi-todos looks present and in use. + * + * Detection is deliberately conservative: it only checks whether the todo + * directory exists — pi-todos creates it on first write, so an existing + * directory is the actual "in use" signal. PI_TODO_PATH does not detect + * the provider by itself; it only redirects which directory gets checked, + * so setting it with that directory absent still reads as "absent". A user + * who installed pi-todos but never created a todo also reads as "absent" + * and simply gets no mirror — the widget is unaffected either way, so a + * false negative costs nothing. + */ +export function detectPiTodos(cwd: string): boolean { + return existsSync(resolveTodoDir(cwd)); +} + +function serialize(frontMatter: PiTodoFrontMatter, body: string): string { + const closed = ["closed", "done"].includes(frontMatter.status.toLowerCase()); + const json = JSON.stringify( + { + id: frontMatter.id, + title: frontMatter.title, + tags: frontMatter.tags ?? [], + status: frontMatter.status, + created_at: frontMatter.created_at, + assigned_to_session: closed ? undefined : frontMatter.assigned_to_session || undefined, + }, + null, + 2, + ); + const trimmed = body.replace(/^\n+/, "").replace(/\s+$/, ""); + return trimmed ? `${json}\n\n${trimmed}\n` : `${json}\n`; +} + +/** + * Split the leading JSON object off a todo file. Scans braces instead of using + * a greedy regex so bodies containing braces survive intact. + */ +function splitFrontMatter(content: string): { json: string; body: string } { + if (!content.startsWith("{")) return { json: "", body: content }; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = 0; i < content.length; i += 1) { + const char = content[i]; + if (inString) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) { + return { + json: content.slice(0, i + 1), + body: content.slice(i + 1).replace(/^\r?\n+/, ""), + }; + } + } + } + return { json: "", body: content }; +} + +function parseFrontMatter(content: string, id: string): PiTodoFrontMatter | null { + const { json } = splitFrontMatter(content); + if (!json) return null; + try { + const parsed = JSON.parse(json) as Partial; + return { + id, + title: typeof parsed.title === "string" ? parsed.title : "", + tags: Array.isArray(parsed.tags) ? parsed.tags.filter((tag) => typeof tag === "string") : [], + status: typeof parsed.status === "string" && parsed.status ? parsed.status : "open", + created_at: typeof parsed.created_at === "string" ? parsed.created_at : "", + assigned_to_session: + typeof parsed.assigned_to_session === "string" ? parsed.assigned_to_session : undefined, + }; + } catch { + return null; + } +} + +/** + * Take the same O_EXCL lock pi-todos takes, so a concurrent `/todos` edit can + * never interleave with our write. Returns null when the todo is already + * locked: pi-todos prompts the user before stealing a stale lock, and a + * background sync has no business doing that, so we skip the todo and pick it + * up on the next sync instead of blocking the agent. + */ +async function withLock(todosDir: string, id: string, fn: () => Promise): Promise { + const lockPath = path.join(todosDir, `${id}.lock`); + let handle: FileHandle; + try { + handle = await fs.open(lockPath, "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "EEXIST") return null; + throw error; + } + try { + try { + await handle.writeFile( + JSON.stringify( + { id, pid: process.pid, session: null, created_at: new Date().toISOString() }, + null, + 2, + ), + "utf8", + ); + } finally { + // Always release the fd, even when the write above threw, so a + // write failure can never leak a lock-file handle. Swallow the + // close error itself so it can never mask the original failure. + await handle.close().catch(() => {}); + } + return await fn(); + } finally { + await fs.unlink(lockPath).catch(() => {}); + } +} + +/** Existing plannotator-owned todos for a plan, keyed by step number. */ +async function readOwnedTodos(todosDir: string, planId: string): Promise> { + const owned = new Map(); + const entries = await fs.readdir(todosDir).catch(() => [] as string[]); + const wantPlan = `${OWNER_TAG}:plan:${planId}`; + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + const id = entry.slice(0, -3); + const content = await fs.readFile(path.join(todosDir, entry), "utf8").catch(() => null); + if (content === null) continue; + const frontMatter = parseFrontMatter(content, id); + if (!frontMatter || !frontMatter.tags.includes(wantPlan)) continue; + const step = frontMatter.tags + .map((tag) => STEP_TAG_PATTERN.exec(tag)?.[1]) + .find((value): value is string => value !== undefined); + if (step === undefined) continue; + owned.set(Number(step), { id, frontMatter, body: splitFrontMatter(content).body }); + } + return owned; +} + +export function createPiTodosProvider(env: TodoProviderEnv): TodoProvider { + const todosDir = resolveTodoDir(env.cwd); + // Serializes overlapping sync() calls on this instance. pi-todos has no + // atomic upsert: two syncs racing between readOwnedTodos and its writes + // would both see the same step as "missing" and each create a todo for + // it. Chaining keeps every call's read-then-write pair uninterrupted. + let queue: Promise = Promise.resolve(); + + async function runSync(items: ChecklistItem[], planId: string): Promise { + await fs.mkdir(todosDir, { recursive: true }); + const owned = await readOwnedTodos(todosDir, planId); + // pi-todos sorts by created_at and has no explicit order field, and + // Date.now() only has ms resolution, so a tight creation loop would + // collide and fall back to readdir order (random hex filenames). + // Stamping base+index keeps todo order equal to plan order. + const base = Date.now(); + + for (const [index, item] of items.entries()) { + const title = `${item.step}. ${item.text}`; + const status = item.completed ? "done" : "open"; + const existing = owned.get(item.step); + + if (existing) { + if (existing.frontMatter.title === title && existing.frontMatter.status === status) { + continue; + } + await withLock(todosDir, existing.id, () => + fs.writeFile( + path.join(todosDir, `${existing.id}.md`), + serialize({ ...existing.frontMatter, title, status }, existing.body), + "utf8", + ), + ); + continue; + } + + let id = crypto.randomBytes(4).toString("hex"); + for ( + let attempt = 0; + attempt < 10 && existsSync(path.join(todosDir, `${id}.md`)); + attempt += 1 + ) { + id = crypto.randomBytes(4).toString("hex"); + } + await withLock(todosDir, id, () => + fs.writeFile( + path.join(todosDir, `${id}.md`), + serialize( + { + id, + title, + tags: [OWNER_TAG, `${OWNER_TAG}:plan:${planId}`, `${OWNER_TAG}:step:${item.step}`], + status, + created_at: new Date(base + index).toISOString(), + assigned_to_session: env.sessionId, + }, + `Plan step ${item.step} from \`${planId}\`.`, + ), + "utf8", + ), + ); + } + + // Steps that vanished from the plan (edited and re-approved, + // renumbered, or an empty resubmission) would otherwise sit in + // /todos as permanently-open work. Close them rather than unlink: + // the user's notes stay readable, and pi-todos' own GC reaps closed + // todos after gcDays. An empty `items` closes every owned step + // still open, so reconciling against nothing left to do still + // clears out what this planId used to own. + const liveSteps = new Set(items.map((item) => item.step)); + for (const [step, stale] of owned) { + if (liveSteps.has(step)) continue; + if (["closed", "done"].includes(stale.frontMatter.status.toLowerCase())) continue; + await withLock(todosDir, stale.id, () => + fs.writeFile( + path.join(todosDir, `${stale.id}.md`), + serialize({ ...stale.frontMatter, status: "closed" }, stale.body), + "utf8", + ), + ); + } + } + + return { + name: "pi-todos", + + sync(items: ChecklistItem[], planId: string): Promise { + const run = queue.then(() => runSync(items, planId)); + // Recover the queue after a rejection so the next call still + // runs; `run` itself still rejects, so the caller (index.ts) + // sees the error and handles/notifies it. + queue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + }; +} diff --git a/apps/pi-extension/todo-providers/types.ts b/apps/pi-extension/todo-providers/types.ts new file mode 100644 index 000000000..afc5ef757 --- /dev/null +++ b/apps/pi-extension/todo-providers/types.ts @@ -0,0 +1,32 @@ +import type { ChecklistItem } from "../generated/checklist.ts"; + +/** + * An editable todo backend that mirrors an approved plan's checklist. + * + * Sync is one-way (Plannotator -> provider). Plannotator remains the source of + * truth for step state: it parses `[DONE:n]` markers out of the plan file and + * pushes the resulting status. Edits made inside the provider are never read + * back, so a user reordering or rewording a todo cannot desync plan execution. + */ +export interface TodoProvider { + /** Stable identifier, surfaced in notifications. */ + readonly name: string; + /** + * Make the provider match `items` for the given plan. + * + * Implementations MUST be idempotent: repeated calls with the same arguments + * converge on the same provider state instead of creating duplicates. This + * runs once on plan approval and again whenever a `[DONE:n]` marker lands, + * so it covers both the initial push and later status reflection. An empty + * `items` is not a no-op: it still reconciles against `planId`'s previously + * synced state, closing any todos this call no longer lists. + */ + sync(items: ChecklistItem[], planId: string): Promise; +} + +/** Host facts a provider needs. Kept narrow so providers stay testable. */ +export interface TodoProviderEnv { + cwd: string; + /** Session id, recorded as the owning session when available. */ + sessionId?: string; +} diff --git a/apps/pi-extension/tsconfig.json b/apps/pi-extension/tsconfig.json index 9dfd596da..3b8ec3213 100644 --- a/apps/pi-extension/tsconfig.json +++ b/apps/pi-extension/tsconfig.json @@ -11,6 +11,6 @@ "moduleDetection": "force", "types": ["node"] }, - "include": ["*.ts", "server/**/*.ts"], + "include": ["*.ts", "server/**/*.ts", "todo-providers/**/*.ts"], "exclude": ["**/*.test.ts"] } diff --git a/packages/shared/config.test.ts b/packages/shared/config.test.ts index 1db9fc4c7..4e78f8798 100644 --- a/packages/shared/config.test.ts +++ b/packages/shared/config.test.ts @@ -6,6 +6,7 @@ import { resolveAnnotateHistory, resolveGuideHistory, resolveUseJina, + resolveTodoProviderEnabled, } from "./config"; import type { PlannotatorConfig } from "./config"; @@ -25,6 +26,42 @@ describe("resolveAIEnabled", () => { }); }); +const TODO_ENV = "PLANNOTATOR_TODO_PROVIDER"; +const originalTodoEnv = process.env[TODO_ENV]; + +describe("resolveTodoProviderEnabled", () => { + beforeEach(() => { + delete process.env[TODO_ENV]; + }); + afterAll(() => { + if (originalTodoEnv === undefined) delete process.env[TODO_ENV]; + else process.env[TODO_ENV] = originalTodoEnv; + }); + + test("defaults to enabled", () => { + expect(resolveTodoProviderEnabled({})).toBe(true); + expect(resolveTodoProviderEnabled({ todoProvider: "auto" })).toBe(true); + }); + + test("config key can turn the mirror off", () => { + expect(resolveTodoProviderEnabled({ todoProvider: "off" })).toBe(false); + }); + + test("env accepts the same off vocabulary as the other flags", () => { + for (const v of ["off", "OFF", "0", "false", "disabled"]) { + process.env[TODO_ENV] = v; + expect(resolveTodoProviderEnabled({})).toBe(false); + } + }); + + test("other env values keep the mirror on", () => { + for (const v of ["auto", "1", "true", "enabled"]) { + process.env[TODO_ENV] = v; + expect(resolveTodoProviderEnabled({ todoProvider: "off" })).toBe(true); + } + }); +}); + const ENV = "PLANNOTATOR_CURSOR_SANDBOX"; const originalEnv = process.env[ENV]; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 0d21a3484..a7bb2ba91 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -152,6 +152,19 @@ export interface PlannotatorConfig { * PLANNOTATOR_CURSOR_SANDBOX env var, which takes precedence. */ cursorSandbox?: boolean; + /** + * Mirror the approved plan checklist into an editable todo provider during + * execution (issue #484). "auto" (default) syncs whenever a provider is + * detected — currently pi-todos. Detection checks the configured todo + * directory; PI_TODO_PATH only redirects which directory is checked. + * + * The mirror is additive: the progress widget is left alone. pi-todos has no + * live surface of its own (its list renders on demand in `/todos`), so the + * widget stays the at-a-glance tracker while the provider contributes + * editable, session-durable todos. Sync is one-way; provider-side edits are + * never read back. Failures are non-fatal. + */ + todoProvider?: "auto" | "off"; } const CONFIG_DIR = getPlannotatorDataDir(); @@ -364,3 +377,25 @@ export function resolveCursorSandbox(config: PlannotatorConfig): boolean { } return coerceConfigBoolean(config.cursorSandbox, true); } + +/** + * Resolve whether the approved plan checklist is mirrored into an editable todo + * provider during execution. + * + * Priority (highest wins): + * PLANNOTATOR_TODO_PROVIDER env var → config.todoProvider → default auto + * + * Env values `off` / `0` / `false` / `disabled` turn the mirror off, matching + * the vocabulary the other flags accept; anything else — including `auto` — + * keeps it on. Enabled only means "sync when a provider is detected": with no + * provider present, the progress widget is the whole experience either way. + */ +export function resolveTodoProviderEnabled(config: PlannotatorConfig): boolean { + const envVal = process.env.PLANNOTATOR_TODO_PROVIDER; + if (envVal !== undefined) { + const v = envVal.toLowerCase(); + return v !== "off" && v !== "0" && v !== "false" && v !== "disabled"; + } + if (config.todoProvider !== undefined) return config.todoProvider !== "off"; + return true; +}