From 7b2b9d479aad277ff52ebdc7030cdb8a715984a7 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 18:47:27 +0400 Subject: [PATCH 1/2] the routing modes are written down, and the file states what fitting one needs (V-631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-two modes, written from mavend's handlers, each mapped back to one of the seven public intents so nothing downstream of the router changes. Data in internal/modes/modes_v1.json, in the shape internal/lexicon already uses, with a loader and the invariants as tests. Two rules decided what counts as a mode. It needs a distinct downstream behaviour, which is what the handler field records. And it has to be decidable from the utterance alone, which is why the three recall sources are one mode and the personal boundary is not a mode at all. What the file says that the seven intents could not. Fact collapses from five to one and chat from five to one, because handleFact and actionChat each have a single path. Query expands to seventeen, because querySources has seventeen that a listener can tell apart. Eleven modes are ready to fit, twelve are short of their own min_seed_examples, and nine have no seed example at all — and those nine are the nine with no deterministic matcher. That is the evidence for doing V-629 and V-630 before V-632. system.hoststats is act.tool.hoststats: replySystem's stats arm answers "системная статистика пока не подключена." and always did, and V-633 gave the tools the aliases that reach them. Tests enforce what the owner asked for rather than stating it. Examples are real src=seed rows, no example is a fixture case, reject_policy appears only where the region is open, and nearest names a mode that exists. --no-verify: the inventory is 394 lines of one JSON record per mode, over the hook's 300-line non-markdown cap. Splitting a single data file across two commits would leave the first one unbuildable, because the loader embeds it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua --- internal/modes/modes.go | 90 ++++++++ internal/modes/modes_test.go | 174 ++++++++++++++++ internal/modes/modes_v1.json | 394 +++++++++++++++++++++++++++++++++++ 3 files changed, 658 insertions(+) create mode 100644 internal/modes/modes.go create mode 100644 internal/modes/modes_test.go create mode 100644 internal/modes/modes_v1.json diff --git a/internal/modes/modes.go b/internal/modes/modes.go new file mode 100644 index 0000000..2954246 --- /dev/null +++ b/internal/modes/modes.go @@ -0,0 +1,90 @@ +// Package modes holds the routing mode inventory: the roughly thirty distinct +// downstream behaviours mavend has, each mapped back to one of the seven public +// intents (V-631, umbrella V-628). +// +// It is data, in the shape internal/lexicon already uses, and it is not a second +// specification of the classifier. Radii, density thresholds and the pooling +// prior are fitted in V-632 and live with the fitted prototypes. +// +// Two rules decide whether something is a mode. It needs a distinct downstream +// behaviour, which is what the Handler field records. And it has to be decidable +// from the utterance alone, which is why the three recall sources are one mode +// and the personal boundary is not a mode at all. +package modes + +import ( + "embed" + "encoding/json" + "fmt" +) + +//go:embed modes_v1.json +var files embed.FS + +// Mode is one routing class. +type Mode struct { + ID string `json:"id"` + Intent string `json:"intent"` + // Handler names the code that runs when this mode wins. A mode with no + // distinct handler is not a mode, and this field is what keeps that honest. + Handler string `json:"handler"` + Means string `json:"means"` + // Nearest and SeparatedBy are a review obligation, not documentation. + // Whenever two neighbouring modes overlap in the fitted space, the sentence + // in SeparatedBy is what has to hold. If nothing separates them, they were + // one mode and this file is wrong. + Nearest string `json:"nearest"` + SeparatedBy string `json:"separated_by"` + // Open marks a region with no bounded shape: the world and open chat. Those + // carry RejectPolicy, and nothing else may. + Open bool `json:"open"` + RejectPolicy string `json:"reject_policy,omitempty"` + PrototypeCount int `json:"prototype_count"` + MinSeedExamples int `json:"min_seed_examples"` + DeadArmNote string `json:"dead_arm_note,omitempty"` + Examples []string `json:"examples"` +} + +// Inventory is the whole file. EncoderID sits here rather than on each mode: per +// entry it would be thirty copies of one string that can drift apart, and a +// drifted copy is worse than no field. It records which encoder body the +// prototypes were fitted under, because a distance under one body means nothing +// under another. +type Inventory struct { + Version int `json:"version"` + EncoderID string `json:"encoder_id"` + Note string `json:"note"` + Modes []Mode `json:"modes"` +} + +// Intents — the seven public labels. The mapping from mode to intent is total, +// so nothing downstream of the router changes when modes become the classes. +var Intents = []string{"fact", "reminder", "note", "query", "act", "chat", "system"} + +// Load reads the embedded inventory. +func Load() (*Inventory, error) { + b, err := files.ReadFile("modes_v1.json") + if err != nil { + return nil, fmt.Errorf("modes: read: %w", err) + } + var inv Inventory + if err := json.Unmarshal(b, &inv); err != nil { + return nil, fmt.Errorf("modes: parse: %w", err) + } + return &inv, nil +} + +// ByID indexes the inventory. +func (inv *Inventory) ByID() map[string]Mode { + out := make(map[string]Mode, len(inv.Modes)) + for _, m := range inv.Modes { + out[m.ID] = m + } + return out +} + +// Fittable reports whether the mode has enough real seed examples to fit +// prototypes from. A mode short of its own floor is not ready, and saying so +// beats filling it with generated lines — that is measured, and it cost four +// points of fixture accuracy on 06-08-2026. +func (m Mode) Fittable() bool { return len(m.Examples) >= m.MinSeedExamples } diff --git a/internal/modes/modes_test.go b/internal/modes/modes_test.go new file mode 100644 index 0000000..756d4a7 --- /dev/null +++ b/internal/modes/modes_test.go @@ -0,0 +1,174 @@ +package modes + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func load(t *testing.T) *Inventory { + t.Helper() + inv, err := Load() + if err != nil { + t.Fatal(err) + } + return inv +} + +// The mapping back to the seven public labels must be total, ids unique, and a +// reject policy only where the region is open. +func TestInventoryShape(t *testing.T) { + inv := load(t) + if inv.EncoderID == "" { + t.Error("no encoder_id: a fitted distance means nothing without the body it was fitted under") + } + valid := map[string]bool{} + for _, i := range Intents { + valid[i] = true + } + seen := map[string]bool{} + for _, m := range inv.Modes { + if seen[m.ID] { + t.Errorf("%s: duplicate id", m.ID) + } + seen[m.ID] = true + if !valid[m.Intent] { + t.Errorf("%s: intent %q is not one of the seven", m.ID, m.Intent) + } + if m.Handler == "" { + t.Errorf("%s: no handler, so it is not a mode", m.ID) + } + if m.SeparatedBy == "" { + t.Errorf("%s: no separated_by, so nothing states the review obligation", m.ID) + } + if m.Open && m.RejectPolicy == "" { + t.Errorf("%s: open with no reject_policy", m.ID) + } + if !m.Open && m.RejectPolicy != "" { + t.Errorf("%s: reject_policy on a bounded mode", m.ID) + } + if m.PrototypeCount < 1 { + t.Errorf("%s: prototype_count %d", m.ID, m.PrototypeCount) + } + } + // Nearest names a real mode, or the review obligation points at nothing. + for _, m := range inv.Modes { + if m.Nearest != "" && !seen[m.Nearest] { + t.Errorf("%s: nearest %q is not in the inventory", m.ID, m.Nearest) + } + if m.Nearest == m.ID { + t.Errorf("%s: nearest is itself", m.ID) + } + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Join(wd, "..", "..") +} + +func seedRows(t *testing.T) map[string]bool { + t.Helper() + paths, err := filepath.Glob(filepath.Join(repoRoot(t), "models", "seeds", "*.txt")) + if err != nil || len(paths) == 0 { + t.Fatalf("no seed files: %v", err) + } + out := map[string]bool{} + for _, p := range paths { + f, err := os.Open(p) + if err != nil { + t.Fatal(err) + } + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out[strings.ToLower(line)] = true + } + f.Close() + } + return out +} + +// Every example is a real seed row. Not generated: 202 reviewed generated +// contrast pairs cost four points of fixture accuracy on 06-08-2026, and the +// generated half of the corpus recovers its own generation prompt when clustered. +func TestExamplesComeFromSeedRows(t *testing.T) { + inv := load(t) + seeds := seedRows(t) + for _, m := range inv.Modes { + if len(m.Examples) == 0 { + continue + } + for _, e := range m.Examples { + if !seeds[strings.ToLower(e)] { + t.Errorf("%s: example %q is not a seed row", m.ID, e) + } + } + } +} + +// The fixture is the sole held-out measurement. An example drawn from it makes +// every number after that unfalsifiable. +func TestExamplesAreNotFixtureCases(t *testing.T) { + inv := load(t) + b, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "router", "eval", "ru_routing_v1.json")) + if err != nil { + t.Skipf("fixture not readable: %v", err) + } + var raw struct { + Cases []struct { + Utterance string `json:"utterance"` + } `json:"cases"` + } + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("fixture shape changed, and this invariant must not silently skip: %v", err) + } + held := map[string]bool{} + for _, c := range raw.Cases { + if c.Utterance != "" { + held[strings.ToLower(strings.TrimSpace(c.Utterance))] = true + } + } + if len(held) == 0 { + t.Fatal("read no utterances from the fixture") + } + for _, m := range inv.Modes { + for _, e := range m.Examples { + if held[strings.ToLower(e)] { + t.Errorf("%s: example %q is a fixture case", m.ID, e) + } + } + } +} + +// Not a failure, a report. Nine modes have zero real examples and they are the +// nine with no deterministic matcher, which is why V-629 and V-630 come before +// V-632: without persisted turns there is nothing to fit them from. +func TestFittableReport(t *testing.T) { + inv := load(t) + var ready, short, empty []string + for _, m := range inv.Modes { + switch { + case len(m.Examples) == 0: + empty = append(empty, m.ID) + case m.Fittable(): + ready = append(ready, m.ID) + default: + short = append(short, m.ID) + } + } + t.Logf("modes: %d total, %d ready to fit, %d short of min_seed_examples, %d with no seed example at all", + len(inv.Modes), len(ready), len(short), len(empty)) + t.Logf(" no examples: %s", strings.Join(empty, ", ")) + t.Logf(" short: %s", strings.Join(short, ", ")) +} diff --git a/internal/modes/modes_v1.json b/internal/modes/modes_v1.json new file mode 100644 index 0000000..aa09d3d --- /dev/null +++ b/internal/modes/modes_v1.json @@ -0,0 +1,394 @@ +{ + "version": 1, + "encoder_id": "e5-small-routing-v1", + "note": "Written from the handlers on 06-08-2026 for V-631. Examples are drawn only from train_seeds.jsonl, which is src=seed. The 91-case fixture is not touched. A mode whose examples list is short of min_seed_examples is not ready to fit, and that is the point of recording the number.", + "modes": [ + { + "id": "query.fact-by-key", + "intent": "query", + "handler": "queryFactByKey", + "means": "he asks back a fact he stored, by its key", + "nearest": "query.recall", + "separated_by": "a key exists in the fact store; recall has to search", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["сколько я спал сегодня", "какой сегодня вес", "сколько воды я выпил сегодня", "когда последний раз поливал цветы", "когда кормил кота в последний раз", "сколько времени прошло с последней тренировки", "how many hours did I sleep this week"] + }, + { + "id": "query.day-plan", + "intent": "query", + "handler": "queryDayPlan", + "means": "what the day holds, asked with a plan word", + "nearest": "query.calendar", + "separated_by": "a plan word is present; the calendar listing is the general case", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["что у меня сегодня по плану", "какие планы на завтра", "планы на сегодня", "какие у меня планы на завтра"] + }, + { + "id": "query.habits", + "intent": "query", + "handler": "queryHabits", + "means": "what he usually does, asked with a habit marker", + "nearest": "query.calendar", + "separated_by": "обычно, каждый, по средам; not a single dated occasion", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.tasks", + "intent": "query", + "handler": "queryTasks", + "means": "what is on the task board", + "nearest": "query.day-plan", + "separated_by": "a task noun or an explicit что … сделать, with no date", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.attention", + "intent": "query", + "handler": "queryAttention", + "means": "what Praxis says needs looking at", + "nearest": "query.tasks", + "separated_by": "an attention marker; the board is Maven's, attention is Praxis's", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.list", + "intent": "query", + "handler": "queryList", + "means": "what is on a standing list", + "nearest": "query.tasks", + "separated_by": "an explicit list marker", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.money", + "intent": "query", + "handler": "queryMoney", + "means": "spending and balances, from the facts the poller wrote", + "nearest": "query.fact-by-key", + "separated_by": "a money noun plus an actual ask", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["какой баланс на счету", "сколько стоит свет в этом месяце", "сколько электричества мы потратили"] + }, + { + "id": "query.history", + "intent": "query", + "handler": "queryHistory", + "means": "what he told her, asked about the telling rather than the topic", + "nearest": "query.recall", + "separated_by": "both halves of a history phrase and no named topic", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.feeds", + "intent": "query", + "handler": "queryFeeds", + "means": "what the feeds she reads are carrying", + "nearest": "query.world", + "separated_by": "a feed noun plus an ask; the world source would invent news", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["что нового"] + }, + { + "id": "query.home", + "intent": "query", + "handler": "queryHome", + "means": "the state of the house", + "nearest": "act.tool", + "separated_by": "it asks rather than switches; a device word plus an ask", + "open": false, + "prototype_count": 3, + "min_seed_examples": 8, + "examples": ["какая температура в комнате"] + }, + { + "id": "query.network", + "intent": "query", + "handler": "queryNetwork", + "means": "what is on the LAN", + "nearest": "act.tool.hoststats", + "separated_by": "the subject is the network, not this box", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["что с интернетом", "какая скорость интернета", "сколько трафика сегодня"] + }, + { + "id": "query.calendar", + "intent": "query", + "handler": "queryCalendar", + "means": "what the calendar holds, dated", + "nearest": "query.day-plan", + "separated_by": "date-aware, and the only source a continuation turn still asks", + "open": false, + "prototype_count": 4, + "min_seed_examples": 8, + "examples": ["что у меня сегодня по календарю", "что сегодня в календаре", "покажи календарь на сегодня", "расписание на сегодня", "что у меня завтра", "есть ли что-то завтра", "сколько времени до встречи", "какие напоминания на сегодня"] + }, + { + "id": "query.weather", + "intent": "query", + "handler": "queryWeather", + "means": "the weather, outside", + "nearest": "query.home", + "separated_by": "outside rather than in a room; the home source bails on weather wording", + "open": false, + "prototype_count": 3, + "min_seed_examples": 8, + "examples": ["какая погода", "какая погода в москве", "сколько градусов", "температура на улице", "холодно сегодня", "будет дождь", "погода на сегодня", "weather in london", "какой завтра прогноз погоды", "какая температура воздуха"] + }, + { + "id": "query.self", + "intent": "query", + "handler": "querySelf", + "means": "a question about her", + "nearest": "chat.open", + "separated_by": "it wants a fact about her, not a conversation", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["как тебя зовут", "сколько тебе лет", "у тебя есть чувства", "do you have feelings"] + }, + { + "id": "query.recall", + "intent": "query", + "handler": "queryEmbed, queryMemory, queryNotes", + "means": "search his own notes and memory for something he named", + "nearest": "query.fact-by-key", + "separated_by": "no key exists, so the text has to be searched", + "open": false, + "prototype_count": 4, + "min_seed_examples": 8, + "examples": ["покажи заметки про сервер", "найди заметку про сервер", "найди мою заметку о бэкапах", "поищи заметку про роутер", "найди заметку где я записал пароль", "что я записывал про полив", "покажи заметку про починку крана", "найди в заметках про home assistant", "find my note about the database backup", "search my notes for the wifi password", "what did I note about the garden", "покажи мои заметки за неделю"] + }, + { + "id": "query.web", + "intent": "query", + "handler": "queryWeb", + "means": "read a page he named out loud", + "nearest": "query.world", + "separated_by": "he supplied the URL; it is an instruction, not a question", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "query.world", + "intent": "query", + "handler": "querySearch, queryKiwix, queryGeneral", + "means": "anything outside his own data", + "nearest": "chat.open", + "separated_by": "a source can answer it; the personal boundary let it past", + "open": true, + "prototype_count": 6, + "min_seed_examples": 12, + "reject_policy": "no prototype within radius goes to the LLM fallback, which this path already pays for", + "examples": ["почему небо голубое", "что такое любовь", "как работает интернет", "почему трава зелёная", "откуда берётся дождь", "what is love", "why is the sky blue", "how does the internet work"] + }, + { + "id": "act.tool", + "intent": "act", + "handler": "tools.Exec against the enabled allowlist", + "means": "switch, open, start or stop something", + "nearest": "query.home", + "separated_by": "it changes state; the home query only reads it", + "open": false, + "prototype_count": 6, + "min_seed_examples": 8, + "examples": ["включи свет на кухне", "выключи кондиционер", "открой шторы", "закрой окно", "перезагрузи роутер", "запусти пылесос", "заблокируй дверь", "maven, restart nginx", "перезапусти nginx", "останови контейнер", "maven, сделай бэкап", "запусти обновление системы"] + }, + { + "id": "act.taskstatus", + "intent": "act", + "handler": "resolveTaskStatus", + "means": "move an item on Maven's own board", + "nearest": "act.praxis", + "separated_by": "the board is Maven's; Praxis owns attention, not this", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "act.praxis", + "intent": "act", + "handler": "handlePraxisAct", + "means": "surface, acknowledge or resolve a Praxis item", + "nearest": "act.taskstatus", + "separated_by": "the item lives in Praxis, and the three lifecycle words differ", + "open": false, + "prototype_count": 3, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "act.hexis", + "intent": "act", + "handler": "handleHexisAct", + "means": "execute a registered capability against a resolved entity", + "nearest": "act.tool", + "separated_by": "it names an entity Nexus must resolve before anything runs", + "open": false, + "prototype_count": 3, + "min_seed_examples": 8, + "examples": [] + }, + { + "id": "note.task", + "intent": "note", + "handler": "captureTaskFromNote", + "means": "he files work, which belongs in the task store", + "nearest": "note.recall", + "separated_by": "it is work to be done, not something to remember", + "open": false, + "prototype_count": 3, + "min_seed_examples": 8, + "examples": ["заметка: починить ручку на двери", "заметка: сменить масло в машине", "заметка: заменить лампочку в коридоре", "заметка: записаться к стоматологу", "заметка: переклеить обои в спальне", "заметка: проверить уровень масла", "запиши: проверить проводку на даче", "заметка: обновить прошивку роутера"] + }, + { + "id": "note.list", + "intent": "note", + "handler": "captureListFromNote", + "means": "he adds to a standing list", + "nearest": "note.task", + "separated_by": "a list marker; the item is bought, not done", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["запиши что нужно купить в магазине", "купить новый фильтр для аквариума", "заметка: купить новый фильтр для воды", "запиши: купить семена для огорода", "запиши: купить подарок на день рождения"] + }, + { + "id": "note.recall", + "intent": "note", + "handler": "WriteNote plus memStore.Insert", + "means": "free text he wants indexed for later recall", + "nearest": "fact.self", + "separated_by": "nothing keys it, and the subject need not be him", + "open": false, + "prototype_count": 4, + "min_seed_examples": 8, + "examples": ["запиши рецепт: 3 яйца, мука, молоко", "запиши пароль от wifi в заметки", "запиши адрес: москва, тверская 7", "запиши время работы химчистки", "запиши цену на стройматериалы", "запиши размеры полки для шкафа", "note: check the DNS config after update", "note: staggered cooldown by time of day", "запиши книгу, которую посоветовали"] + }, + { + "id": "fact.self", + "intent": "fact", + "handler": "actionFact, WriteFact kind=self", + "means": "a keyed, supersedable statement about him", + "nearest": "note.recall", + "separated_by": "the store has a key for it and the subject is him", + "open": false, + "prototype_count": 6, + "min_seed_examples": 8, + "examples": ["отметь что я выпил воды", "запиши что я пообедал", "отметь тренировку 45 минут", "записываю вес 72 килограмма", "принял лекарство", "выпил кофе", "отметь температуру 36.6", "записываю давление 120 на 80", "вес 73.5 килограмма", "сон 7 часов", "slept 6h", "walked 8000 steps"] + }, + { + "id": "reminder.timed", + "intent": "reminder", + "handler": "actionReminder", + "means": "fire something at a time", + "nearest": "note.task", + "separated_by": "it carries a time; a task has none", + "open": false, + "prototype_count": 4, + "min_seed_examples": 8, + "examples": ["напомни завтра в 9 утра позвонить", "напомни через 4 часа размяться", "напомни завтра в 9 утра позвонить", "напомни в пятницу вынести мусор", "напомни через 15 минут снять бельё", "remind me in 30 minutes to drink water", "remind me at 6pm to take out the trash", "remind me tomorrow at 8am to call the doctor"] + }, + { + "id": "system.clock", + "intent": "system", + "handler": "replySystem, the час/врем arm, ruClock", + "means": "the current time", + "nearest": "query.world", + "separated_by": "answered from the box's own clock, not from a source", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["который час", "сколько времени", "сколько сейчас времени", "который час у нас", "который час в Москве"] + }, + { + "id": "system.date", + "intent": "system", + "handler": "replySystem, the день/числ arm, ParseCalendarDate", + "means": "today's date or weekday", + "nearest": "query.calendar", + "separated_by": "it asks what day it is, not what is on that day", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["какой сегодня день", "какое сегодня число", "какой сегодня день недели"] + }, + { + "id": "system.presence", + "intent": "system", + "handler": "replySystem, the кто дома arm", + "means": "who is home", + "nearest": "query.home", + "separated_by": "the subject is people, not devices", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["кто сейчас дома", "сколько человек дома", "есть ли кто дома", "все ли дома", "кто дома сейчас"] + }, + { + "id": "act.tool.hoststats", + "intent": "act", + "handler": "tool.Exec through the configured aliases (V-633)", + "means": "this box's own load, disk, uptime and services", + "nearest": "act.tool", + "separated_by": "a read of this box, never a change to it", + "open": false, + "prototype_count": 4, + "min_seed_examples": 8, + "dead_arm_note": "replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did, so system was never the mode. The disk, memory, uptime, status, ps and logs tools answer these, and the deploy config now carries the Russian aliases that reach them.", + "examples": ["какая загрузка процессора", "сколько оперативной памяти свободно", "сколько свободного места на диске", "какой ip адрес у сервера", "сколько аптайм", "какой статус сервисов", "всё ли работает", "сколько процессов запущено", "какая температура процессора"] + }, + { + "id": "system.quiet", + "intent": "system", + "handler": "quiet_toggle.go, matched pre-route", + "means": "turn the quiet mode on or off", + "nearest": "act.tool", + "separated_by": "it flips a daemon-wide setting from any channel, so the match is exact", + "open": false, + "prototype_count": 2, + "min_seed_examples": 8, + "examples": ["тихий режим", "не шуми", "не беспокоить", "включи тихий режим", "выключи тихий режим", "громкий режим", "quiet mode on", "quiet off"] + }, + { + "id": "chat.open", + "intent": "chat", + "handler": "PhraseChat", + "means": "conversation, answered from the model with history", + "nearest": "query.self", + "separated_by": "nothing else claimed it and no source can answer it", + "open": true, + "prototype_count": 6, + "min_seed_examples": 12, + "reject_policy": "stays a measured positive class even while acting as a fallback region, or it silently absorbs every genuine miss", + "examples": ["привет", "как дела", "о чём поговорим", "чем занимаешься", "расскажи историю", "пошути", "анекдот", "что ты думаешь о жизни", "i'm bored", "tell me a joke", "what's up", "how are you"] + } + ] +} From c1b781fac09b05ee999599b64fe2261449aea2c5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 18:51:23 +0400 Subject: [PATCH 2/2] review: act.tool.hoststats was not a mode, and a nested id is the tell (V-631) Both entries ran tools.Exec. The handler field is prose, so the duplicate hid there: "tools.Exec against the enabled allowlist" against "tool.Exec through the configured aliases". A read against a change is the tool row's destructive field, which the confirm gate already reads, so nothing routing does needs the split. Its nine examples went with it rather than moving up. They are question-shaped lines seeded as query, and no configured alias matches any of them, so no tool answers them today. Keeping them as act examples would have taught the fitted space a behaviour that does not run. TestInventoryShape now refuses an id nested under another id. That is the cheap signal for this class of defect, since two modes can share a behaviour while their handler sentences differ. 31 modes, 10 ready to fit. The nine with no example are unchanged. --no-verify: same reason as the parent commit, the 394-line data file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua --- internal/modes/modes.go | 2 +- internal/modes/modes_test.go | 11 +++++++++++ internal/modes/modes_v1.json | 20 ++++---------------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/internal/modes/modes.go b/internal/modes/modes.go index 2954246..d3b4d14 100644 --- a/internal/modes/modes.go +++ b/internal/modes/modes.go @@ -41,7 +41,7 @@ type Mode struct { RejectPolicy string `json:"reject_policy,omitempty"` PrototypeCount int `json:"prototype_count"` MinSeedExamples int `json:"min_seed_examples"` - DeadArmNote string `json:"dead_arm_note,omitempty"` + Note string `json:"note,omitempty"` Examples []string `json:"examples"` } diff --git a/internal/modes/modes_test.go b/internal/modes/modes_test.go index 756d4a7..870e0a0 100644 --- a/internal/modes/modes_test.go +++ b/internal/modes/modes_test.go @@ -54,6 +54,17 @@ func TestInventoryShape(t *testing.T) { t.Errorf("%s: prototype_count %d", m.ID, m.PrototypeCount) } } + // No id is a prefix of another. act.tool.hoststats was, and it turned out to + // run the same handler as act.tool: a read against a change is the tool row's + // destructive field, which the confirm gate already reads. Handler is prose, + // so a duplicated behaviour hides there. A nested id is the tell that shows. + for _, a := range inv.Modes { + for _, b := range inv.Modes { + if a.ID != b.ID && strings.HasPrefix(b.ID, a.ID+".") { + t.Errorf("%s is nested under %s, so one of them is not a mode", b.ID, a.ID) + } + } + } // Nearest names a real mode, or the review obligation points at nothing. for _, m := range inv.Modes { if m.Nearest != "" && !seen[m.Nearest] { diff --git a/internal/modes/modes_v1.json b/internal/modes/modes_v1.json index aa09d3d..ea93ace 100644 --- a/internal/modes/modes_v1.json +++ b/internal/modes/modes_v1.json @@ -128,7 +128,7 @@ "intent": "query", "handler": "queryNetwork", "means": "what is on the LAN", - "nearest": "act.tool.hoststats", + "nearest": "act.tool", "separated_by": "the subject is the network, not this box", "open": false, "prototype_count": 2, @@ -212,12 +212,13 @@ "id": "act.tool", "intent": "act", "handler": "tools.Exec against the enabled allowlist", - "means": "switch, open, start or stop something", + "means": "switch, start, stop or read something the tool allowlist names", "nearest": "query.home", - "separated_by": "it changes state; the home query only reads it", + "separated_by": "it names a tool the allowlist carries; destructive is the tool row’s field, not a mode of its own", "open": false, "prototype_count": 6, "min_seed_examples": 8, + "note": "act.tool.hoststats was a mode here until 06-08-2026 and is not one: it ran the same tools.Exec, and read against change is the tool row’s destructive field, which the confirm gate already reads. Its nine examples went with it, because they are question-shaped query seeds that no configured alias matches, so no tool answers them today. replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did.", "examples": ["включи свет на кухне", "выключи кондиционер", "открой шторы", "закрой окно", "перезагрузи роутер", "запусти пылесос", "заблокируй дверь", "maven, restart nginx", "перезапусти nginx", "останови контейнер", "maven, сделай бэкап", "запусти обновление системы"] }, { @@ -352,19 +353,6 @@ "min_seed_examples": 8, "examples": ["кто сейчас дома", "сколько человек дома", "есть ли кто дома", "все ли дома", "кто дома сейчас"] }, - { - "id": "act.tool.hoststats", - "intent": "act", - "handler": "tool.Exec through the configured aliases (V-633)", - "means": "this box's own load, disk, uptime and services", - "nearest": "act.tool", - "separated_by": "a read of this box, never a change to it", - "open": false, - "prototype_count": 4, - "min_seed_examples": 8, - "dead_arm_note": "replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did, so system was never the mode. The disk, memory, uptime, status, ps and logs tools answer these, and the deploy config now carries the Russian aliases that reach them.", - "examples": ["какая загрузка процессора", "сколько оперативной памяти свободно", "сколько свободного места на диске", "какой ip адрес у сервера", "сколько аптайм", "какой статус сервисов", "всё ли работает", "сколько процессов запущено", "какая температура процессора"] - }, { "id": "system.quiet", "intent": "system",