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

19 KiB

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).

// 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:

// 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).

// 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:

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:

{
  "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).

// 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:

// 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:

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:

r.POST("/coach", s.handleCoach)
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).