diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go
index 40e41cb..c7c20a5 100644
--- a/cmd/mavend/actions_note.go
+++ b/cmd/mavend/actions_note.go
@@ -11,6 +11,12 @@ import (
// actionNote handles router.IntentNote: embed the note, persist it, and
// index it for recall.
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
+ // An utterance that explicitly files a task is work, not recall, and
+ // belongs in the task store (Vikunja #130). Checked before the embedding
+ // is paid for. Everything else is a note, exactly as before.
+ if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
+ return reply
+ }
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go
index df1f5d5..5e27c58 100644
--- a/cmd/mavend/actions_query.go
+++ b/cmd/mavend/actions_query.go
@@ -56,6 +56,12 @@ var querySources = []querySource{
// habit marker ("обычно", "каждый", …), so a question about this coming
// Wednesday still reaches the calendar.
{"habits", (*reactiveHandler).queryHabits},
+ // Before "calendar" and before the recall sources: "что мне нужно
+ // сделать?" is a question about the task list, and the notes pass would
+ // otherwise answer it with whatever note happens to be nearest. Its
+ // matcher requires a task noun or an explicit "что … сделать", so a
+ // date-bearing question still reaches the calendar.
+ {"tasks", (*reactiveHandler).queryTasks},
{"calendar", (*reactiveHandler).queryCalendar},
{"weather", (*reactiveHandler).queryWeather},
{"embed", (*reactiveHandler).queryEmbed},
diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go
new file mode 100644
index 0000000..93bc056
--- /dev/null
+++ b/cmd/mavend/actions_task.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/router"
+ "github.com/kami/maven/internal/store"
+)
+
+// Task capture on the voice/chat path (Vikunja #130).
+//
+// Two halves, both deliberately small:
+//
+// - captureTaskFromNote runs at the top of actionNote. An utterance that
+// explicitly files a task ("добавь в задачи купить молоко") goes to the task
+// store instead of the note store. Anything without an explicit marker is
+// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
+// not become a task.
+// - queryTasks is a query source that reads the list back.
+//
+// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
+// rule reads the table.
+
+// captureTaskFromNote claims the turn when the utterance explicitly files a
+// task, returning the reply. ("", false) hands the turn back to the note path.
+func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
+ text, ok := router.ParseTaskCapture(dec.Utterance)
+ if !ok {
+ return "", false
+ }
+ resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
+ Text: text,
+ Source: "tap:voice",
+ Status: store.TaskOpen, // he stated it himself — not a candidate
+ Ts: h.now(),
+ })
+ if err != nil {
+ log.Printf("voice: capture task: %v", err)
+ return "не получилось записать задачу.", true
+ }
+ if !resp.Created {
+ return "это уже в списке.", true
+ }
+ return "записала: " + text, true
+}
+
+// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
+//
+// Reads the live set and recites it. Newest first, which is the order the store
+// returns: this source has no opinion about which task matters more, and
+// pretending otherwise would be a guess. Ranking is Vikunja #129.
+func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
+ if !router.IsTaskListQuery(t.dec.Utterance) {
+ return "", false
+ }
+ tasks, err := h.api.ListTasks(ctx, "live")
+ if err != nil {
+ log.Printf("voice: list tasks: %v", err)
+ return "не получилось посмотреть задачи.", true
+ }
+ return formatTaskListRU(tasks), true
+}
+
+// formatTaskListRU renders the live task list the way Maven says it. Candidates
+// are named as candidates — a task she pulled out of his mail is something she
+// suggests, and saying it in the same breath as work he actually stated would
+// put words in his mouth.
+func formatTaskListRU(tasks []ipc.Task) string {
+ var open, cands []string
+ for _, t := range tasks {
+ switch t.Status {
+ case store.TaskCandidate:
+ cands = append(cands, t.Text)
+ default:
+ open = append(open, t.Text)
+ }
+ }
+ if len(open) == 0 && len(cands) == 0 {
+ return "задач нет."
+ }
+ var b strings.Builder
+ if len(open) > 0 {
+ b.WriteString("в списке: ")
+ b.WriteString(strings.Join(open, "; "))
+ b.WriteString(".")
+ }
+ if len(cands) > 0 {
+ if b.Len() > 0 {
+ b.WriteString(" ")
+ }
+ b.WriteString("ещё я нашла, но ты не подтвердил: ")
+ b.WriteString(strings.Join(cands, "; "))
+ b.WriteString(".")
+ }
+ return b.String()
+}
diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go
new file mode 100644
index 0000000..8e1fa1e
--- /dev/null
+++ b/cmd/mavend/actions_task_test.go
@@ -0,0 +1,188 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/router"
+)
+
+// taskAPI answers only the three task methods; every other call is
+// unimplemented, which is the assertion that capture needs nothing else — in
+// particular no embedder, so a filed task costs no model call.
+type taskAPI struct {
+ ipc.UnimplementedCoreAPI
+
+ captured []ipc.CaptureTaskReq
+ created bool
+ capErr error
+
+ tasks []ipc.Task
+ listArg string
+ listErr error
+}
+
+func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
+ a.captured = append(a.captured, req)
+ if a.capErr != nil {
+ return ipc.CaptureTaskResp{}, a.capErr
+ }
+ return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
+}
+
+func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
+ a.listArg = status
+ return a.tasks, a.listErr
+}
+
+func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
+
+func taskHandler(api ipc.CoreAPI) *reactiveHandler {
+ return &reactiveHandler{api: api, now: taskNow}
+}
+
+func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
+ api := &taskAPI{created: true}
+ h := taskHandler(api)
+ reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
+ Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
+ })
+ if !ok {
+ t.Fatal("an explicit capture must claim the turn")
+ }
+ if len(api.captured) != 1 {
+ t.Fatalf("captured %d, want 1", len(api.captured))
+ }
+ got := api.captured[0]
+ if got.Text != "купить молоко" {
+ t.Errorf("text = %q, want the marker stripped", got.Text)
+ }
+ if got.Source != "tap:voice" {
+ t.Errorf("source = %q, want tap:voice", got.Source)
+ }
+ if got.Status != "open" {
+ t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
+ }
+ if !got.Ts.Equal(taskNow()) {
+ t.Errorf("ts = %v, want the handler clock", got.Ts)
+ }
+ if !strings.Contains(reply, "купить молоко") {
+ t.Errorf("reply = %q, want it to read the task back", reply)
+ }
+}
+
+// A note is still a note: capture only fires on an explicit marker, so
+// ordinary recall is untouched.
+func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
+ api := &taskAPI{}
+ h := taskHandler(api)
+ for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
+ if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
+ t.Errorf("%q was captured as a task", u)
+ }
+ }
+ if len(api.captured) != 0 {
+ t.Errorf("captured %d requests, want none", len(api.captured))
+ }
+}
+
+func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
+ h := taskHandler(&taskAPI{created: false})
+ reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
+ if !ok {
+ t.Fatal("expected the capture path to claim it")
+ }
+ if !strings.Contains(reply, "уже") {
+ t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
+ }
+}
+
+func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
+ h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
+ reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
+ if !ok {
+ t.Fatal("a failed capture still claims the turn — the note path must not double-write")
+ }
+ if !strings.Contains(reply, "не получилось") {
+ t.Errorf("reply = %q, want an honest failure", reply)
+ }
+}
+
+func TestQueryTasksRecitesTheLiveList(t *testing.T) {
+ api := &taskAPI{tasks: []ipc.Task{
+ {ID: 1, Text: "купить молоко", Status: "open"},
+ {ID: 2, Text: "продлить страховку", Status: "candidate"},
+ }}
+ h := taskHandler(api)
+ reply, ok := h.queryTasks(context.Background(), &queryTurn{
+ dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
+ })
+ if !ok {
+ t.Fatal("the task source must claim a task-list question")
+ }
+ if api.listArg != "live" {
+ t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
+ }
+ if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
+ t.Errorf("reply = %q, want both tasks", reply)
+ }
+ // The candidate must be named as unconfirmed, not recited as his work.
+ openIdx := strings.Index(reply, "купить молоко")
+ candIdx := strings.Index(reply, "продлить страховку")
+ if !(openIdx < candIdx) {
+ t.Errorf("reply = %q, want confirmed work before candidates", reply)
+ }
+ if !strings.Contains(reply, "не подтвердил") {
+ t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
+ }
+}
+
+func TestQueryTasksEmptyList(t *testing.T) {
+ h := taskHandler(&taskAPI{})
+ reply, ok := h.queryTasks(context.Background(), &queryTurn{
+ dec: router.Decision{Utterance: "что мне нужно сделать?"},
+ })
+ if !ok {
+ t.Fatal("expected the task source to claim it")
+ }
+ if reply != "задач нет." {
+ t.Errorf("reply = %q", reply)
+ }
+}
+
+func TestQueryTasksPassesOtherQuestions(t *testing.T) {
+ api := &taskAPI{}
+ h := taskHandler(api)
+ for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
+ if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
+ t.Errorf("the task source claimed %q", u)
+ }
+ }
+ if api.listArg != "" {
+ t.Error("a non-task question must not read the task list")
+ }
+}
+
+// The chain must reach the task source before the recall sources, or "что мне
+// нужно сделать?" gets answered by whatever note is nearest.
+func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
+ var tasksAt, notesAt = -1, -1
+ for i, src := range querySources {
+ switch src.name {
+ case "tasks":
+ tasksAt = i
+ case "notes":
+ notesAt = i
+ }
+ }
+ if tasksAt < 0 || notesAt < 0 {
+ t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
+ }
+ if tasksAt > notesAt {
+ t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
+ }
+}
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index 9086f4b..953482d 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -58,6 +58,9 @@ var notificationsHTML string
//go:embed reminders.html
var remindersHTML string
+//go:embed tasks.html
+var tasksHTML string
+
//go:embed voice.html
var voiceHTML string
@@ -97,6 +100,7 @@ var sidebarSections = []struct {
Pages: []struct{ Label, URL, Key string }{
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
+ {Label: "Tasks", URL: "/tasks", Key: "tasks"},
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
{Label: "Routines", URL: "/routines", Key: "routines"},
{Label: "Morning", URL: "/morning", Key: "morning"},
@@ -170,6 +174,8 @@ func pageIcon(key string) string {
return ``
case "notifications":
return ``
+ case "tasks":
+ return ``
case "reminders":
return ``
case "routines":
@@ -202,6 +208,8 @@ func pageTitle(key string) string {
return "Rule Trace"
case "notifications":
return "Notifications"
+ case "tasks":
+ return "Tasks"
case "reminders":
return "Reminders"
case "routines":
@@ -408,6 +416,11 @@ func main() {
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
handleReminders(w, r, core)
})
+ // /tasks — capture + review. POST is not step-up gated; see handleTasks for
+ // why a task write is not in the same class as /tools or /routines.
+ mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
+ handleTasks(w, r, core)
+ })
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
@@ -744,6 +757,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
+var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
+
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
@@ -814,6 +829,145 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
+// taskRow is one line on /tasks, with every timestamp already formatted so the
+// template holds no date logic.
+type taskRow struct {
+ ID int64
+ Text string
+ Source string
+ Evidence string
+ Status string
+ Due string
+ Created string
+ Resolved string
+}
+
+// handleTasks serves the task review surface (GET) and the four writes it
+// offers (POST): add, confirm, done, drop.
+//
+// Not step-up gated, unlike /tools and /routines, and the difference is the
+// point: enabling a tool defines argv Maven will execute, and accepting a
+// routine hands the tick loop a new standing reason to interrupt him. A task is
+// neither — nothing in the tick loop reads the tasks table, so the worst a
+// weaker caller can do here is write a line onto a list he reads himself. It
+// still sits behind whatever transport auth fronts mavweb, like every other
+// page.
+//
+// "confirm" is the only interesting move: it promotes a candidate Maven derived
+// from something she read into work he owns. That review step is why derived
+// tasks are captured as candidates in the first place.
+func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+ if core == nil {
+ http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
+ return
+ }
+ ctx := r.Context()
+ var msg, errMsg string
+ if r.Method == http.MethodPost {
+ var err error
+ msg, err = applyTaskPost(ctx, core, r)
+ if err != nil {
+ log.Printf("tasks: %v", err)
+ errMsg = err.Error()
+ }
+ }
+
+ all, err := core.ListTasks(ctx, "")
+ if err != nil {
+ log.Printf("tasks: list: %v", err)
+ http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ var cands, open, resolved []taskRow
+ for _, t := range all {
+ row := taskRow{
+ ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
+ Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
+ Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
+ }
+ switch t.Status {
+ case "candidate":
+ cands = append(cands, row)
+ case "open":
+ open = append(open, row)
+ default:
+ resolved = append(resolved, row)
+ }
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tasksTmpl.Execute(w, struct {
+ Msg, Err string
+ Candidates []taskRow
+ Open []taskRow
+ Resolved []taskRow
+ }{msg, errMsg, cands, open, resolved}); err != nil {
+ log.Printf("tasks render: %v", err)
+ }
+}
+
+// applyTaskPost performs one write and returns the message to show. A bad
+// request returns an error, which the page renders inline rather than as a
+// bare 400 — this is a form surface, not an API.
+func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
+ action := r.FormValue("action")
+ if action == "add" {
+ text := strings.TrimSpace(r.FormValue("text"))
+ if text == "" {
+ return "", errors.New("empty task text")
+ }
+ req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
+ if d := r.FormValue("due"); d != "" {
+ due, err := time.ParseInLocation("2006-01-02", d, time.Local)
+ if err != nil {
+ return "", fmt.Errorf("bad due date %q", d)
+ }
+ req.Due = &due
+ }
+ resp, err := core.CaptureTask(ctx, req)
+ if err != nil {
+ return "", err
+ }
+ if !resp.Created {
+ return "already on the list", nil
+ }
+ return "added task", nil
+ }
+
+ var id int64
+ if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
+ return "", errors.New("invalid id")
+ }
+ var status, msg string
+ switch action {
+ case "confirm":
+ status, msg = "open", "confirmed task"
+ case "done":
+ status, msg = "done", "task done"
+ case "drop":
+ status, msg = "dropped", "dropped task"
+ default:
+ return "", fmt.Errorf("unknown action %q", action)
+ }
+ if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
+ return "", err
+ }
+ return msg, nil
+}
+
+func fmtTaskTime(t *time.Time) string {
+ if t == nil || t.IsZero() {
+ return "—"
+ }
+ return t.Local().Format("02 Jan 15:04")
+}
+
+func fmtTaskDate(t *time.Time) string {
+ if t == nil || t.IsZero() {
+ return "—"
+ }
+ return t.Local().Format("02 Jan")
+}
+
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {
diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html
new file mode 100644
index 0000000..5ebfb96
--- /dev/null
+++ b/cmd/mavweb/tasks.html
@@ -0,0 +1,76 @@
+{{template "shellTop" "tasks"}}
+
Tasks
+{{if .Msg}}
{{.Msg}}
{{end}}
+{{if .Err}}
{{.Err}}
{{end}}
+
+
+
add
+
+
+
+{{if .Candidates}}
+
+
found, not confirmed {{len .Candidates}}
+
maven derived these from something she read. nothing counts as your work until you confirm it.
+
+{{end}}
+{{template "shellBottom"}}
diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go
new file mode 100644
index 0000000..cdd5da7
--- /dev/null
+++ b/cmd/mavweb/tasks_test.go
@@ -0,0 +1,166 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kami/maven/internal/ipc"
+)
+
+// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
+// writes the page made.
+type fakeTaskCore struct {
+ ipc.UnimplementedCoreAPI
+
+ tasks []ipc.Task
+ listErr error
+
+ captured []ipc.CaptureTaskReq
+ created bool
+ captureErr error
+
+ statusID int64
+ statusVal string
+ statusErr error
+}
+
+func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
+ if f.listErr != nil {
+ return nil, f.listErr
+ }
+ return f.tasks, nil
+}
+
+func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
+ f.captured = append(f.captured, req)
+ if f.captureErr != nil {
+ return ipc.CaptureTaskResp{}, f.captureErr
+ }
+ return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
+}
+
+func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
+ f.statusID, f.statusVal = id, status
+ return f.statusErr
+}
+
+func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+ resolved := now.Add(time.Hour)
+ core := &fakeTaskCore{tasks: []ipc.Task{
+ {ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
+ {ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
+ {ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
+ }}
+ rec := httptest.NewRecorder()
+ handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "купить молоко", "продлить страховку", "полить цветы",
+ "полис истекает", // the evidence trail is visible for review
+ "found, not confirmed", // candidates get their own section
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("body missing %q", want)
+ }
+ }
+ // The candidate must offer confirm, and the open task must not.
+ if !strings.Contains(body, "value=confirm") {
+ t.Error("candidate row has no confirm action")
+ }
+}
+
+func TestHandleTasksAddCaptures(t *testing.T) {
+ core := &fakeTaskCore{created: true}
+ form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleTasks(rec, req, core)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if len(core.captured) != 1 {
+ t.Fatalf("captured %d requests, want 1", len(core.captured))
+ }
+ got := core.captured[0]
+ if got.Text != "позвонить в банк" {
+ t.Errorf("text = %q, want trimmed", got.Text)
+ }
+ if got.Source != "tap:web" {
+ t.Errorf("source = %q, want tap:web", got.Source)
+ }
+ if got.Status != "open" {
+ t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
+ }
+ if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
+ t.Errorf("due = %v", got.Due)
+ }
+ if !strings.Contains(rec.Body.String(), "added task") {
+ t.Error("no confirmation message")
+ }
+}
+
+func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
+ core := &fakeTaskCore{created: false}
+ form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleTasks(rec, req, core)
+ if !strings.Contains(rec.Body.String(), "already on the list") {
+ t.Error("a deduped capture must not claim it saved something new")
+ }
+}
+
+func TestHandleTasksStatusActions(t *testing.T) {
+ for _, tc := range []struct{ action, want string }{
+ {"confirm", "open"},
+ {"done", "done"},
+ {"drop", "dropped"},
+ } {
+ core := &fakeTaskCore{}
+ form := url.Values{"action": {tc.action}, "id": {"42"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ handleTasks(httptest.NewRecorder(), req, core)
+ if core.statusID != 42 || core.statusVal != tc.want {
+ t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
+ }
+ }
+}
+
+func TestHandleTasksRejectsBadPost(t *testing.T) {
+ core := &fakeTaskCore{}
+ form := url.Values{"action": {"explode"}, "id": {"1"}}
+ req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleTasks(rec, req, core)
+ // The page still renders, with the error inline — and nothing was written.
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if core.statusVal != "" || len(core.captured) != 0 {
+ t.Error("an unknown action must write nothing")
+ }
+ if !strings.Contains(rec.Body.String(), "unknown action") {
+ t.Error("error not surfaced on the page")
+ }
+}
+
+func TestHandleTasksNoCore(t *testing.T) {
+ rec := httptest.NewRecorder()
+ handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Errorf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/internal/auth/policy.go b/internal/auth/policy.go
index bafe402..33873a3 100644
--- a/internal/auth/policy.go
+++ b/internal/auth/policy.go
@@ -65,7 +65,16 @@ func Requirement(m ipc.Method) Authority {
ipc.MethodCreateReminder,
ipc.MethodMarkReminder,
ipc.MethodRecordNudge,
- ipc.MethodResolveNudge:
+ ipc.MethodResolveNudge,
+ // Task capture (Vikunja #130). Listed explicitly rather than left to
+ // the default so the intent is on the record: capturing a task is a
+ // module write, not an allowlist mutation and not a new standing reason
+ // for Maven to speak — nothing in the tick loop reads tasks. It stays
+ // at AuthRead, the same rung as CreateReminder, which is the closest
+ // existing analogue.
+ ipc.MethodCaptureTask,
+ ipc.MethodListTasks,
+ ipc.MethodSetTaskStatus:
return AuthRead
}
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
diff --git a/internal/ipc/api.go b/internal/ipc/api.go
index fe779e4..c89d520 100644
--- a/internal/ipc/api.go
+++ b/internal/ipc/api.go
@@ -93,6 +93,63 @@ type WriteFactReq struct {
Subject string `json:"subject,omitempty"`
}
+// Task — one captured piece of work (Vikunja #130). Status is
+// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
+// "done" or "dropped". Source is provenance in the facts vocabulary:
+// "tap:voice", "tap:web", "email:". Evidence is the trail a derived
+// task came from, empty for anything he stated himself.
+type Task struct {
+ ID int64 `json:"id"`
+ CreatedTs time.Time `json:"created_ts"`
+ Text string `json:"text"`
+ Source string `json:"source"`
+ Evidence string `json:"evidence,omitempty"`
+ Status string `json:"status"`
+ Due *time.Time `json:"due,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Resolved *time.Time `json:"resolved,omitempty"`
+}
+
+// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
+// through this one shape: the voice path, the web form, and (Vikunja #246) the
+// email reader, which has not been built yet.
+//
+// An extractor that reads mail sets Source "email:", Status
+// "candidate", and Evidence to whatever makes the task reviewable (the subject
+// line). It must NOT set Status "open" — work Maven inferred from something she
+// read is a suggestion until the owner confirms it on the /tasks page. Capture
+// is idempotent on normalised text among live tasks, so re-reading the same
+// mailbox is free.
+type CaptureTaskReq struct {
+ Text string `json:"text"`
+ Source string `json:"source"`
+ Evidence string `json:"evidence,omitempty"`
+ Status string `json:"status,omitempty"` // "" ⇒ open
+ Due *time.Time `json:"due,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Ts time.Time `json:"ts"`
+}
+
+// CaptureTaskResp — Created is false when the same live task already existed,
+// in which case ID is the existing row. A caller tells the owner "уже в
+// списке" rather than claiming it saved something new.
+type CaptureTaskResp struct {
+ ID int64 `json:"id"`
+ Created bool `json:"created"`
+}
+
+type listTasksReq struct {
+ Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
+}
+type listTasksResp struct {
+ Tasks []Task `json:"tasks"`
+}
+type setTaskStatusReq struct {
+ ID int64 `json:"id"`
+ Status string `json:"status"`
+ Ts time.Time `json:"ts"`
+}
+
// idReq — methods keyed by a single id.
type idReq struct {
ID int64 `json:"id"`
@@ -294,6 +351,18 @@ type CoreAPI interface {
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
+ // CaptureTask records a task. See CaptureTaskReq — this is the single
+ // intake seam for the voice path, the web form and the future email
+ // extractor. Idempotent per live normalised text; the response says
+ // whether a row was actually created.
+ CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
+ // ListTasks returns tasks in one status, newest first. "" is every row,
+ // "live" is candidate + open (outstanding work).
+ ListTasks(ctx context.Context, status string) ([]Task, error)
+ // SetTaskStatus moves a task forward once: candidate→open|dropped,
+ // open→done|dropped. Any other move is refused.
+ SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
+
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
diff --git a/internal/ipc/client.go b/internal/ipc/client.go
index 5717ee1..7048795 100644
--- a/internal/ipc/client.go
+++ b/internal/ipc/client.go
@@ -69,6 +69,7 @@ var readOnlyMethods = map[Method]bool{
MethodLookupTool: true,
MethodListTools: true,
MethodListProposedRoutines: true,
+ MethodListTasks: true,
MethodTickTrace: true,
MethodMorningStatus: true,
MethodDayPlan: true,
@@ -427,6 +428,26 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
return r.Routines, nil
}
+func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
+ var r CaptureTaskResp
+ if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
+ return CaptureTaskResp{}, err
+ }
+ return r, nil
+}
+
+func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
+ var r listTasksResp
+ if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
+ return nil, err
+ }
+ return r.Tasks, nil
+}
+
+func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
+ return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
+}
+
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
}
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 21fbb1f..59043f7 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -231,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
+func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
+ id, created, err := a.s.CaptureTask(ctx, store.Task{
+ CreatedTs: req.Ts,
+ Text: req.Text,
+ Source: req.Source,
+ Evidence: req.Evidence,
+ Status: req.Status,
+ Due: req.Due,
+ Weight: req.Weight,
+ })
+ if err != nil {
+ return CaptureTaskResp{}, mapErr(err)
+ }
+ return CaptureTaskResp{ID: id, Created: created}, nil
+}
+
+func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
+ ts, err := a.s.ListTasks(ctx, status)
+ if err != nil {
+ return nil, mapErr(err)
+ }
+ out := make([]Task, len(ts))
+ for i, t := range ts {
+ out[i] = Task{
+ ID: t.ID,
+ CreatedTs: t.CreatedTs,
+ Text: t.Text,
+ Source: t.Source,
+ Evidence: t.Evidence,
+ Status: t.Status,
+ Due: t.Due,
+ Weight: t.Weight,
+ Resolved: t.ResolvedTs,
+ }
+ }
+ return out, nil
+}
+
+func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
+ return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
+}
+
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
@@ -700,6 +742,22 @@ var methodTable = map[Method]handlerFunc{
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
return api.DeleteTool(ctx, p.Name)
}),
+ MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
+ return api.CaptureTask(ctx, p)
+ }),
+ MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
+ out, err := api.ListTasks(ctx, p.Status)
+ if err != nil {
+ return listTasksResp{}, err
+ }
+ if out == nil {
+ out = []Task{}
+ }
+ return listTasksResp{Tasks: out}, nil
+ }),
+ MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
+ return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
+ }),
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
out, err := api.ListProposedRoutines(ctx)
if err != nil {
diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go
index daef767..cd0ed11 100644
--- a/internal/ipc/unimplemented.go
+++ b/internal/ipc/unimplemented.go
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
return ErrNotImplemented
}
+func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
+ return CaptureTaskResp{}, ErrNotImplemented
+}
+func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
+ return nil, ErrNotImplemented
+}
+func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
+ return ErrNotImplemented
+}
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrNotImplemented
}
diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go
index e1c4dad..a3d89c5 100644
--- a/internal/ipc/wire.go
+++ b/internal/ipc/wire.go
@@ -47,6 +47,9 @@ const (
MethodMorningStatus Method = "morning_status"
MethodDayPlan Method = "day_plan"
MethodChat Method = "chat"
+ MethodCaptureTask Method = "capture_task"
+ MethodListTasks Method = "list_tasks"
+ MethodSetTaskStatus Method = "set_task_status"
)
// Request — one frame from module to core. Params is the JSON-encoded argument
diff --git a/internal/router/task.go b/internal/router/task.go
new file mode 100644
index 0000000..248627a
--- /dev/null
+++ b/internal/router/task.go
@@ -0,0 +1,128 @@
+package router
+
+import "strings"
+
+// Task capture and task listing, matched deterministically (Vikunja #130).
+//
+// No new intent. The router's intent enum is a contract shared with the
+// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
+// enforces it), so adding an eighth intent would mean retraining before a task
+// could be captured at all. A task phrased out loud is a note-shaped or
+// query-shaped utterance with an explicit marker in it, and the marker is a
+// lookup — the same reasoning the calendar, plan and habit matchers already
+// follow. What the model classifies is unchanged; what these functions decide
+// is which store the turn lands in.
+
+// taskCapturePrefixes — the leading phrases that mean "put this on the list".
+// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
+// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
+// position tells them apart.
+//
+// Everything here is an explicit instruction. There is deliberately no entry
+// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
+// files, and a capture path that guesses would fill the list with his moods.
+var taskCapturePrefixes = []string{
+ "добавь в задачи",
+ "добавь в список задач",
+ "добавь в список дел",
+ "добавь в список",
+ "добавь задачу",
+ "запиши в задачи",
+ "запиши задачу",
+ "новая задача",
+ "в задачи",
+ "add a task",
+ "add task",
+ "add to my tasks",
+ "add to tasks",
+ "new task",
+}
+
+// ParseTaskCapture reports whether an utterance explicitly files a task, and
+// returns the task text with the marker stripped. A marker with nothing after it
+// is not a capture (there is no task in "добавь в задачи") — the caller falls
+// through to whatever it would otherwise have done with the turn.
+func ParseTaskCapture(text string) (string, bool) {
+ trimmed := strings.TrimSpace(text)
+ lower := strings.ToLower(trimmed)
+ best := ""
+ for _, p := range taskCapturePrefixes {
+ if strings.HasPrefix(lower, p) && len(p) > len(best) {
+ best = p
+ }
+ }
+ if best == "" {
+ return "", false
+ }
+ // Cut on the rune length of the matched prefix. ToLower does not change the
+ // byte length of Russian or English letters, so the index carries over.
+ rest := strings.TrimSpace(trimmed[len(best):])
+ rest = strings.TrimLeft(rest, ":—- ")
+ rest = strings.TrimSpace(rest)
+ rest = strings.TrimRight(rest, ".!")
+ if rest == "" {
+ return "", false
+ }
+ return rest, true
+}
+
+// taskListWords — the nouns that make a question be about the task list.
+var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
+
+// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
+// has no task noun in it at all, so it is matched as a phrase below.
+var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
+
+// IsTaskListQuery reports whether an utterance asks for the outstanding task
+// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
+//
+// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
+// contains a task noun; it is excluded explicitly. Anything that mentions a
+// task noun without asking for the list falls through to ordinary recall.
+func IsTaskListQuery(text string) bool {
+ toks := planTokens(text)
+ if len(toks) == 0 {
+ return false
+ }
+ // "как дела" — the greeting. Excluded before anything else matches.
+ if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
+ return false
+ }
+ // "что мне нужно сделать" / "что мне делать" — no task noun at all.
+ if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
+ (hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
+ return true
+ }
+ if hasTok(toks, "what") && hasTok(toks, "do") {
+ return true
+ }
+ hasNoun := false
+ for _, t := range toks {
+ for _, w := range taskListWords {
+ if t == w {
+ hasNoun = true
+ }
+ }
+ }
+ if !hasNoun {
+ return false
+ }
+ // A task noun plus any of: a question word, "список", or a bare
+ // one/two-word ask ("задачи", "мои задачи").
+ if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
+ hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
+ hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
+ hasTok(toks, "show") {
+ return true
+ }
+ if len(toks) <= 2 {
+ for _, t := range toks {
+ for _, w := range taskListWordsShortcut {
+ if t == w {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/router/task_test.go b/internal/router/task_test.go
new file mode 100644
index 0000000..2fb6984
--- /dev/null
+++ b/internal/router/task_test.go
@@ -0,0 +1,60 @@
+package router
+
+import "testing"
+
+func TestParseTaskCapture(t *testing.T) {
+ cases := []struct {
+ in string
+ text string
+ ok bool
+ }{
+ {"добавь в задачи купить молоко", "купить молоко", true},
+ {"Добавь в список дел: позвонить в банк", "позвонить в банк", true},
+ {"запиши задачу починить кран.", "починить кран", true},
+ {"новая задача — оплатить интернет", "оплатить интернет", true},
+ {"add a task buy milk", "buy milk", true},
+ // A marker with nothing after it files nothing.
+ {"добавь в задачи", "", false},
+ {"новая задача", "", false},
+ // Not a capture: he is talking, not filing.
+ {"надо бы поспать", "", false},
+ {"я не добавил молоко в список", "", false},
+ {"какие у меня задачи?", "", false},
+ {"", "", false},
+ }
+ for _, c := range cases {
+ text, ok := ParseTaskCapture(c.in)
+ if ok != c.ok || text != c.text {
+ t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, c.ok)
+ }
+ }
+}
+
+func TestIsTaskListQuery(t *testing.T) {
+ yes := []string{
+ "какие у меня задачи?",
+ "что мне нужно сделать?",
+ "покажи список дел",
+ "сколько у меня задач?",
+ "задачи",
+ "мои задачи",
+ "what should I do",
+ }
+ for _, s := range yes {
+ if !IsTaskListQuery(s) {
+ t.Errorf("IsTaskListQuery(%q) = false, want true", s)
+ }
+ }
+ no := []string{
+ "как дела?",
+ "какая погода?",
+ "напомни мне позвонить маме в шесть",
+ "я сделал зарядку",
+ "",
+ }
+ for _, s := range no {
+ if IsTaskListQuery(s) {
+ t.Errorf("IsTaskListQuery(%q) = true, want false", s)
+ }
+ }
+}
diff --git a/internal/store/migrations.go b/internal/store/migrations.go
index d59b54a..163ad83 100644
--- a/internal/store/migrations.go
+++ b/internal/store/migrations.go
@@ -131,6 +131,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
expires_ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
+
+ // #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
+ // a fact is a claim about the world that gets superseded, a task is a
+ // piece of work with a lifecycle (captured → open → done), and the
+ // prioritiser needs to read the live set cheaply.
+ //
+ // status: 'candidate' is a task Maven derived from something she read
+ // (mail, later) and has NOT been confirmed by the owner; 'open' is a task
+ // he actually stated (or confirmed). Nothing schedules or announces off
+ // this table — capture is not a nag.
+ //
+ // norm is the normalised dedupe key. The unique index is PARTIAL, over
+ // live rows only: re-capturing "купить молоко" after last week's one is
+ // done must work, while the same mail arriving twice must not produce two
+ // rows.
+ `CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_ts INTEGER NOT NULL,
+ text TEXT NOT NULL,
+ norm TEXT NOT NULL,
+ source TEXT NOT NULL,
+ evidence TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
+ due_ts INTEGER,
+ weight INTEGER NOT NULL DEFAULT 0,
+ resolved_ts INTEGER
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
}
// migrate applies every migration with a number greater than the DB's current
diff --git a/internal/store/tasks.go b/internal/store/tasks.go
new file mode 100644
index 0000000..b211ff6
--- /dev/null
+++ b/internal/store/tasks.go
@@ -0,0 +1,281 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+)
+
+// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
+// lifecycle instead of a valid-time: captured, then either done or dropped.
+//
+// Why not facts: a fact is a claim about the world and a correction supersedes
+// it (append-only, voids_id). A task is not a claim — it is work, it has a
+// state that moves forward once, and the read the prioritiser needs is "every
+// live task right now", which over an append-only log would mean replaying
+// history on every question.
+//
+// Nothing in this file schedules, fires or announces anything. Capture is a
+// store, not a trigger: a task exists to be answered when asked about, and the
+// owner's reminders remain the only thing that speaks unprompted.
+const (
+ // TaskCandidate — Maven derived this task from something she read (mail,
+ // once the email reader exists) and the owner has not confirmed it. A
+ // candidate is inert: it is listed as a candidate and never counted as work
+ // he agreed to.
+ TaskCandidate = "candidate"
+ // TaskOpen — work the owner stated himself, or a candidate he confirmed.
+ TaskOpen = "open"
+ // TaskDone — finished.
+ TaskDone = "done"
+ // TaskDropped — declined, or a candidate rejected. Kept for provenance, so
+ // the same mail cannot resurrect it silently; nothing re-proposes a
+ // dropped task.
+ TaskDropped = "dropped"
+)
+
+// Task — one captured piece of work.
+//
+// Source is provenance in the same vocabulary facts use: "tap:voice" for
+// something he said, "tap:web" for the review page, "email:" for a
+// mail-derived candidate. Evidence is the free-text trail a derived task came
+// from (a subject line), empty for anything he stated himself — it is what
+// makes a candidate reviewable instead of mysterious.
+//
+// Due is optional. Weight is an explicit importance hint (0 = none), which the
+// prioritiser reads; capture never invents one.
+type Task struct {
+ ID int64
+ CreatedTs time.Time
+ Text string
+ Source string
+ Evidence string
+ Status string
+ Due *time.Time
+ Weight int
+ ResolvedTs *time.Time
+}
+
+var (
+ ErrTaskNotFound = errors.New("store: task not found")
+ ErrTaskEmpty = errors.New("store: task text is empty")
+ ErrTaskStatus = errors.New("store: invalid task status")
+)
+
+// liveTaskStatuses — the two statuses that count as outstanding work.
+var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
+
+// CaptureTask inserts a task, or returns the existing live task when the same
+// work is already outstanding. created reports which happened, so a caller can
+// tell the owner "уже в списке" instead of pretending it wrote something.
+//
+// Dedupe is on the normalised text among LIVE rows only (see the partial unique
+// index in migration #14): a weekly errand can be captured again once the last
+// one is done, but a mail that gets re-read produces no second row. This is the
+// property the email intake depends on — it may call CaptureTask for every
+// message it extracts from, as often as it likes, without growing the list.
+func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
+ text := strings.TrimSpace(t.Text)
+ if text == "" {
+ return 0, false, ErrTaskEmpty
+ }
+ status := t.Status
+ if status == "" {
+ status = TaskOpen
+ }
+ if status != TaskCandidate && status != TaskOpen {
+ // Capturing straight into a resolved state is meaningless — a task is
+ // captured live and moved later.
+ return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
+ }
+ norm := NormalizeTaskText(text)
+ created2 := t.CreatedTs
+ if created2.IsZero() {
+ created2 = time.Now()
+ }
+ var due sql.NullInt64
+ if t.Due != nil {
+ due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
+ }
+
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
+ VALUES (?,?,?,?,?,?,?,?)
+ ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
+ created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
+ if err != nil {
+ return 0, false, fmt.Errorf("capture task: %w", err)
+ }
+ if n, err := res.RowsAffected(); err != nil {
+ return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
+ } else if n > 0 {
+ id, err := res.LastInsertId()
+ if err != nil {
+ return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
+ }
+ return id, true, nil
+ }
+
+ // Already live — hand back the row that won.
+ existing, err := s.lookupLiveTaskByNorm(ctx, norm)
+ if err != nil {
+ return 0, false, err
+ }
+ return existing.ID, false, nil
+}
+
+// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
+func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
+ row := s.db.QueryRowContext(ctx, taskSelect+`
+ WHERE norm = ? AND status IN ('candidate','open')`, norm)
+ t, err := scanTask(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Task{}, ErrTaskNotFound
+ }
+ if err != nil {
+ return Task{}, fmt.Errorf("lookup live task: %w", err)
+ }
+ return t, nil
+}
+
+const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks`
+
+// LookupTask returns one task by id.
+func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
+ row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
+ t, err := scanTask(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
+ }
+ if err != nil {
+ return Task{}, fmt.Errorf("lookup task: %w", err)
+ }
+ return t, nil
+}
+
+// ListTasks returns tasks in one status, newest first. An empty status returns
+// every row; "live" returns candidate + open, which is what every read path
+// that means "outstanding work" wants.
+func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
+ q := taskSelect
+ var args []any
+ switch status {
+ case "":
+ case "live":
+ q += ` WHERE status IN (?,?)`
+ args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
+ default:
+ q += ` WHERE status = ?`
+ args = append(args, status)
+ }
+ q += ` ORDER BY created_ts DESC, id DESC`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("list tasks: %w", err)
+ }
+ defer rows.Close()
+ var out []Task
+ for rows.Next() {
+ t, err := scanTask(rows)
+ if err != nil {
+ return nil, fmt.Errorf("list tasks: %w", err)
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// SetTaskStatus moves a task once, forward. The legal moves are:
+//
+// candidate → open (the owner confirms a derived task)
+// candidate → dropped (he rejects it)
+// open → done (finished)
+// open → dropped (abandoned)
+//
+// Anything else — including re-resolving a resolved task — is refused with
+// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
+// tools use: an answered question is not answered twice.
+//
+// Resolving frees the dedupe key, which is the point: the work can recur.
+func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
+ var from []string
+ switch status {
+ case TaskOpen:
+ from = []string{TaskCandidate}
+ case TaskDone:
+ from = []string{TaskOpen}
+ case TaskDropped:
+ from = []string{TaskCandidate, TaskOpen}
+ default:
+ return fmt.Errorf("%w: %q", ErrTaskStatus, status)
+ }
+
+ // resolved_ts is only meaningful for a terminal state; confirming a
+ // candidate leaves it null (the task is still live).
+ var resolved sql.NullInt64
+ if status == TaskDone || status == TaskDropped {
+ resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
+ }
+
+ q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` +
+ strings.Repeat(",?", len(from)-1) + `)`
+ args := []any{status, resolved, id}
+ for _, f := range from {
+ args = append(args, f)
+ }
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return fmt.Errorf("set task status: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("set task status: rows affected: %w", err)
+ }
+ if n == 0 {
+ return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
+ }
+ return nil
+}
+
+// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
+// whitespace collapsed. Exported because the intake seam (and its tests) needs
+// to reason about what will and will not be treated as the same task.
+//
+// Deliberately shallow — no stemming, no synonyms. Russian morphology would
+// need a real lemmatiser to do better, and a normaliser that guesses would
+// silently swallow two different tasks. This only catches the case that
+// actually happens: the same sentence arriving twice with different casing or
+// punctuation.
+func NormalizeTaskText(s string) string {
+ var b strings.Builder
+ space := true // leading space collapses to nothing
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsDigit(r):
+ b.WriteRune(r)
+ space = false
+ case !space:
+ b.WriteRune(' ')
+ space = true
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func scanTask(sc scanner) (Task, error) {
+ var t Task
+ var created int64
+ var due, resolved sql.NullInt64
+ if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil {
+ return Task{}, err
+ }
+ t.CreatedTs = time.UnixMilli(created).UTC()
+ t.Due = millisToTime(due)
+ t.ResolvedTs = millisToTime(resolved)
+ return t, nil
+}
diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go
new file mode 100644
index 0000000..9cf92c8
--- /dev/null
+++ b/internal/store/tasks_test.go
@@ -0,0 +1,221 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestCaptureTaskDedupesLiveWork(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+
+ id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !created {
+ t.Fatal("first capture must create a row")
+ }
+
+ // Same work, different casing and punctuation — one task, not two.
+ again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created {
+ t.Error("second capture of the same live work must not create a row")
+ }
+ if again != id {
+ t.Errorf("dedupe returned id %d, want the existing %d", again, id)
+ }
+
+ live, err := st.ListTasks(ctx, "live")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 {
+ t.Fatalf("live tasks = %d, want 1", len(live))
+ }
+}
+
+func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+
+ id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ // The dedupe key is free again: a recurring errand must be capturable.
+ id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !created || id2 == id {
+ t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
+ }
+ live, err := st.ListTasks(ctx, "live")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].ID != id2 {
+ t.Fatalf("live = %+v, want only the new task %d", live, id2)
+ }
+}
+
+func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+ due := now.Add(48 * time.Hour)
+
+ id, _, err := st.CaptureTask(ctx, Task{
+ Text: "продлить страховку",
+ Source: "email:kami",
+ Evidence: "Re: страховой полис истекает",
+ Status: TaskCandidate,
+ Due: &due,
+ Weight: 2,
+ CreatedTs: now,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := st.LookupTask(ctx, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != TaskCandidate {
+ t.Errorf("status = %q, want candidate", got.Status)
+ }
+ if got.Evidence != "Re: страховой полис истекает" {
+ t.Errorf("evidence = %q", got.Evidence)
+ }
+ if got.Due == nil || !got.Due.Equal(due.UTC()) {
+ t.Errorf("due = %v, want %v", got.Due, due.UTC())
+ }
+ if got.Weight != 2 {
+ t.Errorf("weight = %d, want 2", got.Weight)
+ }
+ if got.ResolvedTs != nil {
+ t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
+ }
+}
+
+func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+
+ cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // candidate → done is not a legal move: he has to confirm it first.
+ if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
+ t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
+ }
+ if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ // Already resolved — a second resolve must not move it again.
+ if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
+ t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
+ }
+ got, err := st.LookupTask(ctx, cand)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != TaskDone {
+ t.Errorf("status = %q, want done", got.Status)
+ }
+ if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
+ t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
+ }
+}
+
+func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
+ t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
+ }
+ if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
+ t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
+ }
+}
+
+func TestCaptureTaskRejectsEmptyText(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
+ t.Errorf("err = %v, want ErrTaskEmpty", err)
+ }
+}
+
+func TestListTasksFiltersByStatus(t *testing.T) {
+ ctx := context.Background()
+ st := newTestStore(t)
+ now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
+
+ open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
+ _, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
+ done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
+ if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+
+ cands, err := st.ListTasks(ctx, TaskCandidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cands) != 1 || cands[0].Text != "вторая" {
+ t.Fatalf("candidates = %+v", cands)
+ }
+ opens, err := st.ListTasks(ctx, TaskOpen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(opens) != 1 || opens[0].ID != open1 {
+ t.Fatalf("open = %+v", opens)
+ }
+ all, err := st.ListTasks(ctx, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(all) != 3 {
+ t.Fatalf("all = %d, want 3", len(all))
+ }
+ // Newest first.
+ if all[0].Text != "третья" {
+ t.Errorf("first = %q, want newest ('третья')", all[0].Text)
+ }
+}
+
+func TestNormalizeTaskText(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"Купить молоко!", "купить молоко"},
+ {" купить МОЛОКО ", "купить молоко"},
+ {"позвонить в банк (важно)", "позвонить в банк важно"},
+ {"", ""},
+ }
+ for _, c := range cases {
+ if got := NormalizeTaskText(c.in); got != c.want {
+ t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}