Add M2 AI planning coach design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<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 (>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).
|
||||||
Reference in New Issue
Block a user