Add M5 tasks-port design spec

Design for the tasks.Provider port: an Amazing Marvin adapter shelling
out to `am --json`, today's tasks seeding the planning intent field.
Read-only, no writeback, graceful degradation — mirrors the ai port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:50:15 -04:00
parent 6b2a96620c
commit 267eda59e6
@@ -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.