9e03add01b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1163 lines
38 KiB
Markdown
1163 lines
38 KiB
Markdown
# 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 <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:
|
|
|
|
```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": "<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**
|
|
|
|
```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 <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 `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 <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 `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 <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 `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 =
|
|
`<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:
|
|
|
|
```js
|
|
<div class="band" id="tasksBand"></div>
|
|
<div class="band">
|
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
|
```
|
|
|
|
to:
|
|
|
|
```js
|
|
<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 `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 `<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**
|
|
|
|
```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 <noreply@anthropic.com>
|
|
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.
|
|
```
|
|
|