# 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: - `claudeBackend` runs `claude --print` with the prompt delivered on **stdin**. - `codexBackend` runs `codex exec --` with the prompt delivered on **stdin**. 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. 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`. ### `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 = 30 * time.Second`); `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=`, `proposal=nil`. - On success: `status=ready`, `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 (>30s) | `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` fields; do not spawn the real CLI). - `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).