diff --git a/docs/superpowers/plans/2026-05-31-m5-tasks-port.md b/docs/superpowers/plans/2026-05-31-m5-tasks-port.md new file mode 100644 index 0000000..10a2f14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m5-tasks-port.md @@ -0,0 +1,794 @@ +# M5 — Tasks Port Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `tasks.Provider` port with an Amazing Marvin adapter (`am --json`) that lists today's open tasks; surface them on the planning screen as clickable chips that seed the intent field. + +**Architecture:** A new leaf package `internal/tasks` (interface + primitives only, like `ai`) with a CLI adapter that shells out to ampy's `am --json`. The `session.Controller` fetches the list asynchronously on entering planning — mirroring the existing planning coach (generation counter, status enum, graceful degradation) — and projects it into the broadcast `State`. The browser renders the list during planning; clicking a task fills `#intent`, which flows into the unchanged coach pipeline. Read-only; no writeback; no new endpoints. + +**Tech Stack:** Go 1.26, stdlib `os/exec` + `encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`. + +**Reference:** Design spec at `docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md`. The patterns being mirrored: the `ai` port (`internal/ai/coach.go`, `internal/ai/backend.go`) and the planning coach in `internal/session/session.go` (`RequestCoach`, `resetCoachLocked`, `CoachView`). + +--- + +## File Structure + +- **Create `internal/tasks/tasks.go`** — the `Provider` interface and the `Task` value type. Leaf package; imports only `context`. +- **Create `internal/tasks/marvin.go`** — the `Marvin` adapter (shells out to `am --json`), an injectable `runner`, and the pure `parse` function. +- **Create `internal/tasks/marvin_test.go`** — `parse` table tests, command-splitting test, and adapter tests with a fake runner. +- **Modify `internal/session/session.go`** — add tasks fields, `TaskView`/`TasksView`, `State.Tasks`, `SetTasks`, `startTasksFetchLocked`, the planning-only projection, and the `EnterPlanning` hook. +- **Modify `internal/session/session_test.go`** — `fakeProvider`, `waitTasksStatus`, and four controller tests. +- **Modify `cmd/antidriftd/main.go`** — construct the Marvin adapter and call `ctrl.SetTasks`. +- **Modify `internal/web/web_test.go`** — `stubProvider` and a planning-payload-carries-tasks test. +- **Modify `internal/web/static/app.js`** — `updatePlanningTasks`, a tasks band in the planning render, and calls from both render paths. +- **Modify `internal/web/static/app.css`** — `.tasklist` / `.task-chip` styling. + +--- + +## Task 1: `tasks` package (port + Marvin adapter) + +**Files:** +- Create: `internal/tasks/tasks.go` +- Create: `internal/tasks/marvin.go` +- Test: `internal/tasks/marvin_test.go` + +- [ ] **Step 1: Write the port interface and value type** + +Create `internal/tasks/tasks.go`: + +```go +// Package tasks is the Tasks port: it answers "what should I be doing?" by +// listing the open to-do items due today or earlier. It imports nothing from +// the rest of the app, so it stays a leaf package. +package tasks + +import "context" + +// 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) +} +``` + +- [ ] **Step 2: Write the failing adapter tests** + +Create `internal/tasks/marvin_test.go`: + +```go +package tasks + +import ( + "context" + "errors" + "testing" +) + +func TestParse(t *testing.T) { + cases := []struct { + name string + in string + want []Task + wantErr bool + }{ + { + name: "valid array", + in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`, + want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}, + }, + {name: "empty array", in: `[]`, want: []Task{}}, + {name: "json null", in: `null`, want: []Task{}}, + { + name: "drops done and empty-title", + in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`, + want: []Task{{ID: "a", Title: "keep", Day: ""}}, + }, + {name: "malformed", in: `not json`, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parse([]byte(tc.in)) + if tc.wantErr { + if err == nil { + t.Fatal("want error, got nil") + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestNewMarvinCommandSplitting(t *testing.T) { + cases := []struct { + in string + wantCmd string + wantArgs []string + }{ + {"", "am", []string{"--json"}}, + {"am", "am", []string{"--json"}}, + {"uv run am", "uv", []string{"run", "am", "--json"}}, + } + for _, tc := range cases { + m := NewMarvin(tc.in) + if m.cmd != tc.wantCmd { + t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd) + } + if len(m.args) != len(tc.wantArgs) { + t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs) + } + for i := range m.args { + if m.args[i] != tc.wantArgs[i] { + t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i]) + } + } + } +} + +func TestTodayUsesRunnerOutput(t *testing.T) { + m := NewMarvin("am") + var gotName string + var gotArgs []string + m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + gotName, gotArgs = name, args + return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil + } + got, err := m.Today(context.Background()) + if err != nil { + t.Fatalf("Today: %v", err) + } + if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" { + t.Fatalf("runner called with %q %v", gotName, gotArgs) + } + if len(got) != 1 || got[0].Title != "Do thing" { + t.Fatalf("Today result = %+v", got) + } +} + +func TestTodayPropagatesError(t *testing.T) { + m := NewMarvin("am") + m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + return nil, errors.New("am not found") + } + if _, err := m.Today(context.Background()); err == nil { + t.Fatal("want error from failing runner") + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/tasks/ -v` +Expected: FAIL — `undefined: NewMarvin`, `undefined: parse` (build error). + +- [ ] **Step 4: Write the adapter implementation** + +Create `internal/tasks/marvin.go`: + +```go +package tasks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// runner executes a command and returns its stdout. Production shells out; +// tests inject a fake to avoid spawning a process. +type runner func(ctx context.Context, name string, args ...string) ([]byte, error) + +func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + if s := strings.TrimSpace(errb.String()); s != "" { + return nil, fmt.Errorf("%s: %w: %s", name, err, s) + } + return nil, fmt.Errorf("%s: %w", name, err) + } + return out.Bytes(), nil +} + +// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI +// (`am --json`, no subcommand) and parses the open tasks due today or earlier. +type Marvin struct { + cmd string + args []string + run runner +} + +// NewMarvin builds the adapter. command is split on spaces so both "am" and +// "uv run am" work; empty defaults to "am". The "--json" flag is always +// appended, and no subcommand is passed (ampy lists today's tasks by default). +func NewMarvin(command string) *Marvin { + fields := strings.Fields(command) + if len(fields) == 0 { + fields = []string{"am"} + } + args := append([]string{}, fields[1:]...) + args = append(args, "--json") + return &Marvin{cmd: fields[0], args: args, run: execRunner} +} + +// Today returns the open tasks due today or earlier, or an error if the CLI +// fails. Callers degrade gracefully on error. +func (m *Marvin) Today(ctx context.Context) ([]Task, error) { + out, err := m.run(ctx, m.cmd, m.args...) + if err != nil { + return nil, err + } + return parse(out) +} + +type rawTask struct { + ID string `json:"id"` + Title string `json:"title"` + Day string `json:"day"` + Done bool `json:"done"` +} + +// parse maps `am --json` output (a JSON array of task objects) to []Task. It +// drops tasks that are done or have an empty title. A JSON null or empty array +// yields an empty slice, not an error. +func parse(data []byte) ([]Task, error) { + var raw []rawTask + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("tasks: parse: %w", err) + } + out := make([]Task, 0, len(raw)) + for _, r := range raw { + title := strings.TrimSpace(r.Title) + if r.Done || title == "" { + continue + } + out = append(out, Task{ID: r.ID, Title: title, Day: r.Day}) + } + return out, nil +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/tasks/ -v` +Expected: PASS — `TestParse`, `TestNewMarvinCommandSplitting`, `TestTodayUsesRunnerOutput`, `TestTodayPropagatesError`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/tasks/tasks.go internal/tasks/marvin.go internal/tasks/marvin_test.go +git commit -m "$(cat <<'EOF' +Add tasks port and Amazing Marvin adapter + +The tasks.Provider port answers "what should I be doing?". The Marvin +adapter shells out to ampy's `am --json` and parses today's open tasks, +dropping done/empty-title entries. Leaf package mirroring ai. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 2: Controller wiring (async fetch + projection) + +**Files:** +- Modify: `internal/session/session.go` +- Test: `internal/session/session_test.go` + +- [ ] **Step 1: Write the failing controller tests** + +Append to `internal/session/session_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add: + +```go +type fakeProvider struct { + list []tasks.Task + err error + gate chan struct{} // if non-nil, Today blocks until it receives +} + +func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) { + if f.gate != nil { + <-f.gate + } + return f.list, f.err +} + +// waitTasksStatus polls until the tasks view reaches want, or fails after 2s. +func waitTasksStatus(t *testing.T, c *Controller, want string) State { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + st := c.State() + if st.Tasks != nil && st.Tasks.Status == want { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks) + return State{} +} + +func TestEnterPlanningFetchesTasks(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitTasksStatus(t, c, "ready") + if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" { + t.Fatalf("tasks list wrong: %+v", st.Tasks) + } +} + +func TestTasksFetchError(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{err: errors.New("am down")}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitTasksStatus(t, c, "error") + if len(st.Tasks.Tasks) != 0 { + t.Fatalf("error state should carry no tasks: %+v", st.Tasks) + } +} + +func TestNoProviderNoTasksView(t *testing.T) { + c, _ := newTestController(t) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if c.State().Tasks != nil { + t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks) + } +} + +func TestTasksViewAbsentOutsidePlanning(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}}) + if c.State().Tasks != nil { + t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v` +Expected: FAIL — `c.SetTasks undefined` and `st.Tasks undefined` (build error). + +- [ ] **Step 3: Add the import and constants** + +In `internal/session/session.go`, add `"antidrift/internal/tasks"` to the import block (it already imports `"antidrift/internal/ai"`). + +Immediately after the existing coach constants block (the `const ( coachIdle = "idle" … )` group near the top of the file), add: + +```go +const tasksTimeout = 30 * time.Second + +const ( + tasksIdle = "idle" + tasksPending = "pending" + tasksReady = "ready" + tasksError = "error" +) +``` + +- [ ] **Step 4: Add the controller fields** + +In the `Controller` struct, immediately after the coach fields (`coachGen int`), add: + +```go + tasksProvider tasks.Provider + tasksStatus string + tasksList []tasks.Task + tasksGen int +``` + +(Field is named `tasksProvider`, not `tasks`, so it does not shadow the `tasks` package.) + +- [ ] **Step 5: Add the view types** + +Immediately after the `CoachView` struct definition, add: + +```go +// TaskView is one to-do item in the planning tasks list. +type TaskView struct { + ID string `json:"id"` + Title string `json:"title"` + Day string `json:"day,omitempty"` +} + +// TasksView projects the ephemeral planning tasks state (Marvin's today list). +type TasksView struct { + Status string `json:"status"` + Tasks []TaskView `json:"tasks,omitempty"` +} +``` + +In the `State` struct, add a field immediately after `Coach *CoachView … `: + +```go + Tasks *TasksView `json:"tasks,omitempty"` +``` + +- [ ] **Step 6: Add the planning-only projection** + +In `stateLocked`, inside the existing `if c.runtimeState == domain.RuntimePlanning {` block, immediately after `st.Coach = cv`, add: + +```go + if c.tasksProvider != nil { + tstatus := c.tasksStatus + if tstatus == "" { + tstatus = tasksIdle + } + tv := &TasksView{Status: tstatus} + for _, t := range c.tasksList { + tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) + } + st.Tasks = tv + } +``` + +- [ ] **Step 7: Add SetTasks and the async fetch** + +Immediately after `resetCoachLocked` (so the tasks helpers sit next to the coach helpers), add: + +```go +// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps +// the planning tasks list absent. +func (c *Controller) SetTasks(p tasks.Provider) { + c.mu.Lock() + c.tasksProvider = p + c.mu.Unlock() +} + +// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider +// is set. Mirrors RequestCoach: generation-guarded, discards stale or +// post-planning results, and notifies on completion. Caller holds mu. +func (c *Controller) startTasksFetchLocked() { + c.tasksList = nil + if c.tasksProvider == nil { + c.tasksStatus = tasksIdle + return + } + c.tasksGen++ + gen := c.tasksGen + c.tasksStatus = tasksPending + provider := c.tasksProvider + go func() { + ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout) + defer cancel() + list, err := provider.Today(ctx) + + c.mu.Lock() + if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + if err != nil { + c.tasksStatus = tasksError + c.tasksList = nil + } else { + c.tasksStatus = tasksReady + c.tasksList = list + } + c.mu.Unlock() + c.notify() + }() +} +``` + +(`context` is already imported in `session.go`.) + +- [ ] **Step 8: Hook the fetch into EnterPlanning** + +In `EnterPlanning`, add `c.startTasksFetchLocked()` immediately after the existing `c.resetCoachLocked()` line: + +```go + c.runtimeState = next + c.resetCoachLocked() + c.startTasksFetchLocked() + return c.persistLocked() +``` + +(Launching the goroutine while holding `mu` is safe: its first action is the `Today()` call, so it only blocks on the lock after `EnterPlanning` has returned and released it.) + +- [ ] **Step 9: Run the tests to verify they pass** + +Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v` +Expected: PASS — `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`. + +- [ ] **Step 10: Run the full session suite (no regressions, race-clean)** + +Run: `go test -race ./internal/session/` +Expected: PASS (existing coach/drift/nudge tests unaffected — `EnterPlanning` with no provider just sets the idle status). + +- [ ] **Step 11: Commit** + +```bash +git add internal/session/session.go internal/session/session_test.go +git commit -m "$(cat <<'EOF' +Fetch today's tasks asynchronously on entering planning + +The controller fetches the Marvin task list when entering planning, +mirroring the planning coach: generation-guarded goroutine, status enum +(idle/pending/ready/error), projected into State only while planning and +only when a provider is set. nil provider degrades to no tasks view. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 3: Daemon wiring + web payload test + +**Files:** +- Modify: `cmd/antidriftd/main.go` +- Test: `internal/web/web_test.go` + +- [ ] **Step 1: Write the failing web test** + +Append to `internal/web/web_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add: + +```go +type stubProvider struct { + list []tasks.Task +} + +func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) { + return s.list, nil +} + +func TestPlanningStatePayloadCarriesTasks(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) + r := s.Router() + if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + t.Fatalf("/planning code %d", w.Code) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" { + break + } + time.Sleep(5 * time.Millisecond) + } + js := s.stateJSON() + if !strings.Contains(js, `"tasks"`) { + t.Fatalf("planning payload missing tasks object: %s", js) + } + if !strings.Contains(js, `"title":"Write the spec"`) { + t.Fatalf("planning payload missing task title: %s", js) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v` +Expected: FAIL — `undefined: tasks` / `s.ctrl.SetTasks undefined` until the import resolves and the controller exposes the method (the method exists from Task 2, so the only failure here would be a missing import; if Task 2 is complete this compiles and the assertion drives correctness). + +- [ ] **Step 3: Wire the adapter into the daemon** + +In `cmd/antidriftd/main.go`, add `"antidrift/internal/tasks"` to the import block. Then, immediately after the AI-wiring block (after the `if backend, err := ai.NewBackend(...) { … } else { … }` block closes), add: + +```go + // Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is + // overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute + // path). A missing or failing CLI degrades to no tasks panel at fetch time — + // planning still works. + ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD"))) + log.Printf("tasks: marvin adapter (am)") +``` + +(`os` and `log` are already imported in `main.go`.) + +- [ ] **Step 4: Run the web test to verify it passes** + +Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v` +Expected: PASS. + +- [ ] **Step 5: Verify the daemon builds** + +Run: `go build ./cmd/antidriftd/` +Expected: builds with no output. + +- [ ] **Step 6: Commit** + +```bash +git add cmd/antidriftd/main.go internal/web/web_test.go +git commit -m "$(cat <<'EOF' +Wire Marvin tasks adapter into the daemon + +main constructs the Marvin adapter (command overridable via +ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the +planning state payload carries today's tasks. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 4: Planning UI — today's tasks as seed chips + +**Files:** +- Modify: `internal/web/static/app.js` +- Modify: `internal/web/static/app.css` + +This task is presentational; the data path is already covered by the Task 3 web test. There is no JS test harness in this project (consistent with M4), so verify by build/test plus the manual checklist below. + +- [ ] **Step 1: Add the `updatePlanningTasks` function** + +In `internal/web/static/app.js`, immediately after the `updatePlanningCoach` function definition (before `function render(state)`), add: + +```js +// updatePlanningTasks renders today's Marvin tasks as clickable seed chips. +// Clicking a chip fills the intent field; the coach pipeline is unchanged. +// idle/error/empty render nothing; pending shows a quiet loading line. +function updatePlanningTasks(tasks) { + const el = document.getElementById('tasksBand'); + if (!el) return; + if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; } + if (tasks.status === 'pending') { el.innerHTML = `
loading tasks…
`; return; } + const list = tasks.tasks || []; + if (!list.length) { el.innerHTML = ''; return; } + const chips = list.map((t, i) => + ``).join(''); + el.innerHTML = `
${chips}
`; + el.querySelectorAll('.task-chip').forEach(btn => { + btn.onclick = () => { + const intent = document.getElementById('intent'); + if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); } + }; + }); +} +``` + +(Task titles are interpolated into `innerHTML` exactly as the existing coach/evidence rendering does. The titles originate from the user's own Marvin database; this carries the same pre-existing self-XSS posture as the rest of the UI and introduces no new untrusted source — out of scope to change here.) + +- [ ] **Step 2: Add the tasks band to the planning render** + +In the planning branch of `render` (`} else if (rs === 'planning') {`), add a tasks band placeholder between the intent band and the "Next action" band. Change: + +```js +
+ + + +
+
+
+ +``` + +to: + +```js +
+ + + +
+
+
+
+ +``` + +- [ ] **Step 3: Call `updatePlanningTasks` from both render paths** + +In the planning partial-update early-return at the top of `render`, change: + +```js + if (rs === 'planning' && renderedState === 'planning') { + updatePlanningCoach(state.coach); + return; + } +``` + +to: + +```js + if (rs === 'planning' && renderedState === 'planning') { + updatePlanningCoach(state.coach); + updatePlanningTasks(state.tasks); + return; + } +``` + +And at the end of the full planning render branch, change the trailing: + +```js + updatePlanningCoach(state.coach); + + } else if (rs === 'active') { +``` + +to: + +```js + updatePlanningCoach(state.coach); + updatePlanningTasks(state.tasks); + + } else if (rs === 'active') { +``` + +- [ ] **Step 4: Add the chip styling** + +Append to `internal/web/static/app.css`: + +```css +/* Planning: today's tasks as clickable seed chips */ +.tasklist { display: flex; flex-wrap: wrap; gap: 8px; } +.task-chip { + padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; + background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; + cursor: pointer; text-align: left; +} +.task-chip:hover { border-color: var(--accent); color: var(--accent); } +``` + +- [ ] **Step 5: Verify assets still serve and the full suite is race-clean** + +Run: `go vet ./... && go test -race ./...` +Expected: PASS across all packages (the embedded-asset and planning-payload tests cover the wiring; JS/CSS are presentational). + +- [ ] **Step 6: Manual visual check (by eye)** + +Build and run the daemon (`go run ./cmd/antidriftd/`) with `am` available, click **Start planning**, and confirm: a "Today" band shows task chips; clicking a chip fills the intent field; **Sharpen** then drives the coach as before. With `am` absent, the band stays empty and planning works normally. + +- [ ] **Step 7: Commit** + +```bash +git add internal/web/static/app.js internal/web/static/app.css +git commit -m "$(cat <<'EOF' +Show today's tasks as seed chips on the planning screen + +The planning view renders Marvin's today list as clickable chips; +clicking one fills the intent field, feeding the existing coach flow. +idle/error/empty render nothing, pending shows a quiet loading line. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Final Verification + +After all tasks: + +- [ ] `go vet ./... && go test -race ./...` is clean. +- [ ] `internal/tasks` imports only `context`, `encoding/json`, and stdlib `os/exec`/`bytes`/`fmt`/`strings` — nothing from `domain`, `session`, `evidence`, or `web` (leaf package preserved). +- [ ] No new POST routes; the seed click is client-only. +- [ ] No writeback to Marvin (read-only, per spec §7).