diff --git a/internal/ai/coach.go b/internal/ai/coach.go index aac6387..5ceb321 100644 --- a/internal/ai/coach.go +++ b/internal/ai/coach.go @@ -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": "", "success_condition": "", "timebox_minutes": } +{"next_action": "", "success_condition": "", "timebox_minutes": , "allowed_window_classes": [""]} 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 } diff --git a/internal/ai/proposal.go b/internal/ai/proposal.go index a898d67..0a2ed70 100644 --- a/internal/ai/proposal.go +++ b/internal/ai/proposal.go @@ -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 } diff --git a/internal/ai/proposal_test.go b/internal/ai/proposal_test.go index 0fc510d..1ccc931 100644 --- a/internal/ai/proposal_test.go +++ b/internal/ai/proposal_test.go @@ -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) + } +}