Capture tasks, with one intake seam mail can call later (#130) #60

Closed
claude wants to merge 1 commits from overnight/task-capture into overnight/behavior-profile
Contributor

What changed

A task capture store, the manual paths into it, and one intake seam the email reader can call later.

  • internal/store/tasks.go + migration #14: a tasks table with statuses candidate | open | done | dropped. Each row moves forward once (candidate→open|dropped, open→done|dropped); a second resolve is refused, the same one-way shape proposed_routines and tools already use. Dedupe is on normalised text via a partial unique index over live rows only — so re-reading the same mailbox writes nothing, but a weekly errand is capturable again once the last one is done.
  • internal/ipc: Task DTO, CaptureTaskReq/Resp, and CaptureTask / ListTasks / SetTaskStatus on CoreAPI (store adapter, dispatch table, client proxy, unimplemented stub). internal/auth/policy.go lists the three methods explicitly at AuthRead — a task write is a module write, not an allowlist mutation.
  • Voice/chat capture: router.ParseTaskCapture matches an explicit marker prefix ("добавь в задачи …", "новая задача …", "add a task …") and captureTaskFromNote runs at the top of actionNote, before the embedding is paid for. Everything else is still a note.
  • Voice read-back: a tasks query source (router.IsTaskListQuery) answers "какие у меня задачи?", "что мне нужно сделать?". Placed before the recall sources on purpose — the notes pass would otherwise answer it with whatever note is nearest.
  • /tasks page in mavweb: add form, confirm/done/drop actions, candidates in their own section with their evidence trail visible.

Why this shape

A task is not a fact and not a note. A fact is a claim the next claim supersedes; a note is recalled by meaning. A task is work with a lifecycle, and the read that matters — "everything outstanding right now" — would mean replaying an append-only log on every question.

No new intent. The router's 7-intent enum is a contract with the relabelling prompt (llm/check_prompt_parity.py), so an eighth intent would mean retraining before a task could be captured at all. Capture rides the note intent, the list rides a query source, and both are matched deterministically like the calendar, plan and habit matchers already are.

Nothing here speaks. No tick rule reads the tasks table — the list is answered when asked about. That is also why POST /tasks is not step-up gated the way /tools and /routines are: enabling a tool defines argv Maven executes, accepting a routine hands the tick loop a new reason to interrupt him, and a task write does neither.

There is deliberately no capture marker for "надо" / "нужно". "надо бы поспать" is a thing he says, not a task he files, and a path that guesses would fill the list with his moods.

What the email side (#246) must call

The email reader is not in this PR and no IMAP client was added. When it lands, it calls exactly one thing per extracted item:

core.CaptureTask(ctx, ipc.CaptureTaskReq{
    Text:     "продлить страховку",          // the task, in his language
    Source:   "email:<account>",             // provenance, facts vocabulary
    Evidence: "Re: страховой полис истекает", // the trail that makes it reviewable
    Status:   "candidate",                   // NOT "open" — see below
    Due:      &deadline,                     // optional
    Ts:       time.Now(),
})

Three rules for that caller:

  1. Status must be "candidate". Work Maven inferred from something she read is a suggestion until he confirms it on /tasks. Only an utterance he spoke or a row he typed is captured open.
  2. Evidence should be non-empty (the subject line is enough). It is what makes a candidate reviewable instead of mysterious.
  3. Do not deduplicate on your side. CaptureTask is idempotent per live normalised text and returns Created: false with the existing id when the work is already outstanding. Re-polling a mailbox is free.

Nothing else is needed: no new IPC method, no new table, no new config key.

How it was verified

make build and make test (go test -race) both pass. New tests:

  • internal/store/tasks_test.go — dedupe among live rows, re-capture after done, candidate evidence/due round-trip, forward-only status moves, rejected statuses, NormalizeTaskText.
  • internal/router/task_test.go — capture prefixes incl. the negatives ("надо бы поспать", "я не добавил молоко в список"), list-query matching incl. "как дела?" not matching.
  • cmd/mavend/actions_task_test.go — capture writes tap:voice/open at the handler clock, ordinary notes pass through untouched, dedupe reply, failure reply, list recital ordering (confirmed work before candidates), and a guard that the tasks source sits before notes in the chain.
  • cmd/mavweb/tasks_test.go — page splits candidates from open, add captures tap:web/open with the due date, "already on the list", the three status actions, unknown action writes nothing.

Vikunja #130

## What changed A task capture store, the manual paths into it, and one intake seam the email reader can call later. - **`internal/store/tasks.go`** + migration **#14**: a `tasks` table with statuses `candidate | open | done | dropped`. Each row moves forward once (`candidate→open|dropped`, `open→done|dropped`); a second resolve is refused, the same one-way shape `proposed_routines` and `tools` already use. Dedupe is on normalised text via a **partial** unique index over live rows only — so re-reading the same mailbox writes nothing, but a weekly errand is capturable again once the last one is done. - **`internal/ipc`**: `Task` DTO, `CaptureTaskReq/Resp`, and `CaptureTask` / `ListTasks` / `SetTaskStatus` on `CoreAPI` (store adapter, dispatch table, client proxy, unimplemented stub). `internal/auth/policy.go` lists the three methods explicitly at `AuthRead` — a task write is a module write, not an allowlist mutation. - **Voice/chat capture**: `router.ParseTaskCapture` matches an explicit marker prefix ("добавь в задачи …", "новая задача …", "add a task …") and `captureTaskFromNote` runs at the top of `actionNote`, before the embedding is paid for. Everything else is still a note. - **Voice read-back**: a `tasks` query source (`router.IsTaskListQuery`) answers "какие у меня задачи?", "что мне нужно сделать?". Placed before the recall sources on purpose — the notes pass would otherwise answer it with whatever note is nearest. - **`/tasks` page** in mavweb: add form, confirm/done/drop actions, candidates in their own section with their evidence trail visible. ## Why this shape A task is not a fact and not a note. A fact is a claim the next claim supersedes; a note is recalled by meaning. A task is work with a lifecycle, and the read that matters — "everything outstanding right now" — would mean replaying an append-only log on every question. No new intent. The router's 7-intent enum is a contract with the relabelling prompt (`llm/check_prompt_parity.py`), so an eighth intent would mean retraining before a task could be captured at all. Capture rides the note intent, the list rides a query source, and both are matched deterministically like the calendar, plan and habit matchers already are. Nothing here speaks. No tick rule reads the tasks table — the list is answered when asked about. That is also why `POST /tasks` is **not** step-up gated the way `/tools` and `/routines` are: enabling a tool defines argv Maven executes, accepting a routine hands the tick loop a new reason to interrupt him, and a task write does neither. There is deliberately no capture marker for "надо" / "нужно". "надо бы поспать" is a thing he says, not a task he files, and a path that guesses would fill the list with his moods. ## What the email side (#246) must call The email reader is **not** in this PR and no IMAP client was added. When it lands, it calls exactly one thing per extracted item: ```go core.CaptureTask(ctx, ipc.CaptureTaskReq{ Text: "продлить страховку", // the task, in his language Source: "email:<account>", // provenance, facts vocabulary Evidence: "Re: страховой полис истекает", // the trail that makes it reviewable Status: "candidate", // NOT "open" — see below Due: &deadline, // optional Ts: time.Now(), }) ``` Three rules for that caller: 1. **`Status` must be `"candidate"`.** Work Maven inferred from something she read is a suggestion until he confirms it on `/tasks`. Only an utterance he spoke or a row he typed is captured `open`. 2. **`Evidence` should be non-empty** (the subject line is enough). It is what makes a candidate reviewable instead of mysterious. 3. **Do not deduplicate on your side.** `CaptureTask` is idempotent per live normalised text and returns `Created: false` with the existing id when the work is already outstanding. Re-polling a mailbox is free. Nothing else is needed: no new IPC method, no new table, no new config key. ## How it was verified `make build` and `make test` (go test -race) both pass. New tests: - `internal/store/tasks_test.go` — dedupe among live rows, re-capture after done, candidate evidence/due round-trip, forward-only status moves, rejected statuses, `NormalizeTaskText`. - `internal/router/task_test.go` — capture prefixes incl. the negatives ("надо бы поспать", "я не добавил молоко в список"), list-query matching incl. "как дела?" not matching. - `cmd/mavend/actions_task_test.go` — capture writes `tap:voice`/`open` at the handler clock, ordinary notes pass through untouched, dedupe reply, failure reply, list recital ordering (confirmed work before candidates), and a guard that the `tasks` source sits before `notes` in the chain. - `cmd/mavweb/tasks_test.go` — page splits candidates from open, add captures `tap:web`/`open` with the due date, "already on the list", the three status actions, unknown action writes nothing. Vikunja #130
claude added 1 commit 2026-08-01 00:34:24 +02:00
A task is not a fact and not a note. A fact is a claim about the world that a
correction supersedes; a note is something to recall by meaning. A task is work
with a lifecycle, and the read that matters is "everything outstanding right
now" — which over an append-only log would mean replaying history on every
question. So: a tasks table, migration #14, statuses candidate/open/done/dropped
that each move forward exactly once.

Dedupe is on normalised text among LIVE rows only, via a partial unique index.
That is the property the mail side needs: an extractor may call CaptureTask for
every message it reads, as often as it likes, without growing the list — while a
weekly errand is still capturable again once the last one is done.

Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path
(router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never
"надо бы поспать"), the /tasks form, and the email reader from #246 when it
exists. Mail-derived items set Source "email:<account>", Status "candidate" and
Evidence to whatever makes the row reviewable; a candidate is inert until he
confirms it on /tasks, and Maven names it as unconfirmed when she recites the
list rather than putting words in his mouth.

No new intent — the router enum is a contract with the relabelling prompt, so
capture rides the note intent and the list rides a query source, both matched
deterministically like the calendar and plan matchers already are.

Nothing here speaks. No tick rule reads tasks; the list is answered when asked
about, which is why /tasks POST is not step-up gated the way /tools and
/routines are — a task write moves no boundary.

Vikunja #130
claude reviewed 2026-08-01 11:35:15 +02:00
claude left a comment
Author
Contributor

Adding no eighth intent is the right call. Tying it to the prompt-parity contract in the header comment is the argument that makes it stick. The candidate status earns its keep. A derived task is named as unconfirmed in the spoken list and in the page. Mail can never quietly become work he owns. The partial unique index over live rows only is the correct shape for a recurring errand. NormalizeTaskText refusing to stem is the right kind of shallow.

7f42cc7 touches internal/router/task.go, but only to move the vocabulary into task_phrases.json. The matchers are unchanged and nothing below is fixed on the tip.

1. The idempotence the intake seam is built on does not survive a resolved task.

CaptureTaskReq promises the email extractor "may call CaptureTask for every message it extracts from, as often as it likes, without growing the list". SetTaskStatus documents the opposite property one file over: "Resolving frees the dedupe key, which is the point: the work can recur."

Both are true, and together they break the seam. Walk it with #246. Mail arrives, the extractor captures "продлить страховку" as a candidate. He confirms it, does it, marks it done. The mail is still in the mailbox, because mavmaild is a read-only reader and nothing marks anything read. The next poll extracts the same task, the dedupe index no longer covers the done row, and a fresh candidate appears. He drops it. The poll after that brings it back again.

The free-the-key behaviour is right for voice, where the recurrence signal is him saying it again. It is wrong for a source that re-reads the same immutable text forever. Those are different intake semantics on one seam, and the seam is the whole point of the PR.

The fix belongs here rather than in #246, because #246 will be written against this doc comment. Give the request an optional external identity, a message id plus extracted span. Store it, and make derived capture dedupe on it across every status. Voice capture keeps the live-only rule it has now.

2. SetTaskStatus at AuthRead lets any enrolled module clear his list.

The policy comment argues the rung by comparing capture to CreateReminder. Capture is additive and the comparison holds. SetTaskStatus is not additive and it is in the same case arm.

WriteFact sits at AuthWrite for a stated reason: "a module only writes sources it owns", so a compromised poller cannot forge a trigger. Tasks have a Source column carrying the same vocabulary, and nothing checks it. At AuthRead, mavpoll or mavsttd can mark every open task done, and the page shows them under resolved with no trace of who moved them. resolved_ts records when, never by what.

Split the arm. Capture and list at AuthRead is fine. SetTaskStatus should be at least AuthWrite and source-scoped, or the row should record the caller so a wrong resolution is at least attributable.

3. Capturing over an existing candidate leaves it a candidate, and she says he did not confirm it.

CaptureTask dedupes on norm across both live statuses. It returns the existing row and never touches its status.

He says "добавь в задачи продлить страховку" for a task the mail extractor already filed as a candidate. captureTaskFromNote passes Status: store.TaskOpen, gets Created: false, and replies "это уже в списке". The row is still candidate. Ask for the list afterwards and she says "ещё я нашла, но ты не подтвердил: продлить страховку". He just confirmed it out loud.

Stating the work is a confirmation. Capture should promote candidate to open when the incoming status is open, and the reply should say so.

4. IsTaskListQuery claims questions that have nothing to do with tasks.

Two rules fire with no task noun anywhere in the utterance:

(что|чем) + (сделать|заняться)
what + do

"что мне сделать с этим файлом?" matches the first. "что нужно сделать чтобы перезапустить сервер?" matches it too. "what does docker do?" matches the second, and so does "what do you do?".

The source sits ahead of embed, memory and notes in querySources, so all of these get "задач нет." instead of reaching recall or the model. That is the failure the ordering comment says it is preventing, pointed the other way.

Require a task noun for those two rules as well, or require the pronoun. "что МНЕ нужно сделать" is the phrasing that means the list. "что сделать с файлом" is not.

Smaller notes:

  • formatTaskListRU recites every live task with no cap. internal/memory bounds the same problem at maxRecited = 5 for exactly this reason. Twenty tasks read aloud over TTS is not an answer.
  • handleTasks calls ListTasks(ctx, "") and renders every resolved row that ever existed. There is no limit and no pruning anywhere in tasks.go. The page grows without bound.
  • The store has an Evidence column and the voice path never sets it, correctly. Nothing enforces that: a caller can pass Source: "email:x" with Status: "open" and skip review entirely. The doc says it "must NOT", and CaptureTask accepts it. One check against a derived-source prefix would make the rule real.
  • ParseTaskCapture trims . and ! off the end but not ?. A dictated "добавь в задачи позвонить в банк?" keeps the question mark in the stored text and in the dedupe key.
  • The prefix list has "добавь в список" but not "добавь в тудушки" or "поставь задачу". Not a defect, just the edge of what he says out loud.
Adding no eighth intent is the right call. Tying it to the prompt-parity contract in the header comment is the argument that makes it stick. The candidate status earns its keep. A derived task is named as unconfirmed in the spoken list and in the page. Mail can never quietly become work he owns. The partial unique index over live rows only is the correct shape for a recurring errand. `NormalizeTaskText` refusing to stem is the right kind of shallow. `7f42cc7` touches `internal/router/task.go`, but only to move the vocabulary into `task_phrases.json`. The matchers are unchanged and nothing below is fixed on the tip. **1. The idempotence the intake seam is built on does not survive a resolved task.** `CaptureTaskReq` promises the email extractor "may call CaptureTask for every message it extracts from, as often as it likes, without growing the list". `SetTaskStatus` documents the opposite property one file over: "Resolving frees the dedupe key, which is the point: the work can recur." Both are true, and together they break the seam. Walk it with #246. Mail arrives, the extractor captures "продлить страховку" as a candidate. He confirms it, does it, marks it done. The mail is still in the mailbox, because mavmaild is a read-only reader and nothing marks anything read. The next poll extracts the same task, the dedupe index no longer covers the done row, and a fresh candidate appears. He drops it. The poll after that brings it back again. The free-the-key behaviour is right for voice, where the recurrence signal is him saying it again. It is wrong for a source that re-reads the same immutable text forever. Those are different intake semantics on one seam, and the seam is the whole point of the PR. The fix belongs here rather than in #246, because #246 will be written against this doc comment. Give the request an optional external identity, a message id plus extracted span. Store it, and make derived capture dedupe on it across every status. Voice capture keeps the live-only rule it has now. **2. `SetTaskStatus` at AuthRead lets any enrolled module clear his list.** The policy comment argues the rung by comparing capture to `CreateReminder`. Capture is additive and the comparison holds. `SetTaskStatus` is not additive and it is in the same case arm. `WriteFact` sits at `AuthWrite` for a stated reason: "a module only writes sources it owns", so a compromised poller cannot forge a trigger. Tasks have a `Source` column carrying the same vocabulary, and nothing checks it. At `AuthRead`, `mavpoll` or `mavsttd` can mark every open task done, and the page shows them under resolved with no trace of who moved them. `resolved_ts` records when, never by what. Split the arm. Capture and list at `AuthRead` is fine. `SetTaskStatus` should be at least `AuthWrite` and source-scoped, or the row should record the caller so a wrong resolution is at least attributable. **3. Capturing over an existing candidate leaves it a candidate, and she says he did not confirm it.** `CaptureTask` dedupes on norm across both live statuses. It returns the existing row and never touches its status. He says "добавь в задачи продлить страховку" for a task the mail extractor already filed as a candidate. `captureTaskFromNote` passes `Status: store.TaskOpen`, gets `Created: false`, and replies "это уже в списке". The row is still `candidate`. Ask for the list afterwards and she says "ещё я нашла, но ты не подтвердил: продлить страховку". He just confirmed it out loud. Stating the work is a confirmation. Capture should promote candidate to open when the incoming status is open, and the reply should say so. **4. `IsTaskListQuery` claims questions that have nothing to do with tasks.** Two rules fire with no task noun anywhere in the utterance: (что|чем) + (сделать|заняться) what + do "что мне сделать с этим файлом?" matches the first. "что нужно сделать чтобы перезапустить сервер?" matches it too. "what does docker do?" matches the second, and so does "what do you do?". The source sits ahead of `embed`, `memory` and `notes` in `querySources`, so all of these get "задач нет." instead of reaching recall or the model. That is the failure the ordering comment says it is preventing, pointed the other way. Require a task noun for those two rules as well, or require the pronoun. "что МНЕ нужно сделать" is the phrasing that means the list. "что сделать с файлом" is not. Smaller notes: - `formatTaskListRU` recites every live task with no cap. `internal/memory` bounds the same problem at `maxRecited = 5` for exactly this reason. Twenty tasks read aloud over TTS is not an answer. - `handleTasks` calls `ListTasks(ctx, "")` and renders every resolved row that ever existed. There is no limit and no pruning anywhere in `tasks.go`. The page grows without bound. - The store has an `Evidence` column and the voice path never sets it, correctly. Nothing enforces that: a caller can pass `Source: "email:x"` with `Status: "open"` and skip review entirely. The doc says it "must NOT", and `CaptureTask` accepts it. One check against a derived-source prefix would make the rule real. - `ParseTaskCapture` trims `.` and `!` off the end but not `?`. A dictated "добавь в задачи позвонить в банк?" keeps the question mark in the stored text and in the dedupe key. - The prefix list has "добавь в список" but not "добавь в тудушки" or "поставь задачу". Not a defect, just the edge of what he says out loud.
kami closed this pull request 2026-08-01 14:51:45 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#60