diff --git a/SESSION-05-07-2026.md b/SESSION-05-07-2026.md
index b97765a..a92deb3 100644
--- a/SESSION-05-07-2026.md
+++ b/SESSION-05-07-2026.md
@@ -63,7 +63,7 @@ Notes:
| 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done |
| 13 | **Capability model** — `scope` column on tools table, migration, UI, tests | 6b80fd0 | done |
| 14 | **Notification batching/digest mode** — in-memory queue, configurable window/max_items/severity_ceiling | 354990f | done |
-| 15 | **Rule trace / explanation engine** — `why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending |
+| 15 | **Rule trace/explanation engine** — ExplainTick/ExplainGate, TickTrace IPC, daemon cache | 2689db1 | done |
| 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done |
Notes:
@@ -79,7 +79,7 @@ Notes:
|---|------|--------|--------|
| 17 | **In-process auth gate for /tools** — local PasskeySession check on POST, returns 403 if unasserted | 5afff00 | done |
| 18 | **Digest / notification history UI** — section on `/dash` showing batched notifications | — | pending |
-| 19 | **Rule trace page** — new `/trace` route showing predicate eval results per rule per tick | — | pending |
+| 19 | **Rule trace page** — new /trace route showing predicate eval results per rule per tick | — | pending (depends on #15 backend, can now build)
| 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done |
| 21 | **PWA icons** — SVG icon + manifest.json icons array | 00a3bba | done |
| 22 | **Language unification** — bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done |
diff --git a/cmd/mavweb/dash.html b/cmd/mavweb/dash.html
index 699c9f3..b21f013 100644
--- a/cmd/mavweb/dash.html
+++ b/cmd/mavweb/dash.html
@@ -10,7 +10,7 @@
.updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem}
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
-
+
presence
{{.Presence.Bucket}} — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})
обновляется каждые 10с
diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go
index 557cab1..c17d387 100644
--- a/cmd/mavweb/handlers_test.go
+++ b/cmd/mavweb/handlers_test.go
@@ -55,6 +55,10 @@ type fakeCore struct {
// for handleHistory tests
historyFacts []ipc.Fact
historyErr error
+
+ // for handleTrace tests
+ tickTrace ipc.TickTrace
+ traceErr error
}
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
@@ -131,6 +135,13 @@ func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) {
return f.revertNewID, nil
}
+func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
+ if f.traceErr != nil {
+ return ipc.TickTrace{}, f.traceErr
+ }
+ return f.tickTrace, nil
+}
+
// --- GET ---
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
@@ -578,6 +589,88 @@ func TestHandleHistory(t *testing.T) {
})
}
+// --- handleTrace ---
+
+func TestHandleTrace(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil core returns 503", func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), nil)
+ if rr.Code != http.StatusServiceUnavailable {
+ t.Errorf("status = %d, want 503", rr.Code)
+ }
+ })
+
+ t.Run("core TickTrace error returns 502", func(t *testing.T) {
+ core := &fakeCore{traceErr: ipc.ErrNoFact}
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
+ if rr.Code != http.StatusBadGateway {
+ t.Errorf("status = %d, want 502", rr.Code)
+ }
+ })
+
+ t.Run("renders template with trace data", func(t *testing.T) {
+ now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
+ snooze := time.Date(2025, 6, 1, 13, 0, 0, 0, time.UTC)
+ core := &fakeCore{
+ tickTrace: ipc.TickTrace{
+ Now: now,
+ Winner: "win-rule",
+ Rules: []ipc.RuleTrace{
+ {
+ RuleName: "win-rule",
+ Severity: 5,
+ PredicateResult: true,
+ GateResult: true,
+ GateDetail: ipc.GateDetail{Presence: "present"},
+ WasSelected: true,
+ },
+ {
+ RuleName: "lose-rule",
+ Severity: 3,
+ PredicateResult: true,
+ GateResult: false,
+ GateBlockedBy: "quiet_hours",
+ GateDetail: ipc.GateDetail{
+ QuietHours: true,
+ Presence: "away",
+ SnoozeUntil: &snooze,
+ },
+ WasSelected: false,
+ LostTo: "win-rule",
+ },
+ },
+ },
+ }
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "win-rule") {
+ t.Error("rendered output missing winning rule name")
+ }
+ if !strings.Contains(body, "lose-rule") {
+ t.Error("rendered output missing losing rule name")
+ }
+ if !strings.Contains(body, "quiet_hours") {
+ t.Error("rendered output missing gate blocked by")
+ }
+ if !strings.Contains(body, "5") {
+ t.Error("rendered output missing severity")
+ }
+ if !strings.Contains(body, "3") {
+ t.Error("rendered output missing severity for second rule")
+ }
+ if strings.Contains(body, "nothing fired") {
+ t.Error("rendered 'nothing fired' but a winner was set")
+ }
+ })
+}
+
// --- handleRevert ---
func TestHandleRevert(t *testing.T) {
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index 7f28b67..a0ad818 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -48,6 +48,9 @@ var dashHTML string
//go:embed history.html
var historyHTML string
+//go:embed trace.html
+var traceHTML string
+
// dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS,
// no client fetch); meta-refresh keeps it live. html/template escapes the user
// text in facts/nudges. Read-only: browses the append-only store via CoreAPI,
@@ -133,6 +136,9 @@ func main() {
mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) {
handleHistory(w, r, core)
})
+ mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) {
+ handleTrace(w, r, core)
+ })
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
handleRevert(w, r, core)
})
@@ -372,6 +378,22 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
var historyTmpl = template.Must(template.New("history").Parse(historyHTML))
+var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
+ "ago": func(t time.Time) string {
+ if t.IsZero() {
+ return "never"
+ }
+ return time.Since(t).Round(time.Second).String() + " ago"
+ },
+ "fmtTime": func(t *time.Time) string {
+ if t == nil || t.IsZero() {
+ return "—"
+ }
+ return t.Format("15:04:05")
+ },
+ "join": strings.Join,
+}).Parse(traceHTML))
+
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable)
@@ -392,6 +414,24 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
+func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+ if core == nil {
+ http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
+ return
+ }
+ ctx := r.Context()
+ trace, err := core.TickTrace(ctx)
+ if err != nil {
+ log.Printf("trace: %v", err)
+ http.Error(w, "core read failed", http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := traceTmpl.Execute(w, trace); err != nil {
+ log.Printf("trace render: %v", err)
+ }
+}
+
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html
new file mode 100644
index 0000000..61c176e
--- /dev/null
+++ b/cmd/mavweb/trace.html
@@ -0,0 +1,37 @@
+
+maven · rule trace
+
+
+rule trace
+{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+
+| rule | sev | predicate | gate | blocked by | detail | selected | lost to |
+{{range .Rules}}
+| {{.RuleName}} |
+{{.Severity}} |
+{{.PredicateResult}} |
+{{.GateResult}} |
+{{.GateBlockedBy}} |
+gate
+snooze_until: {{.GateDetail.SnoozeUntil | fmtTime}}
+cooldown_until: {{.GateDetail.CooldownUntil | fmtTime}}
+quiet_hours: {{.GateDetail.QuietHours}}
+calendar_busy: {{.GateDetail.CalendarBusy}}
+presence: {{.GateDetail.Presence}}
+inert_keys_missing: {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
+ |
+{{.WasSelected}} |
+{{.LostTo}} |
+
{{end}}
+