Files
antidrift/docs/superpowers/plans/2026-05-31-m5-tasks-port.md
T
2026-05-31 22:11:59 -04:00

25 KiB

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.goparse 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.gofakeProvider, waitTasksStatus, and four controller tests.
  • Modify cmd/antidriftd/main.go — construct the Marvin adapter and call ctrl.SetTasks.
  • Modify internal/web/web_test.gostubProvider and a planning-payload-carries-tasks test.
  • Modify internal/web/static/app.jsupdatePlanningTasks, 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:

// 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:

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:

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
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 <noreply@anthropic.com>
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:

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:

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:

	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:

// 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 … :

	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:

		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:

// 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:

	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
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 <noreply@anthropic.com>
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:

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:

	// 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
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 <noreply@anthropic.com>
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:

// 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 = `<div class="meta">loading tasks…</div>`; return; }
  const list = tasks.tasks || [];
  if (!list.length) { el.innerHTML = ''; return; }
  const chips = list.map((t, i) =>
    `<button type="button" class="task-chip" data-i="${i}">${t.title}</button>`).join('');
  el.innerHTML = `<label>Today</label><div class="tasklist">${chips}</div>`;
  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:

      <div class="band">
        <label>Rough intent</label>
        <input id="intent" placeholder="e.g. work on the quarterly report">
        <button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
        <div id="coachStatus" class="meta"></div>
      </div>
      <div class="band">
        <label>Next action</label><input id="na" placeholder="What exactly will you do?">

to:

      <div class="band">
        <label>Rough intent</label>
        <input id="intent" placeholder="e.g. work on the quarterly report">
        <button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
        <div id="coachStatus" class="meta"></div>
      </div>
      <div class="band" id="tasksBand"></div>
      <div class="band">
        <label>Next action</label><input id="na" placeholder="What exactly will you do?">
  • Step 3: Call updatePlanningTasks from both render paths

In the planning partial-update early-return at the top of render, change:

  if (rs === 'planning' && renderedState === 'planning') {
    updatePlanningCoach(state.coach);
    return;
  }

to:

  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:

    updatePlanningCoach(state.coach);

  } else if (rs === 'active') {

to:

    updatePlanningCoach(state.coach);
    updatePlanningTasks(state.tasks);

  } else if (rs === 'active') {
  • Step 4: Add the chip styling

Append to internal/web/static/app.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
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 <noreply@anthropic.com>
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).