From 9e03add01b068c7dd731a18f2fabb95ed3a689f2 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 07:58:55 -0400 Subject: [PATCH] Add M6 knowledge-port design spec and implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-01-m6-knowledge-port.md | 1162 +++++++++++++++++ .../2026-06-01-m6-knowledge-port-design.md | 272 ++++ 2 files changed, 1434 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-m6-knowledge-port.md create mode 100644 docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md diff --git a/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md b/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md new file mode 100644 index 0000000..f84f712 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md @@ -0,0 +1,1162 @@ +# M6 — Knowledge 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 `knowledge.Source` port with a single-config-file adapter that +loads the user's standing profile (`~/.antidrift/knowledge.md`, overridable via +`ANTIDRIFT_KNOWLEDGE_FILE`). The `session.Controller` loads it asynchronously on +entering planning — mirroring the tasks fetch — caches the text, and threads it +into the AI **coach** prompt as grounding. A subtle planning-screen indicator +shows whether the profile loaded and from where, and a single new route lets the +user point at a different file. Coach-only; read-only; graceful degradation. + +**Architecture:** A new leaf package `internal/knowledge` (interface + +file adapter, like `tasks`) returning a `Profile{Text, Path}`. The controller +gains tasks-shaped wiring (generation counter, status enum, async load, +planning-only projection) plus one new seam: the loaded text is cached and +passed to the coach. This requires the milestone's one non-additive change — the +`ai.Coach` interface gains a `grounding` parameter. Drift judge and nudge are +untouched. + +**Tech Stack:** Go 1.26, stdlib `os`/`io`/`path/filepath`/`unicode/utf8` + +`encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`. + +**Reference:** Design spec at +`docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md`. The patterns +being mirrored: the `tasks` port (`internal/tasks/tasks.go`, +`internal/tasks/marvin.go`) and the tasks fetch in +`internal/session/session.go` (`startTasksFetchLocked`, `SetTasks`, `TasksView`, +the planning projection in `stateLocked`). + +--- + +## File Structure + +- **Create `internal/knowledge/knowledge.go`** — the `Source` interface and the + `Profile` value type. Leaf package; imports only `context`. +- **Create `internal/knowledge/file.go`** — the `FileSource` adapter (one file + read), path resolution (`~` expansion, default), and the rune-safe truncation + helper. +- **Create `internal/knowledge/file_test.go`** — adapter tests against temp + files: present, absent, explicit-path override, oversize truncation, + whitespace-only, read error. +- **Modify `internal/ai/coach.go`** — change the `Coach` interface and + `Service.Coach` to take `grounding`, and `buildPrompt` to inject an + `## About the user` section only when grounding is non-empty. +- **Modify `internal/ai/coach_test.go`** — update call sites for the new + signature; add a grounding-in-prompt test and an empty-grounding-unchanged + test. +- **Modify `internal/session/session.go`** — knowledge fields, constants, + `KnowledgeView`, `State.Knowledge`, `SetKnowledge`, `SetKnowledgePath`, + `startKnowledgeFetchLocked`, the planning-only projection, the `EnterPlanning` + hook, and passing cached grounding in `RequestCoach`. +- **Modify `internal/session/session_test.go`** — update `fakeCoach` for the new + signature; add `fakeSource`, `waitKnowledgeStatus`, and controller tests. +- **Modify `cmd/antidriftd/main.go`** — construct the `FileSource` adapter and + call `ctrl.SetKnowledge`. +- **Modify `internal/web/web.go`** — add the `POST /knowledge/path` route and + handler. +- **Modify `internal/web/web_test.go`** — update `stubCoach` for the new + signature; add a `stubSource`, a planning-payload-carries-knowledge test, and + a path-selection test. +- **Modify `internal/web/static/app.js`** — `updatePlanningKnowledge`, a + knowledge line + selector in the planning render, and calls from both render + paths. +- **Modify `internal/web/static/app.css`** — `.knowline` / selector styling. + +--- + +## Task 1: `knowledge` package (port + FileSource adapter) + +**Files:** +- Create: `internal/knowledge/knowledge.go` +- Create: `internal/knowledge/file.go` +- Test: `internal/knowledge/file_test.go` + +- [ ] **Step 1: Write the port interface and value type** + +Create `internal/knowledge/knowledge.go`: + +```go +// Package knowledge is the Knowledge port: it answers "who am I; what are my +// priorities?" by loading the user's standing profile. It imports nothing from +// the rest of the app, so it stays a leaf package. +package knowledge + +import "context" + +// Profile is the user's standing context. Primitives only, so knowledge stays a +// leaf package. +type Profile struct { + Text string // grounding text; "" when no profile is available + Path string // resolved source location, for display +} + +// Source answers "who am I; what are my priorities?" — the user's standing +// profile that grounds the advisor. +type Source interface { + // Load returns the user's profile. path selects an explicit location; "" + // means the adapter's configured default. A missing source is NOT an error: + // it yields an empty-Text Profile so the caller degrades to ungrounded. Only + // a real read failure (permissions, unreadable) returns an error. + Load(ctx context.Context, path string) (Profile, error) +} +``` + +- [ ] **Step 2: Write the failing adapter tests** + +Create `internal/knowledge/file_test.go`: + +```go +package knowledge + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadPresentFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "knowledge.md") + if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "I am a focus-tool author." { + t.Errorf("Text = %q (whitespace should be trimmed)", got.Text) + } + if got.Path != p { + t.Errorf("Path = %q, want %q", got.Path, p) + } +} + +func TestLoadAbsentFileIsNotError(t *testing.T) { + p := filepath.Join(t.TempDir(), "missing.md") + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("absent file must not error: %v", err) + } + if got.Text != "" { + t.Errorf("Text = %q, want empty for absent file", got.Text) + } + if got.Path != p { + t.Errorf("Path = %q, want %q even when absent", got.Path, p) + } +} + +func TestLoadExplicitPathBeatsDefault(t *testing.T) { + dir := t.TempDir() + def := filepath.Join(dir, "default.md") + other := filepath.Join(dir, "other.md") + if err := os.WriteFile(def, []byte("default"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(def).Load(context.Background(), other) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "explicit" || got.Path != other { + t.Fatalf("explicit path ignored: %+v", got) + } +} + +func TestLoadTruncatesOversize(t *testing.T) { + p := filepath.Join(t.TempDir(), "big.md") + if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(got.Text) > maxProfileBytes+len("\n…(truncated)") { + t.Errorf("Text not truncated: %d bytes", len(got.Text)) + } + if !strings.HasSuffix(got.Text, "(truncated)") { + t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:]) + } +} + +func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) { + p := filepath.Join(t.TempDir(), "blank.md") + if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "" { + t.Errorf("whitespace-only should be empty, got %q", got.Text) + } +} + +func TestLoadReadErrorIsError(t *testing.T) { + dir := t.TempDir() // a directory is not readable as a file + if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil { + t.Fatal("reading a directory should error") + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/knowledge/ -v` +Expected: FAIL — `undefined: NewFileSource`, `undefined: maxProfileBytes` (build error). + +- [ ] **Step 4: Write the adapter implementation** + +Create `internal/knowledge/file.go`: + +```go +package knowledge + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf8" +) + +// maxProfileBytes caps the grounding text so the coach prompt stays bounded. +const maxProfileBytes = 6 * 1024 + +// FileSource reads the user's profile from a single file. It is stateless: the +// selected path is passed to Load, so the controller can repoint it at runtime +// without a mutable field. +type FileSource struct { + defaultPath string // used when Load is called with path == "" +} + +// NewFileSource builds the adapter. defaultPath is used when Load receives an +// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md. +func NewFileSource(defaultPath string) *FileSource { + return &FileSource{defaultPath: defaultPath} +} + +// Load reads the profile at path (or the default). A missing file yields an +// empty-Text Profile and no error; only a real read failure errors. The +// resolved absolute path is always returned for display. +func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) { + resolved := s.resolve(path) + data, err := os.ReadFile(resolved) + if errors.Is(err, fs.ErrNotExist) { + return Profile{Path: resolved}, nil + } + if err != nil { + return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err) + } + text := strings.TrimSpace(string(data)) + if text == "" { + return Profile{Path: resolved}, nil + } + return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil +} + +// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands +// a leading ~; and makes the result absolute for stable display. +func (s *FileSource) resolve(path string) string { + p := path + if p == "" { + p = s.defaultPath + } + if p == "" { + if home, err := os.UserHomeDir(); err == nil { + p = filepath.Join(home, ".antidrift", "knowledge.md") + } + } + p = expandTilde(p) + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +func expandTilde(p string) string { + if p != "~" && !strings.HasPrefix(p, "~/") { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + if p == "~" { + return home + } + return filepath.Join(home, p[2:]) +} + +// truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + cut := max + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "\n…(truncated)" +} +``` + +(`ctx` is unused — a local file read is fast — but kept for interface +compliance and future adapters.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/knowledge/ -v` +Expected: PASS — all six `TestLoad*`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/knowledge/knowledge.go internal/knowledge/file.go internal/knowledge/file_test.go +git commit -m "$(cat <<'EOF' +Add knowledge port and single-file adapter + +The knowledge.Source port answers "who am I; what are my priorities?". +The FileSource adapter reads one profile file (path selectable per call), +treats a missing/blank file as absent (not an error), and caps the text. +Leaf package mirroring tasks. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 2: Coach grounding (the `ai.Coach` signature change) + +**Files:** +- Modify: `internal/ai/coach.go` +- Test: `internal/ai/coach_test.go` + +- [ ] **Step 1: Update the coach tests for the new signature + grounding** + +In `internal/ai/coach_test.go`, change the three `Coach(...)` call sites to pass +a grounding argument: + +- Line ~25: `svc.Coach(context.Background(), "write the tests", "")` +- Line ~39: `NewService(fb).Coach(context.Background(), "x", "")` +- Line ~46: `NewService(fb).Coach(context.Background(), "x", "")` + +Then add two tests at the end: + +```go +func TestCoachPromptIncludesGrounding(t *testing.T) { + fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`} + if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil { + t.Fatalf("coach: %v", err) + } + if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") { + t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt) + } +} + +func TestCoachEmptyGroundingUnchanged(t *testing.T) { + withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`} + _, _ = NewService(withFb).Coach(context.Background(), "ship it", "") + // The empty-grounding prompt must equal buildPrompt(intent) of the old shape: + // it must NOT contain the grounding header. + if strings.Contains(withFb.gotPrompt, "About the user") { + t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/ai/ -run Coach -v` +Expected: FAIL — too many arguments to `Coach` (build error) until the signature +changes. + +- [ ] **Step 3: Change the interface, the method, and the prompt builder** + +In `internal/ai/coach.go`: + +Change the interface: + +```go +// Coach turns a free-text intent into a validated Proposal. grounding is +// optional standing context about the user (the knowledge port); "" means none. +type Coach interface { + Coach(ctx context.Context, intent, grounding string) (Proposal, error) +} +``` + +Change the method: + +```go +func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) { + out, err := s.backend.Run(ctx, buildPrompt(intent, grounding)) + if err != nil { + return Proposal{}, err + } + return parseProposal(out) +} +``` + +Change `buildPrompt` to take grounding and inject the block only when non-empty, +so an empty grounding yields a byte-for-byte unchanged prompt: + +```go +func buildPrompt(intent, grounding string) string { + preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. + +Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: +{"next_action": "", "success_condition": "", "timebox_minutes": , "allowed_window_classes": [""]} + +Rules: +- next_action: a single concrete imperative action, doable now. +- success_condition: observable and verifiable; how you'd know it is done. +- timebox_minutes: a realistic integer number of minutes for the action. +- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.` + + about := "" + if grounding != "" { + about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them." + } + return preamble + about + "\n\nUser intent: " + intent +} +``` + +(No new imports: grounding is checked with `!= ""`. The controller passes +already-trimmed text from the adapter.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/ai/ -v` +Expected: PASS — existing coach tests plus the two new grounding tests. +Note: `internal/session` and `internal/web` will not build yet (their fake/stub +coaches still have the old signature) — Tasks 3 and 4 fix those. Build them only +after their tasks. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/coach.go internal/ai/coach_test.go +git commit -m "$(cat <<'EOF' +Thread optional grounding into the coach prompt + +The ai.Coach interface gains a grounding parameter carrying standing +context about the user (the knowledge port). buildPrompt injects an +"About the user" block only when grounding is non-empty, so an empty +grounding produces a byte-for-byte unchanged prompt. Drift judge and +nudge are untouched. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 3: Controller wiring (async load + projection + grounding) + +**Files:** +- Modify: `internal/session/session.go` +- Test: `internal/session/session_test.go` + +- [ ] **Step 1: Update `fakeCoach` and write the failing controller tests** + +In `internal/session/session_test.go`, first add `"antidrift/internal/knowledge"` +to the import block. Update `fakeCoach.Coach` (line ~251) to the new signature, +recording grounding so a test can assert it: + +```go +type fakeCoach struct { + prop ai.Proposal + err error + gotGrounding string +} + +func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { + f.gotGrounding = grounding + return f.prop, f.err +} +``` + +(Adjust to the existing `fakeCoach` field set if it differs — keep its current +fields and only add the signature change plus `gotGrounding`.) + +Then add the fake source, the poll helper, and the tests: + +```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 +} + +// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. +func waitKnowledgeStatus(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.Knowledge != nil && st.Knowledge.Status == want { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge) + return State{} +} + +func TestEnterPlanningLoadsKnowledge(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitKnowledgeStatus(t, c, "ready") + if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") { + t.Fatalf("knowledge view wrong: %+v", st.Knowledge) + } +} + +func TestKnowledgeAbsentWhenEmptyText(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitKnowledgeStatus(t, c, "absent") + if st.Knowledge.Path != "/p/missing.md" { + t.Fatalf("absent view should still carry the path: %+v", st.Knowledge) + } +} + +func TestKnowledgeLoadError(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{err: errors.New("permission denied")}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + waitKnowledgeStatus(t, c, "error") +} + +func TestNoSourceNoKnowledgeView(t *testing.T) { + c, _ := newTestController(t) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if c.State().Knowledge != nil { + t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge) + } +} + +func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) { + c, _ := newTestController(t) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}}) + if c.State().Knowledge != nil { + t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge) + } +} + +func TestCoachReceivesCachedGrounding(t *testing.T) { + c, _ := newTestController(t) + fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} + c.SetCoach(fc) + c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + waitKnowledgeStatus(t, c, "ready") + if err := c.RequestCoach("ship it"); err != nil { + t.Fatalf("request coach: %v", err) + } + // Poll until the coach has run (proposal ready), then assert grounding flowed. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && fc.gotGrounding == "" { + time.Sleep(5 * time.Millisecond) + } + if fc.gotGrounding != "I value small diffs." { + t.Fatalf("coach grounding = %q, want the cached profile", fc.gotGrounding) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/session/ -run 'Knowledge|CachedGrounding' -v` +Expected: FAIL — `c.SetKnowledge undefined`, `st.Knowledge undefined` (build error). + +- [ ] **Step 3: Add the import and constants** + +In `internal/session/session.go`, add `"antidrift/internal/knowledge"` to the +import block. Immediately after the tasks constants block (the +`const ( tasksIdle = "idle" … )` group), add: + +```go +const knowledgeTimeout = 10 * time.Second + +const ( + knowledgeIdle = "idle" + knowledgePending = "pending" + knowledgeReady = "ready" + knowledgeAbsent = "absent" + knowledgeError = "error" +) +``` + +- [ ] **Step 4: Add the controller fields** + +In the `Controller` struct, immediately after the tasks fields (`tasksGen int`), +add: + +```go + knowledge knowledge.Source + knowledgeStatus string + knowledgeText string // cached grounding the coach reads + knowledgePath string // selected path; "" = adapter default + knowledgeChars int + knowledgeGen int +``` + +(The field is named `knowledge`; it does not collide with the package because +the package is only referenced by type in this struct and in method signatures, +where the selector form `knowledge.Source` is used before the field is in scope. +If the compiler reports shadowing in any helper, rename the field to +`knowledgeSrc` and adjust — but the tasks port uses `tasksProvider` for exactly +this reason, so prefer `knowledgeSrc` for symmetry if unsure.) + +> **Implementer note:** to match the tasks port's anti-shadowing convention +> (`tasksProvider`, not `tasks`), name this field **`knowledgeSrc`** and use it +> consistently below. The steps that follow assume `knowledgeSrc`. + +- [ ] **Step 5: Add the view types** + +Immediately after the `TasksView` struct definition, add: + +```go +// KnowledgeView projects the ephemeral planning knowledge state (the standing +// profile that grounds the coach). The profile text is intentionally omitted — +// only its presence, source path, and size cross the wire. +type KnowledgeView struct { + Status string `json:"status"` + Path string `json:"path,omitempty"` + Chars int `json:"chars,omitempty"` +} +``` + +In the `State` struct, add a field immediately after `Tasks *TasksView …`: + +```go + Knowledge *KnowledgeView `json:"knowledge,omitempty"` +``` + +- [ ] **Step 6: Add the planning-only projection** + +In `stateLocked`, inside the `if c.runtimeState == domain.RuntimePlanning {` +block, immediately after the tasks projection (`st.Tasks = tv` and its guard), +add: + +```go + if c.knowledgeSrc != nil { + kstatus := c.knowledgeStatus + if kstatus == "" { + kstatus = knowledgeIdle + } + st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} + } +``` + +(Note: `c.knowledgePath` here is the *resolved* path cached from the last load, +not the selected override — see Step 7, which stores the resolved path back.) + +- [ ] **Step 7: Add SetKnowledge, SetKnowledgePath, and the async load** + +Immediately after `startTasksFetchLocked` (so the knowledge helpers sit next to +the tasks helpers), add: + +```go +// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source +// keeps the planning knowledge view absent and the coach ungrounded. +func (c *Controller) SetKnowledge(s knowledge.Source) { + c.mu.Lock() + c.knowledgeSrc = s + c.mu.Unlock() +} + +// SetKnowledgePath selects an explicit profile path (session-only; not +// persisted). While planning, it re-loads immediately so the indicator and the +// cached grounding update. An empty path resets to the adapter default. +func (c *Controller) SetKnowledgePath(path string) { + c.mu.Lock() + c.knowledgePath = strings.TrimSpace(path) + planning := c.runtimeState == domain.RuntimePlanning + if planning { + c.startKnowledgeFetchLocked() + } + c.mu.Unlock() + if planning { + c.notify() + } +} + +// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set. +// Mirrors startTasksFetchLocked: generation-guarded, discards stale or +// post-planning results, and notifies on completion. The loaded text is cached +// in knowledgeText for the coach to read. Caller holds mu. +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 + go func() { + ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout) + defer cancel() + prof, err := src.Load(ctx, path) + + c.mu.Lock() + if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + 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 + } + c.mu.Unlock() + c.notify() + }() +} +``` + +(Caching the *resolved* `prof.Path` back into `c.knowledgePath` lets the +indicator and the pre-filled selector show the real location even when the +selection was empty/default. `context` and `strings` are already imported in +`session.go`.) + +- [ ] **Step 8: Hook the load into EnterPlanning** + +In `EnterPlanning`, add `c.startKnowledgeFetchLocked()` immediately after +`c.startTasksFetchLocked()`: + +```go + c.runtimeState = next + c.resetCoachLocked() + c.startTasksFetchLocked() + c.startKnowledgeFetchLocked() + return c.persistLocked() +``` + +- [ ] **Step 9: Pass cached grounding into the coach** + +In `RequestCoach`, capture the grounding under the lock next to +`coach := c.coach`: + +```go + coach := c.coach + grounding := c.knowledgeText + c.mu.Unlock() +``` + +and pass it to the call inside the goroutine: + +```go + prop, err := coach.Coach(ctx, intent, grounding) +``` + +- [ ] **Step 10: Run the tests to verify they pass** + +Run: `go test ./internal/session/ -run 'Knowledge|CachedGrounding' -v` +Expected: PASS — the five knowledge tests plus `TestCoachReceivesCachedGrounding`. + +- [ ] **Step 11: Run the full session suite (no regressions, race-clean)** + +Run: `go test -race ./internal/session/` +Expected: PASS (existing coach/tasks/drift/nudge tests unaffected — `EnterPlanning` +with no source just sets the idle status; `RequestCoach` with no source passes +`""`). + +- [ ] **Step 12: Commit** + +```bash +git add internal/session/session.go internal/session/session_test.go +git commit -m "$(cat <<'EOF' +Load the knowledge profile on planning and ground the coach + +The controller loads the profile asynchronously on entering planning, +mirroring the tasks fetch: generation-guarded goroutine, status enum +(idle/pending/ready/absent/error), projected into State only while +planning and only when a source is set. The loaded text is cached and +passed to the coach as grounding; SetKnowledgePath repoints the file at +runtime. nil source degrades to no view and an ungrounded coach. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 4: Daemon wiring + web route + web tests + +**Files:** +- Modify: `cmd/antidriftd/main.go` +- Modify: `internal/web/web.go` +- Test: `internal/web/web_test.go` + +- [ ] **Step 1: Update `stubCoach` and write the failing web tests** + +In `internal/web/web_test.go`, add `"antidrift/internal/knowledge"` to the import +block. Update `stubCoach.Coach` (line ~111) to the new signature: + +```go +func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { +``` + +(keep its existing body). Then add the stub source and the two tests: + +```go +type stubSource struct { + profile knowledge.Profile +} + +func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { + if path != "" { + return knowledge.Profile{Text: "from " + path, Path: path}, nil + } + return s.profile, nil +} + +func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) + 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 kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" { + break + } + time.Sleep(5 * time.Millisecond) + } + js := s.stateJSON() + if !strings.Contains(js, `"knowledge"`) || !strings.Contains(js, `"status":"ready"`) { + t.Fatalf("planning payload missing knowledge object: %s", js) + } + if strings.Contains(js, "small diffs") { + t.Fatalf("profile text must NOT cross the wire: %s", js) + } +} + +func TestKnowledgePathSelectionReloads(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) + r := s.Router() + if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + t.Fatalf("/planning code %d", w.Code) + } + if w := post(t, r, "/knowledge/path", `{"path":"/custom/profile.md"}`); w.Code != http.StatusOK { + t.Fatalf("/knowledge/path code %d body %s", w.Code, w.Body.String()) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge) +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/web/ -run 'Knowledge' -v` +Expected: FAIL — no `/knowledge/path` route (404) and/or build error until the +route is added. + +- [ ] **Step 3: Add the route and handler** + +In `internal/web/web.go`, register the route in `Router()` after the existing +POST routes (e.g. after `r.POST("/ontask", s.handleOnTask)`): + +```go + r.POST("/knowledge/path", s.handleKnowledgePath) +``` + +Add the request type and handler (near `handleCoach`): + +```go +type knowledgePathRequest struct { + Path string `json:"path"` +} + +// handleKnowledgePath repoints the profile file at runtime (session-only). It +// mutates config, not commitment state, so it never returns a transition error; +// it just sets the path, re-loads, and broadcasts the refreshed state. +func (s *Server) handleKnowledgePath(c *gin.Context) { + var req knowledgePathRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.ctrl.SetKnowledgePath(req.Path) + s.broadcast() + c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) +} +``` + +- [ ] **Step 4: Wire the adapter into the daemon** + +In `cmd/antidriftd/main.go`, add `"antidrift/internal/knowledge"` to the import +block. Immediately after the tasks-wiring block (after +`log.Printf("tasks: marvin adapter (am)")`), add: + +```go + // Wire the Knowledge port: a single profile file grounding the coach. The + // default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back + // to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an + // ungrounded coach at load time — planning still works. + ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"))) + log.Printf("knowledge: file adapter") +``` + +- [ ] **Step 5: Run the web tests + build the daemon** + +Run: `go test ./internal/web/ -run 'Knowledge' -v && go build ./cmd/antidriftd/` +Expected: PASS and a clean build. + +- [ ] **Step 6: Commit** + +```bash +git add cmd/antidriftd/main.go internal/web/web.go internal/web/web_test.go +git commit -m "$(cat <<'EOF' +Wire the knowledge file adapter and a runtime path selector + +main constructs the FileSource (default via ANTIDRIFT_KNOWLEDGE_FILE) +and injects it. Adds POST /knowledge/path to repoint the profile file at +runtime, and web tests asserting the planning payload carries the +knowledge object (status + path, never the text) and that path selection +reloads. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Task 5: Planning UI — the grounding indicator + file selector + +**Files:** +- Modify: `internal/web/static/app.js` +- Modify: `internal/web/static/app.css` + +This task is presentational; the data path and the route are covered by Task 4's +web tests. There is no JS test harness in this project (consistent with M4/M5), +so verify by build/test plus the manual checklist below. + +- [ ] **Step 1: Add the `updatePlanningKnowledge` function** + +In `internal/web/static/app.js`, immediately after the `updatePlanningTasks` +function definition (before `function render(state)`), add: + +```js +// updatePlanningKnowledge renders the standing-profile grounding indicator and +// a small "change" affordance to repoint the file. idle/nil renders nothing; +// pending/ready/absent/error each show one quiet line. The profile text never +// reaches the browser — only status, path, and size. +function updatePlanningKnowledge(k) { + const el = document.getElementById('knowBand'); + if (!el) return; + if (!k || k.status === 'idle') { el.innerHTML = ''; return; } + const base = (k.path || '').split('/').pop() || k.path || ''; + let line; + if (k.status === 'pending') line = 'loading profile…'; + else if (k.status === 'ready') line = `grounded by ${base} · ${k.chars} chars`; + else if (k.status === 'absent') line = `no profile (${k.path || 'unset'})`; + else line = 'profile unreadable'; + el.innerHTML = + `${line} `; + document.getElementById('knowChange').onclick = () => { + const next = prompt('Profile file path (blank = default):', k.path || ''); + if (next !== null) post('/knowledge/path', { path: next.trim() }); + }; +} +``` + +(`base`/`line` are derived from server-provided status and path; the path is the +user's own filesystem path, carrying the same self-XSS posture as the rest of +the UI — no new untrusted source. A `prompt()`-based selector is intentionally +minimal, matching M5's restraint.) + +- [ ] **Step 2: Add the knowledge band to the planning render** + +In the planning branch of `render` (`} else if (rs === 'planning') {`), add a +knowledge band placeholder immediately after the tasks band. Change: + +```js +
+
+ +``` + +to: + +```js +
+
+
+ +``` + +- [ ] **Step 3: Call `updatePlanningKnowledge` 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); + updatePlanningTasks(state.tasks); + return; + } +``` + +to: + +```js + if (rs === 'planning' && renderedState === 'planning') { + updatePlanningCoach(state.coach); + updatePlanningTasks(state.tasks); + updatePlanningKnowledge(state.knowledge); + return; + } +``` + +And at the end of the full planning render branch, change: + +```js + updatePlanningCoach(state.coach); + updatePlanningTasks(state.tasks); + + } else if (rs === 'active') { +``` + +to: + +```js + updatePlanningCoach(state.coach); + updatePlanningTasks(state.tasks); + updatePlanningKnowledge(state.knowledge); + + } else if (rs === 'active') { +``` + +- [ ] **Step 4: Add the styling** + +Append to `internal/web/static/app.css`: + +```css +/* Planning: standing-profile grounding indicator */ +.knowline { opacity: 0.85; } +.link { + background: none; border: none; padding: 0; font: inherit; font-size: 12px; + color: var(--accent); cursor: pointer; text-decoration: underline; +} +``` + +(If `#knowBand` shows an empty `.band` box when idle, reuse the tasks-band +treatment — both are empty `
` placeholders, so they already +behave identically; no extra rule needed.) + +- [ ] **Step 5: Verify assets still serve and the full suite is race-clean** + +Run: `go vet ./... && go test -race ./...` +Expected: PASS across all packages. + +- [ ] **Step 6: Manual visual check (by eye)** + +Build and run the daemon (`go run ./cmd/antidriftd/`). With a profile at +`~/.antidrift/knowledge.md`, click **Start planning** and confirm: a quiet +"grounded by knowledge.md · N chars" line appears; **Sharpen** produces a +proposal that reflects the profile. Click **change**, point at another file, and +confirm the line updates. Remove the file and confirm the line reads "no +profile" and planning still works. With AI configured, confirm grounded vs. +ungrounded proposals differ sensibly. + +- [ ] **Step 7: Commit** + +```bash +git add internal/web/static/app.js internal/web/static/app.css +git commit -m "$(cat <<'EOF' +Show a profile-grounding indicator on the planning screen + +The planning view renders a quiet line for the standing-profile state +(loading/grounded/absent/unreadable) with a "change" affordance to +repoint the file via POST /knowledge/path. The profile text never +reaches the browser — only status, path, and size. + +Co-Authored-By: Claude Opus 4.8 +EOF +)" +``` + +--- + +## Final Verification + +After all tasks: + +- [ ] `go vet ./... && go test -race ./...` is clean. +- [ ] `internal/knowledge` imports only `context` and stdlib + (`os`/`io/fs`/`errors`/`fmt`/`path/filepath`/`strings`/`unicode/utf8`) — + nothing from `domain`, `session`, `ai`, `evidence`, or `web` (leaf package + preserved). +- [ ] The empty-grounding coach prompt is byte-for-byte identical to the pre-M6 + prompt (pinned by `TestCoachEmptyGroundingUnchanged`). +- [ ] The profile **text** never appears in the SSE/state JSON — only status, + path, and char count (asserted in `TestPlanningStatePayloadCarriesKnowledge`). +- [ ] Grounding feeds the **coach only**; `JudgeDrift` and `Nudge` signatures and + prompts are unchanged. +- [ ] No new persisted snapshot fields; the selected path is session-only. +- [ ] Only one new route (`POST /knowledge/path`); it mutates config, not + commitment state. +- [ ] Update `README.md` "Status" to describe M6 (knowledge port grounds the + coach), following the M3.5/M5 precedent. +``` + diff --git a/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md b/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md new file mode 100644 index 0000000..97861f6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md @@ -0,0 +1,272 @@ +# M6 — Knowledge Port Design + +**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my +priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`). +The profile text is loaded on entering planning and threaded into the AI +**coach** prompt as grounding, so "sharpen this intent" reflects who the user is +and what matters to them. A subtle planning-screen indicator shows whether the +profile loaded and from where, and lets the user point at a different file. +Read-only, graceful degradation. + +**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port +named in `2026-05-31-go-focus-os-design.md`. + +--- + +## 1. Direction + +The Knowledge port is the fourth real port, after Activity (`evidence`), +Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small +leaf-package interface, a single adapter, and a fake for tests. It returns +primitives only, so it imports nothing from `domain` or `session`. + +Its one job is to answer "who am I; what are my priorities?" — the standing +context that does not change session to session (role, current projects, what +counts as important, how the user likes to work). Where the Tasks port answers +*what should I be doing right now*, the Knowledge port answers *what kind of +person, with what priorities, is doing it*. That context exists to make the +advisor's judgment less generic. + +**Coach-only grounding (this milestone).** The profile feeds exactly one +advisor role: the planning **coach**. Planning is the moment a vague intent is +turned into a concrete commitment, it happens at most a few times a day, and the +coach call already runs off the hot path — so grounding it is the highest-value, +lowest-cost place to start. The live drift judge and the ambient nudge are +deliberately left ungrounded in M6: they run on the hot path (debounced/cached +per window, every few minutes) and adding profile text to those prompts would +raise their token cost for marginal benefit. Extending grounding to those roles +is a clean follow-up once M6 proves the wiring (see Out of Scope). + +The profile is **grounding, not instruction**: it informs the coach's proposal +but never forces a transition, exactly as the architecture's cortex layer +requires. Nothing is written back; the file is read-only. + +## 2. The Port + +New package `internal/knowledge`, a leaf package like `tasks` and `ai`: + +```go +// Package knowledge is the Knowledge port: it answers "who am I; what are my +// priorities?" by loading the user's standing profile. It imports nothing from +// the rest of the app, so it stays a leaf package. +package knowledge + +import "context" + +// Profile is the user's standing context. Primitives only, so knowledge stays a +// leaf package. +type Profile struct { + Text string // grounding text; "" when no profile is available + Path string // resolved source location, for display +} + +// Source answers "who am I; what are my priorities?" — the user's standing +// profile that grounds the advisor. +type Source interface { + // Load returns the user's profile. path selects an explicit location; "" + // means the adapter's configured default. A missing source is NOT an error: + // it yields an empty-Text Profile so the caller degrades to ungrounded. + // Only a real read failure (permissions, unreadable) returns an error. + Load(ctx context.Context, path string) (Profile, error) +} +``` + +The `path` parameter (rather than the adapter owning a single fixed path) keeps +the adapter stateless and lets the controller own the *selected* path — which +the UI can change at runtime — without a mutable field or a type assertion. An +empty `path` falls back to the adapter's configured default, and the resolved +location comes back in `Profile.Path` for the indicator to display. + +Files under `internal/knowledge/`: + +- `knowledge.go` — the `Source` interface and the `Profile` value type. +- `file.go` — the `FileSource` adapter (reads one file) and the small + truncation helper. +- `file_test.go` — adapter tests against temp files: present, absent, explicit + path override, oversize truncation, default-path resolution. + +## 3. The File Adapter + +`FileSource` reads one Markdown/plain-text file and returns its contents as the +profile. It is the knowledge analogue of the Marvin adapter, minus the +sub-process: a thin, testable wrapper around a single file read. + +```go +type FileSource struct { + defaultPath string // used when Load is called with path == "" +} + +func NewFileSource(defaultPath string) *FileSource +func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error) +``` + +Behaviour: + +- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the + built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved + absolute path is returned in `Profile.Path` regardless of outcome, so the + indicator can always show *where it looked*. +- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty + `Text` and **no error**. This is the expected steady state for a user who has + not written a profile; it must not look like a failure. +- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same + `fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use. +- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the + coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing + `\n…(truncated)` marker. A profile that long is already an outlier; the cap is + a guard, not a feature. +- **Whitespace-only** file → treated as empty `Text` (absent), so a file of + blank lines does not produce a meaningless grounding block. + +**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The +environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or +empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to +choose the file. The UI selector (§5) is a convenient **session-only** override +on top of it. A missing file or read error never fails startup — the daemon +logs one line and proceeds ungrounded. + +## 4. Controller Wiring + +The wiring mirrors the planning coach and the tasks fetch: an async load on +entering planning, generation-guarded against stale results, projected into +`State` only while planning. The one new seam is that the loaded text is also +**cached for the coach to consume**, since grounding flows into the coach call. + +- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil + source turns the feature off (no indicator, ungrounded coach). +- New `Controller` fields, alongside the tasks fields: + `knowledge knowledge.Source`, `knowledgeStatus string` + (`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the + cached grounding the coach reads), `knowledgePath string` (the currently + selected path — `""` means the adapter default), `knowledgeChars int`, and + `knowledgeGen int` (the generation counter). +- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after + `startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a + goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion + re-acquire the lock and **discard if the generation is stale or the runtime + has left planning**. On success it sets `knowledgeStatus` to `ready` (or + `absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/ + `knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the + synchronous `State()` path. +- `State()` projects a `*KnowledgeView` **only while planning**, beside the + existing `CoachView` and `TasksView`. The view carries status, the resolved + path, and a character count — **not the profile text** (it stays server-side; + the browser never needs the body, and keeping it off the wire avoids leaking + personal context into the SSE payload and the broadcaster). + +```go +// KnowledgeView projects the ephemeral planning knowledge state (the standing +// profile that grounds the coach). The profile text is intentionally omitted — +// only its presence, source path, and size are surfaced. +type KnowledgeView struct { + Status string `json:"status"` // idle|pending|ready|absent|error + Path string `json:"path,omitempty"` // resolved source path, for display + the selector + Chars int `json:"chars,omitempty"` +} +``` + +**Threading grounding into the coach.** `RequestCoach` captures +`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and +passes it to the coach call. If the knowledge fetch is still in flight when the +user presses Sharpen, `knowledgeText` is simply empty for that one call and the +coach runs ungrounded — the same graceful-degradation contract as a missing +file. No awaiting, no blocking. + +This requires a **signature change** to the `ai.Coach` interface — the one +non-additive change in M6: + +```go +type Coach interface { + Coach(ctx context.Context, intent, grounding string) (Proposal, error) +} +``` + +`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which +prepends an `## About the user` section **only when grounding is non-empty**, so +an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is +small and mechanical: the impl in `ai/coach.go`, the call site in +`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge` +and `Nudger` are untouched. + +**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected +path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the +indicator and cached grounding update immediately. It is session-only — not +persisted to the snapshot — so a restart returns to the +`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema +change deferred to a later milestone; the env var is the durable knob for now.) + +No new runtime states, no new transitions, no change to the state machine, no +change to persisted snapshot shape. + +## 5. Web / UI + +The profile load rides in the existing SSE state payload during planning, beside +coach and tasks. M6 adds **one** small POST route — the file selector — which is +the single deliberate server write in this milestone. + +- **Indicator.** The planning render gains a quiet line under the intent band + (near `coachStatus`): `ready` → `grounded by `; `absent` → + `no profile (~/.antidrift/knowledge.md)`; `error` → `profile unreadable`; + `pending` → `loading profile…`; nil source / `idle` → nothing. It is + ambient and non-blocking, matching the tasks "loading…" treatment — never a + button to fight. +- **Selector.** A small "change" affordance next to the indicator reveals an + input pre-filled with the resolved path; submitting POSTs + `/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath` + and re-loads. This is the *only* new endpoint. It mutates session-only config, + not commitment state, so it sits outside the state machine. Submitting an + empty path resets to the default. The control is intentionally minimal; if it + proves more than needed, it can ship behind the env var alone (the indicator + is the core; the selector is the "maybe"). +- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read + `ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call + `ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad + path only surfaces (as `absent`/`error`) at load time. + +## 6. Testing + +- **`knowledge` package:** adapter tests against temp files — a present file + (text + resolved path returned), an absent file (empty `Text`, no error, + path still reported), an explicit `path` override beating the default, an + oversize file truncated on a rune boundary with the marker, a whitespace-only + file treated as absent, and a permission/read error wrapped. `~` expansion + covered with a synthesized home. stdlib `testing` only; no process spawned. +- **`ai` package:** `buildPrompt` includes the `## About the user` block when + grounding is non-empty and is byte-identical to the pre-M6 prompt when + grounding is empty (a table test pins both). Existing coach/drift/nudge tests + updated for the new `Coach` signature (grounding `""`), staying green. +- **`session` package:** with a fake `Source`, assert `knowledgeStatus` + transitions (`pending` → `ready`, `pending` → `absent` on empty text, + `pending` → `error` on failure) and that `State().Knowledge` reflects them + while planning and is absent otherwise / with a nil source. Assert + `RequestCoach` passes the cached `knowledgeText` to a recording fake coach, + and passes `""` when the fetch has not completed. Assert the generation guard + discards a load that returns after leaving planning, and that + `SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an + ungrounded coach. +- **`web` package:** existing tests stay green (markup-agnostic). Add one + assertion that planning-state JSON carries the knowledge object (status + + path, no text) when a source is set, and one that `POST /knowledge/path` + updates the selected path and triggers a re-load. +- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf + package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode` + — nothing from `domain`/`session`/`evidence`/`ai`/`web`). + +## 7. Out of Scope + +- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile + grounding to the hot-path roles is a deliberate follow-up, gated on whether + the token cost is worth it. +- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The + `path`-parameter port shape leaves room for a directory or `am`-style CLI + adapter later without an interface change, but we do not build or abstract for + them now (YAGNI). +- **Persisting the selected path** across restarts (a snapshot-schema change). + The env var is the durable knob; the UI override is session-only. +- **Live file watching / auto-reload.** The profile is re-read on each entry to + planning (and on explicit reselection); no inotify, no polling. +- **Editing the profile from the UI**, structured profile fields (parsing the + Markdown into sections), or per-project knowledge. The file is an opaque + grounding blob. +- **Shipping the profile text to the browser.** Only presence, path, and size + cross the wire.