Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
aipackage with a pluggable CLI backend abstraction and two real adapters from day one:claudeandcodex. - A backend-agnostic
Coachcapability that turns a free-text intent into a validatedProposal. - 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
JudgeDriftandNudgeroles — they join theaiinterface in M3. M2 builds onlyCoach(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
StartManualCommitmentpath. - 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.
-
claudeBackendruns:claude --print --tools "" --no-session-persistence --output-format textThe 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-persistenceavoids writing resumable session files. Do not use--bare(it forcesANTHROPIC_API_KEYand ignores the machine's login). -
codexBackendruns: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-onlystop codex from executing shell commands driven by local config (observed: it otherwise runs tool calls even for a trivial prompt, adding latency);-a neverdisables approval prompts for headless use;--ephemeralskips persisting session files;--skip-git-repo-checklets 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 withnext_action,success_condition,timebox_minutes, then:- trims whitespace; errors if
next_actionorsuccess_conditionis empty; - errors if
timebox_minutes <= 0; - converts minutes to
TimeboxSecs(minutes * 60).
- trims whitespace; errors if
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:
- Lock. If
runtimeState != RuntimePlanning, unlock and returnErrNotPlanning(a real client error — coaching only makes sense in planning). - If
coach == nil: set coach state tostatus=error,err="coach unavailable", unlock,notify(), returnnil(graceful — not an HTTP error). - Otherwise: increment
coachGen, capturegen := coachGen, setstatus=pending, clear prior proposal/error, capture thecoachreference, unlock,notify()(broadcasts the pending state). - 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.coachGenorruntimeState != 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.StartManualCommitmentand theenterReview/Endpaths implicitly leave planning; coach state is reset toidlethere so a stalereadyproposal is not projected outside planning. (Concretely: reset inEnterPlanningand 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 anupdatePlanningCoach(state.coach)that only:- updates the coach status line (pending → "thinking…", error → the message, idle/absent → empty);
- when status is
readyand the proposal has not yet been applied for this generation, writesproposal.next_action,proposal.success_condition, andMath.round(proposal.timebox_secs / 60)into the three inputs, then runs the existingcheck()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_BACKENDselects the adapter:claude(default) orcodex. Unknown values are a startup error. cmd/antidriftd/main.goreads the env var, callsai.NewBackend(name), wraps it inai.NewService(backend), and callsctrl.SetCoach(service). IfNewBackenderrors, 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; missingnext_action; emptysuccess_condition;timebox_minutesof 0 and negative; minutes→secs conversion.Service.Coachagainst afakeBackendreturning 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.CmdArgs/Stdinfields; do not spawn the real CLI). For codex, assert the-o <tmpfile>flag is present and thatRunwould 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):
RequestCoachin planning, fake returns a proposal: status goespendingthenready;State().Coach.Proposalmatches;onChangefires twice.- Fake returns an error: status goes
pendingthenerror. - Nil coach: status
error"coach unavailable";RequestCoachreturns nil. - Wrong state (locked/active):
RequestCoachreturnsErrNotPlanning; no goroutine, no state change. - Stale generation: two
RequestCoachcalls; 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:
Coachis nil inStateonce 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 /coachin planning returns 200 and the broadcast state showsstatus=pending(orreadyif the fake is synchronous).POST /coachoutside planning returns 400.POST /coachwith invalid JSON returns 400.- Coach-unavailable controller:
POST /coachreturns 200, state showsstatus=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
aipackage with both adapters,Coach/Service, parsing, and tests.RequestCoachasync flow with generation-guard and graceful degradation./coachroute and Planning-view Sharpen flow that pre-fills without clobbering user input.ANTIDRIFT_AI_BACKENDwiring 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).