diff --git a/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md b/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md new file mode 100644 index 0000000..86554be --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md @@ -0,0 +1,148 @@ +# M5 — Tasks Port Design + +**Goal:** Add the `tasks.Provider` port — answering "what should I be doing?" — +with an Amazing Marvin adapter that shells out to `am --json`. Today's tasks +surface on the planning screen; clicking one seeds the intent field, which flows +into the existing AI coach. Read-only, no writeback, graceful degradation. + +**Status:** Design approved 2026-05-31. Implements the deferred `tasks` port +named in `2026-05-31-go-focus-os-design.md`. + +--- + +## 1. Direction + +The Tasks port is the third real port, after Activity (`evidence`) and Advisor +(`ai`). It follows the pattern M1 established: a small leaf-package interface, a +single CLI adapter, and a fake for tests. It returns primitives only, so it +imports nothing from `domain` or `session`. + +Its one job is to answer "what should I be doing?" with the open tasks due today +or earlier. That answer surfaces where a work intention is born — the planning +screen — as a list of clickable task titles. Clicking a title drops it into the +intent field; from there the existing coach pipeline sharpens it into a +commitment, unchanged. The task is a **seed**, not a binding link: the session +is never tied to a task ID, and nothing is written back to Marvin. + +## 2. The Port + +New package `internal/tasks`, a leaf package like `ai`: + +```go +// Task is one to-do item. Primitives only, so tasks stays a leaf package. +type Task struct { + ID string + Title string + Day string // "YYYY-MM-DD", or "" if unscheduled +} + +// Provider answers "what should I be doing?" — the open tasks due today or +// earlier. +type Provider interface { + Today(ctx context.Context) ([]Task, error) +} +``` + +Files under `internal/tasks/`: + +- `tasks.go` — the `Provider` interface and the `Task` value type. +- `marvin.go` — the Amazing Marvin adapter and the JSON parse function. +- `tasks_test.go` / `marvin_test.go` — parse tests and adapter tests with a + fake command runner. + +## 3. The Marvin Adapter + +The adapter shells out exactly as `ai.claudeBackend` does: `exec.CommandContext` +with stdout captured into a buffer and failures wrapped with stderr context +(the same shape as `ai.cmdError`). It runs `am --json` (no subcommand, which +lists open tasks scheduled for today or earlier), parses the JSON array, and +maps each element to a `Task`. + +`am --json` emits an array of objects of this shape (from ampy's +`_serialize_task`): + +```json +[{"id": "...", "title": "...", "parentId": "...", "day": "YYYY-MM-DD", "done": false}] +``` + +Only `id`, `title`, and `day` are carried into `Task`; `parentId` is ignored +(no hierarchy in M5). Any element with `done: true` is dropped defensively, even +though the default listing already returns only open tasks. + +Parsing is a pure function `parse([]byte) ([]Task, error)` so it can be tested +directly against fixture strings. The shell-out wrapper holds the resolved +`cmd` and `args` and a runner func, so tests can inject a fake runner instead of +executing a real process. + +**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND`. The environment variable +`ANTIDRIFT_MARVIN_CMD` overrides the command; it is space-split so a value like +`uv run am` or an absolute path works. Unset or empty defaults to `am`. If `am` +cannot be found or fails at call time, `Today` returns an error and the +controller degrades to "no tasks panel" — manual planning still works. This is +the same degradation contract as the AI backend: misconfiguration never fails +startup. + +## 4. Controller Wiring + +The wiring mirrors the planning coach, which already fetches asynchronously and +guards against stale results. + +- `SetTasks(p tasks.Provider)` injects the provider, like `SetCoach`. A nil + provider turns the feature off. +- New `Controller` fields: `tasks tasks.Provider`, `tasksStatus string` + (`idle` / `pending` / `ready` / `error`), `tasksList []tasks.Task`, and + `tasksGen int` (the generation counter). +- `EnterPlanning()` resets the tasks state and, when a provider is set, starts + an **asynchronous** `Today()` fetch in a goroutine — the same structure as + `RequestCoach`: bump `tasksGen`, set `pending`, `notify()`, then on completion + re-acquire the lock and discard the result if the generation is stale or the + runtime has left planning. Tasks are **never** fetched on the synchronous + `State()` path, which runs on every SSE broadcast. +- `State()` projects a `*TasksView{Status string, Tasks []TaskView}` **only + while planning**, alongside the existing `CoachView`. `TaskView` carries the + JSON-tagged `id`, `title`, and `day`. + +No new runtime states, no new transitions, no change to the state machine. + +## 5. Web / UI + +No new endpoints. Tasks ride in the existing SSE state payload during planning. + +The planning render in `app.js` gains a small "Today" band that lists task +titles as clickable chips. Clicking a chip sets the value of `#intent` +client-side; the user then reviews it and presses Sharpen, driving the existing +`/coach` flow. A `pending` status shows a quiet "loading tasks…" line; `error` +or an empty list renders nothing. The seed click is pure client wiring — it adds +no POST route and no new server behavior. + +`main.go` gains a Marvin-adapter block parallel to the existing `ai` block: read +`ANTIDRIFT_MARVIN_CMD`, construct the adapter, call `ctrl.SetTasks(...)`, and +log one line. A construction failure logs "tasks disabled" and proceeds, never +fails startup. + +## 6. Testing + +- **`tasks` package:** table-driven `parse` tests — a valid array, an empty + array, malformed JSON, and `done`-filtering. An adapter test that injects a + fake runner returning canned stdout (and one returning an error) to confirm + the command path and error wrapping, without spawning a process. +- **`session` package:** with a fake `Provider`, assert the `tasksStatus` + transitions (`pending` → `ready`, and `pending` → `error` on failure) and that + `State().Tasks` reflects the fetched list while planning. A nil provider + yields no `TasksView`. Leaving planning before the fetch returns discards the + stale result (generation guard). +- **`web` package:** the existing `web_test.go` stays green (it is + markup-agnostic). Add one assertion that planning-state JSON carries the tasks + when a provider is set. +- stdlib `testing` only (no testify); `go test -race ./...` stays clean; `tasks` + stays a leaf package (imports nothing from `domain` / `session` / `evidence`). + +## 7. Out of Scope + +- **Writeback** — marking a task done when a session completes. Deferred per the + master design ("outcome writeback … beyond the M5 tasks port"). +- Projects, categories, and task hierarchy (`parentId` is dropped). +- Binding a session to a task ID. The seed is fire-and-forget text. +- Due times, labels, estimates, and other Marvin fields. +- A manual "refresh tasks" control — the fetch on entering planning is enough + for M5.