package session import ( "antidrift/internal/domain" "sort" "time" ) // CommitmentView is the UI projection of the active commitment. type CommitmentView struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` DeadlineUnixSecs int64 `json:"deadline_unix_secs"` } // ProposalView / CoachView project the ephemeral planning-coach state. type ProposalView struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` } // DriftView is the active-only drift projection. Nudge is a separate axis from // Status: it is populated precisely when Status is "ontask" (semantic drift // inside an allowed app), so it cannot be folded into the status enum. type DriftView struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` Nudge string `json:"nudge,omitempty"` Enforced bool `json:"enforced,omitempty"` } type CoachView struct { Status string `json:"status"` Proposal *ProposalView `json:"proposal,omitempty"` Error string `json:"error,omitempty"` } // TaskView is one to-do item in the planning tasks list. type TaskView struct { ID string `json:"id"` Title string `json:"title"` Day string `json:"day,omitempty"` } // TasksView projects the ephemeral planning tasks state (Marvin's today list). type TasksView struct { Status string `json:"status"` Tasks []TaskView `json:"tasks,omitempty"` } // 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 cross the wire. type KnowledgeView struct { Status string `json:"status"` Path string `json:"path,omitempty"` Chars int `json:"chars,omitempty"` } // ReflectionView projects the reviewer's output. Recap is shown on Review; // CarryForward is shown on the next Planning screen. Unlike the knowledge // profile, these short lines exist to be displayed, so they cross the wire. type ReflectionView struct { Status string `json:"status,omitempty"` Recap string `json:"recap,omitempty"` CarryForward string `json:"carry_forward,omitempty"` } // WindowView / BucketView / EvidenceView are the evidence projection. type WindowView struct { Class string `json:"class"` Title string `json:"title"` } type BucketView struct { Class string `json:"class"` Title string `json:"title"` Seconds int64 `json:"seconds"` } type EvidenceView struct { Available bool `json:"available"` Reason string `json:"reason"` Current WindowView `json:"current"` SwitchCount int `json:"switch_count"` Buckets []BucketView `json:"buckets"` } // State is the broadcastable view of the controller. type State struct { RuntimeState domain.RuntimeState `json:"runtime_state"` Commitment *CommitmentView `json:"commitment"` Evidence *EvidenceView `json:"evidence"` Coach *CoachView `json:"coach,omitempty"` Tasks *TasksView `json:"tasks,omitempty"` Knowledge *KnowledgeView `json:"knowledge,omitempty"` Reflection *ReflectionView `json:"reflection,omitempty"` Drift *DriftView `json:"drift,omitempty"` } func (c *Controller) stateLocked() State { st := State{RuntimeState: c.runtimeState} if c.commitment != nil { view := &CommitmentView{ NextAction: c.commitment.NextAction, SuccessCondition: c.commitment.SuccessCondition, TimeboxSecs: c.commitment.TimeboxSecs, } if !c.deadline.IsZero() { view.DeadlineUnixSecs = c.deadline.Unix() } st.Commitment = view } if c.stats != nil { st.Evidence = &EvidenceView{ Available: c.stats.Current.Health.Available, Reason: c.stats.Current.Health.Reason, Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, SwitchCount: c.stats.SwitchCount, Buckets: bucketViews(c.stats.Buckets), } } if c.runtimeState == domain.RuntimePlanning { // Surface the live window so planning can show the real class to copy into // the allowed-apps field — exact class names are otherwise invisible, and a // non-matching token silently disables the local on-task fast path. st.Evidence = &EvidenceView{ Available: c.latestWindow.Health.Available, Reason: c.latestWindow.Health.Reason, Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title}, } status := c.coachStatus if status == "" { status = coachIdle } cv := &CoachView{Status: status, Error: c.coachErr} if c.coachProposal != nil { cv.Proposal = &ProposalView{ NextAction: c.coachProposal.NextAction, SuccessCondition: c.coachProposal.SuccessCondition, TimeboxSecs: c.coachProposal.TimeboxSecs, AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, } } st.Coach = cv if c.tasksProvider != nil { tstatus := c.tasksStatus if tstatus == "" { tstatus = tasksIdle } tv := &TasksView{Status: tstatus} for _, t := range c.tasksList { tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) } st.Tasks = tv } if c.knowledgeSrc != nil { kstatus := c.knowledgeStatus if kstatus == "" { kstatus = knowledgeIdle } st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} } if c.carryForward != "" { st.Reflection = &ReflectionView{CarryForward: c.carryForward} } } if c.runtimeState == domain.RuntimeReview { rstatus := c.reflectionStatus if rstatus == "" { rstatus = reflectionIdle } st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap} } if c.runtimeState == domain.RuntimeActive { status := c.driftStatus if status == "" { status = driftIdle } enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced} } return st } func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { out := make([]BucketView, 0, len(buckets)) for k, d := range buckets { out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) } sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) return out }