# 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.