Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 KiB
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— theSourceinterface and theProfilevalue type. Leaf package; imports onlycontext. - Create
internal/knowledge/file.go— theFileSourceadapter (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 theCoachinterface andService.Coachto takegrounding, andbuildPromptto inject an## About the usersection 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, theEnterPlanninghook, and passing cached grounding inRequestCoach. - Modify
internal/session/session_test.go— updatefakeCoachfor the new signature; addfakeSource,waitKnowledgeStatus, and controller tests. - Modify
cmd/antidriftd/main.go— construct theFileSourceadapter and callctrl.SetKnowledge. - Modify
internal/web/web.go— add thePOST /knowledge/pathroute and handler. - Modify
internal/web/web_test.go— updatestubCoachfor the new signature; add astubSource, 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:
// 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:
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:
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
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 <noreply@anthropic.com>
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:
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:
// 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:
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:
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": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
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
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 <noreply@anthropic.com>
EOF
)"
Task 3: Controller wiring (async load + projection + grounding)
Files:
-
Modify:
internal/session/session.go -
Test:
internal/session/session_test.go -
Step 1: Update
fakeCoachand 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:
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:
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:
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:
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, nottasks), name this fieldknowledgeSrcand use it consistently below. The steps that follow assumeknowledgeSrc.
- Step 5: Add the view types
Immediately after the TasksView struct definition, add:
// 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 …:
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:
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:
// 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():
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:
coach := c.coach
grounding := c.knowledgeText
c.mu.Unlock()
and pass it to the call inside the goroutine:
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
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 <noreply@anthropic.com>
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
stubCoachand 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:
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:
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)):
r.POST("/knowledge/path", s.handleKnowledgePath)
Add the request type and handler (near handleCoach):
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:
// 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
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 <noreply@anthropic.com>
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
updatePlanningKnowledgefunction
In internal/web/static/app.js, immediately after the updatePlanningTasks
function definition (before function render(state)), add:
// 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 =
`<span class="knowline meta">${line}</span> <button type="button" class="link" id="knowChange">change</button>`;
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:
<div class="band" id="tasksBand"></div>
<div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
to:
<div class="band" id="tasksBand"></div>
<div class="band" id="knowBand"></div>
<div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
- Step 3: Call
updatePlanningKnowledgefrom both render paths
In the planning partial-update early-return at the top of render, change:
if (rs === 'planning' && renderedState === 'planning') {
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
return;
}
to:
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:
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
} else if (rs === 'active') {
to:
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:
/* 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 <div class="band"> 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
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 <noreply@anthropic.com>
EOF
)"
Final Verification
After all tasks:
go vet ./... && go test -race ./...is clean.internal/knowledgeimports onlycontextand stdlib (os/io/fs/errors/fmt/path/filepath/strings/unicode/utf8) — nothing fromdomain,session,ai,evidence, orweb(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;
JudgeDriftandNudgesignatures 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.