/tasks counts stall shapes, and assesses none of them (V-512)

Step 5 of the board build. internal/tasks/stall.go counts three shapes —
overdue, sitting longer than StallDays, waiting for confirmation — and states
nothing about what any of them means. That is the line
internal/memory/behavior.go already drew for habits, and the reason is the
same: a 1.7B asked to judge will agree fluently and launder a guess into a
decision. A test asserts the wording carries no assessment.

Sitting is measured from created_ts, the only clock a live row carries: the
store stamps resolved_ts and nothing else. So "no state change in eleven days"
is exactly "captured eleven days ago and still live", which is narrower than
the plan's wording and is the claim the data supports. A candidate is never
counted as overdue, because its due date is Maven's reading of a mail rather
than a deadline he set.

Not a nag. No tick rule reads the counts; they go on /tasks and into the list
reply when he asks, and tickLoop.dayPlan still does not read tasks at all. The
empty case renders as nothing: "ничего не залежалось" appended to every list
read is a nag with a friendly face.

Three say entries, so the page and the spoken list cannot word it differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
This commit is contained in:
2026-08-05 19:54:21 +04:00
parent cd6549fa51
commit be62660be9
7 changed files with 220 additions and 2 deletions
+11 -1
View File
@@ -82,7 +82,17 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
}
h.offerCandidates(ctx, cands)
return tasks.FormatRU(ranked), true
reply := tasks.FormatRU(ranked)
// The counted shapes, after the list and only when there are any (V-512).
// They answer "what is going wrong with this list" without assessing any of
// it, and they are said here rather than announced: no tick rule reads them.
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
if !strings.HasSuffix(reply, ".") {
reply += "."
}
reply += " " + stalls
}
return reply, true
}
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
+2 -1
View File
@@ -979,11 +979,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Stalls []tasks.Stall
Candidates []taskRow
Open []taskRow
Resolved []taskRow
ResolvedMore bool
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
log.Printf("tasks render: %v", err)
}
}
+12
View File
@@ -21,6 +21,18 @@
</form>
</section>
{{if .Stalls}}
<section class=card>
<h2 class=card-title>shapes</h2>
<!-- Counts, and nothing about what they mean (V-512). Whether a task should be
dropped is his call and Maven does not have an opinion to show here. -->
<div class=scroll><table>
<tr><th>count</th><th>shape</th></tr>
{{range .Stalls}}<tr><td>{{.N}}</td><td>{{.Line}}</td></tr>{{end}}
</table></div>
</section>
{{end}}
{{if .Candidates}}
<section class=card>
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
+15
View File
@@ -36,6 +36,13 @@ const (
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
// The counted stall shapes on /tasks and in the spoken list (V-512). Each
// one states a count and nothing about what it means: "лежит дольше десяти
// дней" is arithmetic, "стоит бросить" would be a judgement she may not make.
StallOverdue = "stall_overdue"
StallSitting = "stall_sitting"
StallUnconfirmed = "stall_unconfirmed"
ReasonOverdue = "reason_overdue"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
@@ -64,6 +71,7 @@ const (
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
StallOverdue, StallSitting, StallUnconfirmed,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
@@ -86,6 +94,10 @@ var summaryFloor = map[string]string{
TasksFirst: "сначала: {items}",
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
StallOverdue: "{n} {word} просрочено",
StallSitting: "{n} {word} лежит дольше {days} {dayword}",
StallUnconfirmed: "{n} {word} ждёт подтверждения",
ReasonOverdue: "просрочено",
ReasonOverdueDays: "просрочено на {n} {word}",
ReasonToday: "сегодня",
@@ -128,6 +140,9 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
{StallSitting, "{n}"}, {StallSitting, "{days}"},
{StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
+13
View File
@@ -44,6 +44,19 @@
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
},
"stall_overdue": {
"fixed": true,
"variants": ["{n} {word} просрочено"]
},
"stall_sitting": {
"fixed": true,
"variants": ["{n} {word} лежит дольше {days} {dayword}"]
},
"stall_unconfirmed": {
"fixed": true,
"variants": ["{n} {word} ждёт подтверждения"]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
+99
View File
@@ -0,0 +1,99 @@
package tasks
import (
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Counted stall shapes (Vikunja #512, step 5 of docs/plans/15-board-surface.md).
//
// She may count. She may not assess. Every shape here is arithmetic over rows he
// can see — how many, how long, how many undated — and none of it says whether a
// task matters, whether a blocker is real, or whether something should be
// dropped. That is the line internal/memory/behavior.go already drew for habits
// and the reason is the same: a 1.7B asked to judge will agree fluently and
// launder a guess into a decision.
//
// Not a nag either. Nothing here is read by the tick loop; the counts go on
// /tasks and into the answer when he asks for the list. tickLoop.dayPlan
// deliberately does not read tasks — keep it that way.
// StallDays — how long a live task has to have sat before it is counted as
// sitting. Ten days rather than a week, because a task captured on a Friday and
// still open the next Friday is an ordinary week, not a stall.
//
// Measured from Created, which is the only clock a live row carries: the store
// stamps resolved_ts and nothing else, so "no state change in eleven days" is
// exactly "captured eleven days ago and still live". That is a narrower claim
// than the plan's wording and it is the one the data supports.
const StallDays = 10
// Stall — one counted shape: how many tasks, and the sentence that says what
// they have in common. N is always ≥ 1; a shape with no tasks in it is not
// returned, because "нет просроченных" is a reassurance nobody asked for.
type Stall struct {
N int
Line string
}
// Stalls counts the shapes present in a set of live tasks, in a fixed order:
// overdue first, then sitting, then unconfirmed. Fixed because the order is what
// he reads first, and sorting by count would move the sections around every time
// one number changed.
//
// Resolved tasks are not passed in and would not be counted if they were: this
// is a statement about outstanding work.
func Stalls(items []Item, now time.Time) []Stall {
var overdue, sitting, unconfirmed int
for _, it := range items {
if it.Status == StatusCandidate {
unconfirmed++
// A candidate is Maven's reading of something she read. Counting it as
// overdue would put her own guess about a deadline in a number he is
// meant to act on.
continue
}
if it.Due != nil && it.Due.Before(now) {
overdue++
}
if now.Sub(it.Created) >= StallDays*24*time.Hour {
sitting++
}
}
var out []Stall
add := func(n int, key string, args map[string]string) {
if n == 0 {
return
}
out = append(out, Stall{N: n, Line: say.S(key, args)})
}
add(overdue, say.StallOverdue, map[string]string{
"n": fmt.Sprint(overdue), "word": say.CountWord(overdue, "задача", "задачи", "задач"),
})
add(sitting, say.StallSitting, map[string]string{
"n": fmt.Sprint(sitting), "word": say.CountWord(sitting, "задача", "задачи", "задач"),
"days": fmt.Sprint(StallDays), "dayword": say.Days(StallDays),
})
add(unconfirmed, say.StallUnconfirmed, map[string]string{
"n": fmt.Sprint(unconfirmed), "word": say.CountWord(unconfirmed, "задача", "задачи", "задач"),
})
return out
}
// StallsRU joins the shapes into one sentence for the spoken list, or "" when
// there are none. Empty on purpose: the list read is the answer to his question,
// and appending "ничего не залежалось" to it every time is a nag with a friendly
// face.
func StallsRU(stalls []Stall) string {
if len(stalls) == 0 {
return ""
}
parts := make([]string, 0, len(stalls))
for _, s := range stalls {
parts = append(parts, s.Line)
}
return strings.Join(parts, "; ") + "."
}
+68
View File
@@ -0,0 +1,68 @@
package tasks
import (
"strings"
"testing"
"time"
)
func stallNow() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) }
func TestStallsCountsTheThreeShapes(t *testing.T) {
now := stallNow()
day := 24 * time.Hour
yesterday := now.Add(-day)
items := []Item{
// Overdue and sitting at once: it counts in both, because they are two
// different things wrong with one task.
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-20 * day), Due: &yesterday},
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-12 * day)},
{ID: 3, Text: "позвонить маме", Status: StatusOpen, Created: now.Add(-time.Hour)},
{ID: 4, Text: "продлить домен", Status: StatusCandidate, Created: now.Add(-30 * day), Due: &yesterday},
}
got := Stalls(items, now)
if len(got) != 3 {
t.Fatalf("shapes = %+v, want overdue, sitting, unconfirmed", got)
}
if got[0].N != 1 {
t.Errorf("overdue = %d, want 1 — a candidate's due date is Maven's reading of a mail", got[0].N)
}
if got[1].N != 2 {
t.Errorf("sitting = %d, want 2", got[1].N)
}
if got[2].N != 1 {
t.Errorf("unconfirmed = %d, want 1", got[2].N)
}
line := StallsRU(got)
for _, want := range []string{"просрочено", "лежит", "подтверждения"} {
if !strings.Contains(line, want) {
t.Errorf("StallsRU = %q, want it to mention %q", line, want)
}
}
}
func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) {
now := stallNow()
items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-time.Hour)}}
if got := Stalls(items, now); len(got) != 0 {
t.Fatalf("shapes = %+v, want none", got)
}
// "ничего не залежалось" appended to every list read is a nag with a
// friendly face, so the empty case renders as nothing at all.
if got := StallsRU(nil); got != "" {
t.Errorf("StallsRU(nil) = %q, want empty", got)
}
}
func TestStallsCountsNoJudgement(t *testing.T) {
// The line this shape may not cross. Every sentence states a count; none of
// them says whether the work matters or should be dropped.
now := stallNow()
items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-40 * 24 * time.Hour)}}
line := StallsRU(Stalls(items, now))
for _, banned := range []string{"стоит", "лучше", "надо", "брось", "важно"} {
if strings.Contains(line, banned) {
t.Errorf("StallsRU = %q — %q is an assessment, and counting is the whole licence here", line, banned)
}
}
}