Remove shipped specs and plans; history and code are the record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,876 +0,0 @@
|
||||
# M3.5 — Semantic Nudge Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a soft, ambient AI nudge that catches *semantic* drift within an allowed app ("right app, wrong work") — the case the M3 drift interceptor is structurally blind to.
|
||||
|
||||
**Architecture:** A third advisor role, `ai.Nudger`, on the existing `ai.Service`, taking primitives only (leaf package preserved). `session.Controller` keeps a ring of recent window titles and, *only on the local-match on-task path* (where the drift judge stays silent), runs a debounced async nudge using the exact M3 discipline: generation-guarded, goroutine launched after the mutex is released, never fabricates a concern on error. The advisory surfaces over SSE as a new `DriftView.Nudge` field and renders as a dismiss-only "Heads up" tier on the active-view banner.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gin, SSE, stdlib `testing` only (no testify). `go test -race ./...` and `go vet ./...` must stay clean.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md`
|
||||
|
||||
**Model selection:** Task 1 (isolated parser) and Tasks 3–6 (mechanical/trivial) → fast model. Task 2 (controller concurrency) → most capable model.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge`, `ErrInvalidNudge` |
|
||||
| `internal/ai/nudge_test.go` | new: `parseNudge` table tests |
|
||||
| `internal/session/session.go` | nudge fields + constants, `recentTitles` ring, `maybeNudgeLocked` + closure, `SetNudge`, `commitmentLineLocked`, `DriftView.Nudge`, `stateLocked` wiring, `resetDriftLocked` additions, `recentTitlesForTest` |
|
||||
| `internal/session/session_test.go` | `fakeNudger`/`nudgerFunc` + nudge tests |
|
||||
| `internal/web/web_test.go` | assert `nudge` field in serialized state |
|
||||
| `internal/web/static/index.html` | "Heads up" soft tier in `updateActiveDrift` + CSS + client dismiss |
|
||||
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
||||
| `README.md` | M3.5 paragraph in Status |
|
||||
|
||||
`internal/web/web.go` needs **no** change: `DriftView` lives in `session` and serializes via its JSON tags automatically.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ai.Nudger` role and response parsing
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ai/nudge.go`
|
||||
- Test: `internal/ai/nudge_test.go`
|
||||
|
||||
This task is self-contained in the `ai` leaf package. The `ai.Service` and its `Backend` already exist (`internal/ai/coach.go`): `Service` wraps a `Backend` and `Coach`/`JudgeDrift` are methods on it. `extractJSON`, `ErrEmptyResponse`, and `ErrNoJSON` are shared helpers defined in `internal/ai/proposal.go`. You are adding a third role, `Nudge`, that mirrors `JudgeDrift` (`internal/ai/coach.go:47-75`) and `parseVerdict` (`internal/ai/verdict.go`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `internal/ai/nudge_test.go`:
|
||||
|
||||
```go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseNudge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
|
||||
{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
|
||||
{"concern is trimmed", `{"on_track": false, "message": " drifted "}`, "drifted", nil},
|
||||
{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
|
||||
{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
|
||||
{"empty response", " ", "", ErrEmptyResponse},
|
||||
{"no json", "no braces here", "", ErrNoJSON},
|
||||
{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseNudge(tt.in)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/ai/ -run TestParseNudge`
|
||||
Expected: build failure — `undefined: parseNudge` and `undefined: ErrInvalidNudge`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `internal/ai/nudge.go`:
|
||||
|
||||
```go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Nudger judges whether recent activity within an allowed app still serves the
|
||||
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
|
||||
// types, so ai stays a leaf package. The returned string is a one-sentence
|
||||
// advisory, or "" when the trajectory is still on-task.
|
||||
type Nudger interface {
|
||||
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
|
||||
}
|
||||
|
||||
// ErrInvalidNudge marks a parseable-but-unusable nudge response.
|
||||
var ErrInvalidNudge = errors.New("ai: invalid nudge")
|
||||
|
||||
// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
|
||||
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseNudge(out)
|
||||
}
|
||||
|
||||
func buildNudgePrompt(commitment string, recentTitles []string) string {
|
||||
return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.
|
||||
|
||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}
|
||||
|
||||
Rules:
|
||||
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
|
||||
- message: one short sentence naming the drift. REQUIRED when on_track is false.
|
||||
|
||||
Commitment: ` + commitment + `
|
||||
Recent window titles (oldest to newest):
|
||||
` + strings.Join(recentTitles, "\n")
|
||||
}
|
||||
|
||||
// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
|
||||
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
|
||||
// concern with no message degrades to "" (silence) rather than an error: the
|
||||
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
|
||||
// interruptive drift verdict which rejects a reasonless drift.
|
||||
func parseNudge(s string) (string, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", ErrEmptyResponse
|
||||
}
|
||||
if strings.IndexByte(s, '{') < 0 {
|
||||
return "", ErrNoJSON
|
||||
}
|
||||
jsonStr, err := extractJSON(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
|
||||
}
|
||||
var raw struct {
|
||||
OnTrack bool `json:"on_track"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
|
||||
}
|
||||
if raw.OnTrack {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(raw.Message), nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `go test ./internal/ai/ -run TestParseNudge -v`
|
||||
Expected: PASS (all subtests).
|
||||
|
||||
- [ ] **Step 5: Vet and full ai-package test**
|
||||
|
||||
Run: `go vet ./internal/ai/ && go test ./internal/ai/`
|
||||
Expected: no vet output; PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/ai/nudge.go internal/ai/nudge_test.go
|
||||
git commit -m "Add ai.Nudger role for semantic drift within allowed apps
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Controller nudge state, ring, and orchestration
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go`
|
||||
- Test: `internal/session/session_test.go`
|
||||
|
||||
This is the concurrency-sensitive core. Study the existing drift orchestration first: the constants block (`internal/session/session.go:40-43`), the `Controller` struct fields (`:71-98`), `DriftView` (`:117-121`), `stateLocked`'s drift block (`:270-276`), `SetDriftJudge` (`:342-348`), `resetDriftLocked` (`:350-357`), `RecordWindow` (`:583-603`), and `evaluateDriftLocked` (`:605-679`). You will mirror that drift discipline exactly: a closure is built under the lock and returned to `RecordWindow`, which runs it via `go launch()` *after* unlocking; the closure re-acquires the lock, checks the generation guard, and calls `notify()` only after unlocking.
|
||||
|
||||
Key design facts:
|
||||
- The drift judge and the nudge are **mutually exclusive per observation**: the drift judge runs only when the window is *unmatched*; the nudge runs only when the window is a *local match* (on-task). So `RecordWindow`'s single `launch func()` return is sufficient — no second goroutine handle is needed.
|
||||
- The nudge reuses the existing `driftGen` counter as its session-identity guard (it is incremented only at session boundaries in `resetDriftLocked`), so a stale nudge is discarded exactly like a stale verdict.
|
||||
- Nudge state (`recentTitles`, `nudgeMessage`, `lastNudgedAt`) is in-memory only and cleared in `resetDriftLocked`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to the imports of `internal/session/session_test.go` (it currently imports `context`, `errors`, `path/filepath`, `sync/atomic`, `testing`, `time`, and the internal packages): add `"fmt"`.
|
||||
|
||||
Append these test helpers and tests to `internal/session/session_test.go`:
|
||||
|
||||
```go
|
||||
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
|
||||
// blocks the call until closed, mirroring fakeJudge.
|
||||
type fakeNudger struct {
|
||||
msg string
|
||||
err error
|
||||
gate chan struct{}
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
atomic.AddInt32(&f.calls, 1)
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.msg, f.err
|
||||
}
|
||||
|
||||
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
|
||||
type nudgerFunc func(context.Context, string, []string) (string, error)
|
||||
|
||||
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
|
||||
return f(ctx, c, t)
|
||||
}
|
||||
|
||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Drift != nil && st.Drift.Nudge == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
|
||||
}
|
||||
|
||||
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "drifted to an unrelated file"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
|
||||
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
|
||||
waitNudge(t, c, "drifted to an unrelated file")
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("expected exactly one nudge call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "should not run"}
|
||||
c.SetNudge(fn)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitDriftStatus(t, c, "drifting")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDebounced(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
fn := &fakeNudger{msg: "drift"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
|
||||
waitNudge(t, c, "drift")
|
||||
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("debounce should allow one nudge, calls=%d", got)
|
||||
}
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 2 {
|
||||
t.Fatalf("expected second nudge after debounce, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{err: errors.New("backend down")}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
msgs := make(chan string, 2)
|
||||
msgs <- "drifted"
|
||||
msgs <- "" // back on track
|
||||
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
|
||||
return <-msgs, nil
|
||||
}))
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
waitNudge(t, c, "drifted")
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
waitNudge(t, c, "") // an on-track result clears the prior advisory
|
||||
}
|
||||
|
||||
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||
t.Fatalf("expected ontask, got %+v", st.Drift)
|
||||
}
|
||||
if st.Drift.Nudge != "" {
|
||||
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
_ = c.Complete()
|
||||
_ = c.End()
|
||||
close(fn.gate) // release stale goroutine; result must be discarded
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||
t.Fatalf("should be Locked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
for i := 0; i < 15; i++ {
|
||||
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
|
||||
}
|
||||
titles := c.recentTitlesForTest()
|
||||
if len(titles) != 10 {
|
||||
t.Fatalf("ring should cap at 10, got %d", len(titles))
|
||||
}
|
||||
if titles[0] != "file5.go" {
|
||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles'`
|
||||
Expected: build failure — `c.SetNudge`, `c.recentTitlesForTest`, and `nudgeDebounce` are undefined.
|
||||
|
||||
- [ ] **Step 3: Add constants and struct fields**
|
||||
|
||||
In `internal/session/session.go`, extend the drift constants block (currently `internal/session/session.go:40-43`) and add a ring-size constant:
|
||||
|
||||
```go
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const recentTitlesMax = 10
|
||||
```
|
||||
|
||||
Add nudge fields to the `Controller` struct, immediately after the `judgedClasses` field (`internal/session/session.go:97`):
|
||||
|
||||
```go
|
||||
judgedClasses map[string]ai.Verdict
|
||||
|
||||
nudge ai.Nudger
|
||||
recentTitles []string // in-memory ring of recent distinct titles this session
|
||||
nudgeMessage string // current soft advisory ("" = none)
|
||||
lastNudgedAt time.Time
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `Nudge` field to `DriftView`**
|
||||
|
||||
Replace `DriftView` (`internal/session/session.go:117-121`):
|
||||
|
||||
```go
|
||||
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
||||
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
||||
// inside an allowed app), so it cannot be folded into the status enum.
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nudge string `json:"nudge,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the nudge message into `stateLocked`**
|
||||
|
||||
Replace the drift block in `stateLocked` (`internal/session/session.go:270-276`):
|
||||
|
||||
```go
|
||||
if c.runtimeState == domain.RuntimeActive {
|
||||
status := c.driftStatus
|
||||
if status == "" {
|
||||
status = driftIdle
|
||||
}
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `SetNudge`, `commitmentLineLocked`, and clear nudge state in `resetDriftLocked`**
|
||||
|
||||
Add `SetNudge` next to `SetDriftJudge` (after `internal/session/session.go:348`):
|
||||
|
||||
```go
|
||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||
// local matching and the drift judge are unaffected.
|
||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
||||
c.mu.Lock()
|
||||
c.nudge = n
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||
// prompts, or "" if there is none. Caller holds mu.
|
||||
func (c *Controller) commitmentLineLocked() string {
|
||||
if c.commitment == nil {
|
||||
return ""
|
||||
}
|
||||
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||
}
|
||||
|
||||
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||
func (c *Controller) recentTitlesForTest() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.recentTitles...)
|
||||
}
|
||||
```
|
||||
|
||||
Extend `resetDriftLocked` (`internal/session/session.go:350-357`) to clear nudge state too:
|
||||
|
||||
```go
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Controller) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedClasses = map[string]ai.Verdict{}
|
||||
c.recentTitles = nil
|
||||
c.nudgeMessage = ""
|
||||
c.lastNudgedAt = time.Time{}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Record the recent-titles ring and DRY the commitment line**
|
||||
|
||||
Add the ring helper near `applyEvent` in `internal/session/session.go`:
|
||||
|
||||
```go
|
||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
||||
func (c *Controller) recordTitleLocked(title string) {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
||||
return
|
||||
}
|
||||
c.recentTitles = append(c.recentTitles, t)
|
||||
if len(c.recentTitles) > recentTitlesMax {
|
||||
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `RecordWindow`, record the title right after `c.applyEvent(now, snap)` (`internal/session/session.go:596`):
|
||||
|
||||
```go
|
||||
c.applyEvent(now, snap)
|
||||
c.recordTitleLocked(snap.Title)
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
```
|
||||
|
||||
Apply the DRY refactor in `evaluateDriftLocked`: replace the inline commitment build (`internal/session/session.go:646-649`) with the helper:
|
||||
|
||||
```go
|
||||
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||
c.driftGen++
|
||||
gen := c.driftGen
|
||||
c.lastJudgedAt = now
|
||||
c.driftStatus = driftPending
|
||||
c.driftReason = ""
|
||||
judge := c.judge
|
||||
commitment := c.commitmentLineLocked()
|
||||
```
|
||||
|
||||
(The closure below still references `commitment`, `class`, `title`, `gen`, `prevStatus`, `prevReason` unchanged.)
|
||||
|
||||
- [ ] **Step 8: Launch the nudge from the on-task local-match branch**
|
||||
|
||||
In `evaluateDriftLocked`, replace step 1 (`internal/session/session.go:613-619`) so the on-task branch returns the nudge closure when eligible:
|
||||
|
||||
```go
|
||||
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||
// where the semantic nudge runs — the drift judge stays silent here, so the
|
||||
// two are mutually exclusive per observation.
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if evidence.MatchesAllowed(ac, class, title) {
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return c.maybeNudgeLocked(now)
|
||||
}
|
||||
```
|
||||
|
||||
Add `maybeNudgeLocked` after `evaluateDriftLocked` (before `applyVerdictLocked`):
|
||||
|
||||
```go
|
||||
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
||||
// local-match path. It returns the nudging closure when eligible (judge wired,
|
||||
// at least two titles of history, debounce elapsed), else nil. The closure
|
||||
// follows the drift-judge discipline: it runs in a goroutine after the caller
|
||||
// releases the mutex, re-acquires the lock, guards on driftGen, never fabricates
|
||||
// a concern on error, and notifies only after unlocking. Caller holds mu and has
|
||||
// already verified the runtime is Active.
|
||||
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||
return nil
|
||||
}
|
||||
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
||||
return nil
|
||||
}
|
||||
c.lastNudgedAt = now
|
||||
gen := c.driftGen
|
||||
nudge := c.nudge
|
||||
commitment := c.commitmentLineLocked()
|
||||
titles := append([]string(nil), c.recentTitles...)
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
||||
defer cancel()
|
||||
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||
c.mu.Unlock()
|
||||
return // stale: a new session started or the session ended
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
log.Printf("session: nudge failed: %v", err)
|
||||
return // never fabricate a concern; leave any prior message intact
|
||||
}
|
||||
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles' -v`
|
||||
Expected: PASS for all eight new tests.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite under the race detector**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS (all existing drift tests still green; no data races).
|
||||
|
||||
- [ ] **Step 11: Vet**
|
||||
|
||||
Run: `go vet ./internal/session/`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "Orchestrate semantic nudge on the on-task path
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Assert the nudge surfaces in the SSE state payload
|
||||
|
||||
**Files:**
|
||||
- Test: `internal/web/web_test.go`
|
||||
|
||||
`internal/web/web.go` needs no change — `DriftView.Nudge` serializes automatically. This task adds a web-package test proving the field reaches the wire. Reuse `newTestServer`/`post` (`internal/web/web_test.go:20-38`) and the `stubCoach` pattern (`:105-112`). The `/commitment` route already accepts `allowed_window_classes` (added in M3). `context` is already imported in this test file.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `internal/web/web_test.go`:
|
||||
|
||||
```go
|
||||
type stubNudger struct{ msg string }
|
||||
|
||||
func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
return s.msg, nil
|
||||
}
|
||||
|
||||
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Two distinct on-task titles trip the nudge (history >= 2, first call has no
|
||||
// debounce to wait on).
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) {
|
||||
t.Fatalf("payload missing nudge: %s", js)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails first, then passes**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestActiveStatePayloadCarriesNudge -v`
|
||||
Expected: PASS (the implementation already exists from Task 2; this guards the serialization). If it fails to compile because `stubNudger` is unused elsewhere, that is fine — it is used by this test.
|
||||
|
||||
- [ ] **Step 3: Run the full web suite and vet**
|
||||
|
||||
Run: `go test ./internal/web/ && go vet ./internal/web/`
|
||||
Expected: PASS; no vet output.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/web_test.go
|
||||
git commit -m "Assert semantic nudge reaches the state payload
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Active-view "Heads up" soft tier
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/index.html`
|
||||
|
||||
The active view already renders drift from `state.drift` via `updateActiveDrift(drift)` (`internal/web/static/index.html:80-101`), called both on the active fast-path (`:135-137`) and full render (`:188`). The `#drift` div is the first child of the active view (`:177`). You will add a soft, dismiss-only "Heads up" tier that shows when `drift.nudge` is non-empty and the user is not drifting. Match the existing un-escaped interpolation style used for `drift.reason` (these are short, locally generated strings).
|
||||
|
||||
- [ ] **Step 1: Add the nudge CSS**
|
||||
|
||||
After the existing `.drift-hint` rule (`internal/web/static/index.html:36`), add:
|
||||
|
||||
```css
|
||||
.nudge { background: #1d2630; border: 1px solid #2f4255; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; }
|
||||
.nudge-title { font-weight: 600; color: #8fb6d9; }
|
||||
.nudge-msg { color: #c2d2e0; margin: 4px 0 8px; }
|
||||
.nudge-actions button { padding: 6px 12px; background: #2d3242; color: #cdd2e0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the dismiss state and the nudge tier**
|
||||
|
||||
Add a module-level variable next to `let renderedState = null;` (`internal/web/static/index.html:47`):
|
||||
|
||||
```js
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
```
|
||||
|
||||
Replace `updateActiveDrift` (`internal/web/static/index.html:80-101`) with:
|
||||
|
||||
```js
|
||||
function updateActiveDrift(drift) {
|
||||
const el = document.getElementById('drift');
|
||||
if (!el) return;
|
||||
const status = drift && drift.status;
|
||||
if (status === 'drifting') {
|
||||
el.innerHTML = `<div class="drift">
|
||||
<div class="drift-title">⚠ Possible drift</div>
|
||||
<div class="drift-reason">${drift.reason || ''}</div>
|
||||
<div class="drift-actions">
|
||||
<button id="refocus" type="button">Back to task</button>
|
||||
<button id="ontask" type="button" class="secondary">This is on task</button>
|
||||
<button id="enddrift" type="button" class="secondary">End session</button>
|
||||
</div></div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
return;
|
||||
}
|
||||
const nudge = (drift && drift.nudge) || '';
|
||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
||||
if (nudge && nudge !== dismissedNudge) {
|
||||
el.innerHTML = `<div class="nudge">
|
||||
<div class="nudge-title">Heads up</div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="nudge-actions">
|
||||
<button id="dismissnudge" type="button">Dismiss</button>
|
||||
</div></div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
const e = document.getElementById('drift');
|
||||
if (e) e.innerHTML = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (status === 'pending') {
|
||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the build still serves the page**
|
||||
|
||||
Run: `go build ./... && go vet ./...`
|
||||
Expected: no output (the HTML is embedded; this confirms nothing else broke).
|
||||
|
||||
- [ ] **Step 4: Manual smoke check of the markup (optional but recommended)**
|
||||
|
||||
Run: `grep -n "Heads up" internal/web/static/index.html`
|
||||
Expected: one match inside `updateActiveDrift`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/index.html
|
||||
git commit -m "Add dismiss-only Heads up tier for semantic nudges
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire the nudge into the daemon
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go`
|
||||
|
||||
The single `ai.Service` already satisfies `Coach` and `DriftJudge` and is wired at `cmd/antidriftd/main.go:41-44`. Add the third role.
|
||||
|
||||
- [ ] **Step 1: Add `SetNudge` and update the log line**
|
||||
|
||||
Replace the AI-wiring body (`cmd/antidriftd/main.go:41-44`):
|
||||
|
||||
```go
|
||||
svc := ai.NewService(backend)
|
||||
ctrl.SetCoach(svc)
|
||||
ctrl.SetDriftJudge(svc)
|
||||
ctrl.SetNudge(svc)
|
||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and vet**
|
||||
|
||||
Run: `go build ./cmd/antidriftd && go vet ./cmd/antidriftd`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go
|
||||
git commit -m "Wire the semantic nudge into the daemon
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Document M3.5 in the README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
The Status section currently leads with the M3 paragraph (`README.md:26-32`). Add an M3.5 paragraph above it so the newest milestone is first.
|
||||
|
||||
- [ ] **Step 1: Insert the M3.5 paragraph**
|
||||
|
||||
Immediately after the `## Status` heading (`README.md:24`) and its blank line, insert:
|
||||
|
||||
```markdown
|
||||
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
|
||||
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
|
||||
only while you are on-task in an allowed app — periodically reads your recent
|
||||
window titles and, if the trajectory has wandered from the commitment, shows a
|
||||
soft, dismissible "Heads up" line (no interrupt, no buttons to fight). It is
|
||||
debounced to roughly one check every five minutes, reuses the same CLI backend
|
||||
as the coach and drift judge, and degrades gracefully — without it, everything
|
||||
else still works.
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify placement**
|
||||
|
||||
Run: `grep -n "M3.5\|M3 (drift\|M2 (AI" README.md`
|
||||
Expected: `M3.5` line appears before the `M3 (drift` line, which appears before `M2 (AI`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "Document M3.5 semantic nudge in README
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Review
|
||||
|
||||
After all tasks, dispatch a final whole-diff code review and run the complete suite:
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./... && go test -race ./...
|
||||
```
|
||||
|
||||
Expected: clean build, no vet output, all tests pass under the race detector.
|
||||
|
||||
Confirm end-to-end: with a real AI backend, an Active session whose allowed list includes the focused app, sitting in that app while the window titles wander off-commitment, should — after ~5 min and ≥2 distinct titles — surface a soft "Heads up" line that the user can dismiss, with no hard interrupt and no overlap with the drift banner. Without a backend (`SetNudge(nil)` path), nothing changes.
|
||||
```
|
||||
@@ -1,625 +0,0 @@
|
||||
# M4 — "Look good" Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restyle the AntiDrift web UI into a cockpit-style, state-aware stacked HUD, split CSS/JS out of the inline HTML, and polish the review screen — with zero behavior changes.
|
||||
|
||||
**Architecture:** The UI is a single embedded static bundle served by `internal/web`. Today everything lives inline in `static/index.html`. We split it into `index.html` + `app.css` + `app.js`, add Go routes to serve the two new assets from the existing `embed.FS`, then rework the client markup into reusable "band" rows whose accent color is driven by a `data-state` attribute on `<main>`. All SSE wiring, element ids, and POST endpoints are preserved, so the existing Go endpoint tests keep passing.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gin, `embed.FS`, server-sent events; plain HTML/CSS/vanilla JS (no framework, no build step). Tests: stdlib `testing` + `net/http/httptest`.
|
||||
|
||||
---
|
||||
|
||||
## Reference: shared CSS class vocabulary
|
||||
|
||||
These class/attribute names are used by BOTH `app.css` and `app.js`. Keep them identical across tasks:
|
||||
|
||||
- `<main id="app" data-state="...">` — `data-state` ∈ `locked | planning | active | nudge | drift | review`. Drives `--accent`.
|
||||
- `.band` — a horizontal HUD row (top divider + padding). `.band:first-child` has no top border.
|
||||
- `.statusband` — the top band; shows the state pill + status meta, takes a thicker accent top border.
|
||||
- `.pill` — uppercase state label, colored by `--accent`.
|
||||
- `.status-meta` — dim text beside the pill (e.g. "on task · 7 switches").
|
||||
- `.timer` — large tabular-nums countdown.
|
||||
- `.action` — the next-action line. `.meta` — dim secondary text (e.g. "done when: …").
|
||||
- `.evidence` (band) with children `.now`, `.buckets` (`<ul>`/`<li>`), `.switches`; health spans `.health-ok` / `.health-bad`.
|
||||
- `.summary` (review band) with `.summary-row` children.
|
||||
- Buttons: `.btn`, `.btn-primary`, `.btn-ghost`.
|
||||
- The `data-state` value is derived by `stateKey(state)` (defined in Task 2), which accounts for the client-side `dismissedNudge`.
|
||||
|
||||
Preserved element ids (do not rename): `#app`, `#intent`, `#sharpen`, `#coachStatus`, `#na`, `#sc`, `#mins`, `#apps`, `#start`, `#statusband`, `#t`, `#done`, `#end`, `#plan`, `#refocus`, `#ontask`, `#enddrift`, `#dismissnudge`.
|
||||
|
||||
Preserved endpoints: `GET /events`; `POST /planning /coach /commitment /complete /end /refocus /ontask`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Split assets out of the inline HTML and serve them
|
||||
|
||||
Mechanical, behavior-preserving: move the inline `<style>`/`<script>` into `app.css`/`app.js`, link them, and add Go routes so they are served from the embedded FS. No visual or logic change yet.
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/web/static/app.css`
|
||||
- Create: `internal/web/static/app.js`
|
||||
- Modify: `internal/web/static/index.html` (replace whole file)
|
||||
- Modify: `internal/web/web.go:39-56` (add two asset routes)
|
||||
- Test: `internal/web/web_test.go` (add one test func)
|
||||
|
||||
- [ ] **Step 1: Write the failing test for asset serving**
|
||||
|
||||
Add to `internal/web/web_test.go`:
|
||||
|
||||
```go
|
||||
func TestServesStaticAssets(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ctSubstr string
|
||||
}{
|
||||
{"/app.css", "css"},
|
||||
{"/app.js", "javascript"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET %s code = %d, want 200", tc.path, w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) {
|
||||
t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr)
|
||||
}
|
||||
if w.Body.Len() == 0 {
|
||||
t.Errorf("GET %s body is empty", tc.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestServesStaticAssets -v`
|
||||
Expected: FAIL — routes return 404 (and the embedded files don't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create `app.css` by moving the inline styles verbatim**
|
||||
|
||||
Cut the entire current contents of the `<style>…</style>` block in `internal/web/static/index.html` (everything between the tags, currently lines 8–41) and paste it unchanged into the new file `internal/web/static/app.css`. Do not edit the rules in this task — this is a pure move. (They are replaced wholesale in Task 2.)
|
||||
|
||||
- [ ] **Step 4: Create `app.js` by moving the inline script verbatim**
|
||||
|
||||
Cut the entire current contents of the `<script>…</script>` block in `internal/web/static/index.html` (everything between the tags, currently lines 49–227) and paste it unchanged into the new file `internal/web/static/app.js`.
|
||||
|
||||
- [ ] **Step 5: Replace `index.html` with the markup shell**
|
||||
|
||||
Replace the whole file `internal/web/static/index.html` with:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<h1>AntiDrift</h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Note: the script must reference `view` and (from Task 2 on) `app`; both ids exist here. The script tag is at the end of `<body>`, so the DOM is parsed before it runs (matches the current inline-at-end-of-body behavior).
|
||||
|
||||
- [ ] **Step 6: Add asset routes in `web.go`**
|
||||
|
||||
In `internal/web/web.go`, the `Router()` method currently has (around lines 43–46):
|
||||
|
||||
```go
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.FileFromFS("/", http.FS(sub))
|
||||
})
|
||||
```
|
||||
|
||||
Add the two asset routes immediately after the `r.GET("/", …)` block:
|
||||
|
||||
```go
|
||||
r.GET("/app.css", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.css", http.FS(sub))
|
||||
})
|
||||
r.GET("/app.js", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.js", http.FS(sub))
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the asset test and the full web suite**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS — `TestServesStaticAssets` passes and all pre-existing web tests still pass (they assert on endpoints/JSON, not markup).
|
||||
|
||||
- [ ] **Step 8: Verify vet and the whole module**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: clean, all green.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/index.html internal/web/static/app.css internal/web/static/app.js internal/web/web.go internal/web/web_test.go
|
||||
git commit -m "M4: split web UI assets into app.css and app.js
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Cockpit visual system + locked/planning/active/drift/nudge
|
||||
|
||||
Replace `app.css` with the new design system and rewrite `app.js`'s rendering into stacked bands with a `data-state`-driven accent. Review stays a simple functional band here; Task 3 turns it into the recap. No Go changes — the existing endpoint tests are the regression guard.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.css` (replace whole file)
|
||||
- Modify: `internal/web/static/app.js` (replace whole file)
|
||||
- Test: existing `internal/web/web_test.go` (regression only — no new test)
|
||||
|
||||
- [ ] **Step 1: Replace `app.css` with the cockpit design system**
|
||||
|
||||
Replace the whole file `internal/web/static/app.css` with:
|
||||
|
||||
```css
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d0f14;
|
||||
--panel: #161922;
|
||||
--line: #232733;
|
||||
--ink: #e6e8ee;
|
||||
--ink-dim: #8b91a6;
|
||||
--ok: #34d399;
|
||||
--warn: #f0b429;
|
||||
--danger: #f06070;
|
||||
--accent: #6b7280; /* default / locked */
|
||||
}
|
||||
|
||||
/* The single state-driven knob: only --accent changes per state. */
|
||||
[data-state="planning"] { --accent: #4c6ef5; }
|
||||
[data-state="active"] { --accent: #34d399; }
|
||||
[data-state="nudge"] { --accent: #f0b429; }
|
||||
[data-state="drift"] { --accent: #f06070; }
|
||||
[data-state="review"] { --accent: #a78bfa; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
main { max-width: 560px; margin: 7vh auto; padding: 0 20px; }
|
||||
|
||||
h1 {
|
||||
font-size: 12px; letter-spacing: .28em; text-transform: uppercase;
|
||||
color: var(--ink-dim); margin: 0 0 14px 6px;
|
||||
}
|
||||
|
||||
/* The HUD card: a stack of bands. */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.band { border-top: 1px solid var(--line); padding: 16px 20px; }
|
||||
.band:first-child { border-top: 0; }
|
||||
|
||||
.statusband {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
border-top: 3px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--panel));
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
|
||||
font-weight: 700; color: var(--accent);
|
||||
}
|
||||
.status-meta { font-size: 13px; color: var(--ink-dim); }
|
||||
|
||||
.timer {
|
||||
font-size: 52px; font-weight: 700; line-height: 1;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink);
|
||||
}
|
||||
|
||||
.action { font-size: 19px; font-weight: 600; }
|
||||
.meta { color: var(--ink-dim); margin: 4px 0 0; }
|
||||
|
||||
label { display: block; font-size: 12px; color: var(--ink-dim); margin: 14px 0 5px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 8px; color: var(--ink); font: inherit;
|
||||
}
|
||||
input:focus { outline: 0; border-color: var(--accent); }
|
||||
|
||||
.btn {
|
||||
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
|
||||
font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #0d0f14; }
|
||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
||||
|
||||
/* Drift/nudge actions live in the status band; lay them out below the text. */
|
||||
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
||||
|
||||
.drift-reason, .nudge-msg { color: var(--ink); margin: 4px 0 0; font-weight: 400; }
|
||||
.statusband.col { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
|
||||
/* Evidence band */
|
||||
.evidence .now { font-size: 13px; color: var(--ink); }
|
||||
.health-ok { color: var(--ok); }
|
||||
.health-bad { color: var(--warn); }
|
||||
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: var(--ink-dim); }
|
||||
.buckets li {
|
||||
display: flex; justify-content: space-between; padding: 2px 0;
|
||||
font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; }
|
||||
|
||||
/* Review summary band (built out in Task 3) */
|
||||
.summary { font-size: 14px; }
|
||||
.summary-row {
|
||||
display: flex; justify-content: space-between; padding: 3px 0;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink-dim);
|
||||
}
|
||||
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `app.js` with the band-based renderer**
|
||||
|
||||
Replace the whole file `internal/web/static/app.js` with:
|
||||
|
||||
```js
|
||||
const app = document.getElementById('app');
|
||||
const view = document.getElementById('view');
|
||||
let countdownTimer = null;
|
||||
let renderedState = null; // which runtime_state the DOM currently shows
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(secs) {
|
||||
secs = Math.max(0, Math.floor(secs));
|
||||
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
||||
const s = String(secs % 60).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
// stateKey maps server state to the data-state accent bucket. Drift outranks
|
||||
// a nudge, and a nudge the user already dismissed reverts to plain active.
|
||||
function stateKey(state) {
|
||||
const rs = state.runtime_state;
|
||||
if (rs !== 'active') return rs || 'locked';
|
||||
const d = state.drift || {};
|
||||
if (d.status === 'drifting') return 'drift';
|
||||
if (d.nudge && d.nudge !== dismissedNudge) return 'nudge';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function setStateAttr(state) {
|
||||
app.dataset.state = stateKey(state);
|
||||
}
|
||||
|
||||
function evidenceBlock(ev) {
|
||||
if (!ev) return '';
|
||||
const health = ev.available
|
||||
? `<span class="health-ok">tracking</span>`
|
||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||
const now = ev.current && (ev.current.class || ev.current.title)
|
||||
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
||||
const rows = (ev.buckets || []).map(b =>
|
||||
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
||||
return `<div class="band evidence">
|
||||
<div class="now">now ${now} ${health}</div>
|
||||
<ul class="buckets">${rows}</ul>
|
||||
<div class="switches">context switches: ${ev.switch_count || 0}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// updateActiveDrift owns the active status band: it renders on-task, nudge,
|
||||
// drift, or pending content into #statusband and rebinds the buttons.
|
||||
function updateActiveDrift(state) {
|
||||
const el = document.getElementById('statusband');
|
||||
if (!el) return;
|
||||
setStateAttr(state);
|
||||
const drift = state.drift || {};
|
||||
const switches = (state.evidence && state.evidence.switch_count) || 0;
|
||||
|
||||
if (drift.status === 'drifting') {
|
||||
el.className = 'band statusband col';
|
||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
||||
<div class="band-actions">
|
||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const nudge = drift.nudge || '';
|
||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
||||
if (nudge && nudge !== dismissedNudge) {
|
||||
el.className = 'band statusband col';
|
||||
el.innerHTML = `<div><span class="pill">Heads up</span></div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="band-actions">
|
||||
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
|
||||
</div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
setStateAttr(state);
|
||||
updateActiveDrift(state);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (drift.status === 'pending') {
|
||||
el.className = 'band statusband';
|
||||
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.className = 'band statusband';
|
||||
el.innerHTML = `<span class="pill">Active</span>
|
||||
<span class="status-meta">on task · ${switches} switches</span>`;
|
||||
}
|
||||
|
||||
function updatePlanningCoach(coach) {
|
||||
const statusEl = document.getElementById('coachStatus');
|
||||
if (!statusEl) return;
|
||||
if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
|
||||
if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
|
||||
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
|
||||
if (coach.status === 'ready' && coach.proposal) {
|
||||
statusEl.textContent = 'AI suggestion applied — edit freely.';
|
||||
const stamp = JSON.stringify(coach.proposal);
|
||||
if (statusEl.dataset.applied === stamp) return;
|
||||
statusEl.dataset.applied = stamp;
|
||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
|
||||
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
||||
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
||||
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
||||
const apps = document.getElementById('apps');
|
||||
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
|
||||
apps.value = coach.proposal.allowed_window_classes.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
setStateAttr(state);
|
||||
const rs = state.runtime_state;
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
return;
|
||||
}
|
||||
if (rs === 'active' && renderedState === 'active') {
|
||||
updateActiveDrift(state);
|
||||
return;
|
||||
}
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
renderedState = rs;
|
||||
|
||||
if (rs === 'locked') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
|
||||
<div class="band">
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
||||
</div>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||
<label>Allowed apps (comma-separated window classes)</label>
|
||||
<input id="apps" placeholder="e.g. code, firefox">
|
||||
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
||||
</div>`;
|
||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||
[na, sc, mins].forEach(el => el.oninput = check);
|
||||
start.onclick = () => post('/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean),
|
||||
});
|
||||
document.getElementById('sharpen').onclick = () => {
|
||||
const intent = document.getElementById('intent').value.trim();
|
||||
if (intent) post('/coach', { intent });
|
||||
};
|
||||
updatePlanningCoach(state.coach);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<div class="band statusband" id="statusband"></div>
|
||||
<div class="band"><div class="timer" id="t">--:--</div></div>
|
||||
<div class="band">
|
||||
<div class="action">${c.next_action || ''}</div>
|
||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
||||
document.getElementById('done').onclick = () => post('/complete');
|
||||
const t = document.getElementById('t');
|
||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 250);
|
||||
updateActiveDrift(state);
|
||||
|
||||
} else if (rs === 'review') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
document.getElementById('end').onclick = () => post('/end');
|
||||
|
||||
} else {
|
||||
view.textContent = rs;
|
||||
}
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS — all endpoint/asset tests still green (markup changed, contracts did not).
|
||||
|
||||
- [ ] **Step 4: Manual visual check**
|
||||
|
||||
Run the daemon and click through states:
|
||||
|
||||
```bash
|
||||
go run ./cmd/antidriftd
|
||||
```
|
||||
|
||||
Verify in the browser (http://localhost:7777):
|
||||
- Locked: gray accent, "Start planning".
|
||||
- Planning: blue accent, all fields, Sharpen + Start.
|
||||
- Active on-task: green accent, "Active · on task · N switches", big timer, evidence band.
|
||||
- Drift (force by setting allowed apps then switching to an off-list window): red accent, reason + three buttons.
|
||||
- Nudge (semantic nudge within an allowed app): amber accent, message + Dismiss; after Dismiss the accent returns to green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.css internal/web/static/app.js
|
||||
git commit -m "M4: cockpit HUD with state-driven accent
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Polish the review screen into a session recap
|
||||
|
||||
Turn the bare review state into a presentational summary built from data the state already carries (`commitment` + `evidence`). No new backend data.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js` (add `reviewSummary` helper; swap the review branch's markup)
|
||||
- Modify: `internal/web/static/app.css` (`.summary` styles already added in Task 2 — verify, no change expected)
|
||||
- Test: existing `internal/web/web_test.go` (regression only)
|
||||
|
||||
- [ ] **Step 1: Add the `reviewSummary` helper to `app.js`**
|
||||
|
||||
Insert this function immediately after `evidenceBlock` in `internal/web/static/app.js`:
|
||||
|
||||
```js
|
||||
// reviewSummary renders a presentational recap from already-available state:
|
||||
// the commitment plus the per-window evidence buckets. No new backend data.
|
||||
function reviewSummary(ev) {
|
||||
if (!ev) return '';
|
||||
const rows = (ev.buckets || []).map(b =>
|
||||
`<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
|
||||
const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
|
||||
return `<div class="band summary">${switches}${rows}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap the review branch markup to use the recap**
|
||||
|
||||
In `internal/web/static/app.js`, find the review branch (the `else if (rs === 'review')` block) and replace its `view.innerHTML = …` assignment. Change FROM:
|
||||
|
||||
```js
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
```
|
||||
|
||||
TO:
|
||||
|
||||
```js
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
||||
</div>
|
||||
${reviewSummary(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
```
|
||||
|
||||
(The verbose live `evidenceBlock` is replaced by the calmer `reviewSummary` recap; the "now / tracking" line belongs to the active HUD, not the post-session summary.)
|
||||
|
||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual visual check of review**
|
||||
|
||||
Start a short commitment (1 minute) with allowed apps, switch between a couple of windows, let it expire (or click Complete), and confirm the review screen shows: violet accent, "Session ended", the action + success condition, then a summary band listing context switches and the per-window time buckets, then End.
|
||||
|
||||
- [ ] **Step 5: Final module verification**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: clean, all green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css
|
||||
git commit -m "M4: presentational review recap
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After all tasks
|
||||
|
||||
Dispatch a final code reviewer over the three commits, then use **superpowers:finishing-a-development-branch** to close out M4.
|
||||
|
||||
Done when: the UI renders the cockpit HUD across all six `data-state` values, CSS/JS are served as separate embedded assets, the review screen shows a recap, and `go vet ./... && go test -race ./...` is clean.
|
||||
@@ -1,794 +0,0 @@
|
||||
# M5 — Tasks Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `tasks.Provider` port with an Amazing Marvin adapter (`am --json`) that lists today's open tasks; surface them on the planning screen as clickable chips that seed the intent field.
|
||||
|
||||
**Architecture:** A new leaf package `internal/tasks` (interface + primitives only, like `ai`) with a CLI adapter that shells out to ampy's `am --json`. The `session.Controller` fetches the list asynchronously on entering planning — mirroring the existing planning coach (generation counter, status enum, graceful degradation) — and projects it into the broadcast `State`. The browser renders the list during planning; clicking a task fills `#intent`, which flows into the unchanged coach pipeline. Read-only; no writeback; no new endpoints.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib `os/exec` + `encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`.
|
||||
|
||||
**Reference:** Design spec at `docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md`. The patterns being mirrored: the `ai` port (`internal/ai/coach.go`, `internal/ai/backend.go`) and the planning coach in `internal/session/session.go` (`RequestCoach`, `resetCoachLocked`, `CoachView`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create `internal/tasks/tasks.go`** — the `Provider` interface and the `Task` value type. Leaf package; imports only `context`.
|
||||
- **Create `internal/tasks/marvin.go`** — the `Marvin` adapter (shells out to `am --json`), an injectable `runner`, and the pure `parse` function.
|
||||
- **Create `internal/tasks/marvin_test.go`** — `parse` table tests, command-splitting test, and adapter tests with a fake runner.
|
||||
- **Modify `internal/session/session.go`** — add tasks fields, `TaskView`/`TasksView`, `State.Tasks`, `SetTasks`, `startTasksFetchLocked`, the planning-only projection, and the `EnterPlanning` hook.
|
||||
- **Modify `internal/session/session_test.go`** — `fakeProvider`, `waitTasksStatus`, and four controller tests.
|
||||
- **Modify `cmd/antidriftd/main.go`** — construct the Marvin adapter and call `ctrl.SetTasks`.
|
||||
- **Modify `internal/web/web_test.go`** — `stubProvider` and a planning-payload-carries-tasks test.
|
||||
- **Modify `internal/web/static/app.js`** — `updatePlanningTasks`, a tasks band in the planning render, and calls from both render paths.
|
||||
- **Modify `internal/web/static/app.css`** — `.tasklist` / `.task-chip` styling.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `tasks` package (port + Marvin adapter)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/tasks/tasks.go`
|
||||
- Create: `internal/tasks/marvin.go`
|
||||
- Test: `internal/tasks/marvin_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the port interface and value type**
|
||||
|
||||
Create `internal/tasks/tasks.go`:
|
||||
|
||||
```go
|
||||
// Package tasks is the Tasks port: it answers "what should I be doing?" by
|
||||
// listing the open to-do items due today or earlier. It imports nothing from
|
||||
// the rest of the app, so it stays a leaf package.
|
||||
package tasks
|
||||
|
||||
import "context"
|
||||
|
||||
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
Day string // "YYYY-MM-DD", or "" if unscheduled
|
||||
}
|
||||
|
||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
||||
// earlier.
|
||||
type Provider interface {
|
||||
Today(ctx context.Context) ([]Task, error)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing adapter tests**
|
||||
|
||||
Create `internal/tasks/marvin_test.go`:
|
||||
|
||||
```go
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []Task
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid array",
|
||||
in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`,
|
||||
want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}},
|
||||
},
|
||||
{name: "empty array", in: `[]`, want: []Task{}},
|
||||
{name: "json null", in: `null`, want: []Task{}},
|
||||
{
|
||||
name: "drops done and empty-title",
|
||||
in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`,
|
||||
want: []Task{{ID: "a", Title: "keep", Day: ""}},
|
||||
},
|
||||
{name: "malformed", in: `not json`, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parse([]byte(tc.in))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMarvinCommandSplitting(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{"", "am", []string{"--json"}},
|
||||
{"am", "am", []string{"--json"}},
|
||||
{"uv run am", "uv", []string{"run", "am", "--json"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := NewMarvin(tc.in)
|
||||
if m.cmd != tc.wantCmd {
|
||||
t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd)
|
||||
}
|
||||
if len(m.args) != len(tc.wantArgs) {
|
||||
t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs)
|
||||
}
|
||||
for i := range m.args {
|
||||
if m.args[i] != tc.wantArgs[i] {
|
||||
t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayUsesRunnerOutput(t *testing.T) {
|
||||
m := NewMarvin("am")
|
||||
var gotName string
|
||||
var gotArgs []string
|
||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
gotName, gotArgs = name, args
|
||||
return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil
|
||||
}
|
||||
got, err := m.Today(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Today: %v", err)
|
||||
}
|
||||
if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" {
|
||||
t.Fatalf("runner called with %q %v", gotName, gotArgs)
|
||||
}
|
||||
if len(got) != 1 || got[0].Title != "Do thing" {
|
||||
t.Fatalf("Today result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPropagatesError(t *testing.T) {
|
||||
m := NewMarvin("am")
|
||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
return nil, errors.New("am not found")
|
||||
}
|
||||
if _, err := m.Today(context.Background()); err == nil {
|
||||
t.Fatal("want error from failing runner")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/tasks/ -v`
|
||||
Expected: FAIL — `undefined: NewMarvin`, `undefined: parse` (build error).
|
||||
|
||||
- [ ] **Step 4: Write the adapter implementation**
|
||||
|
||||
Create `internal/tasks/marvin.go`:
|
||||
|
||||
```go
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runner executes a command and returns its stdout. Production shells out;
|
||||
// tests inject a fake to avoid spawning a process.
|
||||
type runner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
if s := strings.TrimSpace(errb.String()); s != "" {
|
||||
return nil, fmt.Errorf("%s: %w: %s", name, err, s)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI
|
||||
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
|
||||
type Marvin struct {
|
||||
cmd string
|
||||
args []string
|
||||
run runner
|
||||
}
|
||||
|
||||
// NewMarvin builds the adapter. command is split on spaces so both "am" and
|
||||
// "uv run am" work; empty defaults to "am". The "--json" flag is always
|
||||
// appended, and no subcommand is passed (ampy lists today's tasks by default).
|
||||
func NewMarvin(command string) *Marvin {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"am"}
|
||||
}
|
||||
args := append([]string{}, fields[1:]...)
|
||||
args = append(args, "--json")
|
||||
return &Marvin{cmd: fields[0], args: args, run: execRunner}
|
||||
}
|
||||
|
||||
// Today returns the open tasks due today or earlier, or an error if the CLI
|
||||
// fails. Callers degrade gracefully on error.
|
||||
func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
|
||||
out, err := m.run(ctx, m.cmd, m.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parse(out)
|
||||
}
|
||||
|
||||
type rawTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Day string `json:"day"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// parse maps `am --json` output (a JSON array of task objects) to []Task. It
|
||||
// drops tasks that are done or have an empty title. A JSON null or empty array
|
||||
// yields an empty slice, not an error.
|
||||
func parse(data []byte) ([]Task, error) {
|
||||
var raw []rawTask
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("tasks: parse: %w", err)
|
||||
}
|
||||
out := make([]Task, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
title := strings.TrimSpace(r.Title)
|
||||
if r.Done || title == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Task{ID: r.ID, Title: title, Day: r.Day})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/tasks/ -v`
|
||||
Expected: PASS — `TestParse`, `TestNewMarvinCommandSplitting`, `TestTodayUsesRunnerOutput`, `TestTodayPropagatesError`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/tasks/tasks.go internal/tasks/marvin.go internal/tasks/marvin_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Add tasks port and Amazing Marvin adapter
|
||||
|
||||
The tasks.Provider port answers "what should I be doing?". The Marvin
|
||||
adapter shells out to ampy's `am --json` and parses today's open tasks,
|
||||
dropping done/empty-title entries. Leaf package mirroring ai.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Controller wiring (async fetch + projection)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go`
|
||||
- Test: `internal/session/session_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Append to `internal/session/session_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
||||
|
||||
```go
|
||||
type fakeProvider struct {
|
||||
list []tasks.Task
|
||||
err error
|
||||
gate chan struct{} // if non-nil, Today blocks until it receives
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.list, f.err
|
||||
}
|
||||
|
||||
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
||||
func waitTasksStatus(t *testing.T, c *Controller, want string) State {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Tasks != nil && st.Tasks.Status == want {
|
||||
return st
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks)
|
||||
return State{}
|
||||
}
|
||||
|
||||
func TestEnterPlanningFetchesTasks(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}})
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
st := waitTasksStatus(t, c, "ready")
|
||||
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" {
|
||||
t.Fatalf("tasks list wrong: %+v", st.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksFetchError(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{err: errors.New("am down")})
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
st := waitTasksStatus(t, c, "error")
|
||||
if len(st.Tasks.Tasks) != 0 {
|
||||
t.Fatalf("error state should carry no tasks: %+v", st.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProviderNoTasksView(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
if c.State().Tasks != nil {
|
||||
t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksViewAbsentOutsidePlanning(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}})
|
||||
if c.State().Tasks != nil {
|
||||
t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
||||
Expected: FAIL — `c.SetTasks undefined` and `st.Tasks undefined` (build error).
|
||||
|
||||
- [ ] **Step 3: Add the import and constants**
|
||||
|
||||
In `internal/session/session.go`, add `"antidrift/internal/tasks"` to the import block (it already imports `"antidrift/internal/ai"`).
|
||||
|
||||
Immediately after the existing coach constants block (the `const ( coachIdle = "idle" … )` group near the top of the file), add:
|
||||
|
||||
```go
|
||||
const tasksTimeout = 30 * time.Second
|
||||
|
||||
const (
|
||||
tasksIdle = "idle"
|
||||
tasksPending = "pending"
|
||||
tasksReady = "ready"
|
||||
tasksError = "error"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the controller fields**
|
||||
|
||||
In the `Controller` struct, immediately after the coach fields (`coachGen int`), add:
|
||||
|
||||
```go
|
||||
tasksProvider tasks.Provider
|
||||
tasksStatus string
|
||||
tasksList []tasks.Task
|
||||
tasksGen int
|
||||
```
|
||||
|
||||
(Field is named `tasksProvider`, not `tasks`, so it does not shadow the `tasks` package.)
|
||||
|
||||
- [ ] **Step 5: Add the view types**
|
||||
|
||||
Immediately after the `CoachView` struct definition, add:
|
||||
|
||||
```go
|
||||
// TaskView is one to-do item in the planning tasks list.
|
||||
type TaskView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Day string `json:"day,omitempty"`
|
||||
}
|
||||
|
||||
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
|
||||
type TasksView struct {
|
||||
Status string `json:"status"`
|
||||
Tasks []TaskView `json:"tasks,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
In the `State` struct, add a field immediately after `Coach *CoachView … `:
|
||||
|
||||
```go
|
||||
Tasks *TasksView `json:"tasks,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the planning-only projection**
|
||||
|
||||
In `stateLocked`, inside the existing `if c.runtimeState == domain.RuntimePlanning {` block, immediately after `st.Coach = cv`, add:
|
||||
|
||||
```go
|
||||
if c.tasksProvider != nil {
|
||||
tstatus := c.tasksStatus
|
||||
if tstatus == "" {
|
||||
tstatus = tasksIdle
|
||||
}
|
||||
tv := &TasksView{Status: tstatus}
|
||||
for _, t := range c.tasksList {
|
||||
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
|
||||
}
|
||||
st.Tasks = tv
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add SetTasks and the async fetch**
|
||||
|
||||
Immediately after `resetCoachLocked` (so the tasks helpers sit next to the coach helpers), add:
|
||||
|
||||
```go
|
||||
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
||||
// the planning tasks list absent.
|
||||
func (c *Controller) SetTasks(p tasks.Provider) {
|
||||
c.mu.Lock()
|
||||
c.tasksProvider = p
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
|
||||
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
||||
// post-planning results, and notifies on completion. Caller holds mu.
|
||||
func (c *Controller) startTasksFetchLocked() {
|
||||
c.tasksList = nil
|
||||
if c.tasksProvider == nil {
|
||||
c.tasksStatus = tasksIdle
|
||||
return
|
||||
}
|
||||
c.tasksGen++
|
||||
gen := c.tasksGen
|
||||
c.tasksStatus = tasksPending
|
||||
provider := c.tasksProvider
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
|
||||
defer cancel()
|
||||
list, err := provider.Today(ctx)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
|
||||
c.mu.Unlock()
|
||||
return // stale or left planning: discard
|
||||
}
|
||||
if err != nil {
|
||||
c.tasksStatus = tasksError
|
||||
c.tasksList = nil
|
||||
} else {
|
||||
c.tasksStatus = tasksReady
|
||||
c.tasksList = list
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
(`context` is already imported in `session.go`.)
|
||||
|
||||
- [ ] **Step 8: Hook the fetch into EnterPlanning**
|
||||
|
||||
In `EnterPlanning`, add `c.startTasksFetchLocked()` immediately after the existing `c.resetCoachLocked()` line:
|
||||
|
||||
```go
|
||||
c.runtimeState = next
|
||||
c.resetCoachLocked()
|
||||
c.startTasksFetchLocked()
|
||||
return c.persistLocked()
|
||||
```
|
||||
|
||||
(Launching the goroutine while holding `mu` is safe: its first action is the `Today()` call, so it only blocks on the lock after `EnterPlanning` has returned and released it.)
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
||||
Expected: PASS — `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite (no regressions, race-clean)**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS (existing coach/drift/nudge tests unaffected — `EnterPlanning` with no provider just sets the idle status).
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Fetch today's tasks asynchronously on entering planning
|
||||
|
||||
The controller fetches the Marvin task list when entering planning,
|
||||
mirroring the planning coach: generation-guarded goroutine, status enum
|
||||
(idle/pending/ready/error), projected into State only while planning and
|
||||
only when a provider is set. nil provider degrades to no tasks view.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Daemon wiring + web payload test
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go`
|
||||
- Test: `internal/web/web_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing web test**
|
||||
|
||||
Append to `internal/web/web_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
||||
|
||||
```go
|
||||
type stubProvider struct {
|
||||
list []tasks.Task
|
||||
}
|
||||
|
||||
func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
||||
return s.list, nil
|
||||
}
|
||||
|
||||
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"tasks"`) {
|
||||
t.Fatalf("planning payload missing tasks object: %s", js)
|
||||
}
|
||||
if !strings.Contains(js, `"title":"Write the spec"`) {
|
||||
t.Fatalf("planning payload missing task title: %s", js)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
||||
Expected: FAIL — `undefined: tasks` / `s.ctrl.SetTasks undefined` until the import resolves and the controller exposes the method (the method exists from Task 2, so the only failure here would be a missing import; if Task 2 is complete this compiles and the assertion drives correctness).
|
||||
|
||||
- [ ] **Step 3: Wire the adapter into the daemon**
|
||||
|
||||
In `cmd/antidriftd/main.go`, add `"antidrift/internal/tasks"` to the import block. Then, immediately after the AI-wiring block (after the `if backend, err := ai.NewBackend(...) { … } else { … }` block closes), add:
|
||||
|
||||
```go
|
||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
||||
// overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute
|
||||
// path). A missing or failing CLI degrades to no tasks panel at fetch time —
|
||||
// planning still works.
|
||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
||||
log.Printf("tasks: marvin adapter (am)")
|
||||
```
|
||||
|
||||
(`os` and `log` are already imported in `main.go`.)
|
||||
|
||||
- [ ] **Step 4: Run the web test to verify it passes**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify the daemon builds**
|
||||
|
||||
Run: `go build ./cmd/antidriftd/`
|
||||
Expected: builds with no output.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go internal/web/web_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Wire Marvin tasks adapter into the daemon
|
||||
|
||||
main constructs the Marvin adapter (command overridable via
|
||||
ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the
|
||||
planning state payload carries today's tasks.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Planning UI — today's tasks as seed chips
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js`
|
||||
- Modify: `internal/web/static/app.css`
|
||||
|
||||
This task is presentational; the data path is already covered by the Task 3 web test. There is no JS test harness in this project (consistent with M4), so verify by build/test plus the manual checklist below.
|
||||
|
||||
- [ ] **Step 1: Add the `updatePlanningTasks` function**
|
||||
|
||||
In `internal/web/static/app.js`, immediately after the `updatePlanningCoach` function definition (before `function render(state)`), add:
|
||||
|
||||
```js
|
||||
// updatePlanningTasks renders today's Marvin tasks as clickable seed chips.
|
||||
// Clicking a chip fills the intent field; the coach pipeline is unchanged.
|
||||
// idle/error/empty render nothing; pending shows a quiet loading line.
|
||||
function updatePlanningTasks(tasks) {
|
||||
const el = document.getElementById('tasksBand');
|
||||
if (!el) return;
|
||||
if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; }
|
||||
if (tasks.status === 'pending') { el.innerHTML = `<div class="meta">loading tasks…</div>`; return; }
|
||||
const list = tasks.tasks || [];
|
||||
if (!list.length) { el.innerHTML = ''; return; }
|
||||
const chips = list.map((t, i) =>
|
||||
`<button type="button" class="task-chip" data-i="${i}">${t.title}</button>`).join('');
|
||||
el.innerHTML = `<label>Today</label><div class="tasklist">${chips}</div>`;
|
||||
el.querySelectorAll('.task-chip').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const intent = document.getElementById('intent');
|
||||
if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); }
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
(Task titles are interpolated into `innerHTML` exactly as the existing coach/evidence rendering does. The titles originate from the user's own Marvin database; this carries the same pre-existing self-XSS posture as the rest of the UI and introduces no new untrusted source — out of scope to change here.)
|
||||
|
||||
- [ ] **Step 2: Add the tasks band to the planning render**
|
||||
|
||||
In the planning branch of `render` (`} else if (rs === 'planning') {`), add a tasks band placeholder between the intent band and the "Next action" band. Change:
|
||||
|
||||
```js
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band" id="tasksBand"></div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call `updatePlanningTasks` from both render paths**
|
||||
|
||||
In the planning partial-update early-return at the top of `render`, change:
|
||||
|
||||
```js
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
And at the end of the full planning render branch, change the trailing:
|
||||
|
||||
```js
|
||||
updatePlanningCoach(state.coach);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the chip styling**
|
||||
|
||||
Append to `internal/web/static/app.css`:
|
||||
|
||||
```css
|
||||
/* Planning: today's tasks as clickable seed chips */
|
||||
.tasklist { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.task-chip {
|
||||
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
|
||||
background: var(--bg); color: var(--ink); font: inherit; font-size: 13px;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.task-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify assets still serve and the full suite is race-clean**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: PASS across all packages (the embedded-asset and planning-payload tests cover the wiring; JS/CSS are presentational).
|
||||
|
||||
- [ ] **Step 6: Manual visual check (by eye)**
|
||||
|
||||
Build and run the daemon (`go run ./cmd/antidriftd/`) with `am` available, click **Start planning**, and confirm: a "Today" band shows task chips; clicking a chip fills the intent field; **Sharpen** then drives the coach as before. With `am` absent, the band stays empty and planning works normally.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Show today's tasks as seed chips on the planning screen
|
||||
|
||||
The planning view renders Marvin's today list as clickable chips;
|
||||
clicking one fills the intent field, feeding the existing coach flow.
|
||||
idle/error/empty render nothing, pending shows a quiet loading line.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
After all tasks:
|
||||
|
||||
- [ ] `go vet ./... && go test -race ./...` is clean.
|
||||
- [ ] `internal/tasks` imports only `context`, `encoding/json`, and stdlib `os/exec`/`bytes`/`fmt`/`strings` — nothing from `domain`, `session`, `evidence`, or `web` (leaf package preserved).
|
||||
- [ ] No new POST routes; the seed click is client-only.
|
||||
- [ ] No writeback to Marvin (read-only, per spec §7).
|
||||
@@ -1,458 +0,0 @@
|
||||
# Faithful Reflection (on/off-task split) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the session-end reflection block report how much time was on-task vs off-task, so the AI reviewer stops writing charitable recaps.
|
||||
|
||||
**Architecture:** Each window segment is classified at the moment it is credited, reading the live `driftStatus` that is already correct at that instant (in `RecordWindow`, `applyEvent` credits the just-ended segment *before* `evaluateDriftLocked` reclassifies for the new window). The split is accumulated in two new per-bucket maps plus one scalar on the in-memory `EvidenceStats`; the existing `Buckets` total map is untouched. `buildReflectionFinishedLocked` then renders on/off/unclassified totals plus a top-N on-task list and a top-N off-task list.
|
||||
|
||||
**Tech Stack:** Go, standard library only. Tests use the existing `testing` package with the package-local `fakeClock`, `fakeJudge`, `snap`/`obs`, `startActive`, and `waitDriftStatus` helpers in `internal/session/session_test.go`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-01-faithful-reflection-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `internal/session/stats.go` — `EvidenceStats` gains the split fields; new `creditLocked` helper centralizes crediting; `applyEvent` routes through it. Allocation of the new maps added to both `replayStats` branches.
|
||||
- `internal/session/session.go` — allocate the split maps in `StartManualCommitment`; route the end-of-session flush in `enterReview` through `creditLocked`.
|
||||
- `internal/session/roles.go` — `buildReflectionFinishedLocked` renders totals + split top lists; small `writeBucketList` / `sumDurations` helpers added alongside it.
|
||||
- `internal/session/session_test.go` — three tests: `creditLocked` unit mapping, rendering (incl. empty-list omission), and end-to-end credit-time wiring.
|
||||
|
||||
Three tasks, each self-contained and committed independently:
|
||||
|
||||
1. **Split accounting** — fields, allocation, `creditLocked`, route both credit sites. (Driven by the `creditLocked` unit test.)
|
||||
2. **Reflection rendering** — rewrite `buildReflectionFinishedLocked`. (Driven by a rendering unit test, including empty-off-task omission.)
|
||||
3. **End-to-end faithfulness** — a test-only task proving the live drift status reaches the right bucket through a real `RecordWindow` sequence.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Split accounting in EvidenceStats
|
||||
|
||||
Add the on/off/unclassified accumulators and a single crediting helper, then route the two existing credit sites through it. The existing `Buckets` total map is preserved unchanged (it still feeds the live evidence panel and the persisted history summary).
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/stats.go` (struct fields ~13-22, `applyEvent` ~27-39, `replayStats` ~42-60)
|
||||
- Modify: `internal/session/session.go` (`StartManualCommitment` stats init ~248-252, `enterReview` flush ~280-283)
|
||||
- Test: `internal/session/session_test.go` (new test appended)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `internal/session/session_test.go`:
|
||||
|
||||
```go
|
||||
func TestCreditLockedSplitsByDriftStatus(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.stats = &EvidenceStats{
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
k := bucketKey{Class: "code", Title: "main.go"}
|
||||
|
||||
c.driftStatus = driftOnTask
|
||||
c.creditLocked(k, 10*time.Second)
|
||||
c.driftStatus = driftDrifting
|
||||
c.creditLocked(k, 5*time.Second)
|
||||
c.driftStatus = driftIdle
|
||||
c.creditLocked(k, 3*time.Second)
|
||||
c.driftStatus = driftPending
|
||||
c.creditLocked(k, 2*time.Second)
|
||||
|
||||
if got := c.stats.OnTask[k]; got != 10*time.Second {
|
||||
t.Errorf("OnTask = %v, want 10s", got)
|
||||
}
|
||||
if got := c.stats.OffTask[k]; got != 5*time.Second {
|
||||
t.Errorf("OffTask = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending
|
||||
t.Errorf("unclassified = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four
|
||||
t.Errorf("Buckets total = %v, want 20s", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus`
|
||||
Expected: FAIL — compile error: `c.stats.OnTask` / `c.stats.OffTask` / `c.stats.unclassified` undefined and `c.creditLocked` undefined.
|
||||
|
||||
- [ ] **Step 3: Add the split fields to `EvidenceStats`**
|
||||
|
||||
In `internal/session/stats.go`, replace the struct (currently lines 12-22):
|
||||
|
||||
```go
|
||||
// EvidenceStats is the in-memory accounting for the current session only.
|
||||
type EvidenceStats struct {
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
|
||||
OnTask map[bucketKey]time.Duration // on-task portion per bucket
|
||||
OffTask map[bucketKey]time.Duration // off-task portion per bucket
|
||||
unclassified time.Duration // idle/pending time; aggregate only
|
||||
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `creditLocked` and route `applyEvent` through it**
|
||||
|
||||
In `internal/session/stats.go`, change the credit line in `applyEvent` (currently lines 28-30) and add the helper. The `applyEvent` body becomes:
|
||||
|
||||
```go
|
||||
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||
if c.stats.hasLast {
|
||||
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
|
||||
}
|
||||
newKey := keyFor(snap)
|
||||
if c.stats.hasLast && newKey != c.stats.lastKey {
|
||||
c.stats.SwitchCount++
|
||||
}
|
||||
c.stats.lastKey = newKey
|
||||
c.stats.lastFocusAt = now
|
||||
c.stats.hasLast = true
|
||||
c.stats.Current = snap
|
||||
}
|
||||
|
||||
// creditLocked credits duration d to bucket k: always to the running total, and
|
||||
// to the on/off/unclassified split per the live drift status — which, at every
|
||||
// credit site, is the classification of the segment being credited (applyEvent
|
||||
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
|
||||
// before the state transition). idle/pending route to unclassified: honest, never
|
||||
// falsely on-task. Caller holds mu.
|
||||
func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
|
||||
c.stats.Buckets[k] += d
|
||||
switch c.driftStatus {
|
||||
case driftOnTask:
|
||||
c.stats.OnTask[k] += d
|
||||
case driftDrifting:
|
||||
c.stats.OffTask[k] += d
|
||||
default: // driftIdle, driftPending
|
||||
c.stats.unclassified += d
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Allocate the split maps everywhere `Buckets` is allocated**
|
||||
|
||||
There are three allocation sites. The maps must be non-nil before `creditLocked` runs.
|
||||
|
||||
In `internal/session/session.go`, `StartManualCommitment` (currently lines 248-252):
|
||||
|
||||
```go
|
||||
c.stats = &EvidenceStats{
|
||||
SessionID: sessionID,
|
||||
StartedUnix: now.Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
```
|
||||
|
||||
In `internal/session/stats.go`, `replayStats` — the empty/error branch (currently lines 45-50):
|
||||
|
||||
```go
|
||||
c.stats = &EvidenceStats{
|
||||
SessionID: sessionID,
|
||||
StartedUnix: c.clock().Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
return
|
||||
```
|
||||
|
||||
and the replay branch (currently lines 52-56):
|
||||
|
||||
```go
|
||||
c.stats = &EvidenceStats{
|
||||
SessionID: sessionID,
|
||||
StartedUnix: events[0].AtUnixMillis / 1000,
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Route the end-of-session flush through `creditLocked`**
|
||||
|
||||
In `internal/session/session.go`, `enterReview` flush (currently lines 280-283):
|
||||
|
||||
```go
|
||||
// Flush the final open segment, then freeze accounting.
|
||||
if c.stats != nil && c.stats.hasLast {
|
||||
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
|
||||
c.stats.hasLast = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the new test and the full session suite**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus`
|
||||
Expected: PASS
|
||||
|
||||
Run: `go test ./internal/session/`
|
||||
Expected: PASS — all existing tests (bucket totals, crash replay, audit summary) unaffected, since `Buckets` is unchanged.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/stats.go internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "Split session time into on/off/unclassified buckets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Render the split in the reflection block
|
||||
|
||||
Rewrite `buildReflectionFinishedLocked` to emit on/off/unclassified totals followed by a top-N on-task list and a top-N off-task list, reusing the existing `bucketViews` sorter. An empty list (and its label) is omitted.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go` (`buildReflectionFinishedLocked` ~269-292)
|
||||
- Test: `internal/session/session_test.go` (new test appended)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `internal/session/session_test.go`. This test sets the split maps directly on a started session, so it is deterministic and independent of drift timing:
|
||||
|
||||
```go
|
||||
func TestReflectionBlockShowsOnOffSplit(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats
|
||||
|
||||
c.stats.SwitchCount = 2
|
||||
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||
}
|
||||
c.stats.OffTask = map[bucketKey]time.Duration{
|
||||
{Class: "firefox", Title: "YouTube"}: 8 * time.Minute,
|
||||
{Class: "firefox", Title: "Reddit"}: 4 * time.Minute,
|
||||
}
|
||||
c.stats.unclassified = 5 * time.Minute
|
||||
c.outcomePending = "completed"
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
want := "Next action: write report\n" +
|
||||
"Success condition: report drafted\n" +
|
||||
"Outcome: completed\n" +
|
||||
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||
"Context switches: 2\n" +
|
||||
"On-task:\n" +
|
||||
"- code · main.go: 20m\n" +
|
||||
"Off-task:\n" +
|
||||
"- firefox · YouTube: 8m\n" +
|
||||
"- firefox · Reddit: 4m"
|
||||
if got != want {
|
||||
t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, nil)
|
||||
|
||||
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||
}
|
||||
c.stats.OffTask = map[bucketKey]time.Duration{} // none
|
||||
c.stats.unclassified = 0
|
||||
c.outcomePending = "completed"
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
if strings.Contains(got, "Off-task:") {
|
||||
t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") {
|
||||
t.Errorf("totals line missing or wrong, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestReflectionBlock`
|
||||
Expected: FAIL — the current block renders top buckets from `c.stats.Buckets` (e.g. lines like `- · : 0m`) and has no `On-task NNm / Off-task NNm` totals line, so the string comparison fails and `Off-task:` is absent for the wrong reason in the second test (verify the first test fails on the totals line).
|
||||
|
||||
- [ ] **Step 3: Rewrite `buildReflectionFinishedLocked`**
|
||||
|
||||
In `internal/session/roles.go`, replace the function (currently lines 265-292) with:
|
||||
|
||||
```go
|
||||
// buildReflectionFinishedLocked renders the just-finished session as a compact
|
||||
// block for the reviewer: the commitment, the outcome, on/off/unclassified time
|
||||
// totals, and a top-N on-task list and top-N off-task list. The split fields are
|
||||
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
|
||||
// set (End clears them, but enterReview runs before End).
|
||||
func (c *Controller) buildReflectionFinishedLocked() string {
|
||||
var na, sc string
|
||||
if c.commitment != nil {
|
||||
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
||||
}
|
||||
outcome := c.outcomePending
|
||||
if outcome == "" {
|
||||
outcome = "completed"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Next action: %s\n", na)
|
||||
fmt.Fprintf(&b, "Success condition: %s\n", sc)
|
||||
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
|
||||
if c.stats != nil {
|
||||
onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60
|
||||
offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60
|
||||
unclMin := int64(c.stats.unclassified.Seconds()) / 60
|
||||
fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin)
|
||||
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
|
||||
writeBucketList(&b, "On-task", c.stats.OnTask)
|
||||
writeBucketList(&b, "Off-task", c.stats.OffTask)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// writeBucketList renders a labeled, time-descending list of buckets capped at
|
||||
// reflectionTopBuckets. It writes nothing — not even the label — when the map is
|
||||
// empty, so a single-sided session shows only the list that has time.
|
||||
func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) {
|
||||
views := bucketViews(m)
|
||||
if len(views) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s:\n", label)
|
||||
for i, bv := range views {
|
||||
if i >= reflectionTopBuckets {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
|
||||
}
|
||||
}
|
||||
|
||||
// sumDurations totals the durations in a bucket map.
|
||||
func sumDurations(m map[bucketKey]time.Duration) time.Duration {
|
||||
var total time.Duration
|
||||
for _, d := range m {
|
||||
total += d
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
Note: `bucketViews` (in `views.go`) already returns a `[]BucketView` sorted descending by seconds, so it serves both lists. The old loop over `bucketViews(c.stats.Buckets)` is gone; `c.stats.Buckets` is no longer read here (it remains in use by `views.go` and the audit summary).
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestReflectionBlock`
|
||||
Expected: PASS
|
||||
|
||||
Run: `go test ./internal/session/`
|
||||
Expected: PASS — the existing reflection tests (`TestReflectionFetchedOnReview` etc.) assert on the reviewer's `Recap`, not the finished-block text, so they are unaffected.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go internal/session/session_test.go
|
||||
git commit -m "Render on/off-task split in the reflection block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: End-to-end faithfulness through RecordWindow
|
||||
|
||||
A test-only task: prove that the live drift status produced by the real drift pipeline reaches the correct split bucket. This is the crux of "faithful" — it exercises the credit-time ordering (`applyEvent` before `evaluateDriftLocked`) and the async drift judge, which the direct unit tests in Tasks 1-2 deliberately bypass.
|
||||
|
||||
**Files:**
|
||||
- Test: `internal/session/session_test.go` (new test appended)
|
||||
|
||||
- [ ] **Step 1: Write the test**
|
||||
|
||||
The sequence: the seed segment accrues while `idle` (unclassified, because the seed never runs the drift pipeline), then on-task code time accrues via the local allowlist match, then off-task firefox time accrues after the async judge returns drifting. `waitDriftStatus` ensures the verdict has landed before the segment that follows is credited, so the classification is deterministic despite the async judge. Durations are in minutes so the integer-minute rendering is exact and the two off-task buckets differ (no sort tie).
|
||||
|
||||
```go
|
||||
func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||
c.SetClock(clk.fn())
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}})
|
||||
|
||||
c.RecordWindow(snap("code", "main.go")) // latest window before start
|
||||
startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle
|
||||
|
||||
clk.advance(5 * time.Minute)
|
||||
c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match)
|
||||
clk.advance(10 * time.Minute)
|
||||
c.RecordWindow(snap("code", "main.go")) // credits 10m on-task
|
||||
clk.advance(10 * time.Minute)
|
||||
c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending)
|
||||
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
||||
|
||||
clk.advance(8 * time.Minute)
|
||||
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting
|
||||
clk.advance(4 * time.Minute)
|
||||
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
want := "Next action: write report\n" +
|
||||
"Success condition: report drafted\n" +
|
||||
"Outcome: completed\n" +
|
||||
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||
"Context switches: 2\n" +
|
||||
"On-task:\n" +
|
||||
"- code · main.go: 20m\n" +
|
||||
"Off-task:\n" +
|
||||
"- firefox · YouTube: 8m\n" +
|
||||
"- firefox · Reddit: 4m"
|
||||
if got != want {
|
||||
t.Errorf("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestRecordWindowCreditsSplitFaithfully`
|
||||
Expected: PASS — Tasks 1 and 2 already supply the behavior; this test asserts the wiring end-to-end.
|
||||
|
||||
If it FAILS on the `Unclassified 5m` portion, that confirms the credit-time ordering is being read correctly (the seed segment is genuinely idle); do not "fix" it by pre-classifying the seed — that unclassified slice is the honest result and is asserted on purpose.
|
||||
|
||||
- [ ] **Step 3: Run the full suite with the race detector**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS, no race warnings. (The test spawns the real drift-judge goroutine; `waitDriftStatus` synchronizes before the next credit, and `buildReflectionFinishedLocked` is read under `c.mu`.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session_test.go
|
||||
git commit -m "Test faithful on/off-task crediting through RecordWindow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
After all three tasks:
|
||||
|
||||
- [ ] Run: `go build ./...` — Expected: success.
|
||||
- [ ] Run: `go vet ./...` — Expected: no diagnostics.
|
||||
- [ ] Run: `go test ./...` — Expected: all packages PASS.
|
||||
|
||||
## Out of scope (do not implement)
|
||||
|
||||
- Persisting the split to the focus log or audit/history summary (no cross-restart reconstruction).
|
||||
- Per-bucket unclassified breakdown (only the aggregate is shown).
|
||||
- Any change to the live evidence panel (`views.go`), the drift/nudge pipeline, the reviewer prompt contract beyond the finished-block text, or the web UI.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,796 +0,0 @@
|
||||
# M8 (Tier A) — Window-minimize Enforcement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a session opts into enforcement and the drift judge confirms the active window is off-task, minimize that window — activating the dormant `domain.EnforcementLevel` and establishing the unprivileged `enforce.Guard` port.
|
||||
|
||||
**Architecture:** A new leaf port `enforce.Guard` (`MinimizeActive(ctx)`), with an X11 adapter (native `jezek/xgbutil`, no `xdotool`) and a no-op adapter, mirroring `evidence.Source`. The `session.Controller` owns all policy: it threads a per-commitment `EnforcementLevel` (persisted in the snapshot), and on every confirmed-drift observation at the `block` level it runs the guard's minimize off-lock — the established async-I/O discipline. The browser shows a planning toggle and a drift-band note.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib + `github.com/jezek/xgbutil` (already a dependency), Gin, vanilla JS/CSS, stdlib `testing`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files**
|
||||
- `internal/enforce/enforce.go` — the `Guard` interface (no build tag). One responsibility: define the port.
|
||||
- `internal/enforce/guard_other.go` (`//go:build !linux`) — no-op `NewGuard`.
|
||||
- `internal/enforce/x11.go` (`//go:build linux`) — real `NewGuard` using xgbutil.
|
||||
- `internal/enforce/enforce_test.go` — portable unit test (compiles on all platforms).
|
||||
- `internal/enforce/x11_integration_test.go` (`//go:build linux`) — live X11 smoke test, skipped without `DISPLAY`.
|
||||
|
||||
**Modified files**
|
||||
- `internal/store/store.go` — `Snapshot` gains `EnforcementLevel`.
|
||||
- `internal/session/session.go` — `EnforcementLevel` field, signature change, persistence, the `enforce` hook, `DriftView.Enforced`.
|
||||
- `internal/session/session_test.go` — call-site updates, `fakeGuard`, enforcement tests.
|
||||
- `internal/web/web.go` — `commitmentRequest.Enforce` → level mapping.
|
||||
- `internal/web/web_test.go` — `stubJudge` + enforced-on-the-wire test.
|
||||
- `cmd/antidriftd/main.go` — `ctrl.SetGuard(enforce.NewGuard())`.
|
||||
- `internal/web/static/app.js` — planning toggle + drift-band note.
|
||||
- `internal/web/static/app.css` — note/toggle styling.
|
||||
- `README.md` — M8 paragraph.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `enforce` port and its adapters
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/enforce/enforce.go`
|
||||
- Create: `internal/enforce/guard_other.go`
|
||||
- Create: `internal/enforce/x11.go`
|
||||
- Create: `internal/enforce/enforce_test.go`
|
||||
- Create: `internal/enforce/x11_integration_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `internal/enforce/enforce_test.go` (no build tag — must compile on every platform):
|
||||
|
||||
```go
|
||||
package enforce
|
||||
|
||||
import "testing"
|
||||
|
||||
// NewGuard must return a usable Guard on every platform (real on linux, no-op
|
||||
// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a
|
||||
// real X server on linux, which the integration test covers under a DISPLAY
|
||||
// guard. The behavioural contract is exercised in the session package via a fake
|
||||
// Guard.
|
||||
func TestNewGuardReturnsUsableGuard(t *testing.T) {
|
||||
if g := NewGuard(); g == nil {
|
||||
t.Fatal("NewGuard returned nil")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/enforce/`
|
||||
Expected: FAIL — build error, `undefined: NewGuard` / package has no Go files yet.
|
||||
|
||||
- [ ] **Step 3: Write the interface**
|
||||
|
||||
Create `internal/enforce/enforce.go`:
|
||||
|
||||
```go
|
||||
// Package enforce makes drift cost something at the OS level. Tier A minimizes
|
||||
// the active window when the session is enforcing and the drift judge has
|
||||
// confirmed the window is off-task. The Guard is a pure OS primitive; all
|
||||
// policy — whether and when to enforce — lives in the session controller.
|
||||
package enforce
|
||||
|
||||
import "context"
|
||||
|
||||
// Guard performs OS-level enforcement actions on demand.
|
||||
type Guard interface {
|
||||
// MinimizeActive minimizes the currently-focused window. It is idempotent
|
||||
// (minimizing an already-minimized window is harmless) and best-effort: it
|
||||
// returns an error for diagnostics, but callers never block on it and treat
|
||||
// failure as "enforcement did nothing this time."
|
||||
MinimizeActive(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the no-op adapter**
|
||||
|
||||
Create `internal/enforce/guard_other.go`:
|
||||
|
||||
```go
|
||||
//go:build !linux
|
||||
|
||||
package enforce
|
||||
|
||||
import "context"
|
||||
|
||||
// NewGuard returns a no-op guard on platforms without the X11 adapter.
|
||||
func NewGuard() Guard { return noopGuard{} }
|
||||
|
||||
type noopGuard struct{}
|
||||
|
||||
func (noopGuard) MinimizeActive(context.Context) error { return nil }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write the X11 adapter**
|
||||
|
||||
Create `internal/enforce/x11.go`. The minimize is the standard ICCCM iconify: a `WM_CHANGE_STATE` client message carrying `IconicState` (`icccm.StateIconic`, value 3) sent to the root window, which `ewmh.ClientEvent` does (it targets the root with SubstructureNotify|Redirect):
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package enforce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jezek/xgbutil"
|
||||
"github.com/jezek/xgbutil/ewmh"
|
||||
"github.com/jezek/xgbutil/icccm"
|
||||
)
|
||||
|
||||
// NewGuard returns the real X11 window-minimize guard.
|
||||
func NewGuard() Guard { return x11Guard{} }
|
||||
|
||||
type x11Guard struct{}
|
||||
|
||||
// MinimizeActive iconifies the currently-focused window by sending an ICCCM
|
||||
// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a
|
||||
// short-lived X connection per call: enforcement fires at most once per drift
|
||||
// observation (which is debounce-gated upstream), so there is no shared
|
||||
// connection or event loop to manage, and nothing to race on shutdown. Any X
|
||||
// failure is returned for the caller to log; the caller never blocks on it.
|
||||
func (x11Guard) MinimizeActive(_ context.Context) error {
|
||||
X, err := xgbutil.NewConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("enforce: cannot connect to X server: %w", err)
|
||||
}
|
||||
defer X.Conn().Close()
|
||||
|
||||
active, err := ewmh.ActiveWindowGet(X)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enforce: no active window: %w", err)
|
||||
}
|
||||
if active == 0 {
|
||||
return nil // nothing focused; nothing to minimize
|
||||
}
|
||||
if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", int(icccm.StateIconic)); err != nil {
|
||||
return fmt.Errorf("enforce: minimize request failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write the live X11 integration test**
|
||||
|
||||
Create `internal/enforce/x11_integration_test.go` (mirrors `internal/evidence/x11_integration_test.go`):
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package enforce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) {
|
||||
if os.Getenv("DISPLAY") == "" {
|
||||
t.Skip("no DISPLAY; skipping live X11 minimize smoke test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
// Either it minimizes the active window or returns an error (e.g. no active
|
||||
// window); we only assert it returns without panicking.
|
||||
if err := NewGuard().MinimizeActive(ctx); err != nil {
|
||||
t.Logf("MinimizeActive returned (acceptable): %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/enforce/`
|
||||
Expected: PASS (`TestNewGuardReturnsUsableGuard` passes; the integration test passes or skips depending on `DISPLAY`).
|
||||
|
||||
- [ ] **Step 8: Build all platforms compile**
|
||||
|
||||
Run: `go vet ./internal/enforce/ && GOOS=darwin go build ./internal/enforce/`
|
||||
Expected: no output (both the linux and non-linux files compile).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/enforce/
|
||||
git commit -m "Add enforce.Guard port with X11 and no-op adapters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Activate the per-commitment EnforcementLevel (signature, field, persistence)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/store/store.go:16-28` (Snapshot struct)
|
||||
- Modify: `internal/session/session.go` (Controller field, `New`, `persistLocked`, `StartManualCommitment`, a test accessor)
|
||||
- Modify: `internal/web/web.go:108-122` (request field + level mapping)
|
||||
- Modify: `internal/session/session_test.go` (call-site updates + persistence test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `internal/session/session_test.go` (`domain`, `filepath`, `time` are already imported):
|
||||
|
||||
```go
|
||||
func TestEnforcementLevelPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
first, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.SetClock(func() time.Time { return time.Unix(1000, 0) })
|
||||
if err := first.EnterPlanning(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock {
|
||||
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestEnforcementLevelPersists`
|
||||
Expected: FAIL — build error: `StartManualCommitment` wants 4 args, and `EnforcementLevelForTest` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add the snapshot field**
|
||||
|
||||
In `internal/store/store.go`, add to the `Snapshot` struct (after the `CarryForward` line):
|
||||
|
||||
```go
|
||||
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
||||
```
|
||||
|
||||
(`domain` is already imported in `store.go`.)
|
||||
|
||||
- [ ] **Step 4: Add the Controller field**
|
||||
|
||||
In `internal/session/session.go`, in the `Controller` struct, add next to `allowedClasses` (the other durable per-session field):
|
||||
|
||||
```go
|
||||
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Change the StartManualCommitment signature and set the field**
|
||||
|
||||
In `internal/session/session.go`, change the signature and store the level. Replace the header line:
|
||||
|
||||
```go
|
||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
|
||||
```
|
||||
|
||||
and, immediately after the existing `c.allowedClasses = append([]string(nil), allowedClasses...)` line, add:
|
||||
|
||||
```go
|
||||
c.enforcementLevel = level
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Persist and restore the level**
|
||||
|
||||
In `persistLocked`, after `snap.AllowedWindowClasses = c.allowedClasses`, add:
|
||||
|
||||
```go
|
||||
snap.EnforcementLevel = c.enforcementLevel
|
||||
```
|
||||
|
||||
In `New`, inside the `if c.runtimeState == domain.RuntimeActive && s.SessionID != ""` block, right after `c.allowedClasses = s.AllowedWindowClasses`, add:
|
||||
|
||||
```go
|
||||
c.enforcementLevel = s.EnforcementLevel
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add the test accessor**
|
||||
|
||||
In `internal/session/session.go`, near the other `*ForTest` helpers (e.g. `AllowedClassesForTest`), add:
|
||||
|
||||
```go
|
||||
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
||||
// only.
|
||||
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.enforcementLevel
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update the web handler to supply a level**
|
||||
|
||||
In `internal/web/web.go`, add the field to `commitmentRequest`:
|
||||
|
||||
```go
|
||||
Enforce bool `json:"enforce"`
|
||||
```
|
||||
|
||||
and in `handleCommitment`, replace the `StartManualCommitment` call with a mapped level (Tier A honors only `block`/`warn`):
|
||||
|
||||
```go
|
||||
level := domain.EnforcementWarn
|
||||
if req.Enforce {
|
||||
level = domain.EnforcementBlock
|
||||
}
|
||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
|
||||
```
|
||||
|
||||
Add `"antidrift/internal/domain"` to `web.go`'s imports.
|
||||
|
||||
- [ ] **Step 9: Update the session test call sites**
|
||||
|
||||
In `internal/session/session_test.go`, every existing `StartManualCommitment(...)` call now needs a trailing level argument. Add `, domain.EnforcementWarn` to each (preserving today's advisory behavior). Also update the `startActive` helper to delegate to a new level-aware helper so Task 3 can start at `block`:
|
||||
|
||||
```go
|
||||
func startActive(t *testing.T, c *Controller, allowed []string) {
|
||||
t.Helper()
|
||||
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
|
||||
}
|
||||
|
||||
func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) {
|
||||
t.Helper()
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("planning: %v", err)
|
||||
}
|
||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The direct call sites to update (add `, domain.EnforcementWarn` before the closing paren) are at roughly lines 40, 70, 85, 107, 150, 185, 202, 231, 377, 392, 562, 891, 1069, 1161. (Use `grep -n 'StartManualCommitment(' internal/session/session_test.go` to find any the line numbers have since shifted; the old `startActive` body at ~441 is replaced by the helper above.)
|
||||
|
||||
- [ ] **Step 10: Run the full session + web suite**
|
||||
|
||||
Run: `go build ./... && go test ./internal/session/ ./internal/web/ ./internal/store/`
|
||||
Expected: PASS, including `TestEnforcementLevelPersists`. Fix any call site the grep missed.
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/store/store.go internal/session/session.go internal/session/session_test.go internal/web/web.go
|
||||
git commit -m "Thread per-commitment enforcement level through to the snapshot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fire the guard on confirmed drift at the block level
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go` (`enforce` import, `guard` field, `enforceTimeout`, `SetGuard`, `enforceActionLocked`, `RecordWindow`, the judge closure, `DriftView`, drift projection)
|
||||
- Modify: `internal/session/session_test.go` (`fakeGuard`, `waitGuardCalls`, enforcement tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `internal/session/session_test.go`. First the fake guard and a poll helper (`context`, `sync/atomic`, `time` already imported):
|
||||
|
||||
```go
|
||||
type fakeGuard struct{ calls int32 }
|
||||
|
||||
func (g *fakeGuard) MinimizeActive(context.Context) error {
|
||||
atomic.AddInt32(&g.calls, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&g.calls) >= min {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls))
|
||||
}
|
||||
```
|
||||
|
||||
Then the tests:
|
||||
|
||||
```go
|
||||
func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
|
||||
// Async path: first off-task observation launches the judge; on confirmation
|
||||
// the guard minimizes once.
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
st := waitDriftStatus(t, c, "drifting")
|
||||
if !st.Drift.Enforced {
|
||||
t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift)
|
||||
}
|
||||
waitGuardCalls(t, g, 1)
|
||||
|
||||
// Synchronous cached path: same class again hits the per-class cache, sets
|
||||
// drifting under the lock, and minimizes again without a new judgment.
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitGuardCalls(t, g, 2)
|
||||
if got := atomic.LoadInt32(&g.calls); got < 2 {
|
||||
t.Fatalf("cached-drift path did not minimize: %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnLevelNeverMinimizes(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn)
|
||||
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
st := waitDriftStatus(t, c, "drifting")
|
||||
if st.Drift.Enforced {
|
||||
t.Fatalf("Enforced must be false at warn: %+v", st.Drift)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run
|
||||
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||
t.Fatalf("warn level must not minimize, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnTaskNeverMinimizes(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
|
||||
c.RecordWindow(obs("code", "main.go")) // local on-task match
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||
t.Fatalf("on-task must not minimize, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockWithoutGuardDoesNotPanic(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
// No guard wired.
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
waitDriftStatus(t, c, "drifting") // must reach drifting without panicking
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'`
|
||||
Expected: FAIL — `SetGuard` undefined and `DriftView` has no `Enforced` field.
|
||||
|
||||
- [ ] **Step 3: Import the enforce package and add the guard field**
|
||||
|
||||
In `internal/session/session.go`, add to the import block:
|
||||
|
||||
```go
|
||||
"antidrift/internal/enforce"
|
||||
```
|
||||
|
||||
and add to the `Controller` struct (next to `judge ai.DriftJudge`):
|
||||
|
||||
```go
|
||||
guard enforce.Guard
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the timeout constant and SetGuard**
|
||||
|
||||
Add `enforceTimeout` beside `driftTimeout` in the `const` block:
|
||||
|
||||
```go
|
||||
enforceTimeout = 5 * time.Second
|
||||
```
|
||||
|
||||
Add the setter near `SetDriftJudge`:
|
||||
|
||||
```go
|
||||
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||
// enforcement; everything else behaves identically.
|
||||
func (c *Controller) SetGuard(g enforce.Guard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.guard = g
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the enforcement-action helper**
|
||||
|
||||
In `internal/session/session.go`, add near `evaluateDriftLocked`:
|
||||
|
||||
```go
|
||||
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||
func (c *Controller) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("session: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Wire the synchronous (cached) path in RecordWindow**
|
||||
|
||||
In `RecordWindow`, replace the tail that runs the judge launch:
|
||||
|
||||
```go
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
enforceAct := c.enforceActionLocked()
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
if enforceAct != nil {
|
||||
go enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
(Name the local `enforceAct`, not `enforce` — `enforce` is the imported package.)
|
||||
|
||||
- [ ] **Step 7: Wire the async path in the judge closure**
|
||||
|
||||
In `evaluateDriftLocked`, the judge closure currently ends:
|
||||
|
||||
```go
|
||||
c.judgedClasses[class] = v
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
```
|
||||
|
||||
Replace it with (capture the enforcement action under the lock, run it after unlock):
|
||||
|
||||
```go
|
||||
c.judgedClasses[class] = v
|
||||
var enforceAct func()
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
enforceAct = c.enforceActionLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if enforceAct != nil {
|
||||
enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Add the Enforced projection field**
|
||||
|
||||
In `internal/session/session.go`, add to `DriftView`:
|
||||
|
||||
```go
|
||||
Enforced bool `json:"enforced,omitempty"`
|
||||
```
|
||||
|
||||
and in `stateLocked`, where the `RuntimeActive` drift projection is built, replace:
|
||||
|
||||
```go
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite under the race detector**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS, no race warnings (the enforce goroutine reads only its captured `guard`; all controller reads are under the lock).
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "Minimize the off-task window on confirmed drift at the block level"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Daemon wiring and the on-the-wire web test
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go` (construct + inject the guard)
|
||||
- Modify: `internal/web/web_test.go` (`stubJudge` + enforced-on-the-wire test)
|
||||
|
||||
- [ ] **Step 1: Write the failing web test**
|
||||
|
||||
Add to `internal/web/web_test.go`. A stub drift judge (the `ai`, `context`, `evidence`, `strings`, `time` imports are already present; add `time` if the build complains):
|
||||
|
||||
```go
|
||||
type stubJudge struct{ verdict ai.Verdict }
|
||||
|
||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||
return j.verdict, nil
|
||||
}
|
||||
|
||||
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(s.stateJSON(), `"enforced":true`) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("state never reported enforced:true; last: %s", s.stateJSON())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it passes already at the session layer**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestEnforceTogglePutsEnforcedOnTheWire`
|
||||
Expected: PASS — the level plumbing (Task 2) and projection (Task 3) already make this green. (This test guards the web boundary and the JSON contract; if it fails, the `enforce` request field or the `enforced` projection is wired wrong.)
|
||||
|
||||
- [ ] **Step 3: Wire the guard into the daemon**
|
||||
|
||||
In `cmd/antidriftd/main.go`, add the import `"antidrift/internal/enforce"`, and after the evidence source is constructed (`src := evidence.NewSource()`), inject the guard:
|
||||
|
||||
```go
|
||||
ctrl.SetGuard(enforce.NewGuard())
|
||||
log.Printf("enforce: window-minimize guard")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and vet**
|
||||
|
||||
Run: `go build ./... && go vet ./...`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go internal/web/web_test.go
|
||||
git commit -m "Wire the enforce guard into the daemon and assert it on the wire"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Planning toggle, drift-band note, and README
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js` (planning form toggle + POST field; drift-band note)
|
||||
- Modify: `internal/web/static/app.css` (note + toggle styling)
|
||||
- Modify: `README.md` (M8 paragraph)
|
||||
|
||||
This task is presentation only; it has no Go test (consistent with prior UI milestones). The behavior is already covered by Task 4's on-the-wire test; verify visually in the browser.
|
||||
|
||||
- [ ] **Step 1: Add the planning toggle to the form**
|
||||
|
||||
In `internal/web/static/app.js`, in the `} else if (rs === 'planning') {` branch, inside the final `<div class="band">` (the one with Next action / Success condition / Minutes / Allowed apps), add the toggle right before the `Start commitment` button:
|
||||
|
||||
```html
|
||||
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
||||
<p class="hint">Minimize off-task windows when you drift.</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Send the toggle value on commit**
|
||||
|
||||
In the same branch, update the `start.onclick` POST body to include `enforce`:
|
||||
|
||||
```js
|
||||
start.onclick = () => post('/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean),
|
||||
enforce: document.getElementById('enforce').checked,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the drift-band note**
|
||||
|
||||
In `updateActiveDrift`, in the `if (drift.status === 'drifting')` branch, add the note after the `drift-reason` line so it reads:
|
||||
|
||||
```js
|
||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
||||
${drift.enforced ? '<div class="enforce-note">Off-task window minimized.</div>' : ''}
|
||||
<div class="band-actions">
|
||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add styling**
|
||||
|
||||
In `internal/web/static/app.css`, add:
|
||||
|
||||
```css
|
||||
.enforce-note { opacity: 0.8; font-size: 0.85em; }
|
||||
.enforce-toggle { display: flex; align-items: center; gap: 0.4em; }
|
||||
.hint { opacity: 0.7; font-size: 0.85em; margin: 0.2em 0 0.6em; }
|
||||
```
|
||||
|
||||
(If a `.hint` rule already exists, keep the existing one and drop the duplicate here.)
|
||||
|
||||
- [ ] **Step 5: Add the README paragraph**
|
||||
|
||||
In `README.md`, at the top of the `## Status` section, add an M8 paragraph:
|
||||
|
||||
```markdown
|
||||
**M8 (Tier A) — Enforcement (window-minimize).** Drift finally costs something.
|
||||
A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when
|
||||
the drift judge confirms the active window is off-task, the daemon minimizes that
|
||||
window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen
|
||||
enforcement level rides the snapshot), and degrades to today's advisory behavior
|
||||
when off, unwired, or on a platform without the X11 adapter.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the build embeds the assets**
|
||||
|
||||
Run: `go build ./... && go test ./internal/web/`
|
||||
Expected: PASS (the `go:embed` static assets still build; existing web tests stay green).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css README.md
|
||||
git commit -m "Add the enforce toggle and drift-band minimize note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] Run: `go build ./... && go vet ./... && go test -race ./...`
|
||||
- [ ] Expected: all packages PASS, no race warnings.
|
||||
- [ ] Manual (human): start the daemon, plan a session with **Enforce focus** checked and an allowed app, switch to an unrelated window, confirm it minimizes after the drift judge fires and the band shows "Off-task window minimized."
|
||||
@@ -1,680 +0,0 @@
|
||||
# M9 — Tame `session.go` Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Split the 1278-line `internal/session/session.go` into five focused files and consolidate the 4×-duplicated async-fetch boilerplate into one helper, with **zero behavior change**.
|
||||
|
||||
**Architecture:** Everything stays in `package session` (one `Controller` behind one `sync.Mutex` — sub-packages would force private state to be exported). Phase 1 moves declarations into focused files with no logic edits. Phase 2 adds a `runFetchAsync(timeout, fetch, stale, apply)` helper and migrates the four async roles to it one at a time. The existing `session_test.go` + `web_test.go` suites are the safety net; the contract is **green-to-green under `-race`** after every commit.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib `testing`, `go test -race`. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Critical rules for every task
|
||||
|
||||
- **No behavior change.** Move and re-shape code; never alter what it does. No exported symbol is renamed, removed, or has its signature changed.
|
||||
- **Locate declarations by name** (`grep -n`), not by the line numbers in this plan — line numbers shift as earlier tasks move code. The line numbers here are hints from the pre-refactor file.
|
||||
- **Move bodies verbatim.** In Phase 1, cut each declaration exactly as written and paste it into the new file. Do not edit logic.
|
||||
- **Resolve imports with the compiler.** After moving declarations, run `go build ./internal/session/`. Add the imports the new file needs; delete imports the compiler reports as now-unused in `session.go`. If `goimports` is available (`go run golang.org/x/tools/cmd/goimports@latest -w internal/session/`), it does both automatically. The "expected imports" lists below are guidance, not gospel.
|
||||
- **Green gate after every commit:** `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`. All must pass. Never commit red.
|
||||
- All commands run from the repo root `/home/felixm/dev/antidrift`. Commit messages end with the `Co-Authored-By` trailer used in this repo.
|
||||
|
||||
---
|
||||
|
||||
## File Structure (target)
|
||||
|
||||
All `package session`:
|
||||
|
||||
- `session.go` — `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive`, the `unavailableTitle`/`sessionRetention` consts, `AllowedClassesForTest`/`EnforcementLevelForTest`.
|
||||
- `views.go` — the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews`.
|
||||
- `roles.go` — `runFetchAsync` + coach/tasks/knowledge/reflection (`Set*`, `start*FetchLocked`/`RequestCoach`, `composedGroundingLocked`, `coachErrorMessage`, `buildReflectionFinishedLocked`, `buildReflectionHistory`) + their timeout/status consts.
|
||||
- `drift.go` — `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, `recentTitlesForTest`, the drift/nudge/enforce consts (`driftDebounce`/`driftTimeout`/`enforceTimeout`/`nudgeDebounce`/`nudgeTimeout`, the `drift*` status consts, `recentTitlesMax`).
|
||||
- `stats.go` — `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Close the coverage gap — knowledge stale-discard test
|
||||
|
||||
The audit found one gap: knowledge has no dedicated stale-discard test, while coach/tasks/reflection do. Add one **before** refactoring so the shared helper's stale path is covered for every role. This is a **characterization test** — it documents existing behavior and must pass against the current (un-refactored) code.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a gate channel to `fakeSource`**
|
||||
|
||||
The knowledge test double `fakeSource` (defined around line 1027 of `internal/session/session_test.go`) currently has no gate, so a load can't be held in flight. Add one, mirroring `fakeProvider` (line ~885) and `fakeCoach` (line ~251). Replace:
|
||||
|
||||
```go
|
||||
type fakeSource struct {
|
||||
profile knowledge.Profile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||
return f.profile, f.err
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
type fakeSource struct {
|
||||
profile knowledge.Profile
|
||||
err error
|
||||
gate chan struct{} // if non-nil, Load blocks until it receives
|
||||
}
|
||||
|
||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.profile, f.err
|
||||
}
|
||||
```
|
||||
|
||||
This is behavior-preserving for the existing knowledge tests (they construct `fakeSource` without a `gate`, so it stays nil and `Load` never blocks).
|
||||
|
||||
- [ ] **Step 2: Write the characterization test**
|
||||
|
||||
Add to `internal/session/session_test.go`, mirroring `TestStaleTasksFetchDiscardedAfterLeavingPlanning` (line ~964). The `State.Knowledge` field is a `*KnowledgeView` accessed as `st.Knowledge.Path`/`st.Knowledge.Chars` in the existing knowledge tests:
|
||||
|
||||
```go
|
||||
// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the
|
||||
// "left planning" arm of the discard guard in startKnowledgeFetchLocked:
|
||||
// a slow knowledge load that returns after the user has left planning must
|
||||
// not clobber fresh state.
|
||||
func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
staleGate := make(chan struct{})
|
||||
stale := &fakeSource{
|
||||
profile: knowledge.Profile{Text: "STALE", Path: "/stale"},
|
||||
gate: staleGate,
|
||||
}
|
||||
c.SetKnowledge(stale)
|
||||
|
||||
if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1)
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Leave planning while the gen-1 load is still blocked on the gate.
|
||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
close(staleGate) // release gen 1; it must be discarded (left planning)
|
||||
// Give the released goroutine time to attempt its commit.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
st := c.State()
|
||||
if st.Knowledge != nil && st.Knowledge.Chars != 0 {
|
||||
t.Fatalf("stale knowledge fetch clobbered state: %+v", st.Knowledge)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it — expect PASS (characterizes current behavior)**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning -v`
|
||||
Expected: PASS. (It documents existing behavior; if it fails, the test is wrong or the fake's gate is mis-wired — fix the test, not production code.)
|
||||
|
||||
- [ ] **Step 4: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session_test.go
|
||||
git commit -m "Add knowledge stale-discard characterization test
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extract `views.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/views.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file with the package clause**
|
||||
|
||||
Create `internal/session/views.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the declarations verbatim**
|
||||
|
||||
Cut these declarations from `session.go` (locate each by name with `grep -n 'type CommitmentView' internal/session/session.go` etc.) and paste them, unedited, into `views.go`:
|
||||
|
||||
- the view types, in this order: `CommitmentView`, `ProposalView`, `DriftView`, `CoachView`, `TaskView`, `TasksView`, `KnowledgeView`, `ReflectionView`, `WindowView`, `BucketView`, `EvidenceView`, `State`
|
||||
- the method `func (c *Controller) stateLocked() State { ... }`
|
||||
- the function `func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { ... }`
|
||||
|
||||
Do not change a single line of their bodies.
|
||||
|
||||
- [ ] **Step 3: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`views.go` is expected to need: `sort`, `time`, `antidrift/internal/domain`, `antidrift/internal/evidence`, `antidrift/internal/tasks`. Add whatever the compiler reports as undefined, and remove from `session.go` any import the compiler now reports as unused. (Or run `goimports -w internal/session/` to do both.)
|
||||
|
||||
- [ ] **Step 4: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS (pure code motion — behavior is identical).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/views.go internal/session/session.go
|
||||
git commit -m "Split session views into views.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `stats.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/stats.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/stats.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the declarations verbatim**
|
||||
|
||||
Cut these from `session.go` and paste unedited into `stats.go`:
|
||||
|
||||
- `type bucketKey struct{ Class, Title string }`
|
||||
- `type EvidenceStats struct { ... }`
|
||||
- `func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot)`
|
||||
- `func (c *Controller) replayStats(sessionID string)`
|
||||
- `func keyFor(snap evidence.WindowSnapshot) bucketKey`
|
||||
- `func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent`
|
||||
- `func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot`
|
||||
|
||||
- [ ] **Step 3: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`stats.go` is expected to need: `time`, `antidrift/internal/evidence`, `antidrift/internal/store`. Add/remove per the compiler (or `goimports -w`).
|
||||
|
||||
- [ ] **Step 4: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/stats.go internal/session/session.go
|
||||
git commit -m "Split evidence-stats accounting into stats.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extract `drift.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/drift.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/drift.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the consts verbatim**
|
||||
|
||||
Cut these const groups from `session.go` and paste into `drift.go`:
|
||||
|
||||
- the block `const ( driftDebounce ... driftTimeout ... enforceTimeout ... nudgeDebounce ... nudgeTimeout ... )`
|
||||
- `const recentTitlesMax = 10`
|
||||
- the block `const ( driftIdle ... driftPending ... driftOnTask ... driftDrifting ... )`
|
||||
|
||||
- [ ] **Step 3: Move the methods verbatim**
|
||||
|
||||
Cut these from `session.go` and paste unedited into `drift.go`:
|
||||
|
||||
- `Set{DriftJudge,Guard,Nudge}` — i.e. `SetDriftJudge`, `SetGuard`, `SetNudge`
|
||||
- `resetDriftLocked`, `OnTask`, `Refocus`
|
||||
- `commitmentLineLocked`
|
||||
- `RecordWindow`, `enforceActionLocked`, `evaluateDriftLocked`, `maybeNudgeLocked`
|
||||
- `recordTitleLocked`, `applyVerdictLocked`
|
||||
- `recentTitlesForTest`
|
||||
|
||||
- [ ] **Step 4: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`drift.go` is expected to need: `context`, `log`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/enforce`, `antidrift/internal/evidence`. Add/remove per the compiler (or `goimports -w`). Pay attention: `session.go` may still need `enforce`/`ai` for fields in the `Controller` struct (the struct itself stays in `session.go`), so do not blindly delete its imports — let the compiler decide.
|
||||
|
||||
- [ ] **Step 5: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/drift.go internal/session/session.go
|
||||
git commit -m "Split drift/nudge/enforcement into drift.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Extract `roles.go` (file split, no logic change)
|
||||
|
||||
This isolates the four async roles so Phase 2's consolidation is a clean diff. `session.go` becomes the remainder (core controller + lifecycle).
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/roles.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/roles.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the consts verbatim**
|
||||
|
||||
Cut these from `session.go` into `roles.go`:
|
||||
|
||||
- `const coachTimeout = 60 * time.Second` and the `const ( coachIdle ... coachPending ... coachReady ... coachError )` block
|
||||
- `const tasksTimeout = 30 * time.Second` and the `const ( tasksIdle ... tasksReady ... tasksError )` block
|
||||
- `const knowledgeTimeout = 10 * time.Second` and the `const ( knowledgeIdle ... knowledgeError )` block
|
||||
- `const reflectionTimeout = 30 * time.Second`, `const reflectionHistoryN = 5`, `const reflectionTopBuckets = 3`, and the `const ( reflectionIdle ... reflectionAbsent )` block
|
||||
|
||||
- [ ] **Step 3: Move the methods/functions verbatim**
|
||||
|
||||
Cut these from `session.go` into `roles.go`:
|
||||
|
||||
- coach: `SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage`
|
||||
- tasks: `SetTasks`, `startTasksFetchLocked`
|
||||
- knowledge: `SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked`
|
||||
- reflection: `SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory`
|
||||
|
||||
- [ ] **Step 4: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`roles.go` is expected to need: `context`, `fmt`, `strings`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/knowledge`, `antidrift/internal/store`, `antidrift/internal/tasks`. Add/remove per the compiler (or `goimports -w`).
|
||||
|
||||
- [ ] **Step 5: Green gate + confirm the monolith shrank**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
Run: `wc -l internal/session/*.go`
|
||||
Expected: `session.go` is now well under ~450 lines; `views.go`/`roles.go`/`drift.go`/`stats.go` carry the rest.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go internal/session/session.go
|
||||
git commit -m "Split async AI roles into roles.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `runFetchAsync` and migrate the tasks role
|
||||
|
||||
Phase 2 begins. Introduce the helper and convert the first role. The helper owns only the goroutine dance; the role keeps its setup, stale guard, and apply branches.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Add the helper**
|
||||
|
||||
In `internal/session/roles.go`, add:
|
||||
|
||||
```go
|
||||
// runFetchAsync launches a generation-guarded background fetch. The caller has
|
||||
// already captured its dependencies and (for the *Locked callers) holds c.mu;
|
||||
// this method only spawns the goroutine, which re-acquires the lock itself.
|
||||
// fetch performs the I/O with no lock held; stale reports whether to discard the
|
||||
// result; apply records it under the re-acquired lock (and persists itself when
|
||||
// the role requires it). On a non-stale completion the controller notifies.
|
||||
func (c *Controller) runFetchAsync(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
fetch(ctx)
|
||||
c.mu.Lock()
|
||||
if stale() {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
apply()
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate `startTasksFetchLocked`**
|
||||
|
||||
Replace the body of `startTasksFetchLocked` (the `go func() { ... }()` block) so the function reads exactly:
|
||||
|
||||
```go
|
||||
func (c *Controller) startTasksFetchLocked() {
|
||||
c.tasksList = nil
|
||||
if c.tasksProvider == nil {
|
||||
c.tasksStatus = tasksIdle
|
||||
return
|
||||
}
|
||||
c.tasksGen++
|
||||
gen := c.tasksGen
|
||||
c.tasksStatus = tasksPending
|
||||
provider := c.tasksProvider
|
||||
var list []tasks.Task
|
||||
var err error
|
||||
c.runFetchAsync(tasksTimeout,
|
||||
func(ctx context.Context) { list, err = provider.Today(ctx) },
|
||||
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.tasksStatus = tasksError
|
||||
c.tasksList = nil
|
||||
} else {
|
||||
c.tasksStatus = tasksReady
|
||||
c.tasksList = list
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Tasks' -v`
|
||||
Expected: PASS — including `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`, `TestStaleTasksFetchDiscardedAfterLeavingPlanning`.
|
||||
|
||||
- [ ] **Step 4: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Add runFetchAsync helper and migrate the tasks fetch
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Migrate the knowledge role
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `startKnowledgeFetchLocked`**
|
||||
|
||||
Replace the `go func() { ... }()` block so the function reads exactly (note the three-branch apply and the `knowledgePath` write-back are preserved verbatim, now inside the `apply` closure):
|
||||
|
||||
```go
|
||||
func (c *Controller) startKnowledgeFetchLocked() {
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
if c.knowledgeSrc == nil {
|
||||
c.knowledgeStatus = knowledgeIdle
|
||||
return
|
||||
}
|
||||
c.knowledgeGen++
|
||||
gen := c.knowledgeGen
|
||||
c.knowledgeStatus = knowledgePending
|
||||
src := c.knowledgeSrc
|
||||
path := c.knowledgePath
|
||||
var prof knowledge.Profile
|
||||
var err error
|
||||
c.runFetchAsync(knowledgeTimeout,
|
||||
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
|
||||
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.knowledgeStatus = knowledgeError
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
if prof.Path != "" {
|
||||
c.knowledgePath = prof.Path
|
||||
}
|
||||
} else if prof.Text == "" {
|
||||
c.knowledgeStatus = knowledgeAbsent
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
c.knowledgePath = prof.Path
|
||||
} else {
|
||||
c.knowledgeStatus = knowledgeReady
|
||||
c.knowledgeText = prof.Text
|
||||
c.knowledgeChars = len(prof.Text)
|
||||
c.knowledgePath = prof.Path
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Knowledge' -v`
|
||||
Expected: PASS — including `TestEnterPlanningLoadsKnowledge`, `TestKnowledgeAbsentWhenEmptyText`, `TestKnowledgeLoadError`, `TestNoSourceNoKnowledgeView`, `TestKnowledgeViewAbsentOutsidePlanning`, `TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning` (from Task 1).
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the knowledge fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Migrate the coach role
|
||||
|
||||
`RequestCoach` is the request-triggered entry point: it manages its own lock and notifies the pending state **before** launching. Keep that pre-launch unlock+notify; only the trailing `go func() { ... }()` is replaced.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `RequestCoach`**
|
||||
|
||||
Replace the trailing goroutine. The function must read exactly:
|
||||
|
||||
```go
|
||||
func (c *Controller) RequestCoach(intent string) error {
|
||||
c.mu.Lock()
|
||||
if c.runtimeState != domain.RuntimePlanning {
|
||||
c.mu.Unlock()
|
||||
return ErrNotPlanning
|
||||
}
|
||||
if c.coach == nil {
|
||||
c.coachStatus = coachError
|
||||
c.coachErr = "coach unavailable"
|
||||
c.coachProposal = nil
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
return nil
|
||||
}
|
||||
c.coachGen++
|
||||
gen := c.coachGen
|
||||
c.coachStatus = coachPending
|
||||
c.coachErr = ""
|
||||
c.coachProposal = nil
|
||||
coach := c.coach
|
||||
grounding := c.composedGroundingLocked()
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
|
||||
var prop ai.Proposal
|
||||
var err error
|
||||
c.runFetchAsync(coachTimeout,
|
||||
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
|
||||
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.coachStatus = coachError
|
||||
c.coachErr = coachErrorMessage(err)
|
||||
c.coachProposal = nil
|
||||
} else {
|
||||
c.coachStatus = coachReady
|
||||
c.coachProposal = &prop
|
||||
c.coachErr = ""
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Note: `runFetchAsync` is called here **after** `c.mu.Unlock()`. That is correct — the helper only spawns the goroutine and touches no `c` field before the goroutine re-locks, so holding the lock is not required.
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Coach' -v`
|
||||
Expected: PASS — including `TestRequestCoachReady`, `TestRequestCoachError`, `TestRequestCoachUnavailable`, `TestRequestCoachWrongState`, `TestRequestCoachStaleResultDiscarded`, `TestCoachReceivesCachedGrounding`, `TestLeavingPlanningClearsCoach`, `TestCarryForwardGroundsNextCoach`.
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the coach fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Migrate the reflection role
|
||||
|
||||
Reflection is the subtle one: its stale guard is **gen-only** (no Planning gate — the carry-forward must survive `End` before the reviewer returns), it reads `history` **synchronously under the lock before** the goroutine (a happens-before requirement against `End`'s audit-chain append), and its apply calls `persistLocked`. All three must be preserved.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `startReflectionFetchLocked`**
|
||||
|
||||
Replace the trailing `go func() { ... }()` block. The function must read exactly (the synchronous `history := buildReflectionHistory(c.auditPath)` stays **outside** the closures, before the `runFetchAsync` call; `persistLocked` moves **into** the `apply` closure):
|
||||
|
||||
```go
|
||||
func (c *Controller) startReflectionFetchLocked() {
|
||||
c.reflectionRecap = ""
|
||||
c.carryForward = ""
|
||||
if c.reviewer == nil {
|
||||
c.reflectionStatus = reflectionIdle
|
||||
return
|
||||
}
|
||||
c.reflectionGen++
|
||||
gen := c.reflectionGen
|
||||
c.reflectionStatus = reflectionPending
|
||||
reviewer := c.reviewer
|
||||
finished := c.buildReflectionFinishedLocked()
|
||||
// Read the history synchronously, here under the lock, on purpose: it must
|
||||
// happen-before End appends the just-finished session to the audit chain, so
|
||||
// that session is excluded from "recent history" and not double-counted (it
|
||||
// is already carried in `finished`). Moving this into the goroutine would
|
||||
// race with End's append and reintroduce that double-count. The read is
|
||||
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
|
||||
// on any hot path.
|
||||
history := buildReflectionHistory(c.auditPath)
|
||||
var refl ai.Reflection
|
||||
var err error
|
||||
c.runFetchAsync(reflectionTimeout,
|
||||
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
|
||||
func() bool { return gen != c.reflectionGen },
|
||||
func() {
|
||||
if err != nil || strings.TrimSpace(refl.Recap) == "" {
|
||||
c.reflectionStatus = reflectionAbsent
|
||||
c.reflectionRecap = ""
|
||||
c.carryForward = ""
|
||||
} else {
|
||||
c.reflectionStatus = reflectionReady
|
||||
c.reflectionRecap = refl.Recap
|
||||
c.carryForward = refl.CarryForward
|
||||
}
|
||||
_ = c.persistLocked()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Confirm the reviewer's return type is `ai.Reflection` (grep `internal/ai` for `func (b *Backend) Review`); if the type name differs, match it in the `var refl` declaration.
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Reflection|CarryForward' -v`
|
||||
Expected: PASS — including `TestReflectionFetchedOnReview`, `TestNoReviewerYieldsIdleReflection`, `TestCarryForwardGroundsNextCoach`, `TestReflectionStaleResultDiscarded`, `TestCarryForwardSurvivesRestart`.
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the reflection fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Confirm no inline async-fetch goroutines remain**
|
||||
|
||||
Run: `grep -n 'go func()' internal/session/*.go`
|
||||
Expected: the only matches are inside `runFetchAsync` (in `roles.go`) and any pre-existing goroutines in `drift.go`/elsewhere that are **not** the four migrated role fetches. The coach/tasks/knowledge/reflection fetches must no longer contain their own `go func()` — they go through `runFetchAsync`.
|
||||
|
||||
- [ ] **Step 2: Confirm the split**
|
||||
|
||||
Run: `wc -l internal/session/*.go`
|
||||
Expected: five focused files (`session.go`, `views.go`, `roles.go`, `drift.go`, `stats.go`), none dominating; the old 1278-line monolith is gone.
|
||||
|
||||
- [ ] **Step 3: Full suite under the race detector**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./...`
|
||||
Expected: all packages PASS, no race warnings.
|
||||
|
||||
- [ ] **Step 4: Confirm behavior contract**
|
||||
|
||||
Confirm via `git log --oneline` that the milestone is a series of small commits, each green, with no change to any exported signature (spot-check: `git diff <first-m9-commit>^..HEAD -- internal/session/session.go internal/web internal/store cmd` shows no signature/behavior changes outside the moved/▸consolidated session code, and no `.go` file outside `internal/session/` was modified except as already committed in prior milestones).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the executor)
|
||||
|
||||
- Every task is **green-to-green**; if any `go test -race` goes red, stop and fix before committing — a red gate means a real behavior change slipped in.
|
||||
- The risky preservation points, all called out in their tasks: reflection's **gen-only** stale guard, its **synchronous history read** before the goroutine, and its **`persistLocked`** in apply; coach's **pre-launch unlock+notify**; knowledge's **three-branch apply + `knowledgePath` write-back**.
|
||||
- No exported symbol is renamed or moved out of `package session`; `web_test.go`'s cross-package use of `AllowedClassesForTest`/`EnforcementLevelForTest` keeps working because those stay in `session.go` as regular (non-`_test.go`) methods.
|
||||
@@ -1,996 +0,0 @@
|
||||
# Settings file + settings page Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the user edit the AI backend, Marvin tasks command, and knowledge profile path at runtime from a settings page in the web UI, persisted to a settings file, with a server-side file picker for the profile path.
|
||||
|
||||
**Architecture:** A new leaf `internal/settings` package owns the `Settings` struct and its JSON file (`~/.antidrift/settings.json`). `main.go` builds an `applyFn func(settings.Settings) error` closure that constructs adapters and calls the controller's existing setters, and injects it into the web server. The server's `POST /settings` handler validates-and-applies via that closure (atomically — invalid backend mutates nothing), then persists. A `GET /fs/browse` endpoint backs a custom file-picker modal. `web` never imports `ai`/`tasks`/`knowledge`.
|
||||
|
||||
**Tech Stack:** Go, Gin, vanilla-JS SSE frontend. Spec: `docs/superpowers/specs/2026-06-01-settings-page-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- `internal/settings/settings.go` (new) — `Settings` struct, `ErrInvalidBackend`, `DefaultPath`, `Load`, `Save`, `SeedFromEnv`.
|
||||
- `internal/settings/settings_test.go` (new) — round-trip, seed, first-run.
|
||||
- `internal/web/settings_handlers.go` (new) — server settings fields, `SetSettings`, `handleGetSettings`, `handlePostSettings`, `handleBrowse`.
|
||||
- `internal/web/settings_handlers_test.go` (new) — GET/POST/browse tests with a fake applier.
|
||||
- `internal/web/web.go` (modify) — register routes; drop `POST /knowledge/path` + `handleKnowledgePath`.
|
||||
- `cmd/antidriftd/main.go` (modify) — load-or-seed settings, build `applyFn`, inject into server.
|
||||
- `internal/web/static/index.html` (modify) — gear button + overlay container.
|
||||
- `internal/web/static/app.js` (modify) — settings overlay + browse modal; repoint knowledge "change" link.
|
||||
- `internal/web/static/app.css` (modify) — gear, overlay, modal, browse styles.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `internal/settings` package
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/settings/settings.go`
|
||||
- Test: `internal/settings/settings_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `internal/settings/settings_test.go`:
|
||||
|
||||
```go
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFileIsError(t *testing.T) {
|
||||
_, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err == nil {
|
||||
t.Fatal("want error loading missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvReadsVars(t *testing.T) {
|
||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex")
|
||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am")
|
||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md")
|
||||
got := SeedFromEnv()
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
if got != want {
|
||||
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "")
|
||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "")
|
||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "")
|
||||
got := SeedFromEnv()
|
||||
if got.AIBackend != "claude" {
|
||||
t.Errorf("default backend = %q, want claude", got.AIBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesDir(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "settings.json")
|
||||
if err := Save(path, Settings{AIBackend: "claude"}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/settings/`
|
||||
Expected: FAIL — `undefined: Settings`, `undefined: Save`, etc.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `internal/settings/settings.go`:
|
||||
|
||||
```go
|
||||
// Package settings persists the daemon's user-editable configuration as a single
|
||||
// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a
|
||||
// leaf type package: it imports nothing else in the app so any layer may depend
|
||||
// on the Settings value without pulling in adapters.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||
// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400
|
||||
// without importing the ai package.
|
||||
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||
|
||||
// Settings is the user-editable configuration. Field names mirror the env vars
|
||||
// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE.
|
||||
type Settings struct {
|
||||
AIBackend string `json:"ai_backend"`
|
||||
MarvinCmd string `json:"marvin_cmd"`
|
||||
KnowledgePath string `json:"knowledge_path"`
|
||||
}
|
||||
|
||||
// DefaultPath returns ~/.antidrift/settings.json.
|
||||
func DefaultPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".antidrift", "settings.json"), nil
|
||||
}
|
||||
|
||||
// Load reads a settings file. A missing file is an error; callers treat that as
|
||||
// "first run" and seed instead.
|
||||
func Load(path string) (Settings, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
var s Settings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Save writes settings atomically (temp file + rename), creating the directory
|
||||
// if needed. Mirrors store.Save.
|
||||
func Save(path string, s Settings) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying
|
||||
// the same defaults the daemon used before the settings file existed. An unset
|
||||
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
||||
func SeedFromEnv() Settings {
|
||||
backend := os.Getenv("ANTIDRIFT_AI_BACKEND")
|
||||
if backend == "" {
|
||||
backend = "claude"
|
||||
}
|
||||
return Settings{
|
||||
AIBackend: backend,
|
||||
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"),
|
||||
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/settings/`
|
||||
Expected: PASS (all 5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/settings/
|
||||
git commit -m "Add settings package: Settings file load/save/seed
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Web settings handlers (GET + POST)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/web/settings_handlers.go`
|
||||
- Test: `internal/web/settings_handlers_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `internal/web/settings_handlers_test.go`:
|
||||
|
||||
```go
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
)
|
||||
|
||||
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||
// reject with ErrInvalidBackend.
|
||||
type fakeApplier struct {
|
||||
called int
|
||||
last settings.Settings
|
||||
reject bool
|
||||
}
|
||||
|
||||
func (f *fakeApplier) apply(s settings.Settings) error {
|
||||
if f.reject {
|
||||
return settings.ErrInvalidBackend
|
||||
}
|
||||
f.called++
|
||||
f.last = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got settings.Settings
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got != cur {
|
||||
t.Errorf("got %+v, want %+v", got, cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
|
||||
w := post(t, r, "/settings", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
if fa.called != 1 {
|
||||
t.Fatalf("applier called %d times, want 1", fa.called)
|
||||
}
|
||||
if fa.last.AIBackend != "codex" {
|
||||
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("settings file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{reject: true}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `httptest`, `post`, and `newTestServer` already exist in `web_test.go` (same package), so they are reused here.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestSettings -v` (and `TestGetSettings`, `TestPostSettings`)
|
||||
Run: `go test ./internal/web/ -run 'Settings' -v`
|
||||
Expected: FAIL — `s.SetSettings undefined`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `internal/web/settings_handlers.go`:
|
||||
|
||||
```go
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetSettings injects the persisted settings, their file path, and the applier
|
||||
// closure (built by main, which owns adapter construction). Mirrors the
|
||||
// controller's Set* injection so web never imports ai/tasks/knowledge.
|
||||
func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) {
|
||||
s.settingsMu.Lock()
|
||||
s.settingsPath = path
|
||||
s.settings = current
|
||||
s.applyFn = apply
|
||||
s.settingsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(c *gin.Context) {
|
||||
s.settingsMu.Lock()
|
||||
cur := s.settings
|
||||
s.settingsMu.Unlock()
|
||||
c.JSON(http.StatusOK, cur)
|
||||
}
|
||||
|
||||
// handlePostSettings validates-and-applies atomically, then persists. The applier
|
||||
// checks the backend before mutating any state, so an invalid backend yields 400
|
||||
// with nothing saved and the prior wiring intact.
|
||||
func (s *Server) handlePostSettings(c *gin.Context) {
|
||||
var req settings.Settings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
apply := s.applyFn
|
||||
path := s.settingsPath
|
||||
s.settingsMu.Unlock()
|
||||
if apply == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||
return
|
||||
}
|
||||
if err := apply(req); err != nil {
|
||||
// Apply validates the backend before mutating anything, so any error
|
||||
// (invalid backend or otherwise) means nothing changed; surface it as 400.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := settings.Save(path, req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
s.settings = req
|
||||
s.settingsMu.Unlock()
|
||||
s.broadcast()
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
```
|
||||
|
||||
Add the server fields. In `internal/web/web.go`, modify the `Server` struct (currently lines 26-32) to:
|
||||
|
||||
```go
|
||||
// Server wires the session controller to HTTP and SSE.
|
||||
type Server struct {
|
||||
ctrl *session.Controller
|
||||
bcast *Broadcaster
|
||||
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
|
||||
settingsMu sync.Mutex
|
||||
settings settings.Settings
|
||||
settingsPath string
|
||||
applyFn func(settings.Settings) error
|
||||
}
|
||||
```
|
||||
|
||||
Add the import `"antidrift/internal/settings"` to `web.go`'s import block.
|
||||
|
||||
Register the routes in `internal/web/web.go` `Router()`, after the existing `r.POST(...)` lines (around line 71):
|
||||
|
||||
```go
|
||||
r.GET("/settings", s.handleGetSettings)
|
||||
r.POST("/settings", s.handlePostSettings)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/web/ -run 'Settings' -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go
|
||||
git commit -m "Add GET/POST /settings handlers with injected applier
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `GET /fs/browse` file-list endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/settings_handlers.go`
|
||||
- Test: `internal/web/settings_handlers_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `internal/web/settings_handlers_test.go`:
|
||||
|
||||
```go
|
||||
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Dir string `json:"dir"`
|
||||
Parent string `json:"parent"`
|
||||
Entries []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Parent != filepath.Dir(dir) {
|
||||
t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir))
|
||||
}
|
||||
var names []string
|
||||
for _, e := range resp.Entries {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
hasSub, hasMd, hasTxt := false, false, false
|
||||
for _, e := range resp.Entries {
|
||||
switch e.Name {
|
||||
case "sub":
|
||||
hasSub = e.IsDir
|
||||
case "profile.md":
|
||||
hasMd = !e.IsDir
|
||||
case "notes.txt":
|
||||
hasTxt = true
|
||||
}
|
||||
}
|
||||
if !hasSub || !hasMd {
|
||||
t.Errorf("missing dir or .md entry; names = %v", names)
|
||||
}
|
||||
if hasTxt {
|
||||
t.Errorf(".txt should be filtered out; names = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseBadDirIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestBrowse -v`
|
||||
Expected: FAIL — 404 (route not registered) / `handleBrowse undefined`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Append to `internal/web/settings_handlers.go` (add `"os"`, `"path/filepath"`, `"strings"` to its imports):
|
||||
|
||||
```go
|
||||
type browseEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
type browseResponse struct {
|
||||
Dir string `json:"dir"`
|
||||
Parent string `json:"parent"`
|
||||
Entries []browseEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
|
||||
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
|
||||
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
|
||||
// beyond OS permissions (same trust boundary as the rest of the UI).
|
||||
func (s *Server) handleBrowse(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
if dir == "" {
|
||||
dir = s.browseStart()
|
||||
}
|
||||
dir = filepath.Clean(dir)
|
||||
infos, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}}
|
||||
for _, e := range infos {
|
||||
name := e.Name()
|
||||
full := filepath.Join(dir, name)
|
||||
if e.IsDir() {
|
||||
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true})
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// browseStart picks the directory of the current knowledge path, else $HOME.
|
||||
func (s *Server) browseStart() string {
|
||||
s.settingsMu.Lock()
|
||||
kp := s.settings.KnowledgePath
|
||||
s.settingsMu.Unlock()
|
||||
if kp != "" {
|
||||
return filepath.Dir(kp)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
```
|
||||
|
||||
Register the route in `internal/web/web.go` `Router()`, next to the other settings routes:
|
||||
|
||||
```go
|
||||
r.GET("/fs/browse", s.handleBrowse)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestBrowse -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go
|
||||
git commit -m "Add GET /fs/browse directory-listing endpoint
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Remove the now-redundant `/knowledge/path` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/web.go`
|
||||
|
||||
- [ ] **Step 1: Delete the route and handler**
|
||||
|
||||
In `internal/web/web.go`, remove the route registration line:
|
||||
|
||||
```go
|
||||
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||
```
|
||||
|
||||
and delete the `knowledgePathRequest` type plus the `handleKnowledgePath` function (currently `web.go:143-159`):
|
||||
|
||||
```go
|
||||
type knowledgePathRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// handleKnowledgePath repoints the profile file at runtime ...
|
||||
func (s *Server) handleKnowledgePath(c *gin.Context) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The controller's `SetKnowledgePath` method stays — the applier (Task 5) calls it.
|
||||
|
||||
- [ ] **Step 2: Update the test that exercises the removed route**
|
||||
|
||||
Search for any test referencing `/knowledge/path`:
|
||||
|
||||
Run: `grep -rn "knowledge/path" internal/web/`
|
||||
|
||||
If `TestPlanningStatePayloadCarriesKnowledge` (or any test) POSTs to `/knowledge/path`, repoint it to `/settings`. Concretely, replace a call like
|
||||
`post(t, r, "/knowledge/path", `+"`"+`{"path":"/tmp/x.md"}`+"`"+`)`
|
||||
with wiring settings first and posting the full object:
|
||||
|
||||
```go
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
||||
settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/tmp/x.md"}`)
|
||||
```
|
||||
|
||||
If no test references it, skip this step.
|
||||
|
||||
- [ ] **Step 3: Verify build and tests**
|
||||
|
||||
Run: `go build ./... && go test ./internal/web/`
|
||||
Expected: PASS, no references to `handleKnowledgePath` remain.
|
||||
|
||||
Run: `grep -rn "knowledge/path\|handleKnowledgePath" internal/`
|
||||
Expected: no matches.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/
|
||||
git commit -m "Remove /knowledge/path; folded into /settings
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire settings into `main.go` (applier + first-run seed)
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go`
|
||||
|
||||
- [ ] **Step 1: Replace the env-var wiring with settings load-or-seed + applier**
|
||||
|
||||
In `cmd/antidriftd/main.go`, replace the three wiring blocks (currently lines 39-65: the AI backend block, the `SetTasks` block, and the `SetKnowledge` block) with the following. Keep everything before (`store.DefaultPath`, `session.New`, `web.NewServer`, `srv.Init()`) and after (statusfile, guard, evidence, browser, Run) unchanged.
|
||||
|
||||
```go
|
||||
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
|
||||
// first run. After first run the file is the sole source of truth; env is
|
||||
// ignored. A missing file is the first-run signal.
|
||||
settingsPath, err := settings.DefaultPath()
|
||||
if err != nil {
|
||||
log.Fatalf("resolve settings path: %v", err)
|
||||
}
|
||||
cfg, err := settings.Load(settingsPath)
|
||||
if err != nil {
|
||||
cfg = settings.SeedFromEnv()
|
||||
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
|
||||
} else {
|
||||
log.Printf("settings: seeded %s from environment", settingsPath)
|
||||
}
|
||||
}
|
||||
|
||||
// The knowledge source is a single file adapter whose path is driven entirely
|
||||
// by SetKnowledgePath, so startup and live settings edits share one code path.
|
||||
ctrl.SetKnowledge(knowledge.NewFileSource(""))
|
||||
|
||||
// applyFn re-wires the running daemon from a Settings value: AI backend +
|
||||
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
|
||||
// validates the backend FIRST and mutates nothing on failure, so POST /settings
|
||||
// can reject an invalid backend atomically.
|
||||
applyFn := func(s settings.Settings) error {
|
||||
backend, err := ai.NewBackend(s.AIBackend)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
||||
}
|
||||
svc := ai.NewService(backend)
|
||||
ctrl.SetCoach(svc)
|
||||
ctrl.SetDriftJudge(svc)
|
||||
ctrl.SetNudge(svc)
|
||||
ctrl.SetReviewer(svc)
|
||||
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
|
||||
ctrl.SetKnowledgePath(s.KnowledgePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply once at startup. A bad persisted backend should not be fatal: fall back
|
||||
// to claude (always valid), persist the correction, and re-apply.
|
||||
if err := applyFn(cfg); err != nil {
|
||||
log.Printf("settings: %v; falling back to claude backend", err)
|
||||
cfg.AIBackend = "claude"
|
||||
_ = settings.Save(settingsPath, cfg)
|
||||
if err := applyFn(cfg); err != nil {
|
||||
log.Fatalf("settings: apply failed even with claude: %v", err)
|
||||
}
|
||||
}
|
||||
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
|
||||
```
|
||||
|
||||
Add `"fmt"` and `"antidrift/internal/settings"` to the import block. The `"os"` import may become unused (it was only used for `os.Getenv`) — check: `openBrowser` does not use `os`. Remove `"os"` from the import block if `go build` reports it unused.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: success. If it reports `"os" imported and not used`, remove the `"os"` import line and rebuild.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite + vet**
|
||||
|
||||
Run: `go test ./... && go vet ./...`
|
||||
Expected: all packages PASS, vet clean.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
Run (no env vars needed now):
|
||||
|
||||
```bash
|
||||
go run ./cmd/antidriftd
|
||||
```
|
||||
|
||||
Expected log lines include `settings: seeded ... from environment` (first run) or `settings: ai=claude, ...`. Confirm `~/.antidrift/settings.json` exists:
|
||||
|
||||
Run: `cat ~/.antidrift/settings.json`
|
||||
Expected: JSON with the three keys.
|
||||
|
||||
Stop the daemon (Ctrl-C).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go
|
||||
git commit -m "Drive daemon config from settings file via injected applier
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Settings UI — gear, overlay, browse modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/index.html`
|
||||
- Modify: `internal/web/static/app.js`
|
||||
- Modify: `internal/web/static/app.css`
|
||||
|
||||
This task has no Go test harness (the repo has no JS tests); verification is `go build` + manual browser check. Write the exact code below.
|
||||
|
||||
- [ ] **Step 1: Add the gear button and overlay container to `index.html`**
|
||||
|
||||
Replace the `<main id="app">…</main>` block (lines 12-15) with:
|
||||
|
||||
```html
|
||||
<main id="app">
|
||||
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||
</main>
|
||||
```
|
||||
|
||||
The overlay lives OUTSIDE `#view`, so the SSE re-render (which replaces `view.innerHTML`) never wipes it.
|
||||
|
||||
- [ ] **Step 2: Repoint the knowledge "change" link in `app.js`**
|
||||
|
||||
In `internal/web/static/app.js`, in `updatePlanningKnowledge` (lines 184-187), replace the prompt-based handler:
|
||||
|
||||
```js
|
||||
document.getElementById('knowChange').onclick = () => {
|
||||
const next = prompt('Profile file path (blank = default):', k.path || '');
|
||||
if (next !== null) post('/knowledge/path', { path: next.trim() });
|
||||
};
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
document.getElementById('knowChange').onclick = openSettings;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the settings overlay + browse logic to `app.js`**
|
||||
|
||||
Add at the end of `internal/web/static/app.js`, AFTER the `EventSource` block:
|
||||
|
||||
```js
|
||||
// ---- Settings overlay ----
|
||||
function openSettings() {
|
||||
fetch('/settings').then(r => r.json()).then(s => {
|
||||
const ov = document.getElementById('settingsOverlay');
|
||||
ov.innerHTML = `
|
||||
<div class="modal">
|
||||
<h2>Settings</h2>
|
||||
<label>AI backend</label>
|
||||
<select id="setBackend">
|
||||
<option value="claude">claude</option>
|
||||
<option value="codex">codex</option>
|
||||
</select>
|
||||
<label>Marvin tasks command</label>
|
||||
<input id="setMarvin" placeholder="uv run am">
|
||||
<label>Knowledge profile path</label>
|
||||
<div class="path-row">
|
||||
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
||||
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||
</div>
|
||||
<div id="browsePane" class="browse" hidden></div>
|
||||
<div id="setError" class="set-error"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="setSave">Save</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||
document.getElementById('setKnow').value = s.knowledge_path || '';
|
||||
ov.hidden = false;
|
||||
document.getElementById('setCancel').onclick = closeSettings;
|
||||
document.getElementById('setSave').onclick = saveSettings;
|
||||
document.getElementById('setBrowse').onclick = () =>
|
||||
loadBrowse(parentDir(document.getElementById('setKnow').value));
|
||||
});
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
const ov = document.getElementById('settingsOverlay');
|
||||
ov.hidden = true;
|
||||
ov.innerHTML = '';
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const body = {
|
||||
ai_backend: document.getElementById('setBackend').value,
|
||||
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||
knowledge_path: document.getElementById('setKnow').value.trim(),
|
||||
};
|
||||
post('/settings', body).then(r => {
|
||||
if (r.ok) { closeSettings(); return; }
|
||||
return r.json().then(e => {
|
||||
document.getElementById('setError').textContent = e.error || 'save failed';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
if (!path) return '';
|
||||
return path.replace(/\/[^/]*$/, '');
|
||||
}
|
||||
|
||||
function loadBrowse(dir) {
|
||||
const pane = document.getElementById('browsePane');
|
||||
pane.hidden = false;
|
||||
fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); });
|
||||
return r.json();
|
||||
}).then(d => {
|
||||
const items = [];
|
||||
if (d.parent && d.parent !== d.dir) {
|
||||
items.push(`<li><button type="button" data-dir="${d.parent}">../</button></li>`);
|
||||
}
|
||||
for (const e of d.entries) {
|
||||
if (e.is_dir) {
|
||||
items.push(`<li><button type="button" data-dir="${e.path}">${e.name}/</button></li>`);
|
||||
} else {
|
||||
items.push(`<li><button type="button" data-file="${e.path}">${e.name}</button></li>`);
|
||||
}
|
||||
}
|
||||
pane.innerHTML = `<div class="browse-dir">${d.dir}</div><ul class="browse-list">${items.join('')}</ul>`;
|
||||
pane.querySelectorAll('button[data-dir]').forEach(b =>
|
||||
b.onclick = () => loadBrowse(b.getAttribute('data-dir')));
|
||||
pane.querySelectorAll('button[data-file]').forEach(b =>
|
||||
b.onclick = () => {
|
||||
document.getElementById('setKnow').value = b.getAttribute('data-file');
|
||||
pane.hidden = true;
|
||||
pane.innerHTML = '';
|
||||
});
|
||||
}).catch(err => {
|
||||
pane.innerHTML = `<div class="set-error">${err.message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('gear').onclick = openSettings;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add styles to `app.css`**
|
||||
|
||||
Append to `internal/web/static/app.css`:
|
||||
|
||||
```css
|
||||
/* Settings: header gear + overlay modal */
|
||||
.gear {
|
||||
background: none; border: 0; color: var(--ink-dim); cursor: pointer;
|
||||
font-size: 14px; padding: 0 4px; vertical-align: middle;
|
||||
}
|
||||
.gear:hover { color: var(--accent); }
|
||||
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px; z-index: 10;
|
||||
}
|
||||
.overlay[hidden] { display: none; }
|
||||
.modal {
|
||||
width: 100%; max-width: 520px; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 20px;
|
||||
}
|
||||
.modal h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-actions .btn { margin-top: 0; }
|
||||
.path-row { display: flex; gap: 8px; align-items: center; }
|
||||
.path-row input { flex: 1; }
|
||||
.path-row .btn { margin-top: 0; white-space: nowrap; }
|
||||
.set-error { color: var(--danger); font-size: 13px; margin-top: 8px; min-height: 1em; }
|
||||
|
||||
.browse {
|
||||
margin-top: 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); max-height: 260px; overflow-y: auto;
|
||||
}
|
||||
.browse[hidden] { display: none; }
|
||||
.browse-dir {
|
||||
padding: 8px 10px; font-size: 12px; color: var(--ink-dim);
|
||||
font-family: ui-monospace, monospace; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; background: var(--bg);
|
||||
}
|
||||
.browse-list { list-style: none; margin: 0; padding: 4px 0; }
|
||||
.browse-list button {
|
||||
width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
|
||||
color: var(--ink); font: inherit; font-size: 13px; padding: 5px 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and manual-verify in browser**
|
||||
|
||||
Static assets are `//go:embed`-ed, so a rebuild is required for changes to load.
|
||||
|
||||
Run: `go build ./... && go vet ./...`
|
||||
Expected: success, vet clean.
|
||||
|
||||
Then run the daemon and hard-reload the browser (Ctrl-Shift-R):
|
||||
|
||||
```bash
|
||||
go run ./cmd/antidriftd
|
||||
```
|
||||
|
||||
Manual checklist (verify each):
|
||||
1. A gear (⚙) shows next to the "AntiDrift" header.
|
||||
2. Clicking it opens the settings overlay with backend/marvin/knowledge prefilled from `~/.antidrift/settings.json`.
|
||||
3. Clicking **Browse…** lists the knowledge path's directory; clicking folders navigates, `../` goes up, clicking a `.md` file fills the path field and closes the browser pane.
|
||||
4. Changing the backend to `codex` and Save closes the overlay; `cat ~/.antidrift/settings.json` shows `"ai_backend":"codex"`.
|
||||
5. The planning screen's knowledge "change" link opens the same settings overlay.
|
||||
|
||||
Stop the daemon (Ctrl-C).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/
|
||||
git commit -m "Add settings gear, overlay, and file-browser modal to the UI
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] Run `go build ./... && go test ./... && go vet ./...` — all green.
|
||||
- [ ] Run `grep -rn "os.Getenv" cmd/ internal/` — expect no matches (all config now flows through the settings file).
|
||||
- [ ] Confirm `~/.antidrift/settings.json` round-trips a UI edit (change marvin command in UI, reload page, value persists).
|
||||
</content>
|
||||
Reference in New Issue
Block a user