From 332e17d742f65f8940b9578533cc5bf11b8a37ee Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 13:33:37 -0400 Subject: [PATCH] Add M2 AI planning coach design spec Co-Authored-By: Claude Opus 4.8 --- .../2026-05-31-m2-ai-planning-coach-design.md | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md diff --git a/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md b/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md new file mode 100644 index 0000000..79c59b7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md @@ -0,0 +1,408 @@ +# 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).