267eda59e6
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>
149 lines
6.7 KiB
Markdown
149 lines
6.7 KiB
Markdown
# 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.
|