Nine TDD tasks: internal/aw REST client (EnsureBucket/Insert/Recent), internal/memory port (Store + awStore over-fetch filter + nop + fake), KEEL_AW_URL setting, harness.Services.Memory wiring with nop fallback, off-screen write path (proposal_made/action_taken/proposal_dismissed) and read path (## Recently proposed + follow-up prompt rule), plus full and live verification. Derived from the 2026-06-05 design spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
37 KiB
Off-screen AW-backed Memory — 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: Give Keel its first durable cross-run memory by adding an AW REST client and a memory.Store port, then wiring the off-screen mode to record its decisions to the keel.events bucket and read its recent history back into the next proposal.
Architecture: Two new leaf packages — internal/aw (thin ActivityWatch REST client: buckets + events) and internal/memory (the Store port with an AW-backed adapter, a nop, and a test fake). harness.Services gains a Memory field; cmd/keeld constructs it (bucket ensured at startup, nop fallback if AW is down). Off-screen writes proposal_made / action_taken / proposal_dismissed and reads them back, all best-effort.
Tech Stack: Go 1.26, net/http + encoding/json (AW REST at http://localhost:5600/api/0), net/http/httptest for client tests, existing ports-and-adapters style.
Spec: docs/superpowers/specs/2026-06-05-offscreen-memory-design.md
File structure
internal/aw/aw.go(create) —Event,Client,New,EnsureBucket,Insert,Recent. No Keel concepts.internal/aw/aw_test.go(create) — httptest-backed client tests.internal/memory/memory.go(create) —Event,Store,AWClient,awStore,nopStore,Fake, constructors.internal/memory/memory_test.go(create) —Fake,nopStore,awStoremapping/over-fetch tests.internal/settings/settings.go(modify) — addAWURLfield + seed.internal/settings/settings_test.go(modify) — seed test.internal/harness/services.go(modify) — addMemory memory.Store.cmd/keeld/main.go(modify) — construct the store insidebuildServices.internal/mode/offscreen/offscreen.go(modify) — mem/now/proposalID fields, write path, event constants, helpers.internal/mode/offscreen/collect.go(modify) —briefHistory,relativeAge, brief section.internal/mode/offscreen/offscreen_test.go(modify) — write-path + read-path tests.internal/ai/proposer.go(modify) — add the follow-up rule to the propose prompt.internal/ai/proposer_test.go(modify) — assert the rule is present.
Event-kind names (proposal_made, …) live in the offscreen package — memory stays a generic event store.
Task 1: internal/aw — Client + EnsureBucket
Files:
-
Create:
internal/aw/aw.go -
Test:
internal/aw/aw_test.go -
Step 1: Write the failing test
package aw
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestEnsureBucketPostsCreate(t *testing.T) {
var gotPath string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("EnsureBucket: %v", err)
}
if gotPath != "/api/0/buckets/keel.events" {
t.Fatalf("path = %q", gotPath)
}
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
t.Fatalf("body = %+v", gotBody)
}
}
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("existing bucket should be OK, got %v", err)
}
}
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
t.Fatal("expected error on 500")
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/aw/
Expected: FAIL — undefined: New / package has no Go files compiled.
- Step 3: Write minimal implementation
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
// and writes events. It holds no Keel concepts, so it stays a leaf package.
package aw
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
// derived events it is 0.
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
// Client talks to an aw-server over its /api/0 REST surface.
type Client struct {
baseURL string
http *http.Client
}
// New builds a client for the given base URL (e.g. http://localhost:5600).
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
// response (304) is success.
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
body, _ := json.Marshal(map[string]string{
"client": client,
"type": eventType,
"hostname": hostname(),
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
return nil
default:
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
}
}
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "keel"
}
- Step 4: Run test to verify it passes
Run: go test ./internal/aw/
Expected: PASS
- Step 5: Commit
git add internal/aw/aw.go internal/aw/aw_test.go
git commit -m "feat(aw): AW REST client with idempotent EnsureBucket"
Task 2: internal/aw — Insert + Recent
Files:
-
Modify:
internal/aw/aw.go -
Test:
internal/aw/aw_test.go -
Step 1: Write the failing test (append to
aw_test.go)
func TestInsertPostsEventArray(t *testing.T) {
var gotPath string
var gotEvents []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotEvents)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
t.Fatalf("Insert: %v", err)
}
if gotPath != "/api/0/buckets/keel.events/events" {
t.Fatalf("path = %q", gotPath)
}
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
t.Fatalf("events = %+v", gotEvents)
}
}
func TestRecentDecodesNewestFirst(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("limit = %q, want 5", got)
}
_, _ = io.WriteString(w, `[
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
]`)
}))
defer srv.Close()
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
t.Fatalf("got = %+v", got)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/aw/ -run 'Insert|Recent'
Expected: FAIL — c.Insert undefined / c.Recent undefined.
- Step 3: Write minimal implementation (append to
aw.go)
type eventJSON struct {
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration"`
Data map[string]any `json:"data"`
}
// Insert posts a single event to the bucket. The AW events endpoint takes a
// JSON array, so the event is wrapped in a one-element list.
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
}
return nil
}
// Recent returns up to limit events from the bucket, newest first (the AW
// default ordering for the events endpoint).
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
url := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
}
var raw []eventJSON
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
out := make([]Event, len(raw))
for i, e := range raw {
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
}
return out, nil
}
- Step 4: Run test to verify it passes
Run: go test ./internal/aw/
Expected: PASS (all four tests).
- Step 5: Commit
git add internal/aw/aw.go internal/aw/aw_test.go
git commit -m "feat(aw): Insert and Recent over the events endpoint"
Task 3: internal/memory — Store, nopStore, Fake
Files:
-
Create:
internal/memory/memory.go -
Test:
internal/memory/memory_test.go -
Step 1: Write the failing test
package memory
import (
"context"
"testing"
"time"
)
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
f := NewFake()
ctx := context.Background()
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
got, err := f.Recent(ctx, "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["n"] != "b" {
t.Fatalf("want newest-first [b,a], got %+v", got)
}
}
func TestFakeRecentRespectsLimit(t *testing.T) {
f := NewFake()
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
}
got, _ := f.Recent(ctx, "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("limit not honored: %d", len(got))
}
}
func TestNopStoreIsInert(t *testing.T) {
s := NewNop()
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
t.Fatalf("nop Record: %v", err)
}
got, err := s.Recent(context.Background(), "x", 5)
if err != nil || len(got) != 0 {
t.Fatalf("nop Recent = %+v, %v", got, err)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/memory/
Expected: FAIL — undefined: NewFake / NewNop.
- Step 3: Write minimal implementation
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
// events and read them back with Recent; the AW-backed adapter persists them to
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
// them — this package stays a generic event store.
package memory
import (
"context"
"sync"
"time"
)
// Event is one derived event Keel chooses to remember. Kind is the event type
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
// other tools' source of truth.
type Event struct {
Kind string
At time.Time
Data map[string]any
}
// Store records derived events and reads recent ones back by kind.
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
type nopStore struct{}
// NewNop returns a Store that drops writes and returns no history.
func NewNop() Store { return nopStore{} }
func (nopStore) Record(context.Context, Event) error { return nil }
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
// Fake is an in-memory Store for tests.
type Fake struct {
mu sync.Mutex
events []Event
}
// NewFake returns an empty in-memory Store.
func NewFake() *Fake { return &Fake{} }
func (f *Fake) Record(_ context.Context, e Event) error {
f.mu.Lock()
f.events = append(f.events, e)
f.mu.Unlock()
return nil
}
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Event
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
if f.events[i].Kind == kind {
out = append(out, f.events[i])
}
}
return out, nil
}
// Events returns a snapshot of everything recorded, for test assertions.
func (f *Fake) Events() []Event {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Event(nil), f.events...)
}
- Step 4: Run test to verify it passes
Run: go test ./internal/memory/
Expected: PASS
- Step 5: Commit
git add internal/memory/memory.go internal/memory/memory_test.go
git commit -m "feat(memory): Store port with nop and fake adapters"
Task 4: internal/memory — AW-backed Store
Files:
-
Modify:
internal/memory/memory.go -
Test:
internal/memory/memory_test.go -
Step 1: Write the failing test (append to
memory_test.go)
import "keel/internal/aw" // add to the existing import block
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
// recent list so we can test mapping and over-fetch filtering without HTTP.
type fakeAWClient struct {
inserted []aw.Event
recent []aw.Event // newest-first, mixed kinds
lastLimit int
}
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
f.lastLimit = limit
return f.recent, nil
}
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
fc := &fakeAWClient{}
s := NewAWStore(fc, "keel.events")
at := time.Unix(1000, 0).UTC()
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
t.Fatalf("Record: %v", err)
}
if len(fc.inserted) != 1 {
t.Fatalf("inserted %d", len(fc.inserted))
}
got := fc.inserted[0]
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
t.Fatalf("mapped event = %+v", got)
}
}
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
fc := &fakeAWClient{recent: []aw.Event{
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
}}
s := NewAWStore(fc, "keel.events")
got, err := s.Recent(context.Background(), "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if fc.lastLimit != overfetch {
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
}
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
t.Fatalf("filtered = %+v", got)
}
}
func TestAWStoreRecentCapsAtN(t *testing.T) {
var recent []aw.Event
for i := 0; i < 5; i++ {
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
}
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
got, _ := s.Recent(context.Background(), "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("cap not applied: %d", len(got))
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/memory/ -run AWStore
Expected: FAIL — undefined: NewAWStore / undefined: overfetch.
- Step 3: Write minimal implementation (append to
memory.go, add"keel/internal/aw"to imports)
// overfetch bounds how many recent bucket events awStore pulls before filtering
// by kind, because keel.events interleaves all kinds. Events older than this
// window are not seen — acceptable for "recent".
const overfetch = 200
// kindKey is the data field carrying the Event.Kind inside an AW event.
const kindKey = "_kind"
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
type AWClient interface {
Insert(ctx context.Context, bucketID string, e aw.Event) error
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
}
type awStore struct {
c AWClient
bucketID string
}
// NewAWStore returns a Store that persists events to the given AW bucket.
func NewAWStore(c AWClient, bucketID string) Store {
return &awStore{c: c, bucketID: bucketID}
}
func (s *awStore) Record(ctx context.Context, e Event) error {
data := make(map[string]any, len(e.Data)+1)
for k, v := range e.Data {
data[k] = v
}
data[kindKey] = e.Kind
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
}
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
if err != nil {
return nil, err
}
var out []Event
for _, ev := range raw {
if len(out) >= n {
break
}
if k, _ := ev.Data[kindKey].(string); k == kind {
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: ev.Data})
}
}
return out, nil
}
- Step 4: Run test to verify it passes
Run: go test ./internal/memory/
Expected: PASS
- Step 5: Commit
git add internal/memory/memory.go internal/memory/memory_test.go
git commit -m "feat(memory): AW-backed Store with over-fetch kind filtering"
Task 5: settings — KEEL_AW_URL
Files:
-
Modify:
internal/settings/settings.go:21-25(struct) and:70-80(SeedFromEnv) -
Test:
internal/settings/settings_test.go -
Step 1: Write the failing test (append to
settings_test.go)
func TestSeedFromEnvAWURL(t *testing.T) {
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
t.Fatalf("AWURL = %q", got)
}
}
func TestSeedFromEnvAWURLDefault(t *testing.T) {
t.Setenv("KEEL_AW_URL", "")
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
t.Fatalf("default AWURL = %q", got)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/settings/ -run AWURL
Expected: FAIL — s.AWURL undefined.
- Step 3: Write minimal implementation
In the Settings struct (after KnowledgePath), add:
AWURL string `json:"aw_url"`
Update the struct doc comment to mention the new var:
// Settings is the user-editable configuration. Field names mirror the env vars
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL.
In SeedFromEnv, before the return, add the default and field:
awURL := os.Getenv("KEEL_AW_URL")
if awURL == "" {
awURL = "http://localhost:5600"
}
return Settings{
AIBackend: backend,
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
AWURL: awURL,
}
- Step 4: Run test to verify it passes
Run: go test ./internal/settings/
Expected: PASS
- Step 5: Commit
git add internal/settings/settings.go internal/settings/settings_test.go
git commit -m "feat(settings): KEEL_AW_URL with localhost default"
Task 6: Wire Memory into Services and cmd/keeld
Files:
- Modify:
internal/harness/services.go:16-24 - Modify:
cmd/keeld/main.go:16-28(imports) and:60-73(buildServices)
This task is wiring; it is verified by go build plus a manual smoke against the running AW server (Task 9 re-checks end to end).
- Step 1: Add the Services field
In internal/harness/services.go, add the import "keel/internal/memory" and a field to Services:
Memory memory.Store
Update the struct doc to note: Memory is Keel's durable cross-run event store (AW-backed, or a nop when AW is down).
- Step 2: Construct the store in buildServices
In cmd/keeld/main.go, add imports "keel/internal/aw" and "keel/internal/memory". Replace the body of buildServices so it builds memory after the AI backend validates:
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
awURL := s.AWURL
if awURL == "" {
awURL = "http://localhost:5600"
}
var mem memory.Store = memory.NewNop()
awc := aw.New(awURL)
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
} else {
mem = memory.NewAWStore(awc, "keel.events")
log.Printf("memory: keel.events ready at %s", awURL)
}
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Memory: mem,
Clock: time.Now,
}, nil
}
- Step 3: Build and vet
Run: go build ./... && go vet ./...
Expected: no output, exit 0.
- Step 4: Smoke against the running AW server
Run: go run ./cmd/keeld (Ctrl-C after it logs). Expected log line: memory: keel.events ready at http://localhost:5600.
Verify the bucket now exists:
Run: curl -s http://localhost:5600/api/0/buckets/ | python3 -c "import sys,json; print('keel.events' in json.load(sys.stdin))"
Expected: True
- Step 5: Commit
git add internal/harness/services.go cmd/keeld/main.go
git commit -m "feat(keeld): construct AW memory store, nop fallback when AW is down"
Task 7: Off-screen write path
Files:
-
Modify:
internal/mode/offscreen/offscreen.go -
Test:
internal/mode/offscreen/offscreen_test.go -
Step 1: Write the failing test (append to
offscreen_test.go; add"keel/internal/memory"to imports)
// newTestModeMem is newTestMode with an injected in-memory Store.
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Memory: mem,
Clock: func() time.Time { return time.Unix(1000, 0) },
Notify: func() {},
}
return New(svc)
}
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, e := range f.Events() {
if e.Kind == kind {
return e
}
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
return memory.Event{}
}
func TestProposeRecordsProposalMade(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
ev := waitEvent(t, mem, "proposal_made")
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
t.Fatalf("proposal_made data = %+v", ev.Data)
}
}
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
made := waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
taken := waitEvent(t, mem, "action_taken")
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
}
}
func TestDismissRecordsProposalDismissed(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
_ = waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
_ = waitEvent(t, mem, "proposal_dismissed")
}
- Step 2: Run test to verify it fails
Run: go test ./internal/mode/offscreen/ -run 'Records|Dismiss'
Expected: FAIL — New ignores Memory, so no events are ever recorded (waitEvent times out).
- Step 3: Write minimal implementation in
offscreen.go
Add imports: "crypto/rand", "encoding/hex", "log", "keel/internal/memory".
Add event-kind constants near the existing status constants:
const (
kindProposalMade = "proposal_made"
kindActionTaken = "action_taken"
kindProposalDismissed = "proposal_dismissed"
)
Add fields to the Mode struct (after know):
mem memory.Store // may be nil; record helpers guard
now func() time.Time // event timestamps; never nil after New
and after done:
proposalID string
In New, resolve the clock and wire memory:
func New(svc harness.Services) *Mode {
now := svc.Clock
if now == nil {
now = time.Now
}
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
mem: svc.Memory,
now: now,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
In start, the apply closure's success branch records the proposal (off-lock, fire-and-forget):
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
m.proposalID = newProposalID()
m.recordAsync(memory.Event{
Kind: kindProposalMade,
At: m.now(),
Data: map[string]any{
"proposal_id": m.proposalID,
"mode": "offscreen",
"next_action": p.NextAction,
"rationale": p.Rationale,
},
})
})
In confirm, after tasks.Create succeeds, capture the id under the lock and record synchronously:
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
id := m.proposalID
m.status = statusDone
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindActionTaken,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
})
return nil
}
Replace the dismiss case in Command to record:
case "dismiss":
m.mu.Lock()
id := m.proposalID
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindProposalDismissed,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
})
return nil
Add the helpers at the end of the file:
// recordAsync persists an event off the mode's lock, best-effort. Used on the
// propose path, which sets the event under the async apply lock.
func (m *Mode) recordAsync(ev memory.Event) {
if m.mem == nil {
return
}
go func() {
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}()
}
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
// which already do synchronous work.
func (m *Mode) recordSync(ev memory.Event) {
if m.mem == nil {
return
}
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}
// newProposalID returns a short random hex id correlating a proposal with its
// outcome event.
func newProposalID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "proposal"
}
return hex.EncodeToString(b[:])
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/mode/offscreen/
Expected: PASS — new tests plus the five existing tests (which inject no Memory, so the nil-guard keeps them green).
- Step 5: Commit
git add internal/mode/offscreen/offscreen.go internal/mode/offscreen/offscreen_test.go
git commit -m "feat(offscreen): record proposal_made/action_taken/proposal_dismissed"
Task 8: Off-screen read path (brief history + prompt rule)
Files:
-
Modify:
internal/ai/proposer.go(prompt rule) -
Test:
internal/ai/proposer_test.go -
Modify:
internal/mode/offscreen/collect.go(briefHistory + relativeAge + section) -
Test:
internal/mode/offscreen/offscreen_test.go -
Step 1: Write the failing prompt test (append to
proposer_test.go)
func TestProposePromptHasFollowUpRule(t *testing.T) {
p := buildProposePrompt("brief")
if !strings.Contains(p, "recently proposed") {
t.Fatalf("prompt missing follow-up rule:\n%s", p)
}
}
(If proposer_test.go does not already import strings, add it.)
- Step 2: Run it to verify it fails
Run: go test ./internal/ai/ -run FollowUp
Expected: FAIL — rule text absent.
- Step 3: Add the rule in
internal/ai/proposer.go
In buildProposePrompt, add a bullet to the Rules: block (after the rationale: rule):
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
- Step 4: Run it to verify it passes
Run: go test ./internal/ai/
Expected: PASS
- Step 5: Write the failing brief test (append to
offscreen_test.go)
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
mem := memory.NewFake()
ctx := context.Background()
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s"}})
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
brief := m.assembleBrief()
if !strings.Contains(brief, "Recently proposed") {
t.Fatalf("brief missing history section:\n%s", brief)
}
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
t.Fatalf("brief missing confirmed walk:\n%s", brief)
}
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
}
}
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
if strings.Contains(m.assembleBrief(), "Recently proposed") {
t.Fatal("history section should be absent without memory")
}
}
(Add "strings" to the offscreen test imports if not present.)
- Step 6: Run it to verify it fails
Run: go test ./internal/mode/offscreen/ -run Brief
Expected: FAIL — assembleBrief produces no history section.
- Step 7: Implement briefHistory in
internal/mode/offscreen/collect.go
Add "context" is already imported; add "fmt" and "time" to imports. Add constants near the top:
const (
recentProposals = 5 // how many recent proposals to show
outcomeScan = 50 // how many outcome events to scan for correlation
recentWindow = 7 * 24 * time.Hour
)
In assembleBrief, insert the history section after the tasks block (before goals):
if hist := m.briefHistory(); hist != "" {
b.WriteString("## Recently proposed\n")
b.WriteString(hist)
b.WriteString("\n")
}
Add the methods:
// briefHistory renders recent proposals with their outcome, newest first and
// within recentWindow. Returns "" when there is no memory or no recent history.
func (m *Mode) briefHistory() string {
if m.mem == nil {
return ""
}
ctx := context.Background()
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
if err != nil || len(proposals) == 0 {
return ""
}
taken := m.idSet(ctx, kindActionTaken)
dismissed := m.idSet(ctx, kindProposalDismissed)
now := m.now()
var b strings.Builder
for _, ev := range proposals {
if now.Sub(ev.At) > recentWindow {
continue
}
id, _ := ev.Data["proposal_id"].(string)
action, _ := ev.Data["next_action"].(string)
outcome := "unactioned"
if taken[id] {
outcome = "confirmed"
} else if dismissed[id] {
outcome = "dismissed"
}
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
}
return b.String()
}
// idSet reads recent events of a kind and returns their proposal_ids as a set.
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
out := map[string]bool{}
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
if err != nil {
return out
}
for _, e := range evs {
if id, _ := e.Data["proposal_id"].(string); id != "" {
out[id] = true
}
}
return out
}
// relativeAge renders a coarse human age for a duration.
func relativeAge(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
- Step 8: Run it to verify it passes
Run: go test ./internal/mode/offscreen/
Expected: PASS (history tests plus all earlier offscreen tests).
- Step 9: Commit
git add internal/ai/proposer.go internal/ai/proposer_test.go internal/mode/offscreen/collect.go internal/mode/offscreen/offscreen_test.go
git commit -m "feat(offscreen): read recent proposals into the brief; prompt follows up"
Task 9: Full verification + live smoke
Files: none (verification only)
- Step 1: Build, vet, and full test suite
Run: go build ./... && go vet ./... && go test ./...
Expected: all packages ok, exit 0.
- Step 2: Live end-to-end smoke (AW running)
Start the daemon: go run ./cmd/keeld
In the web UI (http://localhost:7777), start the off-screen mode, wait for a proposal, and confirm it.
- Step 3: Verify the event landed in AW
Run: curl -s 'http://localhost:5600/api/0/buckets/keel.events/events?limit=10' | python3 -m json.tool
Expected: at least a proposal_made and an action_taken event, each with data._kind, data.proposal_id, and data.mode == "offscreen", sharing the same proposal_id.
- Step 4: Verify recall on the next run
Stop and restart go run ./cmd/keeld, start off-screen again, and confirm the next proposal is informed by history (it should avoid repeating the just-confirmed action, or follow up on a dismissed one). This is a qualitative check that the brief now carries ## Recently proposed.
- Step 5: Final commit (if any verification fixups were needed)
git add -A
git commit -m "test: verify off-screen AW memory end to end"
(If Steps 1–4 pass with no changes, skip this commit.)
Done when
go build ./... && go vet ./... && go test ./...is green.- The
keel.eventsbucket exists and receivesproposal_made/action_taken/proposal_dismissedwith correlatedproposal_ids. - Off-screen's brief carries a
## Recently proposedsection when history exists, and proposals visibly avoid repetition / follow up. - AW being down degrades to
nopStorewith a log line, and every off-screen command still succeeds (covered by the nil-memory tests).