From 99d66e98cd69cb4f5df56833af5daaffb20cbec0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 17:29:16 -0400 Subject: [PATCH 01/22] task: Add open/all source toggle to crew config Co-Authored-By: codeaf --- internal/config/crew.go | 138 ++++++++++++++++--- internal/config/crew_test.go | 168 ++++++++++++++++++++++++ internal/config/derivation_test.go | 6 + internal/config/settings.go | 30 +++++ internal/manual/chat/models-and-cost.md | 8 ++ 5 files changed, 334 insertions(+), 16 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index cbf7d5c9f9..2674817ba5 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -19,9 +19,20 @@ import ( // THREE PRESETS AND NO MORE. A fourth would be a fourth thing to explain, and // the axis they move along has exactly three interesting points: everything // cheap, one model thinking over cheap ones working, everything capable and -// asked to think. Every model in every preset is open-source, which is the -// property that makes a shipped default defensible — nobody's default crew -// should be a bet on one vendor's pricing. +// asked to think. +// +// TWO FAMILIES BEHIND THE SAME THREE WORDS. Every preset exists twice: once in +// the open family, every seat an open-weight model, which is the property that +// makes a shipped default defensible, because nobody's default crew should be +// a bet on one vendor's pricing; and once in the all family, the same three +// words resolved over the whole catalog with closed and frontier models in it, +// for the person who has chosen to spend what those cost. Which family the +// words draw from is one row, [KeyCrewSource], and open is what it reads when +// nobody has answered it: the all family is a thing a person asks for, never +// one they get by saying nothing. Both the derived reading and the write +// resolve through the row ([CrewSourceAt]), so the word on the sheet can never +// mean one family while the five values it summarizes were drawn from the +// other. // // THE PRESET IS DERIVED AND NEVER STORED. [CrewAt] reads the five live tier // values and answers which preset they are, or "custom". A stored word would be @@ -56,6 +67,24 @@ var CrewPresets = []string{CrewFrugal, CrewBalanced, CrewMax} // that identity is asserted by a test rather than trusted. const DefaultCrew = CrewBalanced +// The two families the preset words can draw from. They are the values +// [KeyCrewSource] takes, spelled here once and read by the row, the resolver +// and the manual. +const ( + // CrewSourceOpen is the open-weight family and the shipped default. + CrewSourceOpen = "open" + // CrewSourceAll is the whole catalog, closed and frontier models included. + CrewSourceAll = "all" +) + +// CrewSources lists them, open first, which is also the default and the order +// the row widens in. +var CrewSources = []string{CrewSourceOpen, CrewSourceAll} + +// DefaultCrewSource is open, for the same reason the shipped crew is open +// weights: a family nobody chose is the one this build can defend. +const DefaultCrewSource = CrewSourceOpen + // crewModels is the whole table: one row per preset, one model per class. // // THE WORKER COLUMN IS THE DIAL. It climbs the open-weight pareto front one step @@ -97,6 +126,78 @@ var crewModels = map[string]map[string]string{ }, } +// crewAllModels is the same three presets answered from the whole catalog +// rather than its open-weight shelf, which is what the `all` family of +// [KeyCrewSource] draws from. The shape is [crewModels]'s and the laws are its +// laws: the worker column is still the dial, the careful column is still a +// DIFFERENT VENDOR from the worker in every preset, and the reflex and low +// columns still never vary. The ids are locked the way the open ones were, off +// the catalog's own published scores against blended price, and closed models +// live here and only here: the open table is the shipped default and stays +// byte-for-byte what it was. +var crewAllModels = map[string]map[string]string{ + CrewFrugal: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "google/gemini-3.8-flash", + ModelTierMastermind: "anthropic/claude-opus-5", + }, + CrewBalanced: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "anthropic/claude-opus-5", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + CrewMax: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "anthropic/claude-fable-5.1", + ModelTierHigh: "openai/gpt-6-astra", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, +} + +// crewTableFor is the family one source word names. It is total: `all` names +// the catalog-wide family, and every other reading (blank, misspelt, a word a +// later build retired) names the open one, because a family nobody asked for +// is the one this build can defend. +func crewTableFor(source string) map[string]map[string]string { + if strings.EqualFold(strings.TrimSpace(source), CrewSourceAll) { + return crewAllModels + } + return crewModels +} + +// CrewSourceAt is which family the preset words draw from on this profile, +// [DefaultCrewSource] when the row is absent. A word this build does not know +// reads as open, silently, the way a retired choice reads everywhere else on +// this sheet. +func CrewSourceAt(profileDir string) string { + if value, ok := persistedString(profileDir, KeyCrewSource); ok { + if strings.EqualFold(strings.TrimSpace(value), CrewSourceAll) { + return CrewSourceAll + } + } + return CrewSourceOpen +} + +// CrewModelsForSource is the five models one preset would set under one +// family, by tier word, false for a word that is not a preset. It returns a +// copy for [CrewModels]'s reason. +func CrewModelsForSource(source, preset string) (map[string]string, bool) { + row, ok := crewTableFor(source)[strings.ToLower(strings.TrimSpace(preset))] + if !ok { + return nil, false + } + out := make(map[string]string, len(row)) + for tier, model := range row { + out[tier] = model + } + return out, true +} + // crewLines is the one line each preset says about itself. It is what /crew // prints beside each option and what the settings chooser shows under it — the // same words in both places, because they are one sentence about one thing. @@ -111,18 +212,13 @@ func CrewLine(preset string) string { return crewLines[strings.ToLower(strings.TrimSpace(preset))] } -// CrewModels is the five models one preset would set, by tier word. It returns -// a copy, because a caller printing the table must not be able to edit it. +// CrewModels is the five models one preset would set in the OPEN family, by +// tier word: the family a profile nobody has touched reads, and the spelling +// every existing caller and page already holds. The family-aware spelling is +// [CrewModelsForSource]. It returns a copy, because a caller printing the +// table must not be able to edit it. func CrewModels(preset string) (map[string]string, bool) { - row, ok := crewModels[strings.ToLower(strings.TrimSpace(preset))] - if !ok { - return nil, false - } - out := make(map[string]string, len(row)) - for tier, model := range row { - out[tier] = model - } - return out, true + return CrewModelsForSource(CrewSourceOpen, preset) } // CrewAt is the crew as the five live tier values make it: the preset they are, @@ -134,13 +230,19 @@ func CrewModels(preset string) (map[string]string, bool) { // person cleared on purpose reads empty, matches no preset, and turns the answer // to custom, which is the truth: "one of these follows the conversation" is not // any of the three. +// +// The comparison runs against the family [CrewSourceAt] names, so the reading +// moves with the row and never behind it: flip the family and a crew the old +// family wrote matches nothing, which reads as custom and is true, because +// five open ids are not any all-family preset either. func CrewAt(profileDir string) string { live := make(map[string]string, len(ModelTiers)) for _, tier := range ModelTiers { live[tier] = TierModelAt(profileDir, tier) } + table := crewTableFor(CrewSourceAt(profileDir)) for _, preset := range CrewPresets { - if sameCrew(live, crewModels[preset]) { + if sameCrew(live, table[preset]) { return preset } } @@ -165,9 +267,13 @@ func sameCrew(live, preset map[string]string) bool { // old crew and two to the new, and the crew row would read "custom" about a // state nobody chose. It is also the only shape in which a reader that happens // to be resolving a role while somebody presses enter cannot see half a crew. +// +// The preset is resolved under the family [CrewSourceAt] names, so the row and +// the write cannot disagree about which table the word means: flip to `all`, +// press the crew again, and the five ids that land are the all-family ones. func ApplyCrew(profileDir, preset string) error { preset = strings.ToLower(strings.TrimSpace(preset)) - models, ok := crewModels[preset] + models, ok := CrewModelsForSource(CrewSourceAt(profileDir), preset) if !ok { return fmt.Errorf("pick one of: %s", strings.Join(CrewPresets, ", ")) } diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index b5637ce56b..6aa3b6c7c4 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -211,6 +211,174 @@ func TestTheCrewRowRefusesAWordThatIsNotAPreset(t *testing.T) { } } +// ── the family the three words draw from ──────────────────────────────────── + +// THE CAREFUL SEAT IS ALWAYS A SECOND VENDOR, IN EVERY PRESET OF EVERY FAMILY. +// A check from the same family as the work it checks is a check that shares the +// first family's blind spots, and the two tables are one law: a preset that +// moved to the all family and put the same vendor on worker and careful would +// break the property the open crew was built on while pretending to keep it. +func TestTheCarefulSeatIsAlwaysASecondVendorInEveryFamily(t *testing.T) { + vendor := func(id string) string { + if at := strings.Index(id, "/"); at > 0 { + return id[:at] + } + return id + } + for _, family := range []struct{ source, word string }{ + {CrewSourceOpen, CrewSourceOpen}, + {CrewSourceAll, CrewSourceAll}, + } { + for _, preset := range CrewPresets { + models, ok := CrewModelsForSource(family.source, preset) + if !ok { + t.Fatalf("there is no %s preset in the %s family", preset, family.word) + } + if vendor(models[ModelTierWorker]) == vendor(models[ModelTierHigh]) { + t.Errorf("%s under %s has %q working and %q checking, and a check from "+ + "the same vendor catches what that vendor lets through", + preset, family.word, models[ModelTierWorker], models[ModelTierHigh]) + } + } + } +} + +// THE ALL FAMILY IS LOCKED, not derived: the ids are the ones the decision +// settled on, and this is the page that says so. A wrong id here is a person's +// bill, and a silent rewrite of the locked table would be a decision made by a +// diff. +func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { + want := map[string]map[string]string{ + CrewFrugal: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "google/gemini-3.8-flash", + ModelTierMastermind: "anthropic/claude-opus-5", + }, + CrewBalanced: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "anthropic/claude-opus-5", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + CrewMax: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "anthropic/claude-fable-5.1", + ModelTierHigh: "openai/gpt-6-astra", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + } + for preset, expected := range want { + got, ok := CrewModelsForSource(CrewSourceAll, preset) + if !ok { + t.Fatalf("there is no %s preset in the all family", preset) + } + for _, tier := range ModelTiers { + if got[tier] != expected[tier] { + t.Errorf("%s under all sets %s to %q, want %q", preset, tier, got[tier], expected[tier]) + } + if !strings.Contains(got[tier], "/") { + t.Errorf("%s under all sets %s to %q, which is not a provider-qualified id", preset, tier, got[tier]) + } + if err := ValidateTierValue(got[tier]); err != nil { + t.Errorf("%s under all sets %s to %q, which its own row would refuse: %v", preset, tier, got[tier], err) + } + } + } +} + +// A SOURCE NOBODY SET IS THE OPEN FAMILY, BYTE FOR BYTE. The all table may +// exist, but a profile that never answered the row must resolve exactly what it +// resolved before the row was invented: the shipped defaults, the open presets +// under the same words, and a write that lands the open ids. This is the +// default-preservation law, and an unknown word is held to it too, silently, +// the way a retired choice reads everywhere else. +func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { + dir := t.TempDir() + if got := CrewSourceAt(dir); got != CrewSourceOpen { + t.Fatalf("an untouched profile reads the source as %q, want %q", got, CrewSourceOpen) + } + if got := CrewAt(dir); got != DefaultCrew { + t.Fatalf("an untouched profile reads the crew as %q, want %q", got, DefaultCrew) + } + if err := ApplyCrew(dir, CrewMax); err != nil { + t.Fatal(err) + } + want, _ := CrewModels(CrewMax) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != want[tier] { + t.Errorf("with no source set, max wrote %s to %q, want the open %q", tier, got, want[tier]) + } + } + // A WORD THIS BUILD DOES NOT KNOW READS AS OPEN, silently. Being told a + // preference from an older build is now an error is the one thing a + // retirement must never do, and a family is not a thing to guess either. + if err := writeProfileValue(dir, KeyCrewSource, "wide"); err != nil { + t.Fatal(err) + } + if got := CrewSourceAt(dir); got != CrewSourceOpen { + t.Fatalf("an unknown source word reads as %q, want %q", got, CrewSourceOpen) + } + if got := CrewAt(dir); got != CrewMax { + t.Fatalf("an unknown source word moved the crew to %q", got) + } +} + +// FLIPPING TO ALL MAKES THE SAME WORDS MEAN THE ALL FAMILY, and nothing else +// moves until the person picks the crew again. The five ids already written +// are theirs: a family flip is a meaning change, not a write, so the five rows +// in place read custom against the new family until one keystroke re-applies +// them. +func TestFlippingToAllMakesTheWordsMeanTheAllFamily(t *testing.T) { + dir := t.TempDir() + rows := registry(t, dir) + if err := mustRow(t, rows, KeyCrewSource).Apply(CrewSourceAll); err != nil { + t.Fatalf("setting the family to all: %v", err) + } + if got := CrewSourceAt(dir); got != CrewSourceAll { + t.Fatalf("the source row reads %q after all was set", got) + } + // The row is the one place the source is written: a stored tier id is not a + // family claim, and the family is not stored in the crew row either. + if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { + t.Fatalf("setting the crew to balanced: %v", err) + } + want, _ := CrewModelsForSource(CrewSourceAll, CrewBalanced) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != want[tier] { + t.Errorf("under all, balanced wrote %s to %q, want the all-family %q", tier, got, want[tier]) + } + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("the crew reads %q after balanced was applied under all", got) + } + + // And the same five ids are NOT balanced in the open family, which is the + // point of the toggle: the words changed family, and the reading follows. + if err := mustRow(t, registry(t, dir), KeyCrewSource).Apply(CrewSourceOpen); err != nil { + t.Fatal(err) + } + if got := CrewAt(dir); got != CrewCustom { + t.Fatalf("after flipping back to open, the all-family five read %q, want %q", got, CrewCustom) + } + // One keystroke heals it, and heals it open. + if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { + t.Fatal(err) + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("the crew reads %q after balanced was applied under open again", got) + } + open, _ := CrewModels(CrewBalanced) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != open[tier] { + t.Errorf("after re-applying under open, %s reads %q, want the open %q", tier, got, open[tier]) + } + } +} + // ── the level on a tier value ─────────────────────────────────────────────── // A LEVEL IS ACCEPTED AND ANY OTHER SUFFIX IS REFUSED IN WORDS. diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 8f1e3c9268..0b16875979 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -250,6 +250,12 @@ var settingReaders = map[string]string{ // rather than the writer, because a row that could be set and never read // would be exactly the dial-wired-to-nothing this table exists to catch. KeyCrew: "CrewAt", + // The source row is read wherever the crew word is read: /crew's picker, the + // panel's crew row and /status all read the word out of the crew derivation + // (internal/tui3's crew.go and onboarding.go), which resolves the live five + // against the family this row names, and the crew's writer takes its table + // from the same place. Naming the derivation names the whole path. + KeyCrewSource: "CrewAt", // The reflex row names the ROLE that reaches it rather than TierKey, which // its two neighbours share: internal/reflex resolves [roles.RoleReflex] and // nothing else lands on this key, so naming the shared spelling a third diff --git a/internal/config/settings.go b/internal/config/settings.go index 48255ce915..c00c332651 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -214,6 +214,19 @@ const ( // "balanced" over a hand-pinned tier would be lying in the one place a person // went to check. KeyCrew = "models.crew" + // KeyCrewSource is which family the crew words draw from: `open`, the + // open-weight table this build ships, or `all`, the same three words + // resolved over the whole catalog with closed and frontier models in it. + // IT IS A ROW RATHER THAN A SECOND VOCABULARY because the three words are + // the only thing anybody learns: the question a person arrives with is how + // much to spend, and which shelf the answer comes off is one more answer to + // the same question, not six new preset words. The row is read by the + // crew's own machinery (crew.go's [CrewSourceAt]) and never by a caller + // spelling the ids itself, so the family and the tables cannot disagree + // about what a preset means. It is PROFILE-ONLY with the tier rows, for the + // worker row's own reason: a repository that could answer it could send a + // visitor's work, and their credit, to a vendor they never chose. + KeyCrewSource = "models.crew.source" // KeyMouse is whether the surface reports the mouse at all. ON is the // default ([DefaultMouse]), because hover, click and the wheel are v3's own // language and the thing they cost is bought back by a key: an alt-screen @@ -2262,6 +2275,23 @@ func (s *Settings) build() []Setting { read: func() string { return CrewAt(dir) }, write: func(raw string) error { return writeCrew(dir, raw) }, }, + // THE FAMILY THE THREE WORDS DRAW FROM, one row under the crew. It sits + // beside the crew row because it is the same decision read one level up: + // the crew row says which five models, and this says which shelf those + // five come off. Open is the default and the law the shipped crew rests + // on; `all` is the opt-in that spends what the frontier costs. + Setting{ + Key: KeyCrewSource, Category: CategoryModels, Kind: SettingChoice, + Label: "model family", Choices: CrewSources, + Hint: "which family the crew words draw from. `open` is the default: every " + + "seat an open-weight model, so a crew nobody chose is never a bet on one " + + "vendor's pricing. `all` reads the same three words, frugal, balanced and " + + "max, off the whole catalog, closed and frontier models included, and " + + "costs what those models cost. Flip it and pick the crew again; the five " + + "rows already written keep their ids until you do.", + read: func() string { return CrewSourceAt(dir) }, + write: func(raw string) error { return writeChoice(dir, KeyCrewSource, raw, CrewSources) }, + }, Setting{ Key: KeyTierReflexModel, Category: CategoryModels, Kind: SettingText, Label: "reflex", EmptyLabel: "follows the conversation", diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0e30e82c6e..50e0ac4be5 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -345,6 +345,14 @@ router are deliberately not in any preset; you can still pin one on any row. You can also connect that vendor yourself as a direct service; the [services page](services.md) explains its names, limits and missing Phase 1 cost record. +**The `model family` row** (`models.crew.source`, just under the crew row) decides which family +the three preset words draw from. `open` is the default and is the table above: open weights only, +so a crew nobody chose is never a bet on one vendor's pricing. `all` reads the same three words, +`frugal`, `balanced` and `max`, off the whole catalog instead, closed and frontier models included, +and costs what those models cost. Flip the row and pick the crew again: the five rows already on +disk keep their ids until you do, and the crew word reads `custom` in the meantime when they +match no preset in the family you flipped to. + **Why these ids.** They were picked on 2026-09-01 off the catalog's own published scores — OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row — against blended price, open weights only. The worker seat is the dial: `glm-5.3-flash` From c1e4cb57fb62fb6e836e007b2df52015d2fb35a8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 17:34:32 -0400 Subject: [PATCH 02/22] docs: the change entry for the crew model family toggle Co-Authored-By: codeaf --- .../unreleased/1073-crew-model-family.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/1073-crew-model-family.md diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md new file mode 100644 index 0000000000..e45204423f --- /dev/null +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -0,0 +1,20 @@ +--- +kind: added +title: A model family toggle under the crew, so a preset can answer from open weights or from everything +pr: 1073 +surface: [chat, engine] +invalidates: + - "The three crew preset words (frugal, balanced, max) always resolved to open-weight models, and `crewModels` in `internal/config/crew.go` was the whole table of what they meant. There is now a second table `crewAllModels`, and `CrewModelsForSource(source, preset)` picks between them on the new `models.crew.source` setting (`open` default, `all` for closed and frontier). A profile that never answers the row still resolves the open table." + - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." + - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` until a preset is re-applied, which is the point of a meaning toggle rather than a write." +--- + +The crew shipped open-weight models for a reason: a default nobody's pricing can +move under them. That reason still holds for the default, so the toggle defaults to +`open` and changes nothing for anyone who does not ask. It is for the person who +has decided the frontier models are worth it on a given machine, and wants the same +three preset words to mean them. + +The two families are a table apiece, not a filter over one, because the picks are +argued seat by seat rather than chosen by a rule about vendors or price. The open +table is untouched; the all table is new and mirrors its three presets. From e63b4392768ee7d07ae1596396734a46d4e91c50 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 17:38:36 -0400 Subject: [PATCH 03/22] crew: round-1 review fixes DefaultCrewSource is now the value CrewSourceAt returns and its test expects, so the const is no longer dead exported API; the all-family comment and manual say plainly that the worker dial moves once and max's mastermind sits on the ceiling model on purpose; the vendor test drops its redundant struct and says vendor where it means vendor; the manual gains the all-family table. Co-Authored-By: codeaf --- internal/config/crew.go | 25 +++++++++++++++++-------- internal/config/crew_test.go | 19 ++++++++----------- internal/manual/chat/models-and-cost.md | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index 2674817ba5..94e9ea8ab6 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -128,13 +128,22 @@ var crewModels = map[string]map[string]string{ // crewAllModels is the same three presets answered from the whole catalog // rather than its open-weight shelf, which is what the `all` family of -// [KeyCrewSource] draws from. The shape is [crewModels]'s and the laws are its -// laws: the worker column is still the dial, the careful column is still a -// DIFFERENT VENDOR from the worker in every preset, and the reflex and low -// columns still never vary. The ids are locked the way the open ones were, off -// the catalog's own published scores against blended price, and closed models -// live here and only here: the open table is the shipped default and stays -// byte-for-byte what it was. +// [KeyCrewSource] draws from. The careful column is the same law here as +// there: a DIFFERENT VENDOR from the worker in every preset, and the reflex +// and low columns still never vary. The ids are locked the way the open ones +// were, off the catalog's own published scores against blended price, and +// closed models live here and only here: the open table is the shipped +// default and stays byte-for-byte what it was. +// +// TWO COLUMNS SPEND DIFFERENTLY HERE. The worker column is [crewModels]'s dial +// but moves once, not per preset: gpt-5.6-sol holds frugal and balanced, and +// only max buys claude-fable-5.1, because on the catalog the coding ceiling is +// the expensive step and the two steps below it are the same model. What +// frugal-to-balanced buys here is the careful seat (gemini-3.8-flash to +// claude-opus-5) and a smarter mastermind, not a dearer worker. And max's +// mastermind sits on the worker's own model on purpose: fable-5.1 is the +// catalog's ceiling, so there is no better planner to buy above it and the top +// seat adds generation behavior, not a bigger model. var crewAllModels = map[string]map[string]string{ CrewFrugal: { ModelTierReflex: "google/gemini-2.5-flash", @@ -180,7 +189,7 @@ func CrewSourceAt(profileDir string) string { return CrewSourceAll } } - return CrewSourceOpen + return DefaultCrewSource } // CrewModelsForSource is the five models one preset would set under one diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 6aa3b6c7c4..e1da2321f6 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -214,8 +214,8 @@ func TestTheCrewRowRefusesAWordThatIsNotAPreset(t *testing.T) { // ── the family the three words draw from ──────────────────────────────────── // THE CAREFUL SEAT IS ALWAYS A SECOND VENDOR, IN EVERY PRESET OF EVERY FAMILY. -// A check from the same family as the work it checks is a check that shares the -// first family's blind spots, and the two tables are one law: a preset that +// A check from the same vendor as the work it checks is a check that shares +// that vendor's blind spots, and the two tables are one law: a preset that // moved to the all family and put the same vendor on worker and careful would // break the property the open crew was built on while pretending to keep it. func TestTheCarefulSeatIsAlwaysASecondVendorInEveryFamily(t *testing.T) { @@ -225,19 +225,16 @@ func TestTheCarefulSeatIsAlwaysASecondVendorInEveryFamily(t *testing.T) { } return id } - for _, family := range []struct{ source, word string }{ - {CrewSourceOpen, CrewSourceOpen}, - {CrewSourceAll, CrewSourceAll}, - } { + for _, family := range CrewSources { for _, preset := range CrewPresets { - models, ok := CrewModelsForSource(family.source, preset) + models, ok := CrewModelsForSource(family, preset) if !ok { - t.Fatalf("there is no %s preset in the %s family", preset, family.word) + t.Fatalf("there is no %s preset in the %s family", preset, family) } if vendor(models[ModelTierWorker]) == vendor(models[ModelTierHigh]) { t.Errorf("%s under %s has %q working and %q checking, and a check from "+ "the same vendor catches what that vendor lets through", - preset, family.word, models[ModelTierWorker], models[ModelTierHigh]) + preset, family, models[ModelTierWorker], models[ModelTierHigh]) } } } @@ -298,8 +295,8 @@ func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { // the way a retired choice reads everywhere else. func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { dir := t.TempDir() - if got := CrewSourceAt(dir); got != CrewSourceOpen { - t.Fatalf("an untouched profile reads the source as %q, want %q", got, CrewSourceOpen) + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("an untouched profile reads the source as %q, want %q", got, DefaultCrewSource) } if got := CrewAt(dir); got != DefaultCrew { t.Fatalf("an untouched profile reads the crew as %q, want %q", got, DefaultCrew) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 50e0ac4be5..fe215a782e 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -353,6 +353,22 @@ and costs what those models cost. Flip the row and pick the crew again: the five disk keep their ids until you do, and the crew word reads `custom` in the meantime when they match no preset in the family you flipped to. +Under `all`, the same three words resolve to these: + +| class | frugal | balanced | max | +| --- | --- | --- | --- | +| reflex | `google/gemini-2.5-flash` | `google/gemini-2.5-flash` | `google/gemini-2.5-flash` | +| small work | `deepseek/deepseek-v4-flash-0731` | `deepseek/deepseek-v4-flash-0731` | `deepseek/deepseek-v4-flash-0731` | +| worker | `openai/gpt-5.6-sol` | `openai/gpt-5.6-sol` | `anthropic/claude-fable-5.1` | +| careful work | `google/gemini-3.8-flash` | `anthropic/claude-opus-5` | `openai/gpt-6-astra` | +| mastermind | `anthropic/claude-opus-5` | `anthropic/claude-fable-5.1` | `anthropic/claude-fable-5.1` | + +Two of the columns spend differently here. The worker is the same model in `frugal` and +`balanced`, and only `max` buys the frontier coder, because on the catalog the coding ceiling is +the one expensive step. What `frugal` to `balanced` buys instead is the careful seat and a +smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the +catalog's ceiling, so the top seat adds generation behavior rather than a bigger model. + **Why these ids.** They were picked on 2026-09-01 off the catalog's own published scores — OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row — against blended price, open weights only. The worker seat is the dial: `glm-5.3-flash` From 304a5a2233ca60d9e08fd1605d6f226a31cc879c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 17:41:39 -0400 Subject: [PATCH 04/22] crew: round-2 review fix The open table's rationale now says 'Why the open ids', so its referent does not dangle after the all-family table was slotted above it. Co-Authored-By: codeaf --- internal/manual/chat/models-and-cost.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index fe215a782e..bb7ea570d9 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -369,7 +369,7 @@ the one expensive step. What `frugal` to `balanced` buys instead is the careful smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the catalog's ceiling, so the top seat adds generation behavior rather than a bigger model. -**Why these ids.** They were picked on 2026-09-01 off the catalog's own published scores — +**Why the open ids.** They were picked on 2026-09-01 off the catalog's own published scores — OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row — against blended price, open weights only. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, From 65477c0c5473fcd1c677ab0d688df83f941242af Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:01:00 -0400 Subject: [PATCH 05/22] task: Add open/all selector to /crew TUI picker Co-Authored-By: codeaf --- internal/tui3/crew.go | 251 ++++++++++++++++++++++++++++++++-- internal/tui3/crew_test.go | 172 ++++++++++++++++++++++- internal/tui3/payload_test.go | 2 +- internal/tui3/render.go | 2 +- 4 files changed, 412 insertions(+), 15 deletions(-) diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 4247b3f1f6..613ceff5eb 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -1,6 +1,12 @@ package tui3 import ( + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" "strings" "charm.land/bubbletea/v2" @@ -24,9 +30,11 @@ import ( // class models underneath — and the seat above them is the one thing on the // list no key can move ([crewPicker.rows] says why it is there anyway). // -// EVERY WRITE GOES THROUGH [config.ApplyCrew], the same function the panel's row -// writes through. A second writer here is how a command and a panel end up -// disagreeing about which crew is on. +// EVERY CREW WRITE GOES THROUGH [config.ApplyCrew], the same function the +// panel's row writes through, and the one row that does not go through it, the +// family ([crewSourceKey]), reaches the disk through the single interim bridge +// at the foot of the family section. A second writer anywhere else is how a +// command and a panel end up disagreeing about which crew is on. // runCrew is /crew: the three presets with the current one marked, or one applied. func (a *app) runCrew(arg string) { @@ -37,7 +45,7 @@ func (a *app) runCrew(arg string) { arg = strings.ToLower(strings.TrimSpace(arg)) if arg == "" { a.closeLists() - a.crewPick.start(config.CrewAt(a.profileDir), a.crewInheritedLine()) + a.crewPick.start(config.CrewAt(a.profileDir), crewSourceAt(a.profileDir), a.crewInheritedLine()) a.touch() return } @@ -95,6 +103,22 @@ func (a *app) applyCrew(preset string) { a.noteFacts(config.CrewSummary(a.profileDir)+" · "+a.crewUnchangedClause(), facts...) } +// applyCrewUnder is the chooser's enter: the family and the preset are ONE +// decision, and the family row is written FIRST. The resolution that turns a +// family and a preset into five ids reads the row (the config half of this +// wave owns it), so a picker that applied the preset before writing the +// family would hand the resolver yesterday's row on the one enter that +// changed it. A failed family write refuses the preset with it: half of one +// decision applied is the family saying all while the five rows say open, and +// the chooser is where the two are promised as one. +func (a *app) applyCrewUnder(source, preset string) { + if err := writeCrewSource(a.profileDir, source); err != nil { + a.note("could not set the crew's source · " + err.Error()) + return + } + a.applyCrew(preset) +} + // crewUnchangedClause is the tail of every crew confirmation: the model the // conversation is still on, and the one command that moves it. // @@ -377,6 +401,157 @@ func (a *app) sayWorkSeat() { } } +// ── the family row: which pool the presets draw from ────────────────────────── +// +// models.crew.source says whether the three presets pick their five ids from +// open models or from all models. THE CONFIG PACKAGE OWNS THE ROW: the reader, +// the writer and the two tables that answer it are the config half of this +// wave, landing in parallel with this one. Until they land the picker is the +// only surface that moves the row, and it reaches the disk through the two +// functions at the foot of this section, so that when config takes the row +// over there is exactly one block here to delete. +// +// THE BRIDGE IS DOOR-ONLY, and the frame-disk law is why (framedisk_law_test.go +// is the gate): every config reader the frame reaches sits behind config's own +// memo in another package, and this package's interim bridge reads raw, so a read on +// the frame clock would be a law violation rather than a cache question. The +// chooser reads the row at its open ([app.runCrew]) and nowhere else, which is +// why the status segment does not name the family yet; when the config half +// lands its memoised reader, the segment can take the family through it and +// the bridge here dies with the literal. +const ( + // crewSourceOpen is the shipped family: every model in every preset is + // an open-weight one, which is the property that makes a shipped default + // defensible (internal/config's crew.go says why). + crewSourceOpen = "open" + // crewSourceAll is the wider pool: every model the router knows, priced + // as it goes. + crewSourceAll = "all" + // crewSourceKey is the row the family selector writes. + // TODO(config.KeyCrewSource): the config half of this wave adds the + // constant; spell the row through it and delete this literal. + crewSourceKey = "models.crew.source" + // crewSourceLead is the family row's label. It is the key's own word + // because the two options beside it are the row's answer and the row is + // one line. + crewSourceLead = "source" +) + +// crewSources is the selector's two options in the order ←→ walks them, open +// first because it is the default and the walk starts there. +var crewSources = []string{crewSourceOpen, crewSourceAll} + +// crewSourceAt is the family as the profile holds it, or open when the row is +// absent, unreadable or a word this build does not know, which is every +// reader in internal/config's own answer to a row nobody wrote. IT IS READ +// AT THE DOOR AND NOT AT THE DRAW, for [crewPicker.inherited]'s reason +// exactly: the rows are drawn every frame and the answer is one small file. +func crewSourceAt(profileDir string) string { + data, err := os.ReadFile(config.BudgetConfigPath(profileDir)) + if err != nil { + return crewSourceOpen + } + values := make(map[string]json.RawMessage) + if json.Unmarshal(data, &values) != nil { + return crewSourceOpen + } + var source string + // A MISSING ROW UNMARSHALS AS AN ERROR, and so does one holding anything + // but a string, which is the whole point: both are a row this chooser + // cannot act on and both read as the default. + if json.Unmarshal(values[crewSourceKey], &source) != nil { + return crewSourceOpen + } + if source == crewSourceAll { + return crewSourceAll + } + return crewSourceOpen +} + +// writeCrewSource lands the family row in config's own write's shape and for +// its own reasons: the whole file is read, this one row is replaced, and the +// result lands through a temporary file and a rename, so unrelated rows +// survive byte for byte and a reader that arrives mid-write sees the old row +// or the new one, never half of either. THE SHAPE IS COPIED FROM THE WRITER IT +// STANDS IN FOR (internal/config's writeProfileValues) rather than invented +// here; when the config half takes the row over, this function is the one +// thing to delete. +// +// THE WRITE DOES NOT BUMP [config.SettingsGeneration], because that counter +// belongs to the writer it stands in for, and it does not need to: the family +// row is read at doors and never on the frame clock (the paragraph above), and +// every reader in internal/config re-stats the file, so the row is seen +// without the counter's help. +func writeCrewSource(profileDir, source string) error { + if source != crewSourceOpen && source != crewSourceAll { + return fmt.Errorf("pick one of: %s, %s", crewSourceOpen, crewSourceAll) + } + path := config.BudgetConfigPath(profileDir) + data, readErr := os.ReadFile(path) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return fmt.Errorf("write config %s: preserve existing file: %w", crewSourceKey, readErr) + } + values := make(map[string]json.RawMessage) + if readErr == nil { + held := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &held); err != nil { + return fmt.Errorf("write config %s: preserve existing file: %w", crewSourceKey, err) + } + maps.Copy(values, held) + } + encoded, err := json.Marshal(source) + if err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + values[crewSourceKey] = encoded + whole, err := json.MarshalIndent(values, "", " ") + if err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".config-*.json") + if err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + temporaryPath := temporary.Name() + removeTemporary := true + defer func() { + if removeTemporary { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + if _, err := temporary.Write(append(whole, '\n')); err != nil { + _ = temporary.Close() + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("write config %s: %w", crewSourceKey, err) + } + removeTemporary = false + return nil +} + +// crewPresetModels is the five ids a preset would write under one family. The +// config half of this wave owns the two tables and the resolution that turns +// family and preset into ids; until it lands, both families draw from the one +// table that exists, and the picker hands the STAGED family in here so the +// rows it draws are already asking the question the config half will answer. +// TODO(config.KeyCrewSource): hand the family to the source-aware lookup when +// the config half lands it, so a person sees the family's effect on the rows +// before enter commits to it. +func crewPresetModels(source, preset string) (map[string]string, bool) { + return config.CrewModels(preset) +} + // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its // zero value is closed, like [picker], and its cursor is an index into // [config.CrewPresets]. @@ -384,6 +559,13 @@ type crewPicker struct { open bool cursor int current string + // source is the family the presets below draw from: open models or all + // models. IT IS STAGED AND NOT WRITTEN: ←→ moves the word, and enter + // writes it beside the preset as one decision ([app.applyCrewUnder]), + // which is the shape the person asked for, flip the family, watch the + // preset rows take it, then commit. esc leaves the row exactly as it + // was. + source string // inherited is the line naming the seat this profile has no row for, or // empty when every row was written. IT IS READ AT THE DOOR AND NOT AT THE // DRAW: the rows are built every frame and the answer is three lines of a @@ -392,8 +574,17 @@ type crewPicker struct { inherited string } -func (p *crewPicker) start(current, inherited string) { - *p = crewPicker{open: true, current: current, inherited: inherited} +func (p *crewPicker) start(current, source, inherited string) { + *p = crewPicker{open: true, current: current, inherited: inherited, source: crewSourceOpen} + // A WORD THIS BUILD DOES NOT KNOW READS AS THE DEFAULT, for the same + // reason every reader in internal/config folds an unreadable row back to + // its shipped value: the chooser is where a person answers a question, + // not where a stray word off a hand-edited file gets a third option. + for _, known := range crewSources { + if source == known { + p.source = source + } + } for i, preset := range config.CrewPresets { if preset == current { p.cursor = i @@ -408,6 +599,19 @@ func (p *crewPicker) move(delta int) { p.cursor = (p.cursor + delta + len(config.CrewPresets)) % len(config.CrewPresets) } +// moveSource walks the two families, ←→ in [crewPickerKey]. It wraps rather +// than clamps for [move]'s own reason: with two options a clamp would leave +// one of the two keys a dead key on the word it started at. +func (p *crewPicker) moveSource(delta int) { + at := 0 + for i, source := range crewSources { + if source == p.source { + at = i + } + } + p.source = crewSources[(at+delta+len(crewSources))%len(crewSources)] +} + // The chooser's fixed lines around the three presets, spelled once so the // height and the rows cannot count them differently. const ( @@ -437,8 +641,8 @@ const ( crewInheritedMid = " seat is inherited from " crewInheritedTail = " — picking one writes it" // crewFrameRows is how many of the chooser's rows are not preset rows: the - // scope line, seat one, and the closing note. - crewFrameRows = 3 + // scope line, seat one, the family selector and the closing note. + crewFrameRows = 4 ) func (p *crewPicker) height() int { @@ -489,8 +693,30 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // so the chooser's height stays what [crewPicker.height] promised. out = append(out, "") } + // THE FAMILY IS A HEADER THE ←→ KEYS WALK, not a fourth row on the ↑↓ + // list: the presets are one axis and the family is a second one steering + // them, and a list that made ↑↓ pass through the family on the way from + // frugal to max would be moving an axis the person never aimed at. So the + // row sits directly above the presets it steers, it answers to ←→, and the + // two options are drawn beside each other so the move it offers is visible + // before it is taken. + // + // THE WORD THE NEXT ENTER WRITES IS THE WORD LIFTED, and the lifted step is + // the accent for the cursor's own reason: it is the fact this row answers + // and the only fact on it. The row takes no lead and no ground for seat + // one's reason, said above: nothing on this list applies it with enter, + // and a mark that said otherwise would be a promise no key keeps. + words := make([]string, 0, len(crewSources)) + for _, source := range crewSources { + if source == p.source { + words = append(words, pal.accent(source+" models")) + continue + } + words = append(words, pal.dim(source+" models")) + } + out = append(out, fit(" "+pal.dim(crewSourceLead+" ‹ ")+strings.Join(words, pal.dim(" · "))+pal.dim(" ›"), width)) for i, preset := range config.CrewPresets { - models, _ := config.CrewModels(preset) + models, _ := crewPresetModels(p.source, preset) parts := make([]string, 0, len(roles.Tiers)) for _, tier := range roles.Tiers { parts = append(parts, strings.TrimSpace(a.crewClassWord(tier))+" "+models[string(tier)]) @@ -579,12 +805,17 @@ func (a *app) crewPickerKey(msg tea.KeyPressMsg) { a.crewPick.close() case "enter": preset := config.CrewPresets[a.crewPick.cursor] + source := a.crewPick.source a.crewPick.close() - a.applyCrew(preset) + a.applyCrewUnder(source, preset) case "up", "ctrl+p": a.crewPick.move(-1) case "down", "ctrl+n": a.crewPick.move(1) + case "left": + a.crewPick.moveSource(-1) + case "right": + a.crewPick.moveSource(1) } a.touch() } diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index f0a223974b..e3213f0244 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -1,6 +1,8 @@ package tui3 import ( + "encoding/json" + "os" "strings" "testing" @@ -296,6 +298,169 @@ func TestTheTierWordsAndTheClassKeysAreTheSameSet(t *testing.T) { } } +// ── THE FAMILY ROW: WHICH POOL THE PRESETS DRAW FROM ───────────────────── +// +// models.crew.source is one row, and the chooser is where it moves: a header +// the ←→ keys walk, above the presets it steers, staged until enter commits it +// beside the preset as one decision. + +// THE FAMILY IS SAID ABOVE THE PRESETS, with the keys that move it named in +// the hint under the ladder: a header row that takes ←→ while the presets +// below keep ↑↓, so the two axes stay two axes. +func TestTheCrewChooserNamesTheTwoFamiliesAboveThePresets(t *testing.T) { + a, _ := sheetApp(t) + a.width = 240 + a.slash("/crew") + + rows := a.overlayRows(a.width, a.overlayHeight()) + text := plain(strings.Join(rows, "\n")) + // The two families are one row, third on the sheet, sitting directly above + // the first preset after the scope line and seat one. + family := plain(rows[2]) + if !strings.Contains(family, "open models") || !strings.Contains(family, "all models") { + t.Fatalf("the two families are not one row above the presets:\n%s", text) + } + for i, preset := range config.CrewPresets { + if row := plain(rows[3+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { + t.Errorf("row %d is not %s's:\n%q", 3+2*i, preset, row) + } + } + // THE SHIPPED FAMILY IS THE WORD LIFTED, and it is the only one: the word + // the next enter writes is the fact the row answers, and a second lifted + // word would be a second answer on a row with one question. + if !strings.Contains(rows[2], a.pal.accent("open models")) { + t.Fatalf("the open family is not the word in force:\n%q", rows[2]) + } + if strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("both families are lifted at once:\n%q", rows[2]) + } + // AND ←→ MOVES THE WORD while the preset cursor keeps its place and its + // mark: the family is a second axis, not a row the ↑↓ list gained. + a.crewPickerKey(key("left")) + rows = a.overlayRows(a.width, a.overlayHeight()) + if !strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("← did not walk the family to all:\n%q", rows[2]) + } + if a.crewPick.cursor != 1 { + t.Fatalf("walking the family moved the preset cursor to row %d", a.crewPick.cursor) + } + if !strings.Contains(strings.Join(rows, "\n"), a.pal.accent("› ")) { + t.Fatalf("the preset cursor lost its mark to the family row:\n%s", plain(strings.Join(rows, "\n"))) + } +} + +// THE FAMILY IS READ FROM THE PROFILE, and the chooser opens on the word the +// person last committed to, not on the default. +func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { + a, dir := sheetApp(t) + seed := map[string]any{crewSourceKey: crewSourceAll} + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.BudgetConfigPath(dir), encoded, 0o600); err != nil { + t.Fatal(err) + } + + a.slash("/crew") + rows := a.overlayRows(a.width, a.overlayHeight()) + if !strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("the chooser opened on a family other than the persisted one:\n%q", rows[2]) + } + // THE SEGMENT DOES NOT CARRY THE FAMILY, and the frame-disk law is why + // (the family section in crew.go says it once): the chooser is the one + // surface that reads the row until config lands its memoised reader, so the + // segment keeps its exact old shape over an all profile, which is the + // non-regression the HUD was promised. + if got := a.crewSegment(); got != "crew "+config.CrewBalanced { + t.Fatalf("the crew segment reads %q over an all profile", got) + } + // AND ESC LEAVES THE ROW ALONE: walking a family and walking away writes + // nothing, for the esc test's own reason. + a.crewPickerKey(key("right")) + a.crewPickerKey(key("esc")) + if got := crewSourceAt(dir); got != crewSourceAll { + t.Fatalf("esc wrote the family to %q", got) + } +} + +// CHOOSING A FAMILY AND A PRESET WRITES BOTH ROWS, as one decision: the family +// first, because the resolution that turns family and preset into five ids +// reads the row, and the preset under it. +func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { + a, dir := sheetApp(t) + // A NEIGHBOUR THE WRITE MUST NOT TOUCH, because the family row lands + // through the same whole-file read-and-replace config's own writer uses. + seed := map[string]any{"test.neighbour": true} + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.BudgetConfigPath(dir), encoded, 0o600); err != nil { + t.Fatal(err) + } + + a.slash("/crew") + a.crewPickerKey(key("left")) + a.crewPickerKey(key("enter")) + + if a.crewPick.open { + t.Fatal("enter left the chooser open") + } + if got := crewSourceAt(dir); got != crewSourceAll { + t.Fatalf("enter wrote the family %q, want all", got) + } + if got := config.CrewAt(dir); got != config.CrewBalanced { + t.Fatalf("enter applied %q, want balanced", got) + } + // The neighbour survived both writes, the family's and the preset's. + data, err := os.ReadFile(config.BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + held := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &held); err != nil { + t.Fatal(err) + } + if string(held["test.neighbour"]) != "true" { + t.Fatalf("the family write lost its neighbour: %s", data) + } + // AND THE SEGMENT KEEPS ITS SHAPE: the family is the chooser's until the + // config half lands the memoised reader the segment can read through + // (the family section in crew.go says it once). + if got := a.crewSegment(); got != "crew "+config.CrewBalanced { + t.Fatalf("the crew segment reads %q after the write", got) + } +} + +// A ROW NOBODY WROTE READS AS THE SHIPPED FAMILY, and so does a word this build +// does not know: the chooser offers two options and a stray string off the disk +// is not a third. +func TestTheCrewSourceRowDefaultsToOpen(t *testing.T) { + dir := t.TempDir() + if got := crewSourceAt(dir); got != crewSourceOpen { + t.Fatalf("a profile with no row read %q", got) + } + for _, row := range []string{ + `{"models.crew.source": true}`, + `{"models.crew.source": "everyone"}`, + `{`, + } { + if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(row), 0o600); err != nil { + t.Fatal(err) + } + if got := crewSourceAt(dir); got != crewSourceOpen { + t.Fatalf("the row %s read %q, want open", row, got) + } + } + if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(`{"models.crew.source": "all"}`), 0o600); err != nil { + t.Fatal(err) + } + if got := crewSourceAt(dir); got != crewSourceAll { + t.Fatalf("the all row read %q", got) + } +} + // ── THE CREW IS READABLE WHERE PEOPLE GO TO CHECK ─────────────────────────── // // /crew writes four class models and the session picks them up on its next @@ -515,10 +680,11 @@ func TestBareCrewReadsTheFiveSeats(t *testing.T) { if a.crewPick.cursor != 1 { t.Fatalf("the cursor opened on row %d, want balanced at 1", a.crewPick.cursor) } - // The presets follow, in their own order, after the two reading lines. + // The presets follow, in their own order, after the three reading lines: the + // scope line, seat one and the family selector. for i, preset := range config.CrewPresets { - if row := plain(rows[2+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { - t.Errorf("row %d is not %s's:\n%q", 2+2*i, preset, row) + if row := plain(rows[3+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { + t.Errorf("row %d is not %s's:\n%q", 3+2*i, preset, row) } } // And enter still applies the preset under the cursor, seat one untouched. diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index e61ce5da8e..299c250906 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -250,7 +250,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { {pickerKeysModelTab, []string{"tab", "enter", "esc"}}, {pickerKeysFold, []string{"enter", "←", "esc"}}, {pickerKeysFoldTab, []string{"enter", "tab", "esc"}}, - {"↑↓ · enter apply · esc", []string{"↑↓", "enter", "esc"}}, + {"↑↓ · ←→ source · enter apply · esc", []string{"↑↓", "←→", "enter", "esc"}}, {"enter open · esc", []string{"enter", "esc"}}, {"v select · a block · y yank · esc", []string{"v", "a", "y", "esc"}}, {"esc again to rewind", []string{"esc"}}, diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 78762ae34e..d7dc8d6a3d 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3970,7 +3970,7 @@ func (a *app) hintWord() string { } return keys case a.crewPick.open: - return "↑↓ · enter apply · esc" + return "↑↓ · ←→ source · enter apply · esc" case a.effPick.open: // The chord is named beside the keys because this list is the only place // on the surface that can teach it: the chip it opens from prints a mark From ba9c12d61b7986d067b72208914400a332ab7064 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:03:53 -0400 Subject: [PATCH 06/22] crew: reconcile the picker onto config's source helpers The TUI picker landed while the config half was still in flight, so it read and wrote models.crew.source itself through a bridge. With the config half merged, that bridge is deleted: the picker reads config.CrewSourceAt, writes config.SetCrewSource (new, the row's own writer beside ApplyCrew), and draws the preset rows through config.CrewModelsForSource. The derivation-gate entry now names CrewSourceAt, which is the row's actual reader and is reached from tui3. Co-Authored-By: codeaf --- internal/config/crew.go | 14 +++ internal/config/derivation_test.go | 12 +- internal/tui3/crew.go | 182 ++++------------------------- internal/tui3/crew_test.go | 12 +- 4 files changed, 51 insertions(+), 169 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index 94e9ea8ab6..b687430fc0 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -299,6 +299,20 @@ func writeCrew(profileDir, raw string) error { return ApplyCrew(profileDir, raw) } +// SetCrewSource writes the family row alone, in one file write, for the chooser +// that promises the family and the preset as ONE decision: it lands this row +// first and then applies the preset, so the resolution that turns the two into +// five ids reads the family this same enter chose and not yesterday's. The word +// is refused the way every choice row refuses one, so a typo cannot land a +// family nothing reads. +func SetCrewSource(profileDir, source string) error { + source = strings.ToLower(strings.TrimSpace(source)) + if source != CrewSourceOpen && source != CrewSourceAll { + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + } + return writeProfileValue(profileDir, KeyCrewSource, source) +} + // CrewSummary is the one line a crew change confirms itself with: // // crew → balanced · brain glm-5.3 · hands glm-5.3-flash · checks qwen3.8-27b diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 0b16875979..b7b679afda 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -250,12 +250,12 @@ var settingReaders = map[string]string{ // rather than the writer, because a row that could be set and never read // would be exactly the dial-wired-to-nothing this table exists to catch. KeyCrew: "CrewAt", - // The source row is read wherever the crew word is read: /crew's picker, the - // panel's crew row and /status all read the word out of the crew derivation - // (internal/tui3's crew.go and onboarding.go), which resolves the live five - // against the family this row names, and the crew's writer takes its table - // from the same place. Naming the derivation names the whole path. - KeyCrewSource: "CrewAt", + // The source row's own reader is CrewSourceAt, and the picker reaches it by + // that name (internal/tui3's crew.go), so the row names its reader rather + // than the crew derivation one hop away. The crew's writer resolves through + // the same reader, so the row and the five ids it produces cannot disagree + // about which family the preset words mean. + KeyCrewSource: "CrewSourceAt", // The reflex row names the ROLE that reaches it rather than TierKey, which // its two neighbours share: internal/reflex resolves [roles.RoleReflex] and // nothing else lands on this key, so naming the shared spelling a third diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 613ceff5eb..2b7168ea8b 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -1,12 +1,6 @@ package tui3 import ( - "encoding/json" - "errors" - "fmt" - "maps" - "os" - "path/filepath" "strings" "charm.land/bubbletea/v2" @@ -31,10 +25,10 @@ import ( // list no key can move ([crewPicker.rows] says why it is there anyway). // // EVERY CREW WRITE GOES THROUGH [config.ApplyCrew], the same function the -// panel's row writes through, and the one row that does not go through it, the -// family ([crewSourceKey]), reaches the disk through the single interim bridge -// at the foot of the family section. A second writer anywhere else is how a -// command and a panel end up disagreeing about which crew is on. +// panel's row writes through, and the one row that does not — the family, the +// pool the presets draw from — goes through [config.SetCrewSource] beside the +// five. A second writer anywhere else is how a command and a panel end up +// disagreeing about which crew is on. // runCrew is /crew: the three presets with the current one marked, or one applied. func (a *app) runCrew(arg string) { @@ -45,7 +39,7 @@ func (a *app) runCrew(arg string) { arg = strings.ToLower(strings.TrimSpace(arg)) if arg == "" { a.closeLists() - a.crewPick.start(config.CrewAt(a.profileDir), crewSourceAt(a.profileDir), a.crewInheritedLine()) + a.crewPick.start(config.CrewAt(a.profileDir), config.CrewSourceAt(a.profileDir), a.crewInheritedLine()) a.touch() return } @@ -112,7 +106,7 @@ func (a *app) applyCrew(preset string) { // decision applied is the family saying all while the five rows say open, and // the chooser is where the two are promised as one. func (a *app) applyCrewUnder(source, preset string) { - if err := writeCrewSource(a.profileDir, source); err != nil { + if err := config.SetCrewSource(a.profileDir, source); err != nil { a.note("could not set the crew's source · " + err.Error()) return } @@ -404,153 +398,27 @@ func (a *app) sayWorkSeat() { // ── the family row: which pool the presets draw from ────────────────────────── // // models.crew.source says whether the three presets pick their five ids from -// open models or from all models. THE CONFIG PACKAGE OWNS THE ROW: the reader, -// the writer and the two tables that answer it are the config half of this -// wave, landing in parallel with this one. Until they land the picker is the -// only surface that moves the row, and it reaches the disk through the two -// functions at the foot of this section, so that when config takes the row -// over there is exactly one block here to delete. +// open models or from all models. THE CONFIG PACKAGE OWNS THE ROW: +// [config.CrewSourceAt] reads it, [config.SetCrewSource] writes it, and the two +// tables behind them answer it. This package draws the choice and commits it. // -// THE BRIDGE IS DOOR-ONLY, and the frame-disk law is why (framedisk_law_test.go -// is the gate): every config reader the frame reaches sits behind config's own -// memo in another package, and this package's interim bridge reads raw, so a read on -// the frame clock would be a law violation rather than a cache question. The -// chooser reads the row at its open ([app.runCrew]) and nowhere else, which is -// why the status segment does not name the family yet; when the config half -// lands its memoised reader, the segment can take the family through it and -// the bridge here dies with the literal. +// THE ROW IS DOOR-ONLY, and the frame-disk law is why (framedisk_law_test.go is +// the gate): the chooser reads it once at its open ([app.runCrew]) and enter +// writes it once, and neither happens on the frame clock, which is why the +// status segment does not name the family. const ( - // crewSourceOpen is the shipped family: every model in every preset is - // an open-weight one, which is the property that makes a shipped default - // defensible (internal/config's crew.go says why). - crewSourceOpen = "open" - // crewSourceAll is the wider pool: every model the router knows, priced - // as it goes. - crewSourceAll = "all" - // crewSourceKey is the row the family selector writes. - // TODO(config.KeyCrewSource): the config half of this wave adds the - // constant; spell the row through it and delete this literal. - crewSourceKey = "models.crew.source" // crewSourceLead is the family row's label. It is the key's own word // because the two options beside it are the row's answer and the row is // one line. crewSourceLead = "source" ) -// crewSources is the selector's two options in the order ←→ walks them, open -// first because it is the default and the walk starts there. -var crewSources = []string{crewSourceOpen, crewSourceAll} - -// crewSourceAt is the family as the profile holds it, or open when the row is -// absent, unreadable or a word this build does not know, which is every -// reader in internal/config's own answer to a row nobody wrote. IT IS READ -// AT THE DOOR AND NOT AT THE DRAW, for [crewPicker.inherited]'s reason -// exactly: the rows are drawn every frame and the answer is one small file. -func crewSourceAt(profileDir string) string { - data, err := os.ReadFile(config.BudgetConfigPath(profileDir)) - if err != nil { - return crewSourceOpen - } - values := make(map[string]json.RawMessage) - if json.Unmarshal(data, &values) != nil { - return crewSourceOpen - } - var source string - // A MISSING ROW UNMARSHALS AS AN ERROR, and so does one holding anything - // but a string, which is the whole point: both are a row this chooser - // cannot act on and both read as the default. - if json.Unmarshal(values[crewSourceKey], &source) != nil { - return crewSourceOpen - } - if source == crewSourceAll { - return crewSourceAll - } - return crewSourceOpen -} - -// writeCrewSource lands the family row in config's own write's shape and for -// its own reasons: the whole file is read, this one row is replaced, and the -// result lands through a temporary file and a rename, so unrelated rows -// survive byte for byte and a reader that arrives mid-write sees the old row -// or the new one, never half of either. THE SHAPE IS COPIED FROM THE WRITER IT -// STANDS IN FOR (internal/config's writeProfileValues) rather than invented -// here; when the config half takes the row over, this function is the one -// thing to delete. -// -// THE WRITE DOES NOT BUMP [config.SettingsGeneration], because that counter -// belongs to the writer it stands in for, and it does not need to: the family -// row is read at doors and never on the frame clock (the paragraph above), and -// every reader in internal/config re-stats the file, so the row is seen -// without the counter's help. -func writeCrewSource(profileDir, source string) error { - if source != crewSourceOpen && source != crewSourceAll { - return fmt.Errorf("pick one of: %s, %s", crewSourceOpen, crewSourceAll) - } - path := config.BudgetConfigPath(profileDir) - data, readErr := os.ReadFile(path) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return fmt.Errorf("write config %s: preserve existing file: %w", crewSourceKey, readErr) - } - values := make(map[string]json.RawMessage) - if readErr == nil { - held := make(map[string]json.RawMessage) - if err := json.Unmarshal(data, &held); err != nil { - return fmt.Errorf("write config %s: preserve existing file: %w", crewSourceKey, err) - } - maps.Copy(values, held) - } - encoded, err := json.Marshal(source) - if err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - values[crewSourceKey] = encoded - whole, err := json.MarshalIndent(values, "", " ") - if err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - temporary, err := os.CreateTemp(filepath.Dir(path), ".config-*.json") - if err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - temporaryPath := temporary.Name() - removeTemporary := true - defer func() { - if removeTemporary { - _ = os.Remove(temporaryPath) - } - }() - if err := temporary.Chmod(0o600); err != nil { - _ = temporary.Close() - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - if _, err := temporary.Write(append(whole, '\n')); err != nil { - _ = temporary.Close() - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - if err := temporary.Close(); err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - if err := os.Rename(temporaryPath, path); err != nil { - return fmt.Errorf("write config %s: %w", crewSourceKey, err) - } - removeTemporary = false - return nil -} +// crewSourceAt and writeCrewSource lived here as an interim bridge while the +// config half of the wave was in flight. They are gone: the row is read through +// [config.CrewSourceAt] and written through [config.SetCrewSource]. -// crewPresetModels is the five ids a preset would write under one family. The -// config half of this wave owns the two tables and the resolution that turns -// family and preset into ids; until it lands, both families draw from the one -// table that exists, and the picker hands the STAGED family in here so the -// rows it draws are already asking the question the config half will answer. -// TODO(config.KeyCrewSource): hand the family to the source-aware lookup when -// the config half lands it, so a person sees the family's effect on the rows -// before enter commits to it. -func crewPresetModels(source, preset string) (map[string]string, bool) { - return config.CrewModels(preset) -} +// crewPresetModels lived here as the swap point while the config half was in +// flight; the rows now draw through [config.CrewModelsForSource]. // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its // zero value is closed, like [picker], and its cursor is an index into @@ -575,12 +443,12 @@ type crewPicker struct { } func (p *crewPicker) start(current, source, inherited string) { - *p = crewPicker{open: true, current: current, inherited: inherited, source: crewSourceOpen} + *p = crewPicker{open: true, current: current, inherited: inherited, source: config.CrewSourceOpen} // A WORD THIS BUILD DOES NOT KNOW READS AS THE DEFAULT, for the same // reason every reader in internal/config folds an unreadable row back to // its shipped value: the chooser is where a person answers a question, // not where a stray word off a hand-edited file gets a third option. - for _, known := range crewSources { + for _, known := range config.CrewSources { if source == known { p.source = source } @@ -604,12 +472,12 @@ func (p *crewPicker) move(delta int) { // one of the two keys a dead key on the word it started at. func (p *crewPicker) moveSource(delta int) { at := 0 - for i, source := range crewSources { + for i, source := range config.CrewSources { if source == p.source { at = i } } - p.source = crewSources[(at+delta+len(crewSources))%len(crewSources)] + p.source = config.CrewSources[(at+delta+len(config.CrewSources))%len(config.CrewSources)] } // The chooser's fixed lines around the three presets, spelled once so the @@ -706,8 +574,8 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // and the only fact on it. The row takes no lead and no ground for seat // one's reason, said above: nothing on this list applies it with enter, // and a mark that said otherwise would be a promise no key keeps. - words := make([]string, 0, len(crewSources)) - for _, source := range crewSources { + words := make([]string, 0, len(config.CrewSources)) + for _, source := range config.CrewSources { if source == p.source { words = append(words, pal.accent(source+" models")) continue @@ -716,7 +584,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string } out = append(out, fit(" "+pal.dim(crewSourceLead+" ‹ ")+strings.Join(words, pal.dim(" · "))+pal.dim(" ›"), width)) for i, preset := range config.CrewPresets { - models, _ := crewPresetModels(p.source, preset) + models, _ := config.CrewModelsForSource(p.source, preset) parts := make([]string, 0, len(roles.Tiers)) for _, tier := range roles.Tiers { parts = append(parts, strings.TrimSpace(a.crewClassWord(tier))+" "+models[string(tier)]) diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index e3213f0244..e20ceb5502 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -353,7 +353,7 @@ func TestTheCrewChooserNamesTheTwoFamiliesAboveThePresets(t *testing.T) { // person last committed to, not on the default. func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { a, dir := sheetApp(t) - seed := map[string]any{crewSourceKey: crewSourceAll} + seed := map[string]any{config.KeyCrewSource: config.CrewSourceAll} encoded, err := json.Marshal(seed) if err != nil { t.Fatal(err) @@ -379,7 +379,7 @@ func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { // nothing, for the esc test's own reason. a.crewPickerKey(key("right")) a.crewPickerKey(key("esc")) - if got := crewSourceAt(dir); got != crewSourceAll { + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { t.Fatalf("esc wrote the family to %q", got) } } @@ -407,7 +407,7 @@ func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { if a.crewPick.open { t.Fatal("enter left the chooser open") } - if got := crewSourceAt(dir); got != crewSourceAll { + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { t.Fatalf("enter wrote the family %q, want all", got) } if got := config.CrewAt(dir); got != config.CrewBalanced { @@ -438,7 +438,7 @@ func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { // is not a third. func TestTheCrewSourceRowDefaultsToOpen(t *testing.T) { dir := t.TempDir() - if got := crewSourceAt(dir); got != crewSourceOpen { + if got := config.CrewSourceAt(dir); got != config.CrewSourceOpen { t.Fatalf("a profile with no row read %q", got) } for _, row := range []string{ @@ -449,14 +449,14 @@ func TestTheCrewSourceRowDefaultsToOpen(t *testing.T) { if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(row), 0o600); err != nil { t.Fatal(err) } - if got := crewSourceAt(dir); got != crewSourceOpen { + if got := config.CrewSourceAt(dir); got != config.CrewSourceOpen { t.Fatalf("the row %s read %q, want open", row, got) } } if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(`{"models.crew.source": "all"}`), 0o600); err != nil { t.Fatal(err) } - if got := crewSourceAt(dir); got != crewSourceAll { + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { t.Fatalf("the all row read %q", got) } } From ea05bc0f5908cf3bc6081414e7647d208aac774c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:05:44 -0400 Subject: [PATCH 07/22] crew: a chosen family counts as an answered crew CrewConfigured now treats models.crew.source like the tier rows: a person who chose a family and never pinned a tier has an opinion the first-run setup must not paper over with a preset. Shipped defaults still apply only to a profile that answered nothing. Co-Authored-By: codeaf --- internal/config/firstrun.go | 10 +++++++--- internal/config/firstrun_test.go | 23 +++++++++++++++++++++++ internal/tui3/onboarding.go | 6 +++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/internal/config/firstrun.go b/internal/config/firstrun.go index 34768619c1..bb96fa8cec 100644 --- a/internal/config/firstrun.go +++ b/internal/config/firstrun.go @@ -61,10 +61,14 @@ func APIKeyConfigured(profileDir string) bool { } // CrewConfigured is whether a person has ever answered the crew: any of the -// four tier rows is in the profile file. It is any rather than all because -// [ApplyCrew] writes the four together, and a person who pinned one tier by hand -// has an opinion the setup must not paper over with a preset. +// tier rows, or the family row above them, is in the profile file. It is any +// rather than all because [ApplyCrew] writes the tier rows together, and a +// person who pinned one tier by hand, or chose a family, has an opinion the +// setup must not paper over with a preset. func CrewConfigured(profileDir string) bool { + if _, ok := persistedString(profileDir, KeyCrewSource); ok { + return true + } for _, tier := range ModelTiers { if _, ok := persistedString(profileDir, tierKeyFor(tier)); ok { return true diff --git a/internal/config/firstrun_test.go b/internal/config/firstrun_test.go index 9233566be1..7619738417 100644 --- a/internal/config/firstrun_test.go +++ b/internal/config/firstrun_test.go @@ -148,3 +148,26 @@ func TestTheAPIKeyRowMasksReadsTheShellFirstAndWritesTheProfile(t *testing.T) { t.Fatal("a pinned row must refuse a write") } } + +// THE FAMILY IS AN OPINION TOO. A person who chose a family and never pinned a +// tier has still answered the crew, so the setup must not paper over it with a +// preset — the law the tier rows already carry, extended to the row above them. +// And a family nobody chose is the default, which is NOT an answer. +func TestTheFamilyRowCountsAsAnAnsweredCrew(t *testing.T) { + dir := t.TempDir() + if CrewConfigured(dir) { + t.Fatal("a profile nobody has touched must read as unanswered") + } + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("an untouched profile reads the family as %q, want the default", got) + } + if err := SetCrewSource(dir, CrewSourceAll); err != nil { + t.Fatal(err) + } + if !CrewConfigured(dir) { + t.Fatal("a profile whose family was chosen must read as answered") + } + if got := CrewSourceAt(dir); got != CrewSourceAll { + t.Fatalf("the family row reads %q after it was written", got) + } +} diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index c8102fd5ed..87bcaf99a3 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -495,9 +495,9 @@ func (a *app) commitSetupLimit() bool { // commitSetupCrew writes a crew, and MOSTLY DOES NOT. // // THE SETUP MAY NOT PAPER OVER AN OPINION. internal/config says why in its own -// words: [config.CrewConfigured] is true when ANY of the five tier rows is in the -// profile, "because a person who pinned one tier by hand has an opinion the setup -// must not paper over with a preset". So a preset is written on exactly two +// words: [config.CrewConfigured] is true when ANY of the tier rows, or the +// family row above them, is in the profile, "because a person who pinned one tier +// by hand has an opinion the setup must not paper over with a preset". So a preset is written on exactly two // roads — the person chose one in the chooser on this screen, or the profile had // no crew of its own at all and the row they were shown is the shipped default, // which is the case the first-run flow exists for. From f6c2924a798d7d028ad60d2b6c800bb01f6aa81e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:10:28 -0400 Subject: [PATCH 08/22] crew: the open table takes the picks we landed on frugal's mastermind, balanced's careful and mastermind, and the shipped defaults (which ARE the balanced row) move onto the roster we chose seat by seat: glm-5.3 plans the cheap crew, kimi-k3 works as the second-vendor check and the planner above glm-5.3-flash. Max keeps its mastermind on the same model as balanced because a preset may not carry a thinking level (the seat ladder's law), so the level stays an operator's to add. Manual, the pinned-table test, and the seat ladder's expectations follow. Co-Authored-By: codeaf --- internal/config/crew.go | 10 +++++----- internal/config/crew_test.go | 6 +++--- internal/config/seats_test.go | 4 ++-- internal/config/settings.go | 4 ++-- internal/manual/chat/commands.md | 8 ++++---- internal/manual/chat/models-and-cost.md | 16 ++++++++-------- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index b687430fc0..e8619f9039 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -108,14 +108,14 @@ var crewModels = map[string]map[string]string{ ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "deepseek/deepseek-v4-flash-0731", ModelTierHigh: "z-ai/glm-5.3-flash", - ModelTierMastermind: "z-ai/glm-5.3-flash", + ModelTierMastermind: "z-ai/glm-5.3", }, CrewBalanced: { ModelTierReflex: "mistralai/mistral-nemo", ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "z-ai/glm-5.3-flash", - ModelTierHigh: "qwen/qwen3.8-27b", - ModelTierMastermind: "z-ai/glm-5.3", + ModelTierHigh: "moonshotai/kimi-k3", + ModelTierMastermind: "moonshotai/kimi-k3", }, CrewMax: { ModelTierReflex: "mistralai/mistral-nemo", @@ -211,8 +211,8 @@ func CrewModelsForSource(source, preset string) (map[string]string, bool) { // prints beside each option and what the settings chooser shows under it — the // same words in both places, because they are one sentence about one thing. var crewLines = map[string]string{ - CrewFrugal: "deepseek works, glm-flash thinks · pennies a day", - CrewBalanced: "glm-flash works, glm-5.3 thinks, qwen checks", + CrewFrugal: "deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day", + CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", } diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index e1da2321f6..17248b8348 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -45,14 +45,14 @@ func TestCrewPresetsNameTheApprovedModels(t *testing.T) { ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "deepseek/deepseek-v4-flash-0731", ModelTierHigh: "z-ai/glm-5.3-flash", - ModelTierMastermind: "z-ai/glm-5.3-flash", + ModelTierMastermind: "z-ai/glm-5.3", }, CrewBalanced: { ModelTierReflex: "mistralai/mistral-nemo", ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "z-ai/glm-5.3-flash", - ModelTierHigh: "qwen/qwen3.8-27b", - ModelTierMastermind: "z-ai/glm-5.3", + ModelTierHigh: "moonshotai/kimi-k3", + ModelTierMastermind: "moonshotai/kimi-k3", }, CrewMax: { ModelTierReflex: "mistralai/mistral-nemo", diff --git a/internal/config/seats_test.go b/internal/config/seats_test.go index 5ad4c13f77..d863b3e2ba 100644 --- a/internal/config/seats_test.go +++ b/internal/config/seats_test.go @@ -44,7 +44,7 @@ func TestTheSeatLadderAnswersInItsOwnOrder(t *testing.T) { name: "the crew answers when it is the only thing said", crew: CrewFrugal, work: "deepseek/deepseek-v4-flash-0731", workRung: "crew frugal", - plan: "z-ai/glm-5.3-flash", planRung: "crew frugal", + plan: "z-ai/glm-5.3", planRung: "crew frugal", }, { name: "the environment outranks the crew", @@ -98,7 +98,7 @@ func TestTheSeatLadderAnswersInItsOwnOrder(t *testing.T) { // it: the same row a task handed off in conversation rides. hand: map[string]string{ModelTierWorker: "vendor/my-own-worker"}, work: "vendor/my-own-worker", workRung: "crew custom", - plan: "z-ai/glm-5.3-flash", planRung: "crew custom", + plan: "z-ai/glm-5.3", planRung: "crew custom", }, { // A row cleared on purpose means "follow the conversation", and a diff --git a/internal/config/settings.go b/internal/config/settings.go index c00c332651..5f7a726555 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1026,10 +1026,10 @@ const ( // preset, and always a model that sees images: a check from a second family // catches what the first family's blind spots let through, and the vision // role rides this row. - DefaultHighModel = "qwen/qwen3.8-27b" + DefaultHighModel = "moonshotai/kimi-k3" // The mastermind names a capable planning model. Its generation behavior is // left to the provider unless an operator adds a level to the model id. - DefaultMastermindModel = "z-ai/glm-5.3" + DefaultMastermindModel = "moonshotai/kimi-k3" ) // DocumentEngines are the four rungs CODEAF_DOC_ENGINE accepts. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index d264149dab..ed5187a35f 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1422,10 +1422,10 @@ opens the six-seat reading, bottom-anchored like the model picker. From the top: ``` the five models codeaf uses on its own behalf — not the one you chat with you talk to · deepseek-v4-flash - frugal — deepseek works, glm-flash thinks · pennies a day - reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3-flash -› balanced — glm-flash works, glm-5.3 thinks, qwen checks - reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3-flash · careful work qwen/qwen3.8-27b · mastermind z-ai/glm-5.3 + frugal — deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day + reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3 +› balanced — glm-flash works, kimi-k3 checks and thinks + reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3-flash · careful work moonshotai/kimi-k3 · mastermind moonshotai/kimi-k3 max — glm-5.3 works, kimi-k3 thinks and checks reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3 · careful work moonshotai/kimi-k3 · mastermind moonshotai/kimi-k3 each of the five can be pinned on its own in /settings → Providers diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bb7ea570d9..480455737f 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -334,8 +334,8 @@ preset: | reflex | `mistralai/mistral-nemo` | | small work | `deepseek/deepseek-v4-flash-0731` | | worker | `z-ai/glm-5.3-flash` | -| careful work | `qwen/qwen3.8-27b` | -| mastermind | `z-ai/glm-5.3` | +| careful work | `moonshotai/kimi-k3` | +| mastermind | `moonshotai/kimi-k3` | They are all open-weight models, and none of them is the model you are talking to. A crew that followed your conversation would put the most expensive model in the build on the @@ -376,8 +376,8 @@ scores 58 on the agentic index and 72 on coding at about $0.12 per million token one point under `glm-5.3` at a twentieth of its price, and it can see images. The mastermind buys the thinking rung rather than a bigger model, because its calls are few. The careful class is always a different vendor from the worker and always sees images: -`qwen/qwen3.8-27b` scores 68 on coding at $0.42/M input and $2.55/M output with a 1M-token -window. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose — +`moonshotai/kimi-k3` scores 76 on coding and is the open field's strongest checker, so +`balanced` and `max` put it on both the careful and the mastermind seat. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose — the bare `deepseek/deepseek-v4-flash` id resolves to the April build, and the July build at the same price scores thirteen coding points higher. @@ -393,11 +393,11 @@ say rather than the default. | reflex | `mistral-nemo` | `mistral-nemo` | `mistral-nemo` | | small work | `deepseek-v4-flash-0731` | `deepseek-v4-flash-0731` | `deepseek-v4-flash-0731` | | worker | `deepseek-v4-flash-0731` | `glm-5.3-flash` | `glm-5.3` | -| careful work | `glm-5.3-flash` | `qwen3.8-27b` | `kimi-k3` | -| mastermind | `glm-5.3-flash` | `glm-5.3` | `kimi-k3` | +| careful work | `glm-5.3-flash` | `kimi-k3` | `kimi-k3` | +| mastermind | `glm-5.3` | `kimi-k3` | `kimi-k3` | -- **frugal** — deepseek works, glm-flash thinks · pennies a day -- **balanced** — glm-flash works, glm-5.3 thinks, qwen checks +- **frugal** — deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day +- **balanced** — glm-flash works, kimi-k3 checks and thinks - **max** — glm-5.3 works, kimi-k3 thinks and checks The worker column climbs the open-weight front one step per preset, because it is the seat From eff44b542ee547e1b42169068b62ec356d22258a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:11:38 -0400 Subject: [PATCH 09/22] docs: the change entry carries the open-table move and the family gate Co-Authored-By: codeaf --- docs/changes/unreleased/1073-crew-model-family.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md index e45204423f..28e9eaf3b8 100644 --- a/docs/changes/unreleased/1073-crew-model-family.md +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -7,6 +7,8 @@ invalidates: - "The three crew preset words (frugal, balanced, max) always resolved to open-weight models, and `crewModels` in `internal/config/crew.go` was the whole table of what they meant. There is now a second table `crewAllModels`, and `CrewModelsForSource(source, preset)` picks between them on the new `models.crew.source` setting (`open` default, `all` for closed and frontier). A profile that never answers the row still resolves the open table." - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` until a preset is re-applied, which is the point of a meaning toggle rather than a write." + - "The open table was said to be untouched. It is not: the shipped open table moved onto the roster chosen seat by seat (frugal's mastermind, balanced's careful and mastermind), and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." + - "`CrewConfigured` counted only the tier rows, so a person who chose a family and never pinned a tier could still be handed a preset by the first-run setup. It now counts `models.crew.source` too: a chosen family is an answered crew." --- The crew shipped open-weight models for a reason: a default nobody's pricing can @@ -16,5 +18,6 @@ has decided the frontier models are worth it on a given machine, and wants the s three preset words to mean them. The two families are a table apiece, not a filter over one, because the picks are -argued seat by seat rather than chosen by a rule about vendors or price. The open -table is untouched; the all table is new and mirrors its three presets. +argued seat by seat rather than chosen by a rule about vendors or price. The all +table is new and mirrors the open one's three presets; the open table moved onto the +roster this work chose, which is the one edit here that changes a shipped default. From 71ce9bec0c2a1dfef6d1a26c3c1a98e055d3e71f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:17:11 -0400 Subject: [PATCH 10/22] crew: the family reaches the fallback, the settings skin, and the row's line Three things the full suite caught after the open table moved. A profile that answered models.crew.source and no tier row ran the open constants, so the family said all and the ladder did open: defaultTierModel now resolves the default crew in the chosen family. The new row had no line in the settings skin, which the tab test requires. And the crew row's one line grew long enough to wrap mid-phrase, hiding the balanced line from the screen test. Co-Authored-By: codeaf --- internal/config/crew.go | 2 +- internal/config/seats.go | 2 +- internal/config/settings.go | 20 ++++++++++++++++++-- internal/manual/chat/commands.md | 2 +- internal/manual/chat/models-and-cost.md | 2 +- internal/tui3/settings.go | 9 +++++++++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index e8619f9039..3a6b0b61df 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -211,7 +211,7 @@ func CrewModelsForSource(source, preset string) (map[string]string, bool) { // prints beside each option and what the settings chooser shows under it — the // same words in both places, because they are one sentence about one thing. var crewLines = map[string]string{ - CrewFrugal: "deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day", + CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", } diff --git a/internal/config/seats.go b/internal/config/seats.go index 96ab4ed431..298dc4e356 100644 --- a/internal/config/seats.go +++ b/internal/config/seats.go @@ -233,7 +233,7 @@ func TierSeatAt(profileDir, tier string) Seat { if source == "" { source = SeatDefault if !cleared { - model = defaultTierModel(tier) + model = defaultTierModel(profileDir, tier) } } return Seat{Role: tierSeatRole(tier), Model: model, Source: source, From: from} diff --git a/internal/config/settings.go b/internal/config/settings.go index 5f7a726555..aba6d7ee69 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3783,8 +3783,24 @@ func tierKeyFor(tier string) string { } // defaultTierModel is what a tier answers on a profile that has never held its -// key. The five together are the balanced crew. -func defaultTierModel(tier string) string { +// key: the DEFAULT CREW, resolved in the family the profile chose. It is not the +// build's constant alone, because a profile that answered the family row and no +// tier row would otherwise run the open-weight crew under a frontier label — the +// family saying one thing and the ladder another. Under the shipped family the +// answer is the constant, which is what [DefaultWorkerModel] and its kin name, +// because the two tables' default row is the same set by construction +// (crew_test.go pins it). +func defaultTierModel(profileDir, tier string) string { + if table, ok := CrewModelsForSource(CrewSourceAt(profileDir), DefaultCrew); ok { + if model, held := table[tier]; held { + return model + } + } + return builtinTierModel(tier) +} + +// builtinTierModel is the build's own five, for a tier no family table names. +func builtinTierModel(tier string) string { switch tier { case ModelTierReflex: return DefaultReflexModel diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index ed5187a35f..c00168a07c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1422,7 +1422,7 @@ opens the six-seat reading, bottom-anchored like the model picker. From the top: ``` the five models codeaf uses on its own behalf — not the one you chat with you talk to · deepseek-v4-flash - frugal — deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day + frugal — deepseek works, glm-5.3 thinks · pennies a day reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3 › balanced — glm-flash works, kimi-k3 checks and thinks reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3-flash · careful work moonshotai/kimi-k3 · mastermind moonshotai/kimi-k3 diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 480455737f..f01d8f29aa 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -396,7 +396,7 @@ say rather than the default. | careful work | `glm-5.3-flash` | `kimi-k3` | `kimi-k3` | | mastermind | `glm-5.3` | `kimi-k3` | `kimi-k3` | -- **frugal** — deepseek works, glm-flash checks and glm-5.3 thinks · pennies a day +- **frugal** — deepseek works, glm-5.3 thinks · pennies a day - **balanced** — glm-flash works, kimi-k3 checks and thinks - **max** — glm-5.3 works, kimi-k3 thinks and checks diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 46c78d28c5..248672e389 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -370,6 +370,15 @@ var settingUI = map[string]settingMeta{ tab: tabProviders, label: "reflex", widget: widgetSelect, about: "near-free · reads every turn — memory, titles, safety", }, + + // The row directly under the crew word, because it changes what that word + // means rather than pinning a model of its own: the three presets answer + // from open weights or from the whole catalog, and this is the switch. + config.KeyCrewSource: { + tab: tabProviders, label: "model family", widget: widgetCycle, + about: "which models the crew word draws from: open weights, or the whole " + + "catalog with closed and frontier models in it. Open is the default.", + }, config.KeyTierLowModel: { tab: tabProviders, label: "small work", widget: widgetSelect, about: "cheap · the small calls — names, digests, the safety gate", From b8ca5f513ac9f0c2d80786d5aeba010ca231c7cb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:25:23 -0400 Subject: [PATCH 11/22] crew: round-1 review fixes on the new table The all-family comment claimed the open table was byte-for-byte untouched, which this very diff contradicts: the clause is gone. The preset lines named open models above frontier ids under 'all', on the exact screen where someone decides to spend frontier money: crewLines is keyed by family now and CrewLineFor resolves both the picker rows and the settings panel's crew sentence through the family on screen. /crew's listing drew open ids under 'all' for the same reason. SetCrewSource and the settings row share one accepted-set spelling, the tombstone comments are gone, and the em dashes the house law bans are out. Co-Authored-By: codeaf --- internal/config/crew.go | 54 +++++++++++++++++++++++--------- internal/config/firstrun_test.go | 2 +- internal/config/settings.go | 2 +- internal/manual/chat/commands.md | 5 ++- internal/tui3/crew.go | 26 ++++++--------- internal/tui3/crew_test.go | 7 ++--- internal/tui3/settings.go | 17 +++++++--- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index 3a6b0b61df..308b0a2c3b 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -132,8 +132,7 @@ var crewModels = map[string]map[string]string{ // there: a DIFFERENT VENDOR from the worker in every preset, and the reflex // and low columns still never vary. The ids are locked the way the open ones // were, off the catalog's own published scores against blended price, and -// closed models live here and only here: the open table is the shipped -// default and stays byte-for-byte what it was. +// closed models live here and only here. // // TWO COLUMNS SPEND DIFFERENTLY HERE. The worker column is [crewModels]'s dial // but moves once, not per preset: gpt-5.6-sol holds frugal and balanced, and @@ -207,18 +206,38 @@ func CrewModelsForSource(source, preset string) (map[string]string, bool) { return out, true } -// crewLines is the one line each preset says about itself. It is what /crew -// prints beside each option and what the settings chooser shows under it — the -// same words in both places, because they are one sentence about one thing. -var crewLines = map[string]string{ - CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", - CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", - CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", +// crewLines is the one line each preset says about itself, IN THE FAMILY IT +// DRAWS FROM. It is what /crew prints beside each option and what the settings +// chooser shows under it, so it must name the models the preset actually picks +// in the family on screen: a line naming open models above frontier ids is the +// contradiction the chooser exists to prevent. +var crewLines = map[string]map[string]string{ + CrewSourceOpen: { + CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", + CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", + CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", + }, + CrewSourceAll: { + CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", + CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", + CrewMax: "fable works, astra checks and thinks", + }, } -// CrewLine is one preset's own line, empty for a word that is not a preset. +// CrewLine is one preset's own line in the OPEN family, empty for a word that is +// not a preset. The family-aware spelling is [CrewLineFor]. func CrewLine(preset string) string { - return crewLines[strings.ToLower(strings.TrimSpace(preset))] + return CrewLineFor(CrewSourceOpen, preset) +} + +// CrewLineFor is one preset's own line in one family, empty for a word that is +// not a preset or a family this build does not know (which reads as open). +func CrewLineFor(source, preset string) string { + source = strings.ToLower(strings.TrimSpace(source)) + if _, known := crewLines[source]; !known { + source = CrewSourceOpen + } + return crewLines[source][strings.ToLower(strings.TrimSpace(preset))] } // CrewModels is the five models one preset would set in the OPEN family, by @@ -307,10 +326,15 @@ func writeCrew(profileDir, raw string) error { // family nothing reads. func SetCrewSource(profileDir, source string) error { source = strings.ToLower(strings.TrimSpace(source)) - if source != CrewSourceOpen && source != CrewSourceAll { - return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + // The accepted set is [CrewSources], spelled once: a family added to that slice + // is accepted here and by the settings row's choice widget at the same time, + // so the command and the panel can never disagree about what is a family. + for _, known := range CrewSources { + if source == known { + return writeProfileValue(profileDir, KeyCrewSource, source) + } } - return writeProfileValue(profileDir, KeyCrewSource, source) + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) } // CrewSummary is the one line a crew change confirms itself with: @@ -330,7 +354,7 @@ func CrewSummary(profileDir string) string { // CrewClasses is the three class names alone: // -// brain glm-5.3 · hands glm-5.3-flash · checks qwen3.8-27b +// brain kimi-k3 · hands glm-5.3-flash · checks kimi-k3 // // It is the tail of [CrewSummary] lifted out because a second surface prints the // crew now — /status, where the word already has a label of its own and "crew →" diff --git a/internal/config/firstrun_test.go b/internal/config/firstrun_test.go index 7619738417..c8b8c749ed 100644 --- a/internal/config/firstrun_test.go +++ b/internal/config/firstrun_test.go @@ -151,7 +151,7 @@ func TestTheAPIKeyRowMasksReadsTheShellFirstAndWritesTheProfile(t *testing.T) { // THE FAMILY IS AN OPINION TOO. A person who chose a family and never pinned a // tier has still answered the crew, so the setup must not paper over it with a -// preset — the law the tier rows already carry, extended to the row above them. +// preset: the law the tier rows already carry, extended to the row above them. // And a family nobody chose is the default, which is NOT an answer. func TestTheFamilyRowCountsAsAnAnsweredCrew(t *testing.T) { dir := t.TempDir() diff --git a/internal/config/settings.go b/internal/config/settings.go index aba6d7ee69..9706978996 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2290,7 +2290,7 @@ func (s *Settings) build() []Setting { "costs what those models cost. Flip it and pick the crew again; the five " + "rows already written keep their ids until you do.", read: func() string { return CrewSourceAt(dir) }, - write: func(raw string) error { return writeChoice(dir, KeyCrewSource, raw, CrewSources) }, + write: func(raw string) error { return SetCrewSource(dir, raw) }, }, Setting{ Key: KeyTierReflexModel, Category: CategoryModels, Kind: SettingText, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c00168a07c..dbce646fb6 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1422,6 +1422,7 @@ opens the six-seat reading, bottom-anchored like the model picker. From the top: ``` the five models codeaf uses on its own behalf — not the one you chat with you talk to · deepseek-v4-flash + source ‹ open models · all models › frugal — deepseek works, glm-5.3 thinks · pennies a day reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3 › balanced — glm-flash works, kimi-k3 checks and thinks @@ -1433,7 +1434,9 @@ each of the five can be pinned on its own in /settings → Providers The first line says what the presets change and what they do not. The second is **seat one** — `you talk to · `, spelled as the legend above the box spells it — with no marker and -no highlight, because nothing in this chooser can move it. Then the three presets: the one +no highlight, because nothing in this chooser can move it. The third is the **family**: `source` +follows **←→**, and it says which pool the three presets below it draw from, open weights or the +whole catalog. Then the three presets: the one in force wears a highlighted ground, `›` is where **enter** is aimed and it opens on yours, ↑ / ctrl+p and ↓ / ctrl+n move, and **esc** closes without changing anything. The last line points at the settings row where one seat can be pinned by itself; the chooser does not diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 2b7168ea8b..993f416ecc 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -25,8 +25,8 @@ import ( // list no key can move ([crewPicker.rows] says why it is there anyway). // // EVERY CREW WRITE GOES THROUGH [config.ApplyCrew], the same function the -// panel's row writes through, and the one row that does not — the family, the -// pool the presets draw from — goes through [config.SetCrewSource] beside the +// panel's row writes through, and the one row that does not, the family, the +// pool the presets draw from, goes through [config.SetCrewSource] beside the // five. A second writer anywhere else is how a command and a panel end up // disagreeing about which crew is on. @@ -413,13 +413,6 @@ const ( crewSourceLead = "source" ) -// crewSourceAt and writeCrewSource lived here as an interim bridge while the -// config half of the wave was in flight. They are gone: the row is read through -// [config.CrewSourceAt] and written through [config.SetCrewSource]. - -// crewPresetModels lived here as the swap point while the config half was in -// flight; the rows now draw through [config.CrewModelsForSource]. - // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its // zero value is closed, like [picker], and its cursor is an index into // [config.CrewPresets]. @@ -444,10 +437,10 @@ type crewPicker struct { func (p *crewPicker) start(current, source, inherited string) { *p = crewPicker{open: true, current: current, inherited: inherited, source: config.CrewSourceOpen} - // A WORD THIS BUILD DOES NOT KNOW READS AS THE DEFAULT, for the same - // reason every reader in internal/config folds an unreadable row back to - // its shipped value: the chooser is where a person answers a question, - // not where a stray word off a hand-edited file gets a third option. + // A WORD THIS BUILD DOES NOT KNOW READS AS THE DEFAULT. The only caller + // passes [config.CrewSourceAt], which already folds blank, unknown and retired + // words back to open, so this is that same law one hop earlier: a caller that + // ever hands this a raw word still cannot make a third option. for _, known := range config.CrewSources { if source == known { p.source = source @@ -608,7 +601,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string case hovered: lead = pal.accent("· ") } - label := preset + " — " + config.CrewLine(preset) + label := preset + " — " + config.CrewLineFor(p.source, preset) switch { case current: label = pal.accent(label) @@ -698,6 +691,7 @@ func (a *app) crewPickerKey(msg tea.KeyPressMsg) { // surface. func (a *app) crewListing() string { current := config.CrewAt(a.profileDir) + source := config.CrewSourceAt(a.profileDir) var out strings.Builder for at, preset := range config.CrewPresets { if at > 0 { @@ -707,8 +701,8 @@ func (a *app) crewListing() string { if preset == current { lead = "· " } - out.WriteString(lead + preset + " — " + config.CrewLine(preset) + "\n") - models, _ := config.CrewModels(preset) + out.WriteString(lead + preset + " — " + config.CrewLineFor(source, preset) + "\n") + models, _ := config.CrewModelsForSource(source, preset) for _, tier := range roles.Tiers { out.WriteString(" " + a.crewClassWord(tier) + " " + models[string(tier)] + "\n") } diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index e20ceb5502..ec6d7a52ec 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -368,10 +368,9 @@ func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { t.Fatalf("the chooser opened on a family other than the persisted one:\n%q", rows[2]) } // THE SEGMENT DOES NOT CARRY THE FAMILY, and the frame-disk law is why - // (the family section in crew.go says it once): the chooser is the one - // surface that reads the row until config lands its memoised reader, so the - // segment keeps its exact old shape over an all profile, which is the - // non-regression the HUD was promised. + // (the family section in crew.go says it once): it reads the crew word and + // nothing else, so a profile on the all family keeps the same segment shape, + // which is the non-regression the HUD was promised. if got := a.crewSegment(); got != "crew "+config.CrewBalanced { t.Fatalf("the crew segment reads %q over an all profile", got) } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 248672e389..47950e1605 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -736,7 +736,7 @@ func init() { // same row, the same write, the same live seam onto [app.switchModel] — only // the word above the Models section changed. crew := settingUI[config.KeyCrew] - crew.about = crewAbout() + crew.about = crewAbout(config.CrewSourceOpen) settingUI[config.KeyCrew] = crew talk := settingUI[config.ModelSettingKey(talkSlot)] @@ -748,12 +748,14 @@ func init() { // crewAbout is the crew row's one line: the five rows it writes, then each preset // with its own sentence, then what makes the row read custom. The sentences are -// [config.CrewLine]'s, so the panel and /crew say the same words about the same -// thing (internal/config's crew.go holds the table). -func crewAbout() string { +// [config.CrewLineFor]'s IN THE FAMILY ON SCREEN, so the panel and /crew say the +// same words about the same thing and neither names an open model above frontier +// ids. The init-time value is the open family; [sheet.metaFor] re-says it from the +// profile, which is where a family that is not the default comes from. +func crewAbout(source string) string { said := make([]string, 0, len(config.CrewPresets)) for _, preset := range config.CrewPresets { - said = append(said, preset+" — "+config.CrewLine(preset)) + said = append(said, preset+" — "+config.CrewLineFor(source, preset)) } // The three options lead, because they are what the keypress chooses between // and the panel gives a row's line the width it has: what gets cut on a narrow @@ -1412,6 +1414,11 @@ func (s *sheet) metaFor(row config.Setting) (settingMeta, bool) { if ok && row.Key == config.KeySearchProvider { meta.about = config.SearchProviderHintAt(s.profileDir) } + // The crew row names three presets in whichever family the profile is on, so + // its sentence is read from the profile for the same reason. + if ok && row.Key == config.KeyCrew { + meta.about = crewAbout(config.CrewSourceAt(s.profileDir)) + } // AND THE `lane` ROW IS EXPLAINED BY THE ROUTING IN FORCE, because `auto` is // a different promise under `simple` than under the row codeaf ships with — // the same one door the picker's own `auto` row reads (palette.go's From 8db5708efcc0387ac8e8a96c7278c3afe74ee3c5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:34:32 -0400 Subject: [PATCH 12/22] crew: round-2 review fixes The all-family max line named astra as the planner, but max's mastermind is the worker's own fable: the line and the table disagreed on the spend screen. The six preset lines are now locked by a test, which is how the all lines slipped. CrewSummary's doc example still showed the old balanced row. Two em dashes and a stale onboarding quote are out. The manual's open-ids rationale no longer claims the mastermind is never a bigger model while the row above it pays for glm-5.3, and it is re-dated for the seats this change re-picked. Co-Authored-By: codeaf --- internal/config/crew.go | 4 ++-- internal/config/crew_test.go | 13 +++++++++++++ internal/config/settings.go | 6 +++--- internal/manual/chat/models-and-cost.md | 11 +++++++---- internal/tui3/crew.go | 7 ++++--- internal/tui3/onboarding.go | 3 ++- 6 files changed, 31 insertions(+), 13 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index 308b0a2c3b..c6c70d5c8f 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -220,7 +220,7 @@ var crewLines = map[string]map[string]string{ CrewSourceAll: { CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", - CrewMax: "fable works, astra checks and thinks", + CrewMax: "fable works and thinks, astra checks", }, } @@ -339,7 +339,7 @@ func SetCrewSource(profileDir, source string) error { // CrewSummary is the one line a crew change confirms itself with: // -// crew → balanced · brain glm-5.3 · hands glm-5.3-flash · checks qwen3.8-27b +// crew → balanced · brain kimi-k3 · hands glm-5.3-flash · checks kimi-k3 // // The three names are the classes a person actually asked about — what thinks, // what works, what checks — and HANDS IS THE WORKER: the seat that does the diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 17248b8348..e156cab816 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -285,6 +285,19 @@ func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { } } } + // THE LINES ARE PART OF THE TABLE, and they are the words a person reads while + // deciding to spend frontier money: a line naming a model the preset does not + // pick is the contradiction the chooser exists to prevent. + wantLines := map[string]string{ + CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", + CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", + CrewMax: "fable works and thinks, astra checks", + } + for preset, line := range wantLines { + if got := CrewLineFor(CrewSourceAll, preset); got != line { + t.Errorf("the all family's %s line is %q, want %q", preset, got, line) + } + } } // A SOURCE NOBODY SET IS THE OPEN FAMILY, BYTE FOR BYTE. The all table may diff --git a/internal/config/settings.go b/internal/config/settings.go index 9706978996..7c8e4acdd3 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3785,11 +3785,11 @@ func tierKeyFor(tier string) string { // defaultTierModel is what a tier answers on a profile that has never held its // key: the DEFAULT CREW, resolved in the family the profile chose. It is not the // build's constant alone, because a profile that answered the family row and no -// tier row would otherwise run the open-weight crew under a frontier label — the +// tier row would otherwise run the open-weight crew under a frontier label: the // family saying one thing and the ladder another. Under the shipped family the // answer is the constant, which is what [DefaultWorkerModel] and its kin name, -// because the two tables' default row is the same set by construction -// (crew_test.go pins it). +// because the open table's default row and the builtin five name the same set by +// construction (crew_test.go pins it). func defaultTierModel(profileDir, tier string) string { if table, ok := CrewModelsForSource(CrewSourceAt(profileDir), DefaultCrew); ok { if model, held := table[tier]; held { diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f01d8f29aa..fefdbcbd11 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -369,12 +369,15 @@ the one expensive step. What `frugal` to `balanced` buys instead is the careful smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the catalog's ceiling, so the top seat adds generation behavior rather than a bigger model. -**Why the open ids.** They were picked on 2026-09-01 off the catalog's own published scores — -OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row -— against blended price, open weights only. The worker seat is the dial: `glm-5.3-flash` +**Why the open ids.** They were picked off the catalog's own published scores — OpenRouter +republishes Artificial Analysis's coding and agentic indexes on every model row — against +blended price, open weights only, and the mastermind and balanced careful seats were +re-picked on 2026-09-16. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, one point under `glm-5.3` at a twentieth of its price, and it can see images. The -mastermind buys the thinking rung rather than a bigger model, because its calls are few. +mastermind is the one seat whose price may be the model rather than the rung: it answers a +few times a task, so `frugal` pays for `glm-5.3` while the work below it stays on +`glm-5.3-flash` and DeepSeek. The careful class is always a different vendor from the worker and always sees images: `moonshotai/kimi-k3` scores 76 on coding and is the open field's strongest checker, so `balanced` and `max` put it on both the careful and the mastermind seat. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose — diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 993f416ecc..d5392e189b 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -564,9 +564,10 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // // THE WORD THE NEXT ENTER WRITES IS THE WORD LIFTED, and the lifted step is // the accent for the cursor's own reason: it is the fact this row answers - // and the only fact on it. The row takes no lead and no ground for seat - // one's reason, said above: nothing on this list applies it with enter, - // and a mark that said otherwise would be a promise no key keeps. + // and the only fact on it. The + // row takes no `›` lead and no ground of its own, for a reason of its own + // rather than seat one's: it is not on the ↑↓ axis, so no cursor sits on it + // for a lead to mark, and the lifted word is its whole mark. words := make([]string, 0, len(config.CrewSources)) for _, source := range config.CrewSources { if source == p.source { diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 87bcaf99a3..1ac4491ef5 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -497,7 +497,8 @@ func (a *app) commitSetupLimit() bool { // THE SETUP MAY NOT PAPER OVER AN OPINION. internal/config says why in its own // words: [config.CrewConfigured] is true when ANY of the tier rows, or the // family row above them, is in the profile, "because a person who pinned one tier -// by hand has an opinion the setup must not paper over with a preset". So a preset is written on exactly two +// by hand, or chose a family, has an opinion the setup must not paper over with a +// preset". So a preset is written on exactly two // roads — the person chose one in the chooser on this screen, or the profile had // no crew of its own at all and the row they were shown is the shipped default, // which is the case the first-run flow exists for. From ce9759ef4d614708395839160e01d36f0ccbe1a0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 18:42:38 -0400 Subject: [PATCH 13/22] crew: round-3 review fixes The line-locking test pinned only the all family while this wave rewrote two open lines, so a wrong open line would pass everything: both families are pinned now. The family row's comment was left half-rewrapped and said step where it means word. And /crew's plain-text listing now names the pool whenever it is not the shipped one, so an all listing is not byte-indistinguishable from an open one. Co-Authored-By: codeaf --- internal/config/crew_test.go | 25 +++++++++++++++++-------- internal/tui3/crew.go | 11 ++++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index e156cab816..8c264644a0 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -288,14 +288,23 @@ func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { // THE LINES ARE PART OF THE TABLE, and they are the words a person reads while // deciding to spend frontier money: a line naming a model the preset does not // pick is the contradiction the chooser exists to prevent. - wantLines := map[string]string{ - CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", - CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", - CrewMax: "fable works and thinks, astra checks", - } - for preset, line := range wantLines { - if got := CrewLineFor(CrewSourceAll, preset); got != line { - t.Errorf("the all family's %s line is %q, want %q", preset, got, line) + wantLines := map[string]map[string]string{ + CrewSourceOpen: { + CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", + CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", + CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", + }, + CrewSourceAll: { + CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", + CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", + CrewMax: "fable works and thinks, astra checks", + }, + } + for family, lines := range wantLines { + for preset, line := range lines { + if got := CrewLineFor(family, preset); got != line { + t.Errorf("the %s family's %s line is %q, want %q", family, preset, got, line) + } } } } diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index d5392e189b..5f403c2e32 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -562,9 +562,8 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // two options are drawn beside each other so the move it offers is visible // before it is taken. // - // THE WORD THE NEXT ENTER WRITES IS THE WORD LIFTED, and the lifted step is - // the accent for the cursor's own reason: it is the fact this row answers - // and the only fact on it. The + // THE WORD THE NEXT ENTER WRITES IS THE WORD LIFTED, and the lifted word is + // the accent: it is the fact this row answers and the only fact on it. This // row takes no `›` lead and no ground of its own, for a reason of its own // rather than seat one's: it is not on the ↑↓ axis, so no cursor sits on it // for a lead to mark, and the lifted word is its whole mark. @@ -694,6 +693,12 @@ func (a *app) crewListing() string { current := config.CrewAt(a.profileDir) source := config.CrewSourceAt(a.profileDir) var out strings.Builder + // THE WORDS ARE THE SAME IN EITHER FAMILY, so the listing names the pool + // whenever it is not the shipped one: an all listing that looked exactly like + // an open one is the ambiguity this whole feature is about. + if source != config.CrewSourceOpen { + out.WriteString(crewSourceLead + " · " + source + " models\n") + } for at, preset := range config.CrewPresets { if at > 0 { out.WriteString("\n") From 956e1f5141bd747453837179c27ee75e37c13c90 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:06:59 -0400 Subject: [PATCH 14/22] crew: round-4 review fixes CrewLineFor's doc claimed an unknown family yields empty, which would make it a wrong is-this-family-known probe: only an unknown preset yields empty. Two test gaps the round named: the chooser now asserts the preset rows answer in the staged family (a header-only check would pass an all header over open rows), and the family row's refusal branch is pinned the way the crew row's already was. Co-Authored-By: codeaf --- internal/config/crew.go | 3 ++- internal/config/crew_test.go | 16 ++++++++++++++++ internal/tui3/crew_test.go | 8 ++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index c6c70d5c8f..bf7cd6cee1 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -231,7 +231,8 @@ func CrewLine(preset string) string { } // CrewLineFor is one preset's own line in one family, empty for a word that is -// not a preset or a family this build does not know (which reads as open). +// not a preset. A family this build does not know reads as open, the way the +// row does. func CrewLineFor(source, preset string) string { source = strings.ToLower(strings.TrimSpace(source)) if _, known := crewLines[source]; !known { diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 8c264644a0..955baad15a 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -490,3 +490,19 @@ func TestEverySettingsWriteBumpsTheGeneration(t *testing.T) { t.Fatalf("a refused write moved the generation to %d", got) } } + +// THE FAMILY ROW REFUSES A WORD THAT IS NOT A FAMILY, the way the crew row +// refuses a word that is not a preset, and a refused write leaves the row absent +// rather than half-written. +func TestSetCrewSourceRefusesAWordThatIsNotAFamily(t *testing.T) { + dir := t.TempDir() + if err := SetCrewSource(dir, "wide"); err == nil { + t.Fatal("SetCrewSource accepted a word that is not a family") + } + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("a refused write left the row reading %q", got) + } + if CrewConfigured(dir) { + t.Fatal("a refused write marked the crew as answered") + } +} diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index ec6d7a52ec..e8e69b078b 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -341,6 +341,14 @@ func TestTheCrewChooserNamesTheTwoFamiliesAboveThePresets(t *testing.T) { if !strings.Contains(rows[2], a.pal.accent("all models")) { t.Fatalf("← did not walk the family to all:\n%q", rows[2]) } + // AND THE PRESETS BELOW IT ANSWER IN THAT FAMILY: a header saying all above + // rows still naming open models is the contradiction this chooser exists to + // prevent, and it is the one regression a header-only check would pass. + for i, preset := range config.CrewPresets { + if row := plain(rows[3+2*i]); !strings.Contains(row, config.CrewLineFor(config.CrewSourceAll, preset)) { + t.Errorf("row %d under the all family is not %s's all line:\n%q", 3+2*i, preset, row) + } + } if a.crewPick.cursor != 1 { t.Fatalf("walking the family moved the preset cursor to row %d", a.crewPick.cursor) } From 37ad27f4e18e7f3c96db79b8374cf3e342d8eff8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:10:44 -0400 Subject: [PATCH 15/22] crew: fixes from the first Opus round The cross-check found what the kimi rounds did not. First-run setup showed open models and then wrote frontier ones, because crewPickSeats and crewChoiceWord were open-hardwired while commitSetupCrew writes through ApplyCrew, which reads the family: a person agreed to one price and paid another. Both resolve through the family now, with a test. The hint, the manual and a test comment all said a family flip writes nothing and seats only move when you pick a crew. defaultTierModel made that false for a seat nobody pinned, which is the behaviour its own comment argues for, so the prose now says it: unpinned seats move with the family, written rows keep their ids, and a test flips a fresh profile and asserts the frontier ids. Then the smaller ones: the change entry names the crews that read custom after the roster moved; two em dashes on re-flowed manual lines are gone; three comments narrating the config half of the wave are deleted; the source fold is spelled once in normalCrewSource instead of three ways; and the chooser's row is called family, the word the panel and the manual already use. Co-Authored-By: codeaf --- .../unreleased/1073-crew-model-family.md | 1 + internal/config/crew.go | 27 +++++++++++------ internal/config/crew_test.go | 29 +++++++++++++++---- internal/config/settings.go | 5 ++-- internal/manual/chat/commands.md | 4 +-- internal/manual/chat/models-and-cost.md | 11 +++---- internal/tui3/crew.go | 28 +++++++----------- internal/tui3/crew_test.go | 6 ++-- internal/tui3/onboarding.go | 12 ++++---- internal/tui3/onboarding_test.go | 2 +- internal/tui3/payload_test.go | 2 +- internal/tui3/render.go | 2 +- 12 files changed, 75 insertions(+), 54 deletions(-) diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md index 28e9eaf3b8..937e90e632 100644 --- a/docs/changes/unreleased/1073-crew-model-family.md +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -8,6 +8,7 @@ invalidates: - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` until a preset is re-applied, which is the point of a meaning toggle rather than a write." - "The open table was said to be untouched. It is not: the shipped open table moved onto the roster chosen seat by seat (frugal's mastermind, balanced's careful and mastermind), and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." + - "A profile that applied the old `frugal` or the old `balanced` now matches no preset in the open table, because the roster moved: the crew word, the `/crew` highlight and the seat rung read `custom` until the preset is re-applied, which writes the new roster." - "`CrewConfigured` counted only the tier rows, so a person who chose a family and never pinned a tier could still be handed a preset by the first-run setup. It now counts `models.crew.source` too: a chosen family is an answered crew." --- diff --git a/internal/config/crew.go b/internal/config/crew.go index bf7cd6cee1..209300bcf0 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -172,21 +172,34 @@ var crewAllModels = map[string]map[string]string{ // later build retired) names the open one, because a family nobody asked for // is the one this build can defend. func crewTableFor(source string) map[string]map[string]string { - if strings.EqualFold(strings.TrimSpace(source), CrewSourceAll) { + if normalCrewSource(source) == CrewSourceAll { return crewAllModels } return crewModels } +// normalCrewSource folds a source word to one of the two this build knows. It is +// the ONE place the fold is spelled: [CrewSourceAt], [crewTableFor] and +// [CrewLineFor] all call it, so a third family added to [CrewSources] is +// accepted everywhere at once, and a blank, unknown or retired word reads as the +// default family. +func normalCrewSource(source string) string { + source = strings.ToLower(strings.TrimSpace(source)) + for _, known := range CrewSources { + if source == known { + return source + } + } + return DefaultCrewSource +} + // CrewSourceAt is which family the preset words draw from on this profile, // [DefaultCrewSource] when the row is absent. A word this build does not know // reads as open, silently, the way a retired choice reads everywhere else on // this sheet. func CrewSourceAt(profileDir string) string { if value, ok := persistedString(profileDir, KeyCrewSource); ok { - if strings.EqualFold(strings.TrimSpace(value), CrewSourceAll) { - return CrewSourceAll - } + return normalCrewSource(value) } return DefaultCrewSource } @@ -234,11 +247,7 @@ func CrewLine(preset string) string { // not a preset. A family this build does not know reads as open, the way the // row does. func CrewLineFor(source, preset string) string { - source = strings.ToLower(strings.TrimSpace(source)) - if _, known := crewLines[source]; !known { - source = CrewSourceOpen - } - return crewLines[source][strings.ToLower(strings.TrimSpace(preset))] + return crewLines[normalCrewSource(source)][strings.ToLower(strings.TrimSpace(preset))] } // CrewModels is the five models one preset would set in the OPEN family, by diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 955baad15a..29fe6ac63a 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -346,11 +346,11 @@ func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { } } -// FLIPPING TO ALL MAKES THE SAME WORDS MEAN THE ALL FAMILY, and nothing else -// moves until the person picks the crew again. The five ids already written -// are theirs: a family flip is a meaning change, not a write, so the five rows -// in place read custom against the new family until one keystroke re-applies -// them. +// FLIPPING TO ALL MAKES THE SAME WORDS MEAN THE ALL FAMILY. Seats nobody pinned +// move at once, because an unwritten seat is the default crew resolved in the +// current family; the five ids already written are theirs, so a flip is a +// meaning change to them rather than a write, and they read custom against the +// new family until one keystroke re-applies them. func TestFlippingToAllMakesTheWordsMeanTheAllFamily(t *testing.T) { dir := t.TempDir() rows := registry(t, dir) @@ -506,3 +506,22 @@ func TestSetCrewSourceRefusesAWordThatIsNotAFamily(t *testing.T) { t.Fatal("a refused write marked the crew as answered") } } + +// AN UNWRITTEN SEAT FOLLOWS THE FAMILY, which is what makes the hint true: a +// profile on the all family with no rows runs the frontier crew without anyone +// picking it, and the crew word already reads the default preset. +func TestAnUnwrittenSeatFollowsTheFamily(t *testing.T) { + dir := t.TempDir() + if err := SetCrewSource(dir, CrewSourceAll); err != nil { + t.Fatal(err) + } + if got := TierModelAt(dir, ModelTierWorker); got != "openai/gpt-5.6-sol" { + t.Fatalf("the worker on an all profile with no row is %q, want the all default", got) + } + if got := TierModelAt(dir, ModelTierMastermind); got != "anthropic/claude-fable-5.1" { + t.Fatalf("the mastermind is %q, want the all default", got) + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("an all profile with no rows reads the crew as %q, want %q", got, CrewBalanced) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 7c8e4acdd3..a4ab5fbc2e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2287,8 +2287,9 @@ func (s *Settings) build() []Setting { "seat an open-weight model, so a crew nobody chose is never a bet on one " + "vendor's pricing. `all` reads the same three words, frugal, balanced and " + "max, off the whole catalog, closed and frontier models included, and " + - "costs what those models cost. Flip it and pick the crew again; the five " + - "rows already written keep their ids until you do.", + "costs what those models cost. Seats nobody pinned move with the family at " + + "once, because an unwritten seat is the default crew; rows already written " + + "keep their ids until you pick the crew again.", read: func() string { return CrewSourceAt(dir) }, write: func(raw string) error { return SetCrewSource(dir, raw) }, }, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index dbce646fb6..c8a95b05bd 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1422,7 +1422,7 @@ opens the six-seat reading, bottom-anchored like the model picker. From the top: ``` the five models codeaf uses on its own behalf — not the one you chat with you talk to · deepseek-v4-flash - source ‹ open models · all models › + family ‹ open models · all models › frugal — deepseek works, glm-5.3 thinks · pennies a day reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3 › balanced — glm-flash works, kimi-k3 checks and thinks @@ -1434,7 +1434,7 @@ each of the five can be pinned on its own in /settings → Providers The first line says what the presets change and what they do not. The second is **seat one** — `you talk to · `, spelled as the legend above the box spells it — with no marker and -no highlight, because nothing in this chooser can move it. The third is the **family**: `source` +no highlight, because nothing in this chooser can move it. The third is the **family**: `family` follows **←→**, and it says which pool the three presets below it draw from, open weights or the whole catalog. Then the three presets: the one in force wears a highlighted ground, `›` is where **enter** is aimed and it opens on yours, diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index fefdbcbd11..0d2f2eb266 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -349,8 +349,9 @@ You can also connect that vendor yourself as a direct service; the the three preset words draw from. `open` is the default and is the table above: open weights only, so a crew nobody chose is never a bet on one vendor's pricing. `all` reads the same three words, `frugal`, `balanced` and `max`, off the whole catalog instead, closed and frontier models included, -and costs what those models cost. Flip the row and pick the crew again: the five rows already on -disk keep their ids until you do, and the crew word reads `custom` in the meantime when they +and costs what those models cost. Flip the row and the seats nobody pinned move with it at once, +because an unwritten seat is the default crew resolved in the family you are on; the rows already +on disk keep their ids until you pick the crew again, and the crew word reads `custom` while they match no preset in the family you flipped to. Under `all`, the same three words resolve to these: @@ -369,8 +370,8 @@ the one expensive step. What `frugal` to `balanced` buys instead is the careful smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the catalog's ceiling, so the top seat adds generation behavior rather than a bigger model. -**Why the open ids.** They were picked off the catalog's own published scores — OpenRouter -republishes Artificial Analysis's coding and agentic indexes on every model row — against +**Why the open ids.** They were picked off the catalog's own published scores (OpenRouter +republishes Artificial Analysis's coding and agentic indexes on every model row) against blended price, open weights only, and the mastermind and balanced careful seats were re-picked on 2026-09-16. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, @@ -380,7 +381,7 @@ few times a task, so `frugal` pays for `glm-5.3` while the work below it stays o `glm-5.3-flash` and DeepSeek. The careful class is always a different vendor from the worker and always sees images: `moonshotai/kimi-k3` scores 76 on coding and is the open field's strongest checker, so -`balanced` and `max` put it on both the careful and the mastermind seat. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose — +`balanced` and `max` put it on both the careful and the mastermind seat. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose: the bare `deepseek/deepseek-v4-flash` id resolves to the April build, and the July build at the same price scores thirteen coding points higher. diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 5f403c2e32..3999902e69 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -99,8 +99,8 @@ func (a *app) applyCrew(preset string) { // applyCrewUnder is the chooser's enter: the family and the preset are ONE // decision, and the family row is written FIRST. The resolution that turns a -// family and a preset into five ids reads the row (the config half of this -// wave owns it), so a picker that applied the preset before writing the +// family and a preset into five ids reads the row, so a picker that applied +// the preset before writing the // family would hand the resolver yesterday's row on the one enter that // changed it. A failed family write refuses the preset with it: half of one // decision applied is the family saying all while the five rows say open, and @@ -406,12 +406,10 @@ func (a *app) sayWorkSeat() { // the gate): the chooser reads it once at its open ([app.runCrew]) and enter // writes it once, and neither happens on the frame clock, which is why the // status segment does not name the family. -const ( - // crewSourceLead is the family row's label. It is the key's own word - // because the two options beside it are the row's answer and the row is - // one line. - crewSourceLead = "source" -) +// crewSourceLead is the family row's label. It is `family` and not `source` +// because that is the word the settings panel and the manual use for the same +// row: one thing, one name. +const crewSourceLead = "family" // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its // zero value is closed, like [picker], and its cursor is an index into @@ -436,16 +434,10 @@ type crewPicker struct { } func (p *crewPicker) start(current, source, inherited string) { - *p = crewPicker{open: true, current: current, inherited: inherited, source: config.CrewSourceOpen} - // A WORD THIS BUILD DOES NOT KNOW READS AS THE DEFAULT. The only caller - // passes [config.CrewSourceAt], which already folds blank, unknown and retired - // words back to open, so this is that same law one hop earlier: a caller that - // ever hands this a raw word still cannot make a third option. - for _, known := range config.CrewSources { - if source == known { - p.source = source - } - } + // source is already a word this build knows: every caller hands it + // [config.CrewSourceAt]'s answer, which folds blank, unknown and retired words + // back to open. The fold lives there and nowhere else. + *p = crewPicker{open: true, current: current, inherited: inherited, source: source} for i, preset := range config.CrewPresets { if preset == current { p.cursor = i diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index e8e69b078b..3217c8fc30 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -377,8 +377,7 @@ func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { } // THE SEGMENT DOES NOT CARRY THE FAMILY, and the frame-disk law is why // (the family section in crew.go says it once): it reads the crew word and - // nothing else, so a profile on the all family keeps the same segment shape, - // which is the non-regression the HUD was promised. + // nothing else, so a profile on the all family keeps the same segment shape. if got := a.crewSegment(); got != "crew "+config.CrewBalanced { t.Fatalf("the crew segment reads %q over an all profile", got) } @@ -432,8 +431,7 @@ func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { if string(held["test.neighbour"]) != "true" { t.Fatalf("the family write lost its neighbour: %s", data) } - // AND THE SEGMENT KEEPS ITS SHAPE: the family is the chooser's until the - // config half lands the memoised reader the segment can read through + // AND THE SEGMENT KEEPS ITS SHAPE: it reads the crew word and not the family // (the family section in crew.go says it once). if got := a.crewSegment(); got != "crew "+config.CrewBalanced { t.Fatalf("the crew segment reads %q after the write", got) diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 1ac4491ef5..29faa34800 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -1332,7 +1332,7 @@ func (a *app) setupCrewDetail() string { // The three roles are spelled the way internal/config spells them for the // live reading below, because a person opening this twice must not be // shown one sentence in two shapes. - seats = crewPickSeats(a.setup.crewPick) + seats = crewPickSeats(config.CrewSourceAt(a.profileDir), a.setup.crewPick) } if seats == "" { seats = strings.TrimSpace(config.CrewClasses(a.profileDir)) @@ -1348,8 +1348,8 @@ func (a *app) setupCrewDetail() string { // itself rather than the profile it has not been written to yet. It answers "" // for a word that is not a preset, which is the emptiness law: there is no crew // to describe and no placeholder that would be true. -func crewPickSeats(preset string) string { - models, ok := config.CrewModels(preset) +func crewPickSeats(source, preset string) string { + models, ok := config.CrewModelsForSource(source, preset) if !ok { return "" } @@ -1487,11 +1487,11 @@ var crewChoiceWords = map[string]string{ // for a preset this table has not been taught — which is the one-source rule // applied to a gap: a new preset gets a real sentence rather than a blank row, // and the blank row is what would have shipped if this map were the only source. -func crewChoiceWord(preset string) string { +func crewChoiceWord(source, preset string) string { if word := crewChoiceWords[strings.ToLower(strings.TrimSpace(preset))]; word != "" { return word } - return config.CrewLine(preset) + return config.CrewLineFor(source, preset) } // setupCrewRows is the chooser under the crew row: the three presets, each with @@ -1509,7 +1509,7 @@ func (a *app) setupCrewRows(width int) []string { out = append(out, lead+name) // The sentence is wrapped rather than cut, on its own line, so the three // read as three comparable things at any width this screen has. - for _, line := range wrap(crewChoiceWord(preset), width-6) { + for _, line := range wrap(crewChoiceWord(config.CrewSourceAt(a.profileDir), preset), width-6) { out = append(out, strings.Repeat(" ", 6)+pal.muted(line)) } } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index e8a620bbfe..0dff93a5f2 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -780,7 +780,7 @@ func TestTheCrewChooserDescribesTheChoiceAndPromisesNoPrice(t *testing.T) { pressSetup(a, key("enter")) screen := setupScreen(a) for _, preset := range config.CrewPresets { - if word := crewChoiceWord(preset); !strings.Contains(screen, word) { + if word := crewChoiceWord(config.CrewSourceOpen, preset); !strings.Contains(screen, word) { t.Fatalf("the %s row must carry %q whole; got:\n%s", preset, word, screen) } } diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index 299c250906..1f66f63d78 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -250,7 +250,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { {pickerKeysModelTab, []string{"tab", "enter", "esc"}}, {pickerKeysFold, []string{"enter", "←", "esc"}}, {pickerKeysFoldTab, []string{"enter", "tab", "esc"}}, - {"↑↓ · ←→ source · enter apply · esc", []string{"↑↓", "←→", "enter", "esc"}}, + {"↑↓ · ←→ family · enter apply · esc", []string{"↑↓", "←→", "enter", "esc"}}, {"enter open · esc", []string{"enter", "esc"}}, {"v select · a block · y yank · esc", []string{"v", "a", "y", "esc"}}, {"esc again to rewind", []string{"esc"}}, diff --git a/internal/tui3/render.go b/internal/tui3/render.go index d7dc8d6a3d..12ad8346a5 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3970,7 +3970,7 @@ func (a *app) hintWord() string { } return keys case a.crewPick.open: - return "↑↓ · ←→ source · enter apply · esc" + return "↑↓ · ←→ family · enter apply · esc" case a.effPick.open: // The chord is named beside the keys because this list is the only place // on the surface that can teach it: the chip it opens from prints a mark From 6ffb9581350cb09ac06d04d755c1161f993c307f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:17:29 -0400 Subject: [PATCH 16/22] crew: the fixes from the Opus confirmation round A blocker and the majors the kimi rounds could not see. ONE DECISION WAS TWO FILE WRITES: the chooser wrote the family row and then the five tier rows, so a reader between them saw the family set to all over open ids, and it pinned source=open on a profile that never answered. config.ApplyCrewUnder lands family and preset in one writeProfileValues, and the family row rides along only when it changes. Setup showed open models and wrote frontier ones, because crewPickSeats and crewChoiceWord were open-hardwired while commitSetupCrew writes through the family. The KeyCrew hint still described the old open roster. The manual still denied any closed model a preset, which this change made false, and both surfaces still said max's mastermind adds a generation behavior when no id carries a level. Four seat examples named qwen3.8-27b as a frugal seat that never existed. The const keeps its own godoc, and the last PR-history clauses are gone. Co-Authored-By: codeaf --- .../unreleased/1073-crew-model-family.md | 2 +- internal/config/crew.go | 49 ++++++++++++++++--- internal/config/crew_test.go | 7 ++- internal/config/seats.go | 6 +-- internal/config/settings.go | 9 ++-- internal/manual/chat/models-and-cost.md | 5 +- internal/tui3/crew.go | 27 +++++----- 7 files changed, 73 insertions(+), 32 deletions(-) diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md index 937e90e632..69753809f3 100644 --- a/docs/changes/unreleased/1073-crew-model-family.md +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -7,7 +7,7 @@ invalidates: - "The three crew preset words (frugal, balanced, max) always resolved to open-weight models, and `crewModels` in `internal/config/crew.go` was the whole table of what they meant. There is now a second table `crewAllModels`, and `CrewModelsForSource(source, preset)` picks between them on the new `models.crew.source` setting (`open` default, `all` for closed and frontier). A profile that never answers the row still resolves the open table." - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` until a preset is re-applied, which is the point of a meaning toggle rather than a write." - - "The open table was said to be untouched. It is not: the shipped open table moved onto the roster chosen seat by seat (frugal's mastermind, balanced's careful and mastermind), and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." + - "The open table's frugal mastermind and its balanced careful and mastermind seats moved onto the roster chosen seat by seat, and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." - "A profile that applied the old `frugal` or the old `balanced` now matches no preset in the open table, because the roster moved: the crew word, the `/crew` highlight and the seat rung read `custom` until the preset is re-applied, which writes the new roster." - "`CrewConfigured` counted only the tier rows, so a person who chose a family and never pinned a tier could still be handed a preset by the first-run setup. It now counts `models.crew.source` too: a chosen family is an answered crew." --- diff --git a/internal/config/crew.go b/internal/config/crew.go index 209300bcf0..e09e3b71d0 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -99,9 +99,10 @@ const DefaultCrewSource = CrewSourceOpen // a dial. // // Every id is an open-weight model, picked off the catalog's own published -// scores against blended price on 2026-09-01 (settings.go's DefaultWorkerModel -// says how); the closed models that are cheaper on their own vendor's platform -// than through the router are deliberately not here. +// scores against blended price, most recently on 2026-09-16 (settings.go's +// DefaultWorkerModel says how). No closed model is here: the open family is the +// shelf that has to stand on price alone, and the `all` family is where a closed +// model goes. var crewModels = map[string]map[string]string{ CrewFrugal: { ModelTierReflex: "mistralai/mistral-nemo", @@ -141,8 +142,7 @@ var crewModels = map[string]map[string]string{ // frugal-to-balanced buys here is the careful seat (gemini-3.8-flash to // claude-opus-5) and a smarter mastermind, not a dearer worker. And max's // mastermind sits on the worker's own model on purpose: fable-5.1 is the -// catalog's ceiling, so there is no better planner to buy above it and the top -// seat adds generation behavior, not a bigger model. +// catalog's ceiling, so there is no better planner to buy above it. var crewAllModels = map[string]map[string]string{ CrewFrugal: { ModelTierReflex: "google/gemini-2.5-flash", @@ -322,6 +322,43 @@ func ApplyCrew(profileDir, preset string) error { return writeProfileValues(profileDir, values) } +// ApplyCrewUnder writes a FAMILY AND A PRESET AS ONE DECISION, IN ONE FILE +// WRITE. The family row and the five tier rows are one state, so writing them +// apart leaves a window in which a reader sees the family set to `all` while the +// rows still hold open ids — the half-written crew [ApplyCrew] forbids, read as +// `custom` about a state nobody chose. One call is also what makes the chooser's +// promise real: it writes the family first because the resolution reads it, and +// here there is no "first" for a reader to catch. +// +// The family row rides along ONLY WHEN IT CHANGES, so an enter that keeps the +// family does not pin a setting the person never answered: a profile that never +// touched /crew stays free to follow a later default. +func ApplyCrewUnder(profileDir, source, preset string) error { + source = strings.ToLower(strings.TrimSpace(source)) + known := "" + for _, word := range CrewSources { + if source == word { + known = word + } + } + if known == "" { + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + } + preset = strings.ToLower(strings.TrimSpace(preset)) + models, ok := CrewModelsForSource(known, preset) + if !ok { + return fmt.Errorf("pick one of: %s", strings.Join(CrewPresets, ", ")) + } + values := make(map[string]any, len(models)+1) + for tier, model := range models { + values[tierKeyFor(tier)] = model + } + if known != CrewSourceAt(profileDir) { + values[KeyCrewSource] = known + } + return writeProfileValues(profileDir, values) +} + // writeCrew is the row's writer: the same refusal wording every choice row uses, // and then the atomic write. func writeCrew(profileDir, raw string) error { @@ -380,7 +417,7 @@ func CrewClasses(profileDir string) string { // CrewClassModels is the three ids [CrewClasses] names, in that order and // without the role words in front of them: // -// glm-5.3, glm-5.3-flash, qwen3.8-27b +// kimi-k3, glm-5.3-flash, kimi-k3 // // It exists because a surface drawing the crew line has to be able to say which // runs of it are the ANSWER — the ids a person typed /crew to change — and which diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 29fe6ac63a..5ca0c74723 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -240,10 +240,9 @@ func TestTheCarefulSeatIsAlwaysASecondVendorInEveryFamily(t *testing.T) { } } -// THE ALL FAMILY IS LOCKED, not derived: the ids are the ones the decision -// settled on, and this is the page that says so. A wrong id here is a person's -// bill, and a silent rewrite of the locked table would be a decision made by a -// diff. +// THE ALL FAMILY IS LOCKED, not derived: this is the page that says which ids +// the three words mean. A wrong id here is a person's bill, and a silent +// rewrite of the locked table would be a decision made by a diff. func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { want := map[string]map[string]string{ CrewFrugal: { diff --git a/internal/config/seats.go b/internal/config/seats.go index 298dc4e356..16d091e343 100644 --- a/internal/config/seats.go +++ b/internal/config/seats.go @@ -403,7 +403,7 @@ func (s Seat) Report() string { return withNotice(s.Line(), s.Notice()) } // Describe is one seat in a receipt's voice: // -// work qwen/qwen3.8-27b (crew frugal) +// work deepseek/deepseek-v4-flash-0731 (crew frugal) // plan follows the work model (default) // // THE EMPTINESS LAW, as the crew row already keeps it: an unfilled plan seat is @@ -433,7 +433,7 @@ type Seats struct { // Sentence is both seats, unlabelled, for a door whose opening lines have a // label column of their own: // -// work qwen/qwen3.8-27b (crew frugal) · plan qwen/qwen3.8-27b (crew frugal) +// work deepseek/deepseek-v4-flash-0731 (crew frugal) · plan z-ai/glm-5.3 (crew frugal) // // ONE SHAPE AND NOT TWO. It would read a little better to collapse a run whose // seats came from the same crew into one clause, and it would mean a script @@ -445,7 +445,7 @@ func (s Seats) Sentence() string { // Line is the one line a headless run opens with: // -// models: work qwen/qwen3.8-27b (crew frugal) · plan qwen/qwen3.8-27b (crew frugal) +// models: work deepseek/deepseek-v4-flash-0731 (crew frugal) · plan z-ai/glm-5.3 (crew frugal) func (s Seats) Line() string { return modelsLabel + s.Sentence() } // Notice is the inheritance line for whichever seat was filled by an older row, diff --git a/internal/config/settings.go b/internal/config/settings.go index a4ab5fbc2e..aa5955acee 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2268,10 +2268,11 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyCrew, Category: CategoryModels, Kind: SettingChoice, Label: "crew", Choices: CrewPresets, - Hint: "the five models codeaf works with, chosen as one: `frugal` is glm-5.3-flash " + - "thinking over deepseek-v4-flash working, `balanced` has glm-5.3 think while " + - "glm-5.3-flash works and qwen checks, `max` puts glm-5.3 to work with kimi-k3 " + - "thinking and checking. Change one of the five rows below and this reads `custom`.", + Hint: "the five models codeaf works with, chosen as one word. `frugal` is deepseek " + + "working and glm-5.3 thinking, `balanced` is glm-5.3-flash working with kimi-k3 " + + "thinking and checking, `max` is glm-5.3 working with kimi-k3 thinking and " + + "checking. Which shelf those words draw from is the `model family` row below. " + + "Change one of the five rows below and this reads `custom`.", read: func() string { return CrewAt(dir) }, write: func(raw string) error { return writeCrew(dir, raw) }, }, diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0d2f2eb266..fc6b1d6875 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -341,7 +341,8 @@ They are all open-weight models, and none of them is the model you are talking t that followed your conversation would put the most expensive model in the build on the cheapest questions in it — a call made twice every turn on a frontier model is a bill nobody agreed to. Closed models that are cheaper on their own vendor's platform than through the -router are deliberately not in any preset; you can still pin one on any row. +router are not in any open-family preset; the `all` family is where they are, and you +can still pin one on any row. You can also connect that vendor yourself as a direct service; the [services page](services.md) explains its names, limits and missing Phase 1 cost record. @@ -368,7 +369,7 @@ Two of the columns spend differently here. The worker is the same model in `frug `balanced`, and only `max` buys the frontier coder, because on the catalog the coding ceiling is the one expensive step. What `frugal` to `balanced` buys instead is the careful seat and a smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the -catalog's ceiling, so the top seat adds generation behavior rather than a bigger model. +catalog's ceiling, so there is no bigger planner to buy above it. **Why the open ids.** They were picked off the catalog's own published scores (OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row) against diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 3999902e69..2e821b5dd0 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -66,6 +66,12 @@ func (a *app) applyCrew(preset string) { a.note("could not set the crew · " + err.Error()) return } + a.crewApplied() +} + +// crewApplied is the tail both crew writes share: the panel rebuild and the +// person-facing confirmation. +func (a *app) crewApplied() { // The panel may be holding rows read before this write, so it is rebuilt if // it is open. Everything else is live: the crew source the session resolves // through re-reads on its next call (cmd/codeaf's v3RolesSource). @@ -98,19 +104,16 @@ func (a *app) applyCrew(preset string) { } // applyCrewUnder is the chooser's enter: the family and the preset are ONE -// decision, and the family row is written FIRST. The resolution that turns a -// family and a preset into five ids reads the row, so a picker that applied -// the preset before writing the -// family would hand the resolver yesterday's row on the one enter that -// changed it. A failed family write refuses the preset with it: half of one -// decision applied is the family saying all while the five rows say open, and -// the chooser is where the two are promised as one. +// decision, and [config.ApplyCrewUnder] lands them as one file write. Landing +// them apart would leave a window in which a reader sees the family set to `all` +// while the five rows still hold open ids, which is the half-written crew the +// chooser is the one place to promise against. func (a *app) applyCrewUnder(source, preset string) { - if err := config.SetCrewSource(a.profileDir, source); err != nil { - a.note("could not set the crew's source · " + err.Error()) + if err := config.ApplyCrewUnder(a.profileDir, source, preset); err != nil { + a.note("could not set the crew · " + err.Error()) return } - a.applyCrew(preset) + a.crewApplied() } // crewUnchangedClause is the tail of every crew confirmation: the model the @@ -406,6 +409,7 @@ func (a *app) sayWorkSeat() { // the gate): the chooser reads it once at its open ([app.runCrew]) and enter // writes it once, and neither happens on the frame clock, which is why the // status segment does not name the family. + // crewSourceLead is the family row's label. It is `family` and not `source` // because that is the word the settings panel and the manual use for the same // row: one thing, one name. @@ -420,8 +424,7 @@ type crewPicker struct { current string // source is the family the presets below draw from: open models or all // models. IT IS STAGED AND NOT WRITTEN: ←→ moves the word, and enter - // writes it beside the preset as one decision ([app.applyCrewUnder]), - // which is the shape the person asked for, flip the family, watch the + // writes it beside the preset as one decision ([app.applyCrewUnder]): flip the family, watch the // preset rows take it, then commit. esc leaves the row exactly as it // was. source string From 72b0274365d29082d851b0536e127dff169743f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:19:55 -0400 Subject: [PATCH 17/22] crew: the comments the atomic write left behind Opus round three was almost entirely this: the blocker fix moved the chooser onto one write and left the old two-write story in a doc comment on SetCrewSource, the tui3 file header, the family section, and a test. All four now say what happens. One em dash in the new ApplyCrewUnder comment is gone. The fold is one function that both reads and writers share, so normalCrewSource's one-place claim is true rather than aspirational. The crew row's hint stops spelling the open models by hand and points at the family row instead. Co-Authored-By: codeaf --- internal/config/crew.go | 58 +++++++++++++++++-------------------- internal/config/settings.go | 9 +++--- internal/tui3/crew.go | 20 ++++++------- internal/tui3/crew_test.go | 8 ++--- 4 files changed, 45 insertions(+), 50 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index e09e3b71d0..9c07e3eee4 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -178,18 +178,27 @@ func crewTableFor(source string) map[string]map[string]string { return crewModels } -// normalCrewSource folds a source word to one of the two this build knows. It is -// the ONE place the fold is spelled: [CrewSourceAt], [crewTableFor] and -// [CrewLineFor] all call it, so a third family added to [CrewSources] is -// accepted everywhere at once, and a blank, unknown or retired word reads as the -// default family. -func normalCrewSource(source string) string { +// knownCrewSource folds a word and says whether it is one of the families this +// build knows. It is the ONE place the fold is spelled: [normalCrewSource], +// [ApplyCrewUnder] and [SetCrewSource] all go through it, so a family added to +// [CrewSources] is accepted everywhere at once. A reader folds a word it does +// not know to the default family; a writer refuses it. +func knownCrewSource(source string) (string, bool) { source = strings.ToLower(strings.TrimSpace(source)) for _, known := range CrewSources { if source == known { - return source + return known, true } } + return "", false +} + +// normalCrewSource folds a source word to one of the two this build knows, +// reading a word it does not know as the default family. +func normalCrewSource(source string) string { + if known, ok := knownCrewSource(source); ok { + return known + } return DefaultCrewSource } @@ -325,7 +334,7 @@ func ApplyCrew(profileDir, preset string) error { // ApplyCrewUnder writes a FAMILY AND A PRESET AS ONE DECISION, IN ONE FILE // WRITE. The family row and the five tier rows are one state, so writing them // apart leaves a window in which a reader sees the family set to `all` while the -// rows still hold open ids — the half-written crew [ApplyCrew] forbids, read as +// rows still hold open ids, which is the half-written crew [ApplyCrew] forbids, read as // `custom` about a state nobody chose. One call is also what makes the chooser's // promise real: it writes the family first because the resolution reads it, and // here there is no "first" for a reader to catch. @@ -334,14 +343,8 @@ func ApplyCrew(profileDir, preset string) error { // family does not pin a setting the person never answered: a profile that never // touched /crew stays free to follow a later default. func ApplyCrewUnder(profileDir, source, preset string) error { - source = strings.ToLower(strings.TrimSpace(source)) - known := "" - for _, word := range CrewSources { - if source == word { - known = word - } - } - if known == "" { + known, ok := knownCrewSource(source) + if !ok { return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) } preset = strings.ToLower(strings.TrimSpace(preset)) @@ -365,23 +368,16 @@ func writeCrew(profileDir, raw string) error { return ApplyCrew(profileDir, raw) } -// SetCrewSource writes the family row alone, in one file write, for the chooser -// that promises the family and the preset as ONE decision: it lands this row -// first and then applies the preset, so the resolution that turns the two into -// five ids reads the family this same enter chose and not yesterday's. The word -// is refused the way every choice row refuses one, so a typo cannot land a -// family nothing reads. +// SetCrewSource writes the family row ALONE, in one file write. The settings +// row is its caller; the chooser commits the family and the preset together +// through [ApplyCrewUnder]. The word is refused the way every choice row refuses +// one, so a typo cannot land a family nothing reads. func SetCrewSource(profileDir, source string) error { - source = strings.ToLower(strings.TrimSpace(source)) - // The accepted set is [CrewSources], spelled once: a family added to that slice - // is accepted here and by the settings row's choice widget at the same time, - // so the command and the panel can never disagree about what is a family. - for _, known := range CrewSources { - if source == known { - return writeProfileValue(profileDir, KeyCrewSource, source) - } + known, ok := knownCrewSource(source) + if !ok { + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) } - return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + return writeProfileValue(profileDir, KeyCrewSource, known) } // CrewSummary is the one line a crew change confirms itself with: diff --git a/internal/config/settings.go b/internal/config/settings.go index aa5955acee..c8468c5543 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2268,11 +2268,10 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyCrew, Category: CategoryModels, Kind: SettingChoice, Label: "crew", Choices: CrewPresets, - Hint: "the five models codeaf works with, chosen as one word. `frugal` is deepseek " + - "working and glm-5.3 thinking, `balanced` is glm-5.3-flash working with kimi-k3 " + - "thinking and checking, `max` is glm-5.3 working with kimi-k3 thinking and " + - "checking. Which shelf those words draw from is the `model family` row below. " + - "Change one of the five rows below and this reads `custom`.", + Hint: "the five models codeaf works with, chosen as one word. Each preset's own " + + "line is in the crew row at the top, and which shelf those words draw from " + + "is the `model family` row below. Change one of the five rows below and this " + + "reads `custom`.", read: func() string { return CrewAt(dir) }, write: func(raw string) error { return writeCrew(dir, raw) }, }, diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 2e821b5dd0..fa9eabe90c 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -24,11 +24,10 @@ import ( // class models underneath — and the seat above them is the one thing on the // list no key can move ([crewPicker.rows] says why it is there anyway). // -// EVERY CREW WRITE GOES THROUGH [config.ApplyCrew], the same function the -// panel's row writes through, and the one row that does not, the family, the -// pool the presets draw from, goes through [config.SetCrewSource] beside the -// five. A second writer anywhere else is how a command and a panel end up -// disagreeing about which crew is on. +// EVERY CREW WRITE GOES THROUGH config's own writers: a preset through +// [config.ApplyCrew], and the chooser's family-and-preset decision through +// [config.ApplyCrewUnder] in one file write. A second writer anywhere else is how +// a command and a panel end up disagreeing about which crew is on. // runCrew is /crew: the three presets with the current one marked, or one applied. func (a *app) runCrew(arg string) { @@ -402,17 +401,18 @@ func (a *app) sayWorkSeat() { // // models.crew.source says whether the three presets pick their five ids from // open models or from all models. THE CONFIG PACKAGE OWNS THE ROW: -// [config.CrewSourceAt] reads it, [config.SetCrewSource] writes it, and the two -// tables behind them answer it. This package draws the choice and commits it. +// [config.CrewSourceAt] reads it. [config.SetCrewSource] writes it alone for the +// settings row, and [config.ApplyCrewUnder] commits it with a preset as one write +// for this chooser. The two tables behind them answer it. // // THE ROW IS DOOR-ONLY, and the frame-disk law is why (framedisk_law_test.go is // the gate): the chooser reads it once at its open ([app.runCrew]) and enter // writes it once, and neither happens on the frame clock, which is why the // status segment does not name the family. -// crewSourceLead is the family row's label. It is `family` and not `source` -// because that is the word the settings panel and the manual use for the same -// row: one thing, one name. +// crewSourceLead is this row's one word in the chooser. The settings panel labels +// the same row `model family`; here it is abbreviated to fit one line, and both +// name the family the presets draw from. const crewSourceLead = "family" // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index 3217c8fc30..e88bb0e87d 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -390,9 +390,9 @@ func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { } } -// CHOOSING A FAMILY AND A PRESET WRITES BOTH ROWS, as one decision: the family -// first, because the resolution that turns family and preset into five ids -// reads the row, and the preset under it. +// CHOOSING A FAMILY AND A PRESET WRITES BOTH ROWS, as one decision and one file +// write: the family and the five ids land together through +// [config.ApplyCrewUnder]. func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { a, dir := sheetApp(t) // A NEIGHBOUR THE WRITE MUST NOT TOUCH, because the family row lands @@ -419,7 +419,7 @@ func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { if got := config.CrewAt(dir); got != config.CrewBalanced { t.Fatalf("enter applied %q, want balanced", got) } - // The neighbour survived both writes, the family's and the preset's. + // The neighbour survived the one write. data, err := os.ReadFile(config.BudgetConfigPath(dir)) if err != nil { t.Fatal(err) From 70d0e205bd46484379df9b62b70011df7aaff498 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:23:03 -0400 Subject: [PATCH 18/22] crew: round-four fixes from the Opus review The setup chooser read the profile three times per frame: crewChoiceWord took a family it never used, since the screen's own words cover all three presets, and setupCrewRows is drawn every frame. It takes the preset alone again, and its fallback says why it does not read. ApplyCrewUnder's comment claimed a profile stays free to follow a later default, when it writes all five tier rows. The Default*Model comment carried a second date for one pick. The crew row's hint pointed at itself. And the unwritten-seat test pins against the all table instead of typing the ids a second time. Co-Authored-By: codeaf --- internal/config/crew.go | 5 +++-- internal/config/crew_test.go | 11 +++++++---- internal/config/settings.go | 11 ++++++----- internal/tui3/onboarding.go | 10 +++++++--- internal/tui3/onboarding_test.go | 2 +- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index 9c07e3eee4..d5f5aa38ef 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -340,8 +340,9 @@ func ApplyCrew(profileDir, preset string) error { // here there is no "first" for a reader to catch. // // The family row rides along ONLY WHEN IT CHANGES, so an enter that keeps the -// family does not pin a setting the person never answered: a profile that never -// touched /crew stays free to follow a later default. +// family does not pin a setting the person never answered: the five tier rows are +// written and the family row stays unanswered, free to follow a later default +// family. func ApplyCrewUnder(profileDir, source, preset string) error { known, ok := knownCrewSource(source) if !ok { diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 5ca0c74723..053db01e62 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -514,11 +514,14 @@ func TestAnUnwrittenSeatFollowsTheFamily(t *testing.T) { if err := SetCrewSource(dir, CrewSourceAll); err != nil { t.Fatal(err) } - if got := TierModelAt(dir, ModelTierWorker); got != "openai/gpt-5.6-sol" { - t.Fatalf("the worker on an all profile with no row is %q, want the all default", got) + want, ok := CrewModelsForSource(CrewSourceAll, DefaultCrew) + if !ok { + t.Fatal("there is no default preset in the all family") } - if got := TierModelAt(dir, ModelTierMastermind); got != "anthropic/claude-fable-5.1" { - t.Fatalf("the mastermind is %q, want the all default", got) + for tier, model := range want { + if got := TierModelAt(dir, tier); got != model { + t.Fatalf("%s on an all profile with no row is %q, want the all default %q", tier, got, model) + } } if got := CrewAt(dir); got != CrewBalanced { t.Fatalf("an all profile with no rows reads the crew as %q, want %q", got, CrewBalanced) diff --git a/internal/config/settings.go b/internal/config/settings.go index c8468c5543..b8a3e61068 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1009,7 +1009,8 @@ var ModelTiers = []string{ModelTierReflex, ModelTierLow, ModelTierWorker, ModelT // // The ids are OPEN-WEIGHT MODELS, chosen off the catalog's own published // scores (OpenRouter republishes Artificial Analysis's coding and agentic -// indexes on every row) against the blended price, on 2026-09-01. The low row is +// indexes on every row) against the blended price, most recently on 2026-09-16 (crew.go's crewModels +// comment owns the date). The low row is // pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id // resolves to the April build, and the July build at the same price scores // thirteen coding points higher. @@ -2268,10 +2269,10 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyCrew, Category: CategoryModels, Kind: SettingChoice, Label: "crew", Choices: CrewPresets, - Hint: "the five models codeaf works with, chosen as one word. Each preset's own " + - "line is in the crew row at the top, and which shelf those words draw from " + - "is the `model family` row below. Change one of the five rows below and this " + - "reads `custom`.", + Hint: "the five models codeaf works with, chosen as one word. `frugal`, " + + "`balanced` and `max` each pick their own roster, listed by /crew and under " + + "the crew row, and which shelf they draw from is the `model family` row " + + "below. Change one of the five rows below and this reads `custom`.", read: func() string { return CrewAt(dir) }, write: func(raw string) error { return writeCrew(dir, raw) }, }, diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 29faa34800..c3c582d2f3 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -1487,11 +1487,15 @@ var crewChoiceWords = map[string]string{ // for a preset this table has not been taught — which is the one-source rule // applied to a gap: a new preset gets a real sentence rather than a blank row, // and the blank row is what would have shipped if this map were the only source. -func crewChoiceWord(source, preset string) string { +func crewChoiceWord(preset string) string { if word := crewChoiceWords[strings.ToLower(strings.TrimSpace(preset))]; word != "" { return word } - return config.CrewLineFor(source, preset) + // The fallback is for a preset this screen has not been taught, and it is the + // open family's line: reading the profile here would put a disk read on the + // frame clock (this runs inside setupCrewRows, which is drawn every frame), + // and the row above already names the family on screen. + return config.CrewLine(preset) } // setupCrewRows is the chooser under the crew row: the three presets, each with @@ -1509,7 +1513,7 @@ func (a *app) setupCrewRows(width int) []string { out = append(out, lead+name) // The sentence is wrapped rather than cut, on its own line, so the three // read as three comparable things at any width this screen has. - for _, line := range wrap(crewChoiceWord(config.CrewSourceAt(a.profileDir), preset), width-6) { + for _, line := range wrap(crewChoiceWord(preset), width-6) { out = append(out, strings.Repeat(" ", 6)+pal.muted(line)) } } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index 0dff93a5f2..e8a620bbfe 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -780,7 +780,7 @@ func TestTheCrewChooserDescribesTheChoiceAndPromisesNoPrice(t *testing.T) { pressSetup(a, key("enter")) screen := setupScreen(a) for _, preset := range config.CrewPresets { - if word := crewChoiceWord(config.CrewSourceOpen, preset); !strings.Contains(screen, word) { + if word := crewChoiceWord(preset); !strings.Contains(screen, word) { t.Fatalf("the %s row must carry %q whole; got:\n%s", preset, word, screen) } } From 894292f28eef98b2520c46226f115ca535e86a8c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:27:40 -0400 Subject: [PATCH 19/22] crew: round-five fixes from the Opus review A real one first: the chooser marked a preset in force by the SAVED word alone, so on an untouched profile one step of left still wore the selected ground and the accent while the row listed frontier models the profile does not run. The picker keeps the saved family beside the saved word, a row is in force only when both match, and the custom line obeys the same test. A test flips the family and asserts the balanced row stops wearing the mark; it fails without the fix. Then the setup screen reads the family once, arriving at the door, instead of a profile read inside the draw; one date for one pick; and the manual's new preset bullets use colons where they copied the UI's separator. Co-Authored-By: codeaf --- internal/config/crew.go | 2 +- internal/config/settings.go | 4 ++-- internal/manual/chat/models-and-cost.md | 8 ++++---- internal/tui3/crew.go | 12 +++++++++--- internal/tui3/crew_test.go | 7 +++++++ internal/tui3/firstrun.go | 1 + internal/tui3/onboarding.go | 11 +++++++---- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index d5f5aa38ef..5d674c2f2a 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -261,7 +261,7 @@ func CrewLineFor(source, preset string) string { // CrewModels is the five models one preset would set in the OPEN family, by // tier word: the family a profile nobody has touched reads, and the spelling -// every existing caller and page already holds. The family-aware spelling is +// the callers hold. The family-aware spelling is // [CrewModelsForSource]. It returns a copy, because a caller printing the // table must not be able to edit it. func CrewModels(preset string) (map[string]string, bool) { diff --git a/internal/config/settings.go b/internal/config/settings.go index b8a3e61068..3a9d2775df 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1009,8 +1009,8 @@ var ModelTiers = []string{ModelTierReflex, ModelTierLow, ModelTierWorker, ModelT // // The ids are OPEN-WEIGHT MODELS, chosen off the catalog's own published // scores (OpenRouter republishes Artificial Analysis's coding and agentic -// indexes on every row) against the blended price, most recently on 2026-09-16 (crew.go's crewModels -// comment owns the date). The low row is +// indexes on every row) against the blended price (crew.go's crewModels comment owns the date). The low +// row is // pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id // resolves to the April build, and the July build at the same price scores // thirteen coding points higher. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index fc6b1d6875..67f030ef6e 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -374,7 +374,7 @@ catalog's ceiling, so there is no bigger planner to buy above it. **Why the open ids.** They were picked off the catalog's own published scores (OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row) against blended price, open weights only, and the mastermind and balanced careful seats were -re-picked on 2026-09-16. The worker seat is the dial: `glm-5.3-flash` +picked on 2026-09-16. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, one point under `glm-5.3` at a twentieth of its price, and it can see images. The mastermind is the one seat whose price may be the model rather than the rung: it answers a @@ -401,9 +401,9 @@ say rather than the default. | careful work | `glm-5.3-flash` | `kimi-k3` | `kimi-k3` | | mastermind | `glm-5.3` | `kimi-k3` | `kimi-k3` | -- **frugal** — deepseek works, glm-5.3 thinks · pennies a day -- **balanced** — glm-flash works, kimi-k3 checks and thinks -- **max** — glm-5.3 works, kimi-k3 thinks and checks +- **frugal**: deepseek works, glm-5.3 thinks · pennies a day +- **balanced**: glm-flash works, kimi-k3 checks and thinks +- **max**: glm-5.3 works, kimi-k3 thinks and checks The worker column climbs the open-weight front one step per preset, because it is the seat that pays most of a task's bill. The reflex and small-work columns never vary — they are the diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index fa9eabe90c..03e2d696f0 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -428,6 +428,12 @@ type crewPicker struct { // preset rows take it, then commit. esc leaves the row exactly as it // was. source string + // persistedSource is the family the SAVED crew is in, read at the door beside + // current. The row in force is the one the profile holds IN THE FAMILY THE + // PROFILE HOLDS: once ←→ moves the staged family, no preset on screen is in + // force any more, and a row that kept the mark would name models the profile + // does not run. + persistedSource string // inherited is the line naming the seat this profile has no row for, or // empty when every row was written. IT IS READ AT THE DOOR AND NOT AT THE // DRAW: the rows are built every frame and the answer is three lines of a @@ -440,7 +446,7 @@ func (p *crewPicker) start(current, source, inherited string) { // source is already a word this build knows: every caller hands it // [config.CrewSourceAt]'s answer, which folds blank, unknown and retired words // back to open. The fold lives there and nowhere else. - *p = crewPicker{open: true, current: current, inherited: inherited, source: source} + *p = crewPicker{open: true, current: current, persistedSource: source, inherited: inherited, source: source} for i, preset := range config.CrewPresets { if preset == current { p.cursor = i @@ -587,7 +593,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // first, the cursor's `›` disappeared the moment the cursor landed on the // preset already in force — which is the one row a person is most likely // to arrow onto, and the one moment they most need to know enter is aimed. - oncursor, current := i == p.cursor, preset == p.current + oncursor, current := i == p.cursor, preset == p.current && p.source == p.persistedSource hovered := hover == len(out) || hover == len(out)+1 lead := " " switch { @@ -623,7 +629,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string } out = append(out, head, tail) } - if p.current == config.CrewCustom { + if p.current == config.CrewCustom && p.source == p.persistedSource { out = append(out, pal.dim(fit(crewCustomLine, width))) } // AND THE ROW NOBODY WROTE, said last among the readings and before the door diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index e88bb0e87d..7112fda523 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -349,6 +349,13 @@ func TestTheCrewChooserNamesTheTwoFamiliesAboveThePresets(t *testing.T) { t.Errorf("row %d under the all family is not %s's all line:\n%q", 3+2*i, preset, row) } } + // AND THE IN-FORCE MARK DOES NOT MOVE WITH THE FAMILY. The row that wears the + // selected ground is the preset the profile holds IN THE FAMILY IT HOLDS, so + // once ←→ lifts the family no row on screen is in force: one that kept the + // mark would name models the profile does not run. + if strings.Contains(rows[3+2*1], a.pal.accent("balanced — "+config.CrewLineFor(config.CrewSourceAll, config.CrewBalanced))) { + t.Errorf("the balanced row is still marked in force after ←:\n%q", rows[3+2*1]) + } if a.crewPick.cursor != 1 { t.Fatalf("walking the family moved the preset cursor to row %d", a.crewPick.cursor) } diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index c1b7575a87..0690112f19 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -112,6 +112,7 @@ type setupFlow struct { crewOpen bool crewAt int crewPick string + crewSource string reviewOpen bool example int seeded bool diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index c3c582d2f3..20f8df326c 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -696,6 +696,9 @@ func (a *app) startSetupControls() { s.closeChoosers() s.reviewOpen, s.detail = false, false s.crewPick = "" + // The family is read HERE, arriving on the screen, and not in the draw: this + // screen's rows are drawn every frame and a profile read belongs at a door. + s.crewSource = config.CrewSourceAt(a.profileDir) s.crewAt = a.setupCrewCursor() s.example = exampleForControl(controlLimit) // ARRIVING ON THE SCREEN IS THE FIRST OF THE TWO DELIBERATE ACTS, so the @@ -1332,7 +1335,7 @@ func (a *app) setupCrewDetail() string { // The three roles are spelled the way internal/config spells them for the // live reading below, because a person opening this twice must not be // shown one sentence in two shapes. - seats = crewPickSeats(config.CrewSourceAt(a.profileDir), a.setup.crewPick) + seats = crewPickSeats(a.setup.crewSource, a.setup.crewPick) } if seats == "" { seats = strings.TrimSpace(config.CrewClasses(a.profileDir)) @@ -1492,9 +1495,9 @@ func crewChoiceWord(preset string) string { return word } // The fallback is for a preset this screen has not been taught, and it is the - // open family's line: reading the profile here would put a disk read on the - // frame clock (this runs inside setupCrewRows, which is drawn every frame), - // and the row above already names the family on screen. + // open family's line: setupCrewRows runs inside the draw, so reading the profile + // here would put a profile read on the frame clock, and the seats this screen + // shows already take the family from a value read at the door. return config.CrewLine(preset) } From 64ab7eae0ccef651c7646212b3c864bfec694283 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:33:57 -0400 Subject: [PATCH 20/22] crew: round-six fixes from the Opus review The custom line is about the SAVED crew, so it shows whichever family is staged: gating it on the staged family dropped a row the height still counted. ApplyCrew is one resolve-and-write again, delegating to ApplyCrewUnder, so the two cannot drift. ApplyCrewUnder's doc no longer describes the two-write version it replaced; a test comment that said the crew row is the one place the source is written is corrected; builtinTierModel says what it is reachable for; a comment reflows; and the change entry qualifies when a family flip reads custom. Co-Authored-By: codeaf --- .../unreleased/1073-crew-model-family.md | 2 +- internal/config/crew.go | 18 +++++------------- internal/config/crew_test.go | 2 +- internal/config/settings.go | 8 ++++---- internal/tui3/crew.go | 4 +++- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md index 69753809f3..628fa6c6b2 100644 --- a/docs/changes/unreleased/1073-crew-model-family.md +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -6,7 +6,7 @@ surface: [chat, engine] invalidates: - "The three crew preset words (frugal, balanced, max) always resolved to open-weight models, and `crewModels` in `internal/config/crew.go` was the whole table of what they meant. There is now a second table `crewAllModels`, and `CrewModelsForSource(source, preset)` picks between them on the new `models.crew.source` setting (`open` default, `all` for closed and frontier). A profile that never answers the row still resolves the open table." - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." - - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` until a preset is re-applied, which is the point of a meaning toggle rather than a write." + - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` while the tier rows are written and match no preset in the new family; seats nobody pinned follow the family and keep the preset word." - "The open table's frugal mastermind and its balanced careful and mastermind seats moved onto the roster chosen seat by seat, and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." - "A profile that applied the old `frugal` or the old `balanced` now matches no preset in the open table, because the roster moved: the crew word, the `/crew` highlight and the seat rung read `custom` until the preset is re-applied, which writes the new roster." - "`CrewConfigured` counted only the tier rows, so a person who chose a family and never pinned a tier could still be handed a preset by the first-run setup. It now counts `models.crew.source` too: a chosen family is an answered crew." diff --git a/internal/config/crew.go b/internal/config/crew.go index 5d674c2f2a..ce3a600ddf 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -319,25 +319,17 @@ func sameCrew(live, preset map[string]string) bool { // the write cannot disagree about which table the word means: flip to `all`, // press the crew again, and the five ids that land are the all-family ones. func ApplyCrew(profileDir, preset string) error { - preset = strings.ToLower(strings.TrimSpace(preset)) - models, ok := CrewModelsForSource(CrewSourceAt(profileDir), preset) - if !ok { - return fmt.Errorf("pick one of: %s", strings.Join(CrewPresets, ", ")) - } - values := make(map[string]any, len(models)) - for tier, model := range models { - values[tierKeyFor(tier)] = model - } - return writeProfileValues(profileDir, values) + // The family is whatever the profile already holds, so passing it writes no + // family row and the write is exactly the five tiers. One resolve-and-write + // serves both callers rather than two that can drift apart. + return ApplyCrewUnder(profileDir, CrewSourceAt(profileDir), preset) } // ApplyCrewUnder writes a FAMILY AND A PRESET AS ONE DECISION, IN ONE FILE // WRITE. The family row and the five tier rows are one state, so writing them // apart leaves a window in which a reader sees the family set to `all` while the // rows still hold open ids, which is the half-written crew [ApplyCrew] forbids, read as -// `custom` about a state nobody chose. One call is also what makes the chooser's -// promise real: it writes the family first because the resolution reads it, and -// here there is no "first" for a reader to catch. +// `custom` about a state nobody chose. // // The family row rides along ONLY WHEN IT CHANGES, so an enter that keeps the // family does not pin a setting the person never answered: the five tier rows are diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 053db01e62..bb4545be0a 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -359,7 +359,7 @@ func TestFlippingToAllMakesTheWordsMeanTheAllFamily(t *testing.T) { if got := CrewSourceAt(dir); got != CrewSourceAll { t.Fatalf("the source row reads %q after all was set", got) } - // The row is the one place the source is written: a stored tier id is not a + // The crew row never writes the source: a stored tier id is not a family claim: a stored tier id is not a // family claim, and the family is not stored in the crew row either. if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { t.Fatalf("setting the crew to balanced: %v", err) diff --git a/internal/config/settings.go b/internal/config/settings.go index 3a9d2775df..4b8e27ef84 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1009,9 +1009,8 @@ var ModelTiers = []string{ModelTierReflex, ModelTierLow, ModelTierWorker, ModelT // // The ids are OPEN-WEIGHT MODELS, chosen off the catalog's own published // scores (OpenRouter republishes Artificial Analysis's coding and agentic -// indexes on every row) against the blended price (crew.go's crewModels comment owns the date). The low -// row is -// pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id +// indexes on every row) against the blended price (crew.go's crewModels comment +// owns the date). The low row is pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id // resolves to the April build, and the July build at the same price scores // thirteen coding points higher. const ( @@ -3801,7 +3800,8 @@ func defaultTierModel(profileDir, tier string) string { return builtinTierModel(tier) } -// builtinTierModel is the build's own five, for a tier no family table names. +// builtinTierModel is the build's own five. It is reachable only for a tier word +// no family table holds, because both tables answer every tier this build knows. func builtinTierModel(tier string) string { switch tier { case ModelTierReflex: diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 03e2d696f0..621a1e5b65 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -629,7 +629,9 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string } out = append(out, head, tail) } - if p.current == config.CrewCustom && p.source == p.persistedSource { + // The custom line is about the SAVED crew, so it shows whichever family is + // staged: hiding it would drop a row [crewPicker.height] still counts. + if p.current == config.CrewCustom { out = append(out, pal.dim(fit(crewCustomLine, width))) } // AND THE ROW NOBODY WROTE, said last among the readings and before the door From 070016419310aff5fe650975fc0164cde18c3125 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:38:08 -0400 Subject: [PATCH 21/22] crew: round-seven fixes from the Opus review Round seven found no blocker and no major. A comment that repeated its own sentence is fixed; the default-preservation test says what it holds (the family, with the roster move owned by the change entry) instead of claiming byte-identical output; the roster comment names which seats moved on which date; the manual says the same; and the family row's UI entry no longer claims a placement the map does not control. Co-Authored-By: codeaf --- internal/config/crew.go | 7 ++++--- internal/config/crew_test.go | 16 ++++++++-------- internal/manual/chat/models-and-cost.md | 4 ++-- internal/tui3/settings.go | 7 ++++--- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index ce3a600ddf..dadb640521 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -99,8 +99,9 @@ const DefaultCrewSource = CrewSourceOpen // a dial. // // Every id is an open-weight model, picked off the catalog's own published -// scores against blended price, most recently on 2026-09-16 (settings.go's -// DefaultWorkerModel says how). No closed model is here: the open family is the +// scores against blended price: the worker, reflex and small-work seats on +// 2026-09-01, and the frugal mastermind and the balanced careful and mastermind +// seats on 2026-09-16 (settings.go's DefaultWorkerModel says how). No closed model is here: the open family is the // shelf that has to stand on price alone, and the `all` family is where a closed // model goes. var crewModels = map[string]map[string]string{ @@ -132,7 +133,7 @@ var crewModels = map[string]map[string]string{ // [KeyCrewSource] draws from. The careful column is the same law here as // there: a DIFFERENT VENDOR from the worker in every preset, and the reflex // and low columns still never vary. The ids are locked the way the open ones -// were, off the catalog's own published scores against blended price, and +// are, off the catalog's own published scores against blended price, and // closed models live here and only here. // // TWO COLUMNS SPEND DIFFERENTLY HERE. The worker column is [crewModels]'s dial diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index bb4545be0a..e5d2c355de 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -308,12 +308,12 @@ func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { } } -// A SOURCE NOBODY SET IS THE OPEN FAMILY, BYTE FOR BYTE. The all table may -// exist, but a profile that never answered the row must resolve exactly what it -// resolved before the row was invented: the shipped defaults, the open presets -// under the same words, and a write that lands the open ids. This is the -// default-preservation law, and an unknown word is held to it too, silently, -// the way a retired choice reads everywhere else. +// A SOURCE NOBODY SET IS THE OPEN FAMILY. The all table may exist, but a profile +// that never answered the row resolves the open family: the shipped defaults, +// the open presets under the same words, and a write that lands the open ids. +// This is the default-preservation law for the FAMILY; the open roster moved in +// the same change, which the change entry owns. An unknown word is held to the +// same law, silently, the way a retired choice reads everywhere else. func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { dir := t.TempDir() if got := CrewSourceAt(dir); got != DefaultCrewSource { @@ -359,8 +359,8 @@ func TestFlippingToAllMakesTheWordsMeanTheAllFamily(t *testing.T) { if got := CrewSourceAt(dir); got != CrewSourceAll { t.Fatalf("the source row reads %q after all was set", got) } - // The crew row never writes the source: a stored tier id is not a family claim: a stored tier id is not a - // family claim, and the family is not stored in the crew row either. + // The crew row never writes the source: a stored tier id is not a family claim, + // and the family is not stored in the crew row either. if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { t.Fatalf("setting the crew to balanced: %v", err) } diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 67f030ef6e..e51f418e42 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -373,8 +373,8 @@ catalog's ceiling, so there is no bigger planner to buy above it. **Why the open ids.** They were picked off the catalog's own published scores (OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row) against -blended price, open weights only, and the mastermind and balanced careful seats were -picked on 2026-09-16. The worker seat is the dial: `glm-5.3-flash` +blended price, open weights only, and the `frugal` mastermind and the `balanced` careful and mastermind seats were +picked on 2026-09-16, the rest on 2026-09-01. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, one point under `glm-5.3` at a twentieth of its price, and it can see images. The mastermind is the one seat whose price may be the model rather than the rung: it answers a diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 47950e1605..017bfa832e 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -371,9 +371,10 @@ var settingUI = map[string]settingMeta{ about: "near-free · reads every turn — memory, titles, safety", }, - // The row directly under the crew word, because it changes what that word - // means rather than pinning a model of its own: the three presets answer - // from open weights or from the whole catalog, and this is the switch. + // The row is placed by build(), not by this map, and it sits directly under + // the crew word because it changes what that word means rather than pinning a + // model of its own: the three presets answer from open weights or from the + // whole catalog, and this is the switch. config.KeyCrewSource: { tab: tabProviders, label: "model family", widget: widgetCycle, about: "which models the crew word draws from: open weights, or the whole " + From c24af02488185df4a9133bc1060bf356ce311485 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 16 Sep 2026 19:44:02 -0400 Subject: [PATCH 22/22] crew: round-eight fixes from the Opus review The family is read ONCE per call now, not once per unwritten tier: CrewAt reads it and passes it to tierSeatUnder, so the five seats and the preset table cannot be resolved under different families and one CrewAt costs one read, not eleven. ApplyCrew passes an empty family meaning keep the profile's, so ApplyCrewUnder reads the profile once and there is no check-then-write window. The family section no longer claims the row is door-only, which stopped being true when the crew word and the settings sheet began reading it. The roster comment and the careful seat's comment say vendor where family now means open/all. The manual phrase parses. A test comment drops its PR history. Co-Authored-By: codeaf --- internal/config/crew.go | 30 ++++++++++++++++++++---------- internal/config/crew_test.go | 5 ++--- internal/config/seats.go | 10 +++++++++- internal/config/settings.go | 8 ++++---- internal/manual/chat/commands.md | 3 +-- internal/tui3/crew.go | 8 ++++---- 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/internal/config/crew.go b/internal/config/crew.go index dadb640521..0e9ed04c9b 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -99,9 +99,10 @@ const DefaultCrewSource = CrewSourceOpen // a dial. // // Every id is an open-weight model, picked off the catalog's own published -// scores against blended price: the worker, reflex and small-work seats on -// 2026-09-01, and the frugal mastermind and the balanced careful and mastermind -// seats on 2026-09-16 (settings.go's DefaultWorkerModel says how). No closed model is here: the open family is the +// scores against blended price: 2026-09-01 for the roster as a whole, with the +// frugal mastermind and the balanced careful and mastermind re-picked on +// 2026-09-16 (the manual's open table lists every seat; settings.go's +// DefaultWorkerModel says how). No closed model is here: the open family is the // shelf that has to stand on price alone, and the `all` family is where a closed // model goes. var crewModels = map[string]map[string]string{ @@ -284,11 +285,12 @@ func CrewModels(preset string) (map[string]string, bool) { // family wrote matches nothing, which reads as custom and is true, because // five open ids are not any all-family preset either. func CrewAt(profileDir string) string { + family := CrewSourceAt(profileDir) live := make(map[string]string, len(ModelTiers)) for _, tier := range ModelTiers { - live[tier] = TierModelAt(profileDir, tier) + live[tier] = tierSeatUnder(profileDir, family, tier).Model } - table := crewTableFor(CrewSourceAt(profileDir)) + table := crewTableFor(family) for _, preset := range CrewPresets { if sameCrew(live, table[preset]) { return preset @@ -320,10 +322,11 @@ func sameCrew(live, preset map[string]string) bool { // the write cannot disagree about which table the word means: flip to `all`, // press the crew again, and the five ids that land are the all-family ones. func ApplyCrew(profileDir, preset string) error { - // The family is whatever the profile already holds, so passing it writes no - // family row and the write is exactly the five tiers. One resolve-and-write - // serves both callers rather than two that can drift apart. - return ApplyCrewUnder(profileDir, CrewSourceAt(profileDir), preset) + // An empty family means KEEP THE ONE THE PROFILE HOLDS, so this path reads the + // profile once inside ApplyCrewUnder rather than once here and again there, and + // the write is exactly the five tiers. One resolve-and-write serves both callers + // rather than two that can drift apart. + return ApplyCrewUnder(profileDir, "", preset) } // ApplyCrewUnder writes a FAMILY AND A PRESET AS ONE DECISION, IN ONE FILE @@ -337,6 +340,13 @@ func ApplyCrew(profileDir, preset string) error { // written and the family row stays unanswered, free to follow a later default // family. func ApplyCrewUnder(profileDir, source, preset string) error { + // An empty source keeps the family the profile holds, which is how [ApplyCrew] + // asks for the five tiers alone. The profile is read ONCE here, so there is no + // window between a check and the write in which the family could move. + persisted := CrewSourceAt(profileDir) + if strings.TrimSpace(source) == "" { + source = persisted + } known, ok := knownCrewSource(source) if !ok { return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) @@ -350,7 +360,7 @@ func ApplyCrewUnder(profileDir, source, preset string) error { for tier, model := range models { values[tierKeyFor(tier)] = model } - if known != CrewSourceAt(profileDir) { + if known != persisted { values[KeyCrewSource] = known } return writeProfileValues(profileDir, values) diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index e5d2c355de..017a406845 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -311,9 +311,8 @@ func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { // A SOURCE NOBODY SET IS THE OPEN FAMILY. The all table may exist, but a profile // that never answered the row resolves the open family: the shipped defaults, // the open presets under the same words, and a write that lands the open ids. -// This is the default-preservation law for the FAMILY; the open roster moved in -// the same change, which the change entry owns. An unknown word is held to the -// same law, silently, the way a retired choice reads everywhere else. +// This is the default-preservation law for the FAMILY. An unknown word is held +// to the same law, silently, the way a retired choice reads everywhere else. func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { dir := t.TempDir() if got := CrewSourceAt(dir); got != DefaultCrewSource { diff --git a/internal/config/seats.go b/internal/config/seats.go index 16d091e343..71b0e5a90a 100644 --- a/internal/config/seats.go +++ b/internal/config/seats.go @@ -229,11 +229,19 @@ func crewRow(profileDir, tier string) (model, from string, source SeatSource, cl // from the ladder — five extra file reads per row, and a cycle. A surface that // wants both facts asks for both. func TierSeatAt(profileDir, tier string) Seat { + return tierSeatUnder(profileDir, CrewSourceAt(profileDir), tier) +} + +// tierSeatUnder is [TierSeatAt] with the family already read, so a caller that +// walks every tier ([CrewAt]) reads the profile ONCE for the family instead of +// once per unwritten seat, and the five seats cannot be resolved under one +// family while the preset table is resolved under another. +func tierSeatUnder(profileDir, family, tier string) Seat { model, from, source, cleared := crewRow(profileDir, tier) if source == "" { source = SeatDefault if !cleared { - model = defaultTierModel(profileDir, tier) + model = defaultTierModel(family, tier) } } return Seat{Role: tierSeatRole(tier), Model: model, Source: source, From: from} diff --git a/internal/config/settings.go b/internal/config/settings.go index 4b8e27ef84..6ad56c26ff 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1023,8 +1023,8 @@ const ( // it without a vision detour. DefaultWorkerModel = "z-ai/glm-5.3-flash" // The careful tier is ALWAYS A DIFFERENT VENDOR FROM THE WORKER, in every - // preset, and always a model that sees images: a check from a second family - // catches what the first family's blind spots let through, and the vision + // preset, and always a model that sees images: a check from a second vendor + // catches what the first vendor's blind spots let through, and the vision // role rides this row. DefaultHighModel = "moonshotai/kimi-k3" // The mastermind names a capable planning model. Its generation behavior is @@ -3791,8 +3791,8 @@ func tierKeyFor(tier string) string { // answer is the constant, which is what [DefaultWorkerModel] and its kin name, // because the open table's default row and the builtin five name the same set by // construction (crew_test.go pins it). -func defaultTierModel(profileDir, tier string) string { - if table, ok := CrewModelsForSource(CrewSourceAt(profileDir), DefaultCrew); ok { +func defaultTierModel(family, tier string) string { + if table, ok := CrewModelsForSource(family, DefaultCrew); ok { if model, held := table[tier]; held { return model } diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c8a95b05bd..1683a21d82 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1434,8 +1434,7 @@ each of the five can be pinned on its own in /settings → Providers The first line says what the presets change and what they do not. The second is **seat one** — `you talk to · `, spelled as the legend above the box spells it — with no marker and -no highlight, because nothing in this chooser can move it. The third is the **family**: `family` -follows **←→**, and it says which pool the three presets below it draw from, open weights or the +no highlight, because nothing in this chooser can move it. The third is the **family**: **←→** moves `family`, which says which pool the three presets below it draw from, open weights or the whole catalog. Then the three presets: the one in force wears a highlighted ground, `›` is where **enter** is aimed and it opens on yours, ↑ / ctrl+p and ↓ / ctrl+n move, and **esc** closes without changing anything. The last diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 621a1e5b65..aefbeca475 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -405,10 +405,10 @@ func (a *app) sayWorkSeat() { // settings row, and [config.ApplyCrewUnder] commits it with a preset as one write // for this chooser. The two tables behind them answer it. // -// THE ROW IS DOOR-ONLY, and the frame-disk law is why (framedisk_law_test.go is -// the gate): the chooser reads it once at its open ([app.runCrew]) and enter -// writes it once, and neither happens on the frame clock, which is why the -// status segment does not name the family. +// THE SEGMENT DOES NOT NAME THE FAMILY. The chooser reads the row at its open +// ([app.runCrew]) and enter writes it through [config.ApplyCrewUnder], and the +// crew word the status segment shows resolves through the same row: it is read +// there, and it is not printed above the presets. // crewSourceLead is this row's one word in the chooser. The settings panel labels // the same row `model family`; here it is abbreviated to fit one line, and both