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>
6.7 KiB
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:
// 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— theProviderinterface and theTaskvalue 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):
[{"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, likeSetCoach. A nil provider turns the feature off.- New
Controllerfields:tasks tasks.Provider,tasksStatus string(idle/pending/ready/error),tasksList []tasks.Task, andtasksGen int(the generation counter). EnterPlanning()resets the tasks state and, when a provider is set, starts an asynchronousToday()fetch in a goroutine — the same structure asRequestCoach: bumptasksGen, setpending,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 synchronousState()path, which runs on every SSE broadcast.State()projects a*TasksView{Status string, Tasks []TaskView}only while planning, alongside the existingCoachView.TaskViewcarries the JSON-taggedid,title, andday.
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
taskspackage: table-drivenparsetests — a valid array, an empty array, malformed JSON, anddone-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.sessionpackage: with a fakeProvider, assert thetasksStatustransitions (pending→ready, andpending→erroron failure) and thatState().Tasksreflects the fetched list while planning. A nil provider yields noTasksView. Leaving planning before the fetch returns discards the stale result (generation guard).webpackage: the existingweb_test.gostays green (it is markup-agnostic). Add one assertion that planning-state JSON carries the tasks when a provider is set.- stdlib
testingonly (no testify);go test -race ./...stays clean;tasksstays a leaf package (imports nothing fromdomain/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 (
parentIdis 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.