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/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.