Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9452e54867 | |||
| 8b5121c69d | |||
| e832a2da85 | |||
| c4eb870808 | |||
| 8b7bec1173 | |||
| 569264be11 | |||
| 267eda59e6 |
@@ -15,6 +15,7 @@ import (
|
|||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
"antidrift/internal/statusfile"
|
"antidrift/internal/statusfile"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
|
"antidrift/internal/tasks"
|
||||||
"antidrift/internal/web"
|
"antidrift/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +47,13 @@ func main() {
|
|||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
|
||||||
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
||||||
// A misconfigured home dir degrades to no status file, not a failed start.
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
|||||||
@@ -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 <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:
|
||||||
|
|
||||||
|
```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 <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:
|
||||||
|
|
||||||
|
```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 <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:
|
||||||
|
|
||||||
|
```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 = `<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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```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 <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).
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 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.
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/statemachine"
|
"antidrift/internal/statemachine"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
|
"antidrift/internal/tasks"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,15 @@ const (
|
|||||||
coachError = "error"
|
coachError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tasksTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
tasksIdle = "idle"
|
||||||
|
tasksPending = "pending"
|
||||||
|
tasksReady = "ready"
|
||||||
|
tasksError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
@@ -92,6 +102,11 @@ type Controller struct {
|
|||||||
coachErr string
|
coachErr string
|
||||||
coachGen int
|
coachGen int
|
||||||
|
|
||||||
|
tasksProvider tasks.Provider
|
||||||
|
tasksStatus string
|
||||||
|
tasksList []tasks.Task
|
||||||
|
tasksGen int
|
||||||
|
|
||||||
allowedClasses []string // durable: the active session's allowed window classes
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
judge ai.DriftJudge
|
judge ai.DriftJudge
|
||||||
driftStatus string
|
driftStatus string
|
||||||
@@ -139,6 +154,19 @@ type CoachView struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowView / BucketView / EvidenceView are the evidence projection.
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
||||||
type WindowView struct {
|
type WindowView struct {
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
@@ -165,6 +193,7 @@ type State struct {
|
|||||||
Commitment *CommitmentView `json:"commitment"`
|
Commitment *CommitmentView `json:"commitment"`
|
||||||
Evidence *EvidenceView `json:"evidence"`
|
Evidence *EvidenceView `json:"evidence"`
|
||||||
Coach *CoachView `json:"coach,omitempty"`
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
|
Tasks *TasksView `json:"tasks,omitempty"`
|
||||||
Drift *DriftView `json:"drift,omitempty"`
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +308,17 @@ func (c *Controller) stateLocked() State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
st.Coach = cv
|
st.Coach = cv
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if c.runtimeState == domain.RuntimeActive {
|
if c.runtimeState == domain.RuntimeActive {
|
||||||
status := c.driftStatus
|
status := c.driftStatus
|
||||||
@@ -325,6 +365,7 @@ func (c *Controller) EnterPlanning() error {
|
|||||||
}
|
}
|
||||||
c.runtimeState = next
|
c.runtimeState = next
|
||||||
c.resetCoachLocked()
|
c.resetCoachLocked()
|
||||||
|
c.startTasksFetchLocked()
|
||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +386,49 @@ func (c *Controller) resetCoachLocked() {
|
|||||||
c.coachGen++
|
c.coachGen++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// AllowedClassesForTest exposes the session allowed classes for tests.
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
func (c *Controller) AllowedClassesForTest() []string {
|
func (c *Controller) AllowedClassesForTest() []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
|
"antidrift/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestController(t *testing.T) (*Controller, string) {
|
func newTestController(t *testing.T) (*Controller, string) {
|
||||||
@@ -772,3 +773,148 @@ func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
|||||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the
|
||||||
|
// "left planning" arm of the discard guard in startTasksFetchLocked.
|
||||||
|
//
|
||||||
|
// Sequence:
|
||||||
|
// 1. Enter planning with a gated provider (fetch in flight, blocked).
|
||||||
|
// 2. StartManualCommitment moves Planning -> Active, leaving planning.
|
||||||
|
// 3. End the session to return to Locked.
|
||||||
|
// 4. Install a second (ungated) provider and re-enter planning.
|
||||||
|
// 5. Wait for the fresh fetch to reach "ready" with the new provider's list.
|
||||||
|
// 6. Release the gate — the stale goroutine from step 1 now runs and must be
|
||||||
|
// discarded (runtimeState != Planning at commit time).
|
||||||
|
// 7. Assert the stale result did not clobber the fresh tasks list.
|
||||||
|
func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
|
||||||
|
// Step 1: gated provider whose result ("STALE") must never surface.
|
||||||
|
staleGate := make(chan struct{})
|
||||||
|
stale := &fakeProvider{
|
||||||
|
list: []tasks.Task{{ID: "s1", Title: "STALE"}},
|
||||||
|
gate: staleGate,
|
||||||
|
}
|
||||||
|
c.SetTasks(stale)
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
// Wait until the goroutine has started and is pending (blocked on gate).
|
||||||
|
waitTasksStatus(t, c, "pending")
|
||||||
|
|
||||||
|
// Step 2: leave planning via a commitment — the stale fetch is still blocked.
|
||||||
|
if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil); err != nil {
|
||||||
|
t.Fatalf("start commitment: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("expected Active after StartManualCommitment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: end the session to return to Locked.
|
||||||
|
if err := c.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: fresh provider whose result ("FRESH") is the expected state.
|
||||||
|
fresh := &fakeProvider{list: []tasks.Task{{ID: "f1", Title: "FRESH"}}}
|
||||||
|
c.SetTasks(fresh)
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("second enter planning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: wait for the fresh fetch to complete and be visible.
|
||||||
|
st := waitTasksStatus(t, c, "ready")
|
||||||
|
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" {
|
||||||
|
t.Fatalf("expected FRESH tasks after second planning entry, got %+v", st.Tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: release the stale goroutine — it must detect runtimeState != Planning
|
||||||
|
// (we are now in Planning again, but tasksGen has moved on) and discard.
|
||||||
|
close(staleGate)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Step 7: the fresh list must still be intact; STALE must not have landed.
|
||||||
|
st = c.State()
|
||||||
|
if st.Tasks == nil {
|
||||||
|
t.Fatalf("tasks view should still be present in planning")
|
||||||
|
}
|
||||||
|
if st.Tasks.Status != "ready" {
|
||||||
|
t.Fatalf("tasks status should still be ready, got %q", st.Tasks.Status)
|
||||||
|
}
|
||||||
|
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" {
|
||||||
|
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -107,3 +107,12 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
font-variant-numeric: tabular-nums; color: var(--ink-dim);
|
font-variant-numeric: tabular-nums; color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
|
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
|
|||||||
@@ -136,11 +136,33 @@ function updatePlanningCoach(coach) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
setStateAttr(state);
|
setStateAttr(state);
|
||||||
const rs = state.runtime_state;
|
const rs = state.runtime_state;
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
if (rs === 'planning' && renderedState === 'planning') {
|
||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
|
updatePlanningTasks(state.tasks);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rs === 'active' && renderedState === 'active') {
|
if (rs === 'active' && renderedState === 'active') {
|
||||||
@@ -166,6 +188,7 @@ function render(state) {
|
|||||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||||
<div id="coachStatus" class="meta"></div>
|
<div id="coachStatus" class="meta"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="band" id="tasksBand"></div>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||||
@@ -190,6 +213,7 @@ function render(state) {
|
|||||||
if (intent) post('/coach', { intent });
|
if (intent) post('/coach', { intent });
|
||||||
};
|
};
|
||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
|
updatePlanningTasks(state.tasks);
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
} else if (rs === 'active') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
|
"antidrift/internal/tasks"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -230,6 +231,37 @@ func TestServesStaticAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
|
|||||||
Reference in New Issue
Block a user