Add the morning routine engine — a daily checklist, not four timers
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for a daily window: several items, each evidenced by a fact key, completed in any order, checked once near the end of the window. Modelling it as four independent reminder timers would stack into exactly the kind of noise Maven is supposed not to produce, so the engine nags at most once per day per routine and only for what is actually still missing. internal/morning follows the established pure-engine pattern (loop, routine, pattern): no store, no clock of its own. Evaluate answers "what's still missing" at any point; Due decides whether to nag. The impurity — reading facts under the store lock, holding the last-nudge map across ticks — stays in the tick driver, which calls Due each tick exactly as it does for loop.Rule and routine.Routine. Completion evidence is a fact key's latest non-voided value timestamped inside today's window, so manual ("выпил воды", voice-tapped) and inferred (another daemon writing the same key) are indistinguishable and both count. Weekdays scopes which days a routine applies to, so weekday/weekend variants are two routine rows rather than a special case in the engine. Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb built on the same server-rendered shape as /trace — no live-update loop, since checklist state moves on the scale of minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
This commit is contained in:
+36
-3
@@ -63,6 +63,9 @@ var voiceHTML string
|
||||
//go:embed ecosystem.html
|
||||
var ecosystemHTML string
|
||||
|
||||
//go:embed morning.html
|
||||
var morningHTML string
|
||||
|
||||
// ── Ethos Workstation Shell ──
|
||||
//
|
||||
// Two template pieces that wrap every page:
|
||||
@@ -95,6 +98,7 @@ var sidebarSections = []struct {
|
||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,6 +173,8 @@ func pageIcon(key string) string {
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
case "routines":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
||||
case "morning":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
case "chat":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
||||
case "voice":
|
||||
@@ -199,6 +205,8 @@ func pageTitle(key string) string {
|
||||
return "Reminders"
|
||||
case "routines":
|
||||
return "Routines"
|
||||
case "morning":
|
||||
return "Morning Routines"
|
||||
case "chat":
|
||||
return "Chat"
|
||||
case "voice":
|
||||
@@ -291,6 +299,12 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
@@ -384,6 +398,9 @@ func main() {
|
||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRoutines(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleMorning(w, r, core)
|
||||
})
|
||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEcosystem(w, r, ecoURLsCfg)
|
||||
@@ -829,6 +846,24 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
status, err := core.MorningStatus(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := morningTmpl.Execute(w, status); err != nil {
|
||||
log.Printf("morning render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||
@@ -901,9 +936,7 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
// nil session ⇒ WebAuthn not configured; step-up gate not applicable
|
||||
// (see wiring in main — asserting would be impossible, not just unmet).
|
||||
if session != nil && !session.IsStepUp() {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user