From be62660be94c8980fdd7f5a127e0d89d3329e4b3 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 19:54:21 +0400 Subject: [PATCH] /tasks counts stall shapes, and assesses none of them (V-512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy --- cmd/mavend/actions_task.go | 12 +++- cmd/mavweb/main.go | 3 +- cmd/mavweb/tasks.html | 12 ++++ internal/say/summary.go | 15 +++++ internal/say/summary_ru_v1.json | 13 +++++ internal/tasks/stall.go | 99 +++++++++++++++++++++++++++++++++ internal/tasks/stall_test.go | 68 ++++++++++++++++++++++ 7 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 internal/tasks/stall.go create mode 100644 internal/tasks/stall_test.go 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}} +
+

shapes

+ +
+ +{{range .Stalls}}{{end}} +
countshape
{{.N}}{{.Line}}
+
+{{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) + } + } +}