diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index b20cb21..ea024a8 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -24,6 +24,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st } } + // The board is Maven's own store, so a spoken status change is answered here + // and never offered to an ecosystem client (Vikunja #512). First, because + // task_status is on no allowlist and no capability registry: reaching either + // of them would answer a turn about his own task list with a gap. + if dec.Slots.Fn == router.TaskStatusFn { + return h.resolveTaskStatus(ctx, dec) + } + // Praxis ecosystem tools: intercept before the system command executor. if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn { if reply := h.handlePraxisAct(ctx, dec); reply != "" { diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 69462ce..e6ddb05 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "strings" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" @@ -84,6 +85,84 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, return tasks.FormatRU(ranked), true } +// resolveTaskStatus moves a task he named out loud (Vikunja #512). +// +// The position path already worked: resolveCandidate answers "первую сделал" +// against the list she just read. This is the other half — naming the task +// instead of its position, which reached no code at all before the stage-0 rule +// in internal/router/taskstatus.go filled the fn slot. +// +// Three answers besides the move, and none of them guesses. No match says so. A +// match on more than one asks which, because closing the wrong task is work he +// never finished being marked done. No task named asks which too, since the +// router claims the turn without the referent and the list lives here. +func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string { + live, err := h.api.ListTasks(ctx, "live") + if err != nil { + log.Printf("voice: task status: list: %v", err) + return "не получилось посмотреть задачи." + } + if dec.Slots.Text == "" { + return "какую задачу?" + } + match := matchTaskText(live, dec.Slots.Text) + switch len(match) { + case 0: + return "не нашла такой задачи." + case 1: + default: + return "у тебя несколько подходящих — какую именно?" + } + pick := match[0] + status := dec.Slots.Value + // A candidate is work Maven proposed and he never confirmed, and the store + // refuses candidate → done: the legal move is to open it first. Saying it is + // done IS the confirmation, so both writes happen rather than the turn + // naming a gap about a distinction he did not make. + if pick.Status == store.TaskCandidate && status == store.TaskDone { + if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil { + log.Printf("voice: task status: promote %d: %v", pick.ID, err) + return "не получилось изменить задачу." + } + } + if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil { + log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err) + return "не получилось изменить задачу." + } + log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status) + if status == store.TaskDropped { + return "убрала: " + pick.Text + } + return "закрыла: " + pick.Text +} + +// matchTaskText finds the live tasks he could have meant. +// +// Normalised containment, either direction, over store.NormalizeTaskText — the +// same key capture dedupes on, so a task he can file twice is a task he can name +// twice. Either direction because he shortens what he said ("молоко" for +// "купить молоко") as often as he pads it. +// +// Deliberately not fuzzy. A ranked best guess would always return exactly one +// answer, and the one thing this must be able to say is that it is not sure. +func matchTaskText(live []ipc.Task, named string) []ipc.Task { + want := store.NormalizeTaskText(named) + if want == "" { + return nil + } + var out []ipc.Task + for _, t := range live { + have := store.NormalizeTaskText(t.Text) + if have == "" { + continue + } + if strings.Contains(have, want) || strings.Contains(want, have) { + out = append(out, t) + } + } + return out +} + // taskItems maps wire rows onto the ranker's input. Written here rather than in // internal/tasks so the ranker stays a pure package with no ipc (and therefore // no store, and therefore no cgo) dependency — the same posture as diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 5f38bba..d371b10 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -26,6 +26,9 @@ type taskAPI struct { tasks []ipc.Task listArg string listErr error + + moved []setStatusCall + moveErr error } func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) { @@ -253,3 +256,87 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) { } } } + +// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he +// says is done can be shown to take both legal moves. +type setStatusCall struct { + id int64 + status string + by string +} + +func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error { + a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by}) + return a.moveErr +} + +func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) { + api := &taskAPI{tasks: []ipc.Task{ + {ID: 7, Text: "купить молоко", Status: "open"}, + {ID: 8, Text: "оплатить интернет", Status: "open"}, + }} + h := taskHandler(api) + reply := h.resolveTaskStatus(context.Background(), router.Decision{ + Intent: router.IntentAct, + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"}, + }) + if api.listArg != "live" { + t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg) + } + if len(api.moved) != 1 { + t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved) + } + if api.moved[0].id != 7 || api.moved[0].status != "done" { + t.Errorf("moved %+v, want id 7 → done", api.moved[0]) + } + if !strings.Contains(reply, "купить молоко") { + t.Errorf("reply = %q, want the task named back", reply) + } +} + +func TestResolveTaskStatusRefusesToGuess(t *testing.T) { + cases := []struct { + name string + tasks []ipc.Task + named string + want string + }{ + {"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"}, + {"two matches", []ipc.Task{ + {ID: 7, Text: "купить молоко", Status: "open"}, + {ID: 8, Text: "купить молоко и хлеб", Status: "open"}, + }, "купить молоко", "несколько"}, + {"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + api := &taskAPI{tasks: c.tasks} + h := taskHandler(api) + reply := h.resolveTaskStatus(context.Background(), router.Decision{ + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named}, + }) + if len(api.moved) != 0 { + t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved) + } + if !strings.Contains(reply, c.want) { + t.Errorf("reply = %q, want it to contain %q", reply, c.want) + } + }) + } +} + +func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) { + // The store refuses candidate → done. Saying it is done is the confirmation + // the candidate was waiting for, so the arm makes both legal moves. + api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}} + h := taskHandler(api) + h.resolveTaskStatus(context.Background(), router.Decision{ + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"}, + }) + if len(api.moved) != 2 { + t.Fatalf("moved %+v, want open then done", api.moved) + } + if api.moved[0].status != "open" || api.moved[1].status != "done" { + t.Errorf("moved %+v, want open then done", api.moved) + } +}