Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.go—Proposal, sentinel errors,extractJSON,parseProposal. - Create
internal/ai/proposal_test.go— parsing/extraction tests. - Create
internal/ai/coach.go—Coach+Backendinterfaces,Service,buildPrompt. - Create
internal/ai/coach_test.go—Service.Coachover afakeBackend. - 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 /coachroute +handleCoach+coachRequest. - Modify
internal/web/web_test.go—/coachroute tests. - Modify
internal/web/static/index.html— intent box, Sharpen, partial-update path. - Modify
cmd/antidriftd/main.go—ANTIDRIFT_AI_BACKENDwiring + 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
renderskip 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/codexprocess (grep tests forexec.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.