Skip to content

feat(pi): mirror plan checklist to pi-todos - #1139

Merged
backnotprop merged 2 commits into
backnotprop:mainfrom
jms830:feat/todo-provider-sync
Jul 27, 2026
Merged

feat(pi): mirror plan checklist to pi-todos#1139
backnotprop merged 2 commits into
backnotprop:mainfrom
jms830:feat/todo-provider-sync

Conversation

@jms830

@jms830 jms830 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Implements the one-way sync from #484: feature-detect a todo provider on plan approval, push the checklist, reflect [DONE:n] markers onto todo status.

One deliberate departure from the shape sketched in the thread: the mirror is additive rather than a replacement — the existing progress widget stays on unconditionally instead of being swapped out for provider todos. pi-todos has no live surface to swap to, so replacing the widget would be a downgrade. Reasoning and evidence below; it's a one-line change if you'd rather have the original behaviour.

What this does

On plan approval the Pi extension looks for an editable todo provider. If one is present, the approved checklist is mirrored into it, and each [DONE:n] marker reflects onto todo status as execution proceeds. Sync is strictly one-way, so a user reordering or rewording todos can never desync plan execution.

Gated by PLANNOTATOR_TODO_PROVIDER / config.todoProvider (auto default, off to disable), and inert when no provider is detected.

Two findings that shaped the implementation

1. The mirror is additive — the progress widget is untouched.

The thread framed this as todos instead of the setWidget tracker. Tracing pi-todos, that turns out to be a downgrade: it registers only session_start, a todo tool, and the /todos command. No fs watcher, no interval, no widget/status/footer surface. Its list renders on demand when the user opens /todos, so external writes are invisible until then. Suppressing the live tracker would trade a visible progress display for files behind a keystroke.

So the widget stays as-is and the provider adds editable, session-durable todos alongside it. This also means a false-positive detection costs nothing.

2. oh-my-pi's native panel isn't reachable from an extension today.

Worth flagging since the thread mentioned it as a target. On omp 17.1.5 I traced two independent blocks:

  • Panel repaint is gated on tool name — modes/controllers/event-controller.ts only calls setTodos(details.phases) when event.toolName === "todo", and session/agent-session.ts carries the same guard. A third-party tool returning details.phases repaints nothing.
  • State-seeding is out of reach too. ToolSession does expose getTodoPhases?() / setTodoPhases?(), but extension-registered tools receive ExtensionContext, whose sessionManager is a read-only Pick<> without those accessors.

setTodos with phases is genuinely current — it's just scoped to omp's own built-in todo tool. So this PR ships pi-todos behind a TodoProvider interface, and an omp provider drops in with no caller changes the moment upstream opens that path (one allowlist entry, or a details.phases-driven repaint, would do it). Reasoning is recorded in todo-providers/index.ts.

pi-todos integration surface

pi-todos exposes no API to other extensions, so its on-disk format is the contract. Verified against mitsuhiko/agent-stuff extensions/todos.ts @ a3f8ab11:

  • $PI_TODO_PATH, else <cwd>/.pi/todos
  • <8 hex>.md — JSON front-matter, blank line, markdown body
  • <8 hex>.lock taken with O_EXCL
  • status free-form; closed / done count as closed
  • a closed todo drops assigned_to_session

Behaviour that follows from that:

  • Idempotent and serialized. Todos are reconciled by tag (plannotator, plannotator:plan:<path>, plannotator:step:<n>), so repeated syncs converge instead of duplicating. Calls on one provider instance are queued because pi-todos has no atomic upsert; overlapping read/create passes cannot mint duplicate steps.
  • Lock-respecting. A todo another session holds a lock on is skipped, not stolen — pi-todos prompts before stealing a stale lock and a background sync shouldn't. It's picked up on the next sync.
  • Ordering. sortTodos orders by created_at with no index field, and Date.now() is ms-resolution, so a tight creation loop would collide and fall back to readdir order over random hex filenames. created_at is stamped base + i so todo order matches plan order. Titles carry the step number, so order stays legible after completed items sort to the bottom.
  • Edited plans. Steps dropped from a re-approved plan — including a plan whose checklist becomes empty — are closed rather than left as permanently-open work, and rather than unlinked, so notes survive and pi-todos' own GC reaps them.
  • Non-fatal. Any sync failure notifies once for that plan, latches the mirror off, and leaves execution alone. Returning to idle clears the latch so a later plan can re-detect after a transient failure or late provider install.

Tests

apps/pi-extension/todo-provider-sync.test.ts (6 tests) drives the real extension through a Pi host stub against a real .pi/todos directory: approval writes the checklist with the Pi session id, [DONE:1] closes the matching todo, widget and mirror coexist, no-provider sessions remain widget-only, a provider appearing later is picked up by plan two, and a deterministic write failure notifies once while widget progress continues.

apps/pi-extension/todo-providers/pi-todos.test.ts (18 tests) round-trips written files through a re-derived reader — format drift in either direction fails loudly. Covers actual filename shape, ordering under upstream's sort, repeated and overlapping syncs, DONE reflection and assignment clearing, dropped/empty-plan closing, plan isolation, lock skipping/release, and detection including PI_TODO_PATH.

The oracle is an independent implementation written from the documented format, not a copy — agent-stuff is Apache-2.0 and this repo is MIT OR Apache-2.0, and a different parsing strategy makes it a real cross-check rather than the same code twice.

resolveTodoProviderEnabled gets its own block in packages/shared/config.test.ts (4 tests). It's deliberately not in the config.json boolean coercion table: that table asserts boolean coercion, and like resolveSharingEnabled this key is a string enum.

Verification

  • bun run typecheck — clean
  • targeted provider/config/package tests — 65 pass, 0 fail
  • affected-scope suite — 784 pass, 1 skip, 6 fail; zero new failure names vs. recorded untouched-main baseline
  • DOM_TESTS=1 bun test packages/ui — 570 pass, 0 fail
  • bun pm pack --dry-run — all four todo-providers/ files present in the tarball
  • three negative controls verified the guards are meaningful: removing the package allowlist entry, sync queue, or idle reset makes its specific regression test fail

This adds 28 tests. Two caveats, stated plainly. The full suite is not green on this ARM64 machine: node-pty/pty, process-tree timeout, and port-sensitive tests fluctuate under load, so I compared the affected scope against a recorded untouched-main baseline; two apparent full-suite deltas passed isolated reruns. And pi-todos itself isn't installed here: the extension is exercised end-to-end against a real .pi/todos directory, and the file format is pinned by round-trip against its documented contract, but I haven't run a live pi + plannotator + pi-todos session. Happy to do whatever additional verification you'd want.

Not in scope

Bidirectional editing — user edits in /todos flowing back into plan execution — as flagged in the thread. That needs conflict rules for steps added, removed, or reordered on the provider side, and is a much larger change than this.

Implements the one-way half of #484. The issue is currently closed; the maintainer noted it would be reopened alongside a PR.

jms830 added 2 commits July 27, 2026 11:41
Closes the one-way half of backnotprop#484: on plan approval, feature-detect an
editable todo provider and mirror the approved checklist into it, then
reflect [DONE:n] markers onto todo status as execution proceeds.

pi-todos is the first provider. It exposes no API to other extensions,
so the integration surface is its on-disk format (.pi/todos/<id>.md with
JSON front-matter, <id>.lock taken with O_EXCL). The provider writes
through that contract, reconciles by tag so repeated syncs are
idempotent, closes steps dropped from an edited plan, and skips any todo
another session holds a lock on. Provider state resets on return to
idle, so a second plan re-detects rather than inheriting the first
plan's decision.

The mirror is additive: the existing progress widget is untouched.
pi-todos has no live surface of its own -- its list renders on demand in
/todos -- so suppressing the widget would trade a visible tracker for
files behind a keystroke. Sync is one-way, so provider-side edits can
never desync plan execution.

oh-my-pi's native todo panel is the obvious second provider and slots in
behind the same interface, but is not implementable from an extension
today: the panel repaint is gated to the built-in `todo` tool, and the
session handed to an extension-registered tool is a read-only projection
without the todo accessors. Details in todo-providers/index.ts.

Gated by PLANNOTATOR_TODO_PROVIDER / config.todoProvider, and inert when
no provider is detected.
@backnotprop
backnotprop merged commit a54b46b into backnotprop:main Jul 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants