Compare commits

..

6 Commits

Author SHA1 Message Date
felixm 5342da2755 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. Also updates the
README Status section to describe the M6 knowledge port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:08:55 -04:00
felixm b1b807590c 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 (1M context) <noreply@anthropic.com>
2026-06-01 08:06:10 -04:00
felixm 8bd37ed46d 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 (1M context) <noreply@anthropic.com>
2026-06-01 08:04:13 -04:00
felixm 0f1790c8d5 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 (1M context) <noreply@anthropic.com>
2026-06-01 08:01:03 -04:00
felixm 09061f8e30 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 (1M context) <noreply@anthropic.com>
2026-06-01 07:59:48 -04:00
felixm 9e03add01b Add M6 knowledge-port design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:58:55 -04:00
15 changed files with 2032 additions and 14 deletions
+11
View File
@@ -23,6 +23,17 @@ go test ./...
## Status ## Status
M6 (knowledge port): the planning coach now sharpens intents against who you
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
values, what counts as good work — and the daemon loads it asynchronously on
entering planning, mirroring the tasks fetch. The cached text grounds the AI
coach prompt only; the drift judge and nudge are untouched, and the profile text
never crosses the wire. A subtle planning-screen indicator shows whether the
profile loaded and from where, with a "change" affordance to repoint at another
file. It degrades gracefully — a missing, blank, or unreadable file just leaves
the coach ungrounded, and planning still works.
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that — the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
only while you are on-task in an allowed app — periodically reads your recent only while you are on-task in an allowed app — periodically reads your recent
+8
View File
@@ -12,6 +12,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session" "antidrift/internal/session"
"antidrift/internal/statusfile" "antidrift/internal/statusfile"
"antidrift/internal/store" "antidrift/internal/store"
@@ -54,6 +55,13 @@ func main() {
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD"))) ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
log.Printf("tasks: marvin adapter (am)") log.Printf("tasks: marvin adapter (am)")
// 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")
// Mirror runtime status to ~/.antidrift_status for a window-manager bar. // Mirror runtime status to ~/.antidrift_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start. // A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil { if statusPath, err := statusfile.DefaultPath(); err != nil {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
# M6 — Knowledge Port Design
**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my
priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`).
The profile text is loaded on entering planning and threaded into the AI
**coach** prompt as grounding, so "sharpen this intent" reflects who the user is
and what matters to them. A subtle planning-screen indicator shows whether the
profile loaded and from where, and lets the user point at a different file.
Read-only, graceful degradation.
**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port
named in `2026-05-31-go-focus-os-design.md`.
---
## 1. Direction
The Knowledge port is the fourth real port, after Activity (`evidence`),
Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small
leaf-package interface, a single adapter, and a fake for tests. It returns
primitives only, so it imports nothing from `domain` or `session`.
Its one job is to answer "who am I; what are my priorities?" — the standing
context that does not change session to session (role, current projects, what
counts as important, how the user likes to work). Where the Tasks port answers
*what should I be doing right now*, the Knowledge port answers *what kind of
person, with what priorities, is doing it*. That context exists to make the
advisor's judgment less generic.
**Coach-only grounding (this milestone).** The profile feeds exactly one
advisor role: the planning **coach**. Planning is the moment a vague intent is
turned into a concrete commitment, it happens at most a few times a day, and the
coach call already runs off the hot path — so grounding it is the highest-value,
lowest-cost place to start. The live drift judge and the ambient nudge are
deliberately left ungrounded in M6: they run on the hot path (debounced/cached
per window, every few minutes) and adding profile text to those prompts would
raise their token cost for marginal benefit. Extending grounding to those roles
is a clean follow-up once M6 proves the wiring (see Out of Scope).
The profile is **grounding, not instruction**: it informs the coach's proposal
but never forces a transition, exactly as the architecture's cortex layer
requires. Nothing is written back; the file is read-only.
## 2. The Port
New package `internal/knowledge`, a leaf package like `tasks` and `ai`:
```go
// Package knowledge is the Knowledge port: it answers "who am I; what are my
// priorities?" by loading the user's standing profile. It imports nothing from
// the rest of the app, so it stays a leaf package.
package knowledge
import "context"
// Profile is the user's standing context. Primitives only, so knowledge stays a
// leaf package.
type Profile struct {
Text string // grounding text; "" when no profile is available
Path string // resolved source location, for display
}
// Source answers "who am I; what are my priorities?" — the user's standing
// profile that grounds the advisor.
type Source interface {
// Load returns the user's profile. path selects an explicit location; ""
// means the adapter's configured default. A missing source is NOT an error:
// it yields an empty-Text Profile so the caller degrades to ungrounded.
// Only a real read failure (permissions, unreadable) returns an error.
Load(ctx context.Context, path string) (Profile, error)
}
```
The `path` parameter (rather than the adapter owning a single fixed path) keeps
the adapter stateless and lets the controller own the *selected* path — which
the UI can change at runtime — without a mutable field or a type assertion. An
empty `path` falls back to the adapter's configured default, and the resolved
location comes back in `Profile.Path` for the indicator to display.
Files under `internal/knowledge/`:
- `knowledge.go` — the `Source` interface and the `Profile` value type.
- `file.go` — the `FileSource` adapter (reads one file) and the small
truncation helper.
- `file_test.go` — adapter tests against temp files: present, absent, explicit
path override, oversize truncation, default-path resolution.
## 3. The File Adapter
`FileSource` reads one Markdown/plain-text file and returns its contents as the
profile. It is the knowledge analogue of the Marvin adapter, minus the
sub-process: a thin, testable wrapper around a single file read.
```go
type FileSource struct {
defaultPath string // used when Load is called with path == ""
}
func NewFileSource(defaultPath string) *FileSource
func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error)
```
Behaviour:
- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the
built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved
absolute path is returned in `Profile.Path` regardless of outcome, so the
indicator can always show *where it looked*.
- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty
`Text` and **no error**. This is the expected steady state for a user who has
not written a profile; it must not look like a failure.
- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same
`fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use.
- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the
coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing
`\n…(truncated)` marker. A profile that long is already an outlier; the cap is
a guard, not a feature.
- **Whitespace-only** file → treated as empty `Text` (absent), so a file of
blank lines does not produce a meaningless grounding block.
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The
environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or
empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to
choose the file. The UI selector (§5) is a convenient **session-only** override
on top of it. A missing file or read error never fails startup — the daemon
logs one line and proceeds ungrounded.
## 4. Controller Wiring
The wiring mirrors the planning coach and the tasks fetch: an async load on
entering planning, generation-guarded against stale results, projected into
`State` only while planning. The one new seam is that the loaded text is also
**cached for the coach to consume**, since grounding flows into the coach call.
- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil
source turns the feature off (no indicator, ungrounded coach).
- New `Controller` fields, alongside the tasks fields:
`knowledge knowledge.Source`, `knowledgeStatus string`
(`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the
cached grounding the coach reads), `knowledgePath string` (the currently
selected path — `""` means the adapter default), `knowledgeChars int`, and
`knowledgeGen int` (the generation counter).
- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after
`startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a
goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion
re-acquire the lock and **discard if the generation is stale or the runtime
has left planning**. On success it sets `knowledgeStatus` to `ready` (or
`absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/
`knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the
synchronous `State()` path.
- `State()` projects a `*KnowledgeView` **only while planning**, beside the
existing `CoachView` and `TasksView`. The view carries status, the resolved
path, and a character count — **not the profile text** (it stays server-side;
the browser never needs the body, and keeping it off the wire avoids leaking
personal context into the SSE payload and the broadcaster).
```go
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size are surfaced.
type KnowledgeView struct {
Status string `json:"status"` // idle|pending|ready|absent|error
Path string `json:"path,omitempty"` // resolved source path, for display + the selector
Chars int `json:"chars,omitempty"`
}
```
**Threading grounding into the coach.** `RequestCoach` captures
`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and
passes it to the coach call. If the knowledge fetch is still in flight when the
user presses Sharpen, `knowledgeText` is simply empty for that one call and the
coach runs ungrounded — the same graceful-degradation contract as a missing
file. No awaiting, no blocking.
This requires a **signature change** to the `ai.Coach` interface — the one
non-additive change in M6:
```go
type Coach interface {
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
}
```
`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which
prepends an `## About the user` section **only when grounding is non-empty**, so
an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is
small and mechanical: the impl in `ai/coach.go`, the call site in
`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge`
and `Nudger` are untouched.
**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected
path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the
indicator and cached grounding update immediately. It is session-only — not
persisted to the snapshot — so a restart returns to the
`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema
change deferred to a later milestone; the env var is the durable knob for now.)
No new runtime states, no new transitions, no change to the state machine, no
change to persisted snapshot shape.
## 5. Web / UI
The profile load rides in the existing SSE state payload during planning, beside
coach and tasks. M6 adds **one** small POST route — the file selector — which is
the single deliberate server write in this milestone.
- **Indicator.** The planning render gains a quiet line under the intent band
(near `coachStatus`): `ready``grounded by <basename(path)>`; `absent`
`no profile (~/.antidrift/knowledge.md)`; `error``profile unreadable`;
`pending``loading profile…`; nil source / `idle` → nothing. It is
ambient and non-blocking, matching the tasks "loading…" treatment — never a
button to fight.
- **Selector.** A small "change" affordance next to the indicator reveals an
input pre-filled with the resolved path; submitting POSTs
`/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath`
and re-loads. This is the *only* new endpoint. It mutates session-only config,
not commitment state, so it sits outside the state machine. Submitting an
empty path resets to the default. The control is intentionally minimal; if it
proves more than needed, it can ship behind the env var alone (the indicator
is the core; the selector is the "maybe").
- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read
`ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call
`ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad
path only surfaces (as `absent`/`error`) at load time.
## 6. Testing
- **`knowledge` package:** adapter tests against temp files — a present file
(text + resolved path returned), an absent file (empty `Text`, no error,
path still reported), an explicit `path` override beating the default, an
oversize file truncated on a rune boundary with the marker, a whitespace-only
file treated as absent, and a permission/read error wrapped. `~` expansion
covered with a synthesized home. stdlib `testing` only; no process spawned.
- **`ai` package:** `buildPrompt` includes the `## About the user` block when
grounding is non-empty and is byte-identical to the pre-M6 prompt when
grounding is empty (a table test pins both). Existing coach/drift/nudge tests
updated for the new `Coach` signature (grounding `""`), staying green.
- **`session` package:** with a fake `Source`, assert `knowledgeStatus`
transitions (`pending``ready`, `pending``absent` on empty text,
`pending``error` on failure) and that `State().Knowledge` reflects them
while planning and is absent otherwise / with a nil source. Assert
`RequestCoach` passes the cached `knowledgeText` to a recording fake coach,
and passes `""` when the fetch has not completed. Assert the generation guard
discards a load that returns after leaving planning, and that
`SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an
ungrounded coach.
- **`web` package:** existing tests stay green (markup-agnostic). Add one
assertion that planning-state JSON carries the knowledge object (status +
path, no text) when a source is set, and one that `POST /knowledge/path`
updates the selected path and triggers a re-load.
- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf
package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode`
— nothing from `domain`/`session`/`evidence`/`ai`/`web`).
## 7. Out of Scope
- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile
grounding to the hot-path roles is a deliberate follow-up, gated on whether
the token cost is worth it.
- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The
`path`-parameter port shape leaves room for a directory or `am`-style CLI
adapter later without an interface change, but we do not build or abstract for
them now (YAGNI).
- **Persisting the selected path** across restarts (a snapshot-schema change).
The env var is the durable knob; the UI override is session-only.
- **Live file watching / auto-reload.** The profile is re-read on each entry to
planning (and on explicit reselection); no inotify, no polling.
- **Editing the profile from the UI**, structured profile fields (parsing the
Markdown into sections), or per-project knowledge. The file is an opaque
grounding blob.
- **Shipping the profile text to the browser.** Only presence, path, and size
cross the wire.
+13 -8
View File
@@ -2,9 +2,10 @@ package ai
import "context" import "context"
// Coach turns a free-text intent into a validated Proposal. // 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 { type Coach interface {
Coach(ctx context.Context, intent string) (Proposal, error) Coach(ctx context.Context, intent, grounding string) (Proposal, error)
} }
// Backend is one way to reach an LLM CLI. Adapters differ only in the command // Backend is one way to reach an LLM CLI. Adapters differ only in the command
@@ -21,16 +22,16 @@ type Service struct {
func NewService(b Backend) *Service { return &Service{backend: b} } func NewService(b Backend) *Service { return &Service{backend: b} }
func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) { func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
out, err := s.backend.Run(ctx, buildPrompt(intent)) out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
if err != nil { if err != nil {
return Proposal{}, err return Proposal{}, err
} }
return parseProposal(out) return parseProposal(out)
} }
func buildPrompt(intent string) string { func buildPrompt(intent, grounding string) string {
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. 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: 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>"]} {"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>"]}
@@ -39,9 +40,13 @@ Rules:
- next_action: a single concrete imperative action, doable now. - next_action: a single concrete imperative action, doable now.
- success_condition: observable and verifiable; how you'd know it is done. - success_condition: observable and verifiable; how you'd know it is done.
- timebox_minutes: a realistic integer number of minutes for the action. - 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. - 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.`
User intent: ` + intent 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
} }
// DriftJudge decides whether the current window is on-task for a commitment. // DriftJudge decides whether the current window is on-task for a commitment.
+23 -3
View File
@@ -22,7 +22,7 @@ func (f *fakeBackend) Name() string { return "fake" }
func TestServiceCoachSuccess(t *testing.T) { func TestServiceCoachSuccess(t *testing.T) {
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`} fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
svc := NewService(fb) svc := NewService(fb)
p, err := svc.Coach(context.Background(), "write the tests") p, err := svc.Coach(context.Background(), "write the tests", "")
if err != nil { if err != nil {
t.Fatalf("coach: %v", err) t.Fatalf("coach: %v", err)
} }
@@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) {
func TestServiceCoachBackendError(t *testing.T) { func TestServiceCoachBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")} fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil { if _, err := NewService(fb).Coach(context.Background(), "x", ""); err == nil {
t.Fatal("want backend error") t.Fatal("want backend error")
} }
} }
func TestServiceCoachUnparseable(t *testing.T) { func TestServiceCoachUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help with that."} fb := &fakeBackend{out: "I cannot help with that."}
if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) { if _, err := NewService(fb).Coach(context.Background(), "x", ""); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err) t.Fatalf("want ErrNoJSON, got %v", err)
} }
} }
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)
}
}
+92
View File
@@ -0,0 +1,92 @@
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)"
}
+98
View File
@@ -0,0 +1,98 @@
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")
}
}
+23
View File
@@ -0,0 +1,23 @@
// 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)
}
+111 -1
View File
@@ -17,6 +17,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/statemachine" "antidrift/internal/statemachine"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks" "antidrift/internal/tasks"
@@ -47,6 +48,16 @@ const (
tasksError = "error" tasksError = "error"
) )
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const ( const (
driftDebounce = 10 * time.Second driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second driftTimeout = 30 * time.Second
@@ -107,6 +118,13 @@ type Controller struct {
tasksList []tasks.Task tasksList []tasks.Task
tasksGen int tasksGen int
knowledgeSrc knowledge.Source
knowledgeStatus string
knowledgeText string // cached grounding the coach reads
knowledgePath string // selected path; "" = adapter default
knowledgeChars int
knowledgeGen int
allowedClasses []string // durable: the active session's allowed window classes allowedClasses []string // durable: the active session's allowed window classes
judge ai.DriftJudge judge ai.DriftJudge
driftStatus string driftStatus string
@@ -167,6 +185,15 @@ type TasksView struct {
Tasks []TaskView `json:"tasks,omitempty"` Tasks []TaskView `json:"tasks,omitempty"`
} }
// 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"`
}
// WindowView / BucketView / EvidenceView are the evidence projection. // WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct { type WindowView struct {
Class string `json:"class"` Class string `json:"class"`
@@ -194,6 +221,7 @@ type State struct {
Evidence *EvidenceView `json:"evidence"` Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"` Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"` Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
@@ -319,6 +347,13 @@ func (c *Controller) stateLocked() State {
} }
st.Tasks = tv st.Tasks = tv
} }
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
} }
if c.runtimeState == domain.RuntimeActive { if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus status := c.driftStatus
@@ -366,6 +401,7 @@ func (c *Controller) EnterPlanning() error {
c.runtimeState = next c.runtimeState = next
c.resetCoachLocked() c.resetCoachLocked()
c.startTasksFetchLocked() c.startTasksFetchLocked()
c.startKnowledgeFetchLocked()
return c.persistLocked() return c.persistLocked()
} }
@@ -429,6 +465,79 @@ func (c *Controller) startTasksFetchLocked() {
}() }()
} }
// 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()
}()
}
// AllowedClassesForTest exposes the session allowed classes for tests. // AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string { func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock() c.mu.Lock()
@@ -545,13 +654,14 @@ func (c *Controller) RequestCoach(intent string) error {
c.coachErr = "" c.coachErr = ""
c.coachProposal = nil c.coachProposal = nil
coach := c.coach coach := c.coach
grounding := c.knowledgeText
c.mu.Unlock() c.mu.Unlock()
c.notify() c.notify()
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
defer cancel() defer cancel()
prop, err := coach.Coach(ctx, intent) prop, err := coach.Coach(ctx, intent, grounding)
c.mu.Lock() c.mu.Lock()
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
+112 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -12,6 +13,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks" "antidrift/internal/tasks"
) )
@@ -246,15 +248,27 @@ type fakeCoach struct {
prop ai.Proposal prop ai.Proposal
err error err error
gate chan struct{} // if non-nil, Coach blocks until it receives gate chan struct{} // if non-nil, Coach blocks until it receives
mu sync.Mutex
gotGrounding string
} }
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) {
f.mu.Lock()
f.gotGrounding = grounding
f.mu.Unlock()
if f.gate != nil { if f.gate != nil {
<-f.gate <-f.gate
} }
return f.prop, f.err return f.prop, f.err
} }
func (f *fakeCoach) grounding() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.gotGrounding
}
// waitCoachStatus polls until the coach view reaches want, or fails after 2s. // waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State { func waitCoachStatus(t *testing.T, c *Controller, want string) State {
t.Helper() t.Helper()
@@ -918,3 +932,100 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks) t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
} }
} }
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.grounding() == "" {
time.Sleep(5 * time.Millisecond)
}
if fc.grounding() != "I value small diffs." {
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
}
}
+7
View File
@@ -116,3 +116,10 @@ input:focus { outline: 0; border-color: var(--accent); }
cursor: pointer; text-align: left; cursor: pointer; text-align: left;
} }
.task-chip:hover { border-color: var(--accent); color: var(--accent); } .task-chip:hover { border-color: var(--accent); color: var(--accent); }
/* 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;
}
+25
View File
@@ -157,12 +157,35 @@ function updatePlanningTasks(tasks) {
}); });
} }
// 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() });
};
}
function render(state) { function render(state) {
setStateAttr(state); setStateAttr(state);
const rs = state.runtime_state; const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') { if (rs === 'planning' && renderedState === 'planning') {
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge);
return; return;
} }
if (rs === 'active' && renderedState === 'active') { if (rs === 'active' && renderedState === 'active') {
@@ -189,6 +212,7 @@ function render(state) {
<div id="coachStatus" class="meta"></div> <div id="coachStatus" class="meta"></div>
</div> </div>
<div class="band" id="tasksBand"></div> <div class="band" id="tasksBand"></div>
<div class="band" id="knowBand"></div>
<div class="band"> <div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?"> <label>Next action</label><input id="na" placeholder="What exactly will you do?">
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?"> <label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
@@ -214,6 +238,7 @@ function render(state) {
}; };
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge);
} else if (rs === 'active') { } else if (rs === 'active') {
const c = state.commitment || {}; const c = state.commitment || {};
+19
View File
@@ -58,6 +58,7 @@ func (s *Server) Router() *gin.Engine {
r.POST("/end", s.handleEnd) r.POST("/end", s.handleEnd)
r.POST("/refocus", s.handleRefocus) r.POST("/refocus", s.handleRefocus)
r.POST("/ontask", s.handleOnTask) r.POST("/ontask", s.handleOnTask)
r.POST("/knowledge/path", s.handleKnowledgePath)
return r return r
} }
@@ -124,6 +125,24 @@ func (s *Server) handleCommitment(c *gin.Context) {
s.respond(c, err) s.respond(c, err)
} }
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()))
}
func (s *Server) handleComplete(c *gin.Context) { func (s *Server) handleComplete(c *gin.Context) {
s.cancelExpiry() s.cancelExpiry()
s.respond(c, s.ctrl.Complete()) s.respond(c, s.ctrl.Complete())
+56 -1
View File
@@ -12,6 +12,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session" "antidrift/internal/session"
"antidrift/internal/tasks" "antidrift/internal/tasks"
@@ -108,7 +109,7 @@ type stubCoach struct {
err error err error
} }
func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) {
return s.prop, s.err return s.prop, s.err
} }
@@ -281,3 +282,57 @@ func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
t.Fatalf("refocus while Active: want 200, got %d", w.Code) t.Fatalf("refocus while Active: want 200, got %d", w.Code)
} }
} }
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)
}