Extend coach proposal with allowed window classes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 16:49:06 -04:00
parent 83bae00e45
commit 75d31b5fa8
3 changed files with 42 additions and 8 deletions
+2 -1
View File
@@ -33,12 +33,13 @@ 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.
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>}
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
Rules:
- next_action: a single concrete imperative action, doable now.
- success_condition: observable and verifiable; how you'd know it is done.
- timebox_minutes: a realistic integer number of minutes for the action.
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
User intent: ` + intent
}
+20 -7
View File
@@ -13,9 +13,10 @@ import (
// Proposal is the coach's structured suggestion for a commitment. It is not a
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
type Proposal struct {
NextAction string
SuccessCondition string
TimeboxSecs int64
NextAction string
SuccessCondition string
TimeboxSecs int64
AllowedWindowClasses []string
}
var (
@@ -61,9 +62,10 @@ func extractJSON(s string) (string, error) {
}
type rawProposal struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxMinutes int64 `json:"timebox_minutes"`
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxMinutes int64 `json:"timebox_minutes"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
}
// parseProposal extracts and validates a Proposal from raw CLI output.
@@ -91,5 +93,16 @@ func parseProposal(s string) (Proposal, error) {
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
return Proposal{}, ErrInvalidProposal
}
return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil
classes := make([]string, 0, len(raw.AllowedWindowClasses))
for _, cls := range raw.AllowedWindowClasses {
if t := strings.TrimSpace(cls); t != "" {
classes = append(classes, t)
}
}
return Proposal{
NextAction: na,
SuccessCondition: sc,
TimeboxSecs: raw.TimeboxMinutes * 60,
AllowedWindowClasses: classes,
}, nil
}
+20
View File
@@ -69,3 +69,23 @@ func TestParseProposal(t *testing.T) {
}
}
}
func TestParseProposalReadsAllowedClasses(t *testing.T) {
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20,"allowed_window_classes":["code"," firefox ",""]}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.AllowedWindowClasses) != 2 || p.AllowedWindowClasses[0] != "code" || p.AllowedWindowClasses[1] != "firefox" {
t.Fatalf("classes wrong (blanks should drop, trim applied): %#v", p.AllowedWindowClasses)
}
}
func TestParseProposalAllowedClassesOptional(t *testing.T) {
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.AllowedWindowClasses) != 0 {
t.Fatalf("absent field should yield empty slice, got %#v", p.AllowedWindowClasses)
}
}