Merge master into the line B review stack (V-405)

The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:46:53 +04:00
160 changed files with 9759 additions and 700 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
func TestHandleAmbientAuth(t *testing.T) {
// "завтра" so the meeting is in the future in every zone: the clock in the
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
// is already stale on a box east of UTC and stores nothing.
// is already stale on a box east of UTC and stores nothing (Vikunja #482).
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
newReq := func(hdr, val string) *http.Request {
+54 -2
View File
@@ -27,6 +27,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/tasks"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
@@ -678,6 +679,8 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
m := shellFuncs()
m["join"] = strings.Join
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
return m
}()).Parse(shellHTML + toolsHTML))
@@ -794,6 +797,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
return out
}
// reminderRow is one line on /reminders, with the payload unwrapped and both
// timestamps already in his clock.
//
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
// shape he never chose, and a time on a page he reads is the time on his wall.
type reminderRow struct {
Created string
Fires string
Status string
Text string
}
// reminderText unwraps the {"text":...} payload the router writes.
//
// A copy of store.ReminderText rather than a call to it, because mavweb is one
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
// whoever renders it. Payload that is not that shape is shown as he said it.
func reminderText(payload string) string {
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if t, ok := m["text"]; ok {
if s, isStr := t.(string); isStr && s != "" {
return s
}
}
}
return strings.TrimSpace(payload)
}
func reminderRows(rs []ipc.Reminder) []reminderRow {
out := make([]reminderRow, 0, len(rs))
for _, r := range rs {
out = append(out, reminderRow{
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
Status: r.Status,
Text: reminderText(r.Payload),
})
}
return out
}
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
@@ -807,7 +855,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
log.Printf("reminders template: %v", err)
}
}
@@ -1355,12 +1403,16 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
servers = nil
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
// flat list stops answering "what can she do to the house" somewhere
// around fifteen rows, and that is the question this page exists for.
if err := toolsTmpl.Execute(w, struct {
Msg string
Proposed []ipc.Tool
Enabled []ipc.Tool
Groups []tool.CapabilityGroup
MCP []ipc.MCPServerStatus
}{msg, proposed, enabled, servers}); err != nil {
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
log.Printf("tools render: %v", err)
}
}
+3 -3
View File
@@ -3,10 +3,10 @@
{{if .Reminders}}<div class=scroll><table>
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
{{range .Reminders}}<tr>
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
<td class=hint>{{.Created}}</td>
<td>{{.Fires}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=text-max>{{.Payload}}</td>
<td class=text-max>{{.Text}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// The page showed the storage envelope and the UTC instant (Vikunja #469).
func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
rows := reminderRows([]ipc.Reminder{{
CreatedTs: fire.Add(-time.Hour),
FireTs: fire,
Status: "pending",
Payload: `{"text":"выпить таблетки"}`,
}})
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
if rows[0].Text != "выпить таблетки" {
t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
}
if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
}
if strings.Contains(rows[0].Text, "{") {
t.Errorf("Text still carries JSON: %q", rows[0].Text)
}
}
// A payload that is not the envelope is his own words, so it is shown as it is.
func TestReminderTextKeepsPlainPayload(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{`{"text":"позвонить маме"}`, "позвонить маме"},
{" полить цветы ", "полить цветы"},
{`{"body":"nope"}`, `{"body":"nope"}`},
{"", ""},
} {
if got := reminderText(tc.in); got != tc.want {
t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+8 -6
View File
@@ -5,9 +5,9 @@
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
@@ -28,14 +28,16 @@
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>