Files
antidrift/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md
T
felixm 267eda59e6 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>
2026-05-31 21:50:15 -04:00

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 — 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):

[{"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 (pendingready, and pendingerror 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.