# 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 `