Add M6 knowledge-port design spec and implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 07:58:55 -04:00
parent 9452e54867
commit 9e03add01b
2 changed files with 1434 additions and 0 deletions
@@ -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.