# 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. ```