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