From cb350efb1990e4a2f9aebc90dd11d85d8a93bcf8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 20:42:28 +0400 Subject: [PATCH 1/2] a surface can ask Nexus for the id behind a name (V-511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blocked_on stores a canonical entity id, so the form that fills it needs a way to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter refuses it, because identity is not the store's to answer, and the daemon overrides it with the Nexus client the voice path already holds. Three outcomes are kept apart, because a caller deciding whether to store an id has to tell them apart. No nexus block is ErrNotImplemented. A miss is ErrNoEntity. Several matches come back Ambiguous with the names, and the caller asks — picking one is how a task ends up blocked on the wrong person with nobody able to see it happened. An outage stays the transport error. "There is no such person" and "Nexus is down" must not read the same. --- cmd/mavend/main.go | 1 + cmd/mavend/tick_api.go | 47 +++++++++++++++++++++++++++++++++++ internal/ipc/api.go | 30 ++++++++++++++++++++++ internal/ipc/client.go | 8 ++++++ internal/ipc/coreapi.go | 7 ++++++ internal/ipc/server.go | 7 ++++++ internal/ipc/storeapi.go | 7 ++++++ internal/ipc/unimplemented.go | 3 +++ internal/ipc/wire.go | 1 + 9 files changed, 111 insertions(+) diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 7968479..57d8402 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -341,6 +341,7 @@ func run(args []string) error { getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, getEvents: intakeEventsFn(evBus), seedStore: seedStoreIfAllowed(st), + nexus: nexusOf(voiceW), } if voiceW != nil && voiceW.handler != nil { api := coreAPI.(*daemonAPI) diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go index c221912..24ef255 100644 --- a/cmd/mavend/tick_api.go +++ b/cmd/mavend/tick_api.go @@ -23,6 +23,11 @@ type daemonAPI struct { chatFn func(ctx context.Context, conversation, text string) string getMCPServers func() []ipc.MCPServerStatus getEvents func(n int) []ipc.IntakeEvent + // nexus — the identity client, nil when no nexus block is configured. It + // is what makes ResolveEntity answerable at all; without it the store + // adapter's refusal stands, and a surface that wanted an entity id says so + // instead of storing a name. + nexus *nexusClient // seedStore — non-nil ONLY when mavend was started with -allow-seed. It is // the whole off-switch for the backdated write path (Vikunja #518), and it // is a store rather than a bool so that leaving the flag off means the @@ -41,6 +46,48 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, return d.getEvents(n), nil } +// nexusOf — the identity client the voice wiring built, or nil. Same shape as +// embedderOf: a wiring that is absent and a wiring with no nexus block are one +// answer here. +func nexusOf(w *voiceWiring) *nexusClient { + if w == nil || w.handler == nil || w.handler.ecosystem == nil { + return nil + } + return w.handler.ecosystem.nexus +} + +// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511). +// +// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented, +// so a surface can say "identity is not configured here" rather than invent an +// id. A miss is ipc.ErrNoEntity. A match against several entities comes back +// Ambiguous with the names, because picking one is how a task ends up blocked +// on the wrong person and nobody can see it happened. +func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) { + if d.nexus == nil { + return ipc.EntityRef{}, ipc.ErrNotImplemented + } + res, err := d.nexus.Resolve(ctx, query, types) + if err != nil { + return ipc.EntityRef{}, err + } + if len(res.Candidates) > 1 { + names := make([]string, 0, len(res.Candidates)) + for _, c := range res.Candidates { + names = append(names, c.DisplayName) + } + return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil + } + if res.Entity == nil || res.Entity.ID == "" { + return ipc.EntityRef{}, ipc.ErrNoEntity + } + return ipc.EntityRef{ + ID: res.Entity.ID, + Type: res.Entity.Type, + DisplayName: res.Entity.DisplayName, + }, nil +} + // Chat runs one text turn and reports which query source claimed it. The sink // rides the context so handleText keeps the one string signature the mic, // telegram and the web all call it through (V-539). diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 4cfa642..7025895 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -549,6 +549,31 @@ type setTaskStatusReq struct { By string `json:"by,omitempty"` } +// EntityRef — one canonical entity from Nexus. Maven never mints these: an id +// exists because Nexus resolved a name to it. +// +// Ambiguous is the answer when the name matched more than one entity. It is a +// separate state from "not found" because the surface handles them +// differently: an unknown name may be a typo, and an ambiguous one has to be +// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating +// Hexis path follows). +type EntityRef struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Ambiguous bool `json:"ambiguous,omitempty"` + Candidates []string `json:"candidates,omitempty"` +} + +type resolveEntityReq struct { + Query string `json:"query"` + Types []string `json:"types,omitempty"` +} + +type resolveEntityResp struct { + Ref EntityRef `json:"ref"` +} + // editTaskReq — the rewrite of the three fields capture set (Vikunja #509). // Due nil clears the date, so "no date given" and "remove the date" cannot be // the same request. @@ -886,6 +911,11 @@ var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done") // text (Vikunja #509). The surface says which row holds it rather than merging. var ErrTaskDuplicate = errors.New("ipc: another live task already has this text") +// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage, +// which surfaces as the transport error: "there is no such person" and "Nexus +// is down" must not read the same to a caller deciding whether to store an id. +var ErrNoEntity = errors.New("ipc: no such entity") + // ErrTaskResolved — a resolved task is not editable. var ErrTaskResolved = errors.New("ipc: task is resolved") diff --git a/internal/ipc/client.go b/internal/ipc/client.go index f917d01..063ca68 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -515,6 +515,14 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil) } +func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + var r resolveEntityResp + if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil { + return EntityRef{}, err + } + return r.Ref, nil +} + func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error { return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil) } diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index bec310a..89464f3 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -142,6 +142,13 @@ type TaskAPI interface { // 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, by string) error + // ResolveEntity asks Nexus for the canonical id behind a name. It is how a + // surface turns "Kate" into an entity id before storing one, because + // identity lives in Nexus and a local name is a second answer to a + // question Nexus already owns. ErrNotImplemented when no Nexus is + // configured, ErrNoEntity when the name matched nothing, and an Ambiguous + // ref when it matched several — the caller asks, it does not pick. + ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) // EditTask rewrites the three fields capture set: text, due date and // weight. Status is not among them — that ladder is one-way and belongs to // SetTaskStatus. A resolved task is refused, and a text edit that would diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f68edb0..e11cbb0 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -540,6 +540,13 @@ var methodTable = map[Method]handlerFunc{ MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error { return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By) }), + MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) { + ref, err := api.ResolveEntity(ctx, p.Query, p.Types) + if err != nil { + return resolveEntityResp{}, err + } + return resolveEntityResp{Ref: ref}, nil + }), MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error { return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight) }), diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index 3fb2a30..67e891b 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -356,6 +356,13 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by)) } +// ResolveEntity is not the store's to answer: identity lives in Nexus and this +// adapter has no client. The daemon overrides it (cmd/mavend/tick_api.go), and +// a deployment with no nexus block keeps this refusal. +func (a *storeAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + return EntityRef{}, ErrNotImplemented +} + func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error { return mapErr(a.s.EditTask(ctx, id, text, due, weight)) } diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 63ce042..25d8a6a 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -114,6 +114,9 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error { return ErrNotImplemented } +func (UnimplementedCoreAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { + return EntityRef{}, ErrNotImplemented +} func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error { return ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index d19888b..137828d 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -57,6 +57,7 @@ const ( MethodSetTaskStatus Method = "set_task_status" MethodSetTaskFields Method = "set_task_fields" MethodEditTask Method = "edit_task" + MethodResolveEntity Method = "resolve_entity" MethodIngestMail Method = "ingest_mail" MethodSwapModel Method = "swap_model" MethodModelStatus Method = "model_status" From 570b204571a9da57333adad2dc697b5f92c82c04 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 20:46:35 +0400 Subject: [PATCH 2/2] board: /tasks confirms a candidate into an open task (V-511) The candidate row is now an intake form, not a button. Confirming asks for a definition of done and refuses without one, takes an optional blocked-on name, and carries the date and the importance through. The blocked-on is a name in the form and a canonical nexus id in the store. promoteCandidate resolves it over the new ipc.ResolveEntity seam and stops the confirmation on an ambiguous or unplaceable name rather than picking. A date set here books a reminder for 09:00 that morning. That is the only unprompted delivery the persona allows, because the owner set the date himself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy --- cmd/mavweb/main.go | 84 ++++++++++++++++++++++++++++++++++++++++ cmd/mavweb/tasks.html | 17 ++++++-- cmd/mavweb/tasks_test.go | 8 +++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index e36ed3d..88b4414 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1041,6 +1041,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri return "", errors.New("invalid id") } + if action == "promote" { + msg, err := promoteCandidate(ctx, core, r, id) + if err != nil { + return "", err + } + return msg, nil + } + if action == "edit" { // The three fields capture set, and only those (Vikunja #509). Status // is not editable here: that ladder is one-way and has its own buttons. @@ -1095,6 +1103,82 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri // fmtTaskDateValue renders a due date the way requires, or // "" for no date. Separate from fmtTaskDate, which renders it for reading. +// promoteCandidate turns a candidate into open work with the three things the +// board needs (Vikunja #511): a definition of done, an optional blocker, and an +// optional date. +// +// The definition of done is required, and the refusal is the store's — this +// only reaches it in a readable order. The blocker is a NAME here and an entity +// id in the row: identity lives in Nexus, so the name is resolved first and a +// name Nexus cannot resolve stops the promotion instead of being stored. +// +// A date set here writes a reminder, which is the one unprompted delivery the +// persona allows: he asked to be told, on a day he named. +func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { + doneWhen := strings.TrimSpace(r.FormValue("done_when")) + if doneWhen == "" { + return "", errors.New("write a definition of done — what has to be true for this to be finished") + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + + blockedOn := "" + if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" { + ref, err := core.ResolveEntity(ctx, name, []string{"person"}) + switch { + case errors.Is(err, ipc.ErrNotImplemented): + return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") + case errors.Is(err, ipc.ErrNoEntity): + return "", fmt.Errorf("nexus does not know %q", name) + case err != nil: + return "", fmt.Errorf("resolving %q: %w", name, err) + case ref.Ambiguous: + // Asking, not picking: a task blocked on the wrong person is a + // mistake nobody can see afterwards. + return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) + } + blockedOn = ref.ID + } + + if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { + return "", err + } + if due != nil { + wgt, err := formWeight(r) + if err != nil { + return "", err + } + if err := core.EditTask(ctx, id, text, due, wgt); err != nil { + return "", err + } + } + if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { + if errors.Is(err, ipc.ErrTaskNoDoneWhen) { + return "", errors.New("write a definition of done before confirming this candidate") + } + return "", err + } + if due == nil { + return "confirmed", nil + } + // A date-only field has no hour. Nine in the morning, because the reminder + // is about a day's work and being told at midnight is being told the night + // before. + fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) + if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { + // The task IS promoted; only the reminder failed. Saying "confirmed" + // and nothing else would leave him expecting a nudge that will not come. + return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) + } + return "confirmed, and maven will remind you that morning", nil +} + // formWeight reads the importance select. Out-of-range clamps rather than // rejects — a bad select is not worth a 400 — but trailing garbage is refused, // because strconv is not Sscanf and "3junk" is not a 3. diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 583a486..427feb5 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -37,16 +37,27 @@

found, not confirmed {{len .Candidates}}

maven derived these from something she read. nothing counts as your work until you confirm it.
+
confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.
- + {{range .Candidates}} -
taskwhere fromduecaptured
taskwhere fromcapturedconfirm
{{.Text}} {{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}{{.Due}} {{.Created}}
- + + + + + + +
diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go index 5f945ba..cbd639e 100644 --- a/cmd/mavweb/tasks_test.go +++ b/cmd/mavweb/tasks_test.go @@ -77,10 +77,14 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) { t.Errorf("body missing %q", want) } } - // The candidate must offer confirm, and the open task must not. - if !strings.Contains(body, "value=confirm") { + // The candidate must offer the intake form, and it asks for a definition of + // done before it will confirm anything (V-511). + if !strings.Contains(body, "value=promote") { t.Error("candidate row has no confirm action") } + if !strings.Contains(body, "name=done_when") { + t.Error("the confirm form does not ask for a definition of done") + } } func TestHandleTasksAddCaptures(t *testing.T) {