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:
kami
2026-07-30 23:49:10 +04:00
parent 76a6a007ef
commit 20184874b2
14 changed files with 726 additions and 13 deletions
+72
View File
@@ -22,6 +22,7 @@ import (
"github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/morning"
"github.com/robfig/cron/v3"
)
@@ -134,6 +135,11 @@ type Config struct {
// distinction from reminders (user-stated) and care rules (world-state).
Routines []RoutineConfig `json:"routines,omitempty"`
// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
// once near the end of a time window instead of firing one reminder per
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
// Praxis — the ecosystem attention-state service. When configured, maven
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
// Maven never touches Praxis's database directly (ecosystem invariant: no
@@ -181,6 +187,28 @@ type RoutineConfig struct {
Severity int `json:"severity,omitempty"`
}
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
// 0=Sunday..6=Saturday; empty means every day (set two routines under
// different names for weekday/weekend variants).
type MorningRoutineConfig struct {
Name string `json:"name"`
Weekdays []int `json:"weekdays,omitempty"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
NudgeAt string `json:"nudge_at,omitempty"`
Severity int `json:"severity,omitempty"`
Items []MorningRoutineItemConfig `json:"items"`
}
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
// presence within the window counts as completion evidence.
type MorningRoutineItemConfig struct {
Key string `json:"key"`
FactKey string `json:"fact_key"`
Label string `json:"label"`
}
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
// server's wall clock. A window crossing midnight (Start > End) is handled:
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
@@ -456,6 +484,13 @@ func (c *Config) applyDefaults() {
c.Routines[i].Severity = 1
}
}
// morning routines: same safe-floor default as cron routines.
for i := range c.MorningRoutines {
if c.MorningRoutines[i].Severity == 0 {
c.MorningRoutines[i].Severity = 1
}
}
}
func (c *Config) validate() error {
@@ -488,9 +523,46 @@ func (c *Config) validate() error {
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
}
}
if len(c.MorningRoutines) > 0 {
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
return err
}
}
return nil
}
// morningRoutinesFromConfig maps the config's morning-routine blocks to the
// engine type. Shared with the daemon so config validation and daemon wiring
// can never drift on the mapping.
func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
out := make([]morning.Routine, len(mc))
for i, r := range mc {
items := make([]morning.Item, len(r.Items))
for j, it := range r.Items {
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
}
weekdays := make([]time.Weekday, len(r.Weekdays))
for j, w := range r.Weekdays {
weekdays[j] = time.Weekday(w)
}
out[i] = morning.Routine{
Name: r.Name,
Weekdays: weekdays,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
NudgeAt: r.NudgeAt,
Severity: r.Severity,
Items: items,
}
}
return out
}
// MorningRoutinesFromConfig is the exported form daemon wiring uses.
func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
return morningRoutinesFromConfig(mc)
}
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
// a plaintext store. A configured-but-invalid key is an error (fail closed,