From 0f1790c8d568fd26542825dc19e360d62dffe479 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 08:01:03 -0400 Subject: [PATCH] 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) --- internal/ai/coach.go | 21 +++++++++++++-------- internal/ai/coach_test.go | 26 +++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/internal/ai/coach.go b/internal/ai/coach.go index 5ceb321..7eab03d 100644 --- a/internal/ai/coach.go +++ b/internal/ai/coach.go @@ -2,9 +2,10 @@ package ai 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 { - 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 @@ -21,16 +22,16 @@ type Service struct { func NewService(b Backend) *Service { return &Service{backend: b} } -func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) { - out, err := s.backend.Run(ctx, buildPrompt(intent)) +func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) { + out, err := s.backend.Run(ctx, buildPrompt(intent, grounding)) if err != nil { return Proposal{}, err } return parseProposal(out) } -func buildPrompt(intent 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. +func buildPrompt(intent, grounding string) string { + preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: {"next_action": "", "success_condition": "", "timebox_minutes": , "allowed_window_classes": [""]} @@ -39,9 +40,13 @@ Rules: - next_action: a single concrete imperative action, doable now. - success_condition: observable and verifiable; how you'd know it is done. - timebox_minutes: a realistic integer number of minutes for the action. -- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty. +- 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. diff --git a/internal/ai/coach_test.go b/internal/ai/coach_test.go index 23b1252..3fc3292 100644 --- a/internal/ai/coach_test.go +++ b/internal/ai/coach_test.go @@ -22,7 +22,7 @@ func (f *fakeBackend) Name() string { return "fake" } func TestServiceCoachSuccess(t *testing.T) { fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`} svc := NewService(fb) - p, err := svc.Coach(context.Background(), "write the tests") + p, err := svc.Coach(context.Background(), "write the tests", "") if err != nil { t.Fatalf("coach: %v", err) } @@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) { func TestServiceCoachBackendError(t *testing.T) { 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") } } func TestServiceCoachUnparseable(t *testing.T) { 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) } } + +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) + } +}