diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go
index e6ddb05..b73ceb2 100644
--- a/cmd/mavend/actions_task.go
+++ b/cmd/mavend/actions_task.go
@@ -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).
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index a36b70a..510faca 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -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)
}
}
diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html
index 70f6797..e6daba3 100644
--- a/cmd/mavweb/tasks.html
+++ b/cmd/mavweb/tasks.html
@@ -21,6 +21,18 @@
+{{if .Stalls}}
+
+{{end}}
+
{{if .Candidates}}
found, not confirmed {{len .Candidates}}
diff --git a/internal/say/summary.go b/internal/say/summary.go
index e4f6791..0ae0cfa 100644
--- a/internal/say/summary.go
+++ b/internal/say/summary.go
@@ -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}"},
diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json
index daed24e..74048fc 100644
--- a/internal/say/summary_ru_v1.json
+++ b/internal/say/summary_ru_v1.json
@@ -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": ["просрочено"]
diff --git a/internal/tasks/stall.go b/internal/tasks/stall.go
new file mode 100644
index 0000000..63ff9ad
--- /dev/null
+++ b/internal/tasks/stall.go
@@ -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, "; ") + "."
+}
diff --git a/internal/tasks/stall_test.go b/internal/tasks/stall_test.go
new file mode 100644
index 0000000..7a3ddec
--- /dev/null
+++ b/internal/tasks/stall_test.go
@@ -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)
+ }
+ }
+}