ae5a0f123c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1157 lines
34 KiB
Markdown
1157 lines
34 KiB
Markdown
# 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": "<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**
|
|
|
|
```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 `<script>` (after `let countdownTimer = null;`):
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
} 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`):
|
|
|
|
```js
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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"`.
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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.
|