Files
antidrift/docs/superpowers/plans/2026-05-31-m2-ai-planning-coach.md
T
2026-05-31 13:47:31 -04:00

34 KiB

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.goProposal, sentinel errors, extractJSON, parseProposal.
  • Create internal/ai/proposal_test.go — parsing/extraction tests.
  • Create internal/ai/coach.goCoach + Backend interfaces, Service, buildPrompt.
  • Create internal/ai/coach_test.goService.Coach over a fakeBackend.
  • Create internal/ai/backend.goclaudeBackend, 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.goPOST /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.goANTIDRIFT_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

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

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
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": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}

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

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.

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
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.

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:

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:

	coach         ai.Coach
	coachStatus   string
	coachProposal *ai.Proposal
	coachErr      string
	coachGen      int

(d) Add the view types near CommitmentView:

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

	Coach *CoachView `json:"coach,omitempty"`

(f) Project it in stateLocked, before return st:

	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:

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

	c.resetCoachLocked()

In StartManualCommitment, after c.outcomePending = "" (still holding mu, before minting the session):

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

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:

	r.POST("/coach", s.handleCoach)

Add the handler and request type (near handleCommitment):

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
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 <script> (after let countdownTimer = null;):

let renderedState = null;     // which runtime_state the DOM currently shows
  • Step 2: Make render skip rebuilding an already-mounted planning view

At the very top of render(state), before the timer reset, add a planning fast-path:

function render(state) {
  const rs = state.runtime_state;
  if (rs === 'planning' && renderedState === 'planning') {
    updatePlanningCoach(state.coach);   // partial update only
    return;
  }
  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
  renderedState = rs;
  // ... existing locked/planning/active/review branches unchanged below ...

(The existing body stays; only the guard above and the renderedState/appliedCoachGen assignments are new. Keep the rest of render as-is.)

  • Step 3: Replace the planning branch markup to add the intent + Sharpen control

Replace the else if (rs === 'planning') block's view.innerHTML and wiring with:

  } else if (rs === 'planning') {
    view.innerHTML = `<span class="pill">Planning</span>
      <label>Rough intent</label>
      <input id="intent" placeholder="e.g. work on the quarterly report">
      <button id="sharpen" type="button">Sharpen</button>
      <div id="coachStatus" class="meta"></div>
      <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>Minutes</label><input id="mins" type="number" min="1" value="25">
      <button id="start" disabled>Start commitment</button>`;
    const na = document.getElementById('na'), sc = document.getElementById('sc'),
          mins = document.getElementById('mins'), start = document.getElementById('start');
    const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
    [na, sc, mins].forEach(el => el.oninput = check);
    document.getElementById('sharpen').onclick = () => {
      const intent = document.getElementById('intent').value.trim();
      if (intent) post('/coach', { intent });
    };
    start.onclick = () => post('/commitment', {
      next_action: na.value.trim(),
      success_condition: sc.value.trim(),
      timebox_secs: Math.round(+mins.value * 60),
    });
    updatePlanningCoach(state.coach);   // reflect any current coach state on mount
  } else if (rs === 'active') {
  • Step 4: Add updatePlanningCoach

Add this function (e.g. just above render):

function updatePlanningCoach(coach) {
  const statusEl = document.getElementById('coachStatus');
  if (!statusEl) return;
  if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
  if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
  if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
  if (coach.status === 'ready' && coach.proposal) {
    statusEl.textContent = 'AI suggestion applied — edit freely.';
    // Pre-fill once per ready proposal so later manual edits are not clobbered
    // by subsequent SSE ticks (idempotent via the dataset stamp below).
    const stamp = JSON.stringify(coach.proposal);
    if (statusEl.dataset.applied === stamp) return;
    statusEl.dataset.applied = stamp;
    const na = document.getElementById('na'), sc = document.getElementById('sc'),
          mins = document.getElementById('mins'), start = document.getElementById('start');
    if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
    if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
    if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
    if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
  }
}

(Pre-fill only fills empty na/sc so it never overwrites text the user already typed; the dataset.applied stamp makes it idempotent across repeated SSE ticks for the same proposal.)

  • Step 5: Manual smoke test
go build ./... && go run ./cmd/antidriftd

In the browser: Start planning → type an intent → Sharpen → status shows "Sharpening…" → fields populate when ready → edit a field → confirm it is not overwritten on the next SSE tick → Start commitment works. Stop the daemon (Ctrl-C).

  • Step 6: Commit
git add internal/web/static/index.html
git commit -m "M2: planning-view Sharpen control with non-clobbering coach updates"

Task 7: Daemon wiring with graceful fallback

Files:

  • Modify: cmd/antidriftd/main.go

  • Step 1: Wire backend selection after the controller is created

In main(), after srv := web.NewServer(ctrl) (and before the evidence watcher), add coach wiring. Add imports "os" and "antidrift/internal/ai".

	// Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND
	// (claude default, or codex). Misconfiguration degrades to no coach rather
	// than failing startup — manual planning still works.
	if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
		log.Printf("ai coach disabled: %v", err)
	} else {
		ctrl.SetCoach(ai.NewService(backend))
		log.Printf("ai coach: %s backend", backend.Name())
	}
  • Step 2: Build and vet

Run: go build ./... && go vet ./... Expected: no errors.

  • Step 3: Run the full suite with the race detector

Run: go test -race ./... Expected: PASS across all packages.

  • Step 4: Commit
git add cmd/antidriftd/main.go
git commit -m "M2: wire planning coach backend into the daemon with graceful fallback"

Task 8: Mark M2 complete

Files:

  • Modify: README.md

  • Step 1: Update the milestone status

Read README.md, find the milestone list, and mark M2 done in the same style M1 used (e.g. a /done marker on the "M2 — AI planning coach" line). Match the exact existing format — do not invent a new one.

  • Step 2: Commit
git add README.md
git commit -m "M2: mark AI planning coach milestone done"

Final Review

  • Run go test -race ./... once more — all green.
  • Confirm no test spawns a real claude/codex process (grep tests for exec.Command).
  • Dispatch a final code review over the whole M2 diff (git diff <pre-M2-sha>..HEAD).
  • Use superpowers:finishing-a-development-branch to complete the work.