From ae5a0f123c7beff0a7e71292aeb37f756af8f85a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 13:47:31 -0400 Subject: [PATCH] Add M2 AI planning coach implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-05-31-m2-ai-planning-coach.md | 1156 +++++++++++++++++ 1 file changed, 1156 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-m2-ai-planning-coach.md diff --git a/docs/superpowers/plans/2026-05-31-m2-ai-planning-coach.md b/docs/superpowers/plans/2026-05-31-m2-ai-planning-coach.md new file mode 100644 index 0000000..5bfb5a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m2-ai-planning-coach.md @@ -0,0 +1,1156 @@ +# M2 — AI Planning Coach 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:** In the Planning view, the user types one rough intent, presses Sharpen, and an AI coach (claude or codex CLI) proposes a structured commitment that pre-fills the three Planning inputs for the user to edit and accept. + +**Architecture:** New leaf `ai` package = the Advisor port: a pluggable `Backend` (claude/codex adapters) under a backend-agnostic `Coach`/`Service`. `session.Controller` runs the coach async (background goroutine + generation guard), keeps ephemeral coach state, and projects it into `State`. `web` adds `POST /coach`; the browser renders pending→ready/error over the existing SSE stream and pre-fills inputs without clobbering typing. The LLM only proposes a draft; activation still flows through the existing manual transition. + +**Tech Stack:** Go 1.26, Gin, SSE, `os/exec` shelling to `claude`/`codex`. Tests use stdlib `testing`, fakes only — no test invokes a real CLI. Keep `go test -race ./...` clean. + +**Spec:** `docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md` + +--- + +## File Structure + +- Create `internal/ai/proposal.go` — `Proposal`, sentinel errors, `extractJSON`, `parseProposal`. +- Create `internal/ai/proposal_test.go` — parsing/extraction tests. +- Create `internal/ai/coach.go` — `Coach` + `Backend` interfaces, `Service`, `buildPrompt`. +- Create `internal/ai/coach_test.go` — `Service.Coach` over a `fakeBackend`. +- Create `internal/ai/backend.go` — `claudeBackend`, `codexBackend`, `NewBackend`. +- Create `internal/ai/backend_test.go` — argument construction + selector tests. +- Modify `internal/session/session.go` — coach fields, `SetCoach`, `RequestCoach`, reset logic, `CoachView`/`ProposalView`, `State.Coach`, projection. +- Modify `internal/session/session_test.go` — async coach tests with a fake coach. +- Modify `internal/web/web.go` — `POST /coach` route + `handleCoach` + `coachRequest`. +- Modify `internal/web/web_test.go` — `/coach` route tests. +- Modify `internal/web/static/index.html` — intent box, Sharpen, partial-update path. +- Modify `cmd/antidriftd/main.go` — `ANTIDRIFT_AI_BACKEND` wiring + graceful fallback. +- Modify `README.md` — mark M2 done (match prior milestone style). + +--- + +## Task 1: `ai` — Proposal parsing + +**Files:** +- Create: `internal/ai/proposal.go` +- Test: `internal/ai/proposal_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +package ai + +import ( + "errors" + "testing" +) + +func TestExtractJSON(t *testing.T) { + cases := []struct { + name, in, want string + wantErr bool + }{ + {"bare", `{"a":1}`, `{"a":1}`, false}, + {"prose around", "sure, here:\n{\"a\":1}\nhope that helps", `{"a":1}`, false}, + {"code fence", "```json\n{\"a\":1}\n```", `{"a":1}`, false}, + {"nested", `{"a":{"b":2},"c":3}`, `{"a":{"b":2},"c":3}`, false}, + {"brace in string", `{"a":"}{"}`, `{"a":"}{"}`, false}, + {"first of two", `{"a":1} then {"b":2}`, `{"a":1}`, false}, + {"none", `no json here`, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := extractJSON(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("want error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestParseProposal(t *testing.T) { + good := `{"next_action":"Draft summary","success_condition":"3 paragraphs written","timebox_minutes":25}` + p, err := parseProposal(good) + if err != nil { + t.Fatalf("good parse: %v", err) + } + if p.NextAction != "Draft summary" || p.SuccessCondition != "3 paragraphs written" { + t.Fatalf("fields wrong: %+v", p) + } + if p.TimeboxSecs != 25*60 { + t.Fatalf("timebox secs got %d want %d", p.TimeboxSecs, 25*60) + } + + if _, err := parseProposal(""); !errors.Is(err, ErrEmptyResponse) { + t.Fatalf("empty: want ErrEmptyResponse, got %v", err) + } + if _, err := parseProposal("no json"); !errors.Is(err, ErrNoJSON) { + t.Fatalf("no json: want ErrNoJSON, got %v", err) + } + bad := []string{ + `{"next_action":"","success_condition":"x","timebox_minutes":25}`, + `{"next_action":"x","success_condition":" ","timebox_minutes":25}`, + `{"next_action":"x","success_condition":"y","timebox_minutes":0}`, + `{"next_action":"x","success_condition":"y","timebox_minutes":-5}`, + `{"next_action":"x"`, + } + for _, in := range bad { + if _, err := parseProposal(in); !errors.Is(err, ErrInvalidProposal) { + t.Fatalf("bad %q: want ErrInvalidProposal, got %v", in, err) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/ai/ -run 'TestExtractJSON|TestParseProposal' -v` +Expected: build failure / FAIL — `extractJSON`, `parseProposal`, `Proposal`, sentinels undefined. + +- [ ] **Step 3: Write the implementation** + +```go +// Package ai is the Advisor port: it turns a user's free-text intent into a +// structured commitment Proposal by shelling out to an LLM CLI. It imports +// nothing from the rest of the app, so it stays a leaf package. +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Proposal is the coach's structured suggestion for a commitment. It is not a +// domain.Commitment: the AI does not mint IDs, timestamps, or state. +type Proposal struct { + NextAction string + SuccessCondition string + TimeboxSecs int64 +} + +var ( + ErrEmptyResponse = errors.New("ai: empty response") + ErrNoJSON = errors.New("ai: no JSON object in response") + ErrInvalidProposal = errors.New("ai: invalid proposal") +) + +// extractJSON returns the first balanced top-level JSON object in s, tolerating +// leading/trailing prose or code fences around it. +func extractJSON(s string) (string, error) { + start := strings.IndexByte(s, '{') + if start < 0 { + return "", ErrNoJSON + } + depth, inStr, esc := 0, false, false + for i := start; i < len(s); i++ { + ch := s[i] + if inStr { + switch { + case esc: + esc = false + case ch == '\\': + esc = true + case ch == '"': + inStr = false + } + continue + } + switch ch { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1], nil + } + } + } + return "", ErrNoJSON +} + +type rawProposal struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxMinutes int64 `json:"timebox_minutes"` +} + +// parseProposal extracts and validates a Proposal from raw CLI output. +func parseProposal(s string) (Proposal, error) { + if strings.TrimSpace(s) == "" { + return Proposal{}, ErrEmptyResponse + } + jsonStr, err := extractJSON(s) + if err != nil { + return Proposal{}, err + } + var raw rawProposal + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) + } + na := strings.TrimSpace(raw.NextAction) + sc := strings.TrimSpace(raw.SuccessCondition) + if na == "" || sc == "" || raw.TimeboxMinutes <= 0 { + return Proposal{}, ErrInvalidProposal + } + return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/ai/ -run 'TestExtractJSON|TestParseProposal' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/proposal.go internal/ai/proposal_test.go +git commit -m "M2: ai Proposal type, JSON extraction and validation" +``` + +--- + +## Task 2: `ai` — Coach service over a Backend + +**Files:** +- Create: `internal/ai/coach.go` +- Test: `internal/ai/coach_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package ai + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeBackend struct { + out string + err error + gotPrompt string +} + +func (f *fakeBackend) Run(ctx context.Context, prompt string) (string, error) { + f.gotPrompt = prompt + return f.out, f.err +} +func (f *fakeBackend) Name() string { return "fake" } + +func TestServiceCoachSuccess(t *testing.T) { + fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`} + svc := NewService(fb) + p, err := svc.Coach(context.Background(), "write the tests") + if err != nil { + t.Fatalf("coach: %v", err) + } + if p.NextAction != "Write tests" || p.TimeboxSecs != 1800 { + t.Fatalf("bad proposal: %+v", p) + } + if !strings.Contains(fb.gotPrompt, "write the tests") { + t.Fatalf("prompt should embed the intent, got: %s", fb.gotPrompt) + } +} + +func TestServiceCoachBackendError(t *testing.T) { + fb := &fakeBackend{err: errors.New("boom")} + if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil { + t.Fatal("want backend error") + } +} + +func TestServiceCoachUnparseable(t *testing.T) { + fb := &fakeBackend{out: "I cannot help with that."} + if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) { + t.Fatalf("want ErrNoJSON, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/ai/ -run TestServiceCoach -v` +Expected: build failure — `NewService`, `Service`, `Coach`, `Backend` undefined. + +- [ ] **Step 3: Write the implementation** + +```go +package ai + +import "context" + +// Coach turns a free-text intent into a validated Proposal. +type Coach interface { + Coach(ctx context.Context, intent string) (Proposal, error) +} + +// Backend is one way to reach an LLM CLI. Adapters differ only in the command +// and arguments they run; each returns the model's text answer. +type Backend interface { + Run(ctx context.Context, prompt string) (string, error) + Name() string +} + +// Service implements Coach over any Backend. +type Service struct { + backend Backend +} + +func NewService(b Backend) *Service { return &Service{backend: b} } + +func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) { + out, err := s.backend.Run(ctx, buildPrompt(intent)) + if err != nil { + return Proposal{}, err + } + return parseProposal(out) +} + +func buildPrompt(intent string) string { + return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. + +Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: +{"next_action": "", "success_condition": "", "timebox_minutes": } + +Rules: +- next_action: a single concrete imperative action, doable now. +- success_condition: observable and verifiable; how you'd know it is done. +- timebox_minutes: a realistic integer number of minutes for the action. + +User intent: ` + intent +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/ai/ -run TestServiceCoach -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/coach.go internal/ai/coach_test.go +git commit -m "M2: ai Coach interface and Service over a pluggable Backend" +``` + +--- + +## Task 3: `ai` — claude and codex backends + +**Files:** +- Create: `internal/ai/backend.go` +- Test: `internal/ai/backend_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package ai + +import ( + "slices" + "testing" +) + +func TestNewBackendSelector(t *testing.T) { + for _, name := range []string{"", "claude"} { + b, err := NewBackend(name) + if err != nil { + t.Fatalf("NewBackend(%q): %v", name, err) + } + if b.Name() != "claude" { + t.Fatalf("NewBackend(%q) name = %q", name, b.Name()) + } + } + b, err := NewBackend("codex") + if err != nil || b.Name() != "codex" { + t.Fatalf("codex backend: %v / %q", err, b.Name()) + } + if _, err := NewBackend("bogus"); err == nil { + t.Fatal("unknown backend should error") + } +} + +func TestClaudeArgs(t *testing.T) { + want := []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"} + if got := newClaudeBackend().args; !slices.Equal(got, want) { + t.Fatalf("claude args = %v want %v", got, want) + } +} + +func TestCodexArgsCarryOutfileAndStdinMarker(t *testing.T) { + got := newCodexBackend().args("/tmp/out.txt") + if got[len(got)-1] != "-" { + t.Fatalf("codex args must end with stdin marker '-', got %v", got) + } + if !slices.Contains(got, "-o") || !slices.Contains(got, "/tmp/out.txt") { + t.Fatalf("codex args must include -o /tmp/out.txt, got %v", got) + } + for _, want := range []string{"exec", "--ignore-user-config", "--ignore-rules", "read-only", "never"} { + if !slices.Contains(got, want) { + t.Fatalf("codex args missing %q, got %v", want, got) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/ai/ -run 'TestNewBackend|TestClaudeArgs|TestCodexArgs' -v` +Expected: build failure — `NewBackend`, `newClaudeBackend`, `newCodexBackend` undefined. + +- [ ] **Step 3: Write the implementation** + +Exact invocations were verified empirically (see spec). claude answers on stdout; codex's stdout is noisy, so its answer is captured from an `-o` temp file. + +```go +package ai + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// claudeBackend shells out to `claude --print` with the prompt on stdin and the +// answer on stdout. +type claudeBackend struct { + cmd string + args []string +} + +func newClaudeBackend() claudeBackend { + return claudeBackend{ + cmd: "claude", + args: []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"}, + } +} + +func (b claudeBackend) Name() string { return "claude" } + +func (b claudeBackend) Run(ctx context.Context, prompt string) (string, error) { + cmd := exec.CommandContext(ctx, b.cmd, b.args...) + cmd.Stdin = strings.NewReader(prompt) + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("claude: %w: %s", err, strings.TrimSpace(errb.String())) + } + return out.String(), nil +} + +// codexBackend shells out to `codex exec` headlessly. codex's stdout includes +// session preamble, so the final answer is written to a temp file via -o and +// read back. The --ignore-* and read-only flags stop it from running shell +// commands driven by local config. +type codexBackend struct { + cmd string + baseArgs []string +} + +func newCodexBackend() codexBackend { + return codexBackend{ + cmd: "codex", + baseArgs: []string{ + "exec", "--skip-git-repo-check", "--ignore-user-config", + "--ignore-rules", "-s", "read-only", "-a", "never", "--ephemeral", + }, + } +} + +func (b codexBackend) Name() string { return "codex" } + +// args builds the full argument list, writing the answer to outfile and reading +// the prompt from stdin (trailing "-"). +func (b codexBackend) args(outfile string) []string { + out := append([]string{}, b.baseArgs...) + return append(out, "-o", outfile, "-") +} + +func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) { + f, err := os.CreateTemp("", "antidrift-codex-*.out") + if err != nil { + return "", fmt.Errorf("codex: temp file: %w", err) + } + path := f.Name() + _ = f.Close() + defer os.Remove(path) + + cmd := exec.CommandContext(ctx, b.cmd, b.args(path)...) + cmd.Stdin = strings.NewReader(prompt) + var errb bytes.Buffer + cmd.Stderr = &errb // stdout is noisy and intentionally discarded + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("codex: %w: %s", err, strings.TrimSpace(errb.String())) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("codex: read output: %w", err) + } + return string(data), nil +} + +// NewBackend returns the named backend. "" defaults to "claude". +func NewBackend(name string) (Backend, error) { + switch name { + case "", "claude": + return newClaudeBackend(), nil + case "codex": + return newCodexBackend(), nil + default: + return nil, fmt.Errorf("ai: unknown backend %q", name) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/ai/ -v` +Expected: PASS (all `ai` tests). No real CLI is spawned. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/backend.go internal/ai/backend_test.go +git commit -m "M2: ai claude and codex CLI backends with selector" +``` + +--- + +## Task 4: `session` — async coach orchestration + +**Files:** +- Modify: `internal/session/session.go` +- Test: `internal/session/session_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/session/session_test.go`. Add `"context"` and `"antidrift/internal/ai"` to the import block. + +```go +type fakeCoach struct { + prop ai.Proposal + err error + gate chan struct{} // if non-nil, Coach blocks until it receives +} + +func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { + if f.gate != nil { + <-f.gate + } + return f.prop, f.err +} + +// waitCoachStatus polls until the coach view reaches want, or fails after 2s. +func waitCoachStatus(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.Coach != nil && st.Coach.Status == want { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach) + return State{} +} + +func TestRequestCoachReady(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}}) + if err := c.RequestCoach("do x"); err != nil { + t.Fatalf("request coach: %v", err) + } + st := waitCoachStatus(t, c, "ready") + if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 { + t.Fatalf("ready proposal wrong: %+v", st.Coach) + } +} + +func TestRequestCoachError(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + c.SetCoach(&fakeCoach{err: errors.New("backend down")}) + if err := c.RequestCoach("x"); err != nil { + t.Fatalf("request: %v", err) + } + st := waitCoachStatus(t, c, "error") + if st.Coach.Error == "" { + t.Fatal("error status should carry a message") + } +} + +func TestRequestCoachUnavailable(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + if err := c.RequestCoach("x"); err != nil { + t.Fatalf("nil coach must degrade, not error: %v", err) + } + st := c.State() + if st.Coach == nil || st.Coach.Status != "error" { + t.Fatalf("want error status for nil coach, got %+v", st.Coach) + } +} + +func TestRequestCoachWrongState(t *testing.T) { + c, _ := newTestController(t) + c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) + if err := c.RequestCoach("x"); err == nil { + t.Fatal("coaching from Locked should error") + } + if c.State().Coach != nil { + t.Fatal("no coach view outside planning") + } +} + +func TestRequestCoachStaleResultDiscarded(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + + slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})} + c.SetCoach(slow) + _ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate + + fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}} + c.SetCoach(fast) + _ = c.RequestCoach("second") // gen2 -> ready NEW + st := waitCoachStatus(t, c, "ready") + if st.Coach.Proposal.NextAction != "NEW" { + t.Fatalf("expected NEW, got %+v", st.Coach.Proposal) + } + + close(slow.gate) // release gen1; it must be discarded + time.Sleep(50 * time.Millisecond) + if got := c.State().Coach.Proposal.NextAction; got != "NEW" { + t.Fatalf("stale gen1 overwrote state: %q", got) + } +} + +func TestLeavingPlanningClearsCoach(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) + _ = c.RequestCoach("x") + waitCoachStatus(t, c, "ready") + if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil { + t.Fatalf("start: %v", err) + } + if c.State().Coach != nil { + t.Fatal("coach view must be gone once Active") + } +} +``` + +(`errors` is already needed — add `"errors"` to the test imports if not present.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/session/ -run 'Coach|Planning' -v` +Expected: build failure — `SetCoach`, `RequestCoach`, `State.Coach` undefined. + +- [ ] **Step 3: Implement controller changes** + +In `internal/session/session.go`: + +(a) Add imports `"context"` and `"antidrift/internal/ai"` (keep existing imports). + +(b) Add constants and a sentinel near the top-level `const` block: + +```go +const coachTimeout = 60 * time.Second + +const ( + coachIdle = "idle" + coachPending = "pending" + coachReady = "ready" + coachError = "error" +) + +var ErrNotPlanning = errors.New("session: coaching is only available while planning") +``` + +Add `"errors"` to the imports. + +(c) Add fields to `Controller`: + +```go + coach ai.Coach + coachStatus string + coachProposal *ai.Proposal + coachErr string + coachGen int +``` + +(d) Add the view types near `CommitmentView`: + +```go +// ProposalView / CoachView project the ephemeral planning-coach state. +type ProposalView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` +} + +type CoachView struct { + Status string `json:"status"` + Proposal *ProposalView `json:"proposal,omitempty"` + Error string `json:"error,omitempty"` +} +``` + +(e) Add `Coach` to `State`: + +```go + Coach *CoachView `json:"coach,omitempty"` +``` + +(f) Project it in `stateLocked`, before `return st`: + +```go + if c.runtimeState == domain.RuntimePlanning { + status := c.coachStatus + if status == "" { + status = coachIdle + } + cv := &CoachView{Status: status, Error: c.coachErr} + if c.coachProposal != nil { + cv.Proposal = &ProposalView{ + NextAction: c.coachProposal.NextAction, + SuccessCondition: c.coachProposal.SuccessCondition, + TimeboxSecs: c.coachProposal.TimeboxSecs, + } + } + st.Coach = cv + } +``` + +(g) Add `SetCoach`, `resetCoachLocked`, `RequestCoach`, and `coachErrorMessage`: + +```go +// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes +// RequestCoach degrade gracefully. +func (c *Controller) SetCoach(coach ai.Coach) { + c.mu.Lock() + c.coach = coach + c.mu.Unlock() +} + +// resetCoachLocked returns coach state to idle and invalidates any in-flight +// request. Caller holds mu. +func (c *Controller) resetCoachLocked() { + c.coachStatus = coachIdle + c.coachProposal = nil + c.coachErr = "" + c.coachGen++ +} + +// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if +// not in planning; otherwise never a hard error (failures surface as coach +// state). The proposal is ephemeral and never persisted. +func (c *Controller) RequestCoach(intent string) error { + c.mu.Lock() + if c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return ErrNotPlanning + } + if c.coach == nil { + c.coachStatus = coachError + c.coachErr = "coach unavailable" + c.coachProposal = nil + c.mu.Unlock() + c.notify() + return nil + } + c.coachGen++ + gen := c.coachGen + c.coachStatus = coachPending + c.coachErr = "" + c.coachProposal = nil + coach := c.coach + c.mu.Unlock() + c.notify() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) + defer cancel() + prop, err := coach.Coach(ctx, intent) + + c.mu.Lock() + if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + if err != nil { + c.coachStatus = coachError + c.coachErr = coachErrorMessage(err) + c.coachProposal = nil + } else { + c.coachStatus = coachReady + c.coachProposal = &prop + c.coachErr = "" + } + c.mu.Unlock() + c.notify() + }() + return nil +} + +func coachErrorMessage(err error) string { + switch { + case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal): + return "coach returned an unusable response" + case errors.Is(err, context.DeadlineExceeded): + return "coach timed out" + default: + return "coach unavailable" + } +} +``` + +(h) Reset coach state when entering and leaving planning. In `EnterPlanning`, after `c.runtimeState = next`: + +```go + c.resetCoachLocked() +``` + +In `StartManualCommitment`, after `c.outcomePending = ""` (still holding mu, before minting the session): + +```go + c.resetCoachLocked() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/session/ -v` +Expected: PASS. + +- [ ] **Step 5: Run the race detector** + +Run: `go test -race ./internal/session/ ./internal/ai/` +Expected: PASS, no race warnings. + +- [ ] **Step 6: Commit** + +```bash +git add internal/session/session.go internal/session/session_test.go +git commit -m "M2: controller async coach with generation guard and ephemeral state" +``` + +--- + +## Task 5: `web` — POST /coach route + +**Files:** +- Modify: `internal/web/web.go` +- Test: `internal/web/web_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/web/web_test.go`. Add `"context"` and `"antidrift/internal/ai"` to imports. Reuse a fake coach (define a local one here, since the session fake is unexported in that package): + +```go +type stubCoach struct { + prop ai.Proposal + err error +} + +func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { + return s.prop, s.err +} + +func TestCoachRouteHappyPath(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { + t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) + } + // Status reaches ready over the controller (synchronous stub completes fast). + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach) +} + +func TestCoachRouteOutsidePlanning(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetCoach(stubCoach{}) + r := s.Router() + // From Locked, /coach is a client error. + if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCoachRouteInvalidJSON(t *testing.T) { + s := newTestServer(t) + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCoachRouteUnavailableDegrades(t *testing.T) { + s := newTestServer(t) // no SetCoach + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { + t.Fatalf("unavailable coach should still 200, got %d", w.Code) + } + if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" { + t.Fatalf("want error status, got %+v", cv) + } +} +``` + +Add `"time"` to the test imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/web/ -run Coach -v` +Expected: build failure — `handleCoach`/route undefined (404 or compile error). + +- [ ] **Step 3: Implement the route** + +In `internal/web/web.go`, register the route in `Router()` after `/planning`: + +```go + r.POST("/coach", s.handleCoach) +``` + +Add the handler and request type (near `handleCommitment`): + +```go +type coachRequest struct { + Intent string `json:"intent"` +} + +func (s *Server) handleCoach(c *gin.Context) { + var req coachRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.respond(c, s.ctrl.RequestCoach(req.Intent)) +} +``` + +`respond` already maps a non-`IllegalTransitionError` (`ErrNotPlanning`) to 400 and broadcasts on success. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/web/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/web.go internal/web/web_test.go +git commit -m "M2: POST /coach route wiring the planning coach" +``` + +--- + +## Task 6: UI — Sharpen control with non-clobbering updates + +**Files:** +- Modify: `internal/web/static/index.html` + +This task has no Go test; verify by reading the diff and a manual smoke test. The key constraint: SSE messages now arrive while the user types in Planning, so the planning view must NOT be rebuilt on every message. + +- [ ] **Step 1: Add module-level render tracking** + +Near the top of the `