// Package loop is maven's proactive trigger engine. // // Loop contract (from spec): // // - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free. // - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State. // - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain". // - the GATE is universal, applied by the loop, never per-rule. quiet-hours, // presence, cooldown, snooze, calendar-busy all live in ONE fires(). cross- // cutting restraint in one place or it drifts. // - ONE nudge per tick (max severity), never dogpile. // - rules as code, not a DSL. revisit at ~30 rules. // // Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler: // - predicate: `fire_ts <= now AND pending` // - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point // - snooze still applies; fires once (pending → fired) // // Architecture: the Gatherer is the only impure bit — it builds a State snapshot // under the store lock. Everything from there on is PURE functions over State. // Phrasing (the llm lane) and delivery are out of scope here — the loop emits // Decisions; the daemon wires them to phrasing + delivery. package loop import ( "sort" "strings" "time" "github.com/kami/maven/internal/store" ) // Severity — higher = more insistent (harder to suppress). // // Per the spec's delivery table: // // sev1–2: care nudges (water, meal, break). voice when present; DROP on away. // sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away. // sev4: ops hard (disk critical, service down). voice + ntfy present; // telegram, repeat til ack, when away. // // "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent // candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1). type Severity int const ( Sev1 Severity = 1 // care, lowest insistence — drops on away Sev2 Severity = 2 Sev3 Severity = 3 // ops soft Sev4 Severity = 4 // ops hard, highest insistence ) func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away // State — the loop's view of the world, gathered under the store lock at the // start of each tick. From here on everything is pure — predicates and the gate // read ONLY this struct and never touch the store. // // Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups // the rules + gate have pre-arranged. Presence/PresenceScore/Now are global. // The Gatherer decides what to populate; rules see what's in the snapshot. type State struct { Now time.Time Presence store.Bucket PresenceScore float64 // Latest non-voided fact per key the loop requested at gather time. // Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire. Facts map[string]store.Fact // Last nudge per rule, for cooldown enforcement (newest send). LastNudge map[string]store.Nudge // Per-rule snooze-until — explicit "don't bother me about this rule until X". // Overlays on top of cooldown; both must be clear to fire. SnoozeUntil map[string]time.Time // Per-rule cooldown-until — derived from LastNudge + cooldown duration // (the auto-tuner adjusts the duration via feedback outcomes). CooldownUntil map[string]time.Time // Cross-cutting env flags derived from facts at gather time: // QuietHours — the care gate suppresses sev1–2 when true. ops still surface. QuietHours bool // CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate. CalendarBusy bool } // Fact returns the latest non-voided fact for key, or // (store.Fact{}, false) — the predicate's "no data" case. // since(key)==null → don't fire is implemented by checking the bool. func (s State) Fact(key string) (store.Fact, bool) { f, ok := s.Facts[key] if !ok || f.Ts.IsZero() { return store.Fact{}, false } return f, true } // FactsUnder returns every gathered fact whose key starts with prefix, ordered // by key so a caller that names them speaks them in a stable order. Facts with // a zero Ts are skipped, the same "no data" rule Fact applies. func (s State) FactsUnder(prefix string) []store.Fact { var out []store.Fact for k, f := range s.Facts { if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() { out = append(out, f) } } sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out } // NudgedSince reports whether rule already sent a nudge at or after ts. // // It is what makes a rule edge-triggered. A polled fact is written only when // the value changes, so its Ts is the moment the service went down — but the // predicate reads the current value, so a service that stays down keeps // qualifying forever and cooldown alone only slows the repetition. Asking // whether he was already told about THIS transition stops it. func (s State) NudgedSince(rule string, ts time.Time) bool { n, ok := s.LastNudge[rule] return ok && !n.Ts.Before(ts) } // Since returns the duration since the latest fact for key, or (0,false). // "false" ⇒ no data ⇒ shuts up when uncertain. func (s State) Since(key string) (time.Duration, bool) { f, ok := s.Fact(key) if !ok { return 0, false } if s.Now.Before(f.Ts) { return 0, true } return s.Now.Sub(f.Ts), true }