Files
antidrift/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md
T
2026-05-31 13:39:10 -04:00

448 lines
19 KiB
Markdown

# M2 — AI Planning Coach — Design
Date: 2026-05-31
## Purpose
M2 adds the first AI capability to AntiDrift: a **planning coach**. In the
Planning view, the user types one rough intent ("work on the quarterly report"),
presses **Sharpen**, and an AI coach proposes a structured commitment —
`next_action`, `success_condition`, and a `timebox` — that pre-fills the existing
three Planning inputs for the user to edit and accept.
This establishes the `ai` port (the **cortex** layer of the decision core) and
the CLI backend, the pattern every later AI role (drift interceptor, nudge,
reflection) will reuse. The coach **proposes**; the user still drives the
existing `/commitment` transition. The LLM never owns a state transition.
AI is **strictly additive**: if the coach is unavailable, slow, or returns
garbage, the three manual Planning inputs remain fully usable. This mirrors the
evidence-health degradation pattern established in M1.
## Scope
**In scope (M2):**
- A new `ai` package with a pluggable CLI **backend** abstraction and **two real
adapters from day one: `claude` and `codex`**.
- A backend-agnostic **`Coach`** capability that turns a free-text intent into a
validated `Proposal`.
- Async, SSE-driven delivery: the coach runs in a background goroutine; the UI
shows a pending state and updates when the proposal lands.
- Graceful degradation on every failure path (missing CLI, timeout, malformed
output, no backend wired).
- Planning-view UI: an intent box + Sharpen button that pre-fills the existing
inputs from the proposal.
**Out of scope (deferred):**
- The `JudgeDrift` and `Nudge` roles — they join the `ai` interface in **M3**.
M2 builds only `Coach` (YAGNI).
- An Anthropic API backend — the interface boundary allows it later without
touching callers; not built now.
- Any change to the commitment/runtime state machine. The coach produces a
draft; activation still goes through the existing `StartManualCommitment`
path.
- Persisting the proposal. It is ephemeral pre-commitment advice (see
"Ephemeral state").
## Architecture
M2 follows the established ports-and-adapters shape. The `ai` package is the new
**Advisor** port; `claude` and `codex` are its adapters; `session.Controller`
(the nervous system) orchestrates the async call and broadcasts; the browser
renders. The coach sits at the **cortex** layer: it proposes at a decision point
the state machine exposes (planning), but never forces a transition.
### The `ai` package — two layers
The pluggability requirement is met by separating *what we ask* from *how we
reach a CLI*.
**Layer 1 — `Backend` (the pluggable adapter).**
```go
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
// and arguments they run.
type Backend interface {
// Run sends prompt to the CLI and returns its raw stdout.
Run(ctx context.Context, prompt string) (string, error)
// Name identifies the backend (e.g. "claude", "codex").
Name() string
}
```
Two real adapters. The exact invocations below were verified empirically on
this machine (claude 2.1.154, codex-cli 0.135.0); both authenticate via the
existing CLI login — **no API keys**.
- **`claudeBackend`** runs:
```
claude --print --tools "" --no-session-persistence --output-format text
```
The prompt is delivered on **stdin** (avoids argv limits and shell-escaping;
also dodges a quirk where an empty `--tools ""` positional can be mistaken for
the prompt). The model's answer is exactly **stdout** (trailing newline
trimmed). `--tools ""` disables all tools so it just answers;
`--no-session-persistence` avoids writing resumable session files. Do **not**
use `--bare` (it forces `ANTHROPIC_API_KEY` and ignores the machine's login).
- **`codexBackend`** runs:
```
codex exec --skip-git-repo-check --ignore-user-config --ignore-rules \
-s read-only -a never --ephemeral -o <tmpfile> -
```
The prompt is delivered on **stdin** (the trailing `-` tells codex to read it
from stdin). codex's **stdout is not clean** (it includes session preamble),
so the adapter writes the final answer to a per-call **temp file** via `-o`,
then reads and returns that file's contents. The adapter creates the temp file
(`os.CreateTemp`) and removes it on return. The flags matter:
`--ignore-user-config --ignore-rules -s read-only` stop codex from executing
shell commands driven by local config (observed: it otherwise runs tool calls
even for a trivial prompt, adding latency); `-a never` disables approval
prompts for headless use; `--ephemeral` skips persisting session files;
`--skip-git-repo-check` lets it run anywhere.
Both use `os/exec` with the `ctx` passed to `exec.CommandContext` so a timeout
cancels the child process. Each adapter stores its command name and base args in
struct fields so argument construction is unit-testable without spawning a
process. The codex adapter's temp-file handling lives inside its `Run` so the
`Backend` interface stays uniform (`Run(ctx, prompt) (string, error)`).
A selector constructs the configured backend:
```go
// NewBackend returns the named backend, or an error for an unknown name.
// name "" defaults to "claude".
func NewBackend(name string) (Backend, error)
```
**Layer 2 — `Coach` (backend-agnostic capability).**
```go
// 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
}
// Coach turns a free-text intent into a validated Proposal.
type Coach interface {
Coach(ctx context.Context, intent string) (Proposal, error)
}
```
`Service` implements `Coach` over any `Backend`:
```go
type Service struct {
backend Backend
}
func NewService(b Backend) *Service
```
`Coach` builds a strict prompt, calls `backend.Run`, extracts and parses the
JSON, and validates it. The `ai` package imports nothing from the rest of the
app (it returns its own `Proposal`, not `domain.Commitment`), so it stays a leaf
package with no import cycles.
### Prompt and JSON contract
The prompt instructs the model to act as a focus coach and to **return only
JSON** of the form:
```json
{
"next_action": "Draft the executive summary section",
"success_condition": "Summary section has 3 paragraphs covering revenue, risks, outlook",
"timebox_minutes": 25
}
```
Parsing is tolerant of a chatty CLI:
- `extractJSON(s string) (string, error)` scans for the first balanced `{...}`
object in the output and returns it. This survives leading/trailing prose or
code fences.
- `parseProposal(jsonStr string) (Proposal, error)` unmarshals into an internal
struct with `next_action`, `success_condition`, `timebox_minutes`, then:
- trims whitespace; errors if `next_action` or `success_condition` is empty;
- errors if `timebox_minutes <= 0`;
- converts minutes to `TimeboxSecs` (`minutes * 60`).
All parse/validation failures return a non-nil error; the caller degrades
gracefully (see below). Sentinel errors: `ErrEmptyResponse`, `ErrNoJSON`,
`ErrInvalidProposal`.
Both CLIs also offer native structured-output flags (claude
`--output-format json --json-schema`; codex `--output-schema <file>`) that would
guarantee shape. We deliberately do **not** use them in M2: they diverge the two
adapters (different flags, envelope vs file) and would push schema concerns into
the `Backend` layer. Prompt-instructed JSON + tolerant `extractJSON` keeps the
`Backend` interface uniform and the parsing in one place. Native schemas remain a
clean future robustness upgrade behind the same `Coach` boundary.
### `session.Controller` — async coach orchestration
A new method drives the coach using the **exact concurrency pattern** already in
`RecordWindow`: mutate state under the mutex, then call `notify()` with the
mutex released (`session.go:139-146`).
```go
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach)
// RequestCoach starts an async coach call for the given intent. It is a no-op
// error path (not a hard failure) unless the runtime state is wrong.
func (c *Controller) RequestCoach(intent string) error
```
Behavior of `RequestCoach`:
1. Lock. If `runtimeState != RuntimePlanning`, unlock and return
`ErrNotPlanning` (a real client error — coaching only makes sense in
planning).
2. If `coach == nil`: set coach state to `status=error`,
`err="coach unavailable"`, unlock, `notify()`, return `nil` (graceful — not
an HTTP error).
3. Otherwise: increment `coachGen`, capture `gen := coachGen`, set
`status=pending`, clear prior proposal/error, capture the `coach` reference,
unlock, `notify()` (broadcasts the pending state).
4. Launch a goroutine:
- `ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)`
(`coachTimeout = 60 * time.Second` — codex in particular runs tens of
seconds even for trivial prompts; 60s gives a real coaching prompt
headroom); `defer cancel()`.
- Call `coach.Coach(ctx, intent)`.
- Lock. **If `gen != c.coachGen` or `runtimeState != RuntimePlanning`,
unlock and return** (stale result — a newer request superseded this one, or
the user left planning). Discard silently.
- On error: `status=error`, `err=<sanitized message>`, `proposal=nil`.
- On success: `status=ready`, `proposal=<the Proposal>`, `err=""`.
- Unlock, `notify()`.
The intent string is **not** stored on the controller; it is captured by the
goroutine closure only.
#### Ephemeral state
The coach state lives on the controller as plain fields and is **never written
to the snapshot**:
```go
// on Controller:
coach ai.Coach
coachStatus string // "idle" | "pending" | "ready" | "error"
coachProposal *ai.Proposal
coachErr string
coachGen int
```
`persistLocked()` is **not** modified — `store.Snapshot` gains no coach fields.
Rationale: a proposal is pre-commitment advice; if the daemon restarts during
planning, there is nothing to recover, and the user simply re-sharpens.
Coach state is reset to `idle` (proposal nil, err "") in two places:
- `EnterPlanning` — entering planning starts with a clean coach.
- `StartManualCommitment` and the `enterReview`/`End` paths implicitly leave
planning; coach state is reset to `idle` there so a stale `ready` proposal is
not projected outside planning. (Concretely: reset in `EnterPlanning` and on
any successful leave-planning transition.)
#### State projection
`State` gains a coach projection, populated **only while in planning**:
```go
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"` // idle | pending | ready | error
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// added to State:
// Coach *CoachView `json:"coach,omitempty"`
```
In `stateLocked()`: if `runtimeState == RuntimePlanning`, attach a `CoachView`
with the current status (default `idle`), the proposal if `ready`, and the error
if `error`. Outside planning, `Coach` is `nil` and omitted.
### `web` layer
One new route:
```go
r.POST("/coach", s.handleCoach)
```
```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 broadcasts on success and maps errors. `ErrNotPlanning` is a
plain (non-`IllegalTransitionError`) error, so it maps to
`http.StatusBadRequest` — acceptable, since the UI only shows Sharpen during
planning. The pending → ready/error progression reaches the browser entirely
over the existing SSE stream; the POST response itself is not relied upon for
the proposal.
### UI (`internal/web/static/index.html`)
The Planning view gains an intent box and a Sharpen button **above** the three
existing inputs:
```
[ Rough intent .......................... ] [ Sharpen ]
(coach status line: thinking… / error note)
Next action [ ........................ ]
Success condition[ ........................ ]
Minutes [ 25 ]
[ Start commitment ]
```
**Partial-update requirement.** Today `render()` replaces the planning view's
`innerHTML` on every SSE message. With a coach, SSE messages now arrive *while
the user is typing*, so a full rebuild would wipe their input and focus. The
fix:
- Track the currently rendered runtime state in a module variable
(e.g. `renderedState`).
- When an SSE message arrives and `rs === 'planning'` **and** the planning view
is already mounted, do **not** rebuild. Instead call an
`updatePlanningCoach(state.coach)` that only:
- updates the coach status line (pending → "thinking…", error → the message,
idle/absent → empty);
- when status is `ready` and the proposal has not yet been applied for this
generation, writes `proposal.next_action`, `proposal.success_condition`, and
`Math.round(proposal.timebox_secs / 60)` into the three inputs, then runs the
existing `check()` to enable Start. Pre-fill happens once per ready proposal
(guard with a flag) so it does not clobber subsequent manual edits on every
SSE tick.
- Only rebuild the planning structure when transitioning *into* planning from a
different state.
The Sharpen button POSTs `{ intent }` to `/coach` and shows the pending state
optimistically; the disabled/enabled logic for Start is unchanged. Other runtime
states (`locked`/`active`/`review`) keep their current full-rebuild render.
## Configuration
Backend selection is config-driven from day one:
- Env var `ANTIDRIFT_AI_BACKEND` selects the adapter: `claude` (default) or
`codex`. Unknown values are a startup error.
- `cmd/antidriftd/main.go` reads the env var, calls `ai.NewBackend(name)`, wraps
it in `ai.NewService(backend)`, and calls `ctrl.SetCoach(service)`. If
`NewBackend` errors, the daemon logs a warning and runs **without** a coach
(manual planning still works) rather than failing to start — graceful
degradation extends to misconfiguration.
## Error Handling and Degradation
Every failure surfaces as a non-blocking `status=error` in the coach view, never
as a broken Planning view:
| Failure | Result |
| ------- | ------ |
| No backend wired (`SetCoach` never called / nil) | `RequestCoach` sets `status=error`, "coach unavailable"; returns nil |
| CLI binary missing | `backend.Run` errors → goroutine sets `status=error` |
| CLI timeout (>60s) | `context` cancels child → error → `status=error` |
| Empty / non-JSON output | `extractJSON`/`parseProposal` error → `status=error` |
| Missing/empty fields, non-positive timebox | `parseProposal` error → `status=error` |
| Request issued outside planning | `RequestCoach` returns `ErrNotPlanning` → HTTP 400 |
Error messages shown to the UI are sanitized to a short human string; raw CLI
stderr is logged server-side, not surfaced to the browser.
## Package Layout Changes
| Package | Change |
| ------- | ------ |
| `ai` (new) | `Backend` interface; `claudeBackend`, `codexBackend`; `NewBackend`; `Coach` interface; `Proposal`; `Service`; prompt builder; `extractJSON`; `parseProposal`; sentinel errors; `fakeBackend` (test) |
| `session` | `coach` fields; `SetCoach`; `RequestCoach`; coach reset in `EnterPlanning` and leave-planning paths; `CoachView`/`ProposalView`; `Coach` field on `State`; `stateLocked` projection |
| `web` | `POST /coach` route + `handleCoach` + `coachRequest` |
| `web/static/index.html` | intent box + Sharpen button; `updatePlanningCoach`; partial-update guard in `render()` |
| `cmd/antidriftd` | read `ANTIDRIFT_AI_BACKEND`; build backend + service; `ctrl.SetCoach`; graceful fallback |
`ai` stays small and single-purpose, consistent with the token-efficiency design
constraint.
## Testing Strategy
**`ai` package:**
- `extractJSON`: bare object, object wrapped in prose, fenced code block, no JSON
(error), multiple objects (returns first balanced one).
- `parseProposal`: valid; missing `next_action`; empty `success_condition`;
`timebox_minutes` of 0 and negative; minutes→secs conversion.
- `Service.Coach` against a `fakeBackend` returning canned strings: success,
chatty-wrapped success, malformed → error.
- `claudeBackend`/`codexBackend`: argument construction is correct and the prompt
is routed to stdin (assert on the built `*exec.Cmd` `Args`/`Stdin` fields; do
not spawn the real CLI). For codex, assert the `-o <tmpfile>` flag is present
and that `Run` would read that path (factor the temp-file path out so it is
injectable/observable in the test).
- `NewBackend`: returns claude by default, codex by name, error on unknown.
**`session` package** (with a fake `ai.Coach`):
- `RequestCoach` in planning, fake returns a proposal: status goes
`pending` then `ready`; `State().Coach.Proposal` matches; `onChange` fires
twice.
- Fake returns an error: status goes `pending` then `error`.
- Nil coach: status `error` "coach unavailable"; `RequestCoach` returns nil.
- Wrong state (locked/active): `RequestCoach` returns `ErrNotPlanning`; no
goroutine, no state change.
- Stale generation: two `RequestCoach` calls; the first (slow) fake result is
discarded, only the second is projected. (Drive via a fake whose return is
gated on a channel so ordering is deterministic.)
- Leaving planning discards a pending/ready proposal: `Coach` is nil in `State`
once active.
- Snapshot has no coach fields (round-trip a snapshot, assert unaffected).
**`web` package** (with a fake `ai.Coach` wired into a real controller):
- `POST /coach` in planning returns 200 and the broadcast state shows
`status=pending` (or `ready` if the fake is synchronous).
- `POST /coach` outside planning returns 400.
- `POST /coach` with invalid JSON returns 400.
- Coach-unavailable controller: `POST /coach` returns 200, state shows
`status=error`.
All tests use fakes; **no test invokes the real `claude`/`codex` CLI**. Tests
must remain race-clean (`go test -race ./...`), consistent with M1.
## Definition of Done
- `ai` package with both adapters, `Coach`/`Service`, parsing, and tests.
- `RequestCoach` async flow with generation-guard and graceful degradation.
- `/coach` route and Planning-view Sharpen flow that pre-fills without clobbering
user input.
- `ANTIDRIFT_AI_BACKEND` wiring in the daemon with graceful fallback.
- `go test -race ./...` passes; manual smoke: type an intent, Sharpen, see the
three fields populate, edit, Start.
- README/roadmap note that M2 is complete (consistent with prior milestones).